@bububuger/spanory 0.1.15 → 0.1.18

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.
@@ -0,0 +1,151 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export const ISSUE_STATUSES = ['open', 'in_progress', 'blocked', 'done'];
4
+ function nowIso(now) {
5
+ return now ?? new Date().toISOString();
6
+ }
7
+ function normalizeId(id) {
8
+ return String(id ?? '').trim();
9
+ }
10
+ function assertStatus(status) {
11
+ if (!ISSUE_STATUSES.includes(status)) {
12
+ throw new Error(`unsupported issue status: ${status}`);
13
+ }
14
+ }
15
+ function assertAllowedTransition(from, to) {
16
+ if (from === to)
17
+ return;
18
+ if (from === 'done' && to !== 'done') {
19
+ throw new Error('cannot transition issue from done to non-done status');
20
+ }
21
+ }
22
+ export function parsePendingTodoItems(todoContent, source = 'todo.md') {
23
+ const lines = String(todoContent ?? '').split(/\r?\n/);
24
+ const items = [];
25
+ for (const line of lines) {
26
+ const m = line.match(/^\s*-\s*\[\s\]\s+([A-Za-z0-9_-]+)\s+(.*)$/);
27
+ if (!m)
28
+ continue;
29
+ items.push({
30
+ id: normalizeId(m[1]),
31
+ title: m[2].trim(),
32
+ source,
33
+ });
34
+ }
35
+ return items;
36
+ }
37
+ export function syncIssueState(prev, pending, now) {
38
+ const timestamp = nowIso(now);
39
+ const byId = new Map(prev.issues.map((issue) => [issue.id, issue]));
40
+ const activeIds = new Set(pending.map((item) => item.id));
41
+ let added = 0;
42
+ let reopened = 0;
43
+ let autoClosed = 0;
44
+ for (const item of pending) {
45
+ const existing = byId.get(item.id);
46
+ if (!existing) {
47
+ byId.set(item.id, {
48
+ id: item.id,
49
+ title: item.title,
50
+ source: item.source,
51
+ status: 'open',
52
+ notes: ['synced from todo pending item'],
53
+ createdAt: timestamp,
54
+ updatedAt: timestamp,
55
+ });
56
+ added += 1;
57
+ continue;
58
+ }
59
+ existing.title = item.title;
60
+ existing.source = item.source;
61
+ existing.updatedAt = timestamp;
62
+ if (existing.status === 'done') {
63
+ existing.status = 'open';
64
+ existing.closedAt = undefined;
65
+ existing.notes.push('reopened by todo pending item');
66
+ reopened += 1;
67
+ }
68
+ }
69
+ for (const issue of byId.values()) {
70
+ if (issue.source !== 'todo.md')
71
+ continue;
72
+ if (activeIds.has(issue.id))
73
+ continue;
74
+ if (issue.status === 'done')
75
+ continue;
76
+ issue.status = 'done';
77
+ issue.updatedAt = timestamp;
78
+ issue.closedAt = timestamp;
79
+ issue.notes.push('auto-closed because todo item is no longer pending');
80
+ autoClosed += 1;
81
+ }
82
+ return {
83
+ state: {
84
+ version: 1,
85
+ updatedAt: timestamp,
86
+ issues: Array.from(byId.values()).sort((a, b) => a.id.localeCompare(b.id)),
87
+ },
88
+ added,
89
+ reopened,
90
+ autoClosed,
91
+ };
92
+ }
93
+ export function setIssueStatus(prev, input, now) {
94
+ const issueId = normalizeId(input.id);
95
+ const timestamp = nowIso(now);
96
+ assertStatus(input.status);
97
+ const issue = prev.issues.find((item) => item.id === issueId);
98
+ if (!issue)
99
+ throw new Error(`issue not found: ${issueId}`);
100
+ assertAllowedTransition(issue.status, input.status);
101
+ issue.status = input.status;
102
+ issue.updatedAt = timestamp;
103
+ if (input.status === 'done')
104
+ issue.closedAt = timestamp;
105
+ if (input.status !== 'done')
106
+ issue.closedAt = undefined;
107
+ if (input.note && input.note.trim())
108
+ issue.notes.push(input.note.trim());
109
+ return {
110
+ version: 1,
111
+ updatedAt: timestamp,
112
+ issues: prev.issues,
113
+ };
114
+ }
115
+ export function createEmptyIssueState(now) {
116
+ return {
117
+ version: 1,
118
+ updatedAt: nowIso(now),
119
+ issues: [],
120
+ };
121
+ }
122
+ export async function loadIssueState(filePath) {
123
+ try {
124
+ const raw = await readFile(filePath, 'utf-8');
125
+ const parsed = JSON.parse(raw);
126
+ if (!parsed || !Array.isArray(parsed.issues)) {
127
+ throw new Error('invalid issue state file');
128
+ }
129
+ return {
130
+ version: 1,
131
+ updatedAt: String(parsed.updatedAt ?? new Date(0).toISOString()),
132
+ issues: parsed.issues,
133
+ };
134
+ }
135
+ catch (error) {
136
+ if (error?.code === 'ENOENT') {
137
+ return createEmptyIssueState();
138
+ }
139
+ throw error;
140
+ }
141
+ }
142
+ export async function saveIssueState(filePath, state) {
143
+ await mkdir(path.dirname(filePath), { recursive: true });
144
+ await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8');
145
+ }
146
+ export function resolveIssueStatePath(input) {
147
+ return input ? path.resolve(process.cwd(), input) : path.resolve(process.cwd(), 'docs/issues/state.json');
148
+ }
149
+ export function resolveTodoPath(input) {
150
+ return input ? path.resolve(process.cwd(), input) : path.resolve(process.cwd(), 'todo.md');
151
+ }
package/dist/otlp.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { buildResource } from '../../otlp-core/dist/index.js';
2
2
  export { buildResource };
