@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 +1 -0
- package/dist/parsers.js +40 -5
- package/dist/readers.js +13 -3
- package/package.json +1 -1
package/dist/parsers.d.ts
CHANGED
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": [
|