refactor: add canonical transcript assembly seam
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// Pure: discovers Claude transcript files and parses one into a record stream.
|
||||
// It never touches the Obelisk database. The per-line logic mirrors the original
|
||||
// indexJsonl exactly, but yields IndexRecords instead of writing rows; the shared
|
||||
// indexJsonl exactly, but yields canonical TranscriptRecords instead of writing rows; the shared
|
||||
// persist layer consumes them. Session aggregates here reflect only THIS chunk
|
||||
// (started_at/ended_at/message_count); persist merges them with any existing row.
|
||||
|
||||
@@ -13,14 +13,14 @@ const require = createRequire(import.meta.url);
|
||||
const fs = require('node:fs');
|
||||
|
||||
import {
|
||||
extractText, extractContentType, extractMessageIsMeta,
|
||||
filePath, trunc, truncJson, readLines, discoverJsonlFiles,
|
||||
extractText, extractContentType, extractMessageIsMeta, isSkillInstructions,
|
||||
filePath, trunc, truncJson, readLines, discoverJsonlFiles, isDir,
|
||||
} from '../parsing.ts';
|
||||
|
||||
import type {
|
||||
Cursor,
|
||||
DiscoverContext,
|
||||
IndexRecord,
|
||||
TranscriptRecord,
|
||||
IndexUnit,
|
||||
ProviderAdapter,
|
||||
RawLookup,
|
||||
@@ -37,7 +37,12 @@ function cursorToSkip(cursor: Cursor): number {
|
||||
}
|
||||
|
||||
export const name = 'claude';
|
||||
export const CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER = '__claude_input_tokens_include_cache_v1__';
|
||||
export const CLAUDE_CANONICAL_TRANSCRIPT_MARKER = '__claude_canonical_transcript_v2__';
|
||||
|
||||
interface ClaudeWorkflowUnitMeta {
|
||||
readonly kind: 'workflow';
|
||||
readonly mainTranscriptPath: string;
|
||||
}
|
||||
|
||||
function totalInputTokens(usage: Record<string, unknown>): number | null {
|
||||
const fields = [
|
||||
@@ -58,9 +63,25 @@ function totalInputTokens(usage: Record<string, unknown>): number | null {
|
||||
|
||||
function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
|
||||
const projectsDir = join(rootDir, 'projects');
|
||||
const historyPath = normalize(join(rootDir, 'history.jsonl'));
|
||||
const historyTitles = new Map<string, string>();
|
||||
if (fs.existsSync(historyPath)) {
|
||||
readLines(historyPath, (line: string) => {
|
||||
try {
|
||||
const item = JSON.parse(line);
|
||||
if (item?.sessionId && item?.title) historyTitles.set(item.sessionId, item.title);
|
||||
} catch { /* malformed history entry */ }
|
||||
});
|
||||
}
|
||||
const changedTranscriptPaths = new Set<string>();
|
||||
const changedWorkflowPaths = new Set<string>();
|
||||
const forcedPaths = new Set<string>();
|
||||
let historyChanged = false;
|
||||
for (const changedPath of ctx.changedPaths ?? []) {
|
||||
const rootRelative = isAbsolute(changedPath)
|
||||
? normalize(changedPath)
|
||||
: normalize(join(rootDir, changedPath));
|
||||
if (rootRelative === historyPath) historyChanged = true;
|
||||
const absolute = isAbsolute(changedPath)
|
||||
? normalize(changedPath)
|
||||
: normalize(join(projectsDir, changedPath));
|
||||
@@ -72,13 +93,16 @@ function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
|
||||
forcedPaths.add(transcript);
|
||||
} else if (absolute.toLowerCase().endsWith('.jsonl')) {
|
||||
changedTranscriptPaths.add(absolute);
|
||||
} else if (absolute.toLowerCase().endsWith('.json')) {
|
||||
changedWorkflowPaths.add(absolute);
|
||||
}
|
||||
}
|
||||
return discoverJsonlFiles(projectsDir).filter((file) => {
|
||||
const transcriptUnits = discoverJsonlFiles(projectsDir).filter((file) => {
|
||||
const normalizedPath = normalize(file.path);
|
||||
if (ctx.changedPaths !== undefined && !changedTranscriptPaths.has(normalizedPath)) return false;
|
||||
if (ctx.changedPaths !== undefined && !historyChanged && !changedTranscriptPaths.has(normalizedPath)) return false;
|
||||
const cursor = ctx.lastCursor(file.path);
|
||||
return forcedPaths.has(normalizedPath)
|
||||
return historyChanged
|
||||
|| forcedPaths.has(normalizedPath)
|
||||
|| cursor === null
|
||||
|| Number(cursor.split(':')[0]) < fs.statSync(file.path).mtimeMs;
|
||||
}).map((f: any) => ({
|
||||
@@ -87,25 +111,155 @@ function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
|
||||
project: f.project,
|
||||
isSubagent: f.isSubagent,
|
||||
agentId: f.agentId,
|
||||
meta: f.workflowRunId ? { workflowRunId: f.workflowRunId } : undefined,
|
||||
meta: {
|
||||
...(f.workflowRunId ? { workflowRunId: f.workflowRunId } : {}),
|
||||
...(historyTitles.has(f.sessionId) ? { historyTitle: historyTitles.get(f.sessionId) } : {}),
|
||||
},
|
||||
}));
|
||||
|
||||
const workflowUnits: IndexUnit[] = [];
|
||||
if (!fs.existsSync(projectsDir)) return transcriptUnits;
|
||||
let projects: string[];
|
||||
try { projects = fs.readdirSync(projectsDir); } catch { return transcriptUnits; }
|
||||
for (const project of projects) {
|
||||
const projectPath = join(projectsDir, project);
|
||||
if (!isDir(projectPath)) continue;
|
||||
let sessionIds: string[];
|
||||
try { sessionIds = fs.readdirSync(projectPath); } catch { continue; }
|
||||
for (const sessionId of sessionIds) {
|
||||
const workflowDir = join(projectPath, sessionId, 'workflows');
|
||||
if (!isDir(workflowDir)) continue;
|
||||
const mainTranscriptPath = join(projectPath, `${sessionId}.jsonl`);
|
||||
let files: string[];
|
||||
try { files = fs.readdirSync(workflowDir); } catch { continue; }
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
const workflowPath = join(workflowDir, file);
|
||||
const normalizedPath = normalize(workflowPath);
|
||||
const relationshipChanged = changedTranscriptPaths.has(normalize(mainTranscriptPath));
|
||||
if (
|
||||
ctx.changedPaths !== undefined
|
||||
&& !changedWorkflowPaths.has(normalizedPath)
|
||||
&& !relationshipChanged
|
||||
) continue;
|
||||
const mtime = fs.statSync(workflowPath).mtimeMs;
|
||||
const cursor = ctx.lastCursor(workflowPath);
|
||||
if (!relationshipChanged && cursor !== null && Number(cursor.split(':')[0]) >= mtime) continue;
|
||||
workflowUnits.push({
|
||||
key: workflowPath,
|
||||
sessionId,
|
||||
project,
|
||||
meta: { kind: 'workflow', mainTranscriptPath } satisfies ClaudeWorkflowUnitMeta,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...transcriptUnits, ...workflowUnits];
|
||||
}
|
||||
|
||||
export function discover(ctx: DiscoverContext): IndexUnit[] {
|
||||
return discoverAt(join(homedir(), '.claude'), ctx);
|
||||
}
|
||||
|
||||
export function* parse(unit: IndexUnit, cursor: Cursor): Generator<IndexRecord, Cursor> {
|
||||
function toolResultText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content.map((part) => typeof part?.text === 'string' ? part.text : '').join('\n');
|
||||
}
|
||||
|
||||
function workflowParentToolUseId(
|
||||
transcriptPath: string,
|
||||
runId: string,
|
||||
workflowName: string | null,
|
||||
): string | null {
|
||||
if (!fs.existsSync(transcriptPath)) return null;
|
||||
const workflowToolIds = new Set<string>();
|
||||
let parentToolUseId: string | null = null;
|
||||
readLines(transcriptPath, (line: string) => {
|
||||
let record: any;
|
||||
try { record = JSON.parse(line); } catch { return; }
|
||||
const content = record?.message?.content;
|
||||
if (!Array.isArray(content)) return;
|
||||
if (record.type === 'assistant') {
|
||||
for (const block of content) {
|
||||
if (block?.type === 'tool_use' && block?.name === 'Workflow' && typeof block.id === 'string') {
|
||||
workflowToolIds.add(block.id);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (record.type !== 'user') return;
|
||||
for (const block of content) {
|
||||
if (block?.type !== 'tool_result' || !workflowToolIds.has(block.tool_use_id)) continue;
|
||||
const text = toolResultText(block.content);
|
||||
if (!text.includes(runId) && !(workflowName && text.includes(workflowName))) continue;
|
||||
parentToolUseId = block.tool_use_id;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return parentToolUseId;
|
||||
}
|
||||
|
||||
function* parseWorkflow(unit: IndexUnit): Generator<TranscriptRecord, Cursor> {
|
||||
const mtime = fs.statSync(unit.key).mtimeMs;
|
||||
const outCursor = `${mtime}:1`;
|
||||
let workflow: any;
|
||||
try { workflow = JSON.parse(fs.readFileSync(unit.key, 'utf8')); } catch { return outCursor; }
|
||||
if (!workflow?.runId) return outCursor;
|
||||
const meta = unit.meta as ClaudeWorkflowUnitMeta;
|
||||
const progress = Array.isArray(workflow.workflowProgress) ? workflow.workflowProgress : [];
|
||||
const agents = progress.filter((item: any) => item?.type === 'workflow_agent' && item.agentId);
|
||||
yield {
|
||||
kind: 'workflow',
|
||||
run_id: workflow.runId,
|
||||
session_id: unit.sessionId,
|
||||
parent_tool_use_id: workflowParentToolUseId(
|
||||
meta.mainTranscriptPath,
|
||||
workflow.runId,
|
||||
workflow.workflowName || null,
|
||||
),
|
||||
task_id: workflow.taskId || null,
|
||||
script: workflow.script || null,
|
||||
result_json: workflow.result ? JSON.stringify(workflow.result) : null,
|
||||
timestamp: workflow.timestamp || null,
|
||||
agent_count: agents.length,
|
||||
duration_ms: workflow.durationMs || null,
|
||||
total_tokens: workflow.totalTokens || null,
|
||||
status: workflow.status || null,
|
||||
workflow_name: workflow.workflowName || null,
|
||||
};
|
||||
for (const item of agents) {
|
||||
yield {
|
||||
kind: 'workflow_agent',
|
||||
agent_id: `agent-${item.agentId}`,
|
||||
run_id: workflow.runId,
|
||||
session_id: unit.sessionId,
|
||||
phase: item.phaseTitle || null,
|
||||
label: item.label || null,
|
||||
model: item.model || null,
|
||||
state: item.state || null,
|
||||
duration_ms: item.durationMs || null,
|
||||
tokens: item.tokens || null,
|
||||
tool_calls: item.toolCalls || null,
|
||||
};
|
||||
}
|
||||
return outCursor;
|
||||
}
|
||||
|
||||
export function* parse(unit: IndexUnit, cursor: Cursor): Generator<TranscriptRecord, Cursor> {
|
||||
if ((unit.meta as ClaudeWorkflowUnitMeta | undefined)?.kind === 'workflow') {
|
||||
return yield* parseWorkflow(unit);
|
||||
}
|
||||
const skip = cursorToSkip(cursor);
|
||||
const mtime = fs.statSync(unit.key).mtimeMs;
|
||||
const isSubagent = unit.isSubagent === true;
|
||||
const records: IndexRecord[] = [];
|
||||
const records: TranscriptRecord[] = [];
|
||||
const sm = {
|
||||
started_at: null as string | null,
|
||||
ended_at: null as string | null,
|
||||
git_branch: null as string | null,
|
||||
version: null as string | null,
|
||||
title: null as string | null,
|
||||
title: ((unit.meta as { historyTitle?: string } | undefined)?.historyTitle ?? null) as string | null,
|
||||
n: 0,
|
||||
};
|
||||
const subagentStats = {
|
||||
@@ -149,15 +303,17 @@ export function* parse(unit: IndexUnit, cursor: Cursor): Generator<IndexRecord,
|
||||
sm.n++;
|
||||
|
||||
const text = extractText(msg.content);
|
||||
const contentType = extractContentType(msg.content);
|
||||
const rawContentType = extractContentType(msg.content);
|
||||
const isMeta = extractMessageIsMeta(obj, text);
|
||||
const contentType = isMeta && isSkillInstructions(text) ? 'skill_instructions' : rawContentType;
|
||||
const aid = isSubagent ? (unit.agentId ?? null) : (obj.agentId || null);
|
||||
|
||||
if (obj.uuid) {
|
||||
records.push({
|
||||
kind: 'message', uuid: obj.uuid, session_id: sid, type: obj.type,
|
||||
parent_uuid: obj.parentUuid || null, timestamp: ts, role: msg.role || obj.type,
|
||||
text, content_type: contentType, is_meta: (isMeta ? 1 : 0), model: msg.model || null,
|
||||
text, content_type: contentType, is_meta: (isMeta ? 1 : 0), visibility: 'visible',
|
||||
model: msg.model || null,
|
||||
is_sidechain: obj.isSidechain ? 1 : 0, agent_id: aid,
|
||||
input_tokens: totalInputTokens(usage), output_tokens: usage.output_tokens || null,
|
||||
cwd: obj.cwd || null, skill: obj.attributionSkill || null, source: 'claude',
|
||||
@@ -167,7 +323,7 @@ export function* parse(unit: IndexUnit, cursor: Cursor): Generator<IndexRecord,
|
||||
if (obj.type === 'assistant' && Array.isArray(msg.content)) {
|
||||
for (const b of msg.content) {
|
||||
if (b.type === 'tool_use' && b.id)
|
||||
records.push({ kind: 'tool_call', id: b.id, message_uuid: obj.uuid, session_id: sid, name: b.name, input_json: truncJson(b.input || {}) as string, file_path: filePath(b.name, b.input) });
|
||||
records.push({ kind: 'tool_call', id: b.id, message_uuid: obj.uuid, session_id: sid, name: b.name, presentation: b.name === 'Skill' ? 'skill' : 'default', input_json: truncJson(b.input || {}) as string, file_path: filePath(b.name, b.input) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,8 +426,11 @@ export function createClaudeProvider({ rootDir = join(homedir(), '.claude') }: {
|
||||
return {
|
||||
name,
|
||||
descriptor: { id: name, name: 'Claude Code', vendor: 'Anthropic', defaultRoot: rootDir, color: '#d97757' },
|
||||
indexVersionMarker: CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER,
|
||||
watchRoots: (configuredRoot) => [join(configuredRoot, 'projects')],
|
||||
indexVersionMarker: CLAUDE_CANONICAL_TRANSCRIPT_MARKER,
|
||||
watchRoots: (configuredRoot) => [
|
||||
join(configuredRoot, 'projects'),
|
||||
join(configuredRoot, 'history.jsonl'),
|
||||
],
|
||||
discover: (ctx) => discoverAt(rootDir, ctx),
|
||||
parse,
|
||||
raw: rawClaude,
|
||||
|
||||
@@ -21,13 +21,14 @@ import {
|
||||
codexIsGuardianThread, codexAgentNickname, codexAgentRole, codexUsage,
|
||||
codexEventText, codexMessagePayloadText, codexVisibleMessageKey,
|
||||
codexToolInput, codexToolOutput,
|
||||
extractMessageIsMeta, isSkillInstructions,
|
||||
readCodexGuardianThreadInfo,
|
||||
} from '../parsing.ts';
|
||||
|
||||
import type {
|
||||
Cursor,
|
||||
DiscoverContext,
|
||||
IndexRecord,
|
||||
TranscriptRecord,
|
||||
IndexUnit,
|
||||
MessageRecord,
|
||||
ProviderAdapter,
|
||||
@@ -36,11 +37,40 @@ import type {
|
||||
} from './types.ts';
|
||||
|
||||
export const name = 'codex';
|
||||
const CODEX_CANONICAL_TRANSCRIPT_MARKER = '__codex_canonical_transcript_v2__';
|
||||
|
||||
const HIDDEN_CONTEXT_ENVELOPE_RE = /^\s*<(environment_context|codex_internal_context)\b[^>]*>[\s\S]*<\/\1>\s*$/;
|
||||
|
||||
function messageVisibility(role: string, text: string | null): 'visible' | 'hidden' {
|
||||
return role === 'user' && typeof text === 'string' && HIDDEN_CONTEXT_ENVELOPE_RE.test(text)
|
||||
? 'hidden'
|
||||
: 'visible';
|
||||
}
|
||||
|
||||
function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
|
||||
const sessionsDir = join(rootDir, 'sessions');
|
||||
const sessionIndexPath = normalize(join(rootDir, 'session_index.jsonl'));
|
||||
const sessionIndex = new Map<string, { title: string; updatedAt: string | null }>();
|
||||
if (fs.existsSync(sessionIndexPath)) {
|
||||
readLines(sessionIndexPath, (line: string) => {
|
||||
try {
|
||||
const item = JSON.parse(line);
|
||||
if (item?.id && item?.thread_name) {
|
||||
sessionIndex.set(codexRawId(item.id) as string, {
|
||||
title: item.thread_name,
|
||||
updatedAt: item.updated_at || null,
|
||||
});
|
||||
}
|
||||
} catch { /* malformed session-index entry */ }
|
||||
});
|
||||
}
|
||||
const changedFiles = new Set<string>();
|
||||
let sessionIndexChanged = false;
|
||||
for (const changedPath of ctx.changedPaths ?? []) {
|
||||
const rootRelative = isAbsolute(changedPath)
|
||||
? normalize(changedPath)
|
||||
: normalize(join(rootDir, changedPath));
|
||||
if (rootRelative === sessionIndexPath) sessionIndexChanged = true;
|
||||
const absolute = isAbsolute(changedPath)
|
||||
? normalize(changedPath)
|
||||
: normalize(join(sessionsDir, changedPath));
|
||||
@@ -49,10 +79,10 @@ function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
|
||||
if (absolute.toLowerCase().endsWith('.jsonl')) changedFiles.add(absolute);
|
||||
}
|
||||
return discoverCodexJsonlFiles(sessionsDir).flatMap((file) => {
|
||||
if (ctx.changedPaths !== undefined && !changedFiles.has(normalize(file.path))) return [];
|
||||
if (ctx.changedPaths !== undefined && !sessionIndexChanged && !changedFiles.has(normalize(file.path))) return [];
|
||||
const cursor = ctx.lastCursor(file.path);
|
||||
const guardian = readCodexGuardianThreadInfo(file.path);
|
||||
if (cursor !== null && Number(cursor.split(':')[0]) >= fs.statSync(file.path).mtimeMs && guardian === null) {
|
||||
if (!sessionIndexChanged && cursor !== null && Number(cursor.split(':')[0]) >= fs.statSync(file.path).mtimeMs && guardian === null) {
|
||||
return [];
|
||||
}
|
||||
let meta: any = null;
|
||||
@@ -67,10 +97,16 @@ function discoverAt(rootDir: string, ctx: DiscoverContext): IndexUnit[] {
|
||||
});
|
||||
const rawId = meta ? codexRawId(meta.id) : null;
|
||||
const parentId = meta ? codexParentThreadId(meta) : null;
|
||||
const indexed = rawId ? sessionIndex.get(rawId) : undefined;
|
||||
return [{
|
||||
key: file.path,
|
||||
sessionId: guardian === null ? codexDbId(parentId || rawId) ?? '' : '',
|
||||
meta: { source: 'codex', guardian: guardian !== null },
|
||||
meta: {
|
||||
source: 'codex',
|
||||
guardian: guardian !== null,
|
||||
indexedTitle: indexed?.title,
|
||||
indexedUpdatedAt: indexed?.updatedAt,
|
||||
},
|
||||
}];
|
||||
});
|
||||
}
|
||||
@@ -79,7 +115,7 @@ export function discover(ctx: DiscoverContext): IndexUnit[] {
|
||||
return discoverAt(join(homedir(), '.codex'), ctx);
|
||||
}
|
||||
|
||||
export function* parse(unit: IndexUnit, _cursor: Cursor): Generator<IndexRecord, Cursor> {
|
||||
export function* parse(unit: IndexUnit, _cursor: Cursor): Generator<TranscriptRecord, Cursor> {
|
||||
const mtime = fs.statSync(unit.key).mtimeMs;
|
||||
const records: { lineNum: number; obj: any }[] = [];
|
||||
let lineNum = 0;
|
||||
@@ -106,14 +142,19 @@ export function* parse(unit: IndexUnit, _cursor: Cursor): Generator<IndexRecord,
|
||||
const project = projectSlugFromPath(normalizeObservedCwd(meta.cwd));
|
||||
const lineUuid = (n: number): string => codexLineUuid(threadRawId, n) as string;
|
||||
|
||||
const out: IndexRecord[] = [];
|
||||
const out: TranscriptRecord[] = [];
|
||||
const msgByUuid = new Map<string, MessageRecord>();
|
||||
const indexedMeta = unit.meta as { indexedTitle?: string; indexedUpdatedAt?: string | null } | undefined;
|
||||
const initialTimestamp = (meta.timestamp || metaRecord.obj.timestamp || null) as string | null;
|
||||
const indexedUpdatedAt = indexedMeta?.indexedUpdatedAt ?? null;
|
||||
const sm = {
|
||||
started_at: (meta.timestamp || metaRecord.obj.timestamp || null) as string | null,
|
||||
ended_at: (meta.timestamp || metaRecord.obj.timestamp || null) as string | null,
|
||||
started_at: initialTimestamp,
|
||||
ended_at: indexedUpdatedAt && (!initialTimestamp || indexedUpdatedAt > initialTimestamp)
|
||||
? indexedUpdatedAt
|
||||
: initialTimestamp,
|
||||
git_branch: (meta.git?.branch || null) as string | null,
|
||||
version: (meta.cli_version || null) as string | null,
|
||||
title: null as string | null,
|
||||
title: indexedMeta?.indexedTitle ?? null,
|
||||
n: 0,
|
||||
lastMessageUuid: null as string | null,
|
||||
lastTextAssistantUuid: null as string | null,
|
||||
@@ -135,10 +176,14 @@ export function* parse(unit: IndexUnit, _cursor: Cursor): Generator<IndexRecord,
|
||||
const insertMessage = ({ uuid, type, role, text = null, contentType = 'text', timestamp, isMeta = 0 }: {
|
||||
uuid: string; type: string; role: string; text?: string | null; contentType?: string; timestamp: string | null; isMeta?: 0 | 1;
|
||||
}) => {
|
||||
const visibility = messageVisibility(role, text);
|
||||
const skillInstructions = role === 'user' && isSkillInstructions(text);
|
||||
const rec: MessageRecord = {
|
||||
kind: 'message', uuid, session_id: sessionId, type, parent_uuid: sm.lastMessageUuid,
|
||||
timestamp: timestamp || null, role, text: trunc(text), content_type: contentType,
|
||||
is_meta: isMeta, model: currentModel, is_sidechain: isSidechain, agent_id: agentId,
|
||||
timestamp: timestamp || null, role, text: trunc(text),
|
||||
content_type: skillInstructions ? 'skill_instructions' : contentType,
|
||||
is_meta: visibility === 'hidden' || skillInstructions ? 1 : (isMeta || extractMessageIsMeta({}, text)), visibility,
|
||||
model: currentModel, is_sidechain: isSidechain, agent_id: agentId,
|
||||
input_tokens: null, output_tokens: null, cwd: currentCwd, skill: null, source: 'codex',
|
||||
};
|
||||
out.push(rec);
|
||||
@@ -191,13 +236,13 @@ export function* parse(unit: IndexUnit, _cursor: Cursor): Generator<IndexRecord,
|
||||
}
|
||||
if (payload.type === 'collab_agent_spawn_end' && payload.call_id && payload.new_thread_id) {
|
||||
const uuid = insertMessage({ uuid: lineUuid(currentLine), type: 'assistant', role: 'assistant', text: null, contentType: 'tool_use', timestamp: ts });
|
||||
const toolId = codexCallId(payload.call_id) as string;
|
||||
const toolId = codexCallId(threadRawId, payload.call_id) as string;
|
||||
const description = payload.new_agent_nickname || payload.new_agent_role || 'Agent';
|
||||
const input = {
|
||||
description, subagent_type: payload.new_agent_role || 'Agent', prompt: payload.prompt || '',
|
||||
new_thread_id: payload.new_thread_id, model: payload.model || null, reasoning_effort: payload.reasoning_effort || null,
|
||||
};
|
||||
out.push({ kind: 'tool_call', id: toolId, message_uuid: uuid, session_id: sessionId, name: 'Agent', input_json: truncJson(input) as string, file_path: null });
|
||||
out.push({ kind: 'tool_call', id: toolId, message_uuid: uuid, session_id: sessionId, name: 'Agent', presentation: 'default', input_json: truncJson(input) as string, file_path: null });
|
||||
callMessageUuids.set(toolId, uuid);
|
||||
out.push({ kind: 'subagent', agent_id: codexDbId(payload.new_thread_id) as string, session_id: sessionId, parent_tool_use_id: toolId, agent_type: payload.new_agent_role || null, description });
|
||||
continue;
|
||||
@@ -235,13 +280,13 @@ export function* parse(unit: IndexUnit, _cursor: Cursor): Generator<IndexRecord,
|
||||
if (['function_call', 'custom_tool_call', 'tool_search_call', 'web_search_call'].includes(payload.type) && payload.call_id) {
|
||||
const uuid = insertMessage({ uuid: lineUuid(currentLine), type: 'assistant', role: 'assistant', text: null, contentType: 'tool_use', timestamp: ts });
|
||||
const name = payload.name || payload.tool || payload.type.replace(/_call$/, '');
|
||||
const toolId = codexCallId(payload.call_id) as string;
|
||||
out.push({ kind: 'tool_call', id: toolId, message_uuid: uuid, session_id: sessionId, name, input_json: truncJson(codexToolInput(payload)) as string, file_path: null });
|
||||
const toolId = codexCallId(threadRawId, payload.call_id) as string;
|
||||
out.push({ kind: 'tool_call', id: toolId, message_uuid: uuid, session_id: sessionId, name, presentation: name === 'Skill' ? 'skill' : 'default', input_json: truncJson(codexToolInput(payload)) as string, file_path: null });
|
||||
callMessageUuids.set(toolId, uuid);
|
||||
continue;
|
||||
}
|
||||
if (['function_call_output', 'custom_tool_call_output', 'tool_search_output'].includes(payload.type) && payload.call_id) {
|
||||
const toolId = codexCallId(payload.call_id) as string;
|
||||
const toolId = codexCallId(threadRawId, payload.call_id) as string;
|
||||
out.push({ kind: 'tool_result', tool_use_id: toolId, message_uuid: callMessageUuids.get(toolId) || '', session_id: sessionId, content: trunc(codexToolOutput(payload) || ''), file_path: null, is_error: payload.is_error ? 1 : 0 });
|
||||
}
|
||||
}
|
||||
@@ -309,8 +354,7 @@ function rawCodex(rootDir: string, input: RawLookup): RawRecord | null {
|
||||
? payload.text
|
||||
: null;
|
||||
} else if (obj?.type === 'response_item' && payload.type === 'message' && Array.isArray(payload.content)) {
|
||||
const parts = payload.content.map((part: any) => part?.text).filter((part: unknown) => typeof part === 'string');
|
||||
messageText = parts.length > 0 ? parts.join('\n') : null;
|
||||
messageText = codexMessagePayloadText(payload);
|
||||
}
|
||||
} catch { /* malformed source line */ }
|
||||
}
|
||||
@@ -323,6 +367,7 @@ export function createCodexProvider({ rootDir = join(homedir(), '.codex') }: { r
|
||||
return {
|
||||
name,
|
||||
descriptor: { id: name, name: 'Codex', vendor: 'OpenAI', defaultRoot: rootDir, color: '#10a37f' },
|
||||
indexVersionMarker: CODEX_CANONICAL_TRANSCRIPT_MARKER,
|
||||
watchRoots: (configuredRoot) => [join(configuredRoot, 'sessions'), join(configuredRoot, 'session_index.jsonl')],
|
||||
discover: (ctx) => discoverAt(rootDir, ctx),
|
||||
parse,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { filePath, projectSlugFromPath, trunc, truncJson } from '../parsing.ts';
|
||||
import type {
|
||||
Cursor,
|
||||
DiscoverContext,
|
||||
IndexRecord,
|
||||
TranscriptRecord,
|
||||
IndexUnit,
|
||||
MessageRecord,
|
||||
ProviderAdapter,
|
||||
@@ -50,12 +50,13 @@ interface ProjectedSession {
|
||||
readonly toolResults: ToolResultRecord[];
|
||||
readonly summaries: SummaryRecord[];
|
||||
readonly subagents: SubagentRecord[];
|
||||
readonly durations: IndexRecord[];
|
||||
readonly durations: TranscriptRecord[];
|
||||
readonly mainMessageCount: number;
|
||||
readonly mainWirePath: string;
|
||||
}
|
||||
|
||||
const SOURCE = 'kimi';
|
||||
export const KIMI_CANONICAL_TRANSCRIPT_MARKER = '__kimi_canonical_transcript_v2__';
|
||||
|
||||
function defaultKimiRoot(): string {
|
||||
return process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code');
|
||||
@@ -203,6 +204,46 @@ function isRealUserMessage(message: JsonRecord): boolean {
|
||||
&& origin.trigger === 'user-slash';
|
||||
}
|
||||
|
||||
function slashCommandText(command: string, args: unknown): string {
|
||||
const trimmedArgs = typeof args === 'string' ? args.trim() : '';
|
||||
return trimmedArgs.length > 0 ? `${command} ${trimmedArgs}` : command;
|
||||
}
|
||||
|
||||
function userSlashCommandText(message: JsonRecord): string | null {
|
||||
const origin = message.origin as JsonRecord | undefined;
|
||||
if (message.role === 'user' && origin?.trigger === 'user-slash') {
|
||||
if (origin.kind === 'skill_activation' && typeof origin.skillName === 'string') {
|
||||
return slashCommandText(`/${origin.skillName}`, origin.skillArgs);
|
||||
}
|
||||
if (
|
||||
origin.kind === 'plugin_command'
|
||||
&& typeof origin.pluginId === 'string'
|
||||
&& typeof origin.commandName === 'string'
|
||||
) {
|
||||
return slashCommandText(`/${origin.pluginId}:${origin.commandName}`, origin.commandArgs);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function projectedMessageText(message: JsonRecord): string | null {
|
||||
const slashCommand = userSlashCommandText(message);
|
||||
return slashCommand === null ? messageText(message.content) : trunc(slashCommand);
|
||||
}
|
||||
|
||||
function isMetaMessage(message: JsonRecord): boolean {
|
||||
const origin = message.origin as JsonRecord | undefined;
|
||||
if (origin === undefined || origin.kind === 'user') return false;
|
||||
return !isRealUserMessage(message);
|
||||
}
|
||||
|
||||
function canonicalMessageContentType(message: JsonRecord): string {
|
||||
const origin = message.origin as JsonRecord | undefined;
|
||||
return origin?.kind === 'skill_activation' && !isRealUserMessage(message)
|
||||
? 'skill_instructions'
|
||||
: messageContentType(message.content);
|
||||
}
|
||||
|
||||
function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: JsonRecord): ProjectedSession {
|
||||
const cwd = typeof state.cwd === 'string'
|
||||
? state.cwd
|
||||
@@ -213,7 +254,7 @@ function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: Jso
|
||||
const toolCalls: ToolCallRecord[] = [];
|
||||
const toolResults: ToolResultRecord[] = [];
|
||||
const summaries: SummaryRecord[] = [];
|
||||
const durations: IndexRecord[] = [];
|
||||
const durations: TranscriptRecord[] = [];
|
||||
const childParentCalls = new Map<string, string>();
|
||||
let mainMessageCount = 0;
|
||||
|
||||
@@ -314,9 +355,10 @@ function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: Jso
|
||||
parent_uuid: previousUuid,
|
||||
timestamp,
|
||||
role: source.role,
|
||||
text: messageText(source.content),
|
||||
content_type: messageContentType(source.content),
|
||||
is_meta: origin !== undefined && origin.kind !== 'user' ? 1 : 0,
|
||||
text: projectedMessageText(source),
|
||||
content_type: canonicalMessageContentType(source),
|
||||
is_meta: isMetaMessage(source) ? 1 : 0,
|
||||
visibility: 'visible',
|
||||
model,
|
||||
is_sidechain: wire.main ? 0 : 1,
|
||||
agent_id: agentDbId,
|
||||
@@ -344,6 +386,7 @@ function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: Jso
|
||||
message_uuid: messageUuid,
|
||||
session_id: sessionId,
|
||||
name,
|
||||
presentation: name === 'Skill' ? 'skill' : 'default',
|
||||
input_json: truncJson(args) ?? '{}',
|
||||
file_path: filePath(name, args as JsonRecord | undefined),
|
||||
});
|
||||
@@ -405,6 +448,7 @@ function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: Jso
|
||||
text: partText(part),
|
||||
content_type: typeof part.type === 'string' ? part.type : 'unknown',
|
||||
is_meta: 0,
|
||||
visibility: 'visible',
|
||||
model,
|
||||
is_sidechain: wire.main ? 0 : 1,
|
||||
agent_id: agentDbId,
|
||||
@@ -421,7 +465,8 @@ function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: Jso
|
||||
const toolId = namespacedToolId(sessionId, wire.agentId, event.toolCallId);
|
||||
pushMessage({
|
||||
kind: 'message', uuid, session_id: sessionId, type: 'assistant', parent_uuid: previousUuid,
|
||||
timestamp, role: 'assistant', text: null, content_type: 'tool_use', is_meta: 0, model,
|
||||
timestamp, role: 'assistant', text: null, content_type: 'tool_use', is_meta: 0,
|
||||
visibility: 'visible', model,
|
||||
is_sidechain: wire.main ? 0 : 1, agent_id: agentDbId, input_tokens: null,
|
||||
output_tokens: null, cwd, skill: null, source: SOURCE,
|
||||
}, event.stepUuid);
|
||||
@@ -431,6 +476,7 @@ function projectSession(meta: KimiSessionUnitMeta, sessionId: string, state: Jso
|
||||
message_uuid: uuid,
|
||||
session_id: sessionId,
|
||||
name: String(event.name ?? 'tool'),
|
||||
presentation: event.name === 'Skill' ? 'skill' : 'default',
|
||||
input_json: truncJson(event.args ?? {}) ?? '{}',
|
||||
file_path: filePath(String(event.name ?? 'tool'), event.args as JsonRecord | undefined),
|
||||
});
|
||||
@@ -552,13 +598,19 @@ function rawFromWire(path: string, messageUuid: string): RawRecord | null {
|
||||
try {
|
||||
const record = JSON.parse(line) as JsonRecord;
|
||||
if (record.type === 'context.append_message') {
|
||||
const content = (record.message as JsonRecord | undefined)?.content;
|
||||
const parts = contentParts(content).map((part) => {
|
||||
if (part.type === 'text' && typeof part.text === 'string') return part.text;
|
||||
if (part.type === 'thinking' && typeof part.thinking === 'string') return part.thinking;
|
||||
return null;
|
||||
}).filter((part): part is string => part !== null);
|
||||
projectedText = parts.length > 0 ? parts.join('\n') : null;
|
||||
const message = record.message as JsonRecord | undefined;
|
||||
if (message !== undefined) {
|
||||
const slashCommand = userSlashCommandText(message);
|
||||
if (slashCommand !== null) projectedText = slashCommand;
|
||||
else {
|
||||
const parts = contentParts(message.content).map((part) => {
|
||||
if (part.type === 'text' && typeof part.text === 'string') return part.text;
|
||||
if (part.type === 'thinking' && typeof part.thinking === 'string') return part.thinking;
|
||||
return null;
|
||||
}).filter((part): part is string => part !== null);
|
||||
projectedText = parts.length > 0 ? parts.join('\n') : null;
|
||||
}
|
||||
}
|
||||
} else if (record.type === 'context.append_loop_event') {
|
||||
const part = (record.event as JsonRecord | undefined)?.part as JsonRecord | undefined;
|
||||
if (part?.type === 'text' && typeof part.text === 'string') projectedText = part.text;
|
||||
@@ -580,6 +632,7 @@ export function createKimiProvider({ rootDir = defaultKimiRoot() }: { rootDir?:
|
||||
return {
|
||||
name,
|
||||
descriptor: { id: name, name: 'Kimi Code', vendor: 'Moonshot AI', defaultRoot: rootDir, color: '#6d6afc' },
|
||||
indexVersionMarker: KIMI_CANONICAL_TRANSCRIPT_MARKER,
|
||||
watchRoots: (configuredRoot) => [join(configuredRoot, 'sessions'), join(configuredRoot, 'session_index.jsonl')],
|
||||
discover(ctx: DiscoverContext): IndexUnit[] {
|
||||
const units: IndexUnit[] = [];
|
||||
@@ -604,7 +657,7 @@ export function createKimiProvider({ rootDir = defaultKimiRoot() }: { rootDir?:
|
||||
}
|
||||
return units;
|
||||
},
|
||||
*parse(unit: IndexUnit, _cursor: Cursor): Generator<IndexRecord, Cursor> {
|
||||
*parse(unit: IndexUnit, _cursor: Cursor): Generator<TranscriptRecord, Cursor> {
|
||||
const meta = unit.meta as KimiSessionUnitMeta;
|
||||
const before = cursorFor(meta.statePath, meta.wireFiles);
|
||||
const state = readState(meta.statePath);
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
// NOT assumed to be a single JSONL file — an adapter may read a SQLite store,
|
||||
// a directory tree, etc. So discovery, change-detection, and resume cursoring
|
||||
// are all adapter-owned and format-specific.
|
||||
// - Persist axis: one shared, provider- and binding-agnostic orchestration
|
||||
// that consumes the records and writes them (index_state, FTS, upsert).
|
||||
// - Consumer axis: shared, provider-agnostic modules consume the canonical
|
||||
// transcript for persistence and session-detail presentation.
|
||||
//
|
||||
// This file defines only the shapes crossing that boundary. Record fields mirror
|
||||
// the columns in packages/core/src/schema.sql; keep them in sync. Types only — no runtime
|
||||
// code — so consumers must import with `import type`.
|
||||
// This file defines only the shapes crossing that seam. Many fields mirror the
|
||||
// SQLite schema, but the database is a serialization adapter rather than the
|
||||
// source of transcript semantics. Types only — no runtime code — so consumers
|
||||
// must import with `import type`.
|
||||
|
||||
// Opaque per-unit resume/watermark token. The orchestration stores it verbatim
|
||||
// (in index_state) and hands it back on the next run; ONLY the adapter that
|
||||
@@ -46,12 +47,10 @@ export interface DiscoverContext {
|
||||
changedPaths?: string[];
|
||||
}
|
||||
|
||||
/** Discriminated union of everything an adapter's parse can emit. Each record
|
||||
* kind maps to one schema table (see packages/core/src/schema.sql); `delete-session` is
|
||||
* the exception — a retraction op, not a table. Sources without a table
|
||||
* (history.jsonl, codex session_index.jsonl) are not records: adapters fold them
|
||||
* into the SessionRecord they already emit. */
|
||||
export type IndexRecord =
|
||||
/** Canonical language emitted by every provider adapter. Persist serializes it;
|
||||
* session-detail assembly consumes it directly. Most record kinds map to one
|
||||
* schema table; update/retraction records encode canonical state transitions. */
|
||||
export type TranscriptRecord =
|
||||
| SessionRecord
|
||||
| MessageRecord
|
||||
| ToolCallRecord
|
||||
@@ -63,6 +62,8 @@ export type IndexRecord =
|
||||
| MessageTurnDurationRecord
|
||||
| DeleteSessionRecord;
|
||||
|
||||
export type MessageVisibility = 'visible' | 'hidden';
|
||||
|
||||
export interface MessageRecord {
|
||||
kind: 'message';
|
||||
uuid: string;
|
||||
@@ -74,6 +75,8 @@ export interface MessageRecord {
|
||||
text: string | null;
|
||||
content_type: string | null;
|
||||
is_meta: 0 | 1;
|
||||
/** Provider-normalized display eligibility. Assemblers never infer this from text. */
|
||||
visibility: MessageVisibility;
|
||||
model: string | null;
|
||||
is_sidechain: 0 | 1;
|
||||
agent_id: string | null;
|
||||
@@ -91,6 +94,7 @@ export interface ToolCallRecord {
|
||||
message_uuid: string;
|
||||
session_id: string;
|
||||
name: string;
|
||||
presentation: 'default' | 'skill';
|
||||
input_json: string;
|
||||
file_path: string | null;
|
||||
}
|
||||
@@ -128,17 +132,18 @@ export interface SubagentRecord {
|
||||
total_tokens?: number | null;
|
||||
}
|
||||
|
||||
// A workflow run. `agent_count` is intentionally absent: it is a derived
|
||||
// aggregate (COUNT of workflow_agents for this run) that persist computes, since
|
||||
// the agents may be indexed on different runs than the workflow metadata.
|
||||
// A workflow run. `agent_count` is optional presentation metadata; persist still
|
||||
// computes the authoritative aggregate because agents may arrive on other runs.
|
||||
export interface WorkflowRecord {
|
||||
kind: 'workflow';
|
||||
run_id: string;
|
||||
session_id: string;
|
||||
parent_tool_use_id?: string | null;
|
||||
task_id: string | null;
|
||||
script: string | null;
|
||||
result_json: string | null;
|
||||
timestamp: string | null;
|
||||
agent_count: number;
|
||||
duration_ms: number | null;
|
||||
total_tokens: number | null;
|
||||
status: string | null;
|
||||
@@ -223,7 +228,7 @@ export interface Provider {
|
||||
/** Discover units needing (re)indexing, using stored cursors to detect change. */
|
||||
discover(ctx: DiscoverContext): IndexUnit[];
|
||||
/** Yield records for one unit resuming from `cursor`; return the new cursor. */
|
||||
parse(unit: IndexUnit, cursor: Cursor): Generator<IndexRecord, Cursor>;
|
||||
parse(unit: IndexUnit, cursor: Cursor): Generator<TranscriptRecord, Cursor>;
|
||||
}
|
||||
|
||||
/** Serializable source metadata consumed by settings and renderer surfaces. */
|
||||
|
||||
Reference in New Issue
Block a user