Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions src/services/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
31 changes: 28 additions & 3 deletions src/services/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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[].
*
Expand All @@ -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.
Expand Down Expand Up @@ -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;
};
};

Expand All @@ -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) {
Expand All @@ -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 {
Expand Down
147 changes: 147 additions & 0 deletions test/unit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down