@spexcode/transcript 0.7.0-next.0 → 0.7.0-next.2
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 +5 -0
- package/dist/parsers.js +231 -9
- package/dist/readers.d.ts +7 -1
- package/dist/readers.js +87 -4
- 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
|
@@ -27,7 +27,12 @@ export type ParsedEvent = {
|
|
|
27
27
|
export type Parse = (value: unknown) => ParsedEvent | null;
|
|
28
28
|
export declare function claudeEvent(value: unknown): ParsedEvent | null;
|
|
29
29
|
export declare function codexEvent(value: unknown): ParsedEvent | null;
|
|
30
|
+
export declare function codexAppServerEvent(value: unknown): ParsedEvent | null;
|
|
31
|
+
export declare function codexAppServerStream(): Parse;
|
|
30
32
|
export declare function piEvent(value: unknown): ParsedEvent | null;
|
|
33
|
+
export declare function geminiEvent(value: unknown): ParsedEvent | null;
|
|
34
|
+
export declare function openclawEvent(value: unknown): ParsedEvent | null;
|
|
35
|
+
export declare function hermesEvents(value: unknown): ParsedEvent[];
|
|
31
36
|
export declare function opencodeEvents(value: unknown): ParsedEvent[];
|
|
32
37
|
export declare class IntervalCollector {
|
|
33
38
|
readonly turns: MutableTurn[];
|
package/dist/parsers.js
CHANGED
|
@@ -51,7 +51,7 @@ const compact = (value) => {
|
|
|
51
51
|
}
|
|
52
52
|
};
|
|
53
53
|
const lineCount = (value) => value ? value.split(/\r?\n/).length : 0;
|
|
54
|
-
// --- the
|
|
54
|
+
// --- the seven native shapes -------------------------------------------------------------------------------
|
|
55
55
|
export function claudeEvent(value) {
|
|
56
56
|
const entry = object(value);
|
|
57
57
|
const message = object(entry?.message);
|
|
@@ -120,6 +120,101 @@ export function codexEvent(value) {
|
|
|
120
120
|
}
|
|
121
121
|
return null;
|
|
122
122
|
}
|
|
123
|
+
// Codex app-server notifications are a different native stream from rollout lines. Keep this mapping stateless:
|
|
124
|
+
// a caller that needs streamed prose uses codexAppServerStream below, while file and in-memory sources still
|
|
125
|
+
// share the same one-record parser contract.
|
|
126
|
+
export function codexAppServerEvent(value) {
|
|
127
|
+
const entry = object(value);
|
|
128
|
+
const params = object(entry?.params);
|
|
129
|
+
if (!entry || !params || typeof entry.method !== 'string')
|
|
130
|
+
return null;
|
|
131
|
+
const item = object(params.item);
|
|
132
|
+
const method = entry.method;
|
|
133
|
+
const recognized = method === 'item/agentMessage/delta' || method === 'item/started' || method === 'item/completed';
|
|
134
|
+
if (!recognized)
|
|
135
|
+
return null;
|
|
136
|
+
const eventAt = timestamp(params.emittedAtMs) ?? timestamp(params.startedAtMs) ?? timestamp(params.completedAtMs);
|
|
137
|
+
if (eventAt === null)
|
|
138
|
+
return { at: null, turn: null };
|
|
139
|
+
if (method === 'item/agentMessage/delta') {
|
|
140
|
+
const id = string(params.itemId);
|
|
141
|
+
const delta = string(params.delta);
|
|
142
|
+
return id && delta !== null
|
|
143
|
+
? { at: eventAt, turn: { id, at: eventAt, role: 'assistant', text: delta, tools: [] } }
|
|
144
|
+
: null;
|
|
145
|
+
}
|
|
146
|
+
if ((method !== 'item/started' && method !== 'item/completed') || !item)
|
|
147
|
+
return null;
|
|
148
|
+
const id = string(item.id);
|
|
149
|
+
const type = string(item.type);
|
|
150
|
+
if (!id || !type)
|
|
151
|
+
return null;
|
|
152
|
+
if (type === 'userMessage') {
|
|
153
|
+
const text = blockText(item.content);
|
|
154
|
+
return text ? { at: eventAt, turn: { id, at: eventAt, role: 'user', text, tools: [] } } : null;
|
|
155
|
+
}
|
|
156
|
+
if (type === 'agentMessage') {
|
|
157
|
+
const text = string(item.text);
|
|
158
|
+
return { at: eventAt, turn: { id, at: eventAt, role: 'assistant', text: text ?? undefined, tools: [] } };
|
|
159
|
+
}
|
|
160
|
+
const toolTypes = new Set(['commandExecution', 'functionCall', 'customToolCall', 'mcpToolCall', 'dynamicToolCall']);
|
|
161
|
+
if (!toolTypes.has(type))
|
|
162
|
+
return null;
|
|
163
|
+
if (method === 'item/started') {
|
|
164
|
+
const name = type === 'commandExecution' ? 'command'
|
|
165
|
+
: string(item.name) ?? string(item.tool) ?? (type === 'mcpToolCall' ? 'mcp' : 'tool');
|
|
166
|
+
const input = item.arguments !== undefined ? item.arguments
|
|
167
|
+
: item.input !== undefined ? item.input
|
|
168
|
+
: item.command !== undefined ? item.command
|
|
169
|
+
: undefined;
|
|
170
|
+
return { at: eventAt, turn: { id, at: eventAt, role: 'assistant', tools: [{ id, name, input: input === undefined ? undefined : compact(input), outputLines: 0, outputBytes: 0 }] } };
|
|
171
|
+
}
|
|
172
|
+
let output = undefined;
|
|
173
|
+
if (type === 'commandExecution')
|
|
174
|
+
output = item.aggregatedOutput;
|
|
175
|
+
else if (type === 'functionCall' || type === 'customToolCall')
|
|
176
|
+
output = item.output ?? item.result;
|
|
177
|
+
else if (type === 'mcpToolCall')
|
|
178
|
+
output = item.result ?? item.error;
|
|
179
|
+
else if (type === 'dynamicToolCall')
|
|
180
|
+
output = item.contentItems ?? item.output ?? item.error;
|
|
181
|
+
return output === undefined || output === null
|
|
182
|
+
? { at: eventAt, turn: null }
|
|
183
|
+
: { at: eventAt, turn: null, toolOutputs: [{ id, text: compact(output) }] };
|
|
184
|
+
}
|
|
185
|
+
// Agent-message deltas are fragments of one native item. The closure remembers only that item's text and
|
|
186
|
+
// re-emits its native id, allowing IntervalCollector to replace the earlier turn in place.
|
|
187
|
+
export function codexAppServerStream() {
|
|
188
|
+
const textByItem = new Map();
|
|
189
|
+
return (value) => {
|
|
190
|
+
const parsed = codexAppServerEvent(value);
|
|
191
|
+
if (!parsed)
|
|
192
|
+
return null;
|
|
193
|
+
const entry = object(value);
|
|
194
|
+
const params = object(entry?.params);
|
|
195
|
+
const item = object(params?.item);
|
|
196
|
+
if (entry?.method === 'item/agentMessage/delta') {
|
|
197
|
+
const id = string(params?.itemId);
|
|
198
|
+
if (!id || !parsed.turn)
|
|
199
|
+
return parsed;
|
|
200
|
+
const text = (textByItem.get(id) ?? '') + (parsed.turn.text ?? '');
|
|
201
|
+
textByItem.set(id, text);
|
|
202
|
+
return { ...parsed, turn: { ...parsed.turn, text } };
|
|
203
|
+
}
|
|
204
|
+
if (item && item.type === 'agentMessage') {
|
|
205
|
+
const id = string(item.id);
|
|
206
|
+
if (id) {
|
|
207
|
+
const text = string(item.text);
|
|
208
|
+
if (text !== null || !textByItem.has(id))
|
|
209
|
+
textByItem.set(id, text ?? '');
|
|
210
|
+
const turn = parsed.turn;
|
|
211
|
+
if (turn)
|
|
212
|
+
return { ...parsed, turn: { ...turn, text: textByItem.get(id) || undefined } };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return parsed;
|
|
216
|
+
};
|
|
217
|
+
}
|
|
123
218
|
const blockText = (content) => typeof content === 'string'
|
|
124
219
|
? string(content)
|
|
125
220
|
: items(content).map((block) => string(object(block)?.text)).filter(Boolean).join('\n') || null;
|
|
@@ -154,6 +249,116 @@ export function piEvent(value) {
|
|
|
154
249
|
}
|
|
155
250
|
return null;
|
|
156
251
|
}
|
|
252
|
+
export function geminiEvent(value) {
|
|
253
|
+
const entry = object(value);
|
|
254
|
+
const message = (entry?.type === 'user' || entry?.type === 'gemini')
|
|
255
|
+
? entry
|
|
256
|
+
: object(items(entry?.messages)[0]) ?? object(items(object(entry?.$set)?.messages)[0]);
|
|
257
|
+
if (!entry || !message)
|
|
258
|
+
return null;
|
|
259
|
+
const eventAt = at(message) ?? at(entry);
|
|
260
|
+
if (eventAt === null)
|
|
261
|
+
return { at: null, turn: null };
|
|
262
|
+
const content = message.content;
|
|
263
|
+
const blocks = items(content);
|
|
264
|
+
const outputs = blocks.flatMap((blockValue) => {
|
|
265
|
+
const block = object(blockValue);
|
|
266
|
+
const response = object(block?.functionResponse);
|
|
267
|
+
const id = string(response?.id);
|
|
268
|
+
return id ? [{ id, text: compact(response?.response ?? '') }] : [];
|
|
269
|
+
});
|
|
270
|
+
if (outputs.length)
|
|
271
|
+
return { at: eventAt, turn: null, toolOutputs: outputs };
|
|
272
|
+
if (message.type === 'user') {
|
|
273
|
+
const text = typeof content === 'string' ? string(content) : blocks.map((block) => string(object(block)?.text)).filter(Boolean).join('\n') || null;
|
|
274
|
+
return text ? { at: eventAt, turn: { id: idOf(message), at: eventAt, role: 'user', text, tools: [] } } : null;
|
|
275
|
+
}
|
|
276
|
+
if (message.type === 'gemini') {
|
|
277
|
+
const turn = { id: idOf(message), at: eventAt, role: 'assistant', tools: [] };
|
|
278
|
+
if (typeof content === 'string')
|
|
279
|
+
turn.text = string(content) ?? undefined;
|
|
280
|
+
for (const callValue of items(message.toolCalls)) {
|
|
281
|
+
const call = object(callValue);
|
|
282
|
+
const id = string(call?.id) ?? `tool-${turn.tools.length}`;
|
|
283
|
+
turn.tools.push({ id, name: string(call?.name) ?? 'tool', input: call?.args === undefined ? undefined : compact(call.args), outputLines: 0, outputBytes: 0 });
|
|
284
|
+
}
|
|
285
|
+
if (!turn.text && !turn.tools.length)
|
|
286
|
+
return null;
|
|
287
|
+
return { at: eventAt, turn };
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
export function openclawEvent(value) {
|
|
292
|
+
const entry = object(value);
|
|
293
|
+
const message = object(entry?.message);
|
|
294
|
+
if (!entry || entry.type !== 'message' || !message)
|
|
295
|
+
return null;
|
|
296
|
+
const eventAt = at(message) ?? at(entry);
|
|
297
|
+
if (eventAt === null)
|
|
298
|
+
return { at: null, turn: null };
|
|
299
|
+
if (message.role === 'user') {
|
|
300
|
+
const text = blockText(message.content);
|
|
301
|
+
return text ? { at: eventAt, turn: { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'user', text, tools: [] } } : null;
|
|
302
|
+
}
|
|
303
|
+
if (message.role === 'toolResult') {
|
|
304
|
+
const id = string(message.toolCallId);
|
|
305
|
+
return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: blockText(message.content) ?? compact(message.content ?? '') }] } : null;
|
|
306
|
+
}
|
|
307
|
+
if (message.role === 'assistant') {
|
|
308
|
+
const turn = { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'assistant', tools: [] };
|
|
309
|
+
for (const blockValue of items(message.content)) {
|
|
310
|
+
const block = object(blockValue);
|
|
311
|
+
if (block?.type === 'text')
|
|
312
|
+
turn.text = [turn.text, string(block.text)].filter(Boolean).join('\n') || undefined;
|
|
313
|
+
if (block?.type === 'toolCall') {
|
|
314
|
+
const id = string(block.id) ?? `tool-${turn.tools.length}`;
|
|
315
|
+
turn.tools.push({ id, name: string(block.name) ?? 'tool', input: block.arguments === undefined ? undefined : compact(block.arguments), outputLines: 0, outputBytes: 0 });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return turn.text || turn.tools.length ? { at: eventAt, turn } : null;
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
export function hermesEvents(value) {
|
|
323
|
+
const root = object(value);
|
|
324
|
+
const events = [];
|
|
325
|
+
for (const messageValue of items(root?.messages)) {
|
|
326
|
+
const message = object(messageValue);
|
|
327
|
+
if (!message)
|
|
328
|
+
continue;
|
|
329
|
+
const eventAt = at(message);
|
|
330
|
+
if (eventAt === null) {
|
|
331
|
+
events.push({ at: null, turn: null });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const role = message.role;
|
|
335
|
+
if (role === 'user') {
|
|
336
|
+
const text = string(message.content);
|
|
337
|
+
if (text)
|
|
338
|
+
events.push({ at: eventAt, turn: { id: idOf(message), at: eventAt, role: 'user', text, tools: [] } });
|
|
339
|
+
}
|
|
340
|
+
else if (role === 'assistant') {
|
|
341
|
+
const turn = { id: idOf(message), at: eventAt, role: 'assistant', tools: [] };
|
|
342
|
+
const text = string(message.content);
|
|
343
|
+
if (text)
|
|
344
|
+
turn.text = text;
|
|
345
|
+
for (const callValue of items(message.tool_calls)) {
|
|
346
|
+
const call = object(callValue);
|
|
347
|
+
const fn = object(call?.function);
|
|
348
|
+
const id = string(call?.id) ?? `tool-${turn.tools.length}`;
|
|
349
|
+
turn.tools.push({ id, name: string(fn?.name) ?? 'tool', input: fn?.arguments === undefined ? undefined : compact(fn.arguments), outputLines: 0, outputBytes: 0 });
|
|
350
|
+
}
|
|
351
|
+
if (turn.text || turn.tools.length)
|
|
352
|
+
events.push({ at: eventAt, turn });
|
|
353
|
+
}
|
|
354
|
+
else if (role === 'tool') {
|
|
355
|
+
const id = string(message.tool_call_id);
|
|
356
|
+
if (id)
|
|
357
|
+
events.push({ at: eventAt, turn: null, toolOutputs: [{ id, text: compact(message.content ?? '') }] });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return events;
|
|
361
|
+
}
|
|
157
362
|
// OpenCode's export is one JSON document, not a line stream: every message arrives with its parts, and a tool
|
|
158
363
|
// part already carries its own result — so its turn is complete on arrival, and a part still running simply has
|
|
159
364
|
// no output yet.
|
|
@@ -256,14 +461,31 @@ export class IntervalCollector {
|
|
|
256
461
|
this.synthesized.set(base, seen + 1);
|
|
257
462
|
turn.id = seen ? `${base}#${seen}` : base;
|
|
258
463
|
}
|
|
259
|
-
this.turns.
|
|
260
|
-
|
|
261
|
-
this.
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
464
|
+
const existingAt = this.turns.findIndex((candidate) => candidate.id === turn.id);
|
|
465
|
+
if (existingAt >= 0) {
|
|
466
|
+
const existing = this.turns[existingAt];
|
|
467
|
+
const incomingTools = new Map(turn.tools.map((tool) => [tool.id, tool]));
|
|
468
|
+
const tools = existing.tools.map((tool) => {
|
|
469
|
+
const incoming = incomingTools.get(tool.id);
|
|
470
|
+
incomingTools.delete(tool.id);
|
|
471
|
+
return incoming ? { ...tool, ...incoming, output: incoming.output ?? tool.output } : tool;
|
|
472
|
+
});
|
|
473
|
+
tools.push(...incomingTools.values());
|
|
474
|
+
const replacement = { ...existing, ...turn, text: turn.text ?? existing.text, tools };
|
|
475
|
+
this.turns[existingAt] = replacement;
|
|
476
|
+
for (const tool of replacement.tools)
|
|
477
|
+
this.byTool.set(tool.id, tool);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
this.turns.push(turn);
|
|
481
|
+
for (const tool of turn.tools)
|
|
482
|
+
this.byTool.set(tool.id, tool);
|
|
483
|
+
if (this.turns.length > MAX_TURNS) {
|
|
484
|
+
const dropped = this.turns.shift();
|
|
485
|
+
for (const tool of dropped.tools)
|
|
486
|
+
this.evicted.add(tool.id);
|
|
487
|
+
this.omittedTurns++;
|
|
488
|
+
}
|
|
267
489
|
}
|
|
268
490
|
}
|
|
269
491
|
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/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.2",
|
|
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": [
|