spexcode 0.7.0-next.0 → 0.7.0-next.2

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.0",
3
+ "version": "0.7.0-next.2",
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.0",
20
- "@spexcode/session-protocol": "0.7.0-next.0",
21
- "@spexcode/session-runtime": "0.7.0-next.0",
22
- "@spexcode/session-topology": "0.7.0-next.0"
19
+ "@spexcode/session-events": "0.7.0-next.2",
20
+ "@spexcode/session-protocol": "0.7.0-next.2",
21
+ "@spexcode/session-runtime": "0.7.0-next.2",
22
+ "@spexcode/session-topology": "0.7.0-next.2"
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.0",
3
+ "version": "0.7.0-next.2",
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.0" },
18
+ "dependencies": { "@spexcode/session-protocol": "0.7.0-next.2" },
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.0",
3
+ "version": "0.7.0-next.2",
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.0",
3
+ "version": "0.7.0-next.2",
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.0" },
18
+ "dependencies": { "@spexcode/session-protocol": "0.7.0-next.2" },
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.0",
3
+ "version": "0.7.0-next.2",
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.0",
28
- "@spexcode/session-runtime": "0.7.0-next.0"
27
+ "@spexcode/session-protocol": "0.7.0-next.2",
28
+ "@spexcode/session-runtime": "0.7.0-next.2"
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.0",
3
+ "version": "0.7.0-next.2",
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.0" },
18
+ "dependencies": { "@spexcode/session-protocol": "0.7.0-next.2" },
19
19
  "devDependencies": {
20
20
  "@types/node": "^20.16.0",
21
21
  "tsx": "^4.19.2",
@@ -2988,7 +2988,7 @@ async function prepareSession(prompt, parent, launcher, name, context) {
2988
2988
  try {
2989
2989
  gitMutationStarted = true;
2990
2990
  traceSessionCreate(id, requestDigest, phase, 'start', 'worktree-add');
2991
- const added = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'worktree', 'add', '-b', branch, path, startPoint], { extraEnv: DEFER_FOOTPRINT_REFRESH }));
2991
+ const added = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'worktree', 'add', '--no-track', '-b', branch, path, startPoint], { extraEnv: DEFER_FOOTPRINT_REFRESH }));
2992
2992
  traceSessionCreate(id, requestDigest, phase, 'finish', 'worktree-add');
2993
2993
  if (added.ok)
2994
2994
  Object.assign(owned, { path: true, worktree: true, branch: true });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-cli",
3
- "version": "0.7.0-next.0",
3
+ "version": "0.7.0-next.2",
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.0",
39
- "@spexcode/session-selflaunch": "0.7.0-next.0",
40
- "@spexcode/spec-core": "0.7.0-next.0",
41
- "@spexcode/spec-eval": "0.7.0-next.0",
42
- "@spexcode/spec-forge": "0.7.0-next.0",
43
- "@spexcode/transcript": "0.7.0-next.0",
38
+ "@spexcode/session-application": "0.7.0-next.2",
39
+ "@spexcode/session-selflaunch": "0.7.0-next.2",
40
+ "@spexcode/spec-core": "0.7.0-next.2",
41
+ "@spexcode/spec-eval": "0.7.0-next.2",
42
+ "@spexcode/spec-forge": "0.7.0-next.2",
43
+ "@spexcode/transcript": "0.7.0-next.2",
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.0",
3
+ "version": "0.7.0-next.2",
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.0",
3
+ "version": "0.7.0-next.2",
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.0"
29
+ "@spexcode/spec-core": "0.7.0-next.2"
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.0",
3
+ "version": "0.7.0-next.2",
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.0"
25
+ "@spexcode/spec-core": "0.7.0-next.2"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^20.16.0",
@@ -1,4 +1,6 @@
1
1
  import { type TranscriptRead, type TranscriptReader, type TranscriptTool, type TranscriptTurn } from './turns.js';
