agent-orchestrator-kit 0.9.0 → 0.11.0

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.
@@ -18,6 +18,73 @@ function addNullable(a, b) {
18
18
  return (a ?? 0) + (b ?? 0);
19
19
  }
20
20
 
21
+ const GROK_46 = {
22
+ inputPerM: 2,
23
+ cachedPerM: 0.5,
24
+ outputPerM: 6,
25
+ longInputPerM: 4,
26
+ longCachedPerM: 1,
27
+ longOutputPerM: 12,
28
+ longAt: 200000,
29
+ };
30
+
31
+ function ratesForModel(model) {
32
+ const id = String(model || '').toLowerCase();
33
+ if (!id) return null;
34
+ let rates = null;
35
+ if (id.includes('grok-4.6') || id.includes('grok-4-6')) rates = { ...GROK_46 };
36
+ else if (id.includes('grok-4.5') || id.includes('grok-4-5')) {
37
+ rates = { ...GROK_46, cachedPerM: 0.3, longCachedPerM: 0.6 };
38
+ } else {
39
+ return null;
40
+ }
41
+ if (id.includes('fast')) {
42
+ for (const key of ['inputPerM', 'cachedPerM', 'outputPerM', 'longInputPerM', 'longCachedPerM', 'longOutputPerM']) {
43
+ rates[key] *= 2;
44
+ }
45
+ }
46
+ return rates;
47
+ }
48
+
49
+ function estimateCursorCostUsd({ model, inputTokens, outputTokens, cacheReadTokens, totalTokens } = {}) {
50
+ const rates = ratesForModel(model);
51
+ if (rates) {
52
+ const input = numOrNull(inputTokens);
53
+ const output = numOrNull(outputTokens) ?? 0;
54
+ if (input == null && output == 0) return null;
55
+ const totalInput = input ?? 0;
56
+ const cached = Math.min(numOrNull(cacheReadTokens) ?? 0, totalInput);
57
+ const fresh = Math.max(0, totalInput - cached);
58
+ const long = totalInput >= rates.longAt;
59
+ const inputRate = long ? rates.longInputPerM : rates.inputPerM;
60
+ const cachedRate = long ? rates.longCachedPerM : rates.cachedPerM;
61
+ const outputRate = long ? rates.longOutputPerM : rates.outputPerM;
62
+ const usd = (fresh * inputRate + cached * cachedRate + output * outputRate) / 1e6;
63
+ return Math.round(usd * 10000) / 10000;
64
+ }
65
+ const input = numOrNull(inputTokens);
66
+ const output = numOrNull(outputTokens);
67
+ if (input != null || output != null) {
68
+ const usd = ((input ?? 0) * 3 + (output ?? 0) * 15) / 1e6;
69
+ return Math.round(usd * 10000) / 10000;
70
+ }
71
+ const total = numOrNull(totalTokens);
72
+ if (total != null) {
73
+ const usd = total * 3.5 / 1e6;
74
+ return Math.round(usd * 10000) / 10000;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ function describeCursorCostEstimate(args) {
80
+ const usd = estimateCursorCostUsd(args);
81
+ if (usd == null) return null;
82
+ return {
83
+ usd,
84
+ costSource: ratesForModel(args && args.model) != null ? 'api-estimate' : 'api-estimate-fallback',
85
+ };
86
+ }
87
+
21
88
  function resolveBaseDir(payload) {
22
89
  const cwd = process.cwd();
23
90
  if (existsSync(join(cwd, 'openspec', 'changes'))) return cwd;
@@ -28,6 +95,67 @@ function resolveBaseDir(payload) {
28
95
  return cwd;
29
96
  }
30
97
 
98
+ const CURSOR_LEFTOVER_GRACE_MS = 120000;
99
+
100
+ function cursorSpendFingerprint(row) {
101
+ if (!row || typeof row !== 'object') return null;
102
+ const input = numOrNull(row.inputTokens);
103
+ const output = numOrNull(row.outputTokens);
104
+ if (input == null && output == null) return null;
105
+ const model = String(row.model || row.modelId || '');
106
+ const cache = numOrNull(row.cacheReadTokens) ?? 0;
107
+ return `${model}|${input ?? 0}|${output ?? 0}|${cache}`;
108
+ }
109
+
110
+ function preferCursorSource(previous, next) {
111
+ if (!previous) return next;
112
+ if (!next) return previous;
113
+ if (next.event === 'stop' && previous.event !== 'stop') return next;
114
+ if (previous.event === 'stop' && next.event !== 'stop') return previous;
115
+ const prevAt = Date.parse(previous.at);
116
+ const nextAt = Date.parse(next.at);
117
+ if (Number.isFinite(nextAt) && Number.isFinite(prevAt) && nextAt !== prevAt) {
118
+ return nextAt > prevAt ? next : previous;
119
+ }
120
+ return (next.totalTokens ?? 0) >= (previous.totalTokens ?? 0) ? next : previous;
121
+ }
122
+
123
+ function stripCursorCollectMeta(record) {
124
+ if (!record || typeof record !== 'object') return record;
125
+ const { event, ...rest } = record;
126
+ return rest;
127
+ }
128
+
129
+ function dedupeCursorSources(sources) {
130
+ const best = new Map();
131
+ const rest = [];
132
+ for (const src of sources || []) {
133
+ if (!src || src.platform !== 'cursor') {
134
+ rest.push(src);
135
+ continue;
136
+ }
137
+ const fp = cursorSpendFingerprint(src);
138
+ if (!fp) {
139
+ rest.push(src);
140
+ continue;
141
+ }
142
+ best.set(fp, preferCursorSource(best.get(fp), src));
143
+ }
144
+ return [...rest, ...[...best.values()].map(stripCursorCollectMeta)];
145
+ }
146
+
147
+ function leftoverWindowEnd(metrics, last) {
148
+ if (metrics.pending && metrics.pending.startedAt) return metrics.pending.startedAt;
149
+ if (!last || !last.endedAt) return null;
150
+ const end = Date.parse(last.endedAt);
151
+ if (!Number.isFinite(end)) return null;
152
+ return new Date(end + CURSOR_LEFTOVER_GRACE_MS).toISOString();
153
+ }
154
+
155
+ function leftoverEndExclusive(metrics) {
156
+ return Boolean(metrics.pending && metrics.pending.startedAt);
157
+ }
158
+
31
159
  function existingIds(metrics) {
32
160
  const ids = new Set();
33
161
  for (const session of metrics.sessions || []) {
@@ -38,18 +166,96 @@ function existingIds(metrics) {
38
166
  return ids;
39
167
  }
40
168
 
169
+ function loadCursorUsageById(cwd) {
170
+ const filePath = join(cwd, '.agents', 'spend', 'cursor-usage.jsonl');
171
+ const bestById = new Map();
172
+ if (!existsSync(filePath)) return bestById;
173
+ let text;
174
+ try {
175
+ text = readFileSync(filePath, 'utf-8');
176
+ } catch {
177
+ return bestById;
178
+ }
179
+ for (const line of text.split('\n')) {
180
+ if (!line.trim()) continue;
181
+ let row;
182
+ try {
183
+ row = JSON.parse(line);
184
+ } catch {
185
+ continue;
186
+ }
187
+ if (!row || typeof row !== 'object') continue;
188
+ const id = row.id == null || row.id === '' ? null : String(row.id);
189
+ if (!id) continue;
190
+ const inputTokens = numOrNull(row.inputTokens);
191
+ const outputTokens = numOrNull(row.outputTokens);
192
+ if (inputTokens == null && outputTokens == null) continue;
193
+ const totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
194
+ const previous = bestById.get(id);
195
+ const previousTotal = previous
196
+ ? (numOrNull(previous.inputTokens) ?? 0) + (numOrNull(previous.outputTokens) ?? 0)
197
+ : -1;
198
+ if (!previous || totalTokens >= previousTotal) bestById.set(id, row);
199
+ }
200
+ return bestById;
201
+ }
202
+
203
+ function applyCursorEstimate(record) {
204
+ const described = describeCursorCostEstimate({
205
+ model: record.model,
206
+ inputTokens: record.inputTokens,
207
+ outputTokens: record.outputTokens,
208
+ cacheReadTokens: record.cacheReadTokens,
209
+ totalTokens: record.totalTokens,
210
+ });
211
+ if (!described) return false;
212
+ let changed = false;
213
+ if (record.costUsdEstimated !== described.usd) {
214
+ record.costUsdEstimated = described.usd;
215
+ changed = true;
216
+ }
217
+ if (record.costSource !== described.costSource) {
218
+ record.costSource = described.costSource;
219
+ changed = true;
220
+ }
221
+ return changed;
222
+ }
223
+
224
+ function attachCursorEstimates(sources, byId) {
225
+ let changed = false;
226
+ for (const src of sources || []) {
227
+ if (!src || src.platform !== 'cursor') continue;
228
+ const row = src.id ? byId.get(String(src.id)) : null;
229
+ if (row) {
230
+ const cache = numOrNull(row.cacheReadTokens);
231
+ if (src.cacheReadTokens == null && cache != null) {
232
+ src.cacheReadTokens = cache;
233
+ changed = true;
234
+ }
235
+ if (!src.model && (row.model || row.modelId)) {
236
+ src.model = row.model || row.modelId;
237
+ changed = true;
238
+ }
239
+ }
240
+ if (applyCursorEstimate(src)) changed = true;
241
+ }
242
+ return changed;
243
+ }
244
+
41
245
  function sourceTotals(sources) {
42
246
  let inputTokens = null;
43
247
  let outputTokens = null;
44
248
  let totalTokens = null;
45
249
  let costUsd = null;
250
+ let costUsdEstimated = null;
46
251
  for (const src of sources || []) {
47
252
  inputTokens = addNullable(inputTokens, numOrNull(src.inputTokens));
48
253
  outputTokens = addNullable(outputTokens, numOrNull(src.outputTokens));
49
254
  totalTokens = addNullable(totalTokens, numOrNull(src.totalTokens));
50
255
  if (src.costUsd != null) costUsd = addNullable(costUsd, numOrNull(src.costUsd));
256
+ costUsdEstimated = addNullable(costUsdEstimated, numOrNull(src.costUsdEstimated));
51
257
  }
52
- return { inputTokens, outputTokens, totalTokens, costUsd };
258
+ return { inputTokens, outputTokens, totalTokens, costUsd, costUsdEstimated };
53
259
  }
54
260
 
55
261
  function looksOverridden(session) {
@@ -70,6 +276,7 @@ function emptyPlatform(source = 'none') {
70
276
  totalTokens: null,
71
277
  costUsd: null,
72
278
  ampCredits: null,
279
+ costUsdEstimated: null,
73
280
  source,
74
281
  };
75
282
  }
@@ -77,7 +284,7 @@ function emptyPlatform(source = 'none') {
77
284
  function recompute(metrics) {
78
285
  const phases = {};
79
286
  const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
80
- const spend = { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null };
287
+ const spend = { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null, costUsdEstimated: null };
81
288
  const byPlatform = {
82
289
  cursor: emptyPlatform(),
83
290
  claude: emptyPlatform(),
@@ -102,12 +309,13 @@ function recompute(metrics) {
102
309
  outputTokens: null,
103
310
  totalTokens: null,
104
311
  costUsd: null,
312
+ costUsdEstimated: null,
105
313
  agents: [],
106
314
  models: [],
107
315
  };
108
316
  phase.sessions += 1;
109
317
  phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
110
- for (const spendKey of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd']) {
318
+ for (const spendKey of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated']) {
111
319
  const fromSession = numOrNull(session[spendKey]);
112
320
  let value = fromSession;
113
321
  if (value == null) {
@@ -135,6 +343,8 @@ function recompute(metrics) {
135
343
  bucket.outputTokens = addNullable(bucket.outputTokens, numOrNull(src.outputTokens));
136
344
  bucket.totalTokens = addNullable(bucket.totalTokens, numOrNull(src.totalTokens));
137
345
  bucket.costUsd = addNullable(bucket.costUsd, numOrNull(src.costUsd));
346
+ bucket.ampCredits = addNullable(bucket.ampCredits, numOrNull(src.ampCredits));
347
+ bucket.costUsdEstimated = addNullable(bucket.costUsdEstimated, numOrNull(src.costUsdEstimated));
138
348
  if (platform === 'claude') bucket.source = 'claude-jsonl';
139
349
  else if (platform === 'amp') bucket.source = 'amp-thread';
140
350
  else if (platform === 'cursor') bucket.source = 'cursor-hook';
@@ -149,11 +359,14 @@ function recompute(metrics) {
149
359
  totalTokens: null,
150
360
  costUsd: null,
151
361
  ampCredits: null,
362
+ costUsdEstimated: null,
152
363
  };
153
364
  row.inputTokens = addNullable(row.inputTokens, numOrNull(src.inputTokens));
154
365
  row.outputTokens = addNullable(row.outputTokens, numOrNull(src.outputTokens));
155
366
  row.totalTokens = addNullable(row.totalTokens, numOrNull(src.totalTokens));
156
367
  row.costUsd = addNullable(row.costUsd, numOrNull(src.costUsd));
368
+ row.ampCredits = addNullable(row.ampCredits, numOrNull(src.ampCredits));
369
+ row.costUsdEstimated = addNullable(row.costUsdEstimated, numOrNull(src.costUsdEstimated));
157
370
  byModel.set(modelKey, row);
158
371
  }
159
372
  }
@@ -169,49 +382,123 @@ function recompute(metrics) {
169
382
  metrics.spendByModel = [...byModel.values()];
170
383
  }
171
384
 
172
- function incomingCursorSources(cwd, existing, windowStart) {
173
- const filePath = join(cwd, '.agents', 'spend', 'cursor-usage.jsonl');
174
- if (!existsSync(filePath)) return [];
385
+ function incomingCursorSources(cwd, existing, fingerprints, windowStart, windowEnd, byId, exclusiveEnd) {
175
386
  const startMs = windowStart ? Date.parse(windowStart) : NaN;
387
+ const endMs = windowEnd ? Date.parse(windowEnd) : NaN;
176
388
  const bestById = new Map();
177
- for (const line of readFileSync(filePath, 'utf-8').split('\n')) {
178
- if (!line.trim()) continue;
179
- let row;
180
- try {
181
- row = JSON.parse(line);
182
- } catch {
183
- continue;
184
- }
185
- if (!row || typeof row !== 'object') continue;
186
- const id = row.id == null || row.id === '' ? null : String(row.id);
187
- if (!id || existing.has(id)) continue;
389
+ for (const [id, row] of byId) {
390
+ if (existing.has(id)) continue;
188
391
  const atMs = Date.parse(row.at);
189
392
  if (Number.isFinite(startMs) && Number.isFinite(atMs) && atMs < startMs) continue;
393
+ if (Number.isFinite(endMs) && Number.isFinite(atMs)) {
394
+ if (exclusiveEnd) {
395
+ if (atMs >= endMs) continue;
396
+ } else if (atMs > endMs) continue;
397
+ }
190
398
  const inputTokens = numOrNull(row.inputTokens);
191
399
  const outputTokens = numOrNull(row.outputTokens);
192
400
  if (inputTokens == null && outputTokens == null) continue;
193
- const totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
401
+ const fp = cursorSpendFingerprint(row);
402
+ if (fp && fingerprints.has(fp)) continue;
403
+ const cacheReadTokens = numOrNull(row.cacheReadTokens);
194
404
  const record = {
195
405
  id,
196
406
  platform: 'cursor',
197
407
  model: row.model || row.modelId || null,
198
408
  inputTokens,
199
409
  outputTokens,
200
- totalTokens,
410
+ totalTokens: (inputTokens ?? 0) + (outputTokens ?? 0),
201
411
  costUsd: null,
202
412
  ampCredits: null,
203
413
  at: row.at == null ? null : String(row.at),
414
+ event: row.event || null,
204
415
  };
416
+ if (cacheReadTokens != null) record.cacheReadTokens = cacheReadTokens;
417
+ applyCursorEstimate(record);
205
418
  const previous = bestById.get(id);
206
419
  if (!previous || (record.totalTokens ?? 0) >= (previous.totalTokens ?? 0)) {
207
420
  bestById.set(id, record);
208
421
  }
209
422
  }
210
- return [...bestById.values()];
423
+ const bestByFingerprint = new Map();
424
+ for (const record of bestById.values()) {
425
+ const fp = cursorSpendFingerprint(record) || record.id;
426
+ bestByFingerprint.set(fp, preferCursorSource(bestByFingerprint.get(fp), record));
427
+ }
428
+ return [...bestByFingerprint.values()].map(stripCursorCollectMeta);
211
429
  }
212
430
 
213
- function backfillChange(cwd, changeName) {
214
- const filePath = join(cwd, 'openspec', 'changes', changeName, 'metrics.json');
431
+ function existingFingerprints(metrics) {
432
+ const set = new Set();
433
+ for (const session of metrics.sessions || []) {
434
+ for (const src of session.sources || []) {
435
+ const fp = cursorSpendFingerprint(src);
436
+ if (fp) set.add(fp);
437
+ }
438
+ }
439
+ return set;
440
+ }
441
+
442
+ function sessionHasSpendNumbers(session) {
443
+ return (
444
+ numOrNull(session.inputTokens) != null
445
+ || numOrNull(session.outputTokens) != null
446
+ || numOrNull(session.totalTokens) != null
447
+ || numOrNull(session.costUsd) != null
448
+ || numOrNull(session.ampCredits) != null
449
+ );
450
+ }
451
+
452
+ function sessionSpendFrozen(session) {
453
+ if (!session) return false;
454
+ if (session.spendSource === 'flag') return true;
455
+ if (!sessionHasSpendNumbers(session)) return false;
456
+ if (!session.spendSource || session.spendSource === 'adapter' || session.spendSource === 'unreported') return false;
457
+ return true;
458
+ }
459
+
460
+ function syncAdapterSessionTotals(session) {
461
+ if (sessionSpendFrozen(session)) return false;
462
+ const totals = sourceTotals(session.sources || []);
463
+ let changed = false;
464
+ for (const key of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated']) {
465
+ if (session[key] !== totals[key]) {
466
+ session[key] = totals[key];
467
+ changed = true;
468
+ }
469
+ }
470
+ if ((session.sources || []).length > 0 && session.spendSource !== 'adapter') {
471
+ session.spendSource = 'adapter';
472
+ changed = true;
473
+ }
474
+ return changed;
475
+ }
476
+
477
+ function enrichMetrics(metrics, cwd) {
478
+ const byId = loadCursorUsageById(cwd);
479
+ let changed = false;
480
+ for (const session of metrics.sessions || []) {
481
+ const deduped = dedupeCursorSources(session.sources || []);
482
+ if (deduped.length !== (session.sources || []).length) {
483
+ session.sources = deduped;
484
+ changed = true;
485
+ } else {
486
+ session.sources = deduped;
487
+ }
488
+ if (attachCursorEstimates(session.sources || [], byId)) changed = true;
489
+ if (syncAdapterSessionTotals(session)) changed = true;
490
+ if (
491
+ session.spendSource === 'unreported'
492
+ && (session.inputTokens != null || session.totalTokens != null || (session.sources || []).length)
493
+ ) {
494
+ session.spendSource = 'adapter';
495
+ changed = true;
496
+ }
497
+ }
498
+ return changed;
499
+ }
500
+
501
+ function backfillMetricsFile(cwd, filePath) {
215
502
  if (!existsSync(filePath)) return;
216
503
  let metrics;
217
504
  try {
@@ -222,27 +509,99 @@ function backfillChange(cwd, changeName) {
222
509
  if (!metrics || typeof metrics !== 'object') return;
223
510
  const sessions = Array.isArray(metrics.sessions) ? metrics.sessions : [];
224
511
  if (!sessions.length) return;
512
+ let collapsed = false;
513
+ for (const session of sessions) {
514
+ const next = dedupeCursorSources(session.sources || []);
515
+ if (next.length !== (session.sources || []).length) collapsed = true;
516
+ session.sources = next;
517
+ }
225
518
  const last = sessions[sessions.length - 1];
226
- const incoming = incomingCursorSources(
227
- cwd,
228
- existingIds(metrics),
229
- last.startedAt || last.endedAt || metrics.createdAt,
230
- );
231
- if (!incoming.length) return;
232
- const overridden = looksOverridden(last);
233
- last.sources = [...(last.sources || []), ...incoming];
234
- if (!overridden) {
235
- const totals = sourceTotals(last.sources);
236
- last.inputTokens = totals.inputTokens;
237
- last.outputTokens = totals.outputTokens;
238
- last.totalTokens = totals.totalTokens;
239
- last.costUsd = totals.costUsd;
519
+ const byId = loadCursorUsageById(cwd);
520
+ const leftoverEnd = leftoverWindowEnd(metrics, last);
521
+ const incoming = last.endedAt && leftoverEnd
522
+ ? incomingCursorSources(
523
+ cwd,
524
+ existingIds(metrics),
525
+ existingFingerprints(metrics),
526
+ last.endedAt,
527
+ leftoverEnd,
528
+ byId,
529
+ leftoverEndExclusive(metrics),
530
+ )
531
+ : [];
532
+ if (incoming.length) {
533
+ last.sources = [...(last.sources || []), ...incoming];
534
+ if (!sessionSpendFrozen(last)) {
535
+ const totals = sourceTotals(last.sources);
536
+ last.inputTokens = totals.inputTokens;
537
+ last.outputTokens = totals.outputTokens;
538
+ last.totalTokens = totals.totalTokens;
539
+ last.costUsd = totals.costUsd;
540
+ last.costUsdEstimated = totals.costUsdEstimated;
541
+ last.spendSource = 'adapter';
542
+ }
240
543
  }
544
+ const enriched = enrichMetrics(metrics, cwd);
545
+ if (!incoming.length && !enriched && !collapsed) return;
241
546
  metrics.updatedAt = new Date().toISOString();
242
547
  recompute(metrics);
243
548
  writeFileSync(filePath, `${JSON.stringify(metrics, null, 2)}\n`);
244
549
  }
245
550
 
551
+ function backfillChange(cwd, changeName) {
552
+ backfillMetricsFile(cwd, join(cwd, 'openspec', 'changes', changeName, 'metrics.json'));
553
+ }
554
+
555
+ function archivedChangeName(dirName) {
556
+ const match = /^(\d{4}-\d{2}-\d{2})-(.+)$/.exec(dirName);
557
+ return match ? match[2] : null;
558
+ }
559
+
560
+ function metricsArchivedAtMs(metricsPath) {
561
+ try {
562
+ const metrics = JSON.parse(readFileSync(metricsPath, 'utf-8'));
563
+ if (metrics && metrics.archivedAt) {
564
+ const t = Date.parse(metrics.archivedAt);
565
+ if (Number.isFinite(t)) return t;
566
+ }
567
+ } catch {}
568
+ return null;
569
+ }
570
+
571
+ function newestArchiveDirForName(archiveDir, changeName) {
572
+ let best = null;
573
+ let bestKey = -Infinity;
574
+ let names;
575
+ try {
576
+ names = readdirSync(archiveDir);
577
+ } catch {
578
+ return null;
579
+ }
580
+ for (const entry of names) {
581
+ if (archivedChangeName(entry) !== changeName) continue;
582
+ const full = join(archiveDir, entry);
583
+ try {
584
+ if (!statSync(full).isDirectory()) continue;
585
+ } catch {
586
+ continue;
587
+ }
588
+ const fromMetrics = metricsArchivedAtMs(join(full, 'metrics.json'));
589
+ let key = fromMetrics;
590
+ if (key == null) {
591
+ try {
592
+ key = statSync(full).mtimeMs;
593
+ } catch {
594
+ key = 0;
595
+ }
596
+ }
597
+ if (key >= bestKey) {
598
+ bestKey = key;
599
+ best = full;
600
+ }
601
+ }
602
+ return best;
603
+ }
604
+
246
605
  function main(raw) {
247
606
  let payload = {};
248
607
  try {
@@ -253,6 +612,7 @@ function main(raw) {
253
612
  const cwd = resolveBaseDir(payload && typeof payload === 'object' ? payload : {});
254
613
  const changesDir = join(cwd, 'openspec', 'changes');
255
614
  if (!existsSync(changesDir)) return;
615
+ const activeNames = new Set();
256
616
  for (const name of readdirSync(changesDir)) {
257
617
  if (name === 'archive') continue;
258
618
  const full = join(changesDir, name);
@@ -261,8 +621,28 @@ function main(raw) {
261
621
  } catch {
262
622
  continue;
263
623
  }
624
+ activeNames.add(name);
264
625
  backfillChange(cwd, name);
265
626
  }
627
+ const archiveDir = join(changesDir, 'archive');
628
+ if (!existsSync(archiveDir)) return;
629
+ let archiveEntries;
630
+ try {
631
+ archiveEntries = readdirSync(archiveDir);
632
+ } catch {
633
+ return;
634
+ }
635
+ const archivedNames = new Set();
636
+ for (const entry of archiveEntries) {
637
+ const changeName = archivedChangeName(entry);
638
+ if (!changeName || activeNames.has(changeName)) continue;
639
+ archivedNames.add(changeName);
640
+ }
641
+ for (const changeName of archivedNames) {
642
+ const dir = newestArchiveDirForName(archiveDir, changeName);
643
+ if (!dir) continue;
644
+ backfillMetricsFile(cwd, join(dir, 'metrics.json'));
645
+ }
266
646
  }
267
647
 
268
648
  if (process.stdin.isTTY) {
@@ -41,7 +41,13 @@ function main(raw) {
41
41
 
42
42
  const generationId = payload.generation_id ? String(payload.generation_id) : '';
43
43
  const conversationId = payload.conversation_id ? String(payload.conversation_id) : '';
44
- const id = generationId || (conversationId ? `${conversationId}:${Date.now()}` : `cursor:${Date.now()}`);
44
+ const cacheReadTokens = numOrNull(payload.cache_read_tokens);
45
+ const id = generationId || [
46
+ conversationId || 'none',
47
+ inputTokens ?? 0,
48
+ outputTokens ?? 0,
49
+ cacheReadTokens ?? 0,
50
+ ].join(':');
45
51
 
46
52
  const record = {
47
53
  id,
@@ -51,7 +57,7 @@ function main(raw) {
51
57
  modelId: payload.model_id ? String(payload.model_id) : null,
52
58
  inputTokens,
53
59
  outputTokens,
54
- cacheReadTokens: numOrNull(payload.cache_read_tokens),
60
+ cacheReadTokens,
55
61
  cacheWriteTokens: numOrNull(payload.cache_write_tokens),
56
62
  at: new Date().toISOString(),
57
63
  };