@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 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
- const toolTypes = new Set(['commandExecution', 'functionCall', 'customToolCall', 'mcpToolCall', 'dynamicToolCall']);
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
- : string(item.name) ?? string(item.tool) ?? (type === 'mcpToolCall' ? 'mcp' : 'tool');
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
- : undefined;
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
- else if (type === 'functionCall' || type === 'customToolCall')
263
- output = item.output ?? item.result;
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
- function scanLines(harness, fd, scan, onLine) {
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 (error) {
175
- throw new TranscriptReadError('invalid', `${harness} transcript cannot be parsed: ${error instanceof Error ? error.message : String(error)}`);
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(this.harness, fd, this.scan, (value, offset) => {
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
- export const claudeTranscript = lineFileReader('claude', (threadId) => claudeTranscriptPath(threadId), claudeEvent);
278
- export const codexTranscript = lineFileReader('codex', (threadId) => codexRolloutPath(threadId), codexEvent);
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);
282
- // OpenCode has no per-thread file: the store's revision is the change token, and one export per
283
- // revision is parsed and kept, so repeated interval reads of a quiet thread cost nothing new.
284
- const opencodeExports = new Map();
285
- export function opencodeTranscriptReader(root = opencodeStoreRoot(), load = opencodeExport) {
286
- const reader = {
287
- revision: () => opencodeStoreRevision(root),
288
- read: async (threadId, range) => {
289
- const revision = opencodeStoreRevision(root);
290
- if (!revision)
291
- throw new TranscriptReadError('missing', `opencode transcript for ${threadId} is unavailable: store was not found`);
292
- const key = `${root}:${threadId}`;
293
- let cached = opencodeExports.get(key);
294
- if (!cached || cached.revision !== revision) {
295
- let exported;
296
- try {
297
- exported = load(threadId);
298
- }
299
- catch (error) {
300
- throw new TranscriptReadError('unreadable', `opencode transcript could not be exported: ${error instanceof Error ? error.message : String(error)}`);
301
- }
302
- let value;
303
- try {
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
- 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) {
295
+ const storeExports = new Map();
296
+ function storeReader(source, root, load) {
340
297
  const read = async (threadId, range) => {
341
- const revision = hermesRevision(root);
298
+ const revision = storeRevision(root, source.files);
342
299
  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);
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', `hermes transcript could not be exported: ${error instanceof Error ? error.message : String(error)}`);
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', `hermes transcript cannot be parsed: ${error instanceof Error ? error.message : String(error)}`);
316
+ throw new TranscriptReadError('invalid', `${source.harness} transcript cannot be parsed: ${error instanceof Error ? error.message : String(error)}`);
360
317
  }
361
- cached = { revision, events: hermesEvents(value) };
362
- hermesExports.set(key, cached);
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, 'hermes');
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.12",
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": [