@krtclcdy/tersio 2.25.5 → 2.25.6
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 +17 -36
- package/dashboard/dist/index.html +15 -15
- package/dist/cli/backups.js +7 -14
- package/dist/cli/banner.js +4 -3
- package/dist/cli/common.js +28 -12
- package/dist/cli/currency.js +1 -1
- package/dist/cli/dashboard.js +66 -35
- package/dist/cli/doctor-fix.js +204 -19
- package/dist/cli/doctor.js +226 -39
- package/dist/cli/hosts.js +25 -5
- package/dist/cli/install.js +144 -48
- package/dist/cli/interactive.js +3 -3
- package/dist/cli/manifest.js +87 -7
- package/dist/cli/profile.js +13 -8
- package/dist/cli/rtk-wiring.js +1 -1
- package/dist/cli/settings.js +20 -5
- package/dist/cli/uninstall.js +3 -3
- package/dist/cli/update.js +6 -7
- package/dist/cli/usage.js +6 -9
- package/dist/extensions/caveman/index.js +4 -2
- package/dist/extensions/caveman/rule-megacave.md +5 -3
- package/dist/extensions/caveman/rule-ultra.md +4 -4
- package/dist/extensions/caveman/rule.md +55 -45
- package/dist/extensions/combo/index.js +62 -21
- package/dist/extensions/commands/index.js +1 -1
- package/dist/extensions/pstack/index.js +119 -0
- package/dist/extensions/pstack/rule.md +65 -0
- package/dist/extensions/rtk/filter.js +0 -1
- package/dist/extensions/rtk/session.js +4 -4
- package/dist/extensions/server.js +90 -6
- package/dist/extensions/shared/agent-stats.js +214 -278
- package/dist/extensions/shared/brand-marks.js +33 -34
- package/dist/extensions/shared/carbon.js +1 -1
- package/dist/extensions/shared/plugin-settings.js +11 -6
- package/dist/extensions/shared/pricing.js +8 -16
- package/dist/extensions/shared/rtk-gain.js +4 -9
- package/dist/extensions/shared/session-state.js +15 -10
- package/dist/extensions/shared/sqlite.js +168 -0
- package/dist/extensions/shared/status.js +8 -8
- package/dist/extensions/shared/types.js +1 -1
- package/dist/extensions/shared/usage-ledger.js +53 -41
- package/dist/extensions/shared/usage-store.js +111 -52
- package/dist/extensions/shared/utils.js +5 -5
- package/dist/extensions/timing/index.js +31 -0
- package/dist/extensions/updater/index.js +3 -6
- package/dist/tersio.js +8 -3
- package/extensions/caveman/index.ts +4 -2
- package/extensions/caveman/rule-megacave.md +5 -3
- package/extensions/caveman/rule-ultra.md +4 -4
- package/extensions/caveman/rule.md +55 -45
- package/extensions/combo/index.ts +62 -19
- package/extensions/commands/index.ts +1 -1
- package/extensions/pstack/index.ts +128 -0
- package/extensions/pstack/rule.md +65 -0
- package/extensions/rtk/filter.ts +0 -1
- package/extensions/rtk/session.ts +4 -4
- package/extensions/server.ts +72 -7
- package/extensions/shared/agent-stats.ts +226 -278
- package/extensions/shared/brand-marks.ts +34 -34
- package/extensions/shared/carbon.ts +1 -1
- package/extensions/shared/plugin-settings.ts +12 -6
- package/extensions/shared/pricing.ts +10 -26
- package/extensions/shared/rtk-gain.ts +4 -9
- package/extensions/shared/session-state.ts +18 -13
- package/extensions/shared/sqlite.ts +168 -0
- package/extensions/shared/status.ts +9 -8
- package/extensions/shared/types.ts +2 -1
- package/extensions/shared/usage-ledger.ts +60 -47
- package/extensions/shared/usage-store.ts +120 -52
- package/extensions/shared/utils.ts +5 -5
- package/extensions/timing/index.ts +45 -0
- package/extensions/updater/index.ts +3 -8
- package/package.json +17 -8
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Usage ledger: append-only JSON lines, read by the CLI, `/tersio`, and the Dashboard. Corrupt lines are skipped.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { homeDir, isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from './utils.js';
|
|
5
4
|
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { homeDir, isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from './utils.js';
|
|
6
|
+
import { sqliteRead } from './sqlite.js';
|
|
6
7
|
export function ledgerPath() {
|
|
7
8
|
const override = process.env.TERSIO_USAGE_FILE;
|
|
8
9
|
if (override)
|
|
@@ -50,7 +51,7 @@ function stampMs(ts) {
|
|
|
50
51
|
export function durOf(v) {
|
|
51
52
|
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
52
53
|
}
|
|
53
|
-
// Measured cost: usage.cost.total, or a bare number in old fixtures. Anything else means unrecorded,
|
|
54
|
+
// Measured cost: usage.cost.total, or a bare number in old fixtures. Anything else means unrecorded, not 0.
|
|
54
55
|
export function costOf(usage) {
|
|
55
56
|
const c = usage.cost;
|
|
56
57
|
if (typeof c === 'number')
|
|
@@ -87,7 +88,7 @@ export function sessionsDirs() {
|
|
|
87
88
|
const override = process.env.TERSIO_SESSIONS_DIR;
|
|
88
89
|
if (override)
|
|
89
90
|
return [override];
|
|
90
|
-
// Both hosts, not just the running one: a session
|
|
91
|
+
// Both hosts, not just the running one: a pi session is missing if we stop at the first existing directory.
|
|
91
92
|
const dirs = isPiProcess()
|
|
92
93
|
? [path.join(piAgentDir(), 'sessions'), path.join(homeDir(), '.omp', 'agent', 'sessions')]
|
|
93
94
|
: [path.join(homeDir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
|
|
@@ -129,22 +130,20 @@ export function opencodeDbPath() {
|
|
|
129
130
|
catch { /* fall through to the platform default */ }
|
|
130
131
|
return path.join(homeDir(), 'Library', 'Application Support', 'opencode', 'opencode.db');
|
|
131
132
|
}
|
|
132
|
-
//
|
|
133
|
-
// current `session_message` (role in the `type` column, model under data.model).
|
|
134
|
-
// Scalars only: the `data` blob holds newlines and tabs, which the sqlite3 CLI would split.
|
|
133
|
+
// `message` vs `session_message`: read scalars only, since the blob's newlines break the row-per-line output.
|
|
135
134
|
const OC_DB_ORDER = ' ORDER BY time_created;';
|
|
136
|
-
const OC_DB_COLUMNS = 'json_extract(data,\'$.time.created\'), json_extract(data,\'$.time.completed\'),' +
|
|
135
|
+
const OC_DB_COLUMNS = 'json_extract(data,\'$.time.created\'), json_extract(data,\'$.time.streamed\'), json_extract(data,\'$.time.completed\'),' +
|
|
137
136
|
' json_extract(data,\'$.tokens.input\'), json_extract(data,\'$.tokens.output\'),' +
|
|
138
137
|
' json_extract(data,\'$.tokens.cache.read\'), json_extract(data,\'$.tokens.cache.write\'), json_extract(data,\'$.cost\')';
|
|
139
|
-
const
|
|
138
|
+
const OC_DB_TOOLS = '(SELECT group_concat(json_extract(j.value,\'$.name\')) FROM json_each(data,\'$.content\') j' +
|
|
139
|
+
' WHERE json_extract(j.value,\'$.type\')=\'tool\')';
|
|
140
|
+
const ocDbLegacy = (since) => `SELECT id, time_created, json_extract(data,'$.role'), json_extract(data,'$.providerID'), json_extract(data,'$.modelID'), ${OC_DB_COLUMNS}, ${OC_DB_TOOLS}` +
|
|
140
141
|
` FROM message WHERE json_extract(data,'$.role')='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
|
|
141
|
-
const ocDbCurrent = (since) => `SELECT id, time_created, 'assistant', json_extract(data,'$.model.providerID'), json_extract(data,'$.model.id'), ${OC_DB_COLUMNS}` +
|
|
142
|
+
const ocDbCurrent = (since) => `SELECT id, time_created, 'assistant', json_extract(data,'$.model.providerID'), json_extract(data,'$.model.id'), ${OC_DB_COLUMNS}, ${OC_DB_TOOLS}` +
|
|
142
143
|
` FROM session_message WHERE type='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
|
|
143
144
|
// A re-scan window covers tokens that land after the row was created, so a late fill is not lost.
|
|
144
145
|
export const OPENCODE_DB_OVERLAP_MS = 3_600_000;
|
|
145
|
-
//
|
|
146
|
-
export const SQLITE_READ_BUFFER = 50 * 1024 * 1024;
|
|
147
|
-
// Empty when sqlite3 is absent or the db is unreadable; that is a sync skip, not a failure.
|
|
146
|
+
// Empty when no SQLite is available or the db is unreadable; that is a sync skip, not a failure.
|
|
148
147
|
export function readOpencodeDbRows(db, sinceMs) {
|
|
149
148
|
if (!Number.isFinite(sinceMs))
|
|
150
149
|
return [];
|
|
@@ -153,7 +152,7 @@ export function readOpencodeDbRows(db, sinceMs) {
|
|
|
153
152
|
return [];
|
|
154
153
|
const since = Math.max(0, Math.floor(sinceMs));
|
|
155
154
|
// Separate calls: a db holding only one table must not cancel the other.
|
|
156
|
-
const run = (sql) =>
|
|
155
|
+
const run = (sql) => sqliteRead(db, sql);
|
|
157
156
|
let out = '';
|
|
158
157
|
try {
|
|
159
158
|
out += run(ocDbLegacy(since));
|
|
@@ -167,17 +166,18 @@ export function readOpencodeDbRows(db, sinceMs) {
|
|
|
167
166
|
for (const line of out.split('\n')) {
|
|
168
167
|
if (!line.trim())
|
|
169
168
|
continue;
|
|
170
|
-
const [id, created, role, providerID, modelID, tCreated, tCompleted, input, output, cacheRead, cacheWrite, cost] = line.split('\t');
|
|
169
|
+
const [id, created, role, providerID, modelID, tCreated, tStreamed, tCompleted, input, output, cacheRead, cacheWrite, cost, tools] = line.split('\t');
|
|
171
170
|
const n = (v) => (v !== undefined && v !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined);
|
|
172
171
|
rows.push({
|
|
173
172
|
id,
|
|
174
173
|
created: Number(created),
|
|
174
|
+
tools: tools ?? '',
|
|
175
175
|
row: {
|
|
176
176
|
role,
|
|
177
177
|
modelID,
|
|
178
178
|
providerID,
|
|
179
179
|
tokens: { input: n(input), output: n(output), cache: { read: n(cacheRead), write: n(cacheWrite) } },
|
|
180
|
-
time: { created: n(tCreated), completed: n(tCompleted) },
|
|
180
|
+
time: { created: n(tCreated), streamed: n(tStreamed), completed: n(tCompleted) },
|
|
181
181
|
cost: n(cost),
|
|
182
182
|
},
|
|
183
183
|
});
|
|
@@ -225,7 +225,7 @@ const RTK_ELIGIBLE_HEADS = new Set([
|
|
|
225
225
|
export function newSessionAccum() {
|
|
226
226
|
return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, byAgent: {}, byAgentMessages: {}, byModelMeasured: {}, byModelCostMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [], recentFloor: Number.POSITIVE_INFINITY };
|
|
227
227
|
}
|
|
228
|
-
export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host, id, agent) {
|
|
228
|
+
export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host, id, agent, ttftMs) {
|
|
229
229
|
const watermark = readResetWatermark();
|
|
230
230
|
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
231
231
|
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
|
|
@@ -250,11 +250,9 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames,
|
|
|
250
250
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
251
251
|
if (Number.isFinite(ms)) {
|
|
252
252
|
const outcome = run ?? { st: 'completed' };
|
|
253
|
-
//
|
|
254
|
-
// anything older than the floor, and re-read the floor when the list is full, so a 50k-row
|
|
255
|
-
// store costs 2000 rows of memory instead of 50k.
|
|
253
|
+
// Only the newest rows are read: skip below the floor, and a 50k-row store costs 2000 rows of memory.
|
|
256
254
|
if (accum.recent.length < RECENT_LIMIT || ms >= accum.recentFloor) {
|
|
257
|
-
accum.recent.push({ m: key, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), h: host, cr: num(usage.cacheRead), cw: num(usage.cacheWrite), usd: costOf(usage), st: outcome.st, code: outcome.code, note: outcome.note, id });
|
|
255
|
+
accum.recent.push({ m: key, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), tf: durOf(ttftMs), h: host, cr: num(usage.cacheRead), cw: num(usage.cacheWrite), usd: costOf(usage), st: outcome.st, code: outcome.code, note: outcome.note, id });
|
|
258
256
|
if (accum.recent.length > RECENT_LIMIT) {
|
|
259
257
|
let drop = 0;
|
|
260
258
|
for (let k = 1; k < accum.recent.length; k += 1)
|
|
@@ -326,7 +324,7 @@ export function classifySessionLine(row) {
|
|
|
326
324
|
const command = part.arguments?.command;
|
|
327
325
|
tools.push(`bash:${leadBinary(command)}`);
|
|
328
326
|
}
|
|
329
|
-
return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
|
|
327
|
+
return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, ttftMs: typeof msg.ttft === 'number' ? msg.ttft : undefined, run: statusOf(msg), tools };
|
|
330
328
|
}
|
|
331
329
|
// True when at least one token bucket holds a positive finite count.
|
|
332
330
|
export function hasPositiveUsage(usage) {
|
|
@@ -337,6 +335,25 @@ export function hasPositiveUsage(usage) {
|
|
|
337
335
|
}
|
|
338
336
|
return false;
|
|
339
337
|
}
|
|
338
|
+
/** Tool names an OpenCode message called; v2 passes SQL's comma-joined string as the blob holds newlines. */
|
|
339
|
+
export function opencodeToolNames(message, joined) {
|
|
340
|
+
if (joined !== undefined)
|
|
341
|
+
return joined === '' ? [] : joined.split(',');
|
|
342
|
+
const parts = message?.content;
|
|
343
|
+
if (!Array.isArray(parts))
|
|
344
|
+
return [];
|
|
345
|
+
const names = [];
|
|
346
|
+
for (const part of parts) {
|
|
347
|
+
if (!part || typeof part !== 'object')
|
|
348
|
+
continue;
|
|
349
|
+
if (part.type !== 'tool')
|
|
350
|
+
continue;
|
|
351
|
+
const name = part.name;
|
|
352
|
+
if (typeof name === 'string' && name)
|
|
353
|
+
names.push(name);
|
|
354
|
+
}
|
|
355
|
+
return names;
|
|
356
|
+
}
|
|
340
357
|
// One OpenCode message file; assistant rows carry the token buckets.
|
|
341
358
|
export function classifyOpencodeMessage(obj) {
|
|
342
359
|
if (!obj || typeof obj !== 'object' || obj.role !== 'assistant')
|
|
@@ -358,14 +375,16 @@ export function classifyOpencodeMessage(obj) {
|
|
|
358
375
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
|
|
359
376
|
const created = num(obj.time?.created);
|
|
360
377
|
const completed = num(obj.time?.completed);
|
|
378
|
+
// `streamed` is when the first token arrived, so it is the host's own first-token latency.
|
|
379
|
+
const streamed = num(obj.time?.streamed);
|
|
361
380
|
return {
|
|
362
381
|
model: `opencode/${provider}/${model}`,
|
|
363
382
|
usage,
|
|
364
383
|
ms: created,
|
|
365
384
|
durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
|
|
385
|
+
ttftMs: created !== undefined && streamed !== undefined && streamed > created ? streamed - created : undefined,
|
|
366
386
|
};
|
|
367
387
|
}
|
|
368
|
-
// One OpenCode message file into the shared accum. The msg id is the file name.
|
|
369
388
|
export function processOpencodeFile(accum, text, file) {
|
|
370
389
|
let obj;
|
|
371
390
|
try {
|
|
@@ -374,18 +393,19 @@ export function processOpencodeFile(accum, text, file) {
|
|
|
374
393
|
catch {
|
|
375
394
|
return;
|
|
376
395
|
}
|
|
377
|
-
const
|
|
396
|
+
const message = obj;
|
|
397
|
+
const parsed = classifyOpencodeMessage(message);
|
|
378
398
|
if (!parsed)
|
|
379
399
|
return;
|
|
380
400
|
const id = file ? path.basename(file).replace(/\.[^.]+$/, '') || undefined : undefined;
|
|
381
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' },
|
|
401
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, opencodeToolNames(message), 'opencode', id, 'main', parsed.ttftMs);
|
|
382
402
|
}
|
|
383
403
|
// One v2 message row into the shared accum. The msg id is the row primary key.
|
|
384
404
|
export function ingestOpencodeDbRow(accum, row) {
|
|
385
405
|
const parsed = classifyOpencodeMessage(row.row);
|
|
386
406
|
if (!parsed)
|
|
387
407
|
return;
|
|
388
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' },
|
|
408
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, opencodeToolNames(row.row, row.tools), 'opencode', row.id, 'main', parsed.ttftMs);
|
|
389
409
|
}
|
|
390
410
|
// Fold `:free`/`-free` suffixes and case variants into one chart key.
|
|
391
411
|
export function canonicalModelId(model) {
|
|
@@ -396,8 +416,7 @@ export function messageRowId(row) {
|
|
|
396
416
|
const id = row.id;
|
|
397
417
|
return typeof id === 'string' && id ? id : undefined;
|
|
398
418
|
}
|
|
399
|
-
// Vendor names mirror the dashboard map;
|
|
400
|
-
// gateway segments shadow it (minimaxai holds xai, gemma rides nvidia).
|
|
419
|
+
// Vendor names mirror the dashboard map; family first, so gateway segments shadow it (minimaxai holds xai).
|
|
401
420
|
const VENDOR_RES = [
|
|
402
421
|
[/inclusionai|ling-/i, 'InclusionAI'],
|
|
403
422
|
[/minimax/i, 'MiniMax'],
|
|
@@ -533,9 +552,7 @@ export function hostOfSessionFile(file) {
|
|
|
533
552
|
return 'opencode';
|
|
534
553
|
return 'pi';
|
|
535
554
|
}
|
|
536
|
-
//
|
|
537
|
-
// session is a subagent's, and everything else is the main agent. The row is the host's own
|
|
538
|
-
// record, so a mirrored transcript carries the same answer as the stats database.
|
|
555
|
+
// A transcript with a parent session ran as a subagent; the host's own row, so a mirror answers the same.
|
|
539
556
|
export function agentOfSessionText(text) {
|
|
540
557
|
if (!text.includes('parentSession'))
|
|
541
558
|
return 'main';
|
|
@@ -652,7 +669,7 @@ export function processSessionText(accum, state, text, host, agent) {
|
|
|
652
669
|
}
|
|
653
670
|
if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
|
|
654
671
|
continue;
|
|
655
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid, agent);
|
|
672
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid, agent, parsed.ttftMs);
|
|
656
673
|
}
|
|
657
674
|
catch { /* skip corrupt lines */ }
|
|
658
675
|
}
|
|
@@ -696,16 +713,12 @@ export function leadBinary(command) {
|
|
|
696
713
|
}
|
|
697
714
|
return 'bash';
|
|
698
715
|
}
|
|
699
|
-
//
|
|
716
|
+
// Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table).
|
|
700
717
|
import { DEFAULT_PRICE, PRICE_TIERS_FIRM, canonicalPriceId, priceFor, pricesOverridePath, refreshPricesIfStale } from './pricing.js';
|
|
701
718
|
import { co2GramsFor, energyWhFor } from './carbon.js';
|
|
702
719
|
export { DEFAULT_PRICE, PRICE_TIERS_FIRM, priceFor, pricesOverridePath, refreshPricesIfStale };
|
|
703
720
|
export { co2GramsFor, energyWhFor };
|
|
704
|
-
/**
|
|
705
|
-
* Price one model's tokens. `measured` is the cost the host reported for those tokens; it outranks
|
|
706
|
-
* every rate card, because it is the only figure from the party that billed. Pass undefined rather
|
|
707
|
-
* than 0 for a cost nobody reported: `costOf` keeps the two apart.
|
|
708
|
-
*/
|
|
721
|
+
/** Price one model's tokens; `measured` outranks every rate card, so pass undefined when unreported. */
|
|
709
722
|
export function usdCost(t, model, measured) {
|
|
710
723
|
const m = 1 / 1_000_000;
|
|
711
724
|
if (measured !== undefined) {
|
|
@@ -727,8 +740,7 @@ export function usdCost(t, model, measured) {
|
|
|
727
740
|
};
|
|
728
741
|
return {
|
|
729
742
|
usd: buckets.input + buckets.output + buckets.cacheRead + buckets.cacheWrite,
|
|
730
|
-
//
|
|
731
|
-
// and a report that sums them beside measured cost reads as a bill it is not.
|
|
743
|
+
// `tail`/`default` are estimates; summing them beside measured cost reads as a bill it is not.
|
|
732
744
|
priced: PRICE_TIERS_FIRM.includes(hit.tier),
|
|
733
745
|
tier: hit.tier,
|
|
734
746
|
buckets,
|
|
@@ -759,7 +771,7 @@ export function readUsage() {
|
|
|
759
771
|
}
|
|
760
772
|
return rows;
|
|
761
773
|
}
|
|
762
|
-
// Delete the ledger file and return the rows cleared. Tersio-owned data only: transcripts and the RTK database
|
|
774
|
+
// Delete the ledger file and return the rows cleared. Tersio-owned data only: transcripts and the RTK database stay.
|
|
763
775
|
export function clearUsageLedger() {
|
|
764
776
|
const rows = readUsage().length;
|
|
765
777
|
try {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
1
|
+
// usage-store.ts — tersio-owned usage.db. A cache of the live parse, not a fork; no SQLite → sync false / read null.
|
|
3
2
|
import fs from 'node:fs';
|
|
4
3
|
import path from 'node:path';
|
|
5
|
-
import {
|
|
4
|
+
import { sqliteExec, sqliteRead } from './sqlite.js';
|
|
5
|
+
import { RECENT_LIMIT, canonicalModelId, classifySessionLine, classifyOpencodeMessage, costOf, durOf, agentOfSessionText, hostOfSessionFile, ingestSessionRow, messageRowId, OPENCODE_DB_OVERLAP_MS, opencodeDbPath, opencodeSessionsDir, newSessionAccum, opencodeToolNames, readOpencodeDbRows, sessionsDirs, walkJsonl, } from './usage-ledger.js';
|
|
6
6
|
import { tersioDataPath } from './utils.js';
|
|
7
7
|
import { MODEL_ALIASES } from './pricing.js';
|
|
8
8
|
import { tersioSettingsFile } from './plugin-settings.js';
|
|
@@ -24,25 +24,21 @@ function nullNum(v) {
|
|
|
24
24
|
function nullStr(v) {
|
|
25
25
|
return v === undefined ? 'NULL' : esc(v);
|
|
26
26
|
}
|
|
27
|
+
// A failed INSERT stops before COMMIT, so it leaves no DELETEs behind.
|
|
27
28
|
function run(db, sql) {
|
|
28
|
-
|
|
29
|
-
execFileSync('sqlite3', ['-bail', db, sql], { stdio: ['ignore', 'ignore', 'ignore'], timeout: 30000 });
|
|
29
|
+
sqliteExec(db, sql);
|
|
30
30
|
}
|
|
31
31
|
function query(db, sql) {
|
|
32
|
-
|
|
33
|
-
encoding: 'utf8',
|
|
34
|
-
timeout: 30000,
|
|
35
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
36
|
-
maxBuffer: SQLITE_READ_BUFFER,
|
|
37
|
-
});
|
|
38
|
-
return out
|
|
32
|
+
return sqliteRead(db, sql)
|
|
39
33
|
.split('\n')
|
|
40
34
|
.filter((line) => line.trim())
|
|
41
35
|
.map((line) => line.split('\t'));
|
|
42
36
|
}
|
|
43
37
|
// Bump on a parse change: unchanged transcripts are never re-read.
|
|
44
|
-
const PARSER_VERSION = '
|
|
45
|
-
|
|
38
|
+
const PARSER_VERSION = '13';
|
|
39
|
+
/** OpenCode rows gained tool names; re-read by id, since a PARSER_VERSION bump drops rotated history. */
|
|
40
|
+
const OPENCODE_TOOLS_VERSION = '1';
|
|
41
|
+
// A re-parse keeping under half the rows means transcripts vanished mid-migration; the backup is restored instead.
|
|
46
42
|
const REPARSE_GUARD_RATIO = 0.5;
|
|
47
43
|
let guardReport = null;
|
|
48
44
|
/** Why the last sync refused to publish a re-parse, if it did. */
|
|
@@ -52,18 +48,14 @@ export function reparseGuardReport() {
|
|
|
52
48
|
const SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);` +
|
|
53
49
|
`CREATE TABLE IF NOT EXISTS files (id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT UNIQUE NOT NULL, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
|
|
54
50
|
`CREATE TABLE IF NOT EXISTS messages (file_id INTEGER NOT NULL REFERENCES files(id), t REAL, model TEXT NOT NULL,` +
|
|
55
|
-
` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
|
|
51
|
+
` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, tf REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
|
|
56
52
|
` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT, a TEXT, id TEXT);` +
|
|
57
|
-
// Every read filters or orders by one of these three
|
|
58
|
-
// walks the whole table: the watermark scan, the day buckets, and the recent list all scan.
|
|
53
|
+
// Every read filters or orders by one of these three; without them each sync and read scans the table.
|
|
59
54
|
`CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file_id);` +
|
|
60
55
|
`CREATE INDEX IF NOT EXISTS idx_messages_t ON messages(t);` +
|
|
61
56
|
`CREATE INDEX IF NOT EXISTS idx_messages_h_t ON messages(h, t);` +
|
|
62
57
|
`CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);`;
|
|
63
|
-
/**
|
|
64
|
-
* WAL plus a checkpoint at the end of a sync: the write path stops paying an fsync per commit,
|
|
65
|
-
* and the main file still holds every row, so a backup that copies only usage.db loses nothing.
|
|
66
|
-
*/
|
|
58
|
+
/** WAL + a checkpoint: the write path avoids an fsync per commit, and usage.db alone holds every row. */
|
|
67
59
|
function pragmas(db) {
|
|
68
60
|
run(db, 'PRAGMA journal_mode=WAL;' +
|
|
69
61
|
'PRAGMA synchronous=NORMAL;' +
|
|
@@ -81,7 +73,7 @@ function storedParserVersion(db) {
|
|
|
81
73
|
return null;
|
|
82
74
|
}
|
|
83
75
|
}
|
|
84
|
-
// A store written by a newer tersio:
|
|
76
|
+
// A store written by a newer tersio: no forward migration, so a re-parse would swap rotated history for what is left.
|
|
85
77
|
function isDowngrade(stored) {
|
|
86
78
|
if (stored === null)
|
|
87
79
|
return false;
|
|
@@ -109,6 +101,16 @@ function newestOpencodeMs(db) {
|
|
|
109
101
|
return null;
|
|
110
102
|
}
|
|
111
103
|
}
|
|
104
|
+
/** Whether the OpenCode tools backfill ran; unfilled rows stay empty, so this widens the scan once. */
|
|
105
|
+
function opencodeToolsBackfilled(db) {
|
|
106
|
+
try {
|
|
107
|
+
const rows = query(db, `SELECT v FROM meta WHERE k='opencode_tools';`);
|
|
108
|
+
return rows.length > 0 && rows[0][0] === OPENCODE_TOOLS_VERSION;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
112
114
|
// Same pre-migration state as the newest backup: another copy churns the 3-slot rotation for nothing.
|
|
113
115
|
function newestBackupMatches(stored) {
|
|
114
116
|
if (stored === null)
|
|
@@ -124,7 +126,7 @@ function newestBackupMatches(stored) {
|
|
|
124
126
|
return false;
|
|
125
127
|
}
|
|
126
128
|
}
|
|
127
|
-
// v9 shape (path-keyed rows) migrates in place with zero re-parse, so history survives even
|
|
129
|
+
// v9 shape (path-keyed rows) migrates in place with zero re-parse, so history survives even without transcripts.
|
|
128
130
|
function canMigrateInPlace(db) {
|
|
129
131
|
try {
|
|
130
132
|
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
@@ -141,7 +143,7 @@ function migrateInPlace(db, preCount) {
|
|
|
141
143
|
`CREATE TABLE files_new (id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT UNIQUE NOT NULL, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
|
|
142
144
|
`INSERT INTO files_new (path, mtime, size) SELECT path, mtime, size FROM files;` +
|
|
143
145
|
`CREATE TABLE messages_new (file_id INTEGER NOT NULL REFERENCES files_new(id), t REAL, model TEXT NOT NULL,` +
|
|
144
|
-
` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
|
|
146
|
+
` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, tf REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
|
|
145
147
|
` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT, a TEXT, id TEXT);` +
|
|
146
148
|
`INSERT INTO messages_new (file_id, ${cols}) SELECT (SELECT id FROM files_new WHERE files_new.path = messages.file), ${cols} FROM messages;` +
|
|
147
149
|
`DROP TABLE messages;DROP TABLE files;` +
|
|
@@ -160,9 +162,7 @@ function migrateInPlace(db, preCount) {
|
|
|
160
162
|
return false;
|
|
161
163
|
}
|
|
162
164
|
}
|
|
163
|
-
// v12
|
|
164
|
-
// store already holds. No rows move and no re-parse is forced, so the existing rows read
|
|
165
|
-
// `NULL` for it: a report then shows them under the main agent only, and new rows carry it.
|
|
165
|
+
// v12 adds the agent kind with a bare ALTER; existing rows read NULL and report under the main agent.
|
|
166
166
|
function canAddAgent(db) {
|
|
167
167
|
try {
|
|
168
168
|
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
@@ -172,8 +172,6 @@ function canAddAgent(db) {
|
|
|
172
172
|
return false;
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
|
-
// v12 shape gains the agent kind (`main`, `subagent`) with a bare ALTER: no rows move, so no
|
|
176
|
-
// re-parse is forced. Existing rows read NULL, which a report folds under the main agent.
|
|
177
175
|
function addAgentColumn(db) {
|
|
178
176
|
try {
|
|
179
177
|
run(db, `ALTER TABLE messages ADD COLUMN a TEXT;`);
|
|
@@ -183,6 +181,44 @@ function addAgentColumn(db) {
|
|
|
183
181
|
}
|
|
184
182
|
return true;
|
|
185
183
|
}
|
|
184
|
+
// v13 shape gains the first-token latency with a bare ALTER, so no row moves and no wipe runs.
|
|
185
|
+
function canAddTtft(db) {
|
|
186
|
+
try {
|
|
187
|
+
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
188
|
+
return names.includes('file_id') && !names.includes('tf');
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// The column is additive, and a rotated transcript cannot be re-parsed, so the ALTER precedes the backfill.
|
|
195
|
+
function addTtftColumn(db) {
|
|
196
|
+
try {
|
|
197
|
+
run(db, `ALTER TABLE messages ADD COLUMN tf REAL;`);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
return !canAddTtft(db);
|
|
203
|
+
}
|
|
204
|
+
/** First-token-latency marker; unchanged transcripts are never revisited, so old stores stay null. */
|
|
205
|
+
const TTFT_VERSION = '1';
|
|
206
|
+
function ttftBackfilled(db) {
|
|
207
|
+
try {
|
|
208
|
+
const rows = query(db, `SELECT v FROM meta WHERE k='ttft_backfill';`);
|
|
209
|
+
return rows.length > 0 && rows[0][0] === TTFT_VERSION;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Clears ledger mtimes so every transcript re-reads; synthetic `db#row-id` OpenCode keys stay untouched. */
|
|
216
|
+
function markTranscriptsDirty(db) {
|
|
217
|
+
try {
|
|
218
|
+
run(db, `UPDATE files SET mtime = 0 WHERE path NOT LIKE '%#%';`);
|
|
219
|
+
}
|
|
220
|
+
catch { /* a store with no files table yet, or a read-only file: nothing to backfill */ }
|
|
221
|
+
}
|
|
186
222
|
// v10 shape gains the message id with a bare ALTER: no rows move, so no re-parse and no guard trip.
|
|
187
223
|
function canAlterInPlace(db) {
|
|
188
224
|
try {
|
|
@@ -233,10 +269,14 @@ function ensureSchema(db, forceWipe) {
|
|
|
233
269
|
run(db, `INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
234
270
|
return;
|
|
235
271
|
}
|
|
236
|
-
// The agent column is additive
|
|
237
|
-
// unchanged transcripts that already hold their rows simply read NULL until a re-parse.
|
|
272
|
+
// The agent column is additive: every row survives and unchanged transcripts read NULL until a re-parse.
|
|
238
273
|
if (stored !== null && canAddAgent(db) && addAgentColumn(db)) {
|
|
239
|
-
run(db, `INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
274
|
+
run(db, `INSERT OR REPLACE INTO meta (k, v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// The ttft column is additive too; its values come from the backfill in `syncUsageDb`.
|
|
278
|
+
if (stored !== null && canAddTtft(db) && addTtftColumn(db)) {
|
|
279
|
+
run(db, `INSERT OR REPLACE INTO meta (k, v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
240
280
|
return;
|
|
241
281
|
}
|
|
242
282
|
run(db, `DROP TABLE IF EXISTS messages;DROP TABLE IF EXISTS files;` + SCHEMA_SQL +
|
|
@@ -347,22 +387,22 @@ function readFiles(db) {
|
|
|
347
387
|
catch { /* fresh db */ }
|
|
348
388
|
return known;
|
|
349
389
|
}
|
|
350
|
-
// OpenCode rows carry
|
|
351
|
-
|
|
352
|
-
function opencodeStoredRow(oc, host, id) {
|
|
390
|
+
// OpenCode rows carry only tokens, msg id, and tools; they record as the main agent, so no NULL kind.
|
|
391
|
+
function opencodeStoredRow(oc, host, id, tools) {
|
|
353
392
|
return {
|
|
354
393
|
t: oc.ms ?? null,
|
|
355
394
|
model: canonicalModelId(oc.model),
|
|
356
395
|
i: intOf(oc.usage.input),
|
|
357
396
|
o: intOf(oc.usage.output),
|
|
358
397
|
d: durOf(oc.durMs),
|
|
398
|
+
tf: durOf(oc.ttftMs),
|
|
359
399
|
cr: intOf(oc.usage.cacheRead),
|
|
360
400
|
cw: intOf(oc.usage.cacheWrite),
|
|
361
401
|
usd: costOf(oc.usage),
|
|
362
402
|
st: 'completed',
|
|
363
403
|
code: undefined,
|
|
364
404
|
note: undefined,
|
|
365
|
-
tools
|
|
405
|
+
tools,
|
|
366
406
|
host,
|
|
367
407
|
agent: 'main',
|
|
368
408
|
id,
|
|
@@ -373,13 +413,14 @@ function parseFile(text, host, file, agent) {
|
|
|
373
413
|
// Guarded: a JSONL transcript must fall through, not abort the sync.
|
|
374
414
|
let oc = null;
|
|
375
415
|
try {
|
|
376
|
-
|
|
416
|
+
const message = JSON.parse(text);
|
|
417
|
+
oc = classifyOpencodeMessage(message);
|
|
418
|
+
if (oc) {
|
|
419
|
+
rows.push(opencodeStoredRow(oc, host, file ? path.basename(file).replace(/\.[^.]+$/, '') : undefined, opencodeToolNames(message)));
|
|
420
|
+
return rows;
|
|
421
|
+
}
|
|
377
422
|
}
|
|
378
423
|
catch { /* JSONL */ }
|
|
379
|
-
if (oc) {
|
|
380
|
-
rows.push(opencodeStoredRow(oc, host, file ? path.basename(file).replace(/\.[^.]+$/, '') : undefined));
|
|
381
|
-
return rows;
|
|
382
|
-
}
|
|
383
424
|
let codexProvider = null;
|
|
384
425
|
let codexModel = null;
|
|
385
426
|
for (const line of text.split('\n')) {
|
|
@@ -411,6 +452,7 @@ function parseFile(text, host, file, agent) {
|
|
|
411
452
|
i: intOf(usage.input),
|
|
412
453
|
o: intOf(usage.output),
|
|
413
454
|
d: undefined,
|
|
455
|
+
tf: undefined,
|
|
414
456
|
cr: intOf(usage.cacheRead),
|
|
415
457
|
cw: intOf(usage.cacheWrite),
|
|
416
458
|
usd: costOf(usage),
|
|
@@ -436,6 +478,7 @@ function parseFile(text, host, file, agent) {
|
|
|
436
478
|
i: intOf(usage.input),
|
|
437
479
|
o: intOf(usage.output),
|
|
438
480
|
d: dur,
|
|
481
|
+
tf: durOf(parsed.ttftMs),
|
|
439
482
|
cr: intOf(usage.cacheRead),
|
|
440
483
|
cw: intOf(usage.cacheWrite),
|
|
441
484
|
usd: costOf(usage),
|
|
@@ -453,12 +496,12 @@ function parseFile(text, host, file, agent) {
|
|
|
453
496
|
return rows;
|
|
454
497
|
}
|
|
455
498
|
function insertSql(file, r) {
|
|
456
|
-
return `INSERT INTO messages (file_id, t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, a, id) VALUES (` +
|
|
499
|
+
return `INSERT INTO messages (file_id, t, model, i, o, d, tf, cr, cw, usd, st, code, note, tools, h, a, id) VALUES (` +
|
|
457
500
|
`(SELECT id FROM files WHERE path=${esc(file)}),${r.t === null ? 'NULL' : String(r.t)},${esc(r.model)},${r.i},${r.o},` +
|
|
458
|
-
`${nullNum(r.d)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
|
|
501
|
+
`${nullNum(r.d)},${nullNum(r.tf)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
|
|
459
502
|
`${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))},${nullStr(r.host)},${nullStr(r.agent ?? undefined)},${nullStr(r.id)});`;
|
|
460
503
|
}
|
|
461
|
-
// Unchanged transcripts are skipped via mtime+size; rows for deleted ones are kept so rotation never erases history.
|
|
504
|
+
// Unchanged transcripts are skipped via mtime+size; rows for deleted ones are kept so rotation never erases history.
|
|
462
505
|
export function syncUsageDb() {
|
|
463
506
|
let db;
|
|
464
507
|
guardReport = null;
|
|
@@ -477,6 +520,9 @@ export function syncUsageDb() {
|
|
|
477
520
|
if (migrated && !forced)
|
|
478
521
|
preWipe = countMessages(db);
|
|
479
522
|
ensureSchema(db, forced);
|
|
523
|
+
// One rewrite so the additive column's NULL rows gain latency; runs while the marker is absent.
|
|
524
|
+
if (!downgrade && !ttftBackfilled(db))
|
|
525
|
+
markTranscriptsDirty(db);
|
|
480
526
|
maybeScheduledBackup(db);
|
|
481
527
|
}
|
|
482
528
|
catch {
|
|
@@ -501,8 +547,10 @@ export function syncUsageDb() {
|
|
|
501
547
|
const ocDbPath = opencodeDbPath();
|
|
502
548
|
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DB !== undefined) {
|
|
503
549
|
const newest = newestOpencodeMs(db);
|
|
550
|
+
// A store predating the tool names re-reads the whole table once; the marker closes the window.
|
|
551
|
+
const since = newest === null || !opencodeToolsBackfilled(db) ? 0 : newest - OPENCODE_DB_OVERLAP_MS;
|
|
504
552
|
if (newest !== null || fs.existsSync(ocDbPath)) {
|
|
505
|
-
ocDbRows.push(...readOpencodeDbRows(ocDbPath,
|
|
553
|
+
ocDbRows.push(...readOpencodeDbRows(ocDbPath, since));
|
|
506
554
|
}
|
|
507
555
|
}
|
|
508
556
|
const known = readFiles(db);
|
|
@@ -522,6 +570,7 @@ export function syncUsageDb() {
|
|
|
522
570
|
if (!prev || prev.mtime !== entry.mtime || prev.size !== entry.size)
|
|
523
571
|
changed.push(file);
|
|
524
572
|
}
|
|
573
|
+
// `ocDbRows` is non-empty whenever the backfill window is open, so this never skips it.
|
|
525
574
|
if (!changed.length && !ocDbRows.length)
|
|
526
575
|
return true;
|
|
527
576
|
const chunks = [];
|
|
@@ -571,7 +620,7 @@ export function syncUsageDb() {
|
|
|
571
620
|
if (!push(`INSERT INTO files (path, mtime, size) VALUES (${esc(key)},${row.created},0)` +
|
|
572
621
|
` ON CONFLICT(path) DO UPDATE SET mtime=excluded.mtime;` +
|
|
573
622
|
`DELETE FROM messages WHERE file_id=(SELECT id FROM files WHERE path=${esc(key)});` +
|
|
574
|
-
insertSql(key, opencodeStoredRow(parsed, 'opencode', row.id))))
|
|
623
|
+
insertSql(key, opencodeStoredRow(parsed, 'opencode', row.id, opencodeToolNames(row.row, row.tools)))))
|
|
575
624
|
return false;
|
|
576
625
|
}
|
|
577
626
|
}
|
|
@@ -580,6 +629,16 @@ export function syncUsageDb() {
|
|
|
580
629
|
}
|
|
581
630
|
if (!push(`INSERT OR REPLACE INTO meta (k, v) VALUES ('last_sync',${esc(String(Date.now()))});`))
|
|
582
631
|
return false;
|
|
632
|
+
// Only once the rows are written, so a sync that fails midway widens the scan again.
|
|
633
|
+
if (ocDbRows.length > 0) {
|
|
634
|
+
if (!push(`INSERT OR REPLACE INTO meta (k, v) VALUES ('opencode_tools','${OPENCODE_TOOLS_VERSION}');`))
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
// Set only after the rewrite commits: a crash mid-backfill leaves mtimes cleared and the marker absent.
|
|
638
|
+
if (changed.length > 0) {
|
|
639
|
+
if (!push(`INSERT OR REPLACE INTO meta (k, v) VALUES ('ttft_backfill','${TTFT_VERSION}');`))
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
583
642
|
if (!flush())
|
|
584
643
|
return false;
|
|
585
644
|
if (preWipe > 0) {
|
|
@@ -617,18 +676,18 @@ export function readUsageDb() {
|
|
|
617
676
|
}
|
|
618
677
|
let rows;
|
|
619
678
|
try {
|
|
620
|
-
// The `tools` column is
|
|
621
|
-
// read as one column and skipped in JS rather than parsed per row. A store written before
|
|
622
|
-
// the agent column does not hold it, so the row is read without that column.
|
|
679
|
+
// The wide `tools` column is read as one string and skipped in JS; a pre-agent store is read without it.
|
|
623
680
|
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
624
681
|
const agentCol = names.includes('a') ? 'a' : "''";
|
|
625
|
-
|
|
682
|
+
// A store predating first-token latency has no `tf` column; read NULL so the row still loads.
|
|
683
|
+
const tfCol = names.includes('tf') ? 'tf' : 'NULL';
|
|
684
|
+
rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, ${agentCol}, id, ${tfCol} FROM messages;`);
|
|
626
685
|
}
|
|
627
686
|
catch {
|
|
628
687
|
return null;
|
|
629
688
|
}
|
|
630
689
|
const accum = newSessionAccum();
|
|
631
|
-
for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, a, id] of rows) {
|
|
690
|
+
for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, a, id, tf] of rows) {
|
|
632
691
|
const ts = t === '' ? undefined : Number(t);
|
|
633
692
|
let toolNames = [];
|
|
634
693
|
if (tools && tools !== '[]' && tools !== '') {
|
|
@@ -654,7 +713,7 @@ export function readUsageDb() {
|
|
|
654
713
|
st: st === 'error' || st === 'aborted' ? st : 'completed',
|
|
655
714
|
code: code === '' ? undefined : Number(code),
|
|
656
715
|
note: note === '' ? undefined : note,
|
|
657
|
-
}, toolNames, h === '' ? undefined : h, id === '' || id === undefined ? undefined : id, a === '' ? undefined : a);
|
|
716
|
+
}, toolNames, h === '' ? undefined : h, id === '' || id === undefined ? undefined : id, a === '' ? undefined : a, tf === undefined || tf === '' ? undefined : Number(tf));
|
|
658
717
|
}
|
|
659
718
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
660
719
|
let syncedAt = 0;
|