@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
+
18
+ /** A read this large used to vanish behind a 1MB cap, which cleared on one busy session_message table. */
19
+ const SQLITE_READ_BUFFER = 50 * 1024 * 1024;
20
+
21
+ const TIMEOUT = 30_000;
22
+ const requireOptional = createRequire(import.meta.url);
23
+
24
+ interface Engine {
25
+ kind: 'bun' | 'node';
26
+ read(db: string, sql: string): unknown[][];
27
+ exec(db: string, sql: string): void;
28
+ }
29
+
30
+ interface BunDatabase {
31
+ query(sql: string): { values(): unknown[][] };
32
+ exec(sql: string): void;
33
+ close(): void;
34
+ }
35
+
36
+ interface NodeStatement {
37
+ all(): unknown[][];
38
+ setReturnArrays?(on: boolean): unknown;
39
+ }
40
+
41
+ interface NodeDatabase {
42
+ prepare(sql: string): NodeStatement;
43
+ exec(sql: string): void;
44
+ close(): void;
45
+ }
46
+
47
+ let cached: Engine | null | undefined;
48
+ let cachedFor: string | undefined;
49
+
50
+ /** Bun's SQLite, or Node's when this build carries one. Null when neither runtime has an engine. */
51
+ function engine(): Engine | null {
52
+ const asked = process.env.TERSIO_SQLITE ?? '';
53
+ if (cached !== undefined && cachedFor === asked) return cached;
54
+ cachedFor = asked;
55
+ cached = null;
56
+ if (asked === 'cli') return cached;
57
+ try {
58
+ const { Database } = requireOptional('bun:sqlite') as { Database: new (db: string, opts?: { readonly?: boolean }) => BunDatabase };
59
+ cached = {
60
+ kind: 'bun',
61
+ read: (db, sql) => { const d = new Database(db, { readonly: true }); try { return d.query(sql).values(); } finally { d.close(); } },
62
+ exec: (db, sql) => { const d = new Database(db); try { d.exec(sql); } finally { d.close(); } },
63
+ };
64
+ return cached;
65
+ } catch { /* not Bun, or no SQLite in this build */ }
66
+ try {
67
+ const { DatabaseSync } = requireOptional('node:sqlite') as { DatabaseSync: new (db: string, opts?: { readOnly?: boolean }) => NodeDatabase };
68
+ // Rows arrive keyed by column name unless the statement returns arrays. A duplicate name collapses
69
+ // its key and shifts every later cell, so a Node that cannot return arrays is no engine at all.
70
+ const probe = new DatabaseSync(':memory:');
71
+ try {
72
+ if (typeof probe.prepare('SELECT 1').setReturnArrays !== 'function') throw new Error('no positional rows');
73
+ } finally {
74
+ probe.close();
75
+ }
76
+ cached = {
77
+ kind: 'node',
78
+ read: (db, sql) => {
79
+ const d = new DatabaseSync(db, { readOnly: true });
80
+ try {
81
+ const st = d.prepare(sql);
82
+ st.setReturnArrays?.(true);
83
+ return st.all();
84
+ } finally { d.close(); }
85
+ },
86
+ exec: (db, sql) => { const d = new DatabaseSync(db); try { d.exec(sql); } finally { d.close(); } },
87
+ };
88
+ return cached;
89
+ } catch { /* Node older than 22.5, or one built without SQLite or positional rows */ }
90
+ return cached;
91
+ }
92
+
93
+ /** The `sqlite3` binary, resolved once from PATH so a missing one costs one scan, not one spawn per call. */
94
+ let binary: string | null | undefined;
95
+ function findBinary(): string | null {
96
+ if (binary !== undefined) return binary;
97
+ binary = null;
98
+ for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
99
+ if (!dir) continue;
100
+ for (const name of process.platform === 'win32' ? ['sqlite3.exe', 'sqlite3'] : ['sqlite3']) {
101
+ const at = path.join(dir, name);
102
+ try {
103
+ fs.accessSync(at, fs.constants.X_OK);
104
+ binary = at;
105
+ return binary;
106
+ } catch { /* not here */ }
107
+ }
108
+ }
109
+ return binary;
110
+ }
111
+
112
+ /** `bun`, `node`, `cli`, or null when this host has no way to read SQLite at all. */
113
+ export function sqliteEngine(): 'bun' | 'node' | 'cli' | null {
114
+ return engine()?.kind ?? (findBinary() === null ? null : 'cli');
115
+ }
116
+
117
+ /** Whether any path can run SQL: what the tests guard their skips on. */
118
+ export function sqliteAvailable(): boolean {
119
+ return sqliteEngine() !== null;
120
+ }
121
+
122
+ /** Rows as the CLI prints them, so a caller cannot tell which engine answered. */
123
+ function toTsv(rows: unknown[][]): string {
124
+ if (rows.length === 0) return '';
125
+ return `${rows.map((row) => row.map((cell) => (cell === null || cell === undefined ? '' : String(cell))).join('\t')).join('\n')}\n`;
126
+ }
127
+
128
+ function cliRead(db: string, sql: string): string {
129
+ const opts = { encoding: 'utf8' as const, timeout: TIMEOUT, stdio: ['pipe', 'pipe', 'ignore'] as ['pipe', 'pipe', 'ignore'], maxBuffer: SQLITE_READ_BUFFER, input: sql };
130
+ try {
131
+ return execFileSync('sqlite3', ['-separator', '\t', '-readonly', db], opts);
132
+ } catch { /* the side files may be gone; try the next rung */ }
133
+ if (!fs.existsSync(`${db}-wal`)) {
134
+ try {
135
+ return execFileSync('sqlite3', ['-separator', '\t', `file:${db}?immutable=1`], opts);
136
+ } catch { /* fall through to the copy */ }
137
+ }
138
+ // A live database cannot be read without its write-ahead log, so copy the set and read the copy.
139
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tersio-sqlite-'));
140
+ const target = path.join(dir, path.basename(db));
141
+ try {
142
+ fs.copyFileSync(db, target);
143
+ for (const suffix of ['-wal', '-shm']) {
144
+ if (fs.existsSync(`${db}${suffix}`)) fs.copyFileSync(`${db}${suffix}`, `${target}${suffix}`);
145
+ }
146
+ return execFileSync('sqlite3', ['-separator', '\t', '-readonly', target], opts);
147
+ } finally {
148
+ fs.rmSync(dir, { recursive: true, force: true });
149
+ }
150
+ }
151
+
152
+ /** Run a read and return the tab-separated rows. Throws when the file is missing, as the callers expect. */
153
+ export function sqliteRead(db: string, sql: string): string {
154
+ if (!fs.existsSync(db)) throw new Error(`missing database: ${db}`);
155
+ const e = engine();
156
+ if (e !== null) return toTsv(e.read(db, sql));
157
+ return cliRead(db, sql);
158
+ }
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: string, sql: string): void {
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,4 +1,4 @@
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.ts';
3
3
  import type { ExtensionCtx, UiApi } from './types.ts';
4
4
 
@@ -7,19 +7,20 @@ export interface StatusState {
7
7
  caveman: string;
8
8
  rtk: string;
9
9
  ponytail: string;
10
+ pstack: string;
10
11
  }
11
12
 
12
- /** OFF collapses the three modes into one word; a preset or a mix names itself. */
13
+ /** OFF collapses every mode into one word; a preset or a mix names itself. */
13
14
  export function formatStatus(state: StatusState): string {
14
- const { level, caveman, rtk, ponytail } = state;
15
- if (level === 'off' && caveman === 'off' && rtk === 'off' && ponytail === 'off') return '🧩 combo OFF';
15
+ const { level, caveman, rtk, ponytail, pstack } = state;
16
+ if (level === 'off' && caveman === 'off' && rtk === 'off' && ponytail === 'off' && pstack === 'off') return '🧩 combo OFF';
16
17
  const label = level === 'custom' ? 'CUSTOM' : level.toUpperCase();
17
- return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()}`;
18
+ return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()} 🥔pstack=${pstack.toUpperCase()}`;
18
19
  }
