@spexcode/transcript 0.7.0-next.0 → 0.7.0-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/frames.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { type TranscriptRead, type TranscriptReader, type TranscriptTool, type TranscriptTurn } from './turns.js';
2
+ export { TranscriptReadError } from './turns.js';
3
+ export type { TranscriptRange, TranscriptRead, TranscriptReader, TranscriptTail, TranscriptTool, TranscriptTurn } from './turns.js';
2
4
  export type StreamTool = Readonly<Omit<TranscriptTool, 'output'> & {
3
5
  output?: null;
4
6
  }>;
package/dist/frames.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { TranscriptReadError } from './turns.js';
2
+ // the browser-safe entry is complete on its own: the normalized shape travels with the frames that carry it
3
+ export { TranscriptReadError } from './turns.js';
2
4
  export const isErrorFrame = (frame) => 'error' in frame;
3
5
  // The revision an absent source reports: the thread has not started writing. Not an error.
4
6
  export const ABSENT_REVISION = 'absent';
package/dist/parsers.d.ts CHANGED
@@ -8,6 +8,7 @@ export type MutableTool = {
8
8
  output?: string;
9
9
  outputLines: number;
10
10
  outputBytes: number;
11
+ outcome?: ToolOutcome;
11
12
  };
12
13
  export type MutableTurn = {
13
14
  id: string | null;
@@ -16,18 +17,25 @@ export type MutableTurn = {
16
17
  text?: string;
17
18
  tools: MutableTool[];
18
19
  };
20
+ export type ToolOutcome = 'failed' | 'rejected';
19
21
  export type ParsedEvent = {
20
22
  at: number | null;
21
23
  turn: MutableTurn | null;
22
24
  toolOutputs?: readonly {
23
25
  id: string;
24
26
  text: string;
27
+ outcome?: ToolOutcome;
25
28
  }[];
26
29
  };
27
30
  export type Parse = (value: unknown) => ParsedEvent | null;
28
31
  export declare function claudeEvent(value: unknown): ParsedEvent | null;
29
32
  export declare function codexEvent(value: unknown): ParsedEvent | null;
33
+ export declare function codexAppServerEvent(value: unknown): ParsedEvent | null;
34
+ export declare function codexAppServerStream(): Parse;
30
35
  export declare function piEvent(value: unknown): ParsedEvent | null;
36
+ export declare function geminiEvent(value: unknown): ParsedEvent | null;
37
+ export declare function openclawEvent(value: unknown): ParsedEvent | null;
38
+ export declare function hermesEvents(value: unknown): ParsedEvent[];
31
39
  export declare function opencodeEvents(value: unknown): ParsedEvent[];
32
40
  export declare class IntervalCollector {
33
41
  readonly turns: MutableTurn[];
package/dist/parsers.js CHANGED
@@ -50,12 +50,69 @@ const compact = (value) => {
50
50
  return String(value);
51
51
  }
52
52
  };
53
+ // A RESULT IS WHAT THE TOOL SAID, NOT ITS WIRE SHAPE. Every harness that records a result as content blocks
54
+ // (Claude's tool_result content, Codex's input_text output blocks, MCP results everywhere) means the text of
55
+ // those blocks, with their line breaks; encoding the block list itself as JSON would show the reader escaped
56
+ // newlines inside a JSON shell. A block that is not text — an image, a reference — is named, not dumped.
57
+ const resultText = (value) => {
58
+ if (typeof value === 'string')
59
+ return value;
60
+ if (!Array.isArray(value))
61
+ return compact(value);
62
+ return value.map((blockValue) => {
63
+ const block = object(blockValue);
64
+ if (!block)
65
+ return compact(blockValue);
66
+ const text = string(block.text);
67
+ if (text !== null)
68
+ return text;
69
+ const type = string(block.type);
70
+ if (type === 'image')
71
+ return '[image]';
72
+ if (type)
73
+ return `[${type}]`;
74
+ return compact(blockValue);
75
+ }).join('\n');
76
+ };
77
+ // CODEX CODE-MODE: the `exec` tool's input is a JS program that calls `tools.exec_command({cmd:"…"})`; the shell
78
+ // command is what actually ran, the JS around it is transport the model writes to reach the sandbox. Surface the
79
+ // command(s) so a GENERIC renderer shows what ran, not the wrapper. This is codex-adapter knowledge and lives
80
+ // here at the bottom of the stack — never leak a code-mode literal up into the shared vocabulary or the UI.
81
+ const CODEX_EXEC_CMD = /\bcmd\s*:\s*"((?:[^"\\]|\\.)*)"/g;
82
+ function codexExecCommand(input) {
83
+ if (typeof input !== 'string' || !input.includes('exec_command'))
84
+ return input;
85
+ const commands = [];
86
+ for (const match of input.matchAll(CODEX_EXEC_CMD)) {
87
+ try {
88
+ commands.push(JSON.parse(`"${match[1]}"`));
89
+ }
90
+ catch {
91
+ commands.push(match[1].replace(/\\(["\\])/g, '$1'));
92
+ }
93
+ }
94
+ return commands.length ? commands.join('\n') : input;
95
+ }
53
96
  const lineCount = (value) => value ? value.split(/\r?\n/).length : 0;
54
- // --- the four native shapes --------------------------------------------------------------------------------
97
+ // --- the seven native shapes -------------------------------------------------------------------------------
55
98
  export function claudeEvent(value) {
56
99
  const entry = object(value);
57
- const message = object(entry?.message);
58
- if (!entry || !message)
100
+ if (!entry)
101
+ return null;
102
+ // A message steered into a RUNNING turn (stream-json `type:user` on stdin, a queued command in the TUI) is not
103
+ // recorded as a `user` message: Claude writes it as an `attachment` of type `queued_command` carrying the prompt
104
+ // blocks. It is the person's turn all the same, and the one place a steer becomes observable — hooks never fire
105
+ // for it — so the reader draws it as a user turn at the moment it entered the conversation.
106
+ if (entry.type === 'attachment') {
107
+ const attachment = object(entry.attachment);
108
+ const queuedAt = at(entry);
109
+ if (attachment?.type !== 'queued_command' || queuedAt === null)
110
+ return null;
111
+ const text = items(attachment.prompt).map((block) => string(object(block)?.text)).filter(Boolean).join('\n') || null;
112
+ return text ? { at: queuedAt, turn: { id: idOf(entry), at: queuedAt, role: 'user', text, tools: [] } } : null;
113
+ }
114
+ const message = object(entry.message);
115
+ if (!message)
59
116
  return null;
60
117
  const eventAt = at(entry) ?? at(message);
61
118
  if (eventAt === null)
@@ -68,7 +125,7 @@ export function claudeEvent(value) {
68
125
  const outputs = blocks.flatMap((block) => {
69
126
  const b = object(block);
70
127
  const id = string(b?.tool_use_id);
71
- return b?.type === 'tool_result' && id ? [{ id, text: compact(b?.content ?? '') }] : [];
128
+ return b?.type === 'tool_result' && id ? [{ id, text: resultText(b?.content ?? ''), ...(b?.is_error === true ? { outcome: 'failed' } : {}) }] : [];
72
129
  });
73
130
  if (outputs.length)
74
131
  return { at: eventAt, turn: null, toolOutputs: outputs };
@@ -90,6 +147,9 @@ export function claudeEvent(value) {
90
147
  }
91
148
  return null;
92
149
  }
150
+ const blockText = (content) => typeof content === 'string'
151
+ ? string(content)
152
+ : items(content).map((block) => string(object(block)?.text)).filter(Boolean).join('\n') || null;
93
153
  export function codexEvent(value) {
94
154
  const entry = object(value);
95
155
  const payload = object(entry?.payload);
@@ -99,30 +159,137 @@ export function codexEvent(value) {
99
159
  if (eventAt === null)
100
160
  return { at: null, turn: null };
101
161
  const type = string(payload.type);
102
- if ((entry.type === 'event_msg' && type === 'user_message')
103
- || (entry.type === 'response_item' && (type === 'message' || type === 'input_message') && payload.role === 'user')) {
162
+ // THE PERSON'S MESSAGE is the `user_message` event. Codex also records it as a `response_item` message (the API
163
+ // form, `input_text` blocks) beside harness injections that no person typed (the AGENTS.md instructions), so
164
+ // that form is not a turn — the event is, once.
165
+ if (entry.type === 'event_msg' && type === 'user_message') {
104
166
  const text = typeof payload.message === 'string' ? payload.message : compact(payload.message ?? payload.content ?? '');
105
167
  return text ? { at: eventAt, turn: { id: idOf(payload) ?? idOf(entry), at: eventAt, role: 'user', text, tools: [] } } : null;
106
168
  }
107
- // commentary AND the final answer are both what the agent said; only structured reasoning stays private
169
+ if (entry.type === 'response_item' && (type === 'message' || type === 'input_message') && payload.role === 'user')
170
+ return { at: eventAt, turn: null };
171
+ // WHAT THE AGENT SAID is the `agent_message` event — commentary and the final answer alike. Codex 0.146 also
172
+ // records the same prose as a `response_item` message (`output_text` blocks) beside it, so that form is not read:
173
+ // reading both would say every sentence twice. An empty event (a final answer that was a tool call) is a clock,
174
+ // not a turn. Structured reasoning stays private.
108
175
  if (entry.type === 'event_msg' && type === 'agent_message') {
109
176
  const text = string(payload.message ?? payload.text);
110
- return text ? { at: eventAt, turn: { id: idOf(payload) ?? idOf(entry), at: eventAt, role: 'assistant', text, tools: [] } } : null;
177
+ return text ? { at: eventAt, turn: { id: idOf(payload) ?? idOf(entry), at: eventAt, role: 'assistant', text, tools: [] } } : { at: eventAt, turn: null };
111
178
  }
179
+ if (entry.type === 'response_item' && type === 'message' && payload.role === 'assistant')
180
+ return { at: eventAt, turn: null };
112
181
  if (entry.type === 'response_item' && (type === 'custom_tool_call' || type === 'function_call')) {
113
182
  const id = string(payload.call_id ?? payload.id) ?? 'tool';
114
- return { at: eventAt, turn: { id: idOf(payload) ?? idOf(entry), at: eventAt, role: 'assistant', tools: [{ id, name: string(payload.name ?? payload.tool_name) ?? 'tool', input: payload.input === undefined && payload.arguments === undefined ? undefined : compact(payload.input ?? payload.arguments), outputLines: 0, outputBytes: 0 }] } };
183
+ const rawInput = payload.input === undefined && payload.arguments === undefined ? undefined : compact(payload.input ?? payload.arguments);
184
+ return { at: eventAt, turn: { id: idOf(payload) ?? idOf(entry), at: eventAt, role: 'assistant', tools: [{ id, name: string(payload.name ?? payload.tool_name) ?? 'tool', input: codexExecCommand(rawInput), outputLines: 0, outputBytes: 0 }] } };
115
185
  }
116
186
  if (entry.type === 'response_item' && (type === 'custom_tool_call_output' || type === 'function_call_output')) {
117
187
  const id = string(payload.call_id ?? payload.id);
118
188
  const output = payload.output ?? payload.result ?? '';
119
- return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: compact(output) }] } : null;
189
+ return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: resultText(output) }] } : null;
120
190
  }
121
191
  return null;
122
192
  }
123
- const blockText = (content) => typeof content === 'string'
124
- ? string(content)
125
- : items(content).map((block) => string(object(block)?.text)).filter(Boolean).join('\n') || null;
193
+ // Codex app-server notifications are a different native stream from rollout lines. Keep this mapping stateless:
194
+ // a caller that needs streamed prose uses codexAppServerStream below, while file and in-memory sources still
195
+ // share the same one-record parser contract.
196
+ export function codexAppServerEvent(value) {
197
+ const entry = object(value);
198
+ const params = object(entry?.params);
199
+ if (!entry || !params || typeof entry.method !== 'string')
200
+ return null;
201
+ const item = object(params.item);
202
+ const method = entry.method;
203
+ const recognized = method === 'item/agentMessage/delta' || method === 'item/started' || method === 'item/completed';
204
+ if (!recognized)
205
+ return null;
206
+ const eventAt = timestamp(params.emittedAtMs) ?? timestamp(params.startedAtMs) ?? timestamp(params.completedAtMs);
207
+ if (eventAt === null)
208
+ return { at: null, turn: null };
209
+ if (method === 'item/agentMessage/delta') {
210
+ const id = string(params.itemId);
211
+ const delta = string(params.delta);
212
+ return id && delta !== null
213
+ ? { at: eventAt, turn: { id, at: eventAt, role: 'assistant', text: delta, tools: [] } }
214
+ : null;
215
+ }
216
+ if ((method !== 'item/started' && method !== 'item/completed') || !item)
217
+ return null;
218
+ const id = string(item.id);
219
+ const type = string(item.type);
220
+ if (!id || !type)
221
+ return null;
222
+ if (type === 'userMessage') {
223
+ const text = blockText(item.content);
224
+ return text ? { at: eventAt, turn: { id, at: eventAt, role: 'user', text, tools: [] } } : null;
225
+ }
226
+ if (type === 'agentMessage') {
227
+ const text = string(item.text);
228
+ return { at: eventAt, turn: { id, at: eventAt, role: 'assistant', text: text ?? undefined, tools: [] } };
229
+ }
230
+ const toolTypes = new Set(['commandExecution', 'functionCall', 'customToolCall', 'mcpToolCall', 'dynamicToolCall']);
231
+ if (!toolTypes.has(type))
232
+ return null;
233
+ if (method === 'item/started') {
234
+ const name = type === 'commandExecution' ? 'command'
235
+ : string(item.name) ?? string(item.tool) ?? (type === 'mcpToolCall' ? 'mcp' : 'tool');
236
+ const input = item.arguments !== undefined ? item.arguments
237
+ : item.input !== undefined ? item.input
238
+ : item.command !== undefined ? item.command
239
+ : undefined;
240
+ return { at: eventAt, turn: { id, at: eventAt, role: 'assistant', tools: [{ id, name, input: input === undefined ? undefined : compact(input), outputLines: 0, outputBytes: 0 }] } };
241
+ }
242
+ let output = undefined;
243
+ if (type === 'commandExecution')
244
+ output = item.aggregatedOutput;
245
+ else if (type === 'functionCall' || type === 'customToolCall')
246
+ output = item.output ?? item.result;
247
+ else if (type === 'mcpToolCall')
248
+ output = item.result ?? item.error;
249
+ else if (type === 'dynamicToolCall')
250
+ output = item.contentItems ?? item.output ?? item.error;
251
+ // the item status is the app-server's own verdict: `failed`, or `declined` when the person refused the call —
252
+ // a declined call has no output, so the empty result is what ends its "running"
253
+ const status = string(item.status);
254
+ const outcome = status === 'failed' ? 'failed' : status === 'declined' ? 'rejected' : undefined;
255
+ if (output === undefined || output === null) {
256
+ return outcome ? { at: eventAt, turn: null, toolOutputs: [{ id, text: '', outcome }] } : { at: eventAt, turn: null };
257
+ }
258
+ return { at: eventAt, turn: null, toolOutputs: [{ id, text: resultText(output), ...(outcome ? { outcome } : {}) }] };
259
+ }
260
+ // Agent-message deltas are fragments of one native item. The closure remembers only that item's text and
261
+ // re-emits its native id, allowing IntervalCollector to replace the earlier turn in place.
262
+ export function codexAppServerStream() {
263
+ const textByItem = new Map();
264
+ return (value) => {
265
+ const parsed = codexAppServerEvent(value);
266
+ if (!parsed)
267
+ return null;
268
+ const entry = object(value);
269
+ const params = object(entry?.params);
270
+ const item = object(params?.item);
271
+ if (entry?.method === 'item/agentMessage/delta') {
272
+ const id = string(params?.itemId);
273
+ if (!id || !parsed.turn)
274
+ return parsed;
275
+ const text = (textByItem.get(id) ?? '') + (parsed.turn.text ?? '');
276
+ textByItem.set(id, text);
277
+ return { ...parsed, turn: { ...parsed.turn, text } };
278
+ }
279
+ if (item && item.type === 'agentMessage') {
280
+ const id = string(item.id);
281
+ if (id) {
282
+ const text = string(item.text);
283
+ if (text !== null || !textByItem.has(id))
284
+ textByItem.set(id, text ?? '');
285
+ const turn = parsed.turn;
286
+ if (turn)
287
+ return { ...parsed, turn: { ...turn, text: textByItem.get(id) || undefined } };
288
+ }
289
+ }
290
+ return parsed;
291
+ };
292
+ }
126
293
  export function piEvent(value) {
127
294
  const entry = object(value);
128
295
  const message = object(entry?.message);
@@ -150,10 +317,120 @@ export function piEvent(value) {
150
317
  }
151
318
  if (message.role === 'toolResult') {
152
319
  const id = string(message.toolCallId);
153
- return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: blockText(message.content) ?? compact(message.content ?? '') }] } : null;
320
+ return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: blockText(message.content) ?? compact(message.content ?? ''), ...(message.isError === true ? { outcome: 'failed' } : {}) }] } : null;
154
321
  }
