@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.
Files changed (73) hide show
  1. package/README.md +17 -36
  2. package/dashboard/dist/index.html +15 -15
  3. package/dist/cli/backups.js +7 -14
  4. package/dist/cli/banner.js +4 -3
  5. package/dist/cli/common.js +28 -12
  6. package/dist/cli/currency.js +1 -1
  7. package/dist/cli/dashboard.js +66 -35
  8. package/dist/cli/doctor-fix.js +204 -19
  9. package/dist/cli/doctor.js +226 -39
  10. package/dist/cli/hosts.js +25 -5
  11. package/dist/cli/install.js +144 -48
  12. package/dist/cli/interactive.js +3 -3
  13. package/dist/cli/manifest.js +87 -7
  14. package/dist/cli/profile.js +13 -8
  15. package/dist/cli/rtk-wiring.js +1 -1
  16. package/dist/cli/settings.js +20 -5
  17. package/dist/cli/uninstall.js +3 -3
  18. package/dist/cli/update.js +6 -7
  19. package/dist/cli/usage.js +6 -9
  20. package/dist/extensions/caveman/index.js +4 -2
  21. package/dist/extensions/caveman/rule-megacave.md +5 -3
  22. package/dist/extensions/caveman/rule-ultra.md +4 -4
  23. package/dist/extensions/caveman/rule.md +55 -45
  24. package/dist/extensions/combo/index.js +62 -21
  25. package/dist/extensions/commands/index.js +1 -1
  26. package/dist/extensions/pstack/index.js +119 -0
  27. package/dist/extensions/pstack/rule.md +65 -0
  28. package/dist/extensions/rtk/filter.js +0 -1
  29. package/dist/extensions/rtk/session.js +4 -4
  30. package/dist/extensions/server.js +90 -6
  31. package/dist/extensions/shared/agent-stats.js +214 -278
  32. package/dist/extensions/shared/brand-marks.js +33 -34
  33. package/dist/extensions/shared/carbon.js +1 -1
  34. package/dist/extensions/shared/plugin-settings.js +11 -6
  35. package/dist/extensions/shared/pricing.js +8 -16
  36. package/dist/extensions/shared/rtk-gain.js +4 -9
  37. package/dist/extensions/shared/session-state.js +15 -10
  38. package/dist/extensions/shared/sqlite.js +168 -0
  39. package/dist/extensions/shared/status.js +8 -8
  40. package/dist/extensions/shared/types.js +1 -1
  41. package/dist/extensions/shared/usage-ledger.js +53 -41
  42. package/dist/extensions/shared/usage-store.js +111 -52
  43. package/dist/extensions/shared/utils.js +5 -5
  44. package/dist/extensions/timing/index.js +31 -0
  45. package/dist/extensions/updater/index.js +3 -6
  46. package/dist/tersio.js +8 -3
  47. package/extensions/caveman/index.ts +4 -2
  48. package/extensions/caveman/rule-megacave.md +5 -3
  49. package/extensions/caveman/rule-ultra.md +4 -4
  50. package/extensions/caveman/rule.md +55 -45
  51. package/extensions/combo/index.ts +62 -19
  52. package/extensions/commands/index.ts +1 -1
  53. package/extensions/pstack/index.ts +128 -0
  54. package/extensions/pstack/rule.md +65 -0
  55. package/extensions/rtk/filter.ts +0 -1
  56. package/extensions/rtk/session.ts +4 -4
  57. package/extensions/server.ts +72 -7
  58. package/extensions/shared/agent-stats.ts +226 -278
  59. package/extensions/shared/brand-marks.ts +34 -34
  60. package/extensions/shared/carbon.ts +1 -1
  61. package/extensions/shared/plugin-settings.ts +12 -6
  62. package/extensions/shared/pricing.ts +10 -26
  63. package/extensions/shared/rtk-gain.ts +4 -9
  64. package/extensions/shared/session-state.ts +18 -13
  65. package/extensions/shared/sqlite.ts +168 -0
  66. package/extensions/shared/status.ts +9 -8
  67. package/extensions/shared/types.ts +2 -1
  68. package/extensions/shared/usage-ledger.ts +60 -47
  69. package/extensions/shared/usage-store.ts +120 -52
  70. package/extensions/shared/utils.ts +5 -5
  71. package/extensions/timing/index.ts +45 -0
  72. package/extensions/updater/index.ts +3 -8
  73. package/package.json +17 -8
@@ -1,8 +1,9 @@
1
- // Shared usage ledger: append-only JSON lines, read by the CLI, `/tersio`, and the Dashboard. Best-effort; corrupt lines are skipped on read.
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, which is deliberately not 0.
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 on pi is missing from the dashboard entirely if we stop at the first directory that exists.
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
- // Two on-disk shapes: legacy `message` (role/modelID/providerID live inside `data`),
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 ocDbLegacy = (since) => `SELECT id, time_created, json_extract(data,'$.role'), json_extract(data,'$.providerID'), json_extract(data,'$.modelID'), ${OC_DB_COLUMNS}` +
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
- // execFileSync caps output at 1MB; a busy table clears it in one SELECT.
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) => execFileSync('sqlite3', ['-separator', '\t', '-readonly', db, sql], { encoding: 'utf8', timeout: 30000, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: SQLITE_READ_BUFFER });
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
- // The store holds every message ever recorded, but only the newest few are ever read. Skip
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 parsed = classifyOpencodeMessage(obj);
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' }, undefined, 'opencode', id, 'main');
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' }, undefined, 'opencode', row.id, 'main');
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; CLI needs names only. Family first:
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
- // Which agent ran a session file, from its own `session` row: a transcript with a parent
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
- // --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
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
- // A tier the caller cannot stand behind is not a price. `tail` and `default` are estimates,
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 are never touched.
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
- // extensions/shared/usage-store.ts — tersio-owned usage.db. A cache of the live parse, never a fork; missing sqlite3 → sync false / read null.
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 { RECENT_LIMIT, SQLITE_READ_BUFFER, canonicalModelId, classifySessionLine, classifyOpencodeMessage, costOf, durOf, agentOfSessionText, hostOfSessionFile, ingestSessionRow, messageRowId, OPENCODE_DB_OVERLAP_MS, opencodeDbPath, opencodeSessionsDir, newSessionAccum, readOpencodeDbRows, sessionsDirs, walkJsonl, } from './usage-ledger.js';
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
- // -bail stops the script before COMMIT, so a failed INSERT never leaves DELETEs behind.
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
- const out = execFileSync('sqlite3', ['-separator', '\t', db, sql], {
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 = '12';
45
- // A re-parse that keeps less than half the rows means transcripts vanished mid-migration; the backup is restored instead of publishing the loss.
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. Without them each sync and each read
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: this code has no forward migration, so a re-parse would swap rotated history for whatever transcripts are left.
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 when transcripts are gone.
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 shape gains the agent kind (`main`, `subagent`) with a bare ALTER for the two names the
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 on the current shape: adding it keeps every row, and the
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 no status, code, note, tool list, or subagent marker: only tokens and the
351
- // msg id. They record as the main agent, so a store never holds a NULL kind.
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
- oc = classifyOpencodeMessage(JSON.parse(text));
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. False when sqlite3 is unavailable.
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, newest === null ? 0 : newest - OPENCODE_DB_OVERLAP_MS));
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 the widest by far and the common case is the literal `[]`, so it 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
- rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, ${agentCol}, id FROM messages;`);
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;