2
+ export { TranscriptReadError } from './turns.js';
3
+ export type { TranscriptRange, TranscriptRead, TranscriptReader, TranscriptTail, TranscriptTool, TranscriptTurn } from './turns.js';
2
4
  export type StreamTool = Readonly<Omit<TranscriptTool, 'output'> & {
3
5
  output?: null;
4
6
  }>;
@@ -1,4 +1,6 @@
1
1
  import { TranscriptReadError } from './turns.js';
2
+ // the browser-safe entry is complete on its own: the normalized shape travels with the frames that carry it
3
+ export { TranscriptReadError } from './turns.js';
2
4
  export const isErrorFrame = (frame) => 'error' in frame;
3
5
  // The revision an absent source reports: the thread has not started writing. Not an error.
4
6
  export const ABSENT_REVISION = 'absent';
@@ -27,7 +27,12 @@ export type ParsedEvent = {
27
27
  export type Parse = (value: unknown) => ParsedEvent | null;
28
28
  export declare function claudeEvent(value: unknown): ParsedEvent | null;
29
29
  export declare function codexEvent(value: unknown): ParsedEvent | null;
30
+ export declare function codexAppServerEvent(value: unknown): ParsedEvent | null;
31
+ export declare function codexAppServerStream(): Parse;
30
32
  export declare function piEvent(value: unknown): ParsedEvent | null;
33
+ export declare function geminiEvent(value: unknown): ParsedEvent | null;
34
+ export declare function openclawEvent(value: unknown): ParsedEvent | null;
35
+ export declare function hermesEvents(value: unknown): ParsedEvent[];
31
36
  export declare function opencodeEvents(value: unknown): ParsedEvent[];
32
37
  export declare class IntervalCollector {
33
38
  readonly turns: MutableTurn[];
@@ -51,7 +51,7 @@ const compact = (value) => {
51
51
  }
52
52
  };
53
53
  const lineCount = (value) => value ? value.split(/\r?\n/).length : 0;
54
- // --- the four native shapes --------------------------------------------------------------------------------
54
+ // --- the seven native shapes -------------------------------------------------------------------------------
55
55
  export function claudeEvent(value) {
56
56
  const entry = object(value);
57
57
  const message = object(entry?.message);
@@ -120,6 +120,101 @@ export function codexEvent(value) {
120
120
  }
121
121
  return null;
122
122
  }
123
+ // Codex app-server notifications are a different native stream from rollout lines. Keep this mapping stateless:
124
+ // a caller that needs streamed prose uses codexAppServerStream below, while file and in-memory sources still
125
+ // share the same one-record parser contract.
126
+ export function codexAppServerEvent(value) {
127
+ const entry = object(value);
128
+ const params = object(entry?.params);
129
+ if (!entry || !params || typeof entry.method !== 'string')
130
+ return null;
131
+ const item = object(params.item);
132
+ const method = entry.method;
133
+ const recognized = method === 'item/agentMessage/delta' || method === 'item/started' || method === 'item/completed';
134
+ if (!recognized)
135
+ return null;
136
+ const eventAt = timestamp(params.emittedAtMs) ?? timestamp(params.startedAtMs) ?? timestamp(params.completedAtMs);
137
+ if (eventAt === null)
138
+ return { at: null, turn: null };
139
+ if (method === 'item/agentMessage/delta') {
140
+ const id = string(params.itemId);
141
+ const delta = string(params.delta);
142
+ return id && delta !== null
143
+ ? { at: eventAt, turn: { id, at: eventAt, role: 'assistant', text: delta, tools: [] } }
144
+ : null;
145
+ }
146
+ if ((method !== 'item/started' && method !== 'item/completed') || !item)
147
+ return null;
148
+ const id = string(item.id);
149
+ const type = string(item.type);
150
+ if (!id || !type)
151
+ return null;
152
+ if (type === 'userMessage') {
153
+ const text = blockText(item.content);
154
+ return text ? { at: eventAt, turn: { id, at: eventAt, role: 'user', text, tools: [] } } : null;
155
+ }
156
+ if (type === 'agentMessage') {
157
+ const text = string(item.text);
158
+ return { at: eventAt, turn: { id, at: eventAt, role: 'assistant', text: text ?? undefined, tools: [] } };
159
+ }
160
+ const toolTypes = new Set(['commandExecution', 'functionCall', 'customToolCall', 'mcpToolCall', 'dynamicToolCall']);
161
+ if (!toolTypes.has(type))
162
+ return null;
163
+ if (method === 'item/started') {
164
+ const name = type === 'commandExecution' ? 'command'
165
+ : string(item.name) ?? string(item.tool) ?? (type === 'mcpToolCall' ? 'mcp' : 'tool');
166
+ const input = item.arguments !== undefined ? item.arguments
167
+ : item.input !== undefined ? item.input
168
+ : item.command !== undefined ? item.command
169
+ : undefined;
170
+ return { at: eventAt, turn: { id, at: eventAt, role: 'assistant', tools: [{ id, name, input: input === undefined ? undefined : compact(input), outputLines: 0, outputBytes: 0 }] } };
171
+ }
172
+ let output = undefined;
173
+ if (type === 'commandExecution')
174
+ output = item.aggregatedOutput;
175
+ else if (type === 'functionCall' || type === 'customToolCall')
176
+ output = item.output ?? item.result;
177
+ else if (type === 'mcpToolCall')
178
+ output = item.result ?? item.error;
179
+ else if (type === 'dynamicToolCall')
180
+ output = item.contentItems ?? item.output ?? item.error;
181
+ return output === undefined || output === null
182
+ ? { at: eventAt, turn: null }
183
+ : { at: eventAt, turn: null, toolOutputs: [{ id, text: compact(output) }] };
184
+ }
185
+ // Agent-message deltas are fragments of one native item. The closure remembers only that item's text and
186
+ // re-emits its native id, allowing IntervalCollector to replace the earlier turn in place.
187
+ export function codexAppServerStream() {
188
+ const textByItem = new Map();
189
+ return (value) => {
190
+ const parsed = codexAppServerEvent(value);
191
+ if (!parsed)
192
+ return null;
193
+ const entry = object(value);
194
+ const params = object(entry?.params);
195
+ const item = object(params?.item);
196
+ if (entry?.method === 'item/agentMessage/delta') {
197
+ const id = string(params?.itemId);
198
+ if (!id || !parsed.turn)
199
+ return parsed;
200
+ const text = (textByItem.get(id) ?? '') + (parsed.turn.text ?? '');
201
+ textByItem.set(id, text);
202
+ return { ...parsed, turn: { ...parsed.turn, text } };
203
+ }
204
+ if (item && item.type === 'agentMessage') {
205
+ const id = string(item.id);
206
+ if (id) {
207
+ const text = string(item.text);
208
+ if (text !== null || !textByItem.has(id))
209
+ textByItem.set(id, text ?? '');
210
+ const turn = parsed.turn;
211
+ if (turn)
212
+ return { ...parsed, turn: { ...turn, text: textByItem.get(id) || undefined } };
213
+ }
214
+ }
215
+ return parsed;
216
+ };
217
+ }
123
218
  const blockText = (content) => typeof content === 'string'
124
219
  ? string(content)
125
220
  : items(content).map((block) => string(object(block)?.text)).filter(Boolean).join('\n') || null;
@@ -154,6 +249,116 @@ export function piEvent(value) {
154
249
  }
155
250
  return null;
156
251
  }