155
322
  return null;
156
323
  }
324
+ export function geminiEvent(value) {
325
+ const entry = object(value);
326
+ const message = (entry?.type === 'user' || entry?.type === 'gemini')
327
+ ? entry
328
+ : object(items(entry?.messages)[0]) ?? object(items(object(entry?.$set)?.messages)[0]);
329
+ if (!entry || !message)
330
+ return null;
331
+ const eventAt = at(message) ?? at(entry);
332
+ if (eventAt === null)
333
+ return { at: null, turn: null };
334
+ const content = message.content;
335
+ const blocks = items(content);
336
+ const outputs = blocks.flatMap((blockValue) => {
337
+ const block = object(blockValue);
338
+ const response = object(block?.functionResponse);
339
+ const id = string(response?.id);
340
+ return id ? [{ id, text: compact(response?.response ?? '') }] : [];
341
+ });
342
+ if (outputs.length)
343
+ return { at: eventAt, turn: null, toolOutputs: outputs };
344
+ if (message.type === 'user') {
345
+ const text = typeof content === 'string' ? string(content) : blocks.map((block) => string(object(block)?.text)).filter(Boolean).join('\n') || null;
346
+ return text ? { at: eventAt, turn: { id: idOf(message), at: eventAt, role: 'user', text, tools: [] } } : null;
347
+ }
348
+ if (message.type === 'gemini') {
349
+ const turn = { id: idOf(message), at: eventAt, role: 'assistant', tools: [] };
350
+ if (typeof content === 'string')
351
+ turn.text = string(content) ?? undefined;
352
+ for (const callValue of items(message.toolCalls)) {
353
+ const call = object(callValue);
354
+ const id = string(call?.id) ?? `tool-${turn.tools.length}`;
355
+ turn.tools.push({ id, name: string(call?.name) ?? 'tool', input: call?.args === undefined ? undefined : compact(call.args), outputLines: 0, outputBytes: 0, ...(call?.status === 'error' ? { outcome: 'failed' } : {}) });
356
+ }
357
+ if (!turn.text && !turn.tools.length)
358
+ return null;
359
+ return { at: eventAt, turn };
360
+ }
361
+ return null;
362
+ }
363
+ export function openclawEvent(value) {
364
+ const entry = object(value);
365
+ const message = object(entry?.message);
366
+ if (!entry || entry.type !== 'message' || !message)
367
+ return null;
368
+ const eventAt = at(message) ?? at(entry);
369
+ if (eventAt === null)
370
+ return { at: null, turn: null };
371
+ if (message.role === 'user') {
372
+ const text = blockText(message.content);
373
+ return text ? { at: eventAt, turn: { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'user', text, tools: [] } } : null;
374
+ }
375
+ if (message.role === 'toolResult') {
376
+ const id = string(message.toolCallId);
377
+ return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: blockText(message.content) ?? compact(message.content ?? ''), ...(message.isError === true ? { outcome: 'failed' } : {}) }] } : null;
378
+ }
379
+ if (message.role === 'assistant') {
380
+ const turn = { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'assistant', tools: [] };
381
+ for (const blockValue of items(message.content)) {
382
+ const block = object(blockValue);
383
+ if (block?.type === 'text')
384
+ turn.text = [turn.text, string(block.text)].filter(Boolean).join('\n') || undefined;
385
+ if (block?.type === 'toolCall') {
386
+ const id = string(block.id) ?? `tool-${turn.tools.length}`;
387
+ turn.tools.push({ id, name: string(block.name) ?? 'tool', input: block.arguments === undefined ? undefined : compact(block.arguments), outputLines: 0, outputBytes: 0 });
388
+ }
389
+ }
390
+ return turn.text || turn.tools.length ? { at: eventAt, turn } : null;
391
+ }
392
+ return null;
393
+ }
394
+ export function hermesEvents(value) {
395
+ const root = object(value);
396
+ const events = [];
397
+ for (const messageValue of items(root?.messages)) {
398
+ const message = object(messageValue);
399
+ if (!message)
400
+ continue;
401
+ const eventAt = at(message);
402
+ if (eventAt === null) {
403
+ events.push({ at: null, turn: null });
404
+ continue;
405
+ }
406
+ const role = message.role;
407
+ if (role === 'user') {
408
+ const text = string(message.content);
409
+ if (text)
410
+ events.push({ at: eventAt, turn: { id: idOf(message), at: eventAt, role: 'user', text, tools: [] } });
411
+ }
412
+ else if (role === 'assistant') {
413
+ const turn = { id: idOf(message), at: eventAt, role: 'assistant', tools: [] };
414
+ const text = string(message.content);
415
+ if (text)
416
+ turn.text = text;
417
+ for (const callValue of items(message.tool_calls)) {
418
+ const call = object(callValue);
419
+ const fn = object(call?.function);
420
+ const id = string(call?.id) ?? `tool-${turn.tools.length}`;
421
+ turn.tools.push({ id, name: string(fn?.name) ?? 'tool', input: fn?.arguments === undefined ? undefined : compact(fn.arguments), outputLines: 0, outputBytes: 0 });
422
+ }
423
+ if (turn.text || turn.tools.length)
424
+ events.push({ at: eventAt, turn });
425
+ }
426
+ else if (role === 'tool') {
427
+ const id = string(message.tool_call_id);
428
+ if (id)
429
+ events.push({ at: eventAt, turn: null, toolOutputs: [{ id, text: compact(message.content ?? '') }] });
430
+ }
431
+ }
432
+ return events;
433
+ }
157
434
  // OpenCode's export is one JSON document, not a line stream: every message arrives with its parts, and a tool
