@spexcode/transcript 0.7.0-next.12 → 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.js +25 -6
- package/dist/readers.d.ts +6 -1
- package/dist/readers.js +84 -96
- package/package.json +1 -1
package/dist/parsers.js
CHANGED
|
@@ -202,6 +202,17 @@ export function codexEvent(value) {
|
|
|
202
202
|
}
|
|
203
203
|
return null;
|
|
204
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
|
+
};
|
|
205
216
|
// Codex app-server notifications are a different native stream from rollout lines. Keep this mapping stateless:
|
|
206
217
|
// a caller that needs streamed prose uses codexAppServerStream below, while file and in-memory sources still
|
|
207
218
|
// share the same one-record parser contract.
|
|
@@ -244,27 +255,35 @@ export function codexAppServerEvent(value) {
|
|
|
244
255
|
const text = string(item.text);
|
|
245
256
|
return { at: eventAt, turn: { id, at: eventAt, role: 'assistant', text: text ?? undefined, tools: [] } };
|
|
246
257
|
}
|
|
247
|
-
|
|
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']);
|
|
248
263
|
if (!toolTypes.has(type))
|
|
249
264
|
return null;
|
|
250
265
|
if (method === 'item/started') {
|
|
251
266
|
const name = type === 'commandExecution' ? 'command'
|
|
252
|
-
:
|
|
267
|
+
: type === 'fileChange' ? 'edit'
|
|
268
|
+
: string(item.name) ?? string(item.tool) ?? (type === 'mcpToolCall' ? 'mcp' : 'tool');
|
|
253
269
|
const input = item.arguments !== undefined ? item.arguments
|
|
254
270
|
: item.input !== undefined ? item.input
|
|
255
271
|
: item.command !== undefined ? item.command
|
|
256
|
-
:
|
|
272
|
+
: type === 'fileChange' ? filePaths(item.changes)
|
|
273
|
+
: undefined;
|
|
257
274
|
return { at: eventAt, turn: { id, at: eventAt, role: 'assistant', tools: [{ id, name, input: input === undefined ? undefined : compact(input), outputLines: 0, outputBytes: 0 }] } };
|
|
258
275
|
}
|
|
259
276
|
let output = undefined;
|
|
260
277
|
if (type === 'commandExecution')
|
|
261
278
|
output = item.aggregatedOutput;
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
264
281
|
else if (type === 'mcpToolCall')
|
|
265
|
-
output = item.result ?? item.error;
|
|
282
|
+
output = object(item.result)?.content ?? item.result ?? object(item.error)?.message ?? item.error;
|
|
266
283
|
else if (type === 'dynamicToolCall')
|
|
267
284
|
output = item.contentItems ?? item.output ?? item.error;
|
|
285
|
+
else if (type === 'fileChange')
|
|
286
|
+
output = fileDiffs(item.changes);
|
|
268
287
|
// the item status is the app-server's own verdict: `failed`, or `declined` when the person refused the call —
|
|
269
288
|
// a declined call has no output, so the empty result is what ends its "running"
|
|
270
289
|
const status = string(item.status);
|
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/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": [
|