@spexcode/transcript 0.7.0-next.13 → 0.7.0-next.15

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
@@ -54,6 +54,28 @@ const compact = (value) => {
54
54
  // (Claude's tool_result content, Codex's input_text output blocks, MCP results everywhere) means the text of
55
55
  // those blocks, with their line breaks; encoding the block list itself as JSON would show the reader escaped
56
56
  // newlines inside a JSON shell. A block that is not text — an image, a reference — is named, not dumped.
57
+ // @@@ a block that is not text is NAMED, with what the producer said about it - the output contract is text,
58
+ // so a picture cannot be carried in it; the honest substitute is a placeholder that says what was there. A
59
+ // bare `[image]` answers none of the questions a person actually has, and both facts worth having are already
60
+ // in the record: the media type when the producer states it (Claude's `source.media_type`, the `data:<mime>;`
61
+ // prefix of Codex's `image_url`) and the size, which base64 always yields. Neither is guessed — an image with
62
+ // no stated type is named `[image 1.2 MB]`, not sniffed from its bytes.
63
+ const KB = 1024;
64
+ const humanBytes = (bytes) => bytes >= KB * KB ? `${(bytes / (KB * KB)).toFixed(1)} MB`
65
+ : bytes >= KB ? `${Math.round(bytes / KB)} KB` : `${bytes} B`;
66
+ const base64Bytes = (data) => {
67
+ const body = data.replace(/=+$/, '');
68
+ return Math.floor((body.length * 3) / 4);
69
+ };
70
+ const blockLabel = (block, type) => {
71
+ const source = object(block.source);
72
+ const url = string(block.image_url) ?? string(block.url) ?? string(source?.url);
73
+ const dataUrl = url && url.startsWith('data:') ? /^data:([^;,]*)[;,]/.exec(url) : null;
74
+ const mime = string(source?.media_type) ?? string(block.mimeType) ?? string(block.mime_type) ?? (dataUrl ? dataUrl[1] : null);
75
+ const encoded = string(source?.data) ?? (url && url.startsWith('data:') ? url.slice(url.indexOf(',') + 1) : null);
76
+ const size = encoded ? ` ${humanBytes(base64Bytes(encoded))}` : '';
77
+ return `[${mime || type}${size}]`;
78
+ };
57
79
  const resultText = (value) => {
58
80
  if (typeof value === 'string')
59
81
  return value;
@@ -67,10 +89,8 @@ const resultText = (value) => {
67
89
  if (text !== null)
68
90
  return text;
69
91
  const type = string(block.type);
70
- if (type === 'image')
71
- return '[image]';
72
92
  if (type)
73
- return `[${type}]`;
93
+ return blockLabel(block, type);
74
94
  return compact(blockValue);
75
95
  }).join('\n');
76
96
  };
@@ -133,7 +153,14 @@ export function claudeEvent(value) {
133
153
  return { at: eventAt, turn: { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'user', text, tools: [] } };
134
154
  }
135
155
  if (entry.type === 'assistant' && message.role === 'assistant') {
136
- const turn = { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'assistant', tools: [] };
156
+ // ONE API MESSAGE IS ONE TURN, however many lines Claude wrote it as. It writes one CONTENT BLOCK per
157
+ // line — prose on one, each tool call on its own — and every line of the same message repeats that
158
+ // message's `id` while carrying its own `uuid`. Keying on the line made one message up to six turns:
159
+ // measured over twelve recent threads, 5,077 assistant lines carry only 2,396 distinct message ids, so a
160
+ // turn count read off the lines is more than twice the truth. Keying on the message id lets the collector
161
+ // fold the fragments back together with the rule it already has for a re-emitted turn — text kept, calls
162
+ // merged by their own ids — so nothing here has to accumulate.
163
+ const turn = { id: idOf(message) ?? idOf(entry), at: eventAt, role: 'assistant', tools: [] };
137
164
  for (const blockValue of items(message.content)) {
138
165
  const block = object(blockValue);
139
166
  if (block?.type === 'text')
@@ -517,6 +544,7 @@ export class IntervalCollector {
517
544
  evicted = new Set();
518
545
  synthesized = new Map(); // `<role>@<at>` → how many turns already wore it
519
546
  sawTimestamp = false;
547
+ sawRecord = false; // a record this harness's parser RECOGNIZED arrived, with or without a clock
520
548
  omittedTurns = 0;
521
549
  omittedBytes = 0;
522
550
  outOfOrderEvents = 0;
@@ -528,6 +556,7 @@ export class IntervalCollector {
528
556
  this.range.to = to; }
529
557
  // returns true once the source has moved past `to` (the caller may then bound its lookahead)
530
558
  add(event) {
559
+ this.sawRecord = true;
531
560
  const eventAt = event.at;
532
561
  if (eventAt === null)
533
562
  return this.pastRange;
@@ -600,7 +629,13 @@ export class IntervalCollector {
600
629
  return this.pastRange;
601
630
  }
602
631
  finish(revision, harness) {
603
- if (!this.sawTimestamp)
632
+ // THE CLOCK GATE IS ABOUT THE HARNESS, NOT THE MOMENT. It catches a source whose conversational records
633
+ // carry no usable time, which makes interval reads impossible. It must NOT catch a thread that has simply
634
+ // not spoken yet: every Claude transcript opens with clockless bookkeeping (`mode`, `permission-mode`,
635
+ // `file-history-snapshot`) before its first message — 40 of 40 real threads on this box — and those lines
636
+ // are not records this parser recognizes at all, so failing on them put an error frame on the page for the
637
+ // first moments of EVERY new session, which is exactly when someone is watching.
638
+ if (this.sawRecord && !this.sawTimestamp)
604
639
  throw new TranscriptReadError('invalid', `${harness} transcript has no reliable timestamps; interval reads are unavailable`);
605
640
  return {
606
641
  revision,
package/dist/readers.js CHANGED
@@ -205,8 +205,13 @@ class LineFileCursor {
205
205
  catch (error) {
206
206
  throw new TranscriptReadError('unreadable', `${this.harness} transcript is unreadable: ${error instanceof Error ? error.message : String(error)}`);
207
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.
208
213
  if (size <= 0)
209
- 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 };
210
215
  // a source that shrank was rewritten underneath the cursor: forget the position and read the interval afresh
211
216
  if (!this.started || size < this.scan.position) {
212
217
  this.restart(size);
@@ -216,8 +221,9 @@ class LineFileCursor {
216
221
  let fd = null;
217
222
  try {
218
223
  fd = openSync(this.path, 'r');
219
- let postRangeLines = 0;
224
+ let postRangeLines = 0, parsedLines = 0, unparsableLines = 0;
220
225
  this.scan = scanLines(fd, this.scan, (value, offset) => {
226
+ parsedLines++;
221
227
  const event = this.parse(value);
222
228
  if (!event)
223
229
  return false;
@@ -226,7 +232,11 @@ class LineFileCursor {
226
232
  intervalOffsets.set(this.seekKey, offset);
227
233
  const pastRange = this.collector.add(event);
228
234
  return pastRange && ++postRangeLines >= lookahead;
229
- }, (bytes) => { this.collector.omittedBytes += bytes; });
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`);
230
240
  }
231
241
  catch (error) {
232
242
  if (error instanceof TranscriptReadError)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/transcript",
3
- "version": "0.7.0-next.13",
3
+ "version": "0.7.0-next.15",
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": [