@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 +1 -0
- package/dist/parsers.js +42 -8
- package/dist/readers.d.ts +6 -1
- package/dist/readers.js +96 -98
- package/package.json +1 -1
package/dist/parsers.d.ts
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) };
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
336
|
-
|
|
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 =
|
|
308
|
+
const revision = storeRevision(root, source.files);
|
|
342
309
|
if (!revision)
|
|
343
|
-
throw new TranscriptReadError('missing',
|
|
344
|
-
const key = `${root}:${threadId}`;
|
|
345
|
-
let cached =
|
|
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',
|
|
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',
|
|
326
|
+
throw new TranscriptReadError('invalid', `${source.harness} transcript cannot be parsed: ${error instanceof Error ? error.message : String(error)}`);
|
|
360
327
|
}
|
|
361
|
-
cached = { revision, events:
|
|
362
|
-
|
|
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,
|
|
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.
|
|
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": [
|