agent-orchestrator-kit 0.10.0 → 0.12.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.
@@ -5,7 +5,7 @@
5
5
  'use strict';
6
6
 
7
7
  const { existsSync, readdirSync, readFileSync, writeFileSync, statSync } = require('fs');
8
- const { join } = require('path');
8
+ const { join, resolve } = require('path');
9
9
 
10
10
  function numOrNull(value) {
11
11
  if (value == null || value === '') return null;
@@ -18,14 +18,300 @@ function addNullable(a, b) {
18
18
  return (a ?? 0) + (b ?? 0);
19
19
  }
20
20
 
21
+ function roundUsd4(x) {
22
+ if (x == null) return null;
23
+ return Math.round(Number(x) * 10000) / 10000;
24
+ }
25
+
26
+ function timestampMs(value) {
27
+ if (value == null || value === '') return NaN;
28
+ const ms = Date.parse(value);
29
+ return Number.isFinite(ms) ? ms : NaN;
30
+ }
31
+
32
+ const GROK_46 = {
33
+ inputPerM: 2,
34
+ cachedPerM: 0.5,
35
+ outputPerM: 6,
36
+ longInputPerM: 4,
37
+ longCachedPerM: 1,
38
+ longOutputPerM: 12,
39
+ longAt: 200000,
40
+ };
41
+
42
+ function ratesForModel(model) {
43
+ const id = String(model || '').toLowerCase();
44
+ if (!id) return null;
45
+ let rates = null;
46
+ if (id.includes('grok-4.6') || id.includes('grok-4-6')) rates = { ...GROK_46 };
47
+ else if (id.includes('grok-4.5') || id.includes('grok-4-5')) {
48
+ rates = { ...GROK_46, cachedPerM: 0.3, longCachedPerM: 0.6 };
49
+ } else {
50
+ return null;
51
+ }
52
+ if (id.includes('fast')) {
53
+ for (const key of ['inputPerM', 'cachedPerM', 'outputPerM', 'longInputPerM', 'longCachedPerM', 'longOutputPerM']) {
54
+ rates[key] *= 2;
55
+ }
56
+ }
57
+ return rates;
58
+ }
59
+
60
+ function estimateCursorCostUsd({ model, inputTokens, outputTokens, cacheReadTokens, totalTokens } = {}) {
61
+ const rates = ratesForModel(model);
62
+ if (rates) {
63
+ const input = numOrNull(inputTokens);
64
+ const output = numOrNull(outputTokens) ?? 0;
65
+ if (input == null && output == 0) return null;
66
+ const totalInput = input ?? 0;
67
+ const cached = Math.min(numOrNull(cacheReadTokens) ?? 0, totalInput);
68
+ const fresh = Math.max(0, totalInput - cached);
69
+ const long = totalInput >= rates.longAt;
70
+ const inputRate = long ? rates.longInputPerM : rates.inputPerM;
71
+ const cachedRate = long ? rates.longCachedPerM : rates.cachedPerM;
72
+ const outputRate = long ? rates.longOutputPerM : rates.outputPerM;
73
+ const usd = (fresh * inputRate + cached * cachedRate + output * outputRate) / 1e6;
74
+ return Math.round(usd * 10000) / 10000;
75
+ }
76
+ const input = numOrNull(inputTokens);
77
+ const output = numOrNull(outputTokens);
78
+ if (input != null || output != null) {
79
+ const usd = ((input ?? 0) * 3 + (output ?? 0) * 15) / 1e6;
80
+ return Math.round(usd * 10000) / 10000;
81
+ }
82
+ const total = numOrNull(totalTokens);
83
+ if (total != null) {
84
+ const usd = total * 3.5 / 1e6;
85
+ return Math.round(usd * 10000) / 10000;
86
+ }
87
+ return null;
88
+ }
89
+
90
+ function describeCursorCostEstimate(args) {
91
+ const usd = estimateCursorCostUsd(args);
92
+ if (usd == null) return null;
93
+ return {
94
+ usd,
95
+ costSource: ratesForModel(args && args.model) != null ? 'api-estimate' : 'api-estimate-fallback',
96
+ };
97
+ }
98
+
99
+ function uniqueExistingAbsPaths(payload) {
100
+ const seen = new Set();
101
+ const out = [];
102
+ const add = (value) => {
103
+ if (value == null || value === '') return;
104
+ let abs;
105
+ try {
106
+ abs = resolve(String(value));
107
+ } catch {
108
+ return;
109
+ }
110
+ if (seen.has(abs)) return;
111
+ try {
112
+ if (!existsSync(abs)) return;
113
+ } catch {
114
+ return;
115
+ }
116
+ seen.add(abs);
117
+ out.push(abs);
118
+ };
119
+ add(process.cwd());
120
+ const roots = payload && Array.isArray(payload.workspace_roots) ? payload.workspace_roots : [];
121
+ for (const root of roots) add(root);
122
+ return out;
123
+ }
124
+
125
+ function leftoverCandidateRoots(payload) {
126
+ return uniqueExistingAbsPaths(payload).filter((root) => existsSync(join(root, 'openspec', 'changes')));
127
+ }
128
+
129
+ function readJsonSafe(filePath) {
130
+ try {
131
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ function metricsHasConversationThread(metrics, conversationId) {
138
+ if (!metrics || typeof metrics !== 'object' || !conversationId) return false;
139
+ const pendingId = metrics.pending && metrics.pending.threadId != null && metrics.pending.threadId !== ''
140
+ ? String(metrics.pending.threadId).trim()
141
+ : '';
142
+ if (pendingId && pendingId === conversationId) return true;
143
+ const sessions = Array.isArray(metrics.sessions) ? metrics.sessions : [];
144
+ const last = sessions[sessions.length - 1];
145
+ const lastId = last && last.threadId != null && last.threadId !== ''
146
+ ? String(last.threadId).trim()
147
+ : '';
148
+ return Boolean(lastId && lastId === conversationId);
149
+ }
150
+
151
+ function activeChangeNames(changesDir) {
152
+ const names = [];
153
+ let entries;
154
+ try {
155
+ entries = readdirSync(changesDir);
156
+ } catch {
157
+ return names;
158
+ }
159
+ for (const name of entries) {
160
+ if (name === 'archive') continue;
161
+ const full = join(changesDir, name);
162
+ try {
163
+ if (statSync(full).isDirectory()) names.push(name);
164
+ } catch {
165
+ continue;
166
+ }
167
+ }
168
+ return names;
169
+ }
170
+
171
+ function rootHasMatchingThread(root, conversationId) {
172
+ const changesDir = join(root, 'openspec', 'changes');
173
+ if (!existsSync(changesDir)) return false;
174
+ for (const name of activeChangeNames(changesDir)) {
175
+ if (metricsHasConversationThread(readJsonSafe(join(changesDir, name, 'metrics.json')), conversationId)) {
176
+ return true;
177
+ }
178
+ }
179
+ const archiveDir = join(changesDir, 'archive');
180
+ if (!existsSync(archiveDir)) return false;
181
+ const seen = new Set();
182
+ let entries;
183
+ try {
184
+ entries = readdirSync(archiveDir);
185
+ } catch {
186
+ return false;
187
+ }
188
+ for (const entry of entries) {
189
+ const changeName = archivedChangeName(entry);
190
+ if (!changeName || seen.has(changeName)) continue;
191
+ seen.add(changeName);
192
+ const dir = newestArchiveDirForName(archiveDir, changeName);
193
+ if (!dir) continue;
194
+ if (metricsHasConversationThread(readJsonSafe(join(dir, 'metrics.json')), conversationId)) return true;
195
+ }
196
+ return false;
197
+ }
198
+
199
+ function rootHasActiveChange(root) {
200
+ return activeChangeNames(join(root, 'openspec', 'changes')).length > 0;
201
+ }
202
+
203
+ function jsonlHasConversationId(root, conversationId) {
204
+ if (!conversationId) return false;
205
+ const filePath = join(root, '.agents', 'spend', 'cursor-usage.jsonl');
206
+ if (!existsSync(filePath)) return false;
207
+ let text;
208
+ try {
209
+ text = readFileSync(filePath, 'utf-8');
210
+ } catch {
211
+ return false;
212
+ }
213
+ for (const line of text.split('\n')) {
214
+ if (!line.trim()) continue;
215
+ let row;
216
+ try {
217
+ row = JSON.parse(line);
218
+ } catch {
219
+ continue;
220
+ }
221
+ const id = row && row.conversationId != null && row.conversationId !== ''
222
+ ? String(row.conversationId).trim()
223
+ : '';
224
+ if (id && id === conversationId) return true;
225
+ }
226
+ return false;
227
+ }
228
+
21
229
  function resolveBaseDir(payload) {
22
- const cwd = process.cwd();
23
- if (existsSync(join(cwd, 'openspec', 'changes'))) return cwd;
24
- const roots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots : [];
25
- for (const root of roots) {
26
- if (root && existsSync(join(String(root), 'openspec', 'changes'))) return String(root);
230
+ const candidates = uniqueExistingAbsPaths(payload && typeof payload === 'object' ? payload : {});
231
+ const conversationId = String((payload && payload.conversation_id) || '').trim();
232
+ if (conversationId) {
233
+ for (const root of candidates) {
234
+ if (rootHasMatchingThread(root, conversationId)) return root;
235
+ }
236
+ }
237
+ for (const root of candidates) {
238
+ if (rootHasActiveChange(root)) return root;
239
+ }
240
+ if (conversationId) {
241
+ for (const root of candidates) {
242
+ if (jsonlHasConversationId(root, conversationId)) return root;
243
+ }
244
+ }
245
+ const cwd = resolve(process.cwd());
246
+ if (
247
+ candidates.includes(cwd)
248
+ && (existsSync(join(cwd, 'openspec', 'changes')) || existsSync(join(cwd, '.agents')))
249
+ ) {
250
+ return cwd;
251
+ }
252
+ if (!candidates.length) return cwd;
253
+ return [...candidates].sort()[0];
254
+ }
255
+
256
+ const CURSOR_LEFTOVER_GRACE_MS = 120000;
257
+
258
+ function cursorSpendFingerprint(row) {
259
+ if (!row || typeof row !== 'object') return null;
260
+ const input = numOrNull(row.inputTokens);
261
+ const output = numOrNull(row.outputTokens);
262
+ if (input == null && output == null) return null;
263
+ const model = String(row.model || row.modelId || '');
264
+ const cache = numOrNull(row.cacheReadTokens) ?? 0;
265
+ return `${model}|${input ?? 0}|${output ?? 0}|${cache}`;
266
+ }
267
+
268
+ function preferCursorSource(previous, next) {
269
+ if (!previous) return next;
270
+ if (!next) return previous;
271
+ if (next.event === 'stop' && previous.event !== 'stop') return next;
272
+ if (previous.event === 'stop' && next.event !== 'stop') return previous;
273
+ const prevAt = Date.parse(previous.at);
274
+ const nextAt = Date.parse(next.at);
275
+ if (Number.isFinite(nextAt) && Number.isFinite(prevAt) && nextAt !== prevAt) {
276
+ return nextAt > prevAt ? next : previous;
277
+ }
278
+ return (next.totalTokens ?? 0) >= (previous.totalTokens ?? 0) ? next : previous;
279
+ }
280
+
281
+ function stripCursorCollectMeta(record) {
282
+ if (!record || typeof record !== 'object') return record;
283
+ const { event, ...rest } = record;
284
+ return rest;
285
+ }
286
+
287
+ function dedupeCursorSources(sources) {
288
+ const best = new Map();
289
+ const rest = [];
290
+ for (const src of sources || []) {
291
+ if (!src || src.platform !== 'cursor') {
292
+ rest.push(src);
293
+ continue;
294
+ }
295
+ const fp = cursorSpendFingerprint(src);
296
+ if (!fp) {
297
+ rest.push(src);
298
+ continue;
299
+ }
300
+ best.set(fp, preferCursorSource(best.get(fp), src));
27
301
  }
28
- return cwd;
302
+ return [...rest, ...[...best.values()].map(stripCursorCollectMeta)];
303
+ }
304
+
305
+ function leftoverWindowEnd(metrics, last) {
306
+ if (metrics.pending && metrics.pending.startedAt) return metrics.pending.startedAt;
307
+ if (!last || !last.endedAt) return null;
308
+ const end = Date.parse(last.endedAt);
309
+ if (!Number.isFinite(end)) return null;
310
+ return new Date(end + CURSOR_LEFTOVER_GRACE_MS).toISOString();
311
+ }
312
+
313
+ function leftoverEndExclusive(metrics) {
314
+ return Boolean(metrics.pending && metrics.pending.startedAt);
29
315
  }
30
316
 
31
317
  function existingIds(metrics) {
@@ -38,18 +324,96 @@ function existingIds(metrics) {
38
324
  return ids;
39
325
  }
40
326
 
327
+ function loadCursorUsageById(cwd) {
328
+ const filePath = join(cwd, '.agents', 'spend', 'cursor-usage.jsonl');
329
+ const bestById = new Map();
330
+ if (!existsSync(filePath)) return bestById;
331
+ let text;
332
+ try {
333
+ text = readFileSync(filePath, 'utf-8');
334
+ } catch {
335
+ return bestById;
336
+ }
337
+ for (const line of text.split('\n')) {
338
+ if (!line.trim()) continue;
339
+ let row;
340
+ try {
341
+ row = JSON.parse(line);
342
+ } catch {
343
+ continue;
344
+ }
345
+ if (!row || typeof row !== 'object') continue;
346
+ const id = row.id == null || row.id === '' ? null : String(row.id);
347
+ if (!id) continue;
348
+ const inputTokens = numOrNull(row.inputTokens);
349
+ const outputTokens = numOrNull(row.outputTokens);
350
+ if (inputTokens == null && outputTokens == null) continue;
351
+ const totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
352
+ const previous = bestById.get(id);
353
+ const previousTotal = previous
354
+ ? (numOrNull(previous.inputTokens) ?? 0) + (numOrNull(previous.outputTokens) ?? 0)
355
+ : -1;
356
+ if (!previous || totalTokens >= previousTotal) bestById.set(id, row);
357
+ }
358
+ return bestById;
359
+ }
360
+
361
+ function applyCursorEstimate(record) {
362
+ const described = describeCursorCostEstimate({
363
+ model: record.model,
364
+ inputTokens: record.inputTokens,
365
+ outputTokens: record.outputTokens,
366
+ cacheReadTokens: record.cacheReadTokens,
367
+ totalTokens: record.totalTokens,
368
+ });
369
+ if (!described) return false;
370
+ let changed = false;
371
+ if (record.costUsdEstimated !== described.usd) {
372
+ record.costUsdEstimated = described.usd;
373
+ changed = true;
374
+ }
375
+ if (record.costSource !== described.costSource) {
376
+ record.costSource = described.costSource;
377
+ changed = true;
378
+ }
379
+ return changed;
380
+ }
381
+
382
+ function attachCursorEstimates(sources, byId) {
383
+ let changed = false;
384
+ for (const src of sources || []) {
385
+ if (!src || src.platform !== 'cursor') continue;
386
+ const row = src.id ? byId.get(String(src.id)) : null;
387
+ if (row) {
388
+ const cache = numOrNull(row.cacheReadTokens);
389
+ if (src.cacheReadTokens == null && cache != null) {
390
+ src.cacheReadTokens = cache;
391
+ changed = true;
392
+ }
393
+ if (!src.model && (row.model || row.modelId)) {
394
+ src.model = row.model || row.modelId;
395
+ changed = true;
396
+ }
397
+ }
398
+ if (applyCursorEstimate(src)) changed = true;
399
+ }
400
+ return changed;
401
+ }
402
+
41
403
  function sourceTotals(sources) {
42
404
  let inputTokens = null;
43
405
  let outputTokens = null;
44
406
  let totalTokens = null;
45
407
  let costUsd = null;
408
+ let costUsdEstimated = null;
46
409
  for (const src of sources || []) {
47
410
  inputTokens = addNullable(inputTokens, numOrNull(src.inputTokens));
48
411
  outputTokens = addNullable(outputTokens, numOrNull(src.outputTokens));
49
412
  totalTokens = addNullable(totalTokens, numOrNull(src.totalTokens));
50
413
  if (src.costUsd != null) costUsd = addNullable(costUsd, numOrNull(src.costUsd));
414
+ costUsdEstimated = addNullable(costUsdEstimated, numOrNull(src.costUsdEstimated));
51
415
  }
52
- return { inputTokens, outputTokens, totalTokens, costUsd };
416
+ return { inputTokens, outputTokens, totalTokens, costUsd, costUsdEstimated: roundUsd4(costUsdEstimated) };
53
417
  }
54
418
 
55
419
  function looksOverridden(session) {
@@ -70,6 +434,7 @@ function emptyPlatform(source = 'none') {
70
434
  totalTokens: null,
71
435
  costUsd: null,
72
436
  ampCredits: null,
437
+ costUsdEstimated: null,
73
438
  source,
74
439
  };
75
440
  }
@@ -77,7 +442,7 @@ function emptyPlatform(source = 'none') {
77
442
  function recompute(metrics) {
78
443
  const phases = {};
79
444
  const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
80
- const spend = { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null };
445
+ const spend = { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null, costUsdEstimated: null };
81
446
  const byPlatform = {
82
447
  cursor: emptyPlatform(),
83
448
  claude: emptyPlatform(),
@@ -91,23 +456,39 @@ function recompute(metrics) {
91
456
  totals.sessions += 1;
92
457
  if (session.runtime === 'cloud') totals.cloudSessions += 1;
93
458
  totals.durationMs = addNullable(totals.durationMs, numOrNull(session.durationMs));
94
- if (session.startedAt && (firstStart == null || session.startedAt < firstStart)) firstStart = session.startedAt;
95
- if (session.endedAt && (lastEnd == null || session.endedAt > lastEnd)) lastEnd = session.endedAt;
459
+ const sessionStartMs = timestampMs(session.startedAt);
460
+ if (Number.isFinite(sessionStartMs) && (firstStart == null || sessionStartMs < timestampMs(firstStart))) {
461
+ firstStart = session.startedAt;
462
+ }
463
+ const sessionEndMs = timestampMs(session.endedAt);
464
+ if (Number.isFinite(sessionEndMs) && (lastEnd == null || sessionEndMs > timestampMs(lastEnd))) {
465
+ lastEnd = session.endedAt;
466
+ }
96
467
 
97
468
  const key = session.phase || 'other';
98
469
  const phase = phases[key] || {
99
470
  sessions: 0,
100
471
  durationMs: null,
472
+ startedAt: null,
473
+ endedAt: null,
474
+ leadTimeMs: null,
101
475
  inputTokens: null,
102
476
  outputTokens: null,
103
477
  totalTokens: null,
104
478
  costUsd: null,
479
+ costUsdEstimated: null,
105
480
  agents: [],
106
481
  models: [],
107
482
  };
108
483
  phase.sessions += 1;
109
484
  phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
110
- for (const spendKey of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd']) {
485
+ if (Number.isFinite(sessionStartMs) && (phase.startedAt == null || sessionStartMs < timestampMs(phase.startedAt))) {
486
+ phase.startedAt = session.startedAt;
487
+ }
488
+ if (Number.isFinite(sessionEndMs) && (phase.endedAt == null || sessionEndMs > timestampMs(phase.endedAt))) {
489
+ phase.endedAt = session.endedAt;
490
+ }
491
+ for (const spendKey of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated']) {
111
492
  const fromSession = numOrNull(session[spendKey]);
112
493
  let value = fromSession;
113
494
  if (value == null) {
@@ -135,6 +516,8 @@ function recompute(metrics) {
135
516
  bucket.outputTokens = addNullable(bucket.outputTokens, numOrNull(src.outputTokens));
136
517
  bucket.totalTokens = addNullable(bucket.totalTokens, numOrNull(src.totalTokens));
137
518
  bucket.costUsd = addNullable(bucket.costUsd, numOrNull(src.costUsd));
519
+ bucket.ampCredits = addNullable(bucket.ampCredits, numOrNull(src.ampCredits));
520
+ bucket.costUsdEstimated = addNullable(bucket.costUsdEstimated, numOrNull(src.costUsdEstimated));
138
521
  if (platform === 'claude') bucket.source = 'claude-jsonl';
139
522
  else if (platform === 'amp') bucket.source = 'amp-thread';
140
523
  else if (platform === 'cursor') bucket.source = 'cursor-hook';
@@ -149,11 +532,14 @@ function recompute(metrics) {
149
532
  totalTokens: null,
150
533
  costUsd: null,
151
534
  ampCredits: null,
535
+ costUsdEstimated: null,
152
536
  };
153
537
  row.inputTokens = addNullable(row.inputTokens, numOrNull(src.inputTokens));
154
538
  row.outputTokens = addNullable(row.outputTokens, numOrNull(src.outputTokens));
155
539
  row.totalTokens = addNullable(row.totalTokens, numOrNull(src.totalTokens));
156
540
  row.costUsd = addNullable(row.costUsd, numOrNull(src.costUsd));
541
+ row.ampCredits = addNullable(row.ampCredits, numOrNull(src.ampCredits));
542
+ row.costUsdEstimated = addNullable(row.costUsdEstimated, numOrNull(src.costUsdEstimated));
157
543
  byModel.set(modelKey, row);
158
544
  }
159
545
  }
@@ -162,6 +548,21 @@ function recompute(metrics) {
162
548
  if (firstStart && lastEnd) {
163
549
  totals.leadTimeMs = Math.max(0, Date.parse(lastEnd) - Date.parse(firstStart));
164
550
  }
551
+ for (const phase of Object.values(phases)) {
552
+ const startMs = timestampMs(phase.startedAt);
553
+ const endMs = timestampMs(phase.endedAt);
554
+ phase.leadTimeMs = Number.isFinite(startMs) && Number.isFinite(endMs)
555
+ ? Math.max(0, endMs - startMs)
556
+ : null;
557
+ phase.costUsdEstimated = roundUsd4(phase.costUsdEstimated);
558
+ }
559
+ spend.costUsdEstimated = roundUsd4(spend.costUsdEstimated);
560
+ for (const bucket of Object.values(byPlatform)) {
561
+ bucket.costUsdEstimated = roundUsd4(bucket.costUsdEstimated);
562
+ }
563
+ for (const row of byModel.values()) {
564
+ row.costUsdEstimated = roundUsd4(row.costUsdEstimated);
565
+ }
165
566
  metrics.phases = phases;
166
567
  metrics.totals = totals;
167
568
  metrics.spend = spend;
@@ -169,49 +570,130 @@ function recompute(metrics) {
169
570
  metrics.spendByModel = [...byModel.values()];
170
571
  }
171
572
 
172
- function incomingCursorSources(cwd, existing, windowStart) {
173
- const filePath = join(cwd, '.agents', 'spend', 'cursor-usage.jsonl');
174
- if (!existsSync(filePath)) return [];
573
+ function incomingCursorSources(cwd, existing, fingerprints, windowStart, windowEnd, byId, exclusiveEnd, filterConversationId) {
175
574
  const startMs = windowStart ? Date.parse(windowStart) : NaN;
575
+ const endMs = windowEnd ? Date.parse(windowEnd) : NaN;
576
+ const filterId = String(filterConversationId || '').trim();
176
577
  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;
578
+ for (const [id, row] of byId) {
579
+ if (existing.has(id)) continue;
580
+ if (filterId) {
581
+ const rowConversationId = row.conversationId == null || row.conversationId === ''
582
+ ? ''
583
+ : String(row.conversationId).trim();
584
+ if (rowConversationId !== filterId) continue;
184
585
  }
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;
188
586
  const atMs = Date.parse(row.at);
189
587
  if (Number.isFinite(startMs) && Number.isFinite(atMs) && atMs < startMs) continue;
588
+ if (Number.isFinite(endMs) && Number.isFinite(atMs)) {
589
+ if (exclusiveEnd) {
590
+ if (atMs >= endMs) continue;
591
+ } else if (atMs > endMs) continue;
592
+ }
190
593
  const inputTokens = numOrNull(row.inputTokens);
191
594
  const outputTokens = numOrNull(row.outputTokens);
192
595
  if (inputTokens == null && outputTokens == null) continue;
193
- const totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
596
+ const fp = cursorSpendFingerprint(row);
597
+ if (fp && fingerprints.has(fp)) continue;
598
+ const cacheReadTokens = numOrNull(row.cacheReadTokens);
194
599
  const record = {
195
600
  id,
196
601
  platform: 'cursor',
197
602
  model: row.model || row.modelId || null,
198
603
  inputTokens,
199
604
  outputTokens,
200
- totalTokens,
605
+ totalTokens: (inputTokens ?? 0) + (outputTokens ?? 0),
201
606
  costUsd: null,
202
607
  ampCredits: null,
203
608
  at: row.at == null ? null : String(row.at),
609
+ event: row.event || null,
204
610
  };
611
+ if (cacheReadTokens != null) record.cacheReadTokens = cacheReadTokens;
612
+ applyCursorEstimate(record);
205
613
  const previous = bestById.get(id);
206
614
  if (!previous || (record.totalTokens ?? 0) >= (previous.totalTokens ?? 0)) {
207
615
  bestById.set(id, record);
208
616
  }
209
617
  }
210
- return [...bestById.values()];
618
+ const bestByFingerprint = new Map();
619
+ for (const record of bestById.values()) {
620
+ const fp = cursorSpendFingerprint(record) || record.id;
621
+ bestByFingerprint.set(fp, preferCursorSource(bestByFingerprint.get(fp), record));
622
+ }
623
+ return [...bestByFingerprint.values()].map(stripCursorCollectMeta);
211
624
  }
212
625
 
213
- function backfillChange(cwd, changeName) {
214
- const filePath = join(cwd, 'openspec', 'changes', changeName, 'metrics.json');
626
+ function existingFingerprints(metrics) {
627
+ const set = new Set();
628
+ for (const session of metrics.sessions || []) {
629
+ for (const src of session.sources || []) {
630
+ const fp = cursorSpendFingerprint(src);
631
+ if (fp) set.add(fp);
632
+ }
633
+ }
634
+ return set;
635
+ }
636
+
637
+ function sessionHasSpendNumbers(session) {
638
+ return (
639
+ numOrNull(session.inputTokens) != null
640
+ || numOrNull(session.outputTokens) != null
641
+ || numOrNull(session.totalTokens) != null
642
+ || numOrNull(session.costUsd) != null
643
+ || numOrNull(session.ampCredits) != null
644
+ );
645
+ }
646
+
647
+ function sessionSpendFrozen(session) {
648
+ if (!session) return false;
649
+ if (session.spendSource === 'flag') return true;
650
+ if (!sessionHasSpendNumbers(session)) return false;
651
+ if (!session.spendSource || session.spendSource === 'adapter' || session.spendSource === 'unreported') return false;
652
+ return true;
653
+ }
654
+
655
+ function syncAdapterSessionTotals(session) {
656
+ if (sessionSpendFrozen(session)) return false;
657
+ const totals = sourceTotals(session.sources || []);
658
+ let changed = false;
659
+ for (const key of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated']) {
660
+ if (session[key] !== totals[key]) {
661
+ session[key] = totals[key];
662
+ changed = true;
663
+ }
664
+ }
665
+ if ((session.sources || []).length > 0 && session.spendSource !== 'adapter') {
666
+ session.spendSource = 'adapter';
667
+ changed = true;
668
+ }
669
+ return changed;
670
+ }
671
+
672
+ function enrichMetrics(metrics, cwd) {
673
+ const byId = loadCursorUsageById(cwd);
674
+ let changed = false;
675
+ for (const session of metrics.sessions || []) {
676
+ const deduped = dedupeCursorSources(session.sources || []);
677
+ if (deduped.length !== (session.sources || []).length) {
678
+ session.sources = deduped;
679
+ changed = true;
680
+ } else {
681
+ session.sources = deduped;
682
+ }
683
+ if (attachCursorEstimates(session.sources || [], byId)) changed = true;
684
+ if (syncAdapterSessionTotals(session)) changed = true;
685
+ if (
686
+ session.spendSource === 'unreported'
687
+ && (session.inputTokens != null || session.totalTokens != null || (session.sources || []).length)
688
+ ) {
689
+ session.spendSource = 'adapter';
690
+ changed = true;
691
+ }
692
+ }
693
+ return changed;
694
+ }
695
+
696
+ function backfillMetricsFile(cwd, filePath) {
215
697
  if (!existsSync(filePath)) return;
216
698
  let metrics;
217
699
  try {
@@ -222,37 +704,104 @@ function backfillChange(cwd, changeName) {
222
704
  if (!metrics || typeof metrics !== 'object') return;
223
705
  const sessions = Array.isArray(metrics.sessions) ? metrics.sessions : [];
224
706
  if (!sessions.length) return;
707
+ let collapsed = false;
708
+ for (const session of sessions) {
709
+ const next = dedupeCursorSources(session.sources || []);
710
+ if (next.length !== (session.sources || []).length) collapsed = true;
711
+ session.sources = next;
712
+ }
225
713
  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;
714
+ const byId = loadCursorUsageById(cwd);
715
+ const leftoverEnd = leftoverWindowEnd(metrics, last);
716
+ const incoming = last.endedAt && leftoverEnd
717
+ ? incomingCursorSources(
718
+ cwd,
719
+ existingIds(metrics),
720
+ existingFingerprints(metrics),
721
+ last.endedAt,
722
+ leftoverEnd,
723
+ byId,
724
+ leftoverEndExclusive(metrics),
725
+ last.threadId,
726
+ )
727
+ : [];
728
+ if (incoming.length) {
729
+ last.sources = [...(last.sources || []), ...incoming];
730
+ if (!sessionSpendFrozen(last)) {
731
+ const totals = sourceTotals(last.sources);
732
+ last.inputTokens = totals.inputTokens;
733
+ last.outputTokens = totals.outputTokens;
734
+ last.totalTokens = totals.totalTokens;
735
+ last.costUsd = totals.costUsd;
736
+ last.costUsdEstimated = totals.costUsdEstimated;
737
+ last.spendSource = 'adapter';
738
+ }
240
739
  }
740
+ const enriched = enrichMetrics(metrics, cwd);
741
+ if (!incoming.length && !enriched && !collapsed) return;
241
742
  metrics.updatedAt = new Date().toISOString();
242
743
  recompute(metrics);
243
744
  writeFileSync(filePath, `${JSON.stringify(metrics, null, 2)}\n`);
244
745
  }
245
746
 
246
- function main(raw) {
247
- let payload = {};
747
+ function backfillChange(cwd, changeName) {
748
+ backfillMetricsFile(cwd, join(cwd, 'openspec', 'changes', changeName, 'metrics.json'));
749
+ }
750
+
751
+ function archivedChangeName(dirName) {
752
+ const match = /^(\d{4}-\d{2}-\d{2})-(.+)$/.exec(dirName);
753
+ return match ? match[2] : null;
754
+ }
755
+
756
+ function metricsArchivedAtMs(metricsPath) {
757
+ try {
758
+ const metrics = JSON.parse(readFileSync(metricsPath, 'utf-8'));
759
+ if (metrics && metrics.archivedAt) {
760
+ const t = Date.parse(metrics.archivedAt);
761
+ if (Number.isFinite(t)) return t;
762
+ }
763
+ } catch {}
764
+ return null;
765
+ }
766
+
767
+ function newestArchiveDirForName(archiveDir, changeName) {
768
+ let best = null;
769
+ let bestKey = -Infinity;
770
+ let names;
248
771
  try {
249
- payload = raw ? JSON.parse(raw) : {};
772
+ names = readdirSync(archiveDir);
250
773
  } catch {
251
- payload = {};
774
+ return null;
775
+ }
776
+ for (const entry of names) {
777
+ if (archivedChangeName(entry) !== changeName) continue;
778
+ const full = join(archiveDir, entry);
779
+ try {
780
+ if (!statSync(full).isDirectory()) continue;
781
+ } catch {
782
+ continue;
783
+ }
784
+ const fromMetrics = metricsArchivedAtMs(join(full, 'metrics.json'));
785
+ let key = fromMetrics;
786
+ if (key == null) {
787
+ try {
788
+ key = statSync(full).mtimeMs;
789
+ } catch {
790
+ key = 0;
791
+ }
792
+ }
793
+ if (key >= bestKey) {
794
+ bestKey = key;
795
+ best = full;
796
+ }
252
797
  }
253
- const cwd = resolveBaseDir(payload && typeof payload === 'object' ? payload : {});
798
+ return best;
799
+ }
800
+
801
+ function backfillRoot(cwd) {
254
802
  const changesDir = join(cwd, 'openspec', 'changes');
255
803
  if (!existsSync(changesDir)) return;
804
+ const activeNames = new Set();
256
805
  for (const name of readdirSync(changesDir)) {
257
806
  if (name === 'archive') continue;
258
807
  const full = join(changesDir, name);
@@ -261,25 +810,72 @@ function main(raw) {
261
810
  } catch {
262
811
  continue;
263
812
  }
813
+ activeNames.add(name);
264
814
  backfillChange(cwd, name);
265
815
  }
816
+ const archiveDir = join(changesDir, 'archive');
817
+ if (!existsSync(archiveDir)) return;
818
+ let archiveEntries;
819
+ try {
820
+ archiveEntries = readdirSync(archiveDir);
821
+ } catch {
822
+ return;
823
+ }
824
+ const archivedNames = new Set();
825
+ for (const entry of archiveEntries) {
826
+ const changeName = archivedChangeName(entry);
827
+ if (!changeName || activeNames.has(changeName)) continue;
828
+ archivedNames.add(changeName);
829
+ }
830
+ for (const changeName of archivedNames) {
831
+ const dir = newestArchiveDirForName(archiveDir, changeName);
832
+ if (!dir) continue;
833
+ backfillMetricsFile(cwd, join(dir, 'metrics.json'));
834
+ }
266
835
  }
267
836
 
268
- if (process.stdin.isTTY) {
837
+ function parsePayload(raw) {
838
+ if (raw && typeof raw === 'object') return raw;
269
839
  try {
270
- main('');
271
- } catch {}
272
- process.exit(0);
840
+ return raw ? JSON.parse(raw) : {};
841
+ } catch {
842
+ return {};
843
+ }
273
844
  }
274
845
 
275
- let input = '';
276
- process.stdin.on('data', (chunk) => {
277
- input += chunk;
278
- });
279
- process.stdin.on('end', () => {
280
- try {
281
- main(input);
282
- } catch {}
283
- process.exit(0);
284
- });
285
- process.stdin.on('error', () => process.exit(0));
846
+ function main(raw) {
847
+ const payload = parsePayload(raw);
848
+ const candidates = leftoverCandidateRoots(payload && typeof payload === 'object' ? payload : {});
849
+ for (const cwd of candidates) {
850
+ try {
851
+ backfillRoot(cwd);
852
+ } catch {}
853
+ }
854
+ }
855
+
856
+ if (require.main === module) {
857
+ if (process.stdin.isTTY) {
858
+ try {
859
+ main('');
860
+ } catch {}
861
+ process.exit(0);
862
+ }
863
+
864
+ let input = '';
865
+ process.stdin.on('data', (chunk) => {
866
+ input += chunk;
867
+ });
868
+ process.stdin.on('end', () => {
869
+ try {
870
+ main(input);
871
+ } catch {}
872
+ process.exit(0);
873
+ });
874
+ process.stdin.on('error', () => process.exit(0));
875
+ }
876
+
877
+ module.exports = {
878
+ main,
879
+ backfillLeftover: main,
880
+ resolveBaseDir,
881
+ };