feat: improve session sharing and Kimi transcript support

- normalize Kimi thinking parts and omit empty placeholders
- add standalone session-sharing and UI demo artifacts
- add regression coverage and refresh the Kimi index marker
- bump the Electron app and CLI packages to v0.2.1
- ignore generated app build assets
This commit is contained in:
tommy0103
2026-07-21 19:29:14 +08:00
parent f79f1b3e3b
commit 28fa99ac94
11 changed files with 92555 additions and 21 deletions
+2
View File
@@ -10,3 +10,5 @@ dist/
app/out/ app/out/
HANDOFF.md HANDOFF.md
.obelisk/ .obelisk/
app/build/
app/scripts/
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "obelisk", "name": "obelisk",
"version": "0.2.0", "version": "0.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "obelisk", "name": "obelisk",
"version": "0.2.0", "version": "0.2.1",
"dependencies": { "dependencies": {
"better-sqlite3": "^11.0.0", "better-sqlite3": "^11.0.0",
"chokidar": "^4.0.3" "chokidar": "^4.0.3"
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obelisk", "name": "obelisk",
"version": "0.2.0", "version": "0.2.1",
"description": "Memory management for Obelisk — let Claude Code search its own memory", "description": "Memory management for Obelisk — let Claude Code search its own memory",
"homepage": "https://github.com/tommy0103/obelisk", "homepage": "https://github.com/tommy0103/obelisk",
"author": { "author": {
@@ -17,6 +17,7 @@
"dist:mac": "electron-vite build && electron-builder --mac", "dist:mac": "electron-vite build && electron-builder --mac",
"dist:win": "electron-vite build && electron-builder --win", "dist:win": "electron-vite build && electron-builder --win",
"dist:linux": "electron-vite build && electron-builder --linux", "dist:linux": "electron-vite build && electron-builder --linux",
"generate:session-share": "node scripts/generate-session-share.mjs",
"verify:icons": "node scripts/verify-icons.mjs", "verify:icons": "node scripts/verify-icons.mjs",
"test:electron": "electron-vite build && electron --no-sandbox tests/electron-concurrency.mjs", "test:electron": "electron-vite build && electron --no-sandbox tests/electron-concurrency.mjs",
"test:electron:timeline": "electron-vite build && electron --no-sandbox tests/electron-session-virtualization.mjs", "test:electron:timeline": "electron-vite build && electron --no-sandbox tests/electron-session-virtualization.mjs",
@@ -190,7 +190,7 @@ function renderOutput(output, isError) {
return `<div class="result-chip ${isError ? 'error' : ''}">${escapeHtml(output)}</div>`; return `<div class="result-chip ${isError ? 'error' : ''}">${escapeHtml(output)}</div>`;
} }
function renderPrettyTool(toolCall) { export function renderPrettyTool(toolCall) {
const args = parseToolInput(toolCall); const args = parseToolInput(toolCall);
const result = toolCall.result || {}; const result = toolCall.result || {};
const isError = Boolean(result.is_error); const isError = Boolean(result.is_error);
+1 -1
View File
@@ -1289,7 +1289,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@obelisk-apps/cli", "name": "@obelisk-apps/cli",
"version": "0.2.0", "version": "0.2.1",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"bin": { "bin": {
"obelisk": "dist/cli/src/obelisk.js" "obelisk": "dist/cli/src/obelisk.js"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@obelisk-apps/cli", "name": "@obelisk-apps/cli",
"version": "0.2.0", "version": "0.2.1",
"description": "Local Obelisk runtime for coding agents.", "description": "Local Obelisk runtime for coding agents.",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"type": "module", "type": "module",
+25 -12
View File
@@ -56,7 +56,7 @@ interface ProjectedSession {
} }
const SOURCE = 'kimi'; const SOURCE = 'kimi';
export const KIMI_CANONICAL_TRANSCRIPT_MARKER = '__kimi_canonical_transcript_v2__'; export const KIMI_CANONICAL_TRANSCRIPT_MARKER = '__kimi_canonical_transcript_v3__';
function defaultKimiRoot(): string { function defaultKimiRoot(): string {
return process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code'); return process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code');
@@ -141,12 +141,26 @@ function contentParts(content: unknown): JsonRecord[] {
: []; : [];
} }
function partText(part: JsonRecord): string | null { function rawPartText(part: JsonRecord): string | null {
if (part.type === 'text' && typeof part.text === 'string') return trunc(part.text); if (part.type === 'text' && typeof part.text === 'string') return part.text;
if (part.type === 'thinking' && typeof part.thinking === 'string') return trunc(part.thinking); if (part.type === 'think' && typeof part.think === 'string') return part.think;
if (part.type === 'thinking' && typeof part.thinking === 'string') return part.thinking;
return null; return null;
} }
function partText(part: JsonRecord): string | null {
const text = rawPartText(part);
return text === null ? null : trunc(text);
}
function partContentType(part: JsonRecord): string {
return part.type === 'think' || part.type === 'thinking'
? 'thinking'
: typeof part.type === 'string'
? part.type
: 'unknown';
}
function messageText(content: unknown): string | null { function messageText(content: unknown): string | null {
const parts = contentParts(content); const parts = contentParts(content);
const text = parts.map(partText).filter((value): value is string => value !== null); const text = parts.map(partText).filter((value): value is string => value !== null);
@@ -154,7 +168,7 @@ function messageText(content: unknown): string | null {
} }
function messageContentType(content: unknown): string { function messageContentType(content: unknown): string {
const types = new Set(contentParts(content).map((part) => String(part.type ?? 'unknown'))); const types = new Set(contentParts(content).map(partContentType));
return types.size === 1 ? [...types][0]! : 'unknown'; return types.size === 1 ? [...types][0]! : 'unknown';
} }
@@ -437,6 +451,8 @@ function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: Jso
if (event.type === 'content.part' && typeof event.stepUuid === 'string') { if (event.type === 'content.part' && typeof event.stepUuid === 'string') {
const part = event.part as JsonRecord | undefined; const part = event.part as JsonRecord | undefined;
if (part === undefined) continue; if (part === undefined) continue;
const text = partText(part);
if (text === null || text.trim().length === 0) continue;
pushMessage({ pushMessage({
kind: 'message', kind: 'message',
uuid: namespacedEventId(sessionId, wire.agentId, event.uuid, line), uuid: namespacedEventId(sessionId, wire.agentId, event.uuid, line),
@@ -445,8 +461,8 @@ function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: Jso
parent_uuid: previousUuid, parent_uuid: previousUuid,
timestamp, timestamp,
role: 'assistant', role: 'assistant',
text: partText(part), text,
content_type: typeof part.type === 'string' ? part.type : 'unknown', content_type: partContentType(part),
is_meta: 0, is_meta: 0,
visibility: 'visible', visibility: 'visible',
model, model,
@@ -604,17 +620,14 @@ function rawFromWire(path: string, messageUuid: string): RawRecord | null {
if (slashCommand !== null) projectedText = slashCommand; if (slashCommand !== null) projectedText = slashCommand;
else { else {
const parts = contentParts(message.content).map((part) => { const parts = contentParts(message.content).map((part) => {
if (part.type === 'text' && typeof part.text === 'string') return part.text; return rawPartText(part);
if (part.type === 'thinking' && typeof part.thinking === 'string') return part.thinking;
return null;
}).filter((part): part is string => part !== null); }).filter((part): part is string => part !== null);
projectedText = parts.length > 0 ? parts.join('\n') : null; projectedText = parts.length > 0 ? parts.join('\n') : null;
} }
} }
} else if (record.type === 'context.append_loop_event') { } else if (record.type === 'context.append_loop_event') {
const part = (record.event as JsonRecord | undefined)?.part as JsonRecord | undefined; const part = (record.event as JsonRecord | undefined)?.part as JsonRecord | undefined;
if (part?.type === 'text' && typeof part.text === 'string') projectedText = part.text; if (part !== undefined) projectedText = rawPartText(part);
if (part?.type === 'thinking' && typeof part.thinking === 'string') projectedText = part.thinking;
} }
} catch { /* malformed torn source line */ } } catch { /* malformed torn source line */ }
return { return {
+2 -2
View File
@@ -133,7 +133,7 @@ test('Kimi undo and clear replace the indexed session instead of leaving stale r
db.close(); db.close();
}); });
test('Kimi prompt semantics marker replays unchanged sessions once', () => { test('Kimi canonical transcript marker replays unchanged sessions once', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-kimi-prompt-marker-')); const home = mkdtempSync(join(tmpdir(), 'obelisk-kimi-prompt-marker-'));
const claudeDir = join(home, '.claude'); const claudeDir = join(home, '.claude');
const codexDir = join(home, '.codex'); const codexDir = join(home, '.codex');
@@ -151,7 +151,7 @@ test('Kimi prompt semantics marker replays unchanged sessions once', () => {
buildIndex(options); buildIndex(options);
let db = new TestDatabase(dbPath); let db = new TestDatabase(dbPath);
const marker = createKimiProvider({ rootDir: kimiDir }).indexVersionMarker; const marker = createKimiProvider({ rootDir: kimiDir }).indexVersionMarker;
assert.equal(typeof marker, 'string'); assert.equal(marker, '__kimi_canonical_transcript_v3__');
db.prepare("UPDATE messages SET text='stale expanded instructions', is_meta=1 WHERE source='kimi'").run(); db.prepare("UPDATE messages SET text='stale expanded instructions', is_meta=1 WHERE source='kimi'").run();
db.prepare('DELETE FROM index_state WHERE jsonl_path=?').run(marker); db.prepare('DELETE FROM index_state WHERE jsonl_path=?').run(marker);
db.close(); db.close();
+46
View File
@@ -145,6 +145,52 @@ test('kimi provider ignores a torn final wire line until it is completed', () =>
assert.equal(ret, unit.meta.currentCursor); assert.equal(ret, unit.meta.currentCursor);
}); });
test('kimi provider normalizes think parts and drops empty thinking placeholders', () => {
const root = mkdtempSync(join(tmpdir(), 'obelisk-kimi-think-'));
const sessionDir = join(root, 'sessions', 'workspace-1', 'session-think-1');
const mainDir = join(sessionDir, 'agents', 'main');
const wirePath = join(mainDir, 'wire.jsonl');
mkdirSync(mainDir, { recursive: true });
writeFileSync(join(sessionDir, 'state.json'), JSON.stringify({ workDir: '/tmp/think' }));
const records = [
{ type: 'metadata', protocol_version: '1.5', created_at: 1 },
{ type: 'context.append_loop_event', time: 2, event: { type: 'step.begin', uuid: 'step-1' } },
{ type: 'context.append_loop_event', time: 3, event: {
type: 'content.part', uuid: 'think-1', stepUuid: 'step-1',
part: { type: 'think', think: 'private reasoning' },
} },
{ type: 'context.append_loop_event', time: 4, event: {
type: 'content.part', uuid: 'think-empty', stepUuid: 'step-1',
part: { type: 'think', think: '' },
} },
{ type: 'context.append_loop_event', time: 5, event: {
type: 'content.part', uuid: 'answer-1', stepUuid: 'step-1',
part: { type: 'text', text: 'visible answer' },
} },
];
writeFileSync(wirePath, records.map(record => JSON.stringify(record)).join('\n') + '\n');
const provider = createKimiProvider({ rootDir: root });
const unit = provider.discover({ lastCursor: () => null })[0];
const { values } = drain(provider.parse(unit, null));
const messages = values.filter(record => record.kind === 'message');
assert.deepEqual(messages.map(record => ({
text: record.text,
content_type: record.content_type,
})), [
{ text: 'private reasoning', content_type: 'thinking' },
{ text: 'visible answer', content_type: 'text' },
]);
assert.equal(values.find(record => record.kind === 'session').message_count, 2);
assert.equal(provider.raw({
source: 'kimi',
messageUuid: messages[0].uuid,
session: { jsonl_path: wirePath },
agentId: null,
}).messageText, 'private reasoning');
});
test('kimi provider replays clear and undo markers with Kimi transcript semantics', () => { test('kimi provider replays clear and undo markers with Kimi transcript semantics', () => {
const root = mkdtempSync(join(tmpdir(), 'obelisk-kimi-undo-')); const root = mkdtempSync(join(tmpdir(), 'obelisk-kimi-undo-'));
const sessionDir = join(root, 'sessions', 'workspace-1', 'session-undo-1'); const sessionDir = join(root, 'sessions', 'workspace-1', 'session-undo-1');