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.
- package/CHANGELOG.md +16 -0
- package/README.md +20 -7
- package/bin/agent-orchestrator.js +248 -63
- package/bin/session-client.js +1 -1
- package/bin/spend-collect.js +197 -2
- package/package.json +1 -1
- package/templates/.agents/rules/session-handoff.mdc +2 -2
- package/templates/.agents/skills/agent-orchestration/SKILL.md +3 -3
- package/templates/.agents/subagents/session-handoff.md +2 -2
- package/templates/.agents/subagents/spec-archiver.md +1 -1
- package/templates/scripts/cursor-spend-collect.cjs +660 -64
- package/templates/scripts/cursor-spend-hook.cjs +31 -10
|
@@ -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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
95
|
-
if (
|
|
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
|
-
|
|
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
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
214
|
-
const
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
247
|
-
|
|
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
|
-
|
|
772
|
+
names = readdirSync(archiveDir);
|
|
250
773
|
} catch {
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
837
|
+
function parsePayload(raw) {
|
|
838
|
+
if (raw && typeof raw === 'object') return raw;
|
|
269
839
|
try {
|
|
270
|
-
|
|
271
|
-
} catch {
|
|
272
|
-
|
|
840
|
+
return raw ? JSON.parse(raw) : {};
|
|
841
|
+
} catch {
|
|
842
|
+
return {};
|
|
843
|
+
}
|
|
273
844
|
}
|
|
274
845
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
+
};
|