@krtclcdy/tersio 2.25.4 → 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 (77) hide show
  1. package/README.md +27 -63
  2. package/dashboard/dist/index.html +18 -16
  3. package/dist/cli/backups.js +187 -0
  4. package/dist/cli/banner.js +4 -3
  5. package/dist/cli/common.js +29 -13
  6. package/dist/cli/currency.js +1 -1
  7. package/dist/cli/dashboard.js +168 -110
  8. package/dist/cli/doctor-fix.js +232 -50
  9. package/dist/cli/doctor.js +281 -32
  10. package/dist/cli/hosts.js +29 -9
  11. package/dist/cli/install.js +204 -69
  12. package/dist/cli/interactive.js +3 -3
  13. package/dist/cli/manifest.js +125 -17
  14. package/dist/cli/profile.js +14 -9
  15. package/dist/cli/rtk-wiring.js +1 -1
  16. package/dist/cli/settings.js +20 -5
  17. package/dist/cli/uninstall.js +24 -8
  18. package/dist/cli/update.js +9 -10
  19. package/dist/cli/usage.js +75 -7
  20. package/dist/extensions/{caveman-session → caveman}/index.js +4 -2
  21. package/{extensions/caveman-session → dist/extensions/caveman}/rule-megacave.md +5 -3
  22. package/{extensions/caveman-session → dist/extensions/caveman}/rule-ultra.md +4 -4
  23. package/dist/extensions/caveman/rule.md +89 -0
  24. package/dist/extensions/{combo-toggle → combo}/index.js +62 -21
  25. package/dist/extensions/{tersio-commands → commands}/index.js +2 -2
  26. package/dist/extensions/pstack/index.js +119 -0
  27. package/dist/extensions/pstack/rule.md +65 -0
  28. package/dist/extensions/{rtk-filter/index.js → rtk/filter.js} +1 -2
  29. package/dist/extensions/{rtk-session/index.js → rtk/session.js} +5 -5
  30. package/dist/extensions/{opencode/server.js → server.js} +98 -14
  31. package/dist/extensions/shared/agent-stats.js +1198 -0
  32. package/dist/extensions/shared/brand-marks.js +153 -0
  33. package/dist/extensions/shared/carbon.js +1 -1
  34. package/dist/extensions/shared/plugin-settings.js +12 -7
  35. package/dist/extensions/shared/pricing.js +86 -30
  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 +113 -39
  42. package/dist/extensions/shared/usage-store.js +191 -48
  43. package/dist/extensions/{lib → shared}/utils.js +6 -6
  44. package/dist/extensions/timing/index.js +31 -0
  45. package/dist/extensions/{ai-addons-updater → updater}/index.js +13 -6
  46. package/dist/probe-catalog.js +48 -0
  47. package/dist/probe-catalog188.js +44 -0
  48. package/dist/tersio.js +9 -4
  49. package/extensions/{caveman-session → caveman}/index.ts +4 -2
  50. package/{dist/extensions/caveman-session → extensions/caveman}/rule-megacave.md +5 -3
  51. package/{dist/extensions/caveman-session → extensions/caveman}/rule-ultra.md +4 -4
  52. package/extensions/caveman/rule.md +89 -0
  53. package/extensions/{combo-toggle → combo}/index.ts +62 -19
  54. package/extensions/{tersio-commands → commands}/index.ts +2 -2
  55. package/extensions/pstack/index.ts +128 -0
  56. package/extensions/pstack/rule.md +65 -0
  57. package/extensions/{rtk-filter/index.ts → rtk/filter.ts} +1 -2
  58. package/extensions/{rtk-session/index.ts → rtk/session.ts} +5 -5
  59. package/extensions/{opencode/server.ts → server.ts} +81 -16
  60. package/extensions/shared/agent-stats.ts +1605 -0
  61. package/extensions/shared/brand-marks.ts +163 -0
  62. package/extensions/shared/carbon.ts +1 -1
  63. package/extensions/shared/plugin-settings.ts +13 -7
  64. package/extensions/shared/pricing.ts +95 -32
  65. package/extensions/shared/rtk-gain.ts +4 -9
  66. package/extensions/shared/session-state.ts +18 -13
  67. package/extensions/shared/sqlite.ts +168 -0
  68. package/extensions/shared/status.ts +9 -8
  69. package/extensions/shared/types.ts +2 -1
  70. package/extensions/shared/usage-ledger.ts +134 -46
  71. package/extensions/shared/usage-store.ts +202 -50
  72. package/extensions/{lib → shared}/utils.ts +6 -6
  73. package/extensions/timing/index.ts +45 -0
  74. package/extensions/{ai-addons-updater → updater}/index.ts +15 -9
  75. package/package.json +27 -18
  76. package/dist/extensions/caveman-session/rule.md +0 -79
  77. package/extensions/caveman-session/rule.md +0 -79