252
+ export function geminiEvent(value) {
253
+ const entry = object(value);
254
+ const message = (entry?.type === 'user' || entry?.type === 'gemini')
255
+ ? entry
256
+ : object(items(entry?.messages)[0]) ?? object(items(object(entry?.$set)?.messages)[0]);
257
+ if (!entry || !message)
258
+ return null;
259
+ const eventAt = at(message) ?? at(entry);
260
+ if (eventAt === null)
261
+ return { at: null, turn: null };
262
+ const content = message.content;
263
+ const blocks = items(content);
264
+ const outputs = blocks.flatMap((blockValue) => {
265
+ const block = object(blockValue);
266
+ const response = object(block?.functionResponse);
267
+ const id = string(response?.id);
268
+ return id ? [{ id, text: compact(response?.response ?? '') }] : [];
269
+ });
270
+ if (outputs.length)
271
+ return { at: eventAt, turn: null, toolOutputs: outputs };
272
+ if (message.type === 'user') {
273
+ const text = typeof content === 'string' ? string(content) : blocks.map((block) => string(object(block)?.text)).filter(Boolean).join('\n') || null;
274
+ return text ? { at: eventAt, turn: { id: idOf(message), at: eventAt, role: 'user', text, tools: [] } } : null;
275
+ }
276
+ if (message.type === 'gemini') {
277
+ const turn = { id: idOf(message), at: eventAt, role: 'assistant', tools: [] };
278
+ if (typeof content === 'string')
279
+ turn.text = string(content) ?? undefined;
280
+ for (const callValue of items(message.toolCalls)) {
281
+ const call = object(callValue);
282
+ const id = string(call?.id) ?? `tool-${turn.tools.length}`;
283
+ turn.tools.push({ id, name: string(call?.name) ?? 'tool', input: call?.args === undefined ? undefined : compact(call.args), outputLines: 0, outputBytes: 0 });
284
+ }
285
+ if (!turn.text && !turn.tools.length)
286
+ return null;
287
+ return { at: eventAt, turn };
288
+ }
289
+ return null;
290
+ }
291
+ export function openclawEvent(value) {
292
+ const entry = object(value);
293
+ const message = object(entry?.message);
294
+ if (!entry || entry.type !== 'message' || !message)
295
+ return null;
296
+ const eventAt = at(message) ?? at(entry);
297
+ if (eventAt === null)
298
+ return { at: null, turn: null };
299
+ if (message.role === 'user') {
300
+ const text = blockText(message.content);
301
+ return text ? { at: eventAt, turn: { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'user', text, tools: [] } } : null;
302
+ }
303
+ if (message.role === 'toolResult') {
304
+ const id = string(message.toolCallId);
305
+ return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: blockText(message.content) ?? compact(message.content ?? '') }] } : null;
306
+ }
307
+ if (message.role === 'assistant') {
308
+ const turn = { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'assistant', tools: [] };
309
+ for (const blockValue of items(message.content)) {
310
+ const block = object(blockValue);
311
+ if (block?.type === 'text')
312
+ turn.text = [turn.text, string(block.text)].filter(Boolean).join('\n') || undefined;
313
+ if (block?.type === 'toolCall') {
314
+ const id = string(block.id) ?? `tool-${turn.tools.length}`;
315
+ turn.tools.push({ id, name: string(block.name) ?? 'tool', input: block.arguments === undefined ? undefined : compact(block.arguments), outputLines: 0, outputBytes: 0 });
316
+ }
317
+ }
318
+ return turn.text || turn.tools.length ? { at: eventAt, turn } : null;
319
+ }
320
+ return null;
321
+ }
322
+ export function hermesEvents(value) {
323
+ const root = object(value);
324
+ const events = [];
325
+ for (const messageValue of items(root?.messages)) {
326
+ const message = object(messageValue);
327
+ if (!message)
328
+ continue;
329
+ const eventAt = at(message);
330
+ if (eventAt === null) {
331
+ events.push({ at: null, turn: null });
332
+ continue;
333
+ }
334
+ const role = message.role;
335
+ if (role === 'user') {
336
+ const text = string(message.content);
337
+ if (text)
338
+ events.push({ at: eventAt, turn: { id: idOf(message), at: eventAt, role: 'user', text, tools: [] } });
339
+ }
340
+ else if (role === 'assistant') {
341
+ const turn = { id: idOf(message), at: eventAt, role: 'assistant', tools: [] };
342
+ const text = string(message.content);
343
+ if (text)
344
+ turn.text = text;
345
+ for (const callValue of items(message.tool_calls)) {
346
+ const call = object(callValue);
347
+ const fn = object(call?.function);
348
+ const id = string(call?.id) ?? `tool-${turn.tools.length}`;
349
+ turn.tools.push({ id, name: string(fn?.name) ?? 'tool', input: fn?.arguments === undefined ? undefined : compact(fn.arguments), outputLines: 0, outputBytes: 0 });
350
+ }
351
+ if (turn.text || turn.tools.length)
352
+ events.push({ at: eventAt, turn });
353
+ }
354
+ else if (role === 'tool') {
355
+ const id = string(message.tool_call_id);
356
+ if (id)
357
+ events.push({ at: eventAt, turn: null, toolOutputs: [{ id, text: compact(message.content ?? '') }] });
358
+ }
359
+ }
360
+ return events;
361
+ }
157
362
  // OpenCode's export is one JSON document, not a line stream: every message arrives with its parts, and a tool
