diff --git a/build.mjs b/build.mjs index 1291e47..416f366 100644 --- a/build.mjs +++ b/build.mjs @@ -49,6 +49,7 @@ const libraryEntries = [ { in: "src/services/capture.ts", out: "dist/services/capture.js" }, { in: "src/services/context.ts", out: "dist/services/context.js" }, { in: "src/services/tracker.ts", out: "dist/services/tracker.js" }, + { in: "src/services/transcript.ts", out: "dist/services/transcript.js" }, ]; await Promise.all( diff --git a/src/services/signals.ts b/src/services/signals.ts index 1d8529c..38ac6d5 100644 --- a/src/services/signals.ts +++ b/src/services/signals.ts @@ -47,6 +47,11 @@ export function groupEntriesIntoTurns(entries: TranscriptEntry[]): Turn[] { } else if (entry.role === "assistant") { currentTurn.assistantEntries.push(entry); currentTurn.allEntries.push(entry); + } else if (entry.role === "tool") { + // Tool calls/results ride along with the turn's captured content but + // aren't scanned for signal keywords — bounded tool output is noisy + // and shouldn't itself trigger a capture. + currentTurn.allEntries.push(entry); } } diff --git a/src/services/transcript.ts b/src/services/transcript.ts index a686a88..b0be9aa 100644 --- a/src/services/transcript.ts +++ b/src/services/transcript.ts @@ -65,6 +65,10 @@ function searchDirForSession(dir: string, sessionId: string): string | null { return null; } +/** Tool call/result text is bounded before it's stored — raw tool output can be + * arbitrarily large and is not worth capturing in full. */ +const MAX_TOOL_TEXT_LENGTH = 500; + const DUPLICATE_LINE_WINDOW = 5; interface ContentBlock { @@ -90,6 +94,11 @@ function extractTextBlocks( .join(separator); } +function truncateForCapture(text: string, maxLength = MAX_TOOL_TEXT_LENGTH): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength)}… [truncated, ${text.length - maxLength} more chars]`; +} + /** * Parse a Codex JSONL transcript file into TranscriptEntry[]. * @@ -98,7 +107,9 @@ function extractTextBlocks( * - Legacy assistant text: { type: "event_msg", payload: { type: "assistant_output_text", text: "..." } } * - Current messages: { type: "response_item", payload: { type: "message", role: "user" | "assistant", content: [...] } } * - user content blocks use `input_text` - * - assistant content blocks use `output_text` + * - assistant content blocks use `output_text` or `text` + * - Current tool calls: { type: "response_item", payload: { type: "function_call", name, arguments, call_id } } + * - Current tool results: { type: "response_item", payload: { type: "function_call_output", call_id, output } } * * Some rollouts contain both formats for the same turn. Identical nearby * entries are deduplicated so the stored conversation contains one copy. @@ -139,6 +150,10 @@ export function parseTranscript(transcriptPath: string): TranscriptEntry[] { text?: string; role?: string; content?: unknown; + name?: string; + arguments?: string; + call_id?: string; + output?: unknown; }; }; @@ -157,7 +172,7 @@ export function parseTranscript(transcriptPath: string): TranscriptEntry[] { } } - // Handle current response_item messages. + // Handle current response_item entries. if (parsed.type === "response_item" && parsed.payload) { const payload = parsed.payload; if (payload.role === "user" && payload.content) { @@ -172,8 +187,18 @@ export function parseTranscript(transcriptPath: string): TranscriptEntry[] { pushEntry( i, "assistant", - extractTextBlocks(payload.content, ["output_text"]), + extractTextBlocks(payload.content, ["output_text", "text"]), ); + } else if (payload.type === "function_call") { + const name = payload.name || "unknown_tool"; + const args = truncateForCapture(payload.arguments ?? ""); + pushEntry(i, "tool", `[tool_call] ${name}(${args})`); + } else if (payload.type === "function_call_output") { + const output = + typeof payload.output === "string" + ? payload.output + : JSON.stringify(payload.output ?? ""); + pushEntry(i, "tool", `[tool_result] ${truncateForCapture(output)}`); } } } catch { diff --git a/test/unit.mjs b/test/unit.mjs index ab7f5a6..70e6afd 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -589,6 +589,153 @@ describe("direct recall policy", () => { }); }); +// ─── transcript parsing ───────────────────────────────────────────────────── + +describe("Codex transcript parsing", () => { + const transcriptModule = new URL("../dist/services/transcript.js", import.meta.url).href; + + function parseFixture(t, lines) { + const tmpDir = makeTmpDir(); + t.after(() => rmSync(tmpDir, { recursive: true, force: true })); + const transcriptFile = join(tmpDir, "rollout.jsonl"); + writeFileSync(transcriptFile, lines.map((l) => JSON.stringify(l)).join("\n")); + + const script = ` + import { parseTranscript } from ${JSON.stringify(transcriptModule)}; + console.log(JSON.stringify(parseTranscript(process.argv[1]))); + `; + const result = spawnSync("node", ["--input-type=module", "-e", script, transcriptFile], { + encoding: "utf-8", + }); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout); + } + + test("extracts response_item user messages from input_text blocks", (t) => { + const entries = parseFixture(t, [ + { + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Please use pnpm, not npm." }], + }, + }, + ]); + assert.deepEqual( + entries.map((e) => [e.role, e.content]), + [["user", "Please use pnpm, not npm."]], + ); + }); + + test("extracts response_item assistant messages from output_text and text blocks", (t) => { + const entries = parseFixture(t, [ + { + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [ + { type: "output_text", text: "Got it," }, + { type: "text", text: "switching to pnpm." }, + ], + }, + }, + ]); + assert.deepEqual(entries.map((e) => e.role), ["assistant"]); + assert.equal(entries[0].content, "Got it,\nswitching to pnpm."); + }); + + test("captures function_call and function_call_output as bounded tool entries", (t) => { + const entries = parseFixture(t, [ + { + type: "response_item", + payload: { + type: "function_call", + call_id: "call_1", + name: "run_command", + arguments: JSON.stringify({ cmd: "pnpm install" }), + }, + }, + { + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call_1", + output: "a".repeat(1000), + }, + }, + ]); + assert.equal(entries.length, 2); + assert.equal(entries[0].role, "tool"); + assert.ok(entries[0].content.startsWith("[tool_call] run_command(")); + assert.ok(entries[0].content.includes("pnpm install")); + assert.equal(entries[1].role, "tool"); + assert.ok(entries[1].content.startsWith("[tool_result] ")); + // Bounded: the 1000-char output must not appear in full. + assert.ok(entries[1].content.length < 1000); + assert.ok(entries[1].content.includes("truncated")); + }); + + test("does not double-capture a turn logged as both event_msg and response_item", (t) => { + const entries = parseFixture(t, [ + { type: "event_msg", payload: { type: "user_message", message: "What is 2+2?" } }, + { + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "What is 2+2?" }], + }, + }, + { type: "event_msg", payload: { type: "assistant_output_text", text: "4" } }, + { + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "4" }], + }, + }, + ]); + assert.deepEqual( + entries.map((e) => [e.role, e.content]), + [ + ["user", "What is 2+2?"], + ["assistant", "4"], + ], + ); + }); + + test("still parses legacy event_msg-only transcripts unchanged (regression guard)", (t) => { + const entries = parseFixture(t, [ + { type: "event_msg", payload: { type: "user_message", message: "What is 2+2?" } }, + { type: "event_msg", payload: { type: "assistant_output_text", text: "4" } }, + ]); + assert.deepEqual( + entries.map((e) => [e.role, e.content]), + [ + ["user", "What is 2+2?"], + ["assistant", "4"], + ], + ); + }); + + test("an identical message repeated far apart is kept as two entries", (t) => { + const filler = Array.from({ length: 10 }, (_, idx) => ({ + type: "event_msg", + payload: { type: "assistant_output_text", text: `filler ${idx}` }, + })); + const entries = parseFixture(t, [ + { type: "event_msg", payload: { type: "user_message", message: "retry" } }, + ...filler, + { type: "event_msg", payload: { type: "user_message", message: "retry" } }, + ]); + const retries = entries.filter((e) => e.content === "retry"); + assert.equal(retries.length, 2); + }); +}); + // ─── session ids ──────────────────────────────────────────────────────────── describe("session ids", () => {