spexcode 0.7.0-next.11 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-application",
3
- "version": "0.7.0-next.11",
3
+ "version": "0.7.0-next.13",
4
4
  "publishConfig": { "access": "public" },
5
5
  "type": "module",
6
6
  "description": "Adopter-owned composition of session protocol and topology transactions.",
@@ -16,10 +16,10 @@
16
16
  "test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
17
17
  },
18
18
  "dependencies": {
19
- "@spexcode/session-events": "0.7.0-next.11",
20
- "@spexcode/session-protocol": "0.7.0-next.11",
21
- "@spexcode/session-runtime": "0.7.0-next.11",
22
- "@spexcode/session-topology": "0.7.0-next.11"
19
+ "@spexcode/session-events": "0.7.0-next.13",
20
+ "@spexcode/session-protocol": "0.7.0-next.13",
21
+ "@spexcode/session-runtime": "0.7.0-next.13",
22
+ "@spexcode/session-topology": "0.7.0-next.13"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^20.16.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-events",
3
- "version": "0.7.0-next.11",
3
+ "version": "0.7.0-next.13",
4
4
  "publishConfig": { "access": "public" },
5
5
  "type": "module",
6
6
  "description": "Append-only session facts and deterministic ordered replay inputs.",
@@ -15,7 +15,7 @@
15
15
  "prepublishOnly": "node ../../scripts/release-publish.mjs --from-package-publish",
16
16
  "test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
17
17
  },
18
- "dependencies": { "@spexcode/session-protocol": "0.7.0-next.11" },
18
+ "dependencies": { "@spexcode/session-protocol": "0.7.0-next.13" },
19
19
  "devDependencies": {
20
20
  "@types/node": "^20.16.0",
21
21
  "tsx": "^4.19.2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-protocol",
3
- "version": "0.7.0-next.11",
3
+ "version": "0.7.0-next.13",
4
4
  "type": "module",
5
5
  "description": "A durable SQLite session message protocol.",
6
6
  "files": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-runtime",
3
- "version": "0.7.0-next.11",
3
+ "version": "0.7.0-next.13",
4
4
  "publishConfig": { "access": "public" },
5
5
  "type": "module",
6
6
  "description": "Adopter-owned bindings between protocol addresses and native runtime identities.",
@@ -15,7 +15,7 @@
15
15
  "prepublishOnly": "node ../../scripts/release-publish.mjs --from-package-publish",
16
16
  "test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
17
17
  },
18
- "dependencies": { "@spexcode/session-protocol": "0.7.0-next.11" },
18
+ "dependencies": { "@spexcode/session-protocol": "0.7.0-next.13" },
19
19
  "devDependencies": {
20
20
  "@types/node": "^20.16.0",
21
21
  "tsx": "^4.19.2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-selflaunch",
3
- "version": "0.7.0-next.11",
3
+ "version": "0.7.0-next.13",
4
4
  "publishConfig": { "access": "public" },
5
5
  "type": "module",
6
6
  "description": "A fail-closed self-launch adopter for the SpexCode session protocol.",
@@ -24,8 +24,8 @@
24
24
  "test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
25
25
  },
26
26
  "dependencies": {
27
- "@spexcode/session-protocol": "0.7.0-next.11",
28
- "@spexcode/session-runtime": "0.7.0-next.11"
27
+ "@spexcode/session-protocol": "0.7.0-next.13",
28
+ "@spexcode/session-runtime": "0.7.0-next.13"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^20.16.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-topology",
3
- "version": "0.7.0-next.11",
3
+ "version": "0.7.0-next.13",
4
4
  "publishConfig": { "access": "public" },
5
5
  "type": "module",
6
6
  "description": "A neutral SQLite relation model for session addresses.",
@@ -15,7 +15,7 @@
15
15
  "prepublishOnly": "node ../../scripts/release-publish.mjs --from-package-publish",
16
16
  "test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
17
17
  },