19
20
 
20
- // The string upstream's skill tells the model to relay for `/caveman status`.
21
+ // The string upstream's skill tells the model to relay for `/caveman status`; `off` is a mode, so it reads as one.
21
22
  export function formatCavemanStatus(mode: string): string {
22
- return `Caveman mode: ${mode && mode !== 'off' ? mode : 'unknown'}`;
23
+ return `Caveman mode: ${mode || 'unknown'}`;
23
24
  }
24
25
 
25
26
  // Stale ctx access throws, so never let it abort a mode restore.
@@ -38,6 +39,7 @@ let lastText: string | undefined;
38
39
  let lastUi: UiApi | undefined;
39
40
 
40
41
  // Paint the host footer bar; same text on the same UI is skipped, a new ctx repaints it.
42
+ // One key only: the combo line already names the caveman mode, and a second key renders as a second footer line.
41
43
  export function announceStatus(ctx: ExtensionCtx | undefined): void {
42
44
  const state = getSharedComboState();
43
45
  const text = formatStatus(state);
@@ -48,5 +50,4 @@ export function announceStatus(ctx: ExtensionCtx | undefined): void {
48
50
  lastText = text;
49
51
  lastUi = ui;
50
52
  ui.setStatus?.('tersio', text);
51
- ui.setStatus?.('caveman', formatCavemanStatus(state.caveman));
52
53
  }
@@ -1,10 +1,11 @@
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
 
3
3
  export interface ComboState {
4
4
  level: string;
5
5
  caveman: string;
6
6
  rtk: string;
7
7
  ponytail: string;
8
+ pstack: string;
8
9
  }
9
10
 
10
11
  export type ComboLevel = 'off' | 'medium' | 'balanced' | 'max' | 'custom';
@@ -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.ts';
5
4
  import { execFileSync } from 'node:child_process';
5
+ import { homeDir, isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from './utils.ts';
6
+ import { sqliteRead } from './sqlite.ts';
6
7
 
7
8
  export type UsageKind = 'command' | 'toggle' | 'install' | 'update' | 'rtk-audit';
8
9
 
@@ -50,7 +51,7 @@ export function appendUsage(kind: UsageKind, detail: string): void {
50
51
  } catch { /* ledger is best-effort; never break the caller */ }
51
52
  }
52
53
 
53
- // --- Session tokens, tokscale-style ------------------------------------------ Assistant messages in the host transcripts carry usage, model, and a timestamp.
54
+ // Session tokens, tokscale-style: assistant messages in the host transcripts carry usage, model, and a timestamp.
54
55
  export interface TokenBreakdown {
55
56
  input: number;
56
57
  output: number;
@@ -67,6 +68,8 @@ export interface RecentRequest {
67
68
  o: number;
68
69
  t: number;
69
70
  d?: number;
71
+ /** First-token latency, when the host recorded one. */
72
+ tf?: number;
70
73
  /** Which agent wrote the session: pi, omp, or codex. */
71
74
  h?: string;
72
75
  cr?: number;
@@ -89,6 +92,14 @@ export interface SessionTokens {
89
92
  byTool: Record<string, number>;
90
93
  byModelMessages: Record<string, number>;
91
94
  byHost: Record<string, Record<string, TokenBreakdown>>;
95
+ /** Model -> agent kind (`main`, `subagent`), so a subagent's work reads apart from its root's. */
96
+ byAgent: Record<string, Record<string, TokenBreakdown>>;
97
+ /** Model -> agent kind -> message count, so a share can read requests beside the tokens. */
98
+ byAgentMessages: Record<string, Record<string, number>>;
99
+ /** Model -> cost the host reported, over the messages that carried one. */
100
+ byModelMeasured: Record<string, number>;
101
+ /** Model -> messages that carried a cost. Compare to `byModelMessages` before trusting the sum. */
102
+ byModelCostMessages: Record<string, number>;
92
103
  costMeasured: number;
93
104
  recent: RecentRequest[];
94
105
  }
@@ -119,7 +130,7 @@ export function durOf(v: unknown): number | undefined {
119
130
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
120
131
  }
121
132
 
122
- // Measured cost: usage.cost.total, or a bare number in old fixtures. Anything else means unrecorded, which is deliberately not 0.
133
+ // Measured cost: usage.cost.total, or a bare number in old fixtures. Anything else means unrecorded, not 0.
123
134
  export function costOf(usage: Record<string, unknown>): number | undefined {
124
135
  const c = usage.cost;
125
136
  if (typeof c === 'number') return Number.isFinite(c) ? c : undefined;
@@ -154,7 +165,7 @@ function addInto(into: TokenBreakdown, u: { input?: unknown; output?: unknown; c
154
165
  export function sessionsDirs(): string[] {
155
166
  const override = process.env.TERSIO_SESSIONS_DIR;
156
167
  if (override) return [override];
157
- // 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.
168
+ // Both hosts, not just the running one: a pi session is missing if we stop at the first existing directory.
158
169
  const dirs = isPiProcess()
159
170
  ? [path.join(piAgentDir(), 'sessions'), path.join(homeDir(), '.omp', 'agent', 'sessions')]
160
171
  : [path.join(homeDir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
@@ -196,55 +207,55 @@ export interface OpencodeDbRow {
196
207
  id: string;
197
208
  created: number;
198
209
  row: OpencodeMessage;
210
+ /** Tool names this message called, comma-joined. Empty when it called none. */
211
+ tools?: string;
199
212
  }
200
213
 
201
- // Two on-disk shapes: legacy `message` (role/modelID/providerID live inside `data`),
202
- // current `session_message` (role in the `type` column, model under data.model).
203
- // Scalars only: the `data` blob holds newlines and tabs, which the sqlite3 CLI would split.
214
+ // `message` vs `session_message`: read scalars only, since the blob's newlines break the row-per-line output.
204
215
  const OC_DB_ORDER = ' ORDER BY time_created;';
205
216
  const OC_DB_COLUMNS =
206
- 'json_extract(data,\'$.time.created\'), json_extract(data,\'$.time.completed\'),' +
217
+ 'json_extract(data,\'$.time.created\'), json_extract(data,\'$.time.streamed\'), json_extract(data,\'$.time.completed\'),' +
207
218
  ' json_extract(data,\'$.tokens.input\'), json_extract(data,\'$.tokens.output\'),' +
208
219
  ' json_extract(data,\'$.tokens.cache.read\'), json_extract(data,\'$.tokens.cache.write\'), json_extract(data,\'$.cost\')';
220
+ const OC_DB_TOOLS =
221
+ '(SELECT group_concat(json_extract(j.value,\'$.name\')) FROM json_each(data,\'$.content\') j' +
222
+ ' WHERE json_extract(j.value,\'$.type\')=\'tool\')';
209
223
  const ocDbLegacy = (since: number): string =>
210
- `SELECT id, time_created, json_extract(data,'$.role'), json_extract(data,'$.providerID'), json_extract(data,'$.modelID'), ${OC_DB_COLUMNS}` +
224
+ `SELECT id, time_created, json_extract(data,'$.role'), json_extract(data,'$.providerID'), json_extract(data,'$.modelID'), ${OC_DB_COLUMNS}, ${OC_DB_TOOLS}` +
211
225
  ` FROM message WHERE json_extract(data,'$.role')='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
212
226
  const ocDbCurrent = (since: number): string =>
213
- `SELECT id, time_created, 'assistant', json_extract(data,'$.model.providerID'), json_extract(data,'$.model.id'), ${OC_DB_COLUMNS}` +
227
+ `SELECT id, time_created, 'assistant', json_extract(data,'$.model.providerID'), json_extract(data,'$.model.id'), ${OC_DB_COLUMNS}, ${OC_DB_TOOLS}` +
214
228
  ` FROM session_message WHERE type='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
215
229
 
216
230
  // A re-scan window covers tokens that land after the row was created, so a late fill is not lost.
217
231
  export const OPENCODE_DB_OVERLAP_MS = 3_600_000;
218
232
 
219
- // execFileSync caps output at 1MB; a busy table clears it in one SELECT.
220
- export const SQLITE_READ_BUFFER = 50 * 1024 * 1024;
221
-
222
- // Empty when sqlite3 is absent or the db is unreadable; that is a sync skip, not a failure.
233
+ // Empty when no SQLite is available or the db is unreadable; that is a sync skip, not a failure.
223
234
  export function readOpencodeDbRows(db: string, sinceMs: number): OpencodeDbRow[] {
224
235
  if (!Number.isFinite(sinceMs)) return [];
225
236
  try {
226
237
  if (!fs.existsSync(db)) return [];
227
238
  const since = Math.max(0, Math.floor(sinceMs));
228
239
  // Separate calls: a db holding only one table must not cancel the other.
229
- const run = (sql: string): string =>
230
- execFileSync('sqlite3', ['-separator', '\t', '-readonly', db, sql], { encoding: 'utf8', timeout: 30000, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: SQLITE_READ_BUFFER });
240
+ const run = (sql: string): string => sqliteRead(db, sql);
231
241
  let out = '';
232
242
  try { out += run(ocDbLegacy(since)); } catch { /* no legacy table or unreadable */ }
233
243
  try { out += run(ocDbCurrent(since)); } catch { /* no session_message table or unreadable */ }
234
244
  const rows: OpencodeDbRow[] = [];
235
245
  for (const line of out.split('\n')) {
236
246
  if (!line.trim()) continue;
237
- const [id, created, role, providerID, modelID, tCreated, tCompleted, input, output, cacheRead, cacheWrite, cost] = line.split('\t');
247
+ const [id, created, role, providerID, modelID, tCreated, tStreamed, tCompleted, input, output, cacheRead, cacheWrite, cost, tools] = line.split('\t');
238
248
  const n = (v: string | undefined): unknown => (v !== undefined && v !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined);
239
249
  rows.push({
240
250
  id,
241
251
  created: Number(created),
252
+ tools: tools ?? '',
242
253
  row: {
243
254
  role,
244
255
  modelID,
245
256
  providerID,
246
257
  tokens: { input: n(input), output: n(output), cache: { read: n(cacheRead), write: n(cacheWrite) } },
247
- time: { created: n(tCreated), completed: n(tCompleted) },
258
+ time: { created: n(tCreated), streamed: n(tStreamed), completed: n(tCompleted) },
248
259
  cost: n(cost),
249
260
  },
250
261
  });
@@ -292,15 +303,25 @@ export interface SessionAccum {
292
303
  byTool: Record<string, number>;
293
304
  byModelMessages: Record<string, number>;
294
305
  byHost: Record<string, Record<string, TokenBreakdown>>;
306
+ /** Model -> agent kind (`main`, `subagent`), so a subagent's work reads apart from its root's. */
307
+ byAgent: Record<string, Record<string, TokenBreakdown>>;
308
+ /** Model -> agent kind -> message count. */
309
+ byAgentMessages: Record<string, Record<string, number>>;
310
+ /** Model -> cost the host itself reported, summed over the messages that carried one. */
311
+ byModelMeasured: Record<string, number>;
312
+ /** Model -> messages that carried a cost. Compare to `byModelMessages` before trusting the sum. */
313
+ byModelCostMessages: Record<string, number>;
295
314
  totals: TokenBreakdown;
296
315
  messages: number;
297
316
  costMeasured: number;
298
317
  recent: RecentRequest[];
318
+ /** Oldest timestamp held in `recent`, so the list stays bounded instead of growing with the store. */
319
+ recentFloor: number;
299
320
  }
300
321
  export function newSessionAccum(): SessionAccum {
301
- return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
322
+ return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, byAgent: {}, byAgentMessages: {}, byModelMeasured: {}, byModelCostMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [], recentFloor: Number.POSITIVE_INFINITY };
302
323
  }
303
- export function ingestSessionRow(accum: SessionAccum, model: string, usage: Record<string, unknown>, ts: string | number | undefined, durMs?: unknown, run?: { st: RunStatus; code?: number; note?: string }, toolNames?: string[], host?: string, id?: string): boolean {
324
+ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Record<string, unknown>, ts: string | number | undefined, durMs?: unknown, run?: { st: RunStatus; code?: number; note?: string }, toolNames?: string[], host?: string, id?: string, agent?: string, ttftMs?: unknown): boolean {
304
325
  const watermark = readResetWatermark();
305
326
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
306
327
  if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark)) return false;
@@ -314,10 +335,28 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
314
335
  hs[host] ??= zeroBreakdown();
315
336
  addInto(hs[host], usage);
316
337
  }
338
+ if (agent) {
339
+ const as = (accum.byAgent[key] ??= {});
340
+ as[agent] ??= zeroBreakdown();
341
+ addInto(as[agent], usage);
342
+ const am = (accum.byAgentMessages[key] ??= {});
343
+ am[agent] = (am[agent] ?? 0) + 1;
344
+ }
317
345
  const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
318
346
  if (Number.isFinite(ms)) {
319
347
  const outcome = run ?? { st: 'completed' as RunStatus };
320
- 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 });
348
+ // Only the newest rows are read: skip below the floor, and a 50k-row store costs 2000 rows of memory.
349
+ if (accum.recent.length < RECENT_LIMIT || ms >= accum.recentFloor) {
350
+ 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 });
351
+ if (accum.recent.length > RECENT_LIMIT) {
352
+ let drop = 0;
353
+ for (let k = 1; k < accum.recent.length; k += 1) if (accum.recent[k].t < accum.recent[drop].t) drop = k;
354
+ accum.recent.splice(drop, 1);
355
+ accum.recentFloor = accum.recent.reduce((lo, r) => Math.min(lo, r.t), Number.POSITIVE_INFINITY);
356
+ } else if (ms < accum.recentFloor) {
357
+ accum.recentFloor = ms;
358
+ }
359
+ }
321
360
  }
322
361
  const day = ts !== undefined ? dayKey(ts) : null;
323
362
  if (day) {
@@ -331,12 +370,16 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
331
370
  }
332
371
  accum.messages += 1;
333
372
  const measured = costOf(usage);
334
- if (measured !== undefined) accum.costMeasured += measured;
373
+ if (measured !== undefined) {
374
+ accum.costMeasured += measured;
375
+ accum.byModelMeasured[key] = (accum.byModelMeasured[key] ?? 0) + measured;
376
+ accum.byModelCostMessages[key] = (accum.byModelCostMessages[key] ?? 0) + 1;
377
+ }
335
378
  for (const name of toolNames ?? []) accum.byTool[name] = (accum.byTool[name] ?? 0) + 1;
336
379
  return true;
337
380
  }
338
381
  export type SessionLineKind = 'codex_provider' | 'codex_model' | 'token_row' | 'assistant_row' | 'skip';
339
- export function classifySessionLine(row: { timestamp?: string | number; type?: unknown; message?: { role?: unknown; model?: unknown; usage?: Record<string, unknown>; duration?: unknown; completedAt?: unknown; timestamp?: unknown; stopReason?: unknown; errorStatus?: unknown; errorMessage?: unknown; isError?: unknown; content?: Array<{ type?: unknown; name?: unknown; arguments?: unknown }> }; payload?: { type?: unknown; model?: unknown; model_provider?: unknown; info?: { last_token_usage?: Record<string, unknown> } } }): { kind: SessionLineKind; provider?: string; model?: string; usage?: Record<string, unknown>; durMs?: number; run?: { st: RunStatus; code?: number; note?: string }; tools?: string[]; ms?: number } {
382
+ export function classifySessionLine(row: { timestamp?: string | number; type?: unknown; message?: { role?: unknown; model?: unknown; usage?: Record<string, unknown>; duration?: unknown; ttft?: unknown; completedAt?: unknown; timestamp?: unknown; stopReason?: unknown; errorStatus?: unknown; errorMessage?: unknown; isError?: unknown; content?: Array<{ type?: unknown; name?: unknown; arguments?: unknown }> }; payload?: { type?: unknown; model?: unknown; model_provider?: unknown; info?: { last_token_usage?: Record<string, unknown> } } }): { kind: SessionLineKind; provider?: string; model?: string; usage?: Record<string, unknown>; durMs?: number; ttftMs?: number; run?: { st: RunStatus; code?: number; note?: string }; tools?: string[]; ms?: number } {
340
383
  const payload = row.payload;
341
384
  if (payload && typeof payload === 'object') {
342
385
  if (row.type === 'session_meta' && typeof payload.model_provider === 'string') return { kind: 'codex_provider', provider: payload.model_provider };
@@ -370,7 +413,7 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
370
413
  const command = (part.arguments as { command?: unknown } | null)?.command;
371
414
  tools.push(`bash:${leadBinary(command)}`);
372
415
  }
373
- return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
416
+ 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 };
374
417
  }
375
418
  // True when at least one token bucket holds a positive finite count.
376
419
  export function hasPositiveUsage(usage: Record<string, unknown>): boolean {
@@ -386,13 +429,30 @@ export interface OpencodeMessage {
386
429
  tokens?: { input?: unknown; output?: unknown; reasoning?: unknown; cache?: { read?: unknown; write?: unknown } } | null;
387
430
  modelID?: unknown;
388
431
  providerID?: unknown;
389
- time?: { created?: unknown; completed?: unknown } | null;
432
+ time?: { created?: unknown; streamed?: unknown; completed?: unknown } | null;
390
433
  cost?: unknown;
391
434
  finish?: unknown;
435
+ /** The message's parts. Tool calls sit here, named under a part of type `tool`. */
436
+ content?: unknown;
437
+ }
438
+
439
+ /** Tool names an OpenCode message called; v2 passes SQL's comma-joined string as the blob holds newlines. */
440
+ export function opencodeToolNames(message: OpencodeMessage, joined?: string): string[] {
441
+ if (joined !== undefined) return joined === '' ? [] : joined.split(',');
442
+ const parts = message?.content;
443
+ if (!Array.isArray(parts)) return [];
444
+ const names: string[] = [];
445
+ for (const part of parts) {
446
+ if (!part || typeof part !== 'object') continue;
447
+ if ((part as Record<string, unknown>).type !== 'tool') continue;
448
+ const name = (part as Record<string, unknown>).name;
449
+ if (typeof name === 'string' && name) names.push(name);
450
+ }
451
+ return names;
392
452
  }
393
453
 
394
454
  // One OpenCode message file; assistant rows carry the token buckets.
395
- export function classifyOpencodeMessage(obj: OpencodeMessage | null | undefined): { model: string; usage: Record<string, unknown>; ms: number | undefined; durMs: number | undefined } | null {
455
+ export function classifyOpencodeMessage(obj: OpencodeMessage | null | undefined): { model: string; usage: Record<string, unknown>; ms: number | undefined; durMs: number | undefined; ttftMs: number | undefined } | null {
396
456
  if (!obj || typeof obj !== 'object' || obj.role !== 'assistant') return null;
397
457
  const t = obj.tokens;
398
458
  if (!t || typeof t !== 'object') return null;
@@ -409,15 +469,17 @@ export function classifyOpencodeMessage(obj: OpencodeMessage | null | undefined)
409
469
  const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
410
470
  const created = num(obj.time?.created);
411
471
  const completed = num(obj.time?.completed);
472
+ // `streamed` is when the first token arrived, so it is the host's own first-token latency.
473
+ const streamed = num(obj.time?.streamed);
412
474
  return {
413
475
  model: `opencode/${provider}/${model}`,
414
476
  usage,
415
477
  ms: created,
416
478
  durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
479
+ ttftMs: created !== undefined && streamed !== undefined && streamed > created ? streamed - created : undefined,
417
480
  };
418
481
  }
419
482
 
420
- // One OpenCode message file into the shared accum. The msg id is the file name.
421
483
  export function processOpencodeFile(accum: SessionAccum, text: string, file?: string): void {
422
484
  let obj: unknown;
423
485
  try {
@@ -425,17 +487,18 @@ export function processOpencodeFile(accum: SessionAccum, text: string, file?: st
425
487
  } catch {
426
488
  return;
427
489
  }
428
- const parsed = classifyOpencodeMessage(obj as OpencodeMessage);
490
+ const message = obj as OpencodeMessage;
491
+ const parsed = classifyOpencodeMessage(message);
429
492
  if (!parsed) return;
430
493
  const id = file ? path.basename(file).replace(/\.[^.]+$/, '') || undefined : undefined;
431
- ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, undefined, 'opencode', id);
494
+ ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, opencodeToolNames(message), 'opencode', id, 'main', parsed.ttftMs);
432
495
  }
433
496
 
434
497
  // One v2 message row into the shared accum. The msg id is the row primary key.
435
498
  export function ingestOpencodeDbRow(accum: SessionAccum, row: OpencodeDbRow): void {
436
499
  const parsed = classifyOpencodeMessage(row.row);
437
500
  if (!parsed) return;
438
- ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, undefined, 'opencode', row.id);
501
+ ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, opencodeToolNames(row.row, row.tools), 'opencode', row.id, 'main', parsed.ttftMs);
439
502
  }
440
503
 
441
504
  // Fold `:free`/`-free` suffixes and case variants into one chart key.
@@ -448,8 +511,7 @@ export function messageRowId(row: unknown): string | undefined {
448
511
  return typeof id === 'string' && id ? id : undefined;
449
512
  }
450
513
 
451
- // Vendor names mirror the dashboard map; CLI needs names only. Family first:
452
- // gateway segments shadow it (minimaxai holds xai, gemma rides nvidia).
514
+ // Vendor names mirror the dashboard map; family first, so gateway segments shadow it (minimaxai holds xai).
453
515
  const VENDOR_RES: Array<[RegExp, string]> = [
454
516
  [/inclusionai|ling-/i, 'InclusionAI'],
455
517
  [/minimax/i, 'MiniMax'],
@@ -560,10 +622,10 @@ export function importSessionTokens(): SessionTokens {
560
622
  set codexProvider(v: string | null) { codexProvider = v; },
561
623
  get codexModel() { return codexModel; },
562
624
  set codexModel(v: string | null) { codexModel = v; },
563
- }, text, hostOfSessionFile(file));
625
+ }, text, hostOfSessionFile(file), agentOfSessionText(text));
564
626
  }