@@ -0,0 +1,168 @@
1
+ // One place that runs SQL against a database file.
2
+ //
3
+ // Bun and Node 22.5+ carry SQLite in process, so the `sqlite3` binary is a fallback rather than a
4
+ // requirement: hosts without it kept silently reading nothing. Reads come back exactly as the sqlite3
5
+ // CLI printed them -- rows on their own lines, cells joined by tabs, NULL an empty cell, one trailing
6
+ // newline -- because every caller already parses that shape. The engine rungs take one statement per
7
+ // read, so a caller with several queries calls once per query.
8
+ //
9
+ // The write path leaves the journal mode alone. The store's own schema sets WAL so a reader (the
10
+ // dashboard) never blocks a writer, and changing the mode needs exclusive access: with a reader holding
11
+ // the file the pragma fails, which is a failure reported after the caller's rows have already committed.
12
+ import { execFileSync } from 'node:child_process';
13
+ import fs from 'node:fs';
14
+ import { createRequire } from 'node:module';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ /** A read this large used to vanish behind a 1MB cap, which cleared on one busy session_message table. */
18
+ const SQLITE_READ_BUFFER = 50 * 1024 * 1024;
19
+ const TIMEOUT = 30_000;
20
+ const requireOptional = createRequire(import.meta.url);
21
+ let cached;
22
+ let cachedFor;
23
+ /** Bun's SQLite, or Node's when this build carries one. Null when neither runtime has an engine. */
24
+ function engine() {
25
+ const asked = process.env.TERSIO_SQLITE ?? '';
26
+ if (cached !== undefined && cachedFor === asked)
27
+ return cached;
28
+ cachedFor = asked;
29
+ cached = null;
30
+ if (asked === 'cli')
31
+ return cached;
32
+ try {
33
+ const { Database } = requireOptional('bun:sqlite');
34
+ cached = {
35
+ kind: 'bun',
36
+ read: (db, sql) => { const d = new Database(db, { readonly: true }); try {
37
+ return d.query(sql).values();
38
+ }
39
+ finally {
40
+ d.close();
41
+ } },
42
+ exec: (db, sql) => { const d = new Database(db); try {
43
+ d.exec(sql);
44
+ }
45
+ finally {
46
+ d.close();
47
+ } },
48
+ };
49
+ return cached;
50
+ }
51
+ catch { /* not Bun, or no SQLite in this build */ }
52
+ try {
53
+ const { DatabaseSync } = requireOptional('node:sqlite');
54
+ // Rows arrive keyed by column name unless the statement returns arrays. A duplicate name collapses
55
+ // its key and shifts every later cell, so a Node that cannot return arrays is no engine at all.
56
+ const probe = new DatabaseSync(':memory:');
57
+ try {
58
+ if (typeof probe.prepare('SELECT 1').setReturnArrays !== 'function')
59
+ throw new Error('no positional rows');
60
+ }
61
+ finally {
62
+ probe.close();
63
+ }
64
+ cached = {
65
+ kind: 'node',
66
+ read: (db, sql) => {
67
+ const d = new DatabaseSync(db, { readOnly: true });
68
+ try {
69
+ const st = d.prepare(sql);
70
+ st.setReturnArrays?.(true);
71
+ return st.all();
72
+ }
73
+ finally {
74
+ d.close();
75
+ }
76
+ },
77
+ exec: (db, sql) => { const d = new DatabaseSync(db); try {
78
+ d.exec(sql);
79
+ }
80
+ finally {
81
+ d.close();
82
+ } },
83
+ };
84
+ return cached;
85
+ }
86
+ catch { /* Node older than 22.5, or one built without SQLite or positional rows */ }
87
+ return cached;
88
+ }
89
+ /** The `sqlite3` binary, resolved once from PATH so a missing one costs one scan, not one spawn per call. */
90
+ let binary;
91
+ function findBinary() {
92
+ if (binary !== undefined)
93
+ return binary;
94
+ binary = null;
95
+ for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
96
+ if (!dir)
97
+ continue;
98
+ for (const name of process.platform === 'win32' ? ['sqlite3.exe', 'sqlite3'] : ['sqlite3']) {
99
+ const at = path.join(dir, name);
100
+ try {
101
+ fs.accessSync(at, fs.constants.X_OK);
102
+ binary = at;
103
+ return binary;
104
+ }
105
+ catch { /* not here */ }
106
+ }
107
+ }
108
+ return binary;
109
+ }
110
+ /** `bun`, `node`, `cli`, or null when this host has no way to read SQLite at all. */
111
+ export function sqliteEngine() {
112
+ return engine()?.kind ?? (findBinary() === null ? null : 'cli');
113
+ }
114
+ /** Whether any path can run SQL: what the tests guard their skips on. */
115
+ export function sqliteAvailable() {
116
+ return sqliteEngine() !== null;
117
+ }
118
+ /** Rows as the CLI prints them, so a caller cannot tell which engine answered. */
119
+ function toTsv(rows) {
120
+ if (rows.length === 0)
121
+ return '';
122
+ return `${rows.map((row) => row.map((cell) => (cell === null || cell === undefined ? '' : String(cell))).join('\t')).join('\n')}\n`;
123
+ }
124
+ function cliRead(db, sql) {
125
+ const opts = { encoding: 'utf8', timeout: TIMEOUT, stdio: ['pipe', 'pipe', 'ignore'], maxBuffer: SQLITE_READ_BUFFER, input: sql };
126
+ try {
127
+ return execFileSync('sqlite3', ['-separator', '\t', '-readonly', db], opts);
128
+ }
129
+ catch { /* the side files may be gone; try the next rung */ }
130
+ if (!fs.existsSync(`${db}-wal`)) {
131
+ try {
132
+ return execFileSync('sqlite3', ['-separator', '\t', `file:${db}?immutable=1`], opts);
133
+ }
134
+ catch { /* fall through to the copy */ }
135
+ }
136
+ // A live database cannot be read without its write-ahead log, so copy the set and read the copy.
137
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tersio-sqlite-'));
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
+ /** Run a read and return the tab-separated rows. Throws when the file is missing, as the callers expect. */
152
+ export function sqliteRead(db, sql) {
153
+ if (!fs.existsSync(db))
154
+ throw new Error(`missing database: ${db}`);
155
+ const e = engine();
156
+ if (e !== null)
157
+ return toTsv(e.read(db, sql));
158
+ return cliRead(db, sql);
159
+ }
160
+ /** Run a script, creating the file if needed. Stops at the first error, so a failed INSERT leaves no DELETEs. */
161
+ export function sqliteExec(db, sql) {
162
+ const e = engine();
163
+ if (e !== null) {
164
+ e.exec(db, sql);
165
+ return;
166
+ }
167
+ execFileSync('sqlite3', ['-bail', db, sql], { stdio: ['ignore', 'ignore', 'ignore'], timeout: TIMEOUT });
168
+ }
@@ -1,16 +1,16 @@
1
- // One status string every surface renders: host footer bar, CLI menu title, dashboard banner. Nothing else formats mode state.
1
+ // One status string every surface renders: host footer, CLI title, dashboard banner. Nothing else formats mode state.
2
2
  import { getSharedComboState } from './session-state.js';
3
- /** OFF collapses the three modes into one word; a preset or a mix names itself. */
3
+ /** OFF collapses every mode into one word; a preset or a mix names itself. */
4
4
  export function formatStatus(state) {
5
- const { level, caveman, rtk, ponytail } = state;
6
- if (level === 'off' && caveman === 'off' && rtk === 'off' && ponytail === 'off')
5
+ const { level, caveman, rtk, ponytail, pstack } = state;
6
+ if (level === 'off' && caveman === 'off' && rtk === 'off' && ponytail === 'off' && pstack === 'off')
7
7
  return '🧩 combo OFF';
8
8
  const label = level === 'custom' ? 'CUSTOM' : level.toUpperCase();
9
- return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()}`;
9
+ return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()} 🥔pstack=${pstack.toUpperCase()}`;
10
10
  }
