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 @@ -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(
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
118 changes: 75 additions & 43 deletions src/services/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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");
Expand All @@ -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;
};
};

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