3
- export declare function parseHeaders(input: any): any;
4
- export declare function compileOtlp(events: any, resource: any): any;
3
+ export declare function parseHeaders(input: any): Record<string, string>;
4
+ export declare function compileOtlp(events: any, resource: any): import("../../otlp-core/dist/index.js").OtlpPayload;
5
5
  export declare function sendOtlp(endpoint: any, payload: any, headers?: {}): Promise<void>;
@@ -19,3 +19,4 @@ export declare function summarizeTools(sessions: any): {
19
19
  sessions: any;
20
20
  }[];
21
21
  export declare function summarizeTurnDiff(sessions: any): any[];
22
+ export declare function summarizeContext(sessions: any): any;
@@ -26,6 +26,32 @@ function usageFromEvent(event) {
26
26
  const total = toNumber(attrs['gen_ai.usage.total_tokens']) || input + output;
27
27
  return { input, output, total };
28
28
  }
29
+ function parseJsonObject(value) {
30
+ if (typeof value !== 'string' || !value.trim())
31
+ return null;
32
+ try {
33
+ const parsed = JSON.parse(value);
34
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
35
+ return parsed;
36
+ }
37
+ catch {
38
+ // ignore parse errors
39
+ }
40
+ return null;
41
+ }
42
+ function parseJsonArray(value) {
43
+ if (typeof value !== 'string' || !value.trim())
44
+ return [];
45
+ try {
46
+ const parsed = JSON.parse(value);
47
+ if (Array.isArray(parsed))
48
+ return parsed;
49
+ }
50
+ catch {
51
+ // ignore parse errors
52
+ }
53
+ return [];
54
+ }
29
55
  export async function loadExportedEvents(inputPath) {
30
56
  const inputStat = await stat(inputPath);
31
57
  const files = [];
@@ -141,8 +167,8 @@ export function summarizeCache(sessions) {
141
167
  for (const turn of turns) {
142
168
  const attrs = turn.attributes ?? {};
143
169
  inputTokens += toNumber(attrs['gen_ai.usage.input_tokens']);
144
- cacheReadInputTokens += toNumber(attrs['gen_ai.usage.details.cache_read_input_tokens']);
145
- cacheCreationInputTokens += toNumber(attrs['gen_ai.usage.details.cache_creation_input_tokens']);
170
+ cacheReadInputTokens += toNumber(attrs['gen_ai.usage.cache_read.input_tokens']);
171
+ cacheCreationInputTokens += toNumber(attrs['gen_ai.usage.cache_creation.input_tokens']);
146
172
  const hitRate = toOptionalNumber(attrs['gen_ai.usage.details.cache_hit_rate']);
147
173
  if (hitRate !== undefined)
148
174
  explicitHitRates.push(hitRate);
@@ -243,3 +269,103 @@ export function summarizeTurnDiff(sessions) {
243
269
  }
244
270
  return rows;
245
271
  }
272
+ export function summarizeContext(sessions) {
273
+ return sessions.map((s) => {
274
+ const events = s.events ?? [];
275
+ const snapshots = events.filter((e) => e?.attributes?.['agentic.context.event_type'] === 'context_snapshot');
276
+ const boundaries = events.filter((e) => e?.attributes?.['agentic.context.event_type'] === 'context_boundary');
277
+ const attributions = events.filter((e) => e?.attributes?.['agentic.context.event_type'] === 'context_source_attribution');
278
+ let maxFillRatio = 0;
279
+ let maxDeltaTokens = 0;
280
+ for (const snapshot of snapshots) {
281
+ const attrs = snapshot.attributes ?? {};
282
+ const fillRatio = toOptionalNumber(attrs['agentic.context.fill_ratio']) ?? 0;
283
+ const deltaTokens = toOptionalNumber(attrs['agentic.context.delta_tokens']) ?? 0;
284
+ maxFillRatio = Math.max(maxFillRatio, fillRatio);
285
+ maxDeltaTokens = Math.max(maxDeltaTokens, deltaTokens);
286
+ }
287
+ const compactCount = boundaries.filter((e) => String(e?.attributes?.['agentic.context.boundary_kind'] ?? '') === 'compact_after').length;
288
+ const last5 = snapshots.slice(-5);
289
+ let unknownTokens = 0;
290
+ let totalTokens = 0;
291
+ for (const snapshot of last5) {
292
+ const composition = parseJsonObject(snapshot?.attributes?.['agentic.context.composition']);
293
+ if (!composition)
294
+ continue;
295
+ for (const [kind, raw] of Object.entries(composition)) {
296
+ const value = Number(raw);
297
+ if (!Number.isFinite(value) || value <= 0)
298
+ continue;
299
+ totalTokens += value;
300
+ if (kind === 'unknown')
301
+ unknownTokens += value;
302
+ }
303
+ }
304
+ const unknownDeltaShareWindow5 = totalTokens > 0 ? round6(unknownTokens / totalTokens) : 0;
305
+ let unknownTopStreak = 0;
306
+ let runningUnknown = 0;
307
+ for (const snapshot of snapshots) {
308
+ const topSources = parseJsonArray(snapshot?.attributes?.['agentic.context.top_sources']);
309
+ const top = String(topSources[0] ?? '').trim();
310
+ if (top === 'unknown') {
311
+ runningUnknown += 1;
312
+ unknownTopStreak = Math.max(unknownTopStreak, runningUnknown);
313
+ }
314
+ else {
315
+ runningUnknown = 0;
316
+ }
317
+ }
318
+ let highPollutionSourceStreak = 0;
319
+ const turnOrder = [];
320
+ const highByTurn = new Map();
321
+ for (const event of attributions) {
322
+ const attrs = event?.attributes ?? {};
323
+ const turnId = String(event?.turnId ?? '');
324
+ if (!turnId)
325
+ continue;
326
+ if (!highByTurn.has(turnId)) {
327
+ highByTurn.set(turnId, []);
328
+ turnOrder.push(turnId);
329
+ }
330
+ const sourceKind = String(attrs['agentic.context.source_kind'] ?? '').trim();
331
+ const score = Number(attrs['agentic.context.pollution_score']);
332
+ if (!sourceKind || !Number.isFinite(score) || score < 80)
333
+ continue;
334
+ highByTurn.get(turnId).push({ sourceKind, score });
335
+ }
336
+ let runningSource = '';
337
+ let runningCount = 0;
338
+ for (const turnId of turnOrder) {
339
+ const items = highByTurn.get(turnId) ?? [];
340
+ if (!items.length) {
341
+ runningSource = '';
342
+ runningCount = 0;
343
+ continue;
344
+ }
345
+ items.sort((a, b) => b.score - a.score);
346
+ const topSource = items[0].sourceKind;
347
+ if (topSource === runningSource) {
348
+ runningCount += 1;
349
+ }
350
+ else {
351
+ runningSource = topSource;
352
+ runningCount = 1;
353
+ }
354
+ highPollutionSourceStreak = Math.max(highPollutionSourceStreak, runningCount);
355
+ }
356
+ return {
357
+ projectId: s.context.projectId ?? events[0]?.projectId,
358
+ sessionId: s.context.sessionId ?? events[0]?.sessionId,
359
+ runtime: events[0]?.runtime,
360
+ snapshots: snapshots.length,
361
+ boundaries: boundaries.length,
362
+ compactCount,
363
+ attributions: attributions.length,
364
+ maxFillRatio: round6(maxFillRatio),
365
+ maxDeltaTokens,
366
+ unknownDeltaShareWindow5,
367
+ unknownTopStreak,
368
+ highPollutionSourceStreak,
369
+ };
370
+ });
371
+ }
@@ -1,8 +1,9 @@
1
1
  // @ts-nocheck
2
- import { readFile } from 'node:fs/promises';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { RUNTIME_CAPABILITIES } from '../shared/capabilities.js';
5
5
  import { normalizeTranscriptMessages, parseProjectIdFromTranscriptPath, pickUsage } from '../shared/normalize.js';
6
+ const INFER_WINDOW_EPSILON_MS = 1200;
6
7
  function parseTimestamp(entry) {
7
8
  const raw = entry?.timestamp;
8
9
  const date = raw ? new Date(raw) : new Date();
@@ -17,6 +18,150 @@ function normalizeIsSidechain(entry) {
17
18
  function normalizeAgentId(entry) {
18
19
  return entry?.agentId ?? entry?.agent_id ?? entry?.message?.agentId ?? entry?.message?.agent_id;
19
20
  }
21
+ function extractToolUses(content) {
22
+ if (!Array.isArray(content))
23
+ return [];
24
+ return content.filter((block) => block && typeof block === 'object' && block.type === 'tool_use');
25
+ }
26
+ function extractToolResults(content) {
27
+ if (!Array.isArray(content))
28
+ return [];
29
+ return content.filter((block) => block && typeof block === 'object' && block.type === 'tool_result');
30
+ }
31
+ function isToolResultOnlyContent(content) {
32
+ return Array.isArray(content)
33
+ && content.length > 0
34
+ && content.every((block) => block && typeof block === 'object' && block.type === 'tool_result');
35
+ }
36
+ function isPromptUserMessage(message) {
37
+ if (!message || message.role !== 'user' || message.isMeta)
38
+ return false;
39
+ const { content } = message;
40
+ if (typeof content === 'string')
41
+ return content.trim().length > 0;
42
+ if (!Array.isArray(content))
43
+ return false;
44
+ if (isToolResultOnlyContent(content))
45
+ return false;
46
+ return content.length > 0;
47
+ }
48
+ function firstNonEmpty(values) {
49
+ for (const value of values) {
50
+ const text = String(value ?? '').trim();
51
+ if (text)
52
+ return text;
53
+ }
54
+ return '';
55
+ }
56
+ function findChildSessionHints(messages) {
57
+ const hasSidechainSignal = messages.some((m) => m?.isSidechain === true || String(m?.agentId ?? '').trim().length > 0);
58
+ if (!hasSidechainSignal)
59
+ return null;
60
+ const hasParentLink = messages.some((m) => String(m?.parentSessionId ?? m?.parent_session_id ?? '').trim().length > 0
61
+ || String(m?.parentTurnId ?? m?.parent_turn_id ?? '').trim().length > 0
62
+ || String(m?.parentToolCallId ?? m?.parent_tool_call_id ?? '').trim().length > 0);
63
+ if (hasParentLink)
64
+ return null;
65
+ const sorted = [...messages].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
66
+ const childStartedAt = sorted[0]?.timestamp;
67
+ if (!childStartedAt)
68
+ return null;
69
+ return {
70
+ childStartedAt,
71
+ agentId: firstNonEmpty(messages.map((m) => m?.agentId)),
72
+ };
73
+ }
74
+ function extractTaskWindows(messages, sessionId) {
75
+ const sorted = [...messages].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
76
+ const windows = [];
77
+ const byCallId = new Map();
78
+ let turnIndex = 0;
79
+ let currentTurnId = 'turn-1';
80
+ for (const msg of sorted) {
81
+ if (isPromptUserMessage(msg)) {
82
+ turnIndex += 1;
83
+ currentTurnId = `turn-${turnIndex}`;
84
+ }
85
+ if (msg.role === 'assistant') {
86
+ for (const tu of extractToolUses(msg.content)) {
87
+ const toolName = String(tu.name ?? '').trim();
88
+ if (toolName !== 'Task')
89
+ continue;
90
+ const callId = String(tu.id ?? '').trim();
91
+ if (!callId)
92
+ continue;
93
+ const window = {
94
+ parentSessionId: sessionId,
95
+ parentTurnId: currentTurnId,
96
+ parentToolCallId: callId,
97
+ startedAtMs: msg.timestamp.getTime(),
98
+ endedAtMs: msg.timestamp.getTime(),
99
+ };
100
+ windows.push(window);
101
+ byCallId.set(callId, window);
102
+ }
103
+ }
104
+ if (msg.role === 'user') {
105
+ for (const tr of extractToolResults(msg.content)) {
106
+ const callId = String(tr.tool_use_id ?? tr.toolUseId ?? '').trim();
107
+ if (!callId)
108
+ continue;
109
+ const window = byCallId.get(callId);
110
+ if (window) {
111
+ window.endedAtMs = Math.max(window.endedAtMs, msg.timestamp.getTime());
112
+ }
113
+ }
114
+ }
115
+ }
116
+ return windows;
117
+ }
118
+ async function inferParentLinkFromSiblingSessions({ transcriptPath, messages }) {
119
+ const hints = findChildSessionHints(messages);
120
+ if (!hints)
121
+ return messages;
122
+ const currentSessionId = path.basename(transcriptPath, '.jsonl');
123
+ const dir = path.dirname(transcriptPath);
124
+ let entries = [];
125
+ try {
126
+ entries = await readdir(dir, { withFileTypes: true });
127
+ }
128
+ catch {
129
+ return messages;
130
+ }
131
+ const candidates = [];
132
+ for (const entry of entries) {
133
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
134
+ continue;
135
+ const siblingSessionId = entry.name.slice(0, -'.jsonl'.length);
136
+ if (siblingSessionId === currentSessionId)
137
+ continue;
138
+ const siblingPath = path.join(dir, entry.name);
139
+ const siblingMessages = await readClaudeTranscript(siblingPath);
140
+ const windows = extractTaskWindows(siblingMessages, siblingSessionId);
141
+ for (const window of windows) {
142
+ const childAtMs = hints.childStartedAt.getTime();
143
+ const lower = window.startedAtMs - INFER_WINDOW_EPSILON_MS;
144
+ const upper = window.endedAtMs + INFER_WINDOW_EPSILON_MS;
145
+ if (childAtMs < lower || childAtMs > upper)
146
+ continue;
147
+ candidates.push({
148
+ ...window,
149
+ score: Math.abs(childAtMs - window.startedAtMs),
150
+ });
151
+ }
152
+ }
153
+ if (candidates.length === 0)
154
+ return messages;
155
+ candidates.sort((a, b) => a.score - b.score);
156
+ const best = candidates[0];
157
+ return messages.map((msg) => ({
158
+ ...msg,
159
+ parentSessionId: best.parentSessionId,
160
+ parentTurnId: best.parentTurnId,
161
+ parentToolCallId: best.parentToolCallId,
162
+ parentLinkConfidence: 'inferred',
163
+ }));
164
+ }
20
165
  async function readClaudeTranscript(transcriptPath) {
21
166
  const raw = await readFile(transcriptPath, 'utf-8');
22
167
  const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
@@ -29,6 +174,9 @@ async function readClaudeTranscript(transcriptPath) {
29
174
  isMeta: entry.isMeta ?? false,
30
175
  isSidechain: normalizeIsSidechain(entry),
31
176
  agentId: normalizeAgentId(entry),
177
+ parentSessionId: entry?.parentSessionId ?? entry?.parent_session_id,
178
+ parentTurnId: entry?.parentTurnId ?? entry?.parent_turn_id,
179
+ parentToolCallId: entry?.parentToolCallId ?? entry?.parent_tool_call_id,
32
180
  content: entry?.message?.content ?? entry.content ?? '',
33
181
  model: entry?.message?.model ?? entry.model,
34
182
  usage: pickUsage(entry?.message?.usage ?? entry?.usage ?? entry?.message_usage),
@@ -62,7 +210,8 @@ export const claudeCodeAdapter = {
62
210
  async collectEvents(context) {
63
211
  const transcriptPath = context.transcriptPath ??
64
212
  path.join(process.env.HOME || '', '.claude', 'projects', context.projectId, `${context.sessionId}.jsonl`);
65
- const messages = await readClaudeTranscript(transcriptPath);
213
+ const loaded = await readClaudeTranscript(transcriptPath);
214
+ const messages = await inferParentLinkFromSiblingSessions({ transcriptPath, messages: loaded });
66
215
  return normalizeTranscriptMessages({
67
216
  runtime: 'claude-code',
68
217
  projectId: context.projectId,
@@ -60,7 +60,7 @@ function sanitizeProjectBase(name) {
60
60
  }
61
61
  function deriveProjectIdFromCwd(cwd) {
62
62
  const base = sanitizeProjectBase(path.basename(String(cwd ?? '').trim()) || 'codex');
63
- const hash = createHash('sha1').update(String(cwd ?? '')).digest('hex').slice(0, 6);
63
+ const hash = createHash('sha1').update(String(cwd ?? '')).digest('hex').slice(0, 10);
64
64
  return `${base}-${hash}`;
65
65
  }
66
66
  function usageFromTotals(start, end) {
@@ -77,6 +77,21 @@ function usageFromTotals(start, end) {
77
77
  total_tokens: total || input + output,
78
78
  });
79
79
  }
80
+ function extractPtySessionId(output) {
81
+ const text = String(output ?? '');
82
+ const match = text.match(/session ID\s+(\d+)/i);
83
+ return match ? match[1] : undefined;
84
+ }
85
+ function extractWallTimeMs(output) {
86
+ const text = String(output ?? '');
87
+ const match = text.match(/Wall time:\s*([0-9]+(?:\.[0-9]+)?)\s*seconds?/i);
88
+ if (!match)
89
+ return undefined;
90
+ const seconds = Number(match[1]);
91
+ if (!Number.isFinite(seconds) || seconds <= 0)
92
+ return undefined;
93
+ return Math.floor(seconds * 1000);
94
+ }
80
95
  function createTurn(turnId, startedAt) {
81
96
  return {
82
97
  turnId,
@@ -194,6 +209,7 @@ async function readCodexSession(transcriptPath) {
194
209
  let sessionMeta = null;
195
210
  let callCounter = 0;
196
211
  const callIndex = new Map();
212
+ const ptyCallBySession = new Map();
197
213
  function finalizeCurrentTurn(at) {
198
214
  if (!currentTurn)
199
215
  return;
@@ -323,6 +339,11 @@ async function readCodexSession(transcriptPath) {
323
339
  const rawName = payload.name ?? payload.tool_name ?? payload.toolName;
324
340
  const rawInput = payload.type === 'custom_tool_call' ? payload.input : payload.arguments;
325
341
  const args = safeJsonParse(rawInput, typeof rawInput === 'string' ? { raw: rawInput } : {});
342
+ const ptySessionId = args.session_id != null ? String(args.session_id) : '';
343
+ if (String(rawName ?? '') === 'write_stdin' && ptySessionId && ptyCallBySession.has(ptySessionId)) {
344
+ callIndex.set(String(payload.call_id ?? payload.callId ?? `call-${callCounter}`), ptyCallBySession.get(ptySessionId));
345
+ continue;
346
+ }
326
347
  const normalized = normalizeToolCall(rawName, args, callCounter);
327
348
  const callId = String(payload.call_id ?? payload.callId ?? normalized.callId ?? `call-${callCounter}`);
328
349
  const call = {
@@ -341,8 +362,22 @@ async function readCodexSession(transcriptPath) {
341
362
  const callId = String(payload.call_id ?? payload.callId ?? '');
342
363
  const call = callIndex.get(callId);
343
364
  if (call) {
344
- call.output = String(payload.output ?? '');
365
+ const output = String(payload.output ?? '');
366
+ if (output)
367
+ call.output = output;
345
368
  call.endedAt = isoAt;
369
+ const wallTimeMs = extractWallTimeMs(output);
370
+ if (wallTimeMs) {
371
+ const derivedEndedAt = new Date(parseTimestamp(call.startedAt).getTime() + wallTimeMs).toISOString();
372
+ if (parseTimestamp(derivedEndedAt).getTime() > parseTimestamp(call.endedAt).getTime()) {
373
+ call.endedAt = derivedEndedAt;
374
+ }
375
+ }
376
+ if (call.toolName === 'Bash') {
377
+ const ptySessionId = extractPtySessionId(output);
378
+ if (ptySessionId)
379
+ ptyCallBySession.set(ptySessionId, call);
380
+ }
346
381
  }
347
382
  continue;
348
383
  }
@@ -398,11 +433,12 @@ function remapTurnIds(events, turns) {
398
433
  function attachCwdAttribute(events, cwd) {
399
434
  if (!cwd)
400
435
  return events;
436
+ const sanitizedCwd = deriveProjectIdFromCwd(cwd);
401
437
  return events.map((event) => ({
402
438
  ...event,
403
439
  attributes: {
404
440
  ...(event.attributes ?? {}),
405
- 'agentic.project.cwd': cwd,
441
+ 'agentic.project.cwd': sanitizedCwd,
406
442
  },
407
443
  }));
408
444
  }
@@ -117,7 +117,10 @@ function correlationKeyFromRequest(req, seq) {
117
117
  }
118
118
  export function createCodexProxyServer(options) {
119
119
  const upstreamBaseUrl = options?.upstreamBaseUrl ?? process.env.OPENAI_BASE_URL ?? 'https://api.openai.com';
120
- const spoolDir = options?.spoolDir ?? process.env.SPANORY_CODEX_PROXY_SPOOL_DIR ?? path.join(process.cwd(), '.spanory', 'codex-proxy-spool');
120
+ const spanoryHome = process.env.SPANORY_HOME ?? path.join(process.env.HOME || '', '.spanory');
121
+ const spoolDir = options?.spoolDir
122
+ ?? process.env.SPANORY_CODEX_PROXY_SPOOL_DIR
123
+ ?? path.join(spanoryHome, 'spool', 'codex-proxy');
121
124
  const maxBodyBytes = Number(options?.maxBodyBytes ?? process.env.SPANORY_CODEX_CAPTURE_MAX_BYTES ?? 131072);
122
125
  const logger = options?.logger ?? console;
123
126
  const upstream = new URL(upstreamBaseUrl);