11
- // The string upstream's skill tells the model to relay for `/caveman status`.
11
+ // The string upstream's skill tells the model to relay for `/caveman status`; `off` is a mode, so it reads as one.
12
12
  export function formatCavemanStatus(mode) {
13
- return `Caveman mode: ${mode && mode !== 'off' ? mode : 'unknown'}`;
13
+ return `Caveman mode: ${mode || 'unknown'}`;
14
14
  }
15
15
  // Stale ctx access throws, so never let it abort a mode restore.
16
16
  function statusUi(ctx) {
@@ -27,6 +27,7 @@ let remembered;
27
27
  let lastText;
28
28
  let lastUi;
29
29
  // Paint the host footer bar; same text on the same UI is skipped, a new ctx repaints it.
30
+ // One key only: the combo line already names the caveman mode, and a second key renders as a second footer line.
30
31
  export function announceStatus(ctx) {
31
32
  const state = getSharedComboState();
32
33
  const text = formatStatus(state);
@@ -40,5 +41,4 @@ export function announceStatus(ctx) {
40
41
  lastText = text;
41
42
  lastUi = ui;
42
43
  ui.setStatus?.('tersio', text);
43
- ui.setStatus?.('caveman', formatCavemanStatus(state.caveman));
44
44
  }
@@ -1,2 +1,2 @@
1
- // Shared OMP extension host types. Structural — the real `pi` object comes from the OMP runtime; these describe the surface this package touches.
1
+ // Shared OMP extension host types. Structural — the real `pi` object comes from the OMP runtime.
2
2
  export {};
@@ -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 '../lib/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
  });