18
- "dependencies": { "@spexcode/session-protocol": "0.7.0-next.11" },
18
+ "dependencies": { "@spexcode/session-protocol": "0.7.0-next.13" },
19
19
  "devDependencies": {
20
20
  "@types/node": "^20.16.0",
21
21
  "tsx": "^4.19.2",
@@ -32,6 +32,8 @@ export declare function readSessionTranscript(c: Context): Promise<(Response & i
32
32
  readonly outputBytes: number;
33
33
  readonly outcome?: "failed" | "rejected" | undefined;
34
34
  }[] | undefined;
35
+ readonly outcome?: import("@spexcode/transcript").TurnOutcome | undefined;
36
+ readonly error?: string | undefined;
35
37
  }[];
36
38
  readonly truncated: boolean;
37
39
  readonly omittedTurns: number;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-cli",
3
- "version": "0.7.0-next.11",
3
+ "version": "0.7.0-next.13",
4
4
  "type": "module",
5
5
  "description": "SpexCode CLI + server. The root spexcode package delegates to this compiled package; dashboard assets live in @spexcode/spec-dashboard.",
6
6
  "bin": {
@@ -35,12 +35,12 @@
35
35
  "test": "tsx --import ../scripts/test-home.mjs --test src/*.test.ts"
36
36
  },
37
37
  "dependencies": {
38
- "@spexcode/session-application": "0.7.0-next.11",
39
- "@spexcode/session-selflaunch": "0.7.0-next.11",
40
- "@spexcode/spec-core": "0.7.0-next.11",
41
- "@spexcode/spec-eval": "0.7.0-next.11",
42
- "@spexcode/spec-forge": "0.7.0-next.11",
43
- "@spexcode/transcript": "0.7.0-next.11",
38
+ "@spexcode/session-application": "0.7.0-next.13",
39
+ "@spexcode/session-selflaunch": "0.7.0-next.13",
40
+ "@spexcode/spec-core": "0.7.0-next.13",
41
+ "@spexcode/spec-eval": "0.7.0-next.13",
42
+ "@spexcode/spec-forge": "0.7.0-next.13",
43
+ "@spexcode/transcript": "0.7.0-next.13",
44
44
  "smol-toml": "^1.8.0"
45
45
  },
46
46
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-core",
3
- "version": "0.7.0-next.11",
3
+ "version": "0.7.0-next.13",
4
4
  "type": "module",
5
5
  "description": "SpexCode's dependency-minimal spec graph core.",
6
6
  "files": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-eval",
3
- "version": "0.7.0-next.11",
3
+ "version": "0.7.0-next.13",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./dist/index.js",
@@ -26,7 +26,7 @@
26
26
  "test": "tsx --import ../scripts/test-home.mjs --test src/*.test.ts"
27
27
  },
28
28
  "dependencies": {
29
- "@spexcode/spec-core": "0.7.0-next.11"
29
+ "@spexcode/spec-core": "0.7.0-next.13"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^20.16.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-forge",
3
- "version": "0.7.0-next.11",
3
+ "version": "0.7.0-next.13",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "test": "tsx --test src/*.test.ts"
23
23
  },