158
363
  // part already carries its own result — so its turn is complete on arrival, and a part still running simply has
159
364
  // no output yet.
@@ -256,14 +461,31 @@ export class IntervalCollector {
256
461
  this.synthesized.set(base, seen + 1);
257
462
  turn.id = seen ? `${base}#${seen}` : base;
258
463
  }
259
- this.turns.push(turn);
260
- for (const tool of turn.tools)
261
- this.byTool.set(tool.id, tool);
262
- if (this.turns.length > MAX_TURNS) {
263
- const dropped = this.turns.shift();
264
- for (const tool of dropped.tools)
265
- this.evicted.add(tool.id);
266
- this.omittedTurns++;
464
+ const existingAt = this.turns.findIndex((candidate) => candidate.id === turn.id);
465
+ if (existingAt >= 0) {
466
+ const existing = this.turns[existingAt];
467
+ const incomingTools = new Map(turn.tools.map((tool) => [tool.id, tool]));
468
+ const tools = existing.tools.map((tool) => {
469
+ const incoming = incomingTools.get(tool.id);
470
+ incomingTools.delete(tool.id);
471
+ return incoming ? { ...tool, ...incoming, output: incoming.output ?? tool.output } : tool;
472
+ });
473
+ tools.push(...incomingTools.values());
474
+ const replacement = { ...existing, ...turn, text: turn.text ?? existing.text, tools };
475
+ this.turns[existingAt] = replacement;
476
+ for (const tool of replacement.tools)
477
+ this.byTool.set(tool.id, tool);
478
+ }
479
+ else {
480
+ this.turns.push(turn);
481
+ for (const tool of turn.tools)
482
+ this.byTool.set(tool.id, tool);
483
+ if (this.turns.length > MAX_TURNS) {
484
+ const dropped = this.turns.shift();
485
+ for (const tool of dropped.tools)
486
+ this.evicted.add(tool.id);
487
+ this.omittedTurns++;
488
+ }
267
489
  }
268
490
  }