@@ -223,9 +223,9 @@ const RTK_ELIGIBLE_HEADS = new Set([
223
223
  'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
224
224
  ]);
225
225
  export function newSessionAccum() {
226
- return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
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) {
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))
@@ -240,10 +240,31 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames,
240
240
  hs[host] ??= zeroBreakdown();
241
241
  addInto(hs[host], usage);
242
242
  }
243
+ if (agent) {
244
+ const as = (accum.byAgent[key] ??= {});
245
+ as[agent] ??= zeroBreakdown();
246
+ addInto(as[agent], usage);
247
+ const am = (accum.byAgentMessages[key] ??= {});
248
+ am[agent] = (am[agent] ?? 0) + 1;
249
+ }
243
250
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
244
251
  if (Number.isFinite(ms)) {
245
252
  const outcome = run ?? { st: 'completed' };
246
- 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 });
253
+ // Only the newest rows are read: skip below the floor, and a 50k-row store costs 2000 rows of memory.
254
+ if (accum.recent.length < RECENT_LIMIT || ms >= accum.recentFloor) {
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 });
256
+ if (accum.recent.length > RECENT_LIMIT) {
257
+ let drop = 0;
258
+ for (let k = 1; k < accum.recent.length; k += 1)
259
+ if (accum.recent[k].t < accum.recent[drop].t)
260
+ drop = k;
261
+ accum.recent.splice(drop, 1);
262
+ accum.recentFloor = accum.recent.reduce((lo, r) => Math.min(lo, r.t), Number.POSITIVE_INFINITY);
263
+ }
264
+ else if (ms < accum.recentFloor) {
265
+ accum.recentFloor = ms;
266
+ }
267
+ }
247
268
  }
248
269
  const day = ts !== undefined ? dayKey(ts) : null;
249
270
  if (day) {
@@ -257,8 +278,11 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames,
257
278
  }
258
279
  accum.messages += 1;
259
280
  const measured = costOf(usage);
260
- if (measured !== undefined)
281
+ if (measured !== undefined) {
261
282
  accum.costMeasured += measured;
283
+ accum.byModelMeasured[key] = (accum.byModelMeasured[key] ?? 0) + measured;
284
+ accum.byModelCostMessages[key] = (accum.byModelCostMessages[key] ?? 0) + 1;
285
+ }
262
286
  for (const name of toolNames ?? [])
263
287
  accum.byTool[name] = (accum.byTool[name] ?? 0) + 1;
264
288
  return true;
@@ -300,7 +324,7 @@ export function classifySessionLine(row) {
300
324
  const command = part.arguments?.command;
301
325
  tools.push(`bash:${leadBinary(command)}`);
302
326
  }
303
- 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 };
304
328
  }
305
329
  // True when at least one token bucket holds a positive finite count.