565
627
  accum.recent.sort((a, b) => b.t - a.t);
566
- 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) };
628
+ 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) };
567
629
  }
568
630
 
569
631
  // Which agent wrote a session file, from the directory it sits in.
@@ -575,6 +637,19 @@ export function hostOfSessionFile(file: string): string {
575
637
  return 'pi';
576
638
  }
577
639
 
640
+ // A transcript with a parent session ran as a subagent; the host's own row, so a mirror answers the same.
641
+ export function agentOfSessionText(text: string): string {
642
+ if (!text.includes('parentSession')) return 'main';
643
+ for (const line of text.split('\n')) {
644
+ if (!line.includes('parentSession')) continue;
645
+ try {
646
+ const row = JSON.parse(line) as { type?: unknown; parentSession?: unknown };
647
+ if (row.type === 'session' && typeof row.parentSession === 'string' && row.parentSession !== '') return 'subagent';
648
+ } catch { /* skip a corrupt line */ }
649
+ }
650
+ return 'main';
651
+ }
652
+
578
653
  const adoptionFileCache = new Map<string, { size: number; mtimeMs: number; counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number } }>();
579
654
 
580
655
  function parseAdoptionFile(file: string): { counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number }; sessions: number } {
@@ -645,7 +720,7 @@ export function readRtkAdoption(): RtkAdoption {
645
720
  const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
646
721
  return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
647
722
  }
648
- export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null; codexModel?: string | null }, text: string, host?: string): void {
723
+ export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null; codexModel?: string | null }, text: string, host?: string, agent?: string): void {
649
724
  for (const line of text.split('\n')) {
650
725
  if (!line.trim()) continue;
651
726
  try {
@@ -664,11 +739,11 @@ export function processSessionText(accum: SessionAccum, state: { codexProvider:
664
739
  const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
665
740
  // The provider alone told you nothing about which model ran.
666
741
  const model = state.codexModel ? `${base}/${state.codexModel}` : base;
667
- ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' as RunStatus }, undefined, host ?? 'codex', rid);
742
+ ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' as RunStatus }, undefined, host ?? 'codex', rid, agent);
668
743
  continue;
669
744
  }
670
745
  if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage) continue;
671
- ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid);
746
+ ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid, agent, parsed.ttftMs);
672
747
  } catch { /* skip corrupt lines */ }