269
491
  return this.pastRange;
@@ -1,10 +1,16 @@
1
1
  import { type TranscriptReader } from './turns.js';
2
2
  export declare function claudeTranscriptPath(threadId: string, root?: string): string | null;
3
- export declare function codexRolloutPath(threadId: string, root?: string): string | null;
3
+ export declare function codexRolloutPath(threadId: string, root?: string, archive?: string): string | null;
4
4
  export declare function piSessionPath(threadId: string, root?: string): string | null;
5
+ export declare function geminiTranscriptPath(threadId: string, root?: string): string | null;
6
+ export declare function openclawTranscriptPath(threadId: string, root?: string): string | null;
5
7
  export declare const claudeTranscript: TranscriptReader;
6
8
  export declare const codexTranscript: TranscriptReader;
7
9
  export declare const piTranscript: TranscriptReader;
10
+ export declare const geminiTranscript: TranscriptReader;
11
+ export declare const openclawTranscript: TranscriptReader;
8
12
  export declare function opencodeTranscriptReader(root?: string, load?: (threadId: string) => string): TranscriptReader;
9
13
  export declare const opencodeTranscript: TranscriptReader;
14
+ export declare function hermesTranscriptReader(root?: string, load?: (threadId: string) => string): TranscriptReader;
15
+ export declare const hermesTranscript: TranscriptReader;
10
16
  export declare function unsupportedTranscript(harness: string): TranscriptReader;