306
330
  export function hasPositiveUsage(usage) {
@@ -311,6 +335,25 @@ export function hasPositiveUsage(usage) {
311
335
  }
312
336
  return false;
313
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
+ }
314
357
  // One OpenCode message file; assistant rows carry the token buckets.
315
358
  export function classifyOpencodeMessage(obj) {
316
359
  if (!obj || typeof obj !== 'object' || obj.role !== 'assistant')
@@ -332,14 +375,16 @@ export function classifyOpencodeMessage(obj) {
332
375
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
333
376
  const created = num(obj.time?.created);
334
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);
335
380
  return {
336
381
  model: `opencode/${provider}/${model}`,
337
382
  usage,
338
383
  ms: created,
339
384
  durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
385
+ ttftMs: created !== undefined && streamed !== undefined && streamed > created ? streamed - created : undefined,
340
386
  };
341
387
  }
342
- // One OpenCode message file into the shared accum. The msg id is the file name.
343
388
  export function processOpencodeFile(accum, text, file) {
344
389
  let obj;
345
390
  try {
@@ -348,18 +393,19 @@ export function processOpencodeFile(accum, text, file) {
348
393
  catch {
349
394
  return;
350
395
  }
351
- const parsed = classifyOpencodeMessage(obj);
396
+ const message = obj;
397
+ const parsed = classifyOpencodeMessage(message);
352
398
  if (!parsed)
353
399
  return;
354
400
  const id = file ? path.basename(file).replace(/\.[^.]+$/, '') || undefined : undefined;
355
- ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode', id);
401
+ ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, opencodeToolNames(message), 'opencode', id, 'main', parsed.ttftMs);
356
402
  }
357
403
  // One v2 message row into the shared accum. The msg id is the row primary key.
358
404
  export function ingestOpencodeDbRow(accum, row) {
359
405
  const parsed = classifyOpencodeMessage(row.row);
360
406
  if (!parsed)
361
407
  return;
362
- ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode', row.id);
408
+ ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, opencodeToolNames(row.row, row.tools), 'opencode', row.id, 'main', parsed.ttftMs);
363
409
  }
364
410
  // Fold `:free`/`-free` suffixes and case variants into one chart key.
365
411
  export function canonicalModelId(model) {
@@ -370,8 +416,7 @@ export function messageRowId(row) {
370
416
  const id = row.id;
371
417
  return typeof id === 'string' && id ? id : undefined;
372
418
  }
373
- // Vendor names mirror the dashboard map; CLI needs names only. Family first:
374
- // 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).
375
420
  const VENDOR_RES = [
376
421
  [/inclusionai|ling-/i, 'InclusionAI'],
377
422
  [/minimax/i, 'MiniMax'],
@@ -491,10 +536,10 @@ export function importSessionTokens() {
491
536
  set codexProvider(v) { codexProvider = v; },
492
537
  get codexModel() { return codexModel; },
493
538
  set codexModel(v) { codexModel = v; },
494
- }, text, hostOfSessionFile(file));
539
+ }, text, hostOfSessionFile(file), agentOfSessionText(text));
495
540
  }
496
541
  accum.recent.sort((a, b) => b.t - a.t);
497
- return { messages: accum.messages, totals: accum.totals, byModel: accum.byModel, byDay: accum.byDay, byDayModel: accum.byDayModel, byTool: accum.byTool, byModelMessages: accum.byModelMessages, byHost: accum.byHost, costMeasured: accum.costMeasured, recent: accum.recent.slice(0, RECENT_LIMIT) };
542
+ return { messages: accum.messages, totals: accum.totals, byModel: accum.byModel, byDay: accum.byDay, byDayModel: accum.byDayModel, byTool: accum.byTool, byModelMessages: accum.byModelMessages, byHost: accum.byHost, byAgent: accum.byAgent, byAgentMessages: accum.byAgentMessages, byModelMeasured: accum.byModelMeasured, byModelCostMessages: accum.byModelCostMessages, costMeasured: accum.costMeasured, recent: accum.recent.slice(0, RECENT_LIMIT) };
498
543
  }
499
544
  // Which agent wrote a session file, from the directory it sits in.