24
24
  "dependencies": {
25
- "@spexcode/spec-core": "0.7.0-next.11"
25
+ "@spexcode/spec-core": "0.7.0-next.13"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^20.16.0",
@@ -1,4 +1,4 @@
1
- import { type TranscriptRange, type TranscriptRead } from './turns.js';
1
+ import { type TranscriptRange, type TranscriptRead, type TurnOutcome } from './turns.js';
2
2
  export declare const MAX_TURNS = 200;
3
3
  export declare const MAX_OUTPUT_BYTES: number;
4
4
  export type MutableTool = {
@@ -16,6 +16,8 @@ export type MutableTurn = {
16
16
  role: 'user' | 'assistant';
17
17
  text?: string;
18
18
  tools: MutableTool[];
19
+ outcome?: TurnOutcome;
20
+ error?: string;
19
21
  };
20
22
  export type ToolOutcome = 'failed' | 'rejected';
21
23
  export type ParsedEvent = {
@@ -34,7 +36,7 @@ export declare function codexAppServerEvent(value: unknown): ParsedEvent | null;
34
36
  export declare function codexAppServerStream(): Parse;
35
37
  export declare function piEvent(value: unknown): ParsedEvent | null;
36
38
  export declare function geminiEvent(value: unknown): ParsedEvent | null;
37
- export declare function openclawEvent(value: unknown): ParsedEvent | null;
39
+ export declare const openclawEvent: typeof piEvent;
38
40
  export declare function hermesEvents(value: unknown): ParsedEvent[];
39
41
  export declare function opencodeEvents(value: unknown): ParsedEvent[];
40
42
  export declare class IntervalCollector {
@@ -183,6 +183,18 @@ export function codexEvent(value) {
183
183
  const rawInput = payload.input === undefined && payload.arguments === undefined ? undefined : compact(payload.input ?? payload.arguments);
184
184
  return { at: eventAt, turn: { id: idOf(payload) ?? idOf(entry), at: eventAt, role: 'assistant', tools: [{ id, name: string(payload.name ?? payload.tool_name) ?? 'tool', input: codexExecCommand(rawInput), outputLines: 0, outputBytes: 0 }] } };
185
185
  }
186
+ // THE ROLLOUT PUTS THE VERDICT IN A THIRD RECORD. Unlike every other source here, a codex result item
187
+ // carries no failure field at all — `function_call_output` and `custom_tool_call_output` have only
188
+ // `{call_id, output}`. The harness records how the command ended in a separate `event_msg`, joined by the
189
+ // same `call_id`: `exec_command_end.status` (completed | failed, 6,194 failed of 83,990 in the rollouts on
190
+ // this box, matching exactly the nonzero exit codes) and `patch_apply_end.success`. Without this second
191
+ // join every failed command in a codex transcript reads as an ordinary one. The outcome is carried alone,
192
+ // with no text, so it lands on the call the output record already filled.
193
+ if (entry.type === 'event_msg' && (type === 'exec_command_end' || type === 'patch_apply_end')) {
194
+ const id = string(payload.call_id);
195
+ const failed = string(payload.status) === 'failed' || payload.success === false;
196
+ return id && failed ? { at: eventAt, turn: null, toolOutputs: [{ id, text: '', outcome: 'failed' }] } : { at: eventAt, turn: null };
197
+ }
186
198
  if (entry.type === 'response_item' && (type === 'custom_tool_call_output' || type === 'function_call_output')) {
187
199
  const id = string(payload.call_id ?? payload.id);
188
200
  const output = payload.output ?? payload.result ?? '';
@@ -190,6 +202,17 @@ export function codexEvent(value) {
190
202
  }
191
203
  return null;
192
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
+ };
193
216
  // Codex app-server notifications are a different native stream from rollout lines. Keep this mapping stateless:
194
217
  // a caller that needs streamed prose uses codexAppServerStream below, while file and in-memory sources still
195
218
  // share the same one-record parser contract.
@@ -203,7 +226,12 @@ export function codexAppServerEvent(value) {
203
226
  const recognized = method === 'item/agentMessage/delta' || method === 'item/started' || method === 'item/completed';
204
227
  if (!recognized)
205
228
  return null;
206
- const eventAt = timestamp(params.emittedAtMs) ?? timestamp(params.startedAtMs) ?? timestamp(params.completedAtMs);
229
+ // `emittedAtMs` is a SIBLING of `method` and `params`, not a field inside them — the app-server's own
230
+ // generated envelope type puts it there, and every line of the capture in `fixtures/codex-app-server` has
231
+ // exactly the keys `method`, `params`, `emittedAtMs`. Reading it from `params` found nothing, which made
232
+ // every `item/agentMessage/delta` clockless and therefore dropped: the streaming path parsed and produced
233
+ // nothing at all. The lifecycle clocks below do sit in `params`.
234
+ const eventAt = timestamp(entry.emittedAtMs) ?? timestamp(params.startedAtMs) ?? timestamp(params.completedAtMs);
207
235
  if (eventAt === null)
208
236
  return { at: null, turn: null };
209
237
  if (method === 'item/agentMessage/delta') {
@@ -227,27 +255,35 @@ export function codexAppServerEvent(value) {
227
255
  const text = string(item.text);
228
256
  return { at: eventAt, turn: { id, at: eventAt, role: 'assistant', text: text ?? undefined, tools: [] } };
229
257
  }
230
- 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']);
231
263
  if (!toolTypes.has(type))
232
264
  return null;
233
265
  if (method === 'item/started') {
234
266
  const name = type === 'commandExecution' ? 'command'
235
- : 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');
236
269
  const input = item.arguments !== undefined ? item.arguments
237
270
  : item.input !== undefined ? item.input
238
271
  : item.command !== undefined ? item.command
239
- : undefined;
272
+ : type === 'fileChange' ? filePaths(item.changes)
273
+ : undefined;
240
274
  return { at: eventAt, turn: { id, at: eventAt, role: 'assistant', tools: [{ id, name, input: input === undefined ? undefined : compact(input), outputLines: 0, outputBytes: 0 }] } };
241
275
  }
242
276
  let output = undefined;
243
277
  if (type === 'commandExecution')
244
278
  output = item.aggregatedOutput;
245
- else if (type === 'functionCall' || type === 'customToolCall')
246
- 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
247
281
  else if (type === 'mcpToolCall')
248
- output = item.result ?? item.error;
282
+ output = object(item.result)?.content ?? item.result ?? object(item.error)?.message ?? item.error;
249
283
  else if (type === 'dynamicToolCall')
250
284
  output = item.contentItems ?? item.output ?? item.error;
285
+ else if (type === 'fileChange')
286
+ output = fileDiffs(item.changes);
251
287
  // the item status is the app-server's own verdict: `failed`, or `declined` when the person refused the call —
252
288
  // a declined call has no output, so the empty result is what ends its "running"
253
289
  const status = string(item.status);
@@ -313,6 +349,16 @@ export function piEvent(value) {
313
349
  turn.tools.push({ id, name: string(block.name) ?? 'tool', input: block.arguments === undefined ? undefined : compact(block.arguments), outputLines: 0, outputBytes: 0 });
314
350
  }
315
351
  }
352
+ // the producer's verdict on the TURN (`StopReason` in pi's shipped types): `error` is the provider's own
353
+ // failure, `aborted` the turn a stop ended. These are exactly the turns that carry no text and no calls —
354
+ // 13 of 578 assistant messages in the sessions on this box — so without them the turn is an empty gap.
355
+ const stop = string(message.stopReason);
356
+ if (stop === 'error' || stop === 'aborted') {
357
+ turn.outcome = stop === 'aborted' ? 'cancelled' : 'failed';
358
+ const reason = string(message.errorMessage);
359
+ if (reason)
360
+ turn.error = reason;
361
+ }
316
362
  return { at: eventAt, turn };
317
363
  }
318
364
  if (message.role === 'toolResult') {
@@ -360,37 +406,23 @@ export function geminiEvent(value) {
360
406
  }
361
407
  return null;
362
408
  }
363
- export function openclawEvent(value) {
364
- const entry = object(value);
365
- const message = object(entry?.message);
366
- if (!entry || entry.type !== 'message' || !message)
367
- return null;
368
- const eventAt = at(message) ?? at(entry);
369
- if (eventAt === null)
370
- return { at: null, turn: null };
371
- if (message.role === 'user') {
372
- const text = blockText(message.content);
373
- return text ? { at: eventAt, turn: { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'user', text, tools: [] } } : null;
374
- }
375
- if (message.role === 'toolResult') {
376
- const id = string(message.toolCallId);
377
- return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: blockText(message.content) ?? compact(message.content ?? ''), ...(message.isError === true ? { outcome: 'failed' } : {}) }] } : null;
378
- }
379
- if (message.role === 'assistant') {
380
- const turn = { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'assistant', tools: [] };
381
- for (const blockValue of items(message.content)) {
382
- const block = object(blockValue);
383
- if (block?.type === 'text')
384
- turn.text = [turn.text, string(block.text)].filter(Boolean).join('\n') || undefined;
385
- if (block?.type === 'toolCall') {
386
- const id = string(block.id) ?? `tool-${turn.tools.length}`;
387
- turn.tools.push({ id, name: string(block.name) ?? 'tool', input: block.arguments === undefined ? undefined : compact(block.arguments), outputLines: 0, outputBytes: 0 });
388
- }
389
- }
390
- return turn.text || turn.tools.length ? { at: eventAt, turn } : null;
391
- }
392
- return null;
393
- }
409
+ // OPENCLAW WRITES PI'S FORMAT. Verified field by field against pi's shipped schema: the `{"type":"session",
410
+ // "version":3}` header, the 8-char hex id tree, the `user|assistant|toolResult` roles, the `text`/`thinking`/
411
+ // `toolCall` blocks, `toolResult`'s exact key set. The only OpenClaw-specific things are its own `customType`
412
+ // namespace and where the file lives — neither of which a parser reads. So this is not a second parser: two
413
+ // hand-written copies had already drifted into two defects (a different clock preference on identical bytes,
414
+ // and dropping every turn with no text and no calls, which is precisely the failed and aborted ones). Where a
415
+ // harness differs only in where its file sits, the adapter row is the LOCATOR ([[transcript-reader]]), never a
416
+ // second copy of the parse.
417
+ export const openclawEvent = piEvent;
418
+ // HERMES COUNTS IN SECONDS. Its export writes `timestamp` as a float epoch SECOND (`1787942674.556185`),
419
+ // where every other harness here writes milliseconds and `at()` reads a bare number as one. Unconverted, a
420
+ // Hermes turn lands in January 1970 and every interval read of a real thread — the session API hands `from`
421
+ // and `to` as epoch ms — comes back empty. The unit is this producer's, so the conversion is this adapter's.
422
+ const hermesAt = (message) => {
423
+ const seconds = at(message);
424
+ return seconds === null ? null : Math.round(seconds * 1000);
425
+ };
394
426
  export function hermesEvents(value) {
395
427
  const root = object(value);
396
428
  const events = [];
@@ -398,7 +430,7 @@ export function hermesEvents(value) {
398
430
  const message = object(messageValue);
399
431
  if (!message)
400
432
  continue;
401
- const eventAt = at(message);
433
+ const eventAt = hermesAt(message);
402
434
  if (eventAt === null) {
403
435
  events.push({ at: null, turn: null });
404
436
  continue;
@@ -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;
@@ -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`); };
@@ -11,12 +11,15 @@ export type TranscriptTool = Readonly<{
11
11
  outputBytes: number;
12
12
  outcome?: 'failed' | 'rejected';
13
13
  }>;
14
+ export type TurnOutcome = 'failed' | 'cancelled';
14
15
  export type TranscriptTurn = Readonly<{
15
16
  id: string;
16
17
  at: number;
17
18
  role: 'user' | 'assistant';
18
19
  text?: string;
19
20
  tools?: readonly TranscriptTool[];
21
+ outcome?: TurnOutcome;
22
+ error?: string;
20
23
  }>;
21
24
  export type TranscriptRead = Readonly<{
22
25
  revision: string;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/transcript",
3
- "version": "0.7.0-next.11",
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": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.7.0-next.11",
3
+ "version": "0.7.0-next.13",
4
4
  "type": "module",
5
5
  "description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
6
6
  "license": "MIT",
@@ -44,7 +44,7 @@
44
44
  "prepack": "node scripts/sync-init-plugins.mjs --check && node scripts/prepack.mjs"
45
45
  },
46
46
  "dependencies": {
47
- "@spexcode/spec-cli": "0.7.0-next.11"
47
+ "@spexcode/spec-cli": "0.7.0-next.13"
48
48
  },
49
49
  "publishConfig": {
50
50
  "access": "public"