@spexcode/transcript 0.7.0-next.11 → 0.7.0-next.13
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/parsers.d.ts +4 -2
- package/dist/parsers.js +71 -39
- package/dist/readers.d.ts +6 -1
- package/dist/readers.js +84 -96
- package/dist/turns.d.ts +3 -0
- package/package.json +1 -1
package/dist/parsers.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type TranscriptRange, type TranscriptRead } from './turns.js';
|
|
1
|
+
import { type TranscriptRange, type TranscriptRead, type TurnOutcome } from './turns.js';
|
|
2
2
|
export declare const MAX_TURNS = 200;
|
|
3
3
|
export declare const MAX_OUTPUT_BYTES: number;
|
|
4
4
|
export type MutableTool = {
|
|
@@ -16,6 +16,8 @@ export type MutableTurn = {
|
|
|
16
16
|
role: 'user' | 'assistant';
|
|
17
17
|
text?: string;
|
|
18
18
|
tools: MutableTool[];
|
|
19
|
+
outcome?: TurnOutcome;
|
|
20
|
+
error?: string;
|
|
19
21
|
};
|
|
20
22
|
export type ToolOutcome = 'failed' | 'rejected';
|
|
21
23
|
export type ParsedEvent = {
|
|
@@ -34,7 +36,7 @@ export declare function codexAppServerEvent(value: unknown): ParsedEvent | null;
|
|
|
34
36
|
export declare function codexAppServerStream(): Parse;
|
|
35
37
|
export declare function piEvent(value: unknown): ParsedEvent | null;
|
|
36
38
|
export declare function geminiEvent(value: unknown): ParsedEvent | null;
|
|
37
|
-
export declare
|
|
39
|
+
export declare const openclawEvent: typeof piEvent;
|
|
38
40
|
export declare function hermesEvents(value: unknown): ParsedEvent[];
|
|
39
41
|
export declare function opencodeEvents(value: unknown): ParsedEvent[];
|
|
40
42
|
export declare class IntervalCollector {
|
package/dist/parsers.js
CHANGED
|
@@ -183,6 +183,18 @@ export function codexEvent(value) {
|
|
|
183
183
|
const rawInput = payload.input === undefined && payload.arguments === undefined ? undefined : compact(payload.input ?? payload.arguments);
|
|
184
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 }] } };
|
|
185
185
|
}
|
|
186
|
+
// THE ROLLOUT PUTS THE VERDICT IN A THIRD RECORD. Unlike every other source here, a codex result item
|
|
187
|
+
// carries no failure field at all — `function_call_output` and `custom_tool_call_output` have only
|
|
188
|
+
// `{call_id, output}`. The harness records how the command ended in a separate `event_msg`, joined by the
|
|
189
|
+
// same `call_id`: `exec_command_end.status` (completed | failed, 6,194 failed of 83,990 in the rollouts on
|
|
190
|
+
// this box, matching exactly the nonzero exit codes) and `patch_apply_end.success`. Without this second
|
|
191
|
+
// join every failed command in a codex transcript reads as an ordinary one. The outcome is carried alone,
|
|
192
|
+
// with no text, so it lands on the call the output record already filled.
|
|
193
|
+
if (entry.type === 'event_msg' && (type === 'exec_command_end' || type === 'patch_apply_end')) {
|
|
194
|
+
const id = string(payload.call_id);
|
|
195
|
+
const failed = string(payload.status) === 'failed' || payload.success === false;
|
|
196
|
+
return id && failed ? { at: eventAt, turn: null, toolOutputs: [{ id, text: '', outcome: 'failed' }] } : { at: eventAt, turn: null };
|
|
197
|
+
}
|
|
186
198
|
if (entry.type === 'response_item' && (type === 'custom_tool_call_output' || type === 'function_call_output')) {
|
|
187
199
|
const id = string(payload.call_id ?? payload.id);
|
|
188
200
|
const output = payload.output ?? payload.result ?? '';
|
|
@@ -190,6 +202,17 @@ export function codexEvent(value) {
|
|
|
190
202
|
}
|
|
191
203
|
return null;
|
|
192
204
|
}
|
|
205
|
+
// A file edit names the paths it touched, and its result is the diff the app-server already computed. Both
|
|
206
|
+
// come off `FileUpdateChange[]`, which is the only shape on the union whose payload is a list of records
|
|
207
|
+
// rather than one field.
|
|
208
|
+
const filePaths = (changes) => {
|
|
209
|
+
const paths = items(changes).map((change) => string(object(change)?.path)).filter(Boolean);
|
|
210
|
+
return paths.length ? paths.join('\n') : undefined;
|
|
211
|
+
};
|
|
212
|
+
const fileDiffs = (changes) => {
|
|
213
|
+
const diffs = items(changes).map((change) => string(object(change)?.diff)).filter(Boolean);
|
|
214
|
+
return diffs.length ? diffs.join('\n') : undefined;
|
|
215
|
+
};
|
|
193
216
|
// Codex app-server notifications are a different native stream from rollout lines. Keep this mapping stateless:
|
|
194
217
|
// a caller that needs streamed prose uses codexAppServerStream below, while file and in-memory sources still
|
|
195
218
|
// share the same one-record parser contract.
|
|
@@ -203,7 +226,12 @@ export function codexAppServerEvent(value) {
|
|
|
203
226
|
const recognized = method === 'item/agentMessage/delta' || method === 'item/started' || method === 'item/completed';
|
|
204
227
|
if (!recognized)
|
|
205
228
|
return null;
|
|
206
|
-
|
|
229
|
+
// `emittedAtMs` is a SIBLING of `method` and `params`, not a field inside them — the app-server's own
|
|
230
|
+
// generated envelope type puts it there, and every line of the capture in `fixtures/codex-app-server` has
|
|
231
|
+
// exactly the keys `method`, `params`, `emittedAtMs`. Reading it from `params` found nothing, which made
|
|
232
|
+
// every `item/agentMessage/delta` clockless and therefore dropped: the streaming path parsed and produced
|
|
233
|
+
// nothing at all. The lifecycle clocks below do sit in `params`.
|
|
234
|
+
const eventAt = timestamp(entry.emittedAtMs) ?? timestamp(params.startedAtMs) ?? timestamp(params.completedAtMs);
|
|
207
235
|
if (eventAt === null)
|
|
208
236
|
return { at: null, turn: null };
|
|
209
237
|
if (method === 'item/agentMessage/delta') {
|
|
@@ -227,27 +255,35 @@ export function codexAppServerEvent(value) {
|
|
|
227
255
|
const text = string(item.text);
|
|
228
256
|
return { at: eventAt, turn: { id, at: eventAt, role: 'assistant', text: text ?? undefined, tools: [] } };
|
|
229
257
|
}
|
|
230
|
-
|
|
258
|
+
// The tool-bearing variants of the app-server's own `ThreadItem` union. `functionCall` and `customToolCall`
|
|
259
|
+
// used to be listed here and are NOT members of it — those are ROLLOUT record types, and no such string
|
|
260
|
+
// exists anywhere in the app-server binary. `fileChange` is a real one that was missing, so a codex file
|
|
261
|
+
// edit appeared as no call at all.
|
|
262
|
+
const toolTypes = new Set(['commandExecution', 'fileChange', 'mcpToolCall', 'dynamicToolCall']);
|
|
231
263
|
if (!toolTypes.has(type))
|
|
232
264
|
return null;
|
|
233
265
|
if (method === 'item/started') {
|
|
234
266
|
const name = type === 'commandExecution' ? 'command'
|
|
235
|
-
:
|
|
267
|
+
: type === 'fileChange' ? 'edit'
|
|
268
|
+
: string(item.name) ?? string(item.tool) ?? (type === 'mcpToolCall' ? 'mcp' : 'tool');
|
|
236
269
|
const input = item.arguments !== undefined ? item.arguments
|
|
237
270
|
: item.input !== undefined ? item.input
|
|
238
271
|
: item.command !== undefined ? item.command
|
|
239
|
-
:
|
|
272
|
+
: type === 'fileChange' ? filePaths(item.changes)
|
|
273
|
+
: undefined;
|
|
240
274
|
return { at: eventAt, turn: { id, at: eventAt, role: 'assistant', tools: [{ id, name, input: input === undefined ? undefined : compact(input), outputLines: 0, outputBytes: 0 }] } };
|
|
241
275
|
}
|
|
242
276
|
let output = undefined;
|
|
243
277
|
if (type === 'commandExecution')
|
|
244
278
|
output = item.aggregatedOutput;
|
|
245
|
-
|
|
246
|
-
|
|
279
|
+
// an MCP result is `{content, structuredContent, _meta}`: the text is in `content`, and handing the wrapper
|
|
280
|
+
// to `resultText` printed the JSON envelope instead of what the tool said
|
|
247
281
|
else if (type === 'mcpToolCall')
|
|
248
|
-
output = item.result ?? item.error;
|
|
282
|
+
output = object(item.result)?.content ?? item.result ?? object(item.error)?.message ?? item.error;
|
|
249
283
|
else if (type === 'dynamicToolCall')
|
|
250
284
|
output = item.contentItems ?? item.output ?? item.error;
|
|
285
|
+
else if (type === 'fileChange')
|
|
286
|
+
output = fileDiffs(item.changes);
|
|
251
287
|
// the item status is the app-server's own verdict: `failed`, or `declined` when the person refused the call —
|
|
252
288
|
// a declined call has no output, so the empty result is what ends its "running"
|
|
253
289
|
const status = string(item.status);
|
|
@@ -313,6 +349,16 @@ export function piEvent(value) {
|
|
|
313
349
|
turn.tools.push({ id, name: string(block.name) ?? 'tool', input: block.arguments === undefined ? undefined : compact(block.arguments), outputLines: 0, outputBytes: 0 });
|
|
314
350
|
}
|
|
315
351
|
}
|
|
352
|
+
// the producer's verdict on the TURN (`StopReason` in pi's shipped types): `error` is the provider's own
|
|
353
|
+
// failure, `aborted` the turn a stop ended. These are exactly the turns that carry no text and no calls —
|
|
354
|
+
// 13 of 578 assistant messages in the sessions on this box — so without them the turn is an empty gap.
|
|
355
|
+
const stop = string(message.stopReason);
|
|
356
|
+
if (stop === 'error' || stop === 'aborted') {
|
|
357
|
+
turn.outcome = stop === 'aborted' ? 'cancelled' : 'failed';
|
|
358
|
+
const reason = string(message.errorMessage);
|
|
359
|
+
if (reason)
|
|
360
|
+
turn.error = reason;
|
|
361
|
+
}
|
|
316
362
|
return { at: eventAt, turn };
|
|
317
363
|
}
|
|
318
364
|
if (message.role === 'toolResult') {
|
|
@@ -360,37 +406,23 @@ export function geminiEvent(value) {
|
|
|
360
406
|
}
|
|
361
407
|
return null;
|
|
362
408
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
}
|
|
409
|
+
// OPENCLAW WRITES PI'S FORMAT. Verified field by field against pi's shipped schema: the `{"type":"session",
|
|
410
|
+
// "version":3}` header, the 8-char hex id tree, the `user|assistant|toolResult` roles, the `text`/`thinking`/
|
|
411
|
+
// `toolCall` blocks, `toolResult`'s exact key set. The only OpenClaw-specific things are its own `customType`
|
|
412
|
+
// namespace and where the file lives — neither of which a parser reads. So this is not a second parser: two
|
|
413
|
+
// hand-written copies had already drifted into two defects (a different clock preference on identical bytes,
|
|
414
|
+
// and dropping every turn with no text and no calls, which is precisely the failed and aborted ones). Where a
|
|
415
|
+
// harness differs only in where its file sits, the adapter row is the LOCATOR ([[transcript-reader]]), never a
|
|
416
|
+
// second copy of the parse.
|
|
417
|
+
export const openclawEvent = piEvent;
|
|
418
|
+
// HERMES COUNTS IN SECONDS. Its export writes `timestamp` as a float epoch SECOND (`1787942674.556185`),
|
|
419
|
+
// where every other harness here writes milliseconds and `at()` reads a bare number as one. Unconverted, a
|
|
420
|
+
// Hermes turn lands in January 1970 and every interval read of a real thread — the session API hands `from`
|
|
421
|
+
// and `to` as epoch ms — comes back empty. The unit is this producer's, so the conversion is this adapter's.
|
|
422
|
+
const hermesAt = (message) => {
|
|
423
|
+
const seconds = at(message);
|
|
424
|
+
return seconds === null ? null : Math.round(seconds * 1000);
|
|
425
|
+
};
|
|
394
426
|
export function hermesEvents(value) {
|
|
395
427
|
const root = object(value);
|
|
396
428
|
const events = [];
|
|
@@ -398,7 +430,7 @@ export function hermesEvents(value) {
|
|
|
398
430
|
const message = object(messageValue);
|
|
399
431
|
if (!message)
|
|
400
432
|
continue;
|
|
401
|
-
const eventAt =
|
|
433
|
+
const eventAt = hermesAt(message);
|
|
402
434
|
if (eventAt === null) {
|
|
403
435
|
events.push({ at: null, turn: null });
|
|
404
436
|
continue;
|
package/dist/readers.d.ts
CHANGED
|
@@ -4,13 +4,18 @@ export declare function codexRolloutPath(threadId: string, root?: string, archiv
|
|
|
4
4
|
export declare function piSessionPath(threadId: string, root?: string): string | null;
|
|
5
5
|
export declare function geminiTranscriptPath(threadId: string, root?: string): string | null;
|
|
6
6
|
export declare function openclawTranscriptPath(threadId: string, root?: string): string | null;
|
|
7
|
+
export declare const claudeTranscriptReader: (root?: string) => TranscriptReader;
|
|
8
|
+
export declare const codexTranscriptReader: (root?: string) => TranscriptReader;
|
|
9
|
+
export declare const piTranscriptReader: (root?: string) => TranscriptReader;
|
|
10
|
+
export declare const geminiTranscriptReader: (root?: string) => TranscriptReader;
|
|
11
|
+
export declare const openclawTranscriptReader: (root?: string) => TranscriptReader;
|
|
7
12
|
export declare const claudeTranscript: TranscriptReader;
|
|
8
13
|
export declare const codexTranscript: TranscriptReader;
|
|
9
14
|
export declare const piTranscript: TranscriptReader;
|
|
10
15
|
export declare const geminiTranscript: TranscriptReader;
|
|
11
16
|
export declare const openclawTranscript: TranscriptReader;
|
|
12
17
|
export declare function opencodeTranscriptReader(root?: string, load?: (threadId: string) => string): TranscriptReader;
|
|
13
|
-
export declare const opencodeTranscript: TranscriptReader;
|
|
14
18
|
export declare function hermesTranscriptReader(root?: string, load?: (threadId: string) => string): TranscriptReader;
|
|
19
|
+
export declare const opencodeTranscript: TranscriptReader;
|
|
15
20
|
export declare const hermesTranscript: TranscriptReader;
|
|
16
21
|
export declare function unsupportedTranscript(harness: string): TranscriptReader;
|
package/dist/readers.js
CHANGED
|
@@ -108,29 +108,9 @@ const openclawRoot = () => process.env.OPENCLAW_STATE_DIR || join(homedir(), '.o
|
|
|
108
108
|
export function openclawTranscriptPath(threadId, root = openclawRoot()) { return findJsonl(root, threadId); }
|
|
109
109
|
const opencodeStoreRoot = () => process.env.SPEXCODE_OPENCODE_DATA_DIR
|
|
110
110
|
|| join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'opencode');
|
|
111
|
-
function opencodeStoreRevision(root) {
|
|
112
|
-
try {
|
|
113
|
-
const database = statSync(join(root, 'opencode.db'));
|
|
114
|
-
let writeAheadLog = '0:0';
|
|
115
|
-
try {
|
|
116
|
-
const stat = statSync(join(root, 'opencode.db-wal'));
|
|
117
|
-
writeAheadLog = `${stat.size}:${Math.floor(stat.mtimeMs)}`;
|
|
118
|
-
}
|
|
119
|
-
catch { /* a checkpointed database has no separate write-ahead log */ }
|
|
120
|
-
return `${database.size}:${Math.floor(database.mtimeMs)}:${writeAheadLog}`;
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
111
|
// The export is read RAW: `--sanitize` replaces every prose and tool-output part with a `[redacted:…]` token,
|
|
127
112
|
// which made the whole conversation unreadable; the reader hands over the same local bytes the other harnesses'
|
|
128
113
|
// files hold, and nothing here leaves the machine that ran the thread.
|
|
129
|
-
function opencodeExport(threadId) {
|
|
130
|
-
return execFileSync(process.env.SPEXCODE_OPENCODE_CMD || 'opencode', ['export', threadId], {
|
|
131
|
-
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
114
|
const fileRevision = (path) => {
|
|
135
115
|
try {
|
|
136
116
|
const stat = statSync(path);
|
|
@@ -147,7 +127,14 @@ const intervalOffsets = new Map();
|
|
|
147
127
|
// One pass over the bytes from `scan.position` to the end of the file. Every complete line is parsed as JSON
|
|
148
128
|
// and handed to `onLine` with its byte offset; `onLine` returning true stops the scan early (a bounded
|
|
149
129
|
// lookahead), which abandons the rest — only a one-shot read does that.
|
|
150
|
-
|
|
130
|
+
// ONE UNREADABLE LINE IS OMITTED PAYLOAD, NOT AN UNREADABLE TRANSCRIPT. A native log is written by another
|
|
131
|
+
// process and can carry a line that is not JSON — a truncated record from a crash, a line someone appended by
|
|
132
|
+
// hand. Throwing on it makes the whole thread unreadable forever, which is the loudest possible failure and
|
|
133
|
+
// the least useful one: the person loses a conversation over one bad line. The reader already has an honest
|
|
134
|
+
// word for this — the line's bytes are counted as omitted and the read reports `truncated`, exactly as it does
|
|
135
|
+
// for a result past the cap. A file that is not this format at all still fails loudly, because nothing in it
|
|
136
|
+
// parses and `finish()` refuses a read that never saw a timestamp.
|
|
137
|
+
function scanLines(fd, scan, onLine, onUnparsable) {
|
|
151
138
|
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
152
139
|
let { position, carry } = scan;
|
|
153
140
|
let lineStart = position - carry.length;
|
|
@@ -171,8 +158,9 @@ function scanLines(harness, fd, scan, onLine) {
|
|
|
171
158
|
try {
|
|
172
159
|
value = JSON.parse(line);
|
|
173
160
|
}
|
|
174
|
-
catch
|
|
175
|
-
|
|
161
|
+
catch {
|
|
162
|
+
onUnparsable(Buffer.byteLength(line));
|
|
163
|
+
continue;
|
|
176
164
|
}
|
|
177
165
|
if (onLine(value, lineOffset))
|
|
178
166
|
return { position, carry: Buffer.alloc(0) };
|
|
@@ -229,7 +217,7 @@ class LineFileCursor {
|
|
|
229
217
|
try {
|
|
230
218
|
fd = openSync(this.path, 'r');
|
|
231
219
|
let postRangeLines = 0;
|
|
232
|
-
this.scan = scanLines(
|
|
220
|
+
this.scan = scanLines(fd, this.scan, (value, offset) => {
|
|
233
221
|
const event = this.parse(value);
|
|
234
222
|
if (!event)
|
|
235
223
|
return false;
|
|
@@ -238,7 +226,7 @@ class LineFileCursor {
|
|
|
238
226
|
intervalOffsets.set(this.seekKey, offset);
|
|
239
227
|
const pastRange = this.collector.add(event);
|
|
240
228
|
return pastRange && ++postRangeLines >= lookahead;
|
|
241
|
-
});
|
|
229
|
+
}, (bytes) => { this.collector.omittedBytes += bytes; });
|
|
242
230
|
}
|
|
243
231
|
catch (error) {
|
|
244
232
|
if (error instanceof TranscriptReadError)
|
|
@@ -274,100 +262,100 @@ function lineFileReader(harness, locate, parse) {
|
|
|
274
262
|
},
|
|
275
263
|
};
|
|
276
264
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
export const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
export
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
value = JSON.parse(exported);
|
|
305
|
-
}
|
|
306
|
-
catch (error) {
|
|
307
|
-
throw new TranscriptReadError('invalid', `opencode transcript cannot be parsed: ${error instanceof Error ? error.message : String(error)}`);
|
|
308
|
-
}
|
|
309
|
-
cached = { revision, events: opencodeEvents(value) };
|
|
310
|
-
opencodeExports.set(key, cached);
|
|
311
|
-
}
|
|
312
|
-
const collector = new IntervalCollector(range);
|
|
313
|
-
for (const event of cached.events)
|
|
314
|
-
collector.add(event);
|
|
315
|
-
return collector.finish(revision, 'opencode');
|
|
316
|
-
},
|
|
317
|
-
};
|
|
318
|
-
return {
|
|
319
|
-
...reader,
|
|
320
|
-
// no file grows here: an open interval is re-collected from the cached export, which is one export per revision
|
|
321
|
-
tail: (threadId, from) => ({ advance: (to) => reader.read(threadId, { from, to }), close: () => { } }),
|
|
322
|
-
};
|
|
323
|
-
}
|
|
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;
|
|
265
|
+
// A ROOT IS A PARAMETER OF EVERY READER, not of some of them. Each locator already takes one; only the store
|
|
266
|
+
// readers exposed it, so anything wanting a second pi root — a producer under an isolated agent dir, a test —
|
|
267
|
+
// had no way to ask. Passing nothing keeps the old behaviour exactly: the locator's own default is evaluated
|
|
268
|
+
// per call, so a late `CLAUDE_CONFIG_DIR` is still picked up.
|
|
269
|
+
export const claudeTranscriptReader = (root) => lineFileReader('claude', (threadId) => claudeTranscriptPath(threadId, root ?? projectTranscriptRoot()), claudeEvent);
|
|
270
|
+
export const codexTranscriptReader = (root) => lineFileReader('codex', (threadId) => codexRolloutPath(threadId, root ?? codexSessionsDir()), codexEvent);
|
|
271
|
+
export const piTranscriptReader = (root) => lineFileReader('pi', (threadId) => piSessionPath(threadId, root ?? piSessionsRoot()), piEvent);
|
|
272
|
+
export const geminiTranscriptReader = (root) => lineFileReader('gemini', (threadId) => geminiTranscriptPath(threadId, root ?? geminiRoot()), geminiEvent);
|
|
273
|
+
export const openclawTranscriptReader = (root) => lineFileReader('openclaw', (threadId) => openclawTranscriptPath(threadId, root ?? openclawRoot()), openclawEvent);
|
|
274
|
+
export const claudeTranscript = claudeTranscriptReader();
|
|
275
|
+
export const codexTranscript = codexTranscriptReader();
|
|
276
|
+
export const piTranscript = piTranscriptReader();
|
|
277
|
+
export const geminiTranscript = geminiTranscriptReader();
|
|
278
|
+
export const openclawTranscript = openclawTranscriptReader();
|
|
279
|
+
function storeRevision(root, files) {
|
|
280
|
+
const legs = [];
|
|
281
|
+
for (const [index, name] of files.entries()) {
|
|
282
|
+
try {
|
|
283
|
+
const stat = statSync(join(root, name));
|
|
284
|
+
legs.push(`${stat.size}:${Math.floor(stat.mtimeMs)}`);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// the database itself must exist; a checkpointed store simply has no separate write-ahead log
|
|
288
|
+
if (index === 0)
|
|
289
|
+
return null;
|
|
290
|
+
legs.push('0:0');
|
|
291
|
+
}
|
|
333
292
|
}
|
|
293
|
+
return legs.join(':');
|
|
334
294
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
const hermesExports = new Map();
|
|
339
|
-
export function hermesTranscriptReader(root = hermesRoot(), load = hermesExport) {
|
|
295
|
+
const storeExports = new Map();
|
|
296
|
+
function storeReader(source, root, load) {
|
|
340
297
|
const read = async (threadId, range) => {
|
|
341
|
-
const revision =
|
|
298
|
+
const revision = storeRevision(root, source.files);
|
|
342
299
|
if (!revision)
|
|
343
|
-
throw new TranscriptReadError('missing',
|
|
344
|
-
const key = `${root}:${threadId}`;
|
|
345
|
-
let cached =
|
|
300
|
+
throw new TranscriptReadError('missing', `${source.harness} transcript for ${threadId} is unavailable: ${source.missing}`);
|
|
301
|
+
const key = `${source.harness}:${root}:${threadId}`;
|
|
302
|
+
let cached = storeExports.get(key);
|
|
346
303
|
if (!cached || cached.revision !== revision) {
|
|
347
304
|
let exported;
|
|
348
305
|
try {
|
|
349
306
|
exported = load(threadId);
|
|
350
307
|
}
|
|
351
308
|
catch (error) {
|
|
352
|
-
throw new TranscriptReadError('unreadable',
|
|
309
|
+
throw new TranscriptReadError('unreadable', `${source.harness} transcript could not be exported: ${error instanceof Error ? error.message : String(error)}`);
|
|
353
310
|
}
|
|
354
311
|
let value;
|
|
355
312
|
try {
|
|
356
313
|
value = JSON.parse(exported);
|
|
357
314
|
}
|
|
358
315
|
catch (error) {
|
|
359
|
-
throw new TranscriptReadError('invalid',
|
|
316
|
+
throw new TranscriptReadError('invalid', `${source.harness} transcript cannot be parsed: ${error instanceof Error ? error.message : String(error)}`);
|
|
360
317
|
}
|
|
361
|
-
cached = { revision, events:
|
|
362
|
-
|
|
318
|
+
cached = { revision, events: source.parse(value) };
|
|
319
|
+
storeExports.set(key, cached);
|
|
363
320
|
}
|
|
364
321
|
const collector = new IntervalCollector(range);
|
|
365
322
|
for (const event of cached.events)
|
|
366
323
|
collector.add(event);
|
|
367
|
-
return collector.finish(revision,
|
|
324
|
+
return collector.finish(revision, source.harness);
|
|
325
|
+
};
|
|
326
|
+
return {
|
|
327
|
+
revision: () => storeRevision(root, source.files),
|
|
328
|
+
read,
|
|
329
|
+
// no file grows here: an open interval is re-collected from the cached export, one export per revision
|
|
330
|
+
tail: (threadId, from) => ({ advance: (to) => read(threadId, { from, to }), close: () => { } }),
|
|
368
331
|
};
|
|
369
|
-
return { revision: () => hermesRevision(root), read, tail: (threadId, from) => ({ advance: (to) => read(threadId, { from, to }), close: () => { } }) };
|
|
370
332
|
}
|
|
333
|
+
// The export is read RAW: OpenCode's `--sanitize` replaces every prose and tool-output part with a
|
|
334
|
+
// `[redacted:…]` token, which made the whole conversation unreadable; the reader hands over the same local
|
|
335
|
+
// bytes the other harnesses' files hold, and nothing here leaves the machine that ran the thread.
|
|
336
|
+
const OPENCODE_STORE = {
|
|
337
|
+
harness: 'opencode',
|
|
338
|
+
defaultRoot: opencodeStoreRoot,
|
|
339
|
+
files: ['opencode.db', 'opencode.db-wal'],
|
|
340
|
+
missing: 'store was not found',
|
|
341
|
+
load: (threadId) => execFileSync(process.env.SPEXCODE_OPENCODE_CMD || 'opencode', ['export', threadId], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }),
|
|
342
|
+
parse: opencodeEvents,
|
|
343
|
+
};
|
|
344
|
+
const HERMES_STORE = {
|
|
345
|
+
harness: 'hermes',
|
|
346
|
+
defaultRoot: () => process.env.HERMES_HOME || join(homedir(), '.hermes', 'profiles', 'default'),
|
|
347
|
+
files: ['state.db', 'state.db-wal'],
|
|
348
|
+
missing: 'state.db was not found',
|
|
349
|
+
load: (threadId) => execFileSync(process.env.SPEXCODE_HERMES_CMD || 'hermes', ['sessions', 'export', '--format', 'jsonl', '--session-id', threadId, '--yes'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }),
|
|
350
|
+
parse: hermesEvents,
|
|
351
|
+
};
|
|
352
|
+
export function opencodeTranscriptReader(root = OPENCODE_STORE.defaultRoot(), load = OPENCODE_STORE.load) {
|
|
353
|
+
return storeReader(OPENCODE_STORE, root, load);
|
|
354
|
+
}
|
|
355
|
+
export function hermesTranscriptReader(root = HERMES_STORE.defaultRoot(), load = HERMES_STORE.load) {
|
|
356
|
+
return storeReader(HERMES_STORE, root, load);
|
|
357
|
+
}
|
|
358
|
+
export const opencodeTranscript = opencodeTranscriptReader();
|
|
371
359
|
export const hermesTranscript = hermesTranscriptReader();
|
|
372
360
|
export function unsupportedTranscript(harness) {
|
|
373
361
|
const refuse = async () => { throw new TranscriptReadError('unsupported', `${harness} does not support transcript access`); };
|
package/dist/turns.d.ts
CHANGED
|
@@ -11,12 +11,15 @@ export type TranscriptTool = Readonly<{
|
|
|
11
11
|
outputBytes: number;
|
|
12
12
|
outcome?: 'failed' | 'rejected';
|
|
13
13
|
}>;
|
|
14
|
+
export type TurnOutcome = 'failed' | 'cancelled';
|
|
14
15
|
export type TranscriptTurn = Readonly<{
|
|
15
16
|
id: string;
|
|
16
17
|
at: number;
|
|
17
18
|
role: 'user' | 'assistant';
|
|
18
19
|
text?: string;
|
|
19
20
|
tools?: readonly TranscriptTool[];
|
|
21
|
+
outcome?: TurnOutcome;
|
|
22
|
+
error?: string;
|
|
20
23
|
}>;
|
|
21
24
|
export type TranscriptRead = Readonly<{
|
|
22
25
|
revision: string;
|
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.13",
|
|
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": [
|