@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 +2 -0
- package/dist/frames.js +2 -0
- package/dist/parsers.d.ts +8 -0
- package/dist/parsers.js +320 -22
- package/dist/readers.d.ts +7 -1
- package/dist/readers.js +87 -4
- package/dist/turns.d.ts +1 -0
- package/package.json +1 -1
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
|
|
97
|
+
// --- the seven native shapes -------------------------------------------------------------------------------
|
|
55
98
|
export function claudeEvent(value) {
|
|
56
99
|
const entry = object(value);
|
|
57
|
-
|
|
58
|
-
|
|
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:
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
189
|
+
return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: resultText(output) }] } : null;
|
|
120
190
|
}
|
|
121
191
|
return null;
|
|
122
192
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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.
|
|
260
|
-
|
|
261
|
-
this.
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spexcode/transcript",
|
|
3
|
-
"version": "0.7.0-next.
|
|
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": [
|