500
545
  export function hostOfSessionFile(file) {
@@ -507,6 +552,22 @@ export function hostOfSessionFile(file) {
507
552
  return 'opencode';
508
553
  return 'pi';
509
554
  }
555
+ // A transcript with a parent session ran as a subagent; the host's own row, so a mirror answers the same.
556
+ export function agentOfSessionText(text) {
557
+ if (!text.includes('parentSession'))
558
+ return 'main';
559
+ for (const line of text.split('\n')) {
560
+ if (!line.includes('parentSession'))
561
+ continue;
562
+ try {
563
+ const row = JSON.parse(line);
564
+ if (row.type === 'session' && typeof row.parentSession === 'string' && row.parentSession !== '')
565
+ return 'subagent';
566
+ }
567
+ catch { /* skip a corrupt line */ }
568
+ }
569
+ return 'main';
570
+ }
510
571
  const adoptionFileCache = new Map();
511
572
  function parseAdoptionFile(file) {
512
573
  const counts = { bashCalls: 0, eligibleCalls: 0, rtkCalls: 0 };
@@ -581,7 +642,7 @@ export function readRtkAdoption() {
581
642
  const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
582
643
  return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
583
644
  }
584
- export function processSessionText(accum, state, text, host) {
645
+ export function processSessionText(accum, state, text, host, agent) {
585
646
  for (const line of text.split('\n')) {
586
647
  if (!line.trim())
587
648
  continue;
@@ -603,12 +664,12 @@ export function processSessionText(accum, state, text, host) {
603
664
  const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
604
665
  // The provider alone told you nothing about which model ran.
605
666
  const model = state.codexModel ? `${base}/${state.codexModel}` : base;
606
- ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' }, undefined, host ?? 'codex', rid);
667
+ ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' }, undefined, host ?? 'codex', rid, agent);
607
668
  continue;
608
669
  }
609
670
  if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
610
671
  continue;
611
- ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid);
672
+ ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid, agent, parsed.ttftMs);
612
673
  }
613
674
  catch { /* skip corrupt lines */ }
614
675
  }
@@ -652,14 +713,25 @@ export function leadBinary(command) {
652
713
  }
653
714
  return 'bash';
654
715
  }
655
- // --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
656
- import { DEFAULT_PRICE, canonicalPriceId, priceFor, refreshPricesIfStale } from './pricing.js';
716
+ // Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table).
717
+ import { DEFAULT_PRICE, PRICE_TIERS_FIRM, canonicalPriceId, priceFor, pricesOverridePath, refreshPricesIfStale } from './pricing.js';
657
718
  import { co2GramsFor, energyWhFor } from './carbon.js';
658
- export { DEFAULT_PRICE, priceFor, refreshPricesIfStale };
719
+ export { DEFAULT_PRICE, PRICE_TIERS_FIRM, priceFor, pricesOverridePath, refreshPricesIfStale };
659
720
  export { co2GramsFor, energyWhFor };
660
- export function usdCost(t, model) {
661
- const { price, known } = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false };
721
+ /** Price one model's tokens; `measured` outranks every rate card, so pass undefined when unreported. */
722
+ export function usdCost(t, model, measured) {
662
723
  const m = 1 / 1_000_000;
724
+ if (measured !== undefined) {
725
+ // Only a bare total arrives, so it attributes to input, the largest bucket for every model here.
726
+ return {
727
+ usd: measured,
728
+ priced: true,
729
+ tier: 'measured',
730
+ buckets: { input: measured, output: 0, cacheRead: 0, cacheWrite: 0 },
731
+ };
732
+ }
733
+ const hit = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false, live: false, tier: 'default' };
734
+ const price = hit.price;
663
735
  const buckets = {
664
736
  input: t.input * price.input * m,
665
737
  output: t.output * price.output * m,
@@ -668,7 +740,9 @@ export function usdCost(t, model) {
668
740
  };
669
741
  return {
670
742
  usd: buckets.input + buckets.output + buckets.cacheRead + buckets.cacheWrite,
671
- priced: model ? known : false,
743
+ // `tail`/`default` are estimates; summing them beside measured cost reads as a bill it is not.
744
+ priced: PRICE_TIERS_FIRM.includes(hit.tier),
745
+ tier: hit.tier,
672
746
  buckets,
673
747
  };
674
748
  }
@@ -697,7 +771,7 @@ export function readUsage() {
697
771
  }
698
772
  return rows;
699
773
  }
700
- // 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.
701
775
  export function clearUsageLedger() {
702
776
  const rows = readUsage().length;
703
777
  try {