@krtclcdy/tersio 2.25.3 → 2.25.5
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/README.md +14 -31
- package/dashboard/dist/index.html +18 -16
- package/dist/cli/backups.js +194 -0
- package/dist/cli/common.js +2 -2
- package/dist/cli/dashboard.js +125 -98
- package/dist/cli/doctor-fix.js +42 -45
- package/dist/cli/doctor.js +70 -8
- package/dist/cli/hosts.js +4 -4
- package/dist/cli/install.js +106 -38
- package/dist/cli/manifest.js +45 -16
- package/dist/cli/profile.js +1 -1
- package/dist/cli/uninstall.js +21 -5
- package/dist/cli/update.js +3 -3
- package/dist/cli/usage.js +75 -4
- package/dist/extensions/{caveman-session → caveman}/index.js +10 -3
- package/dist/extensions/{combo-toggle → combo}/index.js +3 -3
- package/dist/extensions/{tersio-commands → commands}/index.js +1 -1
- package/dist/extensions/rtk/filter.js +87 -0
- package/dist/extensions/{rtk-session/index.js → rtk/session.js} +4 -3
- package/dist/extensions/{opencode/server.js → server.js} +52 -8
- package/dist/extensions/shared/agent-stats.js +1262 -0
- package/dist/extensions/shared/brand-marks.js +154 -0
- package/dist/extensions/shared/host.js +18 -12
- package/dist/extensions/shared/plugin-settings.js +1 -1
- package/dist/extensions/shared/pricing.js +91 -27
- package/dist/extensions/shared/session-state.js +3 -1
- package/dist/extensions/shared/usage-ledger.js +79 -17
- package/dist/extensions/shared/usage-store.js +106 -22
- package/dist/extensions/{lib → shared}/utils.js +1 -1
- package/dist/extensions/{ai-addons-updater → updater}/index.js +15 -5
- package/dist/probe-catalog.js +48 -0
- package/dist/probe-catalog188.js +44 -0
- package/dist/tersio.js +2 -2
- package/extensions/{caveman-session → caveman}/index.ts +10 -3
- package/extensions/{combo-toggle → combo}/index.ts +3 -3
- package/extensions/{tersio-commands → commands}/index.ts +1 -1
- package/extensions/rtk/filter.ts +90 -0
- package/extensions/{rtk-session/index.ts → rtk/session.ts} +4 -3
- package/extensions/{opencode/server.ts → server.ts} +46 -9
- package/extensions/shared/agent-stats.ts +1657 -0
- package/extensions/shared/brand-marks.ts +163 -0
- package/extensions/shared/host.ts +26 -13
- package/extensions/shared/plugin-settings.ts +1 -1
- package/extensions/shared/pricing.ts +107 -28
- package/extensions/shared/session-state.ts +3 -1
- package/extensions/shared/types.ts +10 -2
- package/extensions/shared/usage-ledger.ts +94 -19
- package/extensions/shared/usage-store.ts +106 -22
- package/extensions/{lib → shared}/utils.ts +1 -1
- package/extensions/{ai-addons-updater → updater}/index.ts +17 -7
- package/package.json +13 -12
- package/dist/cli/headroom-install.js +0 -266
- package/dist/cli/headroom.js +0 -492
- package/dist/extensions/headroom-session/index.js +0 -93
- package/dist/extensions/shared/headroom.js +0 -131
- /package/dist/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
- /package/dist/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
- /package/dist/extensions/{caveman-session → caveman}/rule.md +0 -0
- /package/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
- /package/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
- /package/extensions/{caveman-session → caveman}/rule.md +0 -0
|
@@ -0,0 +1,1262 @@
|
|
|
1
|
+
// extensions/shared/agent-stats.ts: read-only aggregate over the agent statistics databases.
|
|
2
|
+
// Today that is omp's stats.db and agent.db, so the naming stays host-neutral. Every metric
|
|
3
|
+
// matches @oh-my-pi/omp-stats: same SQL facts, same rules. Missing sqlite3 or a
|
|
4
|
+
// missing table degrades to zeros, never to a thrown error.
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { SQLITE_READ_BUFFER } from './usage-ledger.js';
|
|
10
|
+
export const RANGES = ['1h', '24h', '7d', '30d', '90d', 'all'];
|
|
11
|
+
export function isRangeKey(v) {
|
|
12
|
+
return typeof v === 'string' && RANGES.includes(v);
|
|
13
|
+
}
|
|
14
|
+
export const AGENT_VIEWS = [
|
|
15
|
+
'all', 'overview', 'models', 'providers', 'costs', 'carbon', 'requests',
|
|
16
|
+
'errors', 'traces', 'tools', 'projects',
|
|
17
|
+
];
|
|
18
|
+
export function isAgentView(v) {
|
|
19
|
+
return typeof v === 'string' && AGENT_VIEWS.includes(v);
|
|
20
|
+
}
|
|
21
|
+
/** Query group names, matching the blocks readStatsDb issues. */
|
|
22
|
+
const VIEW_PARTS = {
|
|
23
|
+
all: ['overall', 'byModel', 'byProvider', 'byAgentType', 'byProject', 'series', 'hourOfDay', 'recent', 'errors', 'traces',
|
|
24
|
+
'seriesByProvider', 'modelSeries', 'modelPerformance', 'tools', 'windows', 'providerHourly'],
|
|
25
|
+
overview: ['overall', 'byModel', 'byProvider', 'byAgentType', 'byProject', 'series', 'hourOfDay', 'recent',
|
|
26
|
+
'seriesByProvider', 'modelSeries'],
|
|
27
|
+
models: ['overall', 'byModel', 'modelSeries', 'modelPerformance', 'series'],
|
|
28
|
+
providers: ['overall', 'byProvider', 'series', 'seriesByProvider', 'providerHourly', 'windows'],
|
|
29
|
+
// `modelSeries` feeds the per-model stack, so the page's first paint is not an empty chart.
|
|
30
|
+
costs: ['overall', 'byModel', 'series', 'modelSeries'],
|
|
31
|
+
// The Carbon page needs the same two facts as Costs: per-model output tokens and the mix.
|
|
32
|
+
carbon: ['overall', 'byModel'],
|
|
33
|
+
requests: ['recent'],
|
|
34
|
+
errors: ['errors', 'overall', 'byModel', 'recent'],
|
|
35
|
+
traces: ['traces'],
|
|
36
|
+
tools: ['overall', 'tools'],
|
|
37
|
+
projects: ['overall', 'byProject'],
|
|
38
|
+
};
|
|
39
|
+
/** Window length in ms. `all` has no cutoff, so it reads as 0. */
|
|
40
|
+
const RANGE_MS = {
|
|
41
|
+
'1h': 3_600_000,
|
|
42
|
+
'24h': 86_400_000,
|
|
43
|
+
'7d': 604_800_000,
|
|
44
|
+
'30d': 2_592_000_000,
|
|
45
|
+
'90d': 7_776_000_000,
|
|
46
|
+
all: 0,
|
|
47
|
+
};
|
|
48
|
+
/** Bucket width per range, measured against the reference: 5 minutes, then 1 hour, then 1 day. */
|
|
49
|
+
const BUCKET_MS = {
|
|
50
|
+
'1h': 300_000,
|
|
51
|
+
'24h': 3_600_000,
|
|
52
|
+
'7d': 86_400_000,
|
|
53
|
+
'30d': 86_400_000,
|
|
54
|
+
'90d': 86_400_000,
|
|
55
|
+
all: 86_400_000,
|
|
56
|
+
};
|
|
57
|
+
/** A series never renders more than this many buckets; a wider span coarsens the bucket. */
|
|
58
|
+
const MAX_BUCKETS = 1500;
|
|
59
|
+
const RECENT_LIMIT = 200;
|
|
60
|
+
const ERROR_LIMIT = 300;
|
|
61
|
+
const TRACE_LIMIT = 300;
|
|
62
|
+
const TOOL_MODEL_LIMIT = 300;
|
|
63
|
+
const SERIES_PROVIDERS = 8;
|
|
64
|
+
const SERIES_MODELS = 12;
|
|
65
|
+
export function statsDbPath() {
|
|
66
|
+
return process.env.TERSIO_AGENT_STATS_DB ?? path.join(os.homedir(), '.omp', 'stats.db');
|
|
67
|
+
}
|
|
68
|
+
export function agentDbPath() {
|
|
69
|
+
return process.env.TERSIO_AGENT_SESSIONS_DB ?? path.join(os.homedir(), '.omp', 'agent', 'agent.db');
|
|
70
|
+
}
|
|
71
|
+
/** Output cost is the remainder: omp prices the whole request and the three cache-side parts. */
|
|
72
|
+
function costMixOf(f) {
|
|
73
|
+
const input = f.costInput;
|
|
74
|
+
const cacheRead = f.costCacheRead;
|
|
75
|
+
const cacheWrite = f.costCacheWrite;
|
|
76
|
+
return {
|
|
77
|
+
input,
|
|
78
|
+
cacheRead,
|
|
79
|
+
cacheWrite,
|
|
80
|
+
output: Math.max(0, f.costUsd - input - cacheRead - cacheWrite),
|
|
81
|
+
total: f.costUsd,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function emptyAgentStats(range = '24h') {
|
|
85
|
+
return {
|
|
86
|
+
available: false,
|
|
87
|
+
range,
|
|
88
|
+
bucketMs: BUCKET_MS[range],
|
|
89
|
+
cutoff: 0,
|
|
90
|
+
generatedAt: Date.now(),
|
|
91
|
+
overall: {
|
|
92
|
+
requests: 0, failed: 0, successful: 0, errorRate: 0, cacheRate: 0,
|
|
93
|
+
costUsd: 0, unpricedRequests: 0,
|
|
94
|
+
avgDurationMs: 0, avgTtftMs: 0, avgTokensPerSecond: 0, maxDurationMs: null,
|
|
95
|
+
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0,
|
|
96
|
+
costMix: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
97
|
+
firstTs: null, lastTs: null,
|
|
98
|
+
},
|
|
99
|
+
byModel: [], byProvider: [], byProject: [], byAgentType: [],
|
|
100
|
+
series: [], seriesByProvider: [], modelSeries: [], modelPerformance: [], hourOfDay: [], topModels: [],
|
|
101
|
+
recent: [], errorGroups: [], errorModels: [], traces: [],
|
|
102
|
+
tools: [], toolsByModel: [], toolSeries: [],
|
|
103
|
+
usageSeries: [], windowInsights: [], providerHourly: [],
|
|
104
|
+
requestStats: {
|
|
105
|
+
requests: 0, failed: 0, aborted: 0, tokens: 0, costUsd: 0, unpriced: 0,
|
|
106
|
+
medianDurationMs: 0, p95DurationMs: 0, medianTtftMs: 0, oldest: null, newest: null,
|
|
107
|
+
},
|
|
108
|
+
errors: [],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// ---------------------------------------------------------------- sqlite access
|
|
112
|
+
/**
|
|
113
|
+
* The omp databases run in WAL mode. Ordered fallbacks, each verified against the live pair:
|
|
114
|
+
* `-readonly` needs the `-shm` side file, `immutable=1` silently ignores a WAL, and a copy of
|
|
115
|
+
* db + wal + shm replays the log to the same row count the running server reports.
|
|
116
|
+
*/
|
|
117
|
+
export function sqliteScript(db, script) {
|
|
118
|
+
if (!fs.existsSync(db))
|
|
119
|
+
throw new Error(`missing database: ${db}`);
|
|
120
|
+
const opts = {
|
|
121
|
+
encoding: 'utf8',
|
|
122
|
+
timeout: 30_000,
|
|
123
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
124
|
+
maxBuffer: SQLITE_READ_BUFFER,
|
|
125
|
+
input: script,
|
|
126
|
+
};
|
|
127
|
+
try {
|
|
128
|
+
return execFileSync('sqlite3', ['-separator', '\t', '-readonly', db], opts);
|
|
129
|
+
}
|
|
130
|
+
catch { /* the side files may be gone; try the next rung */ }
|
|
131
|
+
if (!fs.existsSync(`${db}-wal`)) {
|
|
132
|
+
try {
|
|
133
|
+
return execFileSync('sqlite3', ['-separator', '\t', `file:${db}?immutable=1`], opts);
|
|
134
|
+
}
|
|
135
|
+
catch { /* fall through to the copy */ }
|
|
136
|
+
}
|
|
137
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tersio-omp-'));
|
|
138
|
+
const target = path.join(dir, path.basename(db));
|
|
139
|
+
try {
|
|
140
|
+
fs.copyFileSync(db, target);
|
|
141
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
142
|
+
if (fs.existsSync(`${db}${suffix}`))
|
|
143
|
+
fs.copyFileSync(`${db}${suffix}`, `${target}${suffix}`);
|
|
144
|
+
}
|
|
145
|
+
return execFileSync('sqlite3', ['-separator', '\t', '-readonly', target], opts);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const BLOCK = '@@blk@@';
|
|
152
|
+
function blocks(out) {
|
|
153
|
+
const map = new Map();
|
|
154
|
+
let current = null;
|
|
155
|
+
for (const line of out.split('\n')) {
|
|
156
|
+
if (!line)
|
|
157
|
+
continue;
|
|
158
|
+
if (line.startsWith(BLOCK)) {
|
|
159
|
+
current = [];
|
|
160
|
+
map.set(line.slice(BLOCK.length), current);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (current)
|
|
164
|
+
current.push(line.split('\t'));
|
|
165
|
+
}
|
|
166
|
+
return map;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* One sqlite3 call for a group of queries. A failing statement aborts the script, so the
|
|
170
|
+
* partial output is kept and only the missing blocks re-run one at a time.
|
|
171
|
+
*/
|
|
172
|
+
function readBlocks(db, queries) {
|
|
173
|
+
const script = queries.map(([name, sql]) => `SELECT '${BLOCK}${name}';\n${sql};\n`).join('');
|
|
174
|
+
try {
|
|
175
|
+
return blocks(sqliteScript(db, script));
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
// A failing statement aborts the script, so its stdout holds every block that ran.
|
|
179
|
+
if (!error || typeof error !== 'object' || !('stdout' in error) || typeof error.stdout !== 'string')
|
|
180
|
+
throw error;
|
|
181
|
+
const merged = blocks(error.stdout);
|
|
182
|
+
for (const [name, sql] of queries) {
|
|
183
|
+
if (merged.has(name))
|
|
184
|
+
continue;
|
|
185
|
+
try {
|
|
186
|
+
for (const [k, rows] of blocks(sqliteScript(db, `SELECT '${BLOCK}${name}';\n${sql};\n`)))
|
|
187
|
+
merged.set(k, rows);
|
|
188
|
+
}
|
|
189
|
+
catch { /* an unavailable table leaves its key absent */ }
|
|
190
|
+
}
|
|
191
|
+
return merged;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function tableColumns(db, table) {
|
|
195
|
+
try {
|
|
196
|
+
const out = sqliteScript(db, `SELECT name FROM pragma_table_info('${table}');\n`);
|
|
197
|
+
return new Set(out.split('\n').map((l) => l.trim()).filter(Boolean));
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return new Set();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function num(v) {
|
|
204
|
+
const n = Number(v);
|
|
205
|
+
return Number.isFinite(n) ? n : 0;
|
|
206
|
+
}
|
|
207
|
+
function numOrNull(v) {
|
|
208
|
+
if (v === undefined || v === '')
|
|
209
|
+
return null;
|
|
210
|
+
const n = Number(v);
|
|
211
|
+
return Number.isFinite(n) ? n : null;
|
|
212
|
+
}
|
|
213
|
+
/** A text column must stay on one line: the parser splits rows on tabs and newlines. */
|
|
214
|
+
function txt(column) {
|
|
215
|
+
return `replace(replace(replace(COALESCE(${column}, ''), char(10), ' '), char(13), ' '), char(9), ' ')`;
|
|
216
|
+
}
|
|
217
|
+
// ------------------------------------------------------------------- sql builders
|
|
218
|
+
/** The unpriced rule, copied from the reference: a subscription route with no price is not "free". */
|
|
219
|
+
function unpricedSql(p = '') {
|
|
220
|
+
return `CASE WHEN ${p}total_tokens > 0 AND ${p}cost_total = 0
|
|
221
|
+
AND (${p}provider = 'xai-oauth' OR ${p}cost_unpriced = 1) THEN 1 ELSE 0 END`;
|
|
222
|
+
}
|
|
223
|
+
/** The additive facts every messages group reads. */
|
|
224
|
+
function messageFacts(p = '') {
|
|
225
|
+
return [
|
|
226
|
+
'COUNT(*)',
|
|
227
|
+
`SUM(CASE WHEN ${p}stop_reason = 'error' THEN 1 ELSE 0 END)`,
|
|
228
|
+
`SUM(${p}input_tokens)`,
|
|
229
|
+
`SUM(${p}output_tokens)`,
|
|
230
|
+
`SUM(${p}cache_read_tokens)`,
|
|
231
|
+
`SUM(${p}cache_write_tokens)`,
|
|
232
|
+
`SUM(${p}total_tokens)`,
|
|
233
|
+
`TOTAL(${p}cost_total)`,
|
|
234
|
+
`TOTAL(${p}cost_input)`,
|
|
235
|
+
`TOTAL(${p}cost_cache_read)`,
|
|
236
|
+
`TOTAL(${p}cost_cache_write)`,
|
|
237
|
+
`SUM(${unpricedSql(p)})`,
|
|
238
|
+
`TOTAL(${p}duration)`,
|
|
239
|
+
`COUNT(${p}duration)`,
|
|
240
|
+
`TOTAL(${p}ttft)`,
|
|
241
|
+
`COUNT(${p}ttft)`,
|
|
242
|
+
`TOTAL(CASE WHEN ${p}duration > 0 THEN ${p}output_tokens * 1000.0 / ${p}duration END)`,
|
|
243
|
+
`COUNT(CASE WHEN ${p}duration > 0 THEN 1 END)`,
|
|
244
|
+
`MIN(${p}timestamp)`,
|
|
245
|
+
`MAX(${p}timestamp)`,
|
|
246
|
+
`MAX(${p}duration)`,
|
|
247
|
+
].join(', ');
|
|
248
|
+
}
|
|
249
|
+
/** The zero and null literals an older omp schema needs in place of a named column. */
|
|
250
|
+
const SHIMS = [
|
|
251
|
+
['stop_reason', "''"],
|
|
252
|
+
['cost_no_cache_input', '0'],
|
|
253
|
+
['cost_input', '0'],
|
|
254
|
+
['cost_cache_read', '0'],
|
|
255
|
+
['cost_cache_write', '0'],
|
|
256
|
+
['cost_unpriced', '0'],
|
|
257
|
+
['provider', "''"],
|
|
258
|
+
['folder', "''"],
|
|
259
|
+
['agent_type', "''"],
|
|
260
|
+
];
|
|
261
|
+
/** Applies the shims: a column the table does not hold reads as its literal. */
|
|
262
|
+
function withShims(sql, cols) {
|
|
263
|
+
let out = sql;
|
|
264
|
+
for (const [column, literal] of SHIMS) {
|
|
265
|
+
if (cols.has(column))
|
|
266
|
+
continue;
|
|
267
|
+
out = out.split(column).join(literal);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
function parseFacts(row, at) {
|
|
272
|
+
const g = (i) => num(row?.[at + i]);
|
|
273
|
+
return {
|
|
274
|
+
requests: g(0), failed: g(1),
|
|
275
|
+
input: g(2), output: g(3), cacheRead: g(4), cacheWrite: g(5), total: g(6),
|
|
276
|
+
costUsd: g(7),
|
|
277
|
+
costInput: g(8), costCacheRead: g(9), costCacheWrite: g(10),
|
|
278
|
+
unpriced: g(11),
|
|
279
|
+
durationSum: g(12), durationN: g(13), ttftSum: g(14), ttftN: g(15),
|
|
280
|
+
tpsSum: g(16), tpsN: g(17),
|
|
281
|
+
firstTs: numOrNull(row?.[at + 18]), lastTs: numOrNull(row?.[at + 19]),
|
|
282
|
+
maxDurationMs: numOrNull(row?.[at + 20]),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function mix(f) {
|
|
286
|
+
return { input: f.input, output: f.output, cacheRead: f.cacheRead, cacheWrite: f.cacheWrite, total: f.total };
|
|
287
|
+
}
|
|
288
|
+
const cacheRateOf = (f) => (f.input + f.cacheRead > 0 ? f.cacheRead / (f.input + f.cacheRead) : 0);
|
|
289
|
+
function overallOf(f) {
|
|
290
|
+
return {
|
|
291
|
+
...mix(f),
|
|
292
|
+
costMix: costMixOf(f),
|
|
293
|
+
requests: f.requests,
|
|
294
|
+
failed: f.failed,
|
|
295
|
+
successful: f.requests - f.failed,
|
|
296
|
+
errorRate: f.requests > 0 ? f.failed / f.requests : 0,
|
|
297
|
+
cacheRate: cacheRateOf(f),
|
|
298
|
+
costUsd: f.costUsd,
|
|
299
|
+
unpricedRequests: f.unpriced,
|
|
300
|
+
avgDurationMs: f.durationN > 0 ? f.durationSum / f.durationN : 0,
|
|
301
|
+
avgTtftMs: f.ttftN > 0 ? f.ttftSum / f.ttftN : 0,
|
|
302
|
+
avgTokensPerSecond: f.tpsN > 0 ? f.tpsSum / f.tpsN : 0,
|
|
303
|
+
maxDurationMs: f.maxDurationMs,
|
|
304
|
+
firstTs: f.firstTs,
|
|
305
|
+
lastTs: f.lastTs,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function rowOf(key, f, models, provider = '') {
|
|
309
|
+
return {
|
|
310
|
+
...mix(f),
|
|
311
|
+
key,
|
|
312
|
+
provider,
|
|
313
|
+
costMix: costMixOf(f),
|
|
314
|
+
requests: f.requests,
|
|
315
|
+
failed: f.failed,
|
|
316
|
+
errorRate: f.requests > 0 ? f.failed / f.requests : 0,
|
|
317
|
+
cacheRate: cacheRateOf(f),
|
|
318
|
+
costUsd: f.costUsd,
|
|
319
|
+
unpricedRequests: f.unpriced,
|
|
320
|
+
avgDurationMs: f.durationN > 0 ? f.durationSum / f.durationN : 0,
|
|
321
|
+
avgTtftMs: f.ttftN > 0 ? f.ttftSum / f.ttftN : 0,
|
|
322
|
+
avgTokensPerSecond: f.tpsN > 0 ? f.tpsSum / f.tpsN : 0,
|
|
323
|
+
firstTs: f.firstTs,
|
|
324
|
+
lastTs: f.lastTs,
|
|
325
|
+
models,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function rangeWhere(cutoff, column = 'timestamp') {
|
|
329
|
+
return cutoff > 0 ? `WHERE ${column} >= ${Math.floor(cutoff)}` : '';
|
|
330
|
+
}
|
|
331
|
+
/** Utilization is a fraction with float noise; four decimals keep the payload small. */
|
|
332
|
+
function round4(v) {
|
|
333
|
+
return v === null ? null : Math.round(v * 1e4) / 1e4;
|
|
334
|
+
}
|
|
335
|
+
// ------------------------------------------------------------------- the aggregate
|
|
336
|
+
function bucketSize(range, firstTs, lastTs) {
|
|
337
|
+
const base = BUCKET_MS[range];
|
|
338
|
+
if (firstTs === null || lastTs === null)
|
|
339
|
+
return base;
|
|
340
|
+
const span = Math.max(0, lastTs - firstTs);
|
|
341
|
+
if (span / base <= MAX_BUCKETS)
|
|
342
|
+
return base;
|
|
343
|
+
return Math.max(base, Math.ceil(span / MAX_BUCKETS / base) * base);
|
|
344
|
+
}
|
|
345
|
+
// ------------------------------------------------------------------- the aggregate
|
|
346
|
+
function readStatsDb(db, cutoff, bucketMs, cols, parts) {
|
|
347
|
+
const where = rangeWhere(cutoff);
|
|
348
|
+
const errCol = cols.has('error_message') ? txt('error_message') : "''";
|
|
349
|
+
const apiCol = cols.has('api') ? txt('api') : "''";
|
|
350
|
+
const errorWhere = `WHERE stop_reason = 'error'${cutoff > 0 ? ` AND timestamp >= ${Math.floor(cutoff)}` : ''}`;
|
|
351
|
+
const requestColumns = `timestamp, ${txt('provider')}, ${txt('model')}, ${txt('folder')}, ${apiCol},
|
|
352
|
+
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens,
|
|
353
|
+
cost_total, ${unpricedSql()}, duration, ttft, ${txt('stop_reason')}, ${errCol},
|
|
354
|
+
${txt('agent_type')}, ${txt('session_file')}, ${txt('entry_id')}`;
|
|
355
|
+
const out = readBlocks(db, [
|
|
356
|
+
['overall', `SELECT ${messageFacts()} FROM messages ${where}`],
|
|
357
|
+
['byModel', `SELECT ${txt('model')}, ${txt('provider')}, ${messageFacts()} FROM messages ${where} GROUP BY model, provider ORDER BY 3 DESC`],
|
|
358
|
+
['byProvider', `SELECT ${txt('provider')}, COUNT(DISTINCT model), ${messageFacts()} FROM messages ${where} GROUP BY provider ORDER BY 3 DESC`],
|
|
359
|
+
['byAgentType', `SELECT ${txt('agent_type')}, ${messageFacts()} FROM messages ${where} GROUP BY 1 ORDER BY 2 DESC`],
|
|
360
|
+
['byProject', `SELECT ${txt('folder')}, ${messageFacts()} FROM messages ${where} GROUP BY 1 ORDER BY 2 DESC`],
|
|
361
|
+
['series', `SELECT (timestamp / ${bucketMs}) * ${bucketMs}, COUNT(*),
|
|
362
|
+
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END),
|
|
363
|
+
SUM(total_tokens), TOTAL(cost_total) FROM messages ${where} GROUP BY 1 ORDER BY 1`],
|
|
364
|
+
['hourOfDay', `SELECT CAST(strftime('%H', timestamp / 1000, 'unixepoch', 'localtime') AS INTEGER),
|
|
365
|
+
COUNT(*), SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END), SUM(total_tokens), TOTAL(cost_total)
|
|
366
|
+
FROM messages ${where} GROUP BY 1 ORDER BY 1`],
|
|
367
|
+
['recent', `SELECT ${requestColumns} FROM messages ${where} ORDER BY timestamp DESC LIMIT ${RECENT_LIMIT}`],
|
|
368
|
+
['errors', `SELECT ${requestColumns} FROM messages ${errorWhere} ORDER BY timestamp DESC LIMIT ${ERROR_LIMIT}`],
|
|
369
|
+
['traces', `SELECT ${txt('session_file')}, MAX(${txt('folder')}),
|
|
370
|
+
COUNT(*), SUM(total_tokens), TOTAL(cost_total), SUM(${unpricedSql()}),
|
|
371
|
+
MIN(timestamp), MAX(timestamp + COALESCE(duration, 0))
|
|
372
|
+
FROM messages ${where} GROUP BY session_file ORDER BY 8 DESC LIMIT ${TRACE_LIMIT}`],
|
|
373
|
+
].filter(([name]) => parts.has(name)).map(([name, sql]) => [name, withShims(sql, cols)]));
|
|
374
|
+
// The trace rows are read per file, then folded per root. The model list and the tool counts are
|
|
375
|
+
// read across every transcript and keyed by root, so a child that fell outside the window still
|
|
376
|
+
// folds into the session it belongs to.
|
|
377
|
+
const traceModels = readTraceModels(db);
|
|
378
|
+
const traceTools = readTraceToolCounts(db);
|
|
379
|
+
const traces = foldTraces((out.get('traces') ?? []).map((r) => {
|
|
380
|
+
// Both readers are keyed by root, so every file of a session reads the session's totals.
|
|
381
|
+
const root = rootSessionFile(r[0] ?? '');
|
|
382
|
+
return {
|
|
383
|
+
sessionFile: r[0] ?? '', project: r[1] ?? '', cwd: '', title: null,
|
|
384
|
+
requests: num(r[2]), tokens: num(r[3]), costUsd: num(r[4]), unpriced: num(r[5]),
|
|
385
|
+
models: traceModels.get(root) ?? [],
|
|
386
|
+
toolCalls: traceTools.get(root) ?? 0,
|
|
387
|
+
subagents: 0, firstTs: num(r[6]), lastTs: num(r[7]),
|
|
388
|
+
};
|
|
389
|
+
}));
|
|
390
|
+
applySessionMetadata(traces);
|
|
391
|
+
const requestRows = (rows) => rows.map((r) => ({
|
|
392
|
+
ts: num(r[0]),
|
|
393
|
+
provider: r[1] ?? '',
|
|
394
|
+
model: r[2] ?? '',
|
|
395
|
+
project: r[3] ?? '',
|
|
396
|
+
api: r[4] ?? '',
|
|
397
|
+
input: num(r[5]), output: num(r[6]), cacheRead: num(r[7]), cacheWrite: num(r[8]), totalTokens: num(r[9]),
|
|
398
|
+
costUsd: num(r[10]),
|
|
399
|
+
unpriced: num(r[11]) > 0,
|
|
400
|
+
durationMs: numOrNull(r[12]),
|
|
401
|
+
ttftMs: numOrNull(r[13]),
|
|
402
|
+
stopReason: r[14] ?? '',
|
|
403
|
+
errorMessage: r[15] || null,
|
|
404
|
+
agentType: r[16] ?? '',
|
|
405
|
+
sessionFile: r[17] ?? '',
|
|
406
|
+
entryId: r[18] ?? '',
|
|
407
|
+
}));
|
|
408
|
+
const errorRows = requestRows(out.get('errors') ?? []);
|
|
409
|
+
const errorCounts = new Map();
|
|
410
|
+
for (const row of errorRows) {
|
|
411
|
+
const key = `${row.model}\u0000${row.provider}`;
|
|
412
|
+
const hit = errorCounts.get(key);
|
|
413
|
+
if (hit)
|
|
414
|
+
hit.count += 1;
|
|
415
|
+
else
|
|
416
|
+
errorCounts.set(key, { model: row.model, provider: row.provider, count: 1 });
|
|
417
|
+
}
|
|
418
|
+
const byModel = (out.get('byModel') ?? [])
|
|
419
|
+
.map((r) => rowOf(r[0] ?? '', parseFacts(r, 2), 0, r[1] ?? ''));
|
|
420
|
+
return {
|
|
421
|
+
overall: overallOf(parseFacts(out.get('overall')?.[0], 0)),
|
|
422
|
+
byModel,
|
|
423
|
+
byProvider: (out.get('byProvider') ?? []).map((r) => rowOf(r[0] ?? '', parseFacts(r, 2), num(r[1]), r[0] ?? '')),
|
|
424
|
+
byProject: (out.get('byProject') ?? []).map((r) => rowOf(r[0] ?? '', parseFacts(r, 1), 0)),
|
|
425
|
+
byAgentType: (out.get('byAgentType') ?? []).map((r) => {
|
|
426
|
+
const f = parseFacts(r, 1);
|
|
427
|
+
return { ...mix(f), agentType: r[0] ?? '', requests: f.requests, costUsd: f.costUsd };
|
|
428
|
+
}),
|
|
429
|
+
series: (out.get('series') ?? []).map((r) => ({
|
|
430
|
+
ts: num(r[0]), requests: num(r[1]), errors: num(r[2]), tokens: num(r[3]), costUsd: num(r[4]),
|
|
431
|
+
})),
|
|
432
|
+
hourOfDay: (out.get('hourOfDay') ?? []).map((r) => ({
|
|
433
|
+
hour: num(r[0]), requests: num(r[1]), errors: num(r[2]), tokens: num(r[3]), costUsd: num(r[4]),
|
|
434
|
+
})),
|
|
435
|
+
recent: requestRows(out.get('recent') ?? []),
|
|
436
|
+
errorGroups: groupErrors(errorRows),
|
|
437
|
+
errors: errorRows,
|
|
438
|
+
errorModels: [...errorCounts.values()].toSorted((a, b) => b.count - a.count),
|
|
439
|
+
traces: traces,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Fold every child transcript into its root session, the way the reference lists them.
|
|
444
|
+
*
|
|
445
|
+
* A subagent writes its own file under its root's folder, so a row grouped by file alone would
|
|
446
|
+
* show one row per subagent. Each root holds its children's requests, tokens, cost, models, and
|
|
447
|
+
* time, counts the children folded in, and keeps the row even when its own file is gone.
|
|
448
|
+
*/
|
|
449
|
+
function foldTraces(rows) {
|
|
450
|
+
const byRoot = new Map();
|
|
451
|
+
for (const row of rows) {
|
|
452
|
+
const rootFile = rootSessionFile(row.sessionFile);
|
|
453
|
+
const held = byRoot.get(rootFile);
|
|
454
|
+
if (!held) {
|
|
455
|
+
byRoot.set(rootFile, { ...row, sessionFile: rootFile, models: [...row.models] });
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
held.requests += row.requests;
|
|
459
|
+
held.tokens += row.tokens;
|
|
460
|
+
held.costUsd += row.costUsd;
|
|
461
|
+
held.unpriced += row.unpriced;
|
|
462
|
+
held.models = [...new Set([...held.models, ...row.models])].toSorted();
|
|
463
|
+
held.firstTs = Math.min(held.firstTs, row.firstTs);
|
|
464
|
+
held.lastTs = Math.max(held.lastTs, row.lastTs);
|
|
465
|
+
held.subagents += 1;
|
|
466
|
+
}
|
|
467
|
+
return [...byRoot.values()]
|
|
468
|
+
.map((row) => ({ ...row, models: row.models.toSorted() }))
|
|
469
|
+
.toSorted((a, b) => b.lastTs - a.lastTs);
|
|
470
|
+
}
|
|
471
|
+
/** The root session file for a transcript, which is itself when it is already a root. */
|
|
472
|
+
export function rootSessionFile(sessionFile) {
|
|
473
|
+
const marker = '/sessions/';
|
|
474
|
+
const at = sessionFile.indexOf(marker);
|
|
475
|
+
if (at < 0)
|
|
476
|
+
return sessionFile;
|
|
477
|
+
const base = sessionFile.slice(0, at + marker.length);
|
|
478
|
+
const parts = sessionFile.slice(at + marker.length).split('/');
|
|
479
|
+
// `<project>/<root>.jsonl` is a root; `<project>/<root>/<child>.jsonl` belongs to that root.
|
|
480
|
+
if (parts.length < 3)
|
|
481
|
+
return sessionFile;
|
|
482
|
+
return `${base}${parts[0]}/${parts[1]}.jsonl`;
|
|
483
|
+
}
|
|
484
|
+
/** Same normalization as the reference: ids and counters collapse, an HTTP status does not. */
|
|
485
|
+
const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
|
|
486
|
+
const PREFIXED_ID_RE = /\b(?:req|msg|call|toolu|chatcmpl|resp|run|gen)[-_][A-Za-z0-9_-]{6,}/g;
|
|
487
|
+
const HEX_RE = /\b[0-9a-f]{12,}\b/gi;
|
|
488
|
+
const NUMBER_RE = /(?<![\w.-])\d+(?:\.\d+)?/g;
|
|
489
|
+
const HTTP_STATUS_RE = /^[1-5]\d\d$/;
|
|
490
|
+
const SIGNATURE_MAX = 180;
|
|
491
|
+
export function errorSignature(message) {
|
|
492
|
+
if (!message?.trim())
|
|
493
|
+
return 'Unknown error';
|
|
494
|
+
const normalized = message
|
|
495
|
+
.replace(/\s+/g, ' ')
|
|
496
|
+
.trim()
|
|
497
|
+
.replace(UUID_RE, '<id>')
|
|
498
|
+
.replace(PREFIXED_ID_RE, '<id>')
|
|
499
|
+
.replace(HEX_RE, '<hex>')
|
|
500
|
+
.replace(NUMBER_RE, (n) => (HTTP_STATUS_RE.test(n) ? n : 'N'));
|
|
501
|
+
return normalized.length > SIGNATURE_MAX ? `${normalized.slice(0, SIGNATURE_MAX - 1)}…` : normalized;
|
|
502
|
+
}
|
|
503
|
+
function groupErrors(rows) {
|
|
504
|
+
const groups = new Map();
|
|
505
|
+
for (const row of rows) {
|
|
506
|
+
const signature = errorSignature(row.errorMessage);
|
|
507
|
+
const hit = groups.get(signature);
|
|
508
|
+
if (!hit) {
|
|
509
|
+
groups.set(signature, {
|
|
510
|
+
signature, count: 1, firstSeen: row.ts, lastSeen: row.ts, latest: row,
|
|
511
|
+
models: [{ model: row.model, provider: row.provider, count: 1 }],
|
|
512
|
+
});
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
hit.count += 1;
|
|
516
|
+
hit.firstSeen = Math.min(hit.firstSeen, row.ts);
|
|
517
|
+
hit.lastSeen = Math.max(hit.lastSeen, row.ts);
|
|
518
|
+
const model = hit.models.find((m) => m.model === row.model && m.provider === row.provider);
|
|
519
|
+
if (model)
|
|
520
|
+
model.count += 1;
|
|
521
|
+
else
|
|
522
|
+
hit.models.push({ model: row.model, provider: row.provider, count: 1 });
|
|
523
|
+
if (row.ts >= hit.latest.ts)
|
|
524
|
+
hit.latest = row;
|
|
525
|
+
}
|
|
526
|
+
return [...groups.values()]
|
|
527
|
+
.map((g) => ({ ...g, models: g.models.toSorted((a, b) => b.count - a.count) }))
|
|
528
|
+
.toSorted((a, b) => b.count - a.count || b.lastSeen - a.lastSeen);
|
|
529
|
+
}
|
|
530
|
+
function readProviderSeries(db, cutoff, bucketMs) {
|
|
531
|
+
const out = readBlocks(db, [['providerSeries', `SELECT ${txt('provider')}, (timestamp / ${bucketMs}) * ${bucketMs},
|
|
532
|
+
COUNT(*), SUM(total_tokens), TOTAL(cost_total)
|
|
533
|
+
FROM messages ${rangeWhere(cutoff)} GROUP BY 1, 2 ORDER BY 1, 2`]]);
|
|
534
|
+
const perProvider = new Map();
|
|
535
|
+
for (const r of out.get('providerSeries') ?? []) {
|
|
536
|
+
const provider = r[0] ?? '';
|
|
537
|
+
const held = perProvider.get(provider) ?? { cost: 0, points: [] };
|
|
538
|
+
const point = { ts: num(r[1]), requests: num(r[2]), errors: 0, tokens: num(r[3]), costUsd: num(r[4]) };
|
|
539
|
+
held.points.push(point);
|
|
540
|
+
held.cost += point.costUsd;
|
|
541
|
+
perProvider.set(provider, held);
|
|
542
|
+
}
|
|
543
|
+
return [...perProvider.entries()]
|
|
544
|
+
.toSorted((a, b) => b[1].cost - a[1].cost)
|
|
545
|
+
.slice(0, SERIES_PROVIDERS)
|
|
546
|
+
.map(([provider, held]) => ({ provider, points: held.points }));
|
|
547
|
+
}
|
|
548
|
+
function readToolSeries(db, cutoff, bucketMs) {
|
|
549
|
+
const cols = tableColumns(db, 'tool_calls');
|
|
550
|
+
if (!cols.has('tool_name'))
|
|
551
|
+
return {};
|
|
552
|
+
const join = 'LEFT JOIN messages m ON m.session_file = t.session_file AND m.entry_id = t.entry_id';
|
|
553
|
+
const where = `WHERE t.timestamp >= ${Math.floor(cutoff)}`;
|
|
554
|
+
const facts = (bucket, extra) => `
|
|
555
|
+
${bucket},
|
|
556
|
+
${txt('t.tool_name')}, ${txt('t.model')}, ${txt('t.provider')},
|
|
557
|
+
COUNT(*),
|
|
558
|
+
SUM(CASE WHEN t.is_error = 1 THEN 1 ELSE 0 END),
|
|
559
|
+
SUM(t.args_chars),
|
|
560
|
+
SUM(COALESCE(t.result_chars, 0)),
|
|
561
|
+
TOTAL(COALESCE(m.total_tokens, 0) * 1.0 / t.calls_in_turn),
|
|
562
|
+
TOTAL(COALESCE(m.output_tokens, 0) * 1.0 / t.calls_in_turn),
|
|
563
|
+
TOTAL(COALESCE(m.cost_total, 0) / t.calls_in_turn),
|
|
564
|
+
TOTAL(CASE WHEN COALESCE(m.total_tokens, 0) > 0 AND COALESCE(m.cost_total, 0) = 0
|
|
565
|
+
AND (m.provider = 'xai-oauth' OR m.cost_unpriced = 1) THEN 1 ELSE 0 END * 1.0 / t.calls_in_turn),
|
|
566
|
+
MAX(t.timestamp)${extra}`;
|
|
567
|
+
const out = readBlocks(db, [
|
|
568
|
+
['byTool', `SELECT ${facts("'-'", '')} FROM tool_calls t ${join} ${where} GROUP BY t.tool_name ORDER BY 5 DESC`],
|
|
569
|
+
['byToolModel', `SELECT ${facts("'-'", ', t.model, t.provider')} FROM tool_calls t ${join} ${where}
|
|
570
|
+
GROUP BY t.tool_name, t.model, t.provider ORDER BY 5 DESC LIMIT ${TOOL_MODEL_LIMIT}`],
|
|
571
|
+
['toolSeries', `SELECT (t.timestamp / ${bucketMs}) * ${bucketMs}, ${txt('t.tool_name')}, COUNT(*),
|
|
572
|
+
SUM(CASE WHEN t.is_error = 1 THEN 1 ELSE 0 END) FROM tool_calls t ${where} GROUP BY 1, 2 ORDER BY 1`],
|
|
573
|
+
]);
|
|
574
|
+
const parseTool = (r, at) => ({
|
|
575
|
+
tool: r[1] ?? '',
|
|
576
|
+
calls: num(r[4]),
|
|
577
|
+
errors: num(r[5]),
|
|
578
|
+
argsChars: num(r[6]),
|
|
579
|
+
resultChars: num(r[7]),
|
|
580
|
+
totalTokensShare: num(r[8]),
|
|
581
|
+
outputTokensShare: num(r[9]),
|
|
582
|
+
costShare: num(r[10]),
|
|
583
|
+
unpricedShare: num(r[11]),
|
|
584
|
+
lastUsed: numOrNull(r[12]),
|
|
585
|
+
model: r[at] ?? '',
|
|
586
|
+
provider: r[at + 1] ?? '',
|
|
587
|
+
});
|
|
588
|
+
return {
|
|
589
|
+
tools: (out.get('byTool') ?? []).map((r) => parseTool(r, 13)),
|
|
590
|
+
toolsByModel: (out.get('byToolModel') ?? []).map((r) => parseTool(r, 13)),
|
|
591
|
+
toolSeries: (out.get('toolSeries') ?? []).map((r) => ({
|
|
592
|
+
ts: num(r[0]), tool: r[1] ?? '', calls: num(r[2]), errors: num(r[3]),
|
|
593
|
+
})),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
/** Tool calls per root session, with every child transcript folded in. */
|
|
597
|
+
function readTraceToolCounts(db) {
|
|
598
|
+
const counts = new Map();
|
|
599
|
+
try {
|
|
600
|
+
const out = sqliteScript(db, 'SELECT session_file, COUNT(*) FROM tool_calls GROUP BY 1;\n');
|
|
601
|
+
for (const line of out.split('\n')) {
|
|
602
|
+
if (!line.trim())
|
|
603
|
+
continue;
|
|
604
|
+
const [file, count] = line.split('\t');
|
|
605
|
+
if (!file)
|
|
606
|
+
continue;
|
|
607
|
+
const root = rootSessionFile(file);
|
|
608
|
+
counts.set(root, (counts.get(root) ?? 0) + num(count));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
catch { /* no tool rows: keep zero */ }
|
|
612
|
+
return counts;
|
|
613
|
+
}
|
|
614
|
+
/** Distinct models per root session, with every child transcript folded in. */
|
|
615
|
+
function readTraceModels(db) {
|
|
616
|
+
const models = new Map();
|
|
617
|
+
try {
|
|
618
|
+
const out = sqliteScript(db, 'SELECT session_file, model FROM messages GROUP BY session_file, model;\n');
|
|
619
|
+
for (const line of out.split('\n')) {
|
|
620
|
+
if (!line.trim())
|
|
621
|
+
continue;
|
|
622
|
+
const [file, model] = line.split('\t');
|
|
623
|
+
if (!file || !model)
|
|
624
|
+
continue;
|
|
625
|
+
const set = models.get(rootSessionFile(file)) ?? new Set();
|
|
626
|
+
set.add(model);
|
|
627
|
+
models.set(rootSessionFile(file), set);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
catch { /* an unreadable table leaves the list empty */ }
|
|
631
|
+
return new Map([...models.entries()].map(([file, set]) => [file, [...set].toSorted()]));
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Each root session's own title and working directory, read from the head of its transcript.
|
|
635
|
+
*
|
|
636
|
+
* The `session` line carries the working directory and the `title` line sits above it, so one head
|
|
637
|
+
* read answers both. A rotated or unreadable file leaves the row's folder and file name to speak.
|
|
638
|
+
*/
|
|
639
|
+
function applySessionMetadata(traces) {
|
|
640
|
+
for (const trace of traces) {
|
|
641
|
+
if (!trace.sessionFile.endsWith('.jsonl'))
|
|
642
|
+
continue;
|
|
643
|
+
let fd = null;
|
|
644
|
+
try {
|
|
645
|
+
fd = fs.openSync(trace.sessionFile, 'r');
|
|
646
|
+
const buffer = Buffer.alloc(32 * 1024);
|
|
647
|
+
const read = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
648
|
+
for (const line of buffer.toString('utf8', 0, read).split('\n')) {
|
|
649
|
+
if (!line.trim())
|
|
650
|
+
continue;
|
|
651
|
+
if (line.includes('"cwd"') && !trace.cwd) {
|
|
652
|
+
const match = line.match(/"cwd"\s*:\s*"([^"]+)"/);
|
|
653
|
+
if (match)
|
|
654
|
+
trace.cwd = match[1];
|
|
655
|
+
}
|
|
656
|
+
if (line.includes('"title"') && !trace.title) {
|
|
657
|
+
const match = line.match(/"title"\s*:\s*"([^"]*)"/);
|
|
658
|
+
if (match)
|
|
659
|
+
trace.title = match[1] || null;
|
|
660
|
+
}
|
|
661
|
+
if (trace.cwd && trace.title)
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
catch { /* a file that no longer reads keeps its empty metadata */ }
|
|
666
|
+
finally {
|
|
667
|
+
if (fd !== null)
|
|
668
|
+
fs.closeSync(fd);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/** Daily tokens and cost for the busiest models: the Models table sparkline. */
|
|
673
|
+
function readModelSeries(db, cutoff, bucketMs) {
|
|
674
|
+
const scope = rangeWhere(cutoff);
|
|
675
|
+
const top = `model IN (SELECT model FROM messages ${scope} GROUP BY model ORDER BY COUNT(*) DESC LIMIT ${SERIES_MODELS})`;
|
|
676
|
+
const clause = scope ? `${scope} AND ${top}` : `WHERE ${top}`;
|
|
677
|
+
const out = readBlocks(db, [['modelSeries', `SELECT ${txt('model')}, (timestamp / ${bucketMs}) * ${bucketMs},
|
|
678
|
+
COUNT(*), SUM(total_tokens), TOTAL(cost_total) FROM messages ${clause} GROUP BY 1, 2 ORDER BY 1, 2`]]);
|
|
679
|
+
const perModel = new Map();
|
|
680
|
+
for (const r of out.get('modelSeries') ?? []) {
|
|
681
|
+
const model = r[0] ?? '';
|
|
682
|
+
const points = perModel.get(model) ?? [];
|
|
683
|
+
points.push({ ts: num(r[1]), requests: num(r[2]), errors: 0, tokens: num(r[3]), costUsd: num(r[4]) });
|
|
684
|
+
perModel.set(model, points);
|
|
685
|
+
}
|
|
686
|
+
return [...perModel.entries()].map(([model, points]) => ({ model, points }));
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Throughput and first-token latency per bucket for the busiest models, the chart behind a
|
|
690
|
+
* model's expanded row. Both averages are null in a bucket where no request carried the timing,
|
|
691
|
+
* because a non-null zero would draw a flat line through a real gap.
|
|
692
|
+
*/
|
|
693
|
+
function readModelPerformance(db, cutoff, bucketMs) {
|
|
694
|
+
const scope = rangeWhere(cutoff);
|
|
695
|
+
const top = `model IN (SELECT model FROM messages ${scope} GROUP BY model ORDER BY COUNT(*) DESC LIMIT ${SERIES_MODELS})`;
|
|
696
|
+
const clause = scope ? `${scope} AND ${top}` : `WHERE ${top}`;
|
|
697
|
+
const out = readBlocks(db, [['modelPerformance', `SELECT ${txt('model')}, ${txt('provider')}, (timestamp / ${bucketMs}) * ${bucketMs},
|
|
698
|
+
COUNT(*),
|
|
699
|
+
TOTAL(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration END),
|
|
700
|
+
COUNT(CASE WHEN duration > 0 THEN 1 END),
|
|
701
|
+
TOTAL(ttft),
|
|
702
|
+
COUNT(ttft)
|
|
703
|
+
FROM messages ${clause} GROUP BY 1, 2, 3 ORDER BY 1, 2, 3`]]);
|
|
704
|
+
const groups = new Map();
|
|
705
|
+
for (const r of out.get('modelPerformance') ?? []) {
|
|
706
|
+
const key = `${r[0] ?? ''}\u0000${r[1] ?? ''}`;
|
|
707
|
+
const group = groups.get(key) ?? { model: r[0] ?? '', provider: r[1] ?? '', points: [] };
|
|
708
|
+
const durationN = num(r[5]);
|
|
709
|
+
const ttftN = num(r[7]);
|
|
710
|
+
group.points.push({
|
|
711
|
+
ts: num(r[2]),
|
|
712
|
+
requests: num(r[3]),
|
|
713
|
+
avgTokensPerSecond: durationN > 0 ? num(r[4]) / durationN : null,
|
|
714
|
+
avgTtftMs: ttftN > 0 ? num(r[6]) / ttftN : null,
|
|
715
|
+
});
|
|
716
|
+
groups.set(key, group);
|
|
717
|
+
}
|
|
718
|
+
return [...groups.values()];
|
|
719
|
+
}
|
|
720
|
+
// ------------------------------------------------------------ subscription windows
|
|
721
|
+
/** Reference constants for the quota half, copied from the omp implementation. */
|
|
722
|
+
const WINDOW_RESET_DROP = 0.05;
|
|
723
|
+
const WINDOW_MIN_EXTRAPOLATION = 0.1;
|
|
724
|
+
const WINDOW_TARGET_PEAK = 0.9;
|
|
725
|
+
const WINDOW_EXHAUSTED = 0.999;
|
|
726
|
+
const WINDOW_MAX_POINTS = 400;
|
|
727
|
+
/** Peak per bucket, so a utilization spike survives the cap. */
|
|
728
|
+
function downsampleWindow(points) {
|
|
729
|
+
if (points.length <= WINDOW_MAX_POINTS)
|
|
730
|
+
return points;
|
|
731
|
+
const first = points[0].ts;
|
|
732
|
+
const last = points[points.length - 1].ts;
|
|
733
|
+
const bucket = Math.max(1, Math.ceil((last - first) / WINDOW_MAX_POINTS));
|
|
734
|
+
const out = [];
|
|
735
|
+
let mark = -1;
|
|
736
|
+
for (const point of points) {
|
|
737
|
+
const b = Math.floor((point.ts - first) / bucket);
|
|
738
|
+
if (b !== mark) {
|
|
739
|
+
out.push(point);
|
|
740
|
+
mark = b;
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
const held = out[out.length - 1];
|
|
744
|
+
if ((point.usedFraction ?? -1) >= (held.usedFraction ?? -1))
|
|
745
|
+
out[out.length - 1] = point;
|
|
746
|
+
}
|
|
747
|
+
return out;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Peak of the sum across accounts at any sampled instant, forward filling each account's last
|
|
751
|
+
* known fraction. A peak of 1.7 means demand simultaneously held 1.7 windows of quota.
|
|
752
|
+
*/
|
|
753
|
+
function peakConcurrent(samples) {
|
|
754
|
+
const events = samples.filter((sample) => sample.usedFraction !== null).toSorted((a, b) => a.ts - b.ts);
|
|
755
|
+
const current = new Map();
|
|
756
|
+
let sum = 0;
|
|
757
|
+
let peak = 0;
|
|
758
|
+
for (const event of events) {
|
|
759
|
+
const fraction = event.usedFraction ?? 0;
|
|
760
|
+
sum += fraction - (current.get(event.accountKey) ?? 0);
|
|
761
|
+
current.set(event.accountKey, fraction);
|
|
762
|
+
if (sum > peak)
|
|
763
|
+
peak = sum;
|
|
764
|
+
}
|
|
765
|
+
return peak;
|
|
766
|
+
}
|
|
767
|
+
/** The limit label, plus the window label when it adds information. */
|
|
768
|
+
function windowDisplayLabel(sample) {
|
|
769
|
+
if (!sample.windowLabel || sample.label.toLowerCase().includes(sample.windowLabel.toLowerCase()))
|
|
770
|
+
return sample.label;
|
|
771
|
+
return `${sample.label} · ${sample.windowLabel}`;
|
|
772
|
+
}
|
|
773
|
+
/** Quota snapshots from agent.db, oldest first. Windows group by provider and limit id. */
|
|
774
|
+
function readWindowSamples(cutoff) {
|
|
775
|
+
const db = agentDbPath();
|
|
776
|
+
if (!fs.existsSync(db) || tableColumns(db, 'usage_history').size === 0)
|
|
777
|
+
return [];
|
|
778
|
+
const out = readBlocks(db, [['windows', `SELECT ${txt('provider')}, ${txt('account_key')},
|
|
779
|
+
${txt('email')}, ${txt('account_id')}, ${txt('limit_id')}, ${txt('label')}, ${txt('window_label')},
|
|
780
|
+
used_fraction, status, recorded_at
|
|
781
|
+
FROM usage_history ${rangeWhere(cutoff, 'recorded_at')} ORDER BY recorded_at`]]);
|
|
782
|
+
return (out.get('windows') ?? []).map((r) => {
|
|
783
|
+
const usedFraction = numOrNull(r[7]);
|
|
784
|
+
const status = r[8] || null;
|
|
785
|
+
return {
|
|
786
|
+
provider: r[0] ?? '',
|
|
787
|
+
accountKey: r[1] ?? '',
|
|
788
|
+
accountLabel: r[2] || r[3] || r[1] || '',
|
|
789
|
+
limitId: r[4] ?? '',
|
|
790
|
+
label: r[5] ?? '',
|
|
791
|
+
windowLabel: r[6] ?? '',
|
|
792
|
+
ts: num(r[9]),
|
|
793
|
+
usedFraction,
|
|
794
|
+
exhausted: status === 'exhausted' || (usedFraction !== null && usedFraction >= WINDOW_EXHAUSTED),
|
|
795
|
+
};
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Utilization series and per-window insights. A window is one `(provider, limitId)` pair: distinct
|
|
800
|
+
* limits can share a duration label, and merging them interleaves unrelated fractions per account.
|
|
801
|
+
*/
|
|
802
|
+
function computeWindows(samples, tokensByProvider) {
|
|
803
|
+
const groups = new Map();
|
|
804
|
+
for (const sample of samples) {
|
|
805
|
+
const key = `${sample.provider}\u0000${sample.limitId}`;
|
|
806
|
+
const held = groups.get(key);
|
|
807
|
+
if (held)
|
|
808
|
+
held.push(sample);
|
|
809
|
+
else
|
|
810
|
+
groups.set(key, [sample]);
|
|
811
|
+
}
|
|
812
|
+
const usageSeries = [];
|
|
813
|
+
const windowInsights = [];
|
|
814
|
+
for (const group of groups.values()) {
|
|
815
|
+
const first = group[0];
|
|
816
|
+
const windowLabel = windowDisplayLabel(group[group.length - 1]);
|
|
817
|
+
const accounts = new Map();
|
|
818
|
+
for (const sample of group) {
|
|
819
|
+
const held = accounts.get(sample.accountKey);
|
|
820
|
+
if (held)
|
|
821
|
+
held.push(sample);
|
|
822
|
+
else
|
|
823
|
+
accounts.set(sample.accountKey, [sample]);
|
|
824
|
+
}
|
|
825
|
+
let fractionConsumed = 0;
|
|
826
|
+
let cycles = 0;
|
|
827
|
+
let exhaustedEvents = 0;
|
|
828
|
+
for (const [accountKey, accountSamples] of accounts) {
|
|
829
|
+
usageSeries.push({
|
|
830
|
+
provider: first.provider,
|
|
831
|
+
accountKey,
|
|
832
|
+
accountLabel: accountSamples[accountSamples.length - 1].accountLabel,
|
|
833
|
+
windowKey: first.limitId,
|
|
834
|
+
windowLabel,
|
|
835
|
+
points: downsampleWindow(accountSamples.map((sample) => ({
|
|
836
|
+
ts: sample.ts,
|
|
837
|
+
usedFraction: round4(sample.usedFraction),
|
|
838
|
+
exhausted: sample.exhausted,
|
|
839
|
+
}))),
|
|
840
|
+
});
|
|
841
|
+
let previous = null;
|
|
842
|
+
let wasExhausted = false;
|
|
843
|
+
for (const sample of accountSamples) {
|
|
844
|
+
if (sample.exhausted && !wasExhausted)
|
|
845
|
+
exhaustedEvents += 1;
|
|
846
|
+
wasExhausted = sample.exhausted;
|
|
847
|
+
if (sample.usedFraction === null)
|
|
848
|
+
continue;
|
|
849
|
+
if (previous !== null) {
|
|
850
|
+
const delta = sample.usedFraction - previous;
|
|
851
|
+
if (delta > 0)
|
|
852
|
+
fractionConsumed += delta;
|
|
853
|
+
else if (delta < -WINDOW_RESET_DROP)
|
|
854
|
+
cycles += 1;
|
|
855
|
+
}
|
|
856
|
+
previous = sample.usedFraction;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const providerTokens = tokensByProvider.get(first.provider) ?? 0;
|
|
860
|
+
const peak = peakConcurrent(group);
|
|
861
|
+
windowInsights.push({
|
|
862
|
+
provider: first.provider,
|
|
863
|
+
windowKey: first.limitId,
|
|
864
|
+
windowLabel,
|
|
865
|
+
accounts: accounts.size,
|
|
866
|
+
cycles,
|
|
867
|
+
fractionConsumed,
|
|
868
|
+
estTokensPerWindow: providerTokens > 0 && fractionConsumed >= WINDOW_MIN_EXTRAPOLATION
|
|
869
|
+
? Math.round(providerTokens / fractionConsumed)
|
|
870
|
+
: null,
|
|
871
|
+
peakConcurrentFraction: peak,
|
|
872
|
+
idealAccounts: Math.max(1, Math.ceil(peak / WINDOW_TARGET_PEAK)),
|
|
873
|
+
exhaustedEvents,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
usageSeries.sort((a, b) => a.provider.localeCompare(b.provider) ||
|
|
877
|
+
a.windowKey.localeCompare(b.windowKey) ||
|
|
878
|
+
a.accountLabel.localeCompare(b.accountLabel));
|
|
879
|
+
windowInsights.sort((a, b) => a.provider.localeCompare(b.provider) || b.fractionConsumed - a.fractionConsumed);
|
|
880
|
+
return { usageSeries, windowInsights };
|
|
881
|
+
}
|
|
882
|
+
/** Tokens per provider over the range: the currency a window insight extrapolates in. */
|
|
883
|
+
function readProviderTokens(db, cutoff) {
|
|
884
|
+
const out = readBlocks(db, [['providerTokens', `SELECT ${txt('provider')}, SUM(total_tokens)
|
|
885
|
+
FROM messages ${rangeWhere(cutoff)} GROUP BY 1`]]);
|
|
886
|
+
return new Map((out.get('providerTokens') ?? []).map((r) => [r[0] ?? '', num(r[1])]));
|
|
887
|
+
}
|
|
888
|
+
/** Peak burn hours: one row per provider per local hour, the shape the reference charts. */
|
|
889
|
+
function readProviderHourly(db, cutoff) {
|
|
890
|
+
const out = readBlocks(db, [['providerHourly', `SELECT ${txt('provider')},
|
|
891
|
+
CAST(strftime('%H', timestamp / 1000, 'unixepoch', 'localtime') AS INTEGER),
|
|
892
|
+
SUM(total_tokens), SUM(output_tokens), COUNT(*)
|
|
893
|
+
FROM messages ${rangeWhere(cutoff)} GROUP BY 1, 2`]]);
|
|
894
|
+
return (out.get('providerHourly') ?? []).map((r) => ({
|
|
895
|
+
provider: r[0] ?? '',
|
|
896
|
+
hour: num(r[1]),
|
|
897
|
+
totalTokens: num(r[2]),
|
|
898
|
+
outputTokens: num(r[3]),
|
|
899
|
+
requests: num(r[4]),
|
|
900
|
+
}));
|
|
901
|
+
}
|
|
902
|
+
/** Middle value, ignoring nulls. The Requests page summarises latency this way. */
|
|
903
|
+
function median(values) {
|
|
904
|
+
const nums = values.filter((v) => typeof v === 'number' && Number.isFinite(v)).toSorted((a, b) => a - b);
|
|
905
|
+
if (nums.length === 0)
|
|
906
|
+
return 0;
|
|
907
|
+
const mid = nums.length >> 1;
|
|
908
|
+
return nums.length % 2 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2;
|
|
909
|
+
}
|
|
910
|
+
/** The Requests page summary, computed over the rows the payload carries. */
|
|
911
|
+
function computeRequestStats(rows) {
|
|
912
|
+
const durations = rows.map((row) => row.durationMs);
|
|
913
|
+
const sorted = durations.filter((v) => typeof v === 'number' && Number.isFinite(v)).toSorted((a, b) => a - b);
|
|
914
|
+
const pick = (q) => (sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]);
|
|
915
|
+
const times = rows.map((row) => row.ts);
|
|
916
|
+
return {
|
|
917
|
+
requests: rows.length,
|
|
918
|
+
failed: rows.filter((row) => row.stopReason === 'error').length,
|
|
919
|
+
aborted: rows.filter((row) => row.stopReason === 'aborted').length,
|
|
920
|
+
tokens: rows.reduce((sum, row) => sum + row.totalTokens, 0),
|
|
921
|
+
costUsd: rows.reduce((sum, row) => sum + row.costUsd, 0),
|
|
922
|
+
unpriced: rows.filter((row) => row.unpriced).length,
|
|
923
|
+
medianDurationMs: median(durations),
|
|
924
|
+
p95DurationMs: pick(0.95),
|
|
925
|
+
medianTtftMs: median(rows.map((row) => row.ttftMs)),
|
|
926
|
+
oldest: times.length ? Math.min(...times) : null,
|
|
927
|
+
newest: times.length ? Math.max(...times) : null,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
/** How many spans one trace ships. A long session holds thousands; the transcript virtualises. */
|
|
931
|
+
const TRACE_SPAN_LIMIT = 6000;
|
|
932
|
+
/** How much of a message body a span carries: enough for one transcript line. */
|
|
933
|
+
const TRACE_DETAIL_MAX = 120;
|
|
934
|
+
/** First non-empty line of a message body, for the span's detail line. */
|
|
935
|
+
function firstLine(parts) {
|
|
936
|
+
const text = parts
|
|
937
|
+
.map((part) => String(part.text ?? part.thinking ?? ''))
|
|
938
|
+
.map((value) => value.replace(/\s+/g, ' ').trim())
|
|
939
|
+
.find(Boolean);
|
|
940
|
+
return text ? text.slice(0, TRACE_DETAIL_MAX) : '';
|
|
941
|
+
}
|
|
942
|
+
/** How a request stopped, read from the assistant message's stop reason. */
|
|
943
|
+
function stopState(stopReason) {
|
|
944
|
+
if (stopReason === 'error')
|
|
945
|
+
return 'failed';
|
|
946
|
+
if (stopReason === 'aborted')
|
|
947
|
+
return 'aborted';
|
|
948
|
+
return 'completed';
|
|
949
|
+
}
|
|
950
|
+
/** Tool names that hand the work to another agent, for the Agents figure. */
|
|
951
|
+
const AGENT_TOOLS = new Set(['task', 'subagent']);
|
|
952
|
+
/**
|
|
953
|
+
* One session transcript as spans, tracks, markers, and totals.
|
|
954
|
+
*
|
|
955
|
+
* An assistant message stamps its completion and its `duration` says when the call began, so a
|
|
956
|
+
* model span runs `ts - duration` to `ts`. A tool call is emitted by the message requesting it and
|
|
957
|
+
* closed by the `toolResult` with the same call id, so the two bound a tool span. A user message
|
|
958
|
+
* opens a turn the next user message closes, and everything that is not a message rides along as a
|
|
959
|
+
* marker, since it happened at a point in time rather than across one.
|
|
960
|
+
*/
|
|
961
|
+
export function readSessionTrace(sessionFile, limit = TRACE_SPAN_LIMIT) {
|
|
962
|
+
const project = sessionFile.split('/sessions/')[1]?.split('/')[0] ?? '';
|
|
963
|
+
const empty = {
|
|
964
|
+
sessionFile, project, title: '', cwd: project, startedAt: 0, endedAt: 0,
|
|
965
|
+
tracks: [], markers: [], summary: emptyTraceSummary(), toolStats: [], truncated: false,
|
|
966
|
+
};
|
|
967
|
+
if (!sessionFile.endsWith('.jsonl') || !fs.existsSync(sessionFile))
|
|
968
|
+
return empty;
|
|
969
|
+
let text;
|
|
970
|
+
try {
|
|
971
|
+
text = fs.readFileSync(sessionFile, 'utf8');
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
return empty;
|
|
975
|
+
}
|
|
976
|
+
const raw = [];
|
|
977
|
+
const markers = [];
|
|
978
|
+
let title = '';
|
|
979
|
+
let truncated = false;
|
|
980
|
+
for (const line of text.split('\n')) {
|
|
981
|
+
if (!line.trim())
|
|
982
|
+
continue;
|
|
983
|
+
let parsed;
|
|
984
|
+
try {
|
|
985
|
+
parsed = JSON.parse(line);
|
|
986
|
+
}
|
|
987
|
+
catch {
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
const entryId = String(parsed.id ?? '');
|
|
991
|
+
// The line's stamp is when the entry landed; the message's own stamp, a number, is when it began.
|
|
992
|
+
const ts = Date.parse(String(parsed.timestamp ?? '')) || 0;
|
|
993
|
+
const own = parsed.message?.timestamp;
|
|
994
|
+
const startAt = typeof own === 'number' && Number.isFinite(own) ? own : null;
|
|
995
|
+
if (parsed.type !== 'message') {
|
|
996
|
+
const marker = markerFor(entryId, ts, parsed);
|
|
997
|
+
if (marker)
|
|
998
|
+
markers.push(marker);
|
|
999
|
+
if (parsed.type === 'title' && typeof parsed.title === 'string')
|
|
1000
|
+
title = parsed.title;
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
const message = (parsed.message ?? {});
|
|
1004
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
1005
|
+
const usage = (message.usage ?? {});
|
|
1006
|
+
const cost = (usage.cost ?? {});
|
|
1007
|
+
raw.push({
|
|
1008
|
+
entryId,
|
|
1009
|
+
ts,
|
|
1010
|
+
role: String(message.role ?? ''),
|
|
1011
|
+
tool: String(message.toolName ?? ''),
|
|
1012
|
+
detail: firstLine(content),
|
|
1013
|
+
model: String(message.model ?? ''),
|
|
1014
|
+
provider: String(message.provider ?? ''),
|
|
1015
|
+
tokens: num(String(usage.totalTokens ?? 0)),
|
|
1016
|
+
costUsd: num(String(cost.total ?? 0)),
|
|
1017
|
+
durationMs: typeof message.duration === 'number' ? message.duration : null,
|
|
1018
|
+
startAt,
|
|
1019
|
+
isError: message.isError === true,
|
|
1020
|
+
stopped: stopState(String(message.stopReason ?? '')),
|
|
1021
|
+
callId: String(message.toolCallId ?? ''),
|
|
1022
|
+
calls: content
|
|
1023
|
+
.filter((part) => part.type === 'toolCall')
|
|
1024
|
+
.map((part) => ({ id: String(part.id ?? ''), name: String(part.name ?? 'tool') })),
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
// A tool result closes the call it names, so the call's span runs to it.
|
|
1028
|
+
const resultAt = new Map();
|
|
1029
|
+
const callIds = new Set();
|
|
1030
|
+
for (const row of raw) {
|
|
1031
|
+
for (const call of row.calls)
|
|
1032
|
+
if (call.id)
|
|
1033
|
+
callIds.add(call.id);
|
|
1034
|
+
if (row.role === 'toolResult' && row.callId && !resultAt.has(row.callId))
|
|
1035
|
+
resultAt.set(row.callId, row);
|
|
1036
|
+
}
|
|
1037
|
+
const built = [];
|
|
1038
|
+
let turnOpen = null;
|
|
1039
|
+
for (const row of raw) {
|
|
1040
|
+
const base = {
|
|
1041
|
+
model: row.model, provider: row.provider, tokens: row.tokens, costUsd: row.costUsd,
|
|
1042
|
+
isError: row.isError, entryId: row.entryId,
|
|
1043
|
+
};
|
|
1044
|
+
if (row.role === 'assistant') {
|
|
1045
|
+
// A model call runs from its own start stamp to the line's stamp; the duration is the cross-check.
|
|
1046
|
+
const start = row.startAt !== null && row.startAt < row.ts ? row.startAt : row.durationMs && row.durationMs > 0 ? row.ts - row.durationMs : row.ts;
|
|
1047
|
+
if (turnOpen)
|
|
1048
|
+
turnOpen.end = start;
|
|
1049
|
+
turnOpen = null;
|
|
1050
|
+
built.push({ ...base, id: row.entryId || `model-${built.length}`, kind: 'model', label: row.model || 'assistant',
|
|
1051
|
+
detail: row.detail, start, end: row.ts, tool: '', stopped: row.stopped });
|
|
1052
|
+
row.calls.forEach((call, index) => {
|
|
1053
|
+
const result = call.id ? resultAt.get(call.id) : undefined;
|
|
1054
|
+
const end = result ? Math.max(result.ts, row.ts) : row.ts;
|
|
1055
|
+
built.push({ ...base, id: `${row.entryId}#${index}`, kind: 'tool', label: call.name, detail: result?.detail ?? '',
|
|
1056
|
+
start: row.ts, end, tool: call.name, isError: result ? result.isError : base.isError, stopped: 'completed' });
|
|
1057
|
+
});
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
if (row.role === 'toolResult') {
|
|
1061
|
+
// A result whose call was compacted away still ran: draw it as a point.
|
|
1062
|
+
if (row.callId && callIds.has(row.callId))
|
|
1063
|
+
continue;
|
|
1064
|
+
built.push({ ...base, id: row.entryId || `tool-${built.length}`, kind: 'tool', label: row.tool || 'tool',
|
|
1065
|
+
detail: row.detail, start: row.ts, end: row.ts, tool: row.tool, stopped: 'completed' });
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
if (row.role === 'user') {
|
|
1069
|
+
if (turnOpen)
|
|
1070
|
+
turnOpen.end = row.ts;
|
|
1071
|
+
turnOpen = { ...base, id: `turn-${built.length}`, kind: 'turn', label: 'Prompt', detail: row.detail,
|
|
1072
|
+
start: row.ts, end: row.ts, tool: '', stopped: 'completed' };
|
|
1073
|
+
built.push(turnOpen);
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
built.push({ ...base, id: row.entryId || `bg-${built.length}`, kind: 'background', label: row.role || 'system',
|
|
1077
|
+
detail: row.detail, start: row.ts, end: row.ts, tool: '', stopped: 'completed' });
|
|
1078
|
+
}
|
|
1079
|
+
const lastEnd = built.reduce((max, span) => Math.max(max, span.end), 0);
|
|
1080
|
+
if (turnOpen)
|
|
1081
|
+
turnOpen.end = Math.max(turnOpen.end, lastEnd);
|
|
1082
|
+
// A session longer than the cap keeps its newest spans: the opening minutes are the least useful.
|
|
1083
|
+
const spans = built.length > limit ? built.slice(-limit) : built;
|
|
1084
|
+
if (built.length > limit)
|
|
1085
|
+
truncated = true;
|
|
1086
|
+
spans.sort((a, b) => a.start - b.start);
|
|
1087
|
+
const startedAt = spans.length ? Math.min(...spans.map((span) => span.start)) : 0;
|
|
1088
|
+
const endedAt = spans.length ? Math.max(...spans.map((span) => span.end)) : 0;
|
|
1089
|
+
return {
|
|
1090
|
+
sessionFile, project, title, cwd: project, startedAt, endedAt,
|
|
1091
|
+
tracks: [{ id: 'main', label: 'Session', parentId: null, spans }],
|
|
1092
|
+
markers: markers.toSorted((a, b) => a.ts - b.ts),
|
|
1093
|
+
summary: summarizeTrace(spans, startedAt, endedAt),
|
|
1094
|
+
toolStats: buildToolStats(spans),
|
|
1095
|
+
truncated,
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
/** A non-message journal entry, when it marks a change worth a diamond on the timeline. */
|
|
1099
|
+
function markerFor(entryId, ts, parsed) {
|
|
1100
|
+
const kind = String(parsed.type ?? '');
|
|
1101
|
+
if (kind === 'model_change')
|
|
1102
|
+
return { id: entryId || `model-${ts}`, ts, kind: 'model', label: String(parsed.model ?? 'model') };
|
|
1103
|
+
if (kind === 'thinking_level_change') {
|
|
1104
|
+
return { id: entryId || `think-${ts}`, ts, kind: 'think', label: String(parsed.level ?? 'thinking') };
|
|
1105
|
+
}
|
|
1106
|
+
if (kind === 'session')
|
|
1107
|
+
return { id: entryId || `session-${ts}`, ts, kind: 'session', label: 'session started' };
|
|
1108
|
+
// Only the changes ride along: a session fires hundreds of events that would bury the diamonds.
|
|
1109
|
+
return null;
|
|
1110
|
+
}
|
|
1111
|
+
/** The empty summary, for a file that no longer reads. */
|
|
1112
|
+
function emptyTraceSummary() {
|
|
1113
|
+
return { wallMs: 0, modelMs: 0, toolMs: 0, idleMs: 0, turns: 0, requests: 0, toolCalls: 0, agents: 0, totalTokens: 0, costUsd: 0 };
|
|
1114
|
+
}
|
|
1115
|
+
/** The nine figures: wall time split into model, tool, and idle, then the work counts. */
|
|
1116
|
+
function summarizeTrace(spans, startedAt, endedAt) {
|
|
1117
|
+
const wallMs = endedAt > startedAt ? endedAt - startedAt : 0;
|
|
1118
|
+
let modelMs = 0;
|
|
1119
|
+
let toolMs = 0;
|
|
1120
|
+
let totalTokens = 0;
|
|
1121
|
+
let costUsd = 0;
|
|
1122
|
+
for (const span of spans) {
|
|
1123
|
+
const held = Math.max(0, span.end - span.start);
|
|
1124
|
+
if (span.kind === 'model')
|
|
1125
|
+
modelMs += held;
|
|
1126
|
+
if (span.kind === 'tool')
|
|
1127
|
+
toolMs += held;
|
|
1128
|
+
if (span.kind === 'model')
|
|
1129
|
+
totalTokens += span.tokens;
|
|
1130
|
+
if (span.kind === 'model')
|
|
1131
|
+
costUsd += span.costUsd;
|
|
1132
|
+
}
|
|
1133
|
+
return {
|
|
1134
|
+
wallMs,
|
|
1135
|
+
modelMs,
|
|
1136
|
+
toolMs,
|
|
1137
|
+
// Idle is what no span covered: wall time minus the time a model or a tool held.
|
|
1138
|
+
idleMs: Math.max(0, wallMs - modelMs - toolMs),
|
|
1139
|
+
turns: spans.filter((span) => span.kind === 'turn').length,
|
|
1140
|
+
requests: spans.filter((span) => span.kind === 'model').length,
|
|
1141
|
+
toolCalls: spans.filter((span) => span.kind === 'tool').length,
|
|
1142
|
+
agents: spans.filter((span) => span.kind === 'tool' && AGENT_TOOLS.has(span.tool)).length,
|
|
1143
|
+
totalTokens,
|
|
1144
|
+
costUsd,
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
/** Per-tool duration totals, longest first. */
|
|
1148
|
+
function buildToolStats(spans) {
|
|
1149
|
+
const byTool = new Map();
|
|
1150
|
+
for (const span of spans) {
|
|
1151
|
+
if (span.kind !== 'tool')
|
|
1152
|
+
continue;
|
|
1153
|
+
const held = Math.max(0, span.end - span.start);
|
|
1154
|
+
const list = byTool.get(span.tool || 'tool') ?? [];
|
|
1155
|
+
list.push(held);
|
|
1156
|
+
byTool.set(span.tool || 'tool', list);
|
|
1157
|
+
}
|
|
1158
|
+
return [...byTool.entries()]
|
|
1159
|
+
.map(([tool, held]) => {
|
|
1160
|
+
const sorted = held.toSorted((a, b) => a - b);
|
|
1161
|
+
const totalMs = sorted.reduce((sum, value) => sum + value, 0);
|
|
1162
|
+
return {
|
|
1163
|
+
tool,
|
|
1164
|
+
calls: held.length,
|
|
1165
|
+
errors: spans.filter((span) => span.kind === 'tool' && (span.tool || 'tool') === tool && span.isError).length,
|
|
1166
|
+
totalMs,
|
|
1167
|
+
avgMs: totalMs / Math.max(1, sorted.length),
|
|
1168
|
+
maxMs: sorted[held.length - 1] ?? 0,
|
|
1169
|
+
};
|
|
1170
|
+
})
|
|
1171
|
+
.toSorted((a, b) => b.totalMs - a.totalMs);
|
|
1172
|
+
}
|
|
1173
|
+
/** The payload a request with no readable entry returns, so the route always answers with one shape. */
|
|
1174
|
+
export function emptyRequestEntry() {
|
|
1175
|
+
return { entry: null, output: null, messageRole: '', agent: { model: null, thinkingLevel: null, mode: null, fallback: null } };
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* One request's journal payload, read on demand by the request drawer. `entry` is the raw
|
|
1179
|
+
* journal line, `output` is the message that line carries, and `agent` is the agent state in
|
|
1180
|
+
* force when the request ran. All are empty when the entry journals nothing: a `model_usage`
|
|
1181
|
+
* row carries no message, and a deleted session cannot be re-read.
|
|
1182
|
+
*/
|
|
1183
|
+
export function readAgentRequestEntry(sessionFile, entryId) {
|
|
1184
|
+
const empty = emptyRequestEntry();
|
|
1185
|
+
if (!entryId || !sessionFile.endsWith('.jsonl') || !fs.existsSync(sessionFile))
|
|
1186
|
+
return empty;
|
|
1187
|
+
let text;
|
|
1188
|
+
try {
|
|
1189
|
+
text = fs.readFileSync(sessionFile, 'utf8');
|
|
1190
|
+
}
|
|
1191
|
+
catch {
|
|
1192
|
+
return empty;
|
|
1193
|
+
}
|
|
1194
|
+
const agent = { ...empty.agent };
|
|
1195
|
+
for (const line of text.split('\n')) {
|
|
1196
|
+
if (!line.trim())
|
|
1197
|
+
continue;
|
|
1198
|
+
let parsed;
|
|
1199
|
+
try {
|
|
1200
|
+
parsed = JSON.parse(line);
|
|
1201
|
+
}
|
|
1202
|
+
catch {
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
if (String(parsed.id ?? '') !== entryId) {
|
|
1206
|
+
// A change entry updates the state every later request sees.
|
|
1207
|
+
if (parsed.type === 'model_change') {
|
|
1208
|
+
agent.model = String(parsed.model ?? '') || null;
|
|
1209
|
+
agent.fallback = parsed.resolvedModelIsFallback === true;
|
|
1210
|
+
}
|
|
1211
|
+
else if (parsed.type === 'thinking_level_change') {
|
|
1212
|
+
agent.thinkingLevel = String(parsed.thinkingLevel ?? '') || null;
|
|
1213
|
+
}
|
|
1214
|
+
else if (parsed.type === 'mode_change') {
|
|
1215
|
+
agent.mode = String(parsed.mode ?? '') || null;
|
|
1216
|
+
}
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
const message = (parsed.message ?? null);
|
|
1220
|
+
return { entry: parsed, output: message, messageRole: message ? String(message.role ?? '') : '', agent };
|
|
1221
|
+
}
|
|
1222
|
+
return empty;
|
|
1223
|
+
}
|
|
1224
|
+
/** Reads both omp databases. `available` is false when stats.db holds no `messages` table. */
|
|
1225
|
+
export function readAgentStats(range = '24h', view = 'all', now = Date.now()) {
|
|
1226
|
+
const span = RANGE_MS[range];
|
|
1227
|
+
const cutoff = span > 0 ? now - span : 0;
|
|
1228
|
+
const parts = new Set(VIEW_PARTS[view]);
|
|
1229
|
+
const stats = emptyAgentStats(range);
|
|
1230
|
+
stats.cutoff = cutoff;
|
|
1231
|
+
const db = statsDbPath();
|
|
1232
|
+
const cols = fs.existsSync(db) ? tableColumns(db, 'messages') : new Set();
|
|
1233
|
+
if (cols.size === 0) {
|
|
1234
|
+
if (parts.has('windows'))
|
|
1235
|
+
Object.assign(stats, computeWindows(readWindowSamples(cutoff), new Map()));
|
|
1236
|
+
return stats;
|
|
1237
|
+
}
|
|
1238
|
+
// The bucket width depends on the span, so the bounds come first.
|
|
1239
|
+
const bounds = readBlocks(db, [['bounds', `SELECT MIN(timestamp), MAX(timestamp) FROM messages ${rangeWhere(cutoff)}`]]);
|
|
1240
|
+
const [firstTs, lastTs] = bounds.get('bounds')?.[0] ?? [];
|
|
1241
|
+
stats.bucketMs = bucketSize(range, numOrNull(firstTs), numOrNull(lastTs));
|
|
1242
|
+
Object.assign(stats, readStatsDb(db, cutoff, stats.bucketMs, cols, parts));
|
|
1243
|
+
if (parts.has('seriesByProvider'))
|
|
1244
|
+
stats.seriesByProvider = readProviderSeries(db, cutoff, stats.bucketMs);
|
|
1245
|
+
if (parts.has('modelSeries'))
|
|
1246
|
+
stats.modelSeries = readModelSeries(db, cutoff, stats.bucketMs);
|
|
1247
|
+
if (parts.has('modelPerformance'))
|
|
1248
|
+
stats.modelPerformance = readModelPerformance(db, cutoff, stats.bucketMs);
|
|
1249
|
+
if (parts.has('tools'))
|
|
1250
|
+
Object.assign(stats, readToolSeries(db, cutoff, stats.bucketMs));
|
|
1251
|
+
if (stats.byModel.length > 0)
|
|
1252
|
+
stats.topModels = stats.byModel.slice(0, 12);
|
|
1253
|
+
if (parts.has('providerHourly'))
|
|
1254
|
+
stats.providerHourly = readProviderHourly(db, cutoff);
|
|
1255
|
+
if (parts.has('recent'))
|
|
1256
|
+
stats.requestStats = computeRequestStats(stats.recent);
|
|
1257
|
+
if (parts.has('windows')) {
|
|
1258
|
+
Object.assign(stats, computeWindows(readWindowSamples(cutoff), readProviderTokens(db, cutoff)));
|
|
1259
|
+
}
|
|
1260
|
+
stats.available = true;
|
|
1261
|
+
return stats;
|
|
1262
|
+
}
|