673
748
  }
674
749
  }
@@ -705,18 +780,29 @@ export function leadBinary(command: unknown): string {
705
780
  return 'bash';
706
781
  }
707
782
 
708
- // --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
709
- import { DEFAULT_PRICE, canonicalPriceId, priceFor, refreshPricesIfStale } from './pricing.ts';
783
+ // Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table).
784
+ import { DEFAULT_PRICE, PRICE_TIERS_FIRM, canonicalPriceId, priceFor, pricesOverridePath, refreshPricesIfStale, type PriceHit, type PriceTier } from './pricing.ts';
710
785
  import { co2GramsFor, energyWhFor } from './carbon.ts';
711
786
  import type { ModelPrice } from './pricing.ts';
712
787
 
713
- export { DEFAULT_PRICE, priceFor, refreshPricesIfStale };
788
+ export { DEFAULT_PRICE, PRICE_TIERS_FIRM, priceFor, pricesOverridePath, refreshPricesIfStale };
714
789
  export { co2GramsFor, energyWhFor };
715
- export type { ModelPrice };
790
+ export type { PriceHit, PriceTier, ModelPrice };
716
791
 
717
- export function usdCost(t: TokenBreakdown, model?: string): { usd: number; priced: boolean; buckets: { input: number; output: number; cacheRead: number; cacheWrite: number } } {
718
- const { price, known } = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false };
792
+ /** Price one model's tokens; `measured` outranks every rate card, so pass undefined when unreported. */
793
+ export function usdCost(t: TokenBreakdown, model?: string, measured?: number): { usd: number; priced: boolean; tier: PriceTier; buckets: { input: number; output: number; cacheRead: number; cacheWrite: number } } {
719
794
  const m = 1 / 1_000_000;
795
+ if (measured !== undefined) {
796
+ // Only a bare total arrives, so it attributes to input, the largest bucket for every model here.
797
+ return {
798
+ usd: measured,
799
+ priced: true,
800
+ tier: 'measured',
801
+ buckets: { input: measured, output: 0, cacheRead: 0, cacheWrite: 0 },
802
+ };
803
+ }
804
+ const hit: PriceHit = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false, live: false, tier: 'default' };
805
+ const price = hit.price;
720
806
  const buckets = {
721
807
  input: t.input * price.input * m,
722
808
  output: t.output * price.output * m,
@@ -725,7 +811,9 @@ export function usdCost(t: TokenBreakdown, model?: string): { usd: number; price
725
811
  };
726
812
  return {
727
813
  usd: buckets.input + buckets.output + buckets.cacheRead + buckets.cacheWrite,
728
- priced: model ? known : false,
814
+ // `tail`/`default` are estimates; summing them beside measured cost reads as a bill it is not.
815
+ priced: PRICE_TIERS_FIRM.includes(hit.tier),
816
+ tier: hit.tier,
729
817
  buckets,
730
818
  };
731
819
  }
@@ -754,7 +842,7 @@ export function readUsage(): UsageRow[] {
754
842
  return rows;
755
843
  }
756
844
 
757
- // Delete the ledger file and return the rows cleared. Tersio-owned data only: transcripts and the RTK database are never touched.
845
+ // Delete the ledger file and return the rows cleared. Tersio-owned data only: transcripts and the RTK database stay.
758
846
  export function clearUsageLedger(): number {
759
847
  const rows = readUsage().length;
760
848
  try {