@spexcode/transcript 0.7.0-next.12 → 0.7.0-next.14

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 CHANGED
@@ -45,6 +45,7 @@ export declare class IntervalCollector {
45
45
  private readonly evicted;
46
46
  private readonly synthesized;
47
47
  sawTimestamp: boolean;
48
+ sawRecord: boolean;
48
49
  omittedTurns: number;
49
50
  omittedBytes: number;
50
51
  outOfOrderEvents: number;
package/dist/parsers.js CHANGED
@@ -133,7 +133,14 @@ export function claudeEvent(value) {
133
133
  return { at: eventAt, turn: { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'user', text, tools: [] } };
134
134
  }
135
135
  if (entry.type === 'assistant' && message.role === 'assistant') {
136
- const turn = { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'assistant', tools: [] };
136
+ // ONE API MESSAGE IS ONE TURN, however many lines Claude wrote it as. It writes one CONTENT BLOCK per
137
+ // line — prose on one, each tool call on its own — and every line of the same message repeats that
138
+ // message's `id` while carrying its own `uuid`. Keying on the line made one message up to six turns:
139
+ // measured over twelve recent threads, 5,077 assistant lines carry only 2,396 distinct message ids, so a
140
+ // turn count read off the lines is more than twice the truth. Keying on the message id lets the collector
141
+ // fold the fragments back together with the rule it already has for a re-emitted turn — text kept, calls
142
+ // merged by their own ids — so nothing here has to accumulate.
143
+ const turn = { id: idOf(message) ?? idOf(entry), at: eventAt, role: 'assistant', tools: [] };
137
144
  for (const blockValue of items(message.content)) {
138
145
  const block = object(blockValue);
139
146
  if (block?.type === 'text')
@@ -202,6 +209,17 @@ export function codexEvent(value) {
202
209
  }
203
210
  return null;
204
211
  }
212
+ // A file edit names the paths it touched, and its result is the diff the app-server already computed. Both
213
+ // come off `FileUpdateChange[]`, which is the only shape on the union whose payload is a list of records
214
+ // rather than one field.
215
+ const filePaths = (changes) => {
216
+ const paths = items(changes).map((change) => string(object(change)?.path)).filter(Boolean);
217
+ return paths.length ? paths.join('\n') : undefined;
218
+ };
219
+ const fileDiffs = (changes) => {
220
+ const diffs = items(changes).map((change) => string(object(change)?.diff)).filter(Boolean);
221
+ return diffs.length ? diffs.join('\n') : undefined;
222
+ };
205
223
  // Codex app-server notifications are a different native stream from rollout lines. Keep this mapping stateless:
206
224
  // a caller that needs streamed prose uses codexAppServerStream below, while file and in-memory sources still
207
225
  // share the same one-record parser contract.
@@ -244,27 +262,35 @@ export function codexAppServerEvent(value) {
244
262
  const text = string(item.text);
245
263
  return { at: eventAt, turn: { id, at: eventAt, role: 'assistant', text: text ?? undefined, tools: [] } };
246
264
  }
247
- const toolTypes = new Set(['commandExecution', 'functionCall', 'customToolCall', 'mcpToolCall', 'dynamicToolCall']);
265
+ // The tool-bearing variants of the app-server's own `ThreadItem` union. `functionCall` and `customToolCall`
266
+ // used to be listed here and are NOT members of it — those are ROLLOUT record types, and no such string
267
+ // exists anywhere in the app-server binary. `fileChange` is a real one that was missing, so a codex file
268
+ // edit appeared as no call at all.
269
+ const toolTypes = new Set(['commandExecution', 'fileChange', 'mcpToolCall', 'dynamicToolCall']);
248
270
  if (!toolTypes.has(type))
249
271
  return null;
250
272
  if (method === 'item/started') {
251
273
  const name = type === 'commandExecution' ? 'command'
252
- : string(item.name) ?? string(item.tool) ?? (type === 'mcpToolCall' ? 'mcp' : 'tool');
274
+ : type === 'fileChange' ? 'edit'
275
+ : string(item.name) ?? string(item.tool) ?? (type === 'mcpToolCall' ? 'mcp' : 'tool');
253
276
  const input = item.arguments !== undefined ? item.arguments
254
277
  : item.input !== undefined ? item.input
255
278
  : item.command !== undefined ? item.command
256
- : undefined;
279
+ : type === 'fileChange' ? filePaths(item.changes)
280
+ : undefined;
257
281
  return { at: eventAt, turn: { id, at: eventAt, role: 'assistant', tools: [{ id, name, input: input === undefined ? undefined : compact(input), outputLines: 0, outputBytes: 0 }] } };
258
282
  }
259
283
  let output = undefined;
260
284
  if (type === 'commandExecution')
261
285
  output = item.aggregatedOutput;
262
- else if (type === 'functionCall' || type === 'customToolCall')
263
- output = item.output ?? item.result;
286
+ // an MCP result is `{content, structuredContent, _meta}`: the text is in `content`, and handing the wrapper
287
+ // to `resultText` printed the JSON envelope instead of what the tool said
264
288
  else if (type === 'mcpToolCall')
265
- output = item.result ?? item.error;
289
+ output = object(item.result)?.content ?? item.result ?? object(item.error)?.message ?? item.error;
266
290
  else if (type === 'dynamicToolCall')
267
291
  output = item.contentItems ?? item.output ?? item.error;
292
+ else if (type === 'fileChange')
293
+ output = fileDiffs(item.changes);
268
294
  // the item status is the app-server's own verdict: `failed`, or `declined` when the person refused the call —
269
295
  // a declined call has no output, so the empty result is what ends its "running"
270
296
  const status = string(item.status);
@@ -498,6 +524,7 @@ export class IntervalCollector {
498
524
  evicted = new Set();
499
525
  synthesized = new Map(); // `<role>@<at>` → how many turns already wore it
500
526
  sawTimestamp = false;
527
+ sawRecord = false; // a record this harness's parser RECOGNIZED arrived, with or without a clock
501
528
  omittedTurns = 0;
502
529
  omittedBytes = 0;
503
530
  outOfOrderEvents = 0;
@@ -509,6 +536,7 @@ export class IntervalCollector {
509
536
  this.range.to = to; }
510
537
  // returns true once the source has moved past `to` (the caller may then bound its lookahead)
511
538
  add(event) {
539
+ this.sawRecord = true;
512
540
  const eventAt = event.at;
513
541
  if (eventAt === null)
514
542
  return this.pastRange;
@@ -581,7 +609,13 @@ export class IntervalCollector {
581
609
  return this.pastRange;
582
610
  }
583
611
  finish(revision, harness) {
584
- if (!this.sawTimestamp)
612
+ // THE CLOCK GATE IS ABOUT THE HARNESS, NOT THE MOMENT. It catches a source whose conversational records
613
+ // carry no usable time, which makes interval reads impossible. It must NOT catch a thread that has simply
614
+ // not spoken yet: every Claude transcript opens with clockless bookkeeping (`mode`, `permission-mode`,
615
+ // `file-history-snapshot`) before its first message — 40 of 40 real threads on this box — and those lines
616
+ // are not records this parser recognizes at all, so failing on them put an error frame on the page for the
617
+ // first moments of EVERY new session, which is exactly when someone is watching.
618
+ if (this.sawRecord && !this.sawTimestamp)
585
619
  throw new TranscriptReadError('invalid', `${harness} transcript has no reliable timestamps; interval reads are unavailable`);
586
620
  return {
587
621
  revision,
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) };
@@ -217,8 +205,13 @@ class LineFileCursor {
217
205
  catch (error) {
218
206
  throw new TranscriptReadError('unreadable', `${this.harness} transcript is unreadable: ${error instanceof Error ? error.message : String(error)}`);
219
207
  }
208
+ // AN EMPTY FILE IS A THREAD THAT HAS NOT SPOKEN YET, not a broken one. The harness creates the transcript
209
+ // before it writes the first record, so the moments right after a session starts — exactly when a person is
210
+ // watching — read as zero bytes. Failing there put an error on the page for a conversation that simply had
211
+ // not begun. Zero bytes is unambiguous in a way a garbled file is not: there is nothing to misread, so this
212
+ // is the one place the no-timestamp gate is skipped rather than tripped.
220
213
  if (size <= 0)
221
- throw new TranscriptReadError('unreadable', `${this.harness} transcript is unreadable: file is empty`);
214
+ return { revision: fileRevision(this.path) ?? '0', from: this.from, to, turns: [], truncated: false, omittedTurns: 0, omittedBytes: 0, outOfOrderEvents: 0 };
222
215
  // a source that shrank was rewritten underneath the cursor: forget the position and read the interval afresh
223
216
  if (!this.started || size < this.scan.position) {
224
217
  this.restart(size);
@@ -228,8 +221,9 @@ class LineFileCursor {
228
221
  let fd = null;
229
222
  try {
230
223
  fd = openSync(this.path, 'r');
231
- let postRangeLines = 0;
232
- this.scan = scanLines(this.harness, fd, this.scan, (value, offset) => {
224
+ let postRangeLines = 0, parsedLines = 0, unparsableLines = 0;
225
+ this.scan = scanLines(fd, this.scan, (value, offset) => {
226
+ parsedLines++;
233
227
  const event = this.parse(value);
234
228
  if (!event)
235
229
  return false;
@@ -238,7 +232,11 @@ class LineFileCursor {
238
232
  intervalOffsets.set(this.seekKey, offset);
239
233
  const pastRange = this.collector.add(event);
240
234
  return pastRange && ++postRangeLines >= lookahead;
241
- });
235
+ }, (bytes) => { unparsableLines++; this.collector.omittedBytes += bytes; });
236
+ // a file in which NOTHING is JSON is not this thread's transcript at all — that is the loud case, and it
237
+ // stays loud; a file whose lines parse but say nothing conversational yet is simply not started
238
+ if (!parsedLines && unparsableLines > 0)
239
+ throw new TranscriptReadError('invalid', `${this.harness} transcript cannot be parsed: no line is JSON`);
242
240
  }
243
241
  catch (error) {
244
242
  if (error instanceof TranscriptReadError)
@@ -274,100 +272,100 @@ function lineFileReader(harness, locate, parse) {
274
272
  },
275
273
  };
276
274
  }
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;
275
+ // A ROOT IS A PARAMETER OF EVERY READER, not of some of them. Each locator already takes one; only the store
276
+ // readers exposed it, so anything wanting a second pi root — a producer under an isolated agent dir, a test —
277
+ // had no way to ask. Passing nothing keeps the old behaviour exactly: the locator's own default is evaluated
278
+ // per call, so a late `CLAUDE_CONFIG_DIR` is still picked up.
279
+ export const claudeTranscriptReader = (root) => lineFileReader('claude', (threadId) => claudeTranscriptPath(threadId, root ?? projectTranscriptRoot()), claudeEvent);
280
+ export const codexTranscriptReader = (root) => lineFileReader('codex', (threadId) => codexRolloutPath(threadId, root ?? codexSessionsDir()), codexEvent);
281
+ export const piTranscriptReader = (root) => lineFileReader('pi', (threadId) => piSessionPath(threadId, root ?? piSessionsRoot()), piEvent);
282
+ export const geminiTranscriptReader = (root) => lineFileReader('gemini', (threadId) => geminiTranscriptPath(threadId, root ?? geminiRoot()), geminiEvent);
283
+ export const openclawTranscriptReader = (root) => lineFileReader('openclaw', (threadId) => openclawTranscriptPath(threadId, root ?? openclawRoot()), openclawEvent);
284
+ export const claudeTranscript = claudeTranscriptReader();
285
+ export const codexTranscript = codexTranscriptReader();
286
+ export const piTranscript = piTranscriptReader();
287
+ export const geminiTranscript = geminiTranscriptReader();
288
+ export const openclawTranscript = openclawTranscriptReader();
289
+ function storeRevision(root, files) {
290
+ const legs = [];
291
+ for (const [index, name] of files.entries()) {
292
+ try {
293
+ const stat = statSync(join(root, name));
294
+ legs.push(`${stat.size}:${Math.floor(stat.mtimeMs)}`);
295
+ }
296
+ catch {
297
+ // the database itself must exist; a checkpointed store simply has no separate write-ahead log
298
+ if (index === 0)
299
+ return null;
300
+ legs.push('0:0');
301
+ }
333
302
  }
303
+ return legs.join(':');
334
304
  }
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) {
305
+ const storeExports = new Map();
306
+ function storeReader(source, root, load) {
340
307
  const read = async (threadId, range) => {
341
- const revision = hermesRevision(root);
308
+ const revision = storeRevision(root, source.files);
342
309
  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);
310
+ throw new TranscriptReadError('missing', `${source.harness} transcript for ${threadId} is unavailable: ${source.missing}`);
311
+ const key = `${source.harness}:${root}:${threadId}`;
312
+ let cached = storeExports.get(key);
346
313
  if (!cached || cached.revision !== revision) {
347
314
  let exported;
348
315
  try {
349
316
  exported = load(threadId);
350
317
  }
351
318
  catch (error) {
352
- throw new TranscriptReadError('unreadable', `hermes transcript could not be exported: ${error instanceof Error ? error.message : String(error)}`);
319
+ throw new TranscriptReadError('unreadable', `${source.harness} transcript could not be exported: ${error instanceof Error ? error.message : String(error)}`);
353
320
  }
354
321
  let value;
355
322
  try {
356
323
  value = JSON.parse(exported);
357
324
  }
358
325
  catch (error) {
359
- throw new TranscriptReadError('invalid', `hermes transcript cannot be parsed: ${error instanceof Error ? error.message : String(error)}`);
326
+ throw new TranscriptReadError('invalid', `${source.harness} transcript cannot be parsed: ${error instanceof Error ? error.message : String(error)}`);
360
327
  }
361
- cached = { revision, events: hermesEvents(value) };
362
- hermesExports.set(key, cached);
328
+ cached = { revision, events: source.parse(value) };
329
+ storeExports.set(key, cached);
363
330
  }
364
331
  const collector = new IntervalCollector(range);
365
332
  for (const event of cached.events)
366
333
  collector.add(event);
367
- return collector.finish(revision, 'hermes');
334
+ return collector.finish(revision, source.harness);
335
+ };
336
+ return {
337
+ revision: () => storeRevision(root, source.files),
338
+ read,
339
+ // no file grows here: an open interval is re-collected from the cached export, one export per revision
340
+ tail: (threadId, from) => ({ advance: (to) => read(threadId, { from, to }), close: () => { } }),
368
341
  };
369
- return { revision: () => hermesRevision(root), read, tail: (threadId, from) => ({ advance: (to) => read(threadId, { from, to }), close: () => { } }) };
370
342
  }
343
+ // The export is read RAW: OpenCode's `--sanitize` replaces every prose and tool-output part with a
344
+ // `[redacted:…]` token, which made the whole conversation unreadable; the reader hands over the same local
345
+ // bytes the other harnesses' files hold, and nothing here leaves the machine that ran the thread.
346
+ const OPENCODE_STORE = {
347
+ harness: 'opencode',
348
+ defaultRoot: opencodeStoreRoot,
349
+ files: ['opencode.db', 'opencode.db-wal'],
350
+ missing: 'store was not found',
351
+ load: (threadId) => execFileSync(process.env.SPEXCODE_OPENCODE_CMD || 'opencode', ['export', threadId], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }),
352
+ parse: opencodeEvents,
353
+ };
354
+ const HERMES_STORE = {
355
+ harness: 'hermes',
356
+ defaultRoot: () => process.env.HERMES_HOME || join(homedir(), '.hermes', 'profiles', 'default'),
357
+ files: ['state.db', 'state.db-wal'],
358
+ missing: 'state.db was not found',
359
+ load: (threadId) => execFileSync(process.env.SPEXCODE_HERMES_CMD || 'hermes', ['sessions', 'export', '--format', 'jsonl', '--session-id', threadId, '--yes'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }),
360
+ parse: hermesEvents,
361
+ };
362
+ export function opencodeTranscriptReader(root = OPENCODE_STORE.defaultRoot(), load = OPENCODE_STORE.load) {
363
+ return storeReader(OPENCODE_STORE, root, load);
364
+ }
365
+ export function hermesTranscriptReader(root = HERMES_STORE.defaultRoot(), load = HERMES_STORE.load) {
366
+ return storeReader(HERMES_STORE, root, load);
367
+ }
368
+ export const opencodeTranscript = opencodeTranscriptReader();
371
369
  export const hermesTranscript = hermesTranscriptReader();
372
370
  export function unsupportedTranscript(harness) {
373
371
  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.14",
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": [