From c3b6d93a5bb5daf50262be94aa3557e40df9bc6c Mon Sep 17 00:00:00 2001 From: ayushsingh82 Date: Sat, 8 Aug 2026 15:50:52 +0530 Subject: [PATCH] feat: parse full response_item transcript coverage (fixes #13 part 1) parseTranscript() only recognized response_item entries with payload.role === "assistant", extracting just "output_text" content blocks. Recent Codex JSONL transcripts also carry: - user messages as response_item.payload.type === "message" with role: "user" and "input_text" content blocks - assistant messages using "text" blocks in addition to "output_text" - function_call entries (tool invocations) - function_call_output entries (tool results) None of these were captured, so automatic memory capture could miss user intent and tool context on transcripts that are mostly response_item-shaped rather than legacy event_msg-shaped. parseTranscript() now handles all of the above, with tool calls/results recorded as bounded, truncated "tool"-role entries (capped at 500 chars with a truncation marker) rather than raw unbounded output. Since a rollout file can log the same turn in both the legacy event_msg shape and the current response_item shape, entries are deduped when an identical (role, content) pair recurs within a small line window, so turns aren't captured twice. signals.ts's groupEntriesIntoTurns() now carries "tool" entries into the turn's captured content without scanning them for signal keywords, since bounded tool output is noisy and shouldn't itself trigger a capture. transcript.ts is now bundled to dist/services/transcript.js (same pattern as session.js/tags.js/resultMerge.js) so this can be unit-tested directly against real transcript fixtures instead of only through the hook binaries. --- build.mjs | 1 + src/services/signals.ts | 5 ++ src/services/transcript.ts | 118 ++++++++++++++++++----------- test/unit.mjs | 147 +++++++++++++++++++++++++++++++++++++ 4 files changed, 228 insertions(+), 43 deletions(-) diff --git a/build.mjs b/build.mjs index 99aaaf8..0262d70 100644 --- a/build.mjs +++ b/build.mjs @@ -40,6 +40,7 @@ const libraryEntries = [ { in: "src/services/session.ts", out: "dist/services/session.js" }, { in: "src/services/tags.ts", out: "dist/services/tags.js" }, { in: "src/services/resultMerge.ts", out: "dist/services/resultMerge.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 6a922cd..e006310 100644 --- a/src/services/transcript.ts +++ b/src/services/transcript.ts @@ -65,13 +65,51 @@ function searchDirForSession(dir: string, sessionId: string): string | null { return null; } +const MAX_TOOL_TEXT_LENGTH = 500; + +/** How many transcript lines apart a repeated (role, content) pair may be + * before it's treated as a distinct entry rather than the same turn logged + * twice — Codex rollout files can carry both the legacy `event_msg` shape + * and the current `response_item` shape for the same message. */ +const DUPLICATE_LINE_WINDOW = 5; + +interface ContentBlock { + type?: string; + text?: string; +} + +function extractTextBlocks(content: unknown, blockTypes: string[]): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return (content as ContentBlock[]) + .filter( + (block): block is ContentBlock => + !!block && blockTypes.includes(block.type ?? "") && typeof block.text === "string", + ) + .map((block) => block.text as string) + .join("\n"); +} + +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[]. * * Codex transcript format: - * - User messages: { type: "event_msg", payload: { type: "user_message", message: "..." } } - * - Assistant text: { type: "event_msg", payload: { type: "assistant_output_text", text: "..." } } - * - Also check response_item for assistant messages + * - Legacy user messages: { type: "event_msg", payload: { type: "user_message", message: "..." } } + * - Legacy assistant text: { type: "event_msg", payload: { type: "assistant_output_text", text: "..." } } + * - response_item messages: { type: "response_item", payload: { type: "message", role: "user" | "assistant", content: [...] } } + * - user content blocks use `type: "input_text"` + * - assistant content blocks use `type: "output_text"` or `type: "text"` + * - response_item tool calls: { type: "response_item", payload: { type: "function_call", name, arguments, call_id } } + * - response_item tool results: { type: "response_item", payload: { type: "function_call_output", call_id, output } } + * + * The same turn is sometimes present in both the legacy and current shapes + * within one file; entries are deduped when an identical (role, content) + * pair recurs within a small line window. */ export function parseTranscript(transcriptPath: string): TranscriptEntry[] { const entries: TranscriptEntry[] = []; @@ -80,6 +118,18 @@ export function parseTranscript(transcriptPath: string): TranscriptEntry[] { return entries; } + function pushEntry(index: number, role: string, rawContent: string): void { + const cleaned = cleanContent(stripPrivateContent(rawContent)); + if (!cleaned) return; + + for (let j = entries.length - 1; j >= 0; j--) { + if (index - entries[j].index > DUPLICATE_LINE_WINDOW) break; + if (entries[j].role === role && entries[j].content === cleaned) return; + } + + entries.push({ index, role, content: cleaned }); + } + try { const raw = readFileSync(transcriptPath, "utf-8"); const lines = raw.split("\n"); @@ -97,6 +147,10 @@ export function parseTranscript(transcriptPath: string): TranscriptEntry[] { text?: string; role?: string; content?: unknown; + name?: string; + arguments?: string; + call_id?: string; + output?: unknown; }; }; @@ -106,55 +160,33 @@ export function parseTranscript(transcriptPath: string): TranscriptEntry[] { // User message if (payload.type === "user_message" && payload.message) { - const cleaned = cleanContent(stripPrivateContent(payload.message)); - if (cleaned && cleaned.length > 0) { - entries.push({ - index: i, - role: "user", - content: cleaned, - }); - } + pushEntry(i, "user", payload.message); } // Assistant output text if (payload.type === "assistant_output_text" && payload.text) { - const cleaned = cleanContent(stripPrivateContent(payload.text)); - if (cleaned && cleaned.length > 0) { - entries.push({ - index: i, - role: "assistant", - content: cleaned, - }); - } + pushEntry(i, "assistant", payload.text); } } - // Handle response_item entries (assistant responses) + // Handle response_item entries if (parsed.type === "response_item" && parsed.payload) { const payload = parsed.payload; - if (payload.role === "assistant" && payload.content) { - const content = payload.content; - let text = ""; - - if (typeof content === "string") { - text = content; - } else if (Array.isArray(content)) { - // Extract text from content blocks - for (const block of content as Array<{ type?: string; text?: string }>) { - if (block.type === "output_text" && block.text) { - text += block.text + "\n"; - } - } - } - - const cleaned = cleanContent(stripPrivateContent(text)); - if (cleaned && cleaned.length > 0) { - entries.push({ - index: i, - role: "assistant", - content: cleaned, - }); - } + + if (payload.role === "user" && payload.content) { + pushEntry(i, "user", extractTextBlocks(payload.content, ["input_text"])); + } else if (payload.role === "assistant" && payload.content) { + pushEntry(i, "assistant", 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 0e30a5f..cc720b0 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -246,6 +246,153 @@ describe("cross-container result merging", () => { }); }); +// ─── 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", () => {