@@ -1,9 +1,9 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { closeSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { dirname, join } from 'node:path';
5
5
  import { TranscriptReadError } from './turns.js';
6
- import { IntervalCollector, claudeEvent, codexEvent, opencodeEvents, piEvent } from './parsers.js';
6
+ import { IntervalCollector, claudeEvent, codexEvent, geminiEvent, hermesEvents, openclawEvent, opencodeEvents, piEvent } from './parsers.js';
7
7
  // THE NATIVE-THREAD READERS. Each harness keeps its conversation somewhere private — Claude's project JSONL,
8
8
  // Codex's rollout, pi's session JSONL, OpenCode's store behind `opencode export` — and this module is the only
9
9
  // place that knows where. It answers exactly one question for every harness: "what happened in this thread
@@ -31,7 +31,10 @@ export function claudeTranscriptPath(threadId, root = projectTranscriptRoot()) {
31
31
  const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions');
32
32
  // Walk newest day first and return on the first hit; the walk is exhaustive rather than capped, because
33
33
  // future-dated junk under sessions/ sorts above every real day and a cap once masked every real rollout.
34
- export function codexRolloutPath(threadId, root = codexSessionsDir()) {
34
+ // A thread Codex has ARCHIVED (the app-server's thread/archive, which a closed session runs) keeps its
35
+ // rollout, moved out of the dated tree into the flat `archived_sessions/` beside it — a closed session's
36
+ // conversation is still on disk and still readable, so the locator looks there second.
37
+ export function codexRolloutPath(threadId, root = codexSessionsDir(), archive = join(dirname(root), 'archived_sessions')) {
35
38
  for (const year of children(root))
36
39
  for (const month of children(join(root, year)))
37
40
  for (const day of children(join(root, year, month))) {
@@ -40,7 +43,8 @@ export function codexRolloutPath(threadId, root = codexSessionsDir()) {
40
43
  if (file)
41
44
  return join(dir, file);
42
45
  }
43
- return null;
46
+ const archived = children(archive).find((name) => name.includes(threadId));
47
+ return archived ? join(archive, archived) : null;
44
48
  }
45
49
  const piSessionsRoot = () => join(process.env.SPEXCODE_PI_AGENT_DIR || join(homedir(), '.pi', 'agent'), 'sessions');
46
50
  const piSessionPaths = new Map();
@@ -72,6 +76,36 @@ export function piSessionPath(threadId, root = piSessionsRoot()) {
72
76
  }
73
77
  return null;
74
78
  }
79
+ const findJsonl = (root, threadId, maxDepth = 5) => {
80
+ const walk = (dir, depth) => {
81
+ if (depth > maxDepth)
82
+ return null;
83
+ for (const name of children(dir)) {
84
+ const path = join(dir, name);
85
+ try {
86
+ if (statSync(path).isFile() && name.endsWith('.jsonl')) {
87
+ if (name.includes(threadId))
88
+ return path;
89
+ const header = JSON.parse(readFileSync(path, 'utf8').split('\n', 1)[0]);
90
+ if (header.sessionId === threadId || header.id === threadId)
91
+ return path;
92
+ }
93
+ else if (statSync(path).isDirectory()) {
94
+ const found = walk(path, depth + 1);
95
+ if (found)
96
+ return found;
97
+ }
98
+ }
99
+ catch { /* skip entries that disappear or are not JSON */ }
100
+ }
101
+ return null;
102
+ };
103
+ return walk(root, 0);
104
+ };
105
+ const geminiRoot = () => process.env.GEMINI_HOME || process.env.GEMINI_CONFIG_DIR || join(homedir(), '.gemini');
106
+ export function geminiTranscriptPath(threadId, root = geminiRoot()) { return findJsonl(root, threadId); }
107
+ const openclawRoot = () => process.env.OPENCLAW_STATE_DIR || join(homedir(), '.openclaw', 'state');
108
+ export function openclawTranscriptPath(threadId, root = openclawRoot()) { return findJsonl(root, threadId); }
75
109
  const opencodeStoreRoot = () => process.env.SPEXCODE_OPENCODE_DATA_DIR
76
110
  || join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'opencode');
77
111
  function opencodeStoreRevision(root) {
@@ -243,6 +277,8 @@ function lineFileReader(harness, locate, parse) {
243
277
  export const claudeTranscript = lineFileReader('claude', (threadId) => claudeTranscriptPath(threadId), claudeEvent);
244
278
  export const codexTranscript = lineFileReader('codex', (threadId) => codexRolloutPath(threadId), codexEvent);
245
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);
246
282
  // OpenCode has no per-thread file: the store's revision is the change token, and one export per
247
283
  // revision is parsed and kept, so repeated interval reads of a quiet thread cost nothing new.
248
284
  const opencodeExports = new Map();
@@ -286,6 +322,53 @@ export function opencodeTranscriptReader(root = opencodeStoreRoot(), load = open
286
322
  };
287
323
  }
288
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;
333
+ }
334
+ }
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) {
340
+ const read = async (threadId, range) => {
341
+ const revision = hermesRevision(root);
342
+ 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);
346
+ if (!cached || cached.revision !== revision) {
347
+ let exported;
348
+ try {
349
+ exported = load(threadId);
350
+ }
351
+ catch (error) {
352
+ throw new TranscriptReadError('unreadable', `hermes transcript could not be exported: ${error instanceof Error ? error.message : String(error)}`);
353
+ }
354
+ let value;
355
+ try {
356
+ value = JSON.parse(exported);
357
+ }
358
+ catch (error) {
359
+ throw new TranscriptReadError('invalid', `hermes transcript cannot be parsed: ${error instanceof Error ? error.message : String(error)}`);
360
+ }
361
+ cached = { revision, events: hermesEvents(value) };
362
+ hermesExports.set(key, cached);
363
+ }
364
+ const collector = new IntervalCollector(range);
365
+ for (const event of cached.events)
366
+ collector.add(event);
367
+ return collector.finish(revision, 'hermes');
368
+ };
369
+ return { revision: () => hermesRevision(root), read, tail: (threadId, from) => ({ advance: (to) => read(threadId, { from, to }), close: () => { } }) };
370
+ }
371
+ export const hermesTranscript = hermesTranscriptReader();
289
372
  export function unsupportedTranscript(harness) {
290
373
  const refuse = async () => { throw new TranscriptReadError('unsupported', `${harness} does not support transcript access`); };
291
374
  return {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/transcript",
3
- "version": "0.7.0-next.0",
3
+ "version": "0.7.0-next.2",
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.0",
3
+ "version": "0.7.0-next.2",
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.0"
47
+ "@spexcode/spec-cli": "0.7.0-next.2"
48
48
  },
49
49
  "publishConfig": {
50
50
  "access": "public"