mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
1304 lines
66 KiB
JavaScript
1304 lines
66 KiB
JavaScript
import { parseJsonlLines, extractContent, isMetaText, correlateToolNames, stripUtf8Bom } from './types.js';
|
||
function isRecord(value) {
|
||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||
}
|
||
function finiteNumber(value) {
|
||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||
}
|
||
function firstNumber(record, keys) {
|
||
for (const key of keys) {
|
||
const value = finiteNumber(record[key]);
|
||
if (value !== undefined)
|
||
return value;
|
||
}
|
||
return undefined;
|
||
}
|
||
function firstString(record, keys) {
|
||
for (const key of keys) {
|
||
const value = record[key];
|
||
if (typeof value === 'string' && value.trim())
|
||
return value.trim();
|
||
}
|
||
return undefined;
|
||
}
|
||
function parseJsonish(value) {
|
||
if (typeof value !== 'string')
|
||
return value;
|
||
const trimmed = stripUtf8Bom(value).trim();
|
||
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('[')))
|
||
return value;
|
||
try {
|
||
return JSON.parse(trimmed);
|
||
}
|
||
catch {
|
||
return value;
|
||
}
|
||
}
|
||
function parsedRecord(value) {
|
||
const parsed = parseJsonish(value);
|
||
return isRecord(parsed) ? parsed : undefined;
|
||
}
|
||
function hasOwn(record, key) {
|
||
return Object.prototype.hasOwnProperty.call(record, key);
|
||
}
|
||
function firstPresent(record, keys) {
|
||
for (const key of keys)
|
||
if (hasOwn(record, key))
|
||
return record[key];
|
||
return undefined;
|
||
}
|
||
function firstPresentFrom(records, keys) {
|
||
for (const record of records) {
|
||
const value = firstPresent(record, keys);
|
||
if (value !== undefined)
|
||
return parseJsonish(value);
|
||
}
|
||
return undefined;
|
||
}
|
||
function firstStringFrom(records, keys) {
|
||
for (const record of records) {
|
||
const value = firstString(record, keys);
|
||
if (value !== undefined)
|
||
return value;
|
||
}
|
||
return undefined;
|
||
}
|
||
function mergeSessionMetadata(current, next) {
|
||
const nativeCalls = [...(current.nativeCalls ?? []), ...(next.nativeCalls ?? [])];
|
||
const rawRows = [...(current.rawRows ?? []), ...(next.rawRows ?? [])];
|
||
return {
|
||
...current,
|
||
...(!current.sessionId && next.sessionId ? { sessionId: next.sessionId } : {}),
|
||
...(!current.provider && next.provider ? { provider: next.provider } : {}),
|
||
...(!current.model && next.model ? { model: next.model } : {}),
|
||
...(current.tools === undefined && next.tools !== undefined ? { tools: next.tools } : {}),
|
||
...(!current.startedAt && next.startedAt ? { startedAt: next.startedAt } : {}),
|
||
...(!current.clientSource && next.clientSource ? { clientSource: next.clientSource } : {}),
|
||
...(!current.systemPrompt && next.systemPrompt ? { systemPrompt: next.systemPrompt } : {}),
|
||
...(current.metadata === undefined && next.metadata !== undefined ? { metadata: next.metadata } : {}),
|
||
...(current.usage === undefined && next.usage !== undefined ? { usage: next.usage } : {}),
|
||
...(current.risk === undefined && next.risk !== undefined ? { risk: next.risk } : {}),
|
||
...(current.fidelity === undefined && next.fidelity !== undefined ? { fidelity: next.fidelity } : {}),
|
||
...(current.confidentiality === undefined && next.confidentiality !== undefined ? { confidentiality: next.confidentiality } : {}),
|
||
...(current.sourceRecord === undefined && next.sourceRecord !== undefined ? { sourceRecord: next.sourceRecord } : {}),
|
||
...(rawRows.length > 0 ? { rawRows } : {}),
|
||
...(nativeCalls.length > 0 ? { nativeCalls } : {}),
|
||
};
|
||
}
|
||
function sourceTurnMetadata(source) {
|
||
if (!source)
|
||
return {};
|
||
const parsedMeta = parsedRecord(source['meta']);
|
||
const parsedMetadata = parsedRecord(source['metadata']);
|
||
const sourceRecords = [source, parsedMetadata, parsedMeta].filter((record) => isRecord(record));
|
||
const usageValue = firstPresentFrom(sourceRecords, ['usage']);
|
||
const usage = isRecord(usageValue) ? usageValue : source;
|
||
const model = firstStringFrom(sourceRecords, ['model', 'model_name', 'modelName']);
|
||
const inputTokens = firstNumber(usage, ['input_tokens', 'inputTokens', 'prompt_tokens', 'promptTokens']);
|
||
const outputTokens = firstNumber(usage, ['output_tokens', 'outputTokens', 'completion_tokens', 'completionTokens']);
|
||
const metadata = parsedMetadata ?? parsedMeta;
|
||
return {
|
||
...(model ? { model } : {}),
|
||
...(inputTokens !== undefined ? { inputTokens } : {}),
|
||
...(outputTokens !== undefined ? { outputTokens } : {}),
|
||
sourceRecord: source,
|
||
rawRow: source,
|
||
...(metadata !== undefined ? { metadata } : {}),
|
||
...(usageValue !== undefined ? { usage: usageValue } : {}),
|
||
...(firstPresentFrom(sourceRecords, ['risk']) !== undefined ? { risk: firstPresentFrom(sourceRecords, ['risk']) } : {}),
|
||
...(firstPresentFrom(sourceRecords, ['fidelity']) !== undefined ? { fidelity: firstPresentFrom(sourceRecords, ['fidelity']) } : {}),
|
||
...(firstPresentFrom(sourceRecords, ['confidentiality']) !== undefined ? { confidentiality: firstPresentFrom(sourceRecords, ['confidentiality']) } : {}),
|
||
};
|
||
}
|
||
function hasUsage(metadata) {
|
||
return metadata.inputTokens !== undefined || metadata.outputTokens !== undefined;
|
||
}
|
||
function withSourceMetadata(turns, source) {
|
||
if (turns.length === 0)
|
||
return turns;
|
||
const metadata = sourceTurnMetadata(source);
|
||
let usageAttached = false;
|
||
return turns.map((turn) => {
|
||
const out = {
|
||
...turn,
|
||
...(metadata.model ? { model: metadata.model } : {}),
|
||
...(metadata.sourceRecord !== undefined ? { sourceRecord: metadata.sourceRecord } : {}),
|
||
...(metadata.rawRow !== undefined ? { rawRow: metadata.rawRow } : {}),
|
||
...(metadata.metadata !== undefined ? { metadata: metadata.metadata } : {}),
|
||
...(metadata.usage !== undefined ? { usage: metadata.usage } : {}),
|
||
...(metadata.risk !== undefined ? { risk: metadata.risk } : {}),
|
||
...(metadata.fidelity !== undefined ? { fidelity: metadata.fidelity } : {}),
|
||
...(metadata.confidentiality !== undefined ? { confidentiality: metadata.confidentiality } : {}),
|
||
};
|
||
if (!usageAttached && turn.isMeta !== true && hasUsage(metadata)) {
|
||
if (metadata.inputTokens !== undefined)
|
||
out.inputTokens = metadata.inputTokens;
|
||
if (metadata.outputTokens !== undefined)
|
||
out.outputTokens = metadata.outputTokens;
|
||
usageAttached = true;
|
||
}
|
||
return out;
|
||
});
|
||
}
|
||
// VERIFICATION BAR: an adapter only ships once its parse() is checked against a REAL session log from that tool
|
||
// — a trimmed, sanitized sample + a golden test (see the golden tests in adapters.test.ts). A guessed schema is
|
||
// worse than no adapter: it silently yields 0 turns on real logs while looking supported. Today: claude-code,
|
||
// codex, cursor, Gemini, and Antigravity are verified against trimmed sanitized real-log samples. opencode/kiro
|
||
// stay removed until each has a real-log golden test.
|
||
// claude-code AND cursor share the Anthropic content-block transcript shape: one JSONL record per turn,
|
||
// { role|type: 'user'|'assistant', message: { content: [ {type:'text',text} | {type:'tool_use',name,id?} |
|
||
// {type:'tool_result',...} ] } }. correlateToolNames backfills a tool_result's tool name from its tool_use id.
|
||
function anthropicStyleTranscript(chunk) {
|
||
return correlateToolNames(parseJsonlLines(chunk).flatMap((obj) => {
|
||
const type = (obj['type'] ?? obj['role']);
|
||
if (type !== 'user' && type !== 'assistant')
|
||
return []; // non-turn records (no role/type) → skipped
|
||
const msg = isRecord(obj['message']) ? obj['message'] : undefined;
|
||
const timestamp = typeof obj['timestamp'] === 'string'
|
||
? obj['timestamp']
|
||
: (typeof msg?.['timestamp'] === 'string' ? msg['timestamp'] : undefined);
|
||
const metadataSource = msg ? { ...obj, ...msg } : obj;
|
||
return withSourceMetadata(extractContent(type, msg?.['content'] ?? obj['content']), metadataSource)
|
||
.map((turn) => (timestamp ? { ...turn, timestamp } : turn));
|
||
}));
|
||
}
|
||
// codex content items are {type:'input_text'|'output_text'|'text', text}; flatten to a single string.
|
||
const CODEX_TEXT_TYPES = new Set(['input_text', 'output_text', 'text']);
|
||
function codexMessageText(content) {
|
||
if (typeof content === 'string')
|
||
return content;
|
||
if (!Array.isArray(content))
|
||
return '';
|
||
return content
|
||
.filter((p) => !!p && typeof p === 'object')
|
||
.filter((p) => CODEX_TEXT_TYPES.has(String(p['type'])))
|
||
.map((p) => (typeof p['text'] === 'string' ? p['text'] : ''))
|
||
.join('');
|
||
}
|
||
// codex injects scaffolding envelopes (not human/model-authored) — treat them as meta so distillation skips them.
|
||
const CODEX_META_TAGS = ['<environment_context>', '<permissions instructions>', '<user_instructions>'];
|
||
// Shell-style tool outputs lead with `Exit code: N` — a non-zero code is a tool FAILURE. Downstream signal
|
||
// extraction keys error signals off `errorMessage`, so surface it there (not just toolResult), mirroring claude's
|
||
// is_error. Shared by the codex adapter and the generic chat adapter (both wrap shell-style tool output).
|
||
function exitCodeFailed(output) {
|
||
const m = /^Exit code:\s*(-?\d+)/.exec(output);
|
||
return m ? Number(m[1]) !== 0 : false;
|
||
}
|
||
function stringifyReasoningSummary(summary) {
|
||
if (typeof summary === 'string')
|
||
return summary;
|
||
if (!Array.isArray(summary))
|
||
return '';
|
||
return summary.map((item) => {
|
||
if (typeof item === 'string')
|
||
return item;
|
||
if (!item || typeof item !== 'object')
|
||
return '';
|
||
const row = item;
|
||
for (const key of ['text', 'summary', 'content']) {
|
||
const value = row[key];
|
||
if (typeof value === 'string')
|
||
return value;
|
||
}
|
||
return '';
|
||
}).filter(Boolean).join('\n');
|
||
}
|
||
function withoutKeys(obj, keys) {
|
||
const out = {};
|
||
for (const [key, value] of Object.entries(obj))
|
||
if (!keys.includes(key))
|
||
out[key] = value;
|
||
return out;
|
||
}
|
||
function codexToolUseId(p) {
|
||
for (const key of ['call_id', 'id', 'tool_call_id', 'tool_use_id']) {
|
||
const value = p[key];
|
||
if (typeof value === 'string' && value)
|
||
return value;
|
||
}
|
||
return undefined;
|
||
}
|
||
function codexNativeToolTurn(p) {
|
||
const type = String(p['type'] ?? '');
|
||
if (!type)
|
||
return null;
|
||
const isOutput = /(?:_output|output)$/.test(type);
|
||
const isCall = /(?:_call|call)$/.test(type);
|
||
if (!isCall && !isOutput)
|
||
return null;
|
||
const payload = withoutKeys(p, ['type', 'id', 'call_id', 'tool_call_id', 'tool_use_id']);
|
||
const toolUseId = codexToolUseId(p);
|
||
if (isOutput) {
|
||
return {
|
||
role: 'tool',
|
||
text: '',
|
||
toolName: type,
|
||
...(toolUseId ? { toolUseId } : {}),
|
||
toolResult: JSON.stringify(payload),
|
||
isMeta: false,
|
||
};
|
||
}
|
||
return {
|
||
role: 'assistant',
|
||
text: '',
|
||
toolName: type,
|
||
...(toolUseId ? { toolUseId } : {}),
|
||
toolInput: payload,
|
||
isMeta: false,
|
||
};
|
||
}
|
||
function normalizeChatRole(rawRole) {
|
||
if (rawRole === 'developer')
|
||
return 'system';
|
||
if (rawRole === 'human')
|
||
return 'user';
|
||
if (rawRole === 'model')
|
||
return 'assistant';
|
||
if (rawRole === 'function')
|
||
return 'tool';
|
||
if (['user', 'assistant', 'tool', 'system'].includes(rawRole))
|
||
return rawRole;
|
||
return null;
|
||
}
|
||
// FIX-8: extract a SESSION-level system prompt for Anthropic-shaped transcripts. Claude stores the system prompt
|
||
// out-of-band in some versions: a dedicated `{type:'system'}` record, a record carrying a top-level
|
||
// `systemPrompt`/`system` string, or a leading message with role 'system'. When present we surface it at session
|
||
// level so buyers can see the operating instructions; when absent (current Claude transcripts persist no system
|
||
// record) parseSession simply omits it. We scan a bounded prefix — the system record is always near the top.
|
||
function anthropicSessionSystemPrompt(chunk) {
|
||
const rows = parseJsonlLines(chunk).slice(0, 50);
|
||
for (const row of rows) {
|
||
if (typeof row['systemPrompt'] === 'string' && row['systemPrompt'].trim())
|
||
return row['systemPrompt'];
|
||
if (typeof row['system'] === 'string' && row['system'].trim())
|
||
return row['system'];
|
||
const type = (row['type'] ?? row['role']);
|
||
const msg = isRecord(row['message']) ? row['message'] : undefined;
|
||
const role = (msg?.['role'] ?? row['role']);
|
||
if (type === 'system' || role === 'system') {
|
||
const content = msg?.['content'] ?? row['content'];
|
||
if (typeof content === 'string' && content.trim())
|
||
return content;
|
||
if (Array.isArray(content)) {
|
||
const text = content
|
||
.filter((p) => isRecord(p))
|
||
.map((p) => (typeof p['text'] === 'string' ? p['text'] : ''))
|
||
.join('');
|
||
if (text.trim())
|
||
return text;
|
||
}
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
const NATIVE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||
const CLAUDE_TRANSCRIPT_PATH = /(?:^|[/\\])\.claude[/\\]projects[/\\](?:[^/\\]+[/\\])*([A-Za-z0-9][A-Za-z0-9._-]{0,127})\.jsonl$/;
|
||
const CURSOR_TRANSCRIPT_PATHS = [
|
||
/(?:^|[/\\])\.cursor[/\\]projects[/\\](?:[^/\\]+[/\\])*agent-transcripts[/\\]([A-Za-z0-9][A-Za-z0-9._-]{0,127})\.jsonl$/,
|
||
/(?:^|[/\\])\.cursor[/\\]projects[/\\](?:[^/\\]+[/\\])*agent-transcripts[/\\]([A-Za-z0-9][A-Za-z0-9._-]{0,127})[/\\]\1\.jsonl$/,
|
||
];
|
||
function transcriptSessionId(chunk, keys) {
|
||
const values = new Set();
|
||
for (const row of parseJsonlLines(chunk)) {
|
||
for (const key of keys) {
|
||
if (!hasOwn(row, key))
|
||
continue;
|
||
const value = row[key];
|
||
if (typeof value !== 'string' || !NATIVE_SESSION_ID.test(value))
|
||
return { invalid: true };
|
||
values.add(value);
|
||
if (values.size > 1)
|
||
return { invalid: true };
|
||
}
|
||
}
|
||
return { value: values.values().next().value, invalid: false };
|
||
}
|
||
function nativeResumeIdentity(harness, path, chunk, pathPattern, recordKeys) {
|
||
const pathId = pathPattern.exec(path)?.[1];
|
||
if (!pathId || !NATIVE_SESSION_ID.test(pathId))
|
||
return undefined;
|
||
const recordId = transcriptSessionId(chunk, recordKeys);
|
||
if (recordId.invalid || (recordId.value !== undefined && recordId.value !== pathId))
|
||
return undefined;
|
||
return { harness, sessionId: pathId };
|
||
}
|
||
function cursorResumeIdentity(path, chunk) {
|
||
for (const pattern of CURSOR_TRANSCRIPT_PATHS) {
|
||
const identity = nativeResumeIdentity('cursor', path, chunk, pattern, ['sessionId', 'session_id', 'conversationId', 'conversation_id']);
|
||
if (identity)
|
||
return identity;
|
||
}
|
||
return undefined;
|
||
}
|
||
function anthropicStyleSession(chunk, sessionIdKeys = []) {
|
||
const systemPrompt = anthropicSessionSystemPrompt(chunk);
|
||
const sessionId = transcriptSessionId(chunk, sessionIdKeys);
|
||
return {
|
||
turns: anthropicStyleTranscript(chunk),
|
||
...(systemPrompt ? { systemPrompt } : {}),
|
||
...(!sessionId.invalid && sessionId.value ? { sessionId: sessionId.value } : {}),
|
||
};
|
||
}
|
||
export const claudeCodeAdapter = {
|
||
agent: 'claude-code',
|
||
// 仅发现规则保持目录/后缀大小写兼容;原始路径与独立resume identity验证不变。
|
||
detect: (p) => /(^|[/\\])\.claude[/\\]projects[/\\].*\.jsonl$/i.test(p) || /(^|[/\\])[^/\\]*claude[^/\\]*\.jsonl$/i.test(p),
|
||
resumeIdentityFromSource: (path, chunk) => nativeResumeIdentity('claude-code', path, chunk, CLAUDE_TRANSCRIPT_PATH, ['sessionId', 'session_id']),
|
||
parse: anthropicStyleTranscript,
|
||
parseSession: (chunk) => anthropicStyleSession(chunk, ['sessionId', 'session_id']),
|
||
};
|
||
// Verified against real cursor agent-transcripts
|
||
// (~/.cursor/projects/<proj>/agent-transcripts/<session-id>.jsonl, with newer nested layouts also accepted):
|
||
// same Anthropic content-block shape as claude-code — observed blocks are text + tool_use (no tool_result). Other
|
||
// .jsonl that live under .cursor (eval datasets: task_id/canonical_solution, no role) carry no turn and parse to [].
|
||
export const cursorAdapter = {
|
||
agent: 'cursor',
|
||
detect: (p) => /(^|[/\\])\.cursor[/\\].*\.jsonl$/i.test(p) || /(^|[/\\])[^/\\]*cursor[^/\\]*\.jsonl$/i.test(p),
|
||
resumeIdentityFromSource: cursorResumeIdentity,
|
||
parse: anthropicStyleTranscript,
|
||
parseSession: (chunk) => anthropicStyleSession(chunk, ['sessionId', 'session_id', 'conversationId', 'conversation_id']),
|
||
};
|
||
// Verified against codex-cli 0.137.0 rollout logs (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl). Each line is
|
||
// {timestamp, type, payload}. Codex writes the SAME conversation twice: a high-level `event_msg` stream
|
||
// (user_message/agent_message) and the authoritative `response_item` stream (full roles + tool calls + outputs).
|
||
// We parse ONLY `response_item` — parsing both would double-count every user/assistant turn. A tool call and its
|
||
// output share a `call_id` (codex's analogue of claude's tool_use_id), so correlateToolNames backfills the
|
||
// tool's name onto its (possibly failing) output turn — preserving tool attribution for signal extraction.
|
||
function codexTranscript(chunk) {
|
||
return correlateToolNames(parseJsonlLines(chunk).flatMap((obj) => {
|
||
if (obj['type'] !== 'response_item')
|
||
return [];
|
||
const p = obj['payload'];
|
||
if (!p)
|
||
return [];
|
||
const callId = codexToolUseId(p);
|
||
const timestamp = typeof obj['timestamp'] === 'string' ? obj['timestamp'] : undefined;
|
||
const stamp = (turns) => timestamp ? turns.map((turn) => ({ ...turn, timestamp })) : turns;
|
||
const annotate = (turns) => stamp(withSourceMetadata(turns, p));
|
||
switch (p['type']) {
|
||
case 'message': {
|
||
const raw = String(p['role'] ?? 'user');
|
||
// codex 'developer' role = injected system instruction envelope.
|
||
const role = normalizeChatRole(raw) ?? 'user';
|
||
const text = codexMessageText(p['content']);
|
||
if (!text)
|
||
return [];
|
||
const meta = role === 'system' || isMetaText(text) || CODEX_META_TAGS.some((t) => text.startsWith(t));
|
||
return annotate([{ role, text, isMeta: meta }]);
|
||
}
|
||
case 'function_call': // shell_command etc. — model-issued tool call
|
||
case 'custom_tool_call': // apply_patch etc.
|
||
return annotate([{
|
||
role: 'assistant',
|
||
text: '',
|
||
toolName: String(p['name'] ?? ''),
|
||
...(callId ? { toolUseId: String(callId) } : {}),
|
||
...(p['arguments'] !== undefined ? { toolInput: p['arguments'] } : {}),
|
||
...(p['input'] !== undefined ? { toolInput: p['input'] } : {}),
|
||
isMeta: false,
|
||
}]);
|
||
case 'function_call_output':
|
||
case 'custom_tool_call_output': {
|
||
const out = typeof p['output'] === 'string' ? p['output'] : JSON.stringify(p['output'] ?? '');
|
||
return annotate([{ role: 'tool', text: '', toolResult: out, ...(callId ? { toolUseId: String(callId) } : {}), ...(exitCodeFailed(out) ? { errorMessage: out } : {}), isMeta: false }]);
|
||
}
|
||
case 'reasoning': {
|
||
const text = stringifyReasoningSummary(p['summary']);
|
||
const reasoningSignature = typeof p['signature'] === 'string' ? p['signature'] : undefined;
|
||
const encryptedSignature = typeof p['encrypted_signature'] === 'string'
|
||
? p['encrypted_signature']
|
||
: (typeof p['encryptedSignature'] === 'string' ? p['encryptedSignature'] : undefined);
|
||
const encryptedContent = p['encrypted_content'] ?? p['encryptedContent'];
|
||
const metadata = sourceTurnMetadata(p);
|
||
if (!text && !reasoningSignature && !encryptedSignature && encryptedContent === undefined && !metadata.model && !hasUsage(metadata))
|
||
return [];
|
||
return annotate([{
|
||
role: 'assistant',
|
||
text,
|
||
reasoning: true,
|
||
...(reasoningSignature ? { reasoningSignature } : {}),
|
||
...(encryptedSignature ? { encryptedSignature } : {}),
|
||
...(encryptedContent !== undefined ? { encryptedContent } : {}),
|
||
isMeta: false,
|
||
}]);
|
||
}
|
||
default: // token_count, etc. → drop
|
||
{
|
||
const nativeTurn = codexNativeToolTurn(p);
|
||
return nativeTurn ? annotate([nativeTurn]) : [];
|
||
}
|
||
}
|
||
}));
|
||
}
|
||
// FIX-8: codex injects its system instruction as the first `developer`/`system` message (mapped to a meta
|
||
// system turn by codexTranscript). Surface that as the session-level systemPrompt as well.
|
||
function codexSession(chunk) {
|
||
const turns = codexTranscript(chunk);
|
||
const systemTurn = turns.find((turn) => turn.role === 'system' && turn.text.trim());
|
||
return {
|
||
turns,
|
||
...(systemTurn ? { systemPrompt: systemTurn.text } : {}),
|
||
};
|
||
}
|
||
export const codexAdapter = {
|
||
agent: 'codex',
|
||
// real files are rollout-<ts>-<uuid>.jsonl under .codex/sessions|archived_sessions; keep the generic codex*.jsonl too.
|
||
detect: (p) => /(^|[/\\])\.codex[/\\].*\.jsonl$/i.test(p) || /(^|[/\\])(?:rollout-|[^/\\]*codex)[^/\\]*\.jsonl$/i.test(p),
|
||
parse: codexTranscript,
|
||
parseSession: codexSession,
|
||
};
|
||
// ── Gemini CLI adapter ───────────────────────────────────────────────────────
|
||
// Verified against real Gemini CLI session files at
|
||
// ~/.gemini/tmp/<projectHash>/chats/session-<ts>-<id>.{json,jsonl}. The session
|
||
// file is ONE JSON document:
|
||
// { sessionId, projectHash, startTime, lastUpdated, messages: [...], summary }
|
||
// (We deliberately NEVER read ~/.gemini/tmp/<hash>/logs.json — it is a terse
|
||
// event log, not the conversation.)
|
||
// Each message: { id, timestamp, type: 'user'|'gemini'|'info', content: string,
|
||
// thoughts?: [{subject, description, timestamp}], tokens?: {input,output,...,total},
|
||
// model?: string, toolCalls?: [{ id, name, args, result: [{functionResponse:{...,response}}] }] }.
|
||
// - type 'user' -> user turn (content is a string or [{text}] parts array)
|
||
// - type 'gemini' -> assistant turn; `thoughts` -> a reasoning turn (subject + description),
|
||
// `toolCalls` -> tool_use turn(s) + paired tool_result turn(s).
|
||
// - type 'info' -> CLI scaffolding (e.g. update banner) -> meta, dropped from distillation.
|
||
function geminiContentToString(content) {
|
||
if (typeof content === 'string')
|
||
return content;
|
||
if (Array.isArray(content)) {
|
||
return content
|
||
.map((part) => (typeof part === 'string' ? part : (isRecord(part) && typeof part['text'] === 'string' ? part['text'] : '')))
|
||
.join('');
|
||
}
|
||
return '';
|
||
}
|
||
function geminiThoughtsText(thoughts) {
|
||
if (!Array.isArray(thoughts))
|
||
return '';
|
||
return thoughts
|
||
.map((thought) => {
|
||
if (typeof thought === 'string')
|
||
return thought;
|
||
if (!isRecord(thought))
|
||
return '';
|
||
const subject = typeof thought['subject'] === 'string' ? thought['subject'] : '';
|
||
const description = typeof thought['description'] === 'string' ? thought['description'] : '';
|
||
return [subject, description].filter(Boolean).join(': ');
|
||
})
|
||
.filter(Boolean)
|
||
.join('\n');
|
||
}
|
||
function geminiUsageFromTokens(tokens) {
|
||
if (!isRecord(tokens))
|
||
return {};
|
||
const input = finiteNumber(tokens['input'] ?? tokens['inputTokens'] ?? tokens['promptTokenCount']);
|
||
const output = finiteNumber(tokens['output'] ?? tokens['outputTokens'] ?? tokens['candidatesTokenCount']);
|
||
return {
|
||
...(input !== undefined ? { inputTokens: input } : {}),
|
||
...(output !== undefined ? { outputTokens: output } : {}),
|
||
};
|
||
}
|
||
function geminiToolCallTurns(toolCalls, model) {
|
||
if (!Array.isArray(toolCalls))
|
||
return [];
|
||
const turns = [];
|
||
for (const call of toolCalls) {
|
||
if (!isRecord(call))
|
||
continue;
|
||
const name = typeof call['name'] === 'string' ? call['name'] : '';
|
||
const id = typeof call['id'] === 'string' ? call['id'] : undefined;
|
||
turns.push({
|
||
role: 'assistant',
|
||
text: '',
|
||
...(name ? { toolName: name } : {}),
|
||
...(id ? { toolUseId: id } : {}),
|
||
...(call['args'] !== undefined ? { toolInput: call['args'] } : {}),
|
||
...(model ? { model } : {}),
|
||
isMeta: false,
|
||
});
|
||
// Gemini stores the tool result inside `result: [{ functionResponse: { name, response } }]`.
|
||
const results = Array.isArray(call['result']) ? call['result'] : [];
|
||
for (const result of results) {
|
||
if (!isRecord(result))
|
||
continue;
|
||
const fr = isRecord(result['functionResponse']) ? result['functionResponse'] : undefined;
|
||
const responseValue = fr ? fr['response'] : result['response'];
|
||
if (responseValue === undefined && fr === undefined)
|
||
continue;
|
||
const text = typeof responseValue === 'string' ? responseValue : JSON.stringify(responseValue ?? result);
|
||
turns.push({
|
||
role: 'tool',
|
||
text: '',
|
||
...(name ? { toolName: name } : {}),
|
||
...(id ? { toolUseId: id } : {}),
|
||
toolResult: text,
|
||
...(exitCodeFailed(text) ? { errorMessage: text } : {}),
|
||
isMeta: false,
|
||
});
|
||
}
|
||
}
|
||
return turns;
|
||
}
|
||
function geminiMessageToTurns(message) {
|
||
const type = String(message['type'] ?? '');
|
||
const timestamp = typeof message['timestamp'] === 'string' ? message['timestamp'] : undefined;
|
||
const model = typeof message['model'] === 'string' ? message['model'] : undefined;
|
||
const stamp = (turn) => (timestamp ? { ...turn, timestamp } : turn);
|
||
if (type === 'user') {
|
||
const text = geminiContentToString(message['content']);
|
||
return [stamp({ role: 'user', text, isMeta: isMetaText(text) })];
|
||
}
|
||
if (type === 'gemini') {
|
||
const usage = geminiUsageFromTokens(message['tokens']);
|
||
const turns = [];
|
||
const thoughts = geminiThoughtsText(message['thoughts']);
|
||
if (thoughts) {
|
||
turns.push({ role: 'assistant', text: thoughts, reasoning: true, ...(model ? { model } : {}), isMeta: false });
|
||
}
|
||
const text = geminiContentToString(message['content']);
|
||
if (text) {
|
||
turns.push({
|
||
role: 'assistant',
|
||
text,
|
||
...(model ? { model } : {}),
|
||
...(usage.inputTokens !== undefined ? { inputTokens: usage.inputTokens } : {}),
|
||
...(usage.outputTokens !== undefined ? { outputTokens: usage.outputTokens } : {}),
|
||
isMeta: isMetaText(text),
|
||
});
|
||
}
|
||
turns.push(...geminiToolCallTurns(message['toolCalls'], model));
|
||
// Attach usage to the first non-meta turn even when the assistant only produced
|
||
// thoughts/tool calls (empty content), so token accounting is not lost.
|
||
if (text === '' && (usage.inputTokens !== undefined || usage.outputTokens !== undefined)) {
|
||
const first = turns.find((turn) => turn.isMeta !== true);
|
||
if (first) {
|
||
if (usage.inputTokens !== undefined && first.inputTokens === undefined)
|
||
first.inputTokens = usage.inputTokens;
|
||
if (usage.outputTokens !== undefined && first.outputTokens === undefined)
|
||
first.outputTokens = usage.outputTokens;
|
||
}
|
||
}
|
||
return turns.map(stamp);
|
||
}
|
||
// 'info' and any other CLI scaffolding records -> meta so distillation skips them.
|
||
const infoText = geminiContentToString(message['content']);
|
||
return infoText ? [stamp({ role: 'system', text: infoText, isMeta: true })] : [];
|
||
}
|
||
function geminiSessionFromValue(value) {
|
||
if (!isRecord(value))
|
||
return null;
|
||
const messages = Array.isArray(value['messages']) ? value['messages'] : undefined;
|
||
if (!messages)
|
||
return null;
|
||
const turns = correlateToolNames(messages.filter((m) => isRecord(m)).flatMap(geminiMessageToTurns));
|
||
const sessionId = firstString(value, ['sessionId', 'session_id', 'id']);
|
||
const startedAt = firstString(value, ['startTime', 'start_time', 'lastUpdated']);
|
||
const model = turns.find((turn) => turn.model)?.model;
|
||
return {
|
||
turns,
|
||
...(sessionId ? { sessionId } : {}),
|
||
provider: 'gemini',
|
||
...(model ? { model } : {}),
|
||
...(startedAt ? { startedAt } : {}),
|
||
clientSource: 'gemini-cli',
|
||
sourceRecord: value,
|
||
rawRows: [value],
|
||
};
|
||
}
|
||
function geminiSessions(chunk) {
|
||
const trimmed = stripUtf8Bom(chunk).trim();
|
||
if (!trimmed)
|
||
return [];
|
||
// Gemini session files are a single JSON document. Tolerate JSONL-of-sessions too.
|
||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||
try {
|
||
const parsed = JSON.parse(trimmed);
|
||
const values = Array.isArray(parsed) ? parsed : [parsed];
|
||
return values
|
||
.map(geminiSessionFromValue)
|
||
.filter((session) => session !== null && session.turns.length > 0);
|
||
}
|
||
catch { /* fall through to JSONL */ }
|
||
}
|
||
return parseJsonlLines(chunk)
|
||
.map(geminiSessionFromValue)
|
||
.filter((session) => session !== null && session.turns.length > 0);
|
||
}
|
||
export const geminiAdapter = {
|
||
agent: 'gemini',
|
||
// Real path: ~/.gemini/tmp/<hash>/chats/session-<ts>-<id>.{json,jsonl}. NEVER logs.json.
|
||
detect: (p) => {
|
||
if (/(^|[/\\])logs\.json$/i.test(p))
|
||
return false;
|
||
return /(^|[/\\])\.gemini[/\\].*[/\\]chats[/\\]session-[^/\\]*\.jsonl?$/i.test(p)
|
||
|| /(^|[/\\])gemini[^/\\]*session[^/\\]*\.jsonl?$/i.test(p)
|
||
|| /(^|[/\\])session-[^/\\]*\.gemini\.jsonl?$/i.test(p);
|
||
},
|
||
parse: (chunk) => geminiSessions(chunk).flatMap((session) => session.turns),
|
||
parseSession: (chunk) => geminiSessions(chunk)[0] ?? { turns: [] },
|
||
parseSessions: geminiSessions,
|
||
};
|
||
// Antigravity persists one JSON record per event at
|
||
// ~/.gemini/{antigravity,antigravity-ide}/brain/<uuid>/.system_generated/logs/transcript.jsonl.
|
||
// Verified real records carry {type,status,source,step_index,created_at,content}; PLANNER_RESPONSE additionally
|
||
// carries plaintext `thinking` and tool_calls: [{name,args}]. The file can append a newer snapshot of an earlier
|
||
// step, so the last terminal record for each type + step_index is authoritative.
|
||
const ANTIGRAVITY_TRANSCRIPT_PATH = /(?:^|[/\\])\.gemini[/\\](?:antigravity|antigravity-ide)[/\\]brain[/\\]([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})[/\\]\.system_generated[/\\]logs[/\\]transcript\.jsonl$/i;
|
||
const ANTIGRAVITY_TERMINAL_STATUSES = new Set(['DONE', 'ERROR', 'CANCELED']);
|
||
const ANTIGRAVITY_TOOL_RESULT_TYPES = new Set([
|
||
'ASK_QUESTION',
|
||
'CODE_ACTION',
|
||
'GENERIC',
|
||
'GREP_SEARCH',
|
||
'LIST_DIRECTORY',
|
||
'RUN_COMMAND',
|
||
'SEARCH_WEB',
|
||
'VIEW_FILE',
|
||
]);
|
||
function antigravityStatus(record) {
|
||
return typeof record['status'] === 'string' ? record['status'].toUpperCase() : '';
|
||
}
|
||
function antigravityRecords(chunk) {
|
||
const selected = new Map();
|
||
parseJsonlLines(chunk).forEach((record, rowIndex) => {
|
||
const type = typeof record['type'] === 'string' ? record['type'] : '';
|
||
const stepIndex = finiteNumber(record['step_index']);
|
||
const key = type && stepIndex !== undefined ? `${type}\u0000${stepIndex}` : `row\u0000${rowIndex}`;
|
||
const current = selected.get(key);
|
||
if (!current) {
|
||
selected.set(key, { record, rowIndex });
|
||
return;
|
||
}
|
||
const currentTerminal = ANTIGRAVITY_TERMINAL_STATUSES.has(antigravityStatus(current.record));
|
||
const nextTerminal = ANTIGRAVITY_TERMINAL_STATUSES.has(antigravityStatus(record));
|
||
if (!currentTerminal || nextTerminal) {
|
||
// Snapshot rows are partial: omitted tool_calls carry forward, while an explicit value (including []) wins.
|
||
// Keep the first logical position so a late terminal snapshot cannot move a tool call after its result.
|
||
const selectedRecord = type === 'PLANNER_RESPONSE'
|
||
&& !hasOwn(record, 'tool_calls')
|
||
&& hasOwn(current.record, 'tool_calls')
|
||
? { ...record, tool_calls: current.record['tool_calls'] }
|
||
: record;
|
||
selected.set(key, { record: selectedRecord, rowIndex: current.rowIndex });
|
||
}
|
||
});
|
||
return [...selected.values()]
|
||
.sort((a, b) => a.rowIndex - b.rowIndex)
|
||
.map(({ record }) => record);
|
||
}
|
||
function antigravityRecordMetadata(record) {
|
||
const metadata = {};
|
||
for (const key of ['type', 'status', 'source', 'step_index', 'created_at']) {
|
||
if (record[key] !== undefined)
|
||
metadata[key] = record[key];
|
||
}
|
||
return metadata;
|
||
}
|
||
function antigravityRecordText(record, key = 'content') {
|
||
return typeof record[key] === 'string' ? record[key] : '';
|
||
}
|
||
function antigravityFailure(record) {
|
||
const type = typeof record['type'] === 'string' ? record['type'] : '';
|
||
const status = antigravityStatus(record);
|
||
if (type !== 'ERROR_MESSAGE' && status !== 'ERROR' && status !== 'CANCELED')
|
||
return undefined;
|
||
return antigravityRecordText(record, 'error')
|
||
|| antigravityRecordText(record)
|
||
|| `Antigravity ${type || 'record'} ${status || 'ERROR'}`;
|
||
}
|
||
function annotateAntigravityTurns(record, turns) {
|
||
const timestamp = typeof record['created_at'] === 'string' ? record['created_at'] : undefined;
|
||
const metadata = antigravityRecordMetadata(record);
|
||
const errorMessage = antigravityFailure(record);
|
||
return turns.map((turn) => ({
|
||
...turn,
|
||
...(timestamp ? { timestamp } : {}),
|
||
...(errorMessage && !turn.errorMessage ? { errorMessage } : {}),
|
||
metadata,
|
||
sourceRecord: record,
|
||
rawRow: record,
|
||
}));
|
||
}
|
||
function antigravityResultTypeForTool(toolName) {
|
||
switch (toolName.toLowerCase()) {
|
||
case 'ask_question': return 'ASK_QUESTION';
|
||
case 'grep_search': return 'GREP_SEARCH';
|
||
case 'list_dir': return 'LIST_DIRECTORY';
|
||
case 'run_command': return 'RUN_COMMAND';
|
||
case 'search_web': return 'SEARCH_WEB';
|
||
case 'view_file': return 'VIEW_FILE';
|
||
case 'multi_replace_file_content':
|
||
case 'replace_file_content':
|
||
case 'write_to_file': return 'CODE_ACTION';
|
||
default: return 'GENERIC';
|
||
}
|
||
}
|
||
function antigravityFallbackToolName(resultType) {
|
||
switch (resultType) {
|
||
case 'ASK_QUESTION': return 'ask_question';
|
||
case 'CODE_ACTION': return 'code_action';
|
||
case 'GREP_SEARCH': return 'grep_search';
|
||
case 'LIST_DIRECTORY': return 'list_dir';
|
||
case 'RUN_COMMAND': return 'run_command';
|
||
case 'SEARCH_WEB': return 'search_web';
|
||
case 'VIEW_FILE': return 'view_file';
|
||
default: return 'generic';
|
||
}
|
||
}
|
||
function antigravityTranscriptFromRecords(records) {
|
||
const turns = [];
|
||
const pendingTools = [];
|
||
records.forEach((record, rowIndex) => {
|
||
const type = typeof record['type'] === 'string' ? record['type'] : '';
|
||
const content = antigravityRecordText(record);
|
||
const recordTurns = [];
|
||
if (type === 'USER_INPUT') {
|
||
recordTurns.push({ role: 'user', text: content, isMeta: isMetaText(content) });
|
||
}
|
||
else if (type === 'PLANNER_RESPONSE') {
|
||
const thinking = antigravityRecordText(record, 'thinking');
|
||
if (thinking)
|
||
recordTurns.push({ role: 'assistant', text: thinking, reasoning: true, isMeta: false });
|
||
if (content)
|
||
recordTurns.push({ role: 'assistant', text: content, isMeta: isMetaText(content) });
|
||
const toolCalls = Array.isArray(record['tool_calls']) ? record['tool_calls'] : [];
|
||
toolCalls.forEach((value, ordinal) => {
|
||
if (!isRecord(value))
|
||
return;
|
||
const toolName = typeof value['name'] === 'string' && value['name'] ? value['name'] : 'antigravity_tool';
|
||
const stepIndex = finiteNumber(record['step_index']);
|
||
const toolUseId = `antigravity:${stepIndex ?? `row-${rowIndex}`}:${ordinal}`;
|
||
pendingTools.push({ toolName, toolUseId, resultType: antigravityResultTypeForTool(toolName) });
|
||
recordTurns.push({
|
||
role: 'assistant',
|
||
text: '',
|
||
toolName,
|
||
toolUseId,
|
||
...(hasOwn(value, 'args') ? { toolInput: value['args'] } : {}),
|
||
isMeta: false,
|
||
});
|
||
});
|
||
}
|
||
else if (ANTIGRAVITY_TOOL_RESULT_TYPES.has(type)) {
|
||
const pendingIndex = pendingTools.findIndex((pending) => pending.resultType === type);
|
||
const pending = pendingIndex >= 0 ? pendingTools.splice(pendingIndex, 1)[0] : undefined;
|
||
recordTurns.push({
|
||
role: 'tool',
|
||
text: '',
|
||
toolName: pending?.toolName ?? antigravityFallbackToolName(type),
|
||
...(pending ? { toolUseId: pending.toolUseId } : {}),
|
||
toolResult: content,
|
||
isMeta: false,
|
||
});
|
||
}
|
||
else if (type === 'ERROR_MESSAGE') {
|
||
const errorMessage = antigravityFailure(record);
|
||
recordTurns.push({ role: 'system', text: content || errorMessage, errorMessage, isMeta: true });
|
||
}
|
||
else if (type === 'SYSTEM_MESSAGE' || type === 'CONVERSATION_HISTORY' || type === 'CHECKPOINT') {
|
||
recordTurns.push({ role: 'system', text: content, isMeta: true });
|
||
}
|
||
turns.push(...annotateAntigravityTurns(record, recordTurns));
|
||
});
|
||
return turns;
|
||
}
|
||
function antigravitySession(chunk) {
|
||
const records = antigravityRecords(chunk);
|
||
const startedAt = records.find((record) => typeof record['created_at'] === 'string')?.['created_at'];
|
||
return {
|
||
turns: antigravityTranscriptFromRecords(records),
|
||
provider: 'antigravity',
|
||
clientSource: 'antigravity',
|
||
...(typeof startedAt === 'string' ? { startedAt } : {}),
|
||
rawRows: records,
|
||
};
|
||
}
|
||
export const antigravityAdapter = {
|
||
agent: 'antigravity',
|
||
detect: (path) => ANTIGRAVITY_TRANSCRIPT_PATH.test(path),
|
||
sessionIdFromPath: (path) => ANTIGRAVITY_TRANSCRIPT_PATH.exec(path)?.[1],
|
||
parse: (chunk) => antigravitySession(chunk).turns,
|
||
parseSession: antigravitySession,
|
||
parseSessions: (chunk) => {
|
||
const session = antigravitySession(chunk);
|
||
return session.turns.length > 0 ? [session] : [];
|
||
},
|
||
};
|
||
// ── generic chat-transcript adapter ──────────────────────────────────────────
|
||
// The per-tool adapters above are gated on a REAL private-log golden (a guessed private schema silently yields 0
|
||
// turns). This one is different ON PURPOSE: it targets the DOCUMENTED, stable interchange format — OpenAI
|
||
// chat-completions / Anthropic messages — not a tool's private log. The "real sample" it is verified against is
|
||
// the published schema itself (golden tests), so it is not a guess. The payoff: any agent that can dump its
|
||
// conversation as standard messages joins the self-learning loop WITHOUT a bespoke adapter (this is the intake
|
||
// for the mcp-generic / http-agent runtimes). Registered LAST so any tool-specific path still wins; matched by an
|
||
// explicit on-disk intake convention: name the transcript `<name>.chat|messages|transcript.json[l]`.
|
||
/** Flatten a message `content` (string | OpenAI text-parts | mixed) to a plain string — for tool messages. */
|
||
function chatContentToString(content) {
|
||
if (typeof content === 'string')
|
||
return content;
|
||
if (!Array.isArray(content))
|
||
return '';
|
||
return content
|
||
.map((p) => (typeof p === 'string' ? p : (p && typeof p === 'object' && typeof p['text'] === 'string' ? p['text'] : '')))
|
||
.join('');
|
||
}
|
||
function hasGenericTurnPayload(turn) {
|
||
return Boolean(turn.text
|
||
|| turn.toolName
|
||
|| turn.toolInput !== undefined
|
||
|| turn.toolResult !== undefined
|
||
|| turn.errorMessage
|
||
|| turn.reasoning === true
|
||
|| turn.reasoningSignature
|
||
|| turn.encryptedSignature
|
||
|| turn.encryptedContent !== undefined);
|
||
}
|
||
/** One standard chat message → turns. Handles string/parts/Anthropic-block content (via extractContent), the
|
||
* OpenAI `role:'tool'` result message, and an assistant `tool_calls` array (each → a tool_use-style turn so
|
||
* correlateToolNames can attribute the matching tool result). A record without a known role is not a message. */
|
||
function chatMessageToTurns(obj) {
|
||
const rawRole = obj['role'];
|
||
if (typeof rawRole !== 'string')
|
||
return [];
|
||
const role = normalizeChatRole(rawRole);
|
||
if (!role)
|
||
return [];
|
||
if (role === 'tool') {
|
||
const text = chatContentToString(obj['content']);
|
||
// A failed tool result must surface on errorMessage (not just toolResult) — extractSignals mines strong tool
|
||
// errors from errorMessage and skips empty-text turns, so without this a failure produces no signal. Standard
|
||
// messages have no universal error flag, so we honor an explicit is_error/isError when the producer sets one,
|
||
// plus the shared shell `Exit code: N` convention (same as the codex adapter), and never guess from free text.
|
||
const flaggedError = obj['is_error'] === true || obj['isError'] === true;
|
||
const failed = flaggedError || exitCodeFailed(text);
|
||
return withSourceMetadata([{
|
||
role: 'tool', text: '', toolResult: text,
|
||
...(typeof obj['name'] === 'string' ? { toolName: obj['name'] } : {}),
|
||
...(typeof obj['tool_call_id'] === 'string' ? { toolUseId: obj['tool_call_id'] } : {}),
|
||
...(failed ? { errorMessage: text } : {}),
|
||
isMeta: false,
|
||
}], obj);
|
||
}
|
||
// A system message is an instruction envelope, not agent narration — mark it meta so distillation skips it
|
||
// (mirrors the codex adapter's developer/system handling).
|
||
const content = extractContent(role, obj['content']).filter(hasGenericTurnPayload);
|
||
const turns = role === 'system' ? content.map((t) => ({ ...t, isMeta: true })) : [...content];
|
||
const reasoningContent = firstString(obj, ['reasoning_content', 'reasoningContent', 'thinking']);
|
||
if (role === 'assistant' && reasoningContent) {
|
||
turns.unshift({
|
||
role: 'assistant',
|
||
text: reasoningContent,
|
||
reasoning: true,
|
||
...(firstString(obj, ['reasoning_signature', 'reasoningSignature', 'signature']) ? { reasoningSignature: firstString(obj, ['reasoning_signature', 'reasoningSignature', 'signature']) } : {}),
|
||
...(firstString(obj, ['encrypted_signature', 'encryptedSignature']) ? { encryptedSignature: firstString(obj, ['encrypted_signature', 'encryptedSignature']) } : {}),
|
||
...(obj['encrypted_content'] !== undefined ? { encryptedContent: obj['encrypted_content'] } : {}),
|
||
...(obj['encryptedContent'] !== undefined ? { encryptedContent: obj['encryptedContent'] } : {}),
|
||
isMeta: false,
|
||
});
|
||
}
|
||
const toolCalls = obj['tool_calls'];
|
||
if (role === 'assistant' && Array.isArray(toolCalls)) {
|
||
for (const tc of toolCalls) {
|
||
if (!tc || typeof tc !== 'object')
|
||
continue;
|
||
const c = tc;
|
||
const fn = (c['function'] && typeof c['function'] === 'object' ? c['function'] : {});
|
||
const name = typeof fn['name'] === 'string' ? fn['name'] : (typeof c['name'] === 'string' ? c['name'] : '');
|
||
turns.push({
|
||
role: 'assistant',
|
||
text: '',
|
||
toolName: name,
|
||
...(typeof c['id'] === 'string' ? { toolUseId: c['id'] } : {}),
|
||
...(fn['arguments'] !== undefined ? { toolInput: fn['arguments'] } : {}),
|
||
isMeta: false,
|
||
});
|
||
}
|
||
}
|
||
return withSourceMetadata(turns, obj);
|
||
}
|
||
function genericChatMetadataFromRecord(record) {
|
||
const meta = parsedRecord(record['meta']) ?? {};
|
||
const metadata = parsedRecord(record['metadata']) ?? {};
|
||
const metadataSources = [record, metadata, meta];
|
||
const request = isRecord(parseJsonish(record['request'])) ? parseJsonish(record['request']) : {};
|
||
const requestBody = isRecord(parseJsonish(record['request_body'] ?? record['requestBody']))
|
||
? parseJsonish(record['request_body'] ?? record['requestBody'])
|
||
: {};
|
||
const response = isRecord(parseJsonish(record['response'])) ? parseJsonish(record['response']) : {};
|
||
const responseBody = isRecord(parseJsonish(record['response_body'] ?? record['responseBody']))
|
||
? parseJsonish(record['response_body'] ?? record['responseBody'])
|
||
: {};
|
||
const responseData = isRecord(parseJsonish(response['response_data'] ?? response['responseData']))
|
||
? parseJsonish(response['response_data'] ?? response['responseData'])
|
||
: {};
|
||
const responseBodyData = isRecord(parseJsonish(responseBody['response_data'] ?? responseBody['responseData']))
|
||
? parseJsonish(responseBody['response_data'] ?? responseBody['responseData'])
|
||
: {};
|
||
const sessionId = firstString(record, ['trajectory_id', 'trajectoryId', 'session_id', 'sessionId', 'task_id', 'taskId', 'id']);
|
||
const provider = firstStringFrom(metadataSources, ['provider', 'wire_api', 'wireApi', 'upstream', 'source', 'client_source', 'clientSource']);
|
||
const model = firstStringFrom(metadataSources, ['model', 'model_name', 'modelName', 'chosen_model', 'chosenModel'])
|
||
?? firstString(request, ['model', 'chosen_model', 'chosenModel'])
|
||
?? firstString(requestBody, ['model', 'chosen_model', 'chosenModel'])
|
||
?? firstString(responseData, ['model', 'chosen_model', 'chosenModel'])
|
||
?? firstString(responseBodyData, ['model', 'chosen_model', 'chosenModel'])
|
||
?? firstString(responseBody, ['model', 'chosen_model', 'chosenModel'])
|
||
?? firstString(response, ['model', 'chosen_model', 'chosenModel']);
|
||
const startedAt = firstString(record, ['created_at', 'createdAt', 'timestamp', 'request_time', 'requestTime'])
|
||
?? firstString(meta, ['created_at', 'createdAt', 'create_time', 'createTime', 'timestamp', 'request_time', 'requestTime']);
|
||
const requestTime = firstString(record, ['request_time', 'requestTime'])
|
||
?? firstString(meta, ['request_time', 'requestTime']);
|
||
const responseTime = firstString(record, ['response_time', 'responseTime'])
|
||
?? firstString(meta, ['response_time', 'responseTime']);
|
||
const clientSource = firstStringFrom(metadataSources, ['client_source', 'clientSource']);
|
||
const systemPrompt = firstStringFrom(metadataSources, ['system_prompt', 'systemPrompt']);
|
||
const tools = record['tools'] ?? request['tools'] ?? requestBody['tools'];
|
||
const nativeCall = nativeCallFromRecord(record, { provider, startedAt, requestTime, responseTime });
|
||
const metadataValue = record['metadata'] !== undefined ? parseJsonish(record['metadata']) : (record['meta'] !== undefined ? parseJsonish(record['meta']) : undefined);
|
||
const usage = firstPresentFrom(metadataSources, ['usage']);
|
||
const risk = firstPresentFrom(metadataSources, ['risk']);
|
||
const fidelity = firstPresentFrom(metadataSources, ['fidelity']);
|
||
const confidentiality = firstPresentFrom(metadataSources, ['confidentiality']);
|
||
return {
|
||
...(sessionId ? { sessionId } : {}),
|
||
...(provider ? { provider } : {}),
|
||
...(model ? { model } : {}),
|
||
...(tools !== undefined ? { tools } : {}),
|
||
...(startedAt ? { startedAt } : {}),
|
||
...(clientSource ? { clientSource } : {}),
|
||
...(systemPrompt ? { systemPrompt } : {}),
|
||
...(metadataValue !== undefined ? { metadata: metadataValue } : {}),
|
||
...(usage !== undefined ? { usage } : {}),
|
||
...(risk !== undefined ? { risk } : {}),
|
||
...(fidelity !== undefined ? { fidelity } : {}),
|
||
...(confidentiality !== undefined ? { confidentiality } : {}),
|
||
sourceRecord: record,
|
||
rawRows: [record],
|
||
...(nativeCall ? { nativeCalls: [nativeCall] } : {}),
|
||
};
|
||
}
|
||
function nativeCallFromRecord(record, metadata) {
|
||
const hasRequest = hasOwn(record, 'request') || hasOwn(record, 'request_body') || hasOwn(record, 'requestBody');
|
||
const hasResponse = hasOwn(record, 'response') || hasOwn(record, 'response_body') || hasOwn(record, 'responseBody');
|
||
const requestHeaders = firstPresent(record, ['request_headers', 'requestHeaders']);
|
||
const responseHeaders = firstPresent(record, ['response_headers', 'responseHeaders']);
|
||
const transport = firstPresent(record, ['transport']);
|
||
const transportMetadata = firstPresent(record, ['transport_metadata', 'transportMetadata']);
|
||
const ttfbMs = finiteNumber(record['ttfb_ms'] ?? record['ttfbMs']);
|
||
if (!hasRequest && !hasResponse && requestHeaders === undefined && responseHeaders === undefined && transport === undefined && transportMetadata === undefined && ttfbMs === undefined)
|
||
return undefined;
|
||
const requestBody = parseJsonish(firstPresent(record, ['request', 'request_body', 'requestBody']));
|
||
const responseBody = parseJsonish(firstPresent(record, ['response', 'response_body', 'responseBody']));
|
||
const metadataValue = record['metadata'] !== undefined ? parseJsonish(record['metadata']) : (record['meta'] !== undefined ? parseJsonish(record['meta']) : undefined);
|
||
const meta = parsedRecord(record['meta']) ?? {};
|
||
const normalizedMetadata = parsedRecord(record['metadata']) ?? {};
|
||
const metadataSources = [record, normalizedMetadata, meta];
|
||
const usage = firstPresentFrom(metadataSources, ['usage']);
|
||
const risk = firstPresentFrom(metadataSources, ['risk']);
|
||
const fidelity = firstPresentFrom(metadataSources, ['fidelity']);
|
||
const confidentiality = firstPresentFrom(metadataSources, ['confidentiality']);
|
||
return {
|
||
...(metadata.provider ? { provider: metadata.provider } : {}),
|
||
...(metadata.startedAt ? { timestamp: metadata.startedAt } : {}),
|
||
...(metadata.requestTime ? { request_time: metadata.requestTime } : {}),
|
||
...(metadata.responseTime ? { response_time: metadata.responseTime } : {}),
|
||
...(ttfbMs !== undefined ? { ttfb_ms: ttfbMs } : {}),
|
||
...(requestHeaders !== undefined ? { request_headers: parseJsonish(requestHeaders) } : {}),
|
||
...(responseHeaders !== undefined ? { response_headers: parseJsonish(responseHeaders) } : {}),
|
||
...(transport !== undefined ? { transport: parseJsonish(transport) } : {}),
|
||
...(transportMetadata !== undefined ? { transport_metadata: parseJsonish(transportMetadata) } : {}),
|
||
...(hasRequest ? { request_body: requestBody } : {}),
|
||
...(hasResponse ? { response_body: responseBody } : {}),
|
||
...(metadataValue !== undefined ? { metadata: metadataValue } : {}),
|
||
...(usage !== undefined ? { usage } : {}),
|
||
...(risk !== undefined ? { risk } : {}),
|
||
...(fidelity !== undefined ? { fidelity } : {}),
|
||
...(confidentiality !== undefined ? { confidentiality } : {}),
|
||
sourceRecord: record,
|
||
rawRow: record,
|
||
};
|
||
}
|
||
function responseMessageFromBody(value) {
|
||
const body = parseJsonish(value);
|
||
if (!isRecord(body))
|
||
return [];
|
||
if (isRecord(body['message']))
|
||
return genericChatRecordsFromValue(body['message']).records;
|
||
if (Array.isArray(body['choices'])) {
|
||
return body['choices'].flatMap((choice) => {
|
||
if (!isRecord(choice))
|
||
return [];
|
||
if (isRecord(choice['message'])) {
|
||
const message = { ...choice['message'] };
|
||
if (body['model'] !== undefined && message['model'] === undefined)
|
||
message['model'] = body['model'];
|
||
if (body['usage'] !== undefined && message['usage'] === undefined)
|
||
message['usage'] = body['usage'];
|
||
return genericChatRecordsFromValue(message).records;
|
||
}
|
||
return [];
|
||
});
|
||
}
|
||
if (typeof body['role'] === 'string')
|
||
return genericChatRecordsFromValue(body).records;
|
||
if (body['content'] !== undefined || body['tool_calls'] !== undefined || body['usage'] !== undefined) {
|
||
return [{
|
||
role: 'assistant',
|
||
...(body['content'] !== undefined ? { content: body['content'] } : {}),
|
||
...(body['output'] !== undefined ? { content: body['output'] } : {}),
|
||
...(body['tool_calls'] !== undefined ? { tool_calls: body['tool_calls'] } : {}),
|
||
...(body['model'] !== undefined ? { model: body['model'] } : {}),
|
||
...(body['usage'] !== undefined ? { usage: body['usage'] } : {}),
|
||
}];
|
||
}
|
||
return [];
|
||
}
|
||
function candidateMessages(value) {
|
||
if (Array.isArray(value))
|
||
return value.flatMap(candidateMessages);
|
||
if (isRecord(value))
|
||
return genericChatRecordsFromValue(value).records;
|
||
return [];
|
||
}
|
||
function requestResponseRecordsFromValue(value) {
|
||
const request = parseJsonish(value['request'] ?? value['request_body'] ?? value['requestBody']);
|
||
const responseEnvelope = parseJsonish(value['response'] ?? value['response_body'] ?? value['responseBody']);
|
||
const response = isRecord(responseEnvelope) && (responseEnvelope['response_data'] !== undefined || responseEnvelope['responseData'] !== undefined)
|
||
? parseJsonish(responseEnvelope['response_data'] ?? responseEnvelope['responseData'])
|
||
: responseEnvelope;
|
||
const records = [];
|
||
if (isRecord(request)) {
|
||
if (typeof request['instructions'] === 'string')
|
||
records.push({ role: 'system', content: request['instructions'] });
|
||
if (typeof request['system_prompt'] === 'string')
|
||
records.push({ role: 'system', content: request['system_prompt'] });
|
||
if (typeof request['systemPrompt'] === 'string')
|
||
records.push({ role: 'system', content: request['systemPrompt'] });
|
||
records.push(...genericChatRecordsFromValue(request).records);
|
||
}
|
||
records.push(...responseMessageFromBody(response));
|
||
return records;
|
||
}
|
||
function systemPromptRecord(metadata) {
|
||
return metadata.systemPrompt ? [{ role: 'system', content: metadata.systemPrompt }] : [];
|
||
}
|
||
function isSessionWrapperRecord(value) {
|
||
return Array.isArray(value['messages'])
|
||
|| Array.isArray(value['turns'])
|
||
|| Array.isArray(value['prompt'])
|
||
|| value['request'] !== undefined
|
||
|| value['request_body'] !== undefined
|
||
|| value['requestBody'] !== undefined
|
||
|| value['response'] !== undefined
|
||
|| value['response_body'] !== undefined
|
||
|| value['responseBody'] !== undefined;
|
||
}
|
||
function genericChatRecordsFromValue(value) {
|
||
if (Array.isArray(value)) {
|
||
return value.reduce((acc, item) => {
|
||
const parsed = genericChatRecordsFromValue(item);
|
||
acc.records.push(...parsed.records);
|
||
acc.metadata = mergeSessionMetadata(acc.metadata, parsed.metadata);
|
||
return acc;
|
||
}, { records: [], metadata: {} });
|
||
}
|
||
if (!isRecord(value))
|
||
return { records: [], metadata: {} };
|
||
const metadata = genericChatMetadataFromRecord(value);
|
||
if (Array.isArray(value['prompt'])) {
|
||
return {
|
||
records: systemPromptRecord(metadata).concat(value['prompt']
|
||
.filter((x) => isRecord(x))
|
||
.concat(candidateMessages(value['candidates']))),
|
||
metadata,
|
||
};
|
||
}
|
||
const wrapped = value['messages'] ?? value['turns'];
|
||
if (Array.isArray(wrapped)) {
|
||
return {
|
||
records: systemPromptRecord(metadata).concat(wrapped.filter((x) => isRecord(x))),
|
||
metadata,
|
||
};
|
||
}
|
||
const requestResponseRecords = requestResponseRecordsFromValue(value);
|
||
if (requestResponseRecords.length > 0)
|
||
return { records: requestResponseRecords, metadata };
|
||
if (isRecord(value['message'])) {
|
||
const parsed = genericChatRecordsFromValue(value['message']);
|
||
return { records: parsed.records, metadata: mergeSessionMetadata(metadata, parsed.metadata) };
|
||
}
|
||
return typeof value['role'] === 'string' ? { records: [value], metadata: {} } : { records: [], metadata };
|
||
}
|
||
function applyGenericSessionMetadata(turns, metadata) {
|
||
if (!metadata.model
|
||
&& metadata.metadata === undefined
|
||
&& metadata.usage === undefined
|
||
&& metadata.risk === undefined
|
||
&& metadata.fidelity === undefined
|
||
&& metadata.confidentiality === undefined
|
||
&& metadata.sourceRecord === undefined)
|
||
return turns;
|
||
let sessionScopedApplied = false;
|
||
const hasNonMetaTurn = turns.some((turn) => turn.isMeta !== true);
|
||
return turns.map((turn) => {
|
||
const out = {
|
||
...turn,
|
||
...(!turn.model && metadata.model ? { model: metadata.model } : {}),
|
||
};
|
||
if (!sessionScopedApplied && (!hasNonMetaTurn || turn.isMeta !== true)) {
|
||
if (out.metadata === undefined && metadata.metadata !== undefined)
|
||
out.metadata = metadata.metadata;
|
||
if (out.usage === undefined && metadata.usage !== undefined)
|
||
out.usage = metadata.usage;
|
||
if (out.risk === undefined && metadata.risk !== undefined)
|
||
out.risk = metadata.risk;
|
||
if (out.fidelity === undefined && metadata.fidelity !== undefined)
|
||
out.fidelity = metadata.fidelity;
|
||
if (out.confidentiality === undefined && metadata.confidentiality !== undefined)
|
||
out.confidentiality = metadata.confidentiality;
|
||
if (out.sourceRecord === undefined && metadata.sourceRecord !== undefined)
|
||
out.sourceRecord = metadata.sourceRecord;
|
||
if (out.rawRow === undefined && metadata.sourceRecord !== undefined)
|
||
out.rawRow = metadata.sourceRecord;
|
||
sessionScopedApplied = true;
|
||
}
|
||
return out;
|
||
});
|
||
}
|
||
/** Accept JSONL (one message per line), a bare JSON array of messages, a { messages|turns: [...] } wrapper (the
|
||
* chat-completions request-body shape), or a SINGLE message object (incl. pretty-printed multi-line — which is
|
||
* not valid JSONL, so it must be handled here, not fall through). */
|
||
function genericChatRecords(chunk) {
|
||
const trimmed = stripUtf8Bom(chunk).trim();
|
||
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
|
||
try {
|
||
const parsed = JSON.parse(trimmed);
|
||
return genericChatRecordsFromValue(parsed);
|
||
}
|
||
catch { /* not a single JSON doc → treat as JSONL below */ }
|
||
}
|
||
return parseJsonlLines(chunk).reduce((acc, row) => {
|
||
const parsed = genericChatRecordsFromValue(row);
|
||
acc.records.push(...parsed.records);
|
||
acc.metadata = mergeSessionMetadata(acc.metadata, parsed.metadata);
|
||
return acc;
|
||
}, { records: [], metadata: {} });
|
||
}
|
||
function genericChatSessionFromParseResult(parsed) {
|
||
const turns = correlateToolNames(parsed.records.flatMap(chatMessageToTurns));
|
||
return {
|
||
turns: applyGenericSessionMetadata(turns, parsed.metadata),
|
||
...parsed.metadata,
|
||
};
|
||
}
|
||
function genericChatSession(chunk) {
|
||
return genericChatSessionFromParseResult(genericChatRecords(chunk));
|
||
}
|
||
function genericChatSessionsFromValue(value) {
|
||
if (Array.isArray(value) && value.every((item) => isRecord(item) && isSessionWrapperRecord(item))) {
|
||
return value
|
||
.map((item) => genericChatSessionFromParseResult(genericChatRecordsFromValue(item)))
|
||
.filter((session) => session.turns.length > 0);
|
||
}
|
||
const session = genericChatSessionFromParseResult(genericChatRecordsFromValue(value));
|
||
return session.turns.length > 0 ? [session] : [];
|
||
}
|
||
function genericChatSessions(chunk) {
|
||
const trimmed = stripUtf8Bom(chunk).trim();
|
||
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
|
||
try {
|
||
const parsed = JSON.parse(trimmed);
|
||
return genericChatSessionsFromValue(parsed);
|
||
}
|
||
catch { /* not a single JSON doc → treat as JSONL below */ }
|
||
}
|
||
const rows = parseJsonlLines(chunk);
|
||
if (rows.length > 0 && rows.every(isSessionWrapperRecord)) {
|
||
return rows
|
||
.map((row) => genericChatSessionFromParseResult(genericChatRecordsFromValue(row)))
|
||
.filter((session) => session.turns.length > 0);
|
||
}
|
||
const session = genericChatSessionFromParseResult(rows.reduce((acc, row) => {
|
||
const parsed = genericChatRecordsFromValue(row);
|
||
acc.records.push(...parsed.records);
|
||
acc.metadata = mergeSessionMetadata(acc.metadata, parsed.metadata);
|
||
return acc;
|
||
}, { records: [], metadata: {} }));
|
||
return session.turns.length > 0 ? [session] : [];
|
||
}
|
||
export const genericChatAdapter = {
|
||
agent: 'generic-chat',
|
||
detect: (p) => /(^|[/\\])[^/\\]*\.(chat|messages|transcript)\.jsonl?$/i.test(p),
|
||
parse: (chunk) => genericChatSessions(chunk).flatMap((session) => session.turns),
|
||
parseSession: genericChatSession,
|
||
parseSessions: genericChatSessions,
|
||
};
|
||
// ── Kimi (wire.jsonl) adapter ────────────────────────────────────────────────
|
||
// Kimi CLI persists the raw wire conversation to `wire.jsonl`: one JSON object per line, each carrying the FULL
|
||
// `messages` array (OpenAI chat-completions shape) with PLAINTEXT thinking directly captured (no encrypted/signed
|
||
// reasoning — Kimi exposes it). No real local sample was available, so this is built against that documented
|
||
// "one record per line, full messages + plaintext thinking" shape and reuses the generic chat message decoder
|
||
// (which already understands messages[], reasoning_content/thinking, tool_calls, and the role:'tool' result).
|
||
// When the same file carries multiple lines, the LAST full messages snapshot wins (it is the most complete turn
|
||
// log), avoiding double-counting earlier partial snapshots.
|
||
function kimiSessions(chunk) {
|
||
const rows = parseJsonlLines(chunk);
|
||
// Pick the row with the most messages as the authoritative conversation snapshot; fall back to all rows merged.
|
||
let best;
|
||
let bestLen = -1;
|
||
for (const row of rows) {
|
||
const messages = row['messages'];
|
||
const len = Array.isArray(messages) ? messages.length : -1;
|
||
if (len > bestLen) {
|
||
bestLen = len;
|
||
best = row;
|
||
}
|
||
}
|
||
const source = best ?? rows;
|
||
const sessions = genericChatSessionsFromValue(source);
|
||
return sessions.map((session) => ({ ...session, provider: session.provider ?? 'kimi', clientSource: session.clientSource ?? 'kimi' }));
|
||
}
|
||
export const kimiAdapter = {
|
||
agent: 'kimi',
|
||
detect: (p) => /(^|[/\\])wire\.jsonl$/i.test(p) || /(^|[/\\])kimi[^/\\]*\.jsonl$/i.test(p),
|
||
parse: (chunk) => kimiSessions(chunk).flatMap((session) => session.turns),
|
||
parseSession: (chunk) => kimiSessions(chunk)[0] ?? { turns: [] },
|
||
parseSessions: kimiSessions,
|
||
};
|
||
// ── Removed runtime adapters ─────────────────────────────────────────────────
|
||
// Kiro and OpenCode transcript adapters were removed in V2: no sanitized real-log golden fixture exists for
|
||
// either runtime, so shipping a guessed schema would silently yield 0 turns on real logs while appearing
|
||
// supported. The MCP config installer (setup-hooks --runtime=kiro|opencode) remains functional for injection.
|
||
//
|
||
// Sentinels live in REMOVED_ADAPTERS (NOT in ADAPTERS) so content probes that walk ADAPTERS and call
|
||
// parse() cannot throw "kiro removed" on unrelated JSON (trajectoryExport.adapterForContent).
|
||
// Path-based selection uses adapterForPath, which checks REMOVED_ADAPTERS first; matching paths then fail
|
||
// closed on parse with an actionable error.
|
||
const REMOVED_ADAPTER_MESSAGE = (agent) => `${agent} transcript adapter was removed from evolver v2 — no sanitized real-log golden fixture exists. `
|
||
+ `Re-add with a verified golden test (see adapters.test.ts). `
|
||
+ `MCP injection (setup-hooks --runtime=${agent}) is still supported; only transcript ingest is blocked.`;
|
||
function removedAdapter(agent, detect) {
|
||
return {
|
||
agent,
|
||
detect,
|
||
parse: () => { throw new Error(REMOVED_ADAPTER_MESSAGE(agent)); },
|
||
parseSession: () => { throw new Error(REMOVED_ADAPTER_MESSAGE(agent)); },
|
||
parseSessions: () => { throw new Error(REMOVED_ADAPTER_MESSAGE(agent)); },
|
||
};
|
||
}
|
||
export const kiroRemovedAdapter = removedAdapter('kiro', (p) => /(^|[/\\])\.kiro([/\\]|$)/i.test(p) || /(^|[/\\])kiro[^/\\]*\.jsonl?$/i.test(p));
|
||
export const opencodeRemovedAdapter = removedAdapter('opencode', (p) => /(^|[/\\])\.opencode([/\\]|$)/i.test(p) || /(^|[/\\])opencode[^/\\]*\.jsonl?$/i.test(p));
|
||
/** Explicitly removed transcript adapters — path fail-closed only. Never walk this list for content probes. */
|
||
export const REMOVED_ADAPTERS = [kiroRemovedAdapter, opencodeRemovedAdapter];
|
||
// Only verified adapters are registered for content/path discovery. Removed runtimes are listed in
|
||
// REMOVED_ADAPTERS and selected only via adapterForPath path match (then parse throws).
|
||
// genericChatAdapter is LAST so any tool-specific path (claude/cursor/codex/gemini/antigravity/kimi) resolves first.
|
||
export const ADAPTERS = [claudeCodeAdapter, codexAdapter, cursorAdapter, geminiAdapter, antigravityAdapter, kimiAdapter, genericChatAdapter];
|
||
export function adapterForPath(path) {
|
||
const removed = REMOVED_ADAPTERS.find((a) => a.detect(path));
|
||
if (removed)
|
||
return removed;
|
||
return ADAPTERS.find((a) => a.detect(path));
|
||
}
|
||
/** True when the agent id has an explicit removed transcript sentinel (see REMOVED_ADAPTERS). */
|
||
export function isRemovedAdapter(agent) {
|
||
return REMOVED_ADAPTERS.some((a) => a.agent === agent);
|
||
} |