158
435
  // part already carries its own result — so its turn is complete on arrival, and a part still running simply has
159
436
  // no output yet.
@@ -186,6 +463,8 @@ export function opencodeEvents(value) {
186
463
  tool.output = output.slice(0, MAX_OUTPUT_BYTES);
187
464
  tool.outputBytes = Buffer.byteLength(output);
188
465
  tool.outputLines = lineCount(output);
466
+ if (status === 'error')
467
+ tool.outcome = 'failed';
189
468
  }
190
469
  turn.tools.push(tool);
191
470
  }
@@ -238,6 +517,8 @@ export class IntervalCollector {
238
517
  tool.outputLines += lineCount(output.text);
239
518
  if (tool.output === undefined)
240
519
  tool.output = '';
520
+ if (output.outcome)
521
+ tool.outcome = output.outcome;
241
522
  const remaining = Math.max(0, MAX_OUTPUT_BYTES - Buffer.byteLength(tool.output));
242
523
  tool.output += output.text.slice(0, remaining);
243
524
  if (bytes > remaining)
@@ -256,14 +537,31 @@ export class IntervalCollector {
256
537
  this.synthesized.set(base, seen + 1);
257
538
  turn.id = seen ? `${base}#${seen}` : base;
258
539
  }
259
- this.turns.push(turn);
260
- for (const tool of turn.tools)
261
- this.byTool.set(tool.id, tool);
262
- if (this.turns.length > MAX_TURNS) {
263
- const dropped = this.turns.shift();
264
- for (const tool of dropped.tools)
265
- this.evicted.add(tool.id);
266
- this.omittedTurns++;
540
+ const existingAt = this.turns.findIndex((candidate) => candidate.id === turn.id);
541
+ if (existingAt >= 0) {
542
+ const existing = this.turns[existingAt];
543
+ const incomingTools = new Map(turn.tools.map((tool) => [tool.id, tool]));
544
+ const tools = existing.tools.map((tool) => {
545
+ const incoming = incomingTools.get(tool.id);
546
+ incomingTools.delete(tool.id);
547
+ return incoming ? { ...tool, ...incoming, output: incoming.output ?? tool.output } : tool;
548
+ });
549
+ tools.push(...incomingTools.values());
550
+ const replacement = { ...existing, ...turn, text: turn.text ?? existing.text, tools };
551
+ this.turns[existingAt] = replacement;
552
+ for (const tool of replacement.tools)
553
+ this.byTool.set(tool.id, tool);
554
+ }
555
+ else {
556
+ this.turns.push(turn);
557
+ for (const tool of turn.tools)
558
+ this.byTool.set(tool.id, tool);
559
+ if (this.turns.length > MAX_TURNS) {
560
+ const dropped = this.turns.shift();
561
+ for (const tool of dropped.tools)
562
+ this.evicted.add(tool.id);
563
+ this.omittedTurns++;
564
+ }
267
565
  }
268
566
  }
269
567
  return this.pastRange;
package/dist/readers.d.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  import { type TranscriptReader } from './turns.js';
2
2
  export declare function claudeTranscriptPath(threadId: string, root?: string): string | null;
3
- export declare function codexRolloutPath(threadId: string, root?: string): string | null;
3
+ export declare function codexRolloutPath(threadId: string, root?: string, archive?: string): string | null;
4
4
  export declare function piSessionPath(threadId: string, root?: string): string | null;
5
+ export declare function geminiTranscriptPath(threadId: string, root?: string): string | null;
6
+ export declare function openclawTranscriptPath(threadId: string, root?: string): string | null;
5
7
  export declare const claudeTranscript: TranscriptReader;
6
8
  export declare const codexTranscript: TranscriptReader;
7
9
  export declare const piTranscript: TranscriptReader;
10
+ export declare const geminiTranscript: TranscriptReader;
11
+ export declare const openclawTranscript: TranscriptReader;
8
12
  export declare function opencodeTranscriptReader(root?: string, load?: (threadId: string) => string): TranscriptReader;
9
13
  export declare const opencodeTranscript: TranscriptReader;
14
+ export declare function hermesTranscriptReader(root?: string, load?: (threadId: string) => string): TranscriptReader;
15
+ export declare const hermesTranscript: TranscriptReader;
10
16
  export declare function unsupportedTranscript(harness: string): TranscriptReader;
package/dist/readers.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { closeSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { dirname, join } from 'node:path';
5
5
  import { TranscriptReadError } from './turns.js';
6
- import { IntervalCollector, claudeEvent, codexEvent, opencodeEvents, piEvent } from './parsers.js';
6
+ import { IntervalCollector, claudeEvent, codexEvent, geminiEvent, hermesEvents, openclawEvent, opencodeEvents, piEvent } from './parsers.js';
7
7
  // THE NATIVE-THREAD READERS. Each harness keeps its conversation somewhere private — Claude's project JSONL,
8
8
  // Codex's rollout, pi's session JSONL, OpenCode's store behind `opencode export` — and this module is the only
9
9
  // place that knows where. It answers exactly one question for every harness: "what happened in this thread
@@ -31,7 +31,10 @@ export function claudeTranscriptPath(threadId, root = projectTranscriptRoot()) {
31
31
  const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions');
32
32
  // Walk newest day first and return on the first hit; the walk is exhaustive rather than capped, because
33
33
  // future-dated junk under sessions/ sorts above every real day and a cap once masked every real rollout.
34
- export function codexRolloutPath(threadId, root = codexSessionsDir()) {
34
+ // A thread Codex has ARCHIVED (the app-server's thread/archive, which a closed session runs) keeps its
35
+ // rollout, moved out of the dated tree into the flat `archived_sessions/` beside it — a closed session's
36
+ // conversation is still on disk and still readable, so the locator looks there second.
37
+ export function codexRolloutPath(threadId, root = codexSessionsDir(), archive = join(dirname(root), 'archived_sessions')) {
35
38
  for (const year of children(root))
36
39
  for (const month of children(join(root, year)))
37
40
  for (const day of children(join(root, year, month))) {
@@ -40,7 +43,8 @@ export function codexRolloutPath(threadId, root = codexSessionsDir()) {
40
43
  if (file)
41
44
  return join(dir, file);
42
45
  }
43
- return null;
46
+ const archived = children(archive).find((name) => name.includes(threadId));
47
+ return archived ? join(archive, archived) : null;
44
48
  }
45
49
  const piSessionsRoot = () => join(process.env.SPEXCODE_PI_AGENT_DIR || join(homedir(), '.pi', 'agent'), 'sessions');
46
50
  const piSessionPaths = new Map();
@@ -72,6 +76,36 @@ export function piSessionPath(threadId, root = piSessionsRoot()) {
72
76
  }
73
77
  return null;
74
78
  }
79
+ const findJsonl = (root, threadId, maxDepth = 5) => {
80
+ const walk = (dir, depth) => {
81
+ if (depth > maxDepth)
82
+ return null;
83
+ for (const name of children(dir)) {
84
+ const path = join(dir, name);
85
+ try {
86
+ if (statSync(path).isFile() && name.endsWith('.jsonl')) {
87
+ if (name.includes(threadId))
88
+ return path;
89
+ const header = JSON.parse(readFileSync(path, 'utf8').split('\n', 1)[0]);
90
+ if (header.sessionId === threadId || header.id === threadId)
91
+ return path;
92
+ }
93
+ else if (statSync(path).isDirectory()) {
94
+ const found = walk(path, depth + 1);
95
+ if (found)
96
+ return found;
97
+ }
98
+ }
99
+ catch { /* skip entries that disappear or are not JSON */ }
100
+ }
101
+ return null;
102
+ };
103
+ return walk(root, 0);
104
+ };
105
+ const geminiRoot = () => process.env.GEMINI_HOME || process.env.GEMINI_CONFIG_DIR || join(homedir(), '.gemini');
106
+ export function geminiTranscriptPath(threadId, root = geminiRoot()) { return findJsonl(root, threadId); }
107
+ const openclawRoot = () => process.env.OPENCLAW_STATE_DIR || join(homedir(), '.openclaw', 'state');
108
+ export function openclawTranscriptPath(threadId, root = openclawRoot()) { return findJsonl(root, threadId); }
75
109
  const opencodeStoreRoot = () => process.env.SPEXCODE_OPENCODE_DATA_DIR
76
110
  || join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'opencode');
77
111
  function opencodeStoreRevision(root) {
@@ -243,6 +277,8 @@ function lineFileReader(harness, locate, parse) {
243
277
  export const claudeTranscript = lineFileReader('claude', (threadId) => claudeTranscriptPath(threadId), claudeEvent);
244
278
  export const codexTranscript = lineFileReader('codex', (threadId) => codexRolloutPath(threadId), codexEvent);
245
279
  export const piTranscript = lineFileReader('pi', (threadId) => piSessionPath(threadId), piEvent);
280
+ export const geminiTranscript = lineFileReader('gemini', (threadId) => geminiTranscriptPath(threadId), geminiEvent);
281
+ export const openclawTranscript = lineFileReader('openclaw', (threadId) => openclawTranscriptPath(threadId), openclawEvent);
246
282
  // OpenCode has no per-thread file: the store's revision is the change token, and one export per
247
283
  // revision is parsed and kept, so repeated interval reads of a quiet thread cost nothing new.
248
284
  const opencodeExports = new Map();
@@ -286,6 +322,53 @@ export function opencodeTranscriptReader(root = opencodeStoreRoot(), load = open
286
322
  };
287
323
  }
288
324
  export const opencodeTranscript = opencodeTranscriptReader();
325
+ const hermesRoot = () => process.env.HERMES_HOME || join(homedir(), '.hermes', 'profiles', 'default');
326
+ function hermesRevision(root) {
327
+ try {
328
+ const stat = statSync(join(root, 'state.db'));
329
+ return `${stat.size}:${Math.floor(stat.mtimeMs)}`;
330
+ }
331
+ catch {
332
+ return null;
333
+ }
334
+ }
335
+ function hermesExport(threadId) {
336
+ return execFileSync(process.env.SPEXCODE_HERMES_CMD || 'hermes', ['sessions', 'export', '--format', 'jsonl', '--session-id', threadId, '--yes'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
337
+ }
338
+ const hermesExports = new Map();
339
+ export function hermesTranscriptReader(root = hermesRoot(), load = hermesExport) {
340
+ const read = async (threadId, range) => {
341
+ const revision = hermesRevision(root);
342
+ if (!revision)
343
+ throw new TranscriptReadError('missing', `hermes transcript for ${threadId} is unavailable: state.db was not found`);
344
+ const key = `${root}:${threadId}`;
345
+ let cached = hermesExports.get(key);
346
+ if (!cached || cached.revision !== revision) {
347
+ let exported;
348
+ try {
349
+ exported = load(threadId);
350
+ }
351
+ catch (error) {
352
+ throw new TranscriptReadError('unreadable', `hermes transcript could not be exported: ${error instanceof Error ? error.message : String(error)}`);
353
+ }
354
+ let value;
355
+ try {
356
+ value = JSON.parse(exported);
357
+ }
358
+ catch (error) {
359
+ throw new TranscriptReadError('invalid', `hermes transcript cannot be parsed: ${error instanceof Error ? error.message : String(error)}`);
360
+ }
361
+ cached = { revision, events: hermesEvents(value) };
362
+ hermesExports.set(key, cached);
363
+ }
364
+ const collector = new IntervalCollector(range);
365
+ for (const event of cached.events)
366
+ collector.add(event);
367
+ return collector.finish(revision, 'hermes');
368
+ };
369
+ return { revision: () => hermesRevision(root), read, tail: (threadId, from) => ({ advance: (to) => read(threadId, { from, to }), close: () => { } }) };
370
+ }
371
+ export const hermesTranscript = hermesTranscriptReader();
289
372
  export function unsupportedTranscript(harness) {
290
373
  const refuse = async () => { throw new TranscriptReadError('unsupported', `${harness} does not support transcript access`); };
291
374
  return {
package/dist/turns.d.ts CHANGED
@@ -9,6 +9,7 @@ export type TranscriptTool = Readonly<{
9
9
  output?: string;
10
10
  outputLines: number;
11
11
  outputBytes: number;
12
+ outcome?: 'failed' | 'rejected';
12
13
  }>;
13
14
  export type TranscriptTurn = Readonly<{
14
15
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/transcript",
3
- "version": "0.7.0-next.0",
3
+ "version": "0.7.0-next.10",
4
4
  "type": "module",
5
5
  "description": "Normalized agent transcripts: one parser per harness, a bounded interval reader over a native thread file or an in-memory event stream, and the full/delta frame protocol every transport and renderer share.",
6
6
  "files": [