@krtclcdy/tersio 2.24.0 → 2.25.0

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 (63) hide show
  1. package/README.md +28 -24
  2. package/cli/banner.js +7 -5
  3. package/cli/common.js +14 -13
  4. package/cli/currency.js +2 -7
  5. package/cli/dashboard.js +220 -13
  6. package/cli/doctor-fix.js +99 -29
  7. package/cli/doctor.js +32 -35
  8. package/cli/hosts.js +23 -26
  9. package/cli/install.js +228 -115
  10. package/cli/interactive.js +65 -21
  11. package/cli/manifest.js +8 -4
  12. package/cli/profile.js +49 -16
  13. package/cli/reset.js +3 -7
  14. package/cli/rtk-wiring.js +29 -32
  15. package/cli/settings.js +40 -16
  16. package/cli/uninstall.js +79 -29
  17. package/cli/update.js +27 -20
  18. package/cli/usage.js +54 -19
  19. package/dashboard/dist/index.html +18 -18
  20. package/extensions/ai-addons-updater/index.js +75 -20
  21. package/extensions/ai-addons-updater/index.ts +67 -23
  22. package/extensions/caveman-session/index.js +33 -75
  23. package/extensions/caveman-session/index.ts +35 -77
  24. package/extensions/combo-toggle/index.js +40 -62
  25. package/extensions/combo-toggle/index.ts +38 -72
  26. package/extensions/lib/utils.js +64 -15
  27. package/extensions/lib/utils.ts +62 -15
  28. package/extensions/opencode/index.js +104 -0
  29. package/extensions/opencode/server.js +190 -0
  30. package/extensions/opencode/server.ts +200 -0
  31. package/extensions/rtk-session/index.js +38 -57
  32. package/extensions/rtk-session/index.ts +36 -55
  33. package/extensions/shared/carbon.js +4 -10
  34. package/extensions/shared/carbon.ts +5 -10
  35. package/extensions/shared/host.js +95 -56
  36. package/extensions/shared/host.ts +106 -60
  37. package/extensions/shared/plugin-settings.js +6 -18
  38. package/extensions/shared/plugin-settings.ts +6 -18
  39. package/extensions/shared/pricing.js +79 -27
  40. package/extensions/shared/pricing.ts +80 -28
  41. package/extensions/shared/rtk-gain.js +4 -11
  42. package/extensions/shared/rtk-gain.ts +4 -11
  43. package/extensions/shared/session-state.js +28 -39
  44. package/extensions/shared/session-state.ts +29 -38
  45. package/extensions/shared/status.js +38 -0
  46. package/extensions/shared/status.ts +45 -0
  47. package/extensions/shared/types.js +1 -2
  48. package/extensions/shared/types.ts +2 -2
  49. package/extensions/shared/usage-ledger.js +312 -57
  50. package/extensions/shared/usage-ledger.ts +321 -61
  51. package/extensions/shared/usage-store.js +354 -38
  52. package/extensions/shared/usage-store.ts +360 -43
  53. package/extensions/tersio-commands/index.js +5 -14
  54. package/extensions/tersio-commands/index.ts +5 -14
  55. package/package.json +12 -7
  56. package/tersio.js +4 -6
  57. package/cli/agent-hosts.js +0 -42
  58. package/cli/agents.js +0 -266
  59. package/cli/layer.js +0 -24
  60. package/cli/omp-layer.js +0 -51
  61. package/cli/pi-layer.js +0 -37
  62. package/cli/pi-wiring.js +0 -92
  63. package/extensions/shared/omp-prompt.js +0 -12
@@ -1,9 +1,7 @@
1
- // Shared usage ledger: append-only JSON lines, read by the CLI, `/tersio`, and
2
- // the Dashboard. Best-effort; corrupt lines are skipped on read.
1
+ // Shared usage ledger: append-only JSON lines, read by the CLI, `/tersio`, and the Dashboard. Best-effort; corrupt lines are skipped on read.
3
2
  import fs from 'node:fs';
4
- import os from 'node:os';
5
3
  import path from 'node:path';
6
- import { isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from '../lib/utils.ts';
4
+ import { homeDir, isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from '../lib/utils.ts';
7
5
  import { execFileSync } from 'node:child_process';
8
6
 
9
7
  export type UsageKind = 'command' | 'toggle' | 'install' | 'update' | 'rtk-audit';
@@ -52,8 +50,7 @@ export function appendUsage(kind: UsageKind, detail: string): void {
52
50
  } catch { /* ledger is best-effort; never break the caller */ }
53
51
  }
54
52
 
55
- // --- Session tokens, tokscale-style ------------------------------------------
56
- // Assistant messages in the host transcripts carry usage, model, and a timestamp.
53
+ // --- Session tokens, tokscale-style ------------------------------------------ Assistant messages in the host transcripts carry usage, model, and a timestamp.
57
54
  export interface TokenBreakdown {
58
55
  input: number;
59
56
  output: number;
@@ -70,6 +67,8 @@ export interface RecentRequest {
70
67
  o: number;
71
68
  t: number;
72
69
  d?: number;
70
+ /** Which agent wrote the session: pi, omp, or codex. */
71
+ h?: string;
73
72
  cr?: number;
74
73
  cw?: number;
75
74
  // Undefined when the host recorded no cost; never backfilled.
@@ -77,6 +76,8 @@ export interface RecentRequest {
77
76
  st: RunStatus;
78
77
  code?: number;
79
78
  note?: string;
79
+ /** Message id when the host provides one (transcript row id, opencode msg file). */
80
+ id?: string;
80
81
  }
81
82
 
82
83
  export interface SessionTokens {
@@ -87,6 +88,7 @@ export interface SessionTokens {
87
88
  byDayModel: Record<string, Record<string, number>>;
88
89
  byTool: Record<string, number>;
89
90
  byModelMessages: Record<string, number>;
91
+ byHost: Record<string, Record<string, TokenBreakdown>>;
90
92
  costMeasured: number;
91
93
  recent: RecentRequest[];
92
94
  }
@@ -109,12 +111,15 @@ export interface RtkRecallDiagnostics {
109
111
  function zeroBreakdown(): TokenBreakdown {
110
112
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
111
113
  }
114
+ function stampMs(ts: string | number | undefined): number {
115
+ return ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
116
+ }
117
+
112
118
  export function durOf(v: unknown): number | undefined {
113
119
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
114
120
  }
115
121
 
116
- // Measured cost: usage.cost.total, or a bare number in old fixtures. Anything
117
- // else means unrecorded, which is deliberately not 0.
122
+ // Measured cost: usage.cost.total, or a bare number in old fixtures. Anything else means unrecorded, which is deliberately not 0.
118
123
  export function costOf(usage: Record<string, unknown>): number | undefined {
119
124
  const c = usage.cost;
120
125
  if (typeof c === 'number') return Number.isFinite(c) ? c : undefined;
@@ -125,8 +130,7 @@ export function costOf(usage: Record<string, unknown>): number | undefined {
125
130
  return undefined;
126
131
  }
127
132
 
128
- // First line only: real messages carry multi-line provider errors, and this
129
- // ends up in a tooltip.
133
+ // First line only: real messages carry multi-line provider errors, and this ends up in a tooltip.
130
134
  export function statusOf(msg: { stopReason?: unknown; errorStatus?: unknown; errorMessage?: unknown; isError?: unknown }): { st: RunStatus; code?: number; note?: string } {
131
135
  const stop = typeof msg.stopReason === 'string' ? msg.stopReason : '';
132
136
  const note = typeof msg.errorMessage === 'string' && msg.errorMessage.trim()
@@ -147,22 +151,108 @@ function addInto(into: TokenBreakdown, u: { input?: unknown; output?: unknown; c
147
151
  }
148
152
  }
149
153
 
150
- export function sessionsDir(): string {
154
+ export function sessionsDirs(): string[] {
151
155
  const override = process.env.TERSIO_SESSIONS_DIR;
152
- if (override) return override;
153
- // The running host wins; the other host's directory is the fallback.
156
+ 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.
154
158
  const dirs = isPiProcess()
155
- ? [path.join(piAgentDir(), 'sessions'), path.join(os.homedir(), '.omp', 'agent', 'sessions')]
156
- : [path.join(os.homedir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
157
- return dirs.find((dir) => fs.existsSync(dir)) ?? dirs[0];
159
+ ? [path.join(piAgentDir(), 'sessions'), path.join(homeDir(), '.omp', 'agent', 'sessions')]
160
+ : [path.join(homeDir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
161
+ const found = dirs.filter((dir) => fs.existsSync(dir));
162
+ return found.length > 0 ? found : [dirs[0]];
163
+ }
164
+
165
+ export function sessionsDir(): string {
166
+ return sessionsDirs()[0];
158
167
  }
159
168
 
160
- export function codexSessionsDir(): string {
161
- const override = process.env.TERSIO_CODEX_DIR;
169
+ // One JSON file per message; probe XDG, then local, then macOS default.
170
+ export function opencodeSessionsDir(): string {
171
+ const override = process.env.TERSIO_OPENCODE_DIR;
162
172
  if (override) return override;
163
- const codexHome = process.env.CODEX_HOME;
164
- if (codexHome) return path.join(codexHome, 'sessions');
165
- return path.join(os.homedir(), '.codex', 'sessions');
173
+ const xdg = process.env.XDG_DATA_HOME;
174
+ if (xdg && xdg.trim() !== '') return path.join(xdg, 'opencode', 'storage', 'message');
175
+ const local = path.join(homeDir(), '.local', 'share', 'opencode', 'storage', 'message');
176
+ try {
177
+ if (fs.existsSync(local)) return local;
178
+ } catch { /* fall through to the platform default */ }
179
+ return path.join(homeDir(), 'Library', 'Application Support', 'opencode', 'storage', 'message');
180
+ }
181
+
182
+ // OpenCode v2 keeps messages in sqlite; the JSON tree above is only the pre-v2 layout.
183
+ export function opencodeDbPath(): string {
184
+ const override = process.env.TERSIO_OPENCODE_DB;
185
+ if (override) return override;
186
+ const xdg = process.env.XDG_DATA_HOME;
187
+ if (xdg && xdg.trim() !== '') return path.join(xdg, 'opencode', 'opencode.db');
188
+ const local = path.join(homeDir(), '.local', 'share', 'opencode', 'opencode.db');
189
+ try {
190
+ if (fs.existsSync(local)) return local;
191
+ } catch { /* fall through to the platform default */ }
192
+ return path.join(homeDir(), 'Library', 'Application Support', 'opencode', 'opencode.db');
193
+ }
194
+
195
+ export interface OpencodeDbRow {
196
+ id: string;
197
+ created: number;
198
+ row: OpencodeMessage;
199
+ }
200
+
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.
204
+ const OC_DB_ORDER = ' ORDER BY time_created;';
205
+ const OC_DB_COLUMNS =
206
+ 'json_extract(data,\'$.time.created\'), json_extract(data,\'$.time.completed\'),' +
207
+ ' json_extract(data,\'$.tokens.input\'), json_extract(data,\'$.tokens.output\'),' +
208
+ ' json_extract(data,\'$.tokens.cache.read\'), json_extract(data,\'$.tokens.cache.write\'), json_extract(data,\'$.cost\')';
209
+ 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}` +
211
+ ` FROM message WHERE json_extract(data,'$.role')='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
212
+ const ocDbCurrent = (since: number): string =>
213
+ `SELECT id, time_created, 'assistant', json_extract(data,'$.model.providerID'), json_extract(data,'$.model.id'), ${OC_DB_COLUMNS}` +
214
+ ` FROM session_message WHERE type='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
215
+
216
+ // A re-scan window covers tokens that land after the row was created, so a late fill is not lost.
217
+ export const OPENCODE_DB_OVERLAP_MS = 3_600_000;
218
+
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.
223
+ export function readOpencodeDbRows(db: string, sinceMs: number): OpencodeDbRow[] {
224
+ if (!Number.isFinite(sinceMs)) return [];
225
+ try {
226
+ if (!fs.existsSync(db)) return [];
227
+ const since = Math.max(0, Math.floor(sinceMs));
228
+ // 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 });
231
+ let out = '';
232
+ try { out += run(ocDbLegacy(since)); } catch { /* no legacy table or unreadable */ }
233
+ try { out += run(ocDbCurrent(since)); } catch { /* no session_message table or unreadable */ }
234
+ const rows: OpencodeDbRow[] = [];
235
+ for (const line of out.split('\n')) {
236
+ if (!line.trim()) continue;
237
+ const [id, created, role, providerID, modelID, tCreated, tCompleted, input, output, cacheRead, cacheWrite, cost] = line.split('\t');
238
+ const n = (v: string | undefined): unknown => (v !== undefined && v !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined);
239
+ rows.push({
240
+ id,
241
+ created: Number(created),
242
+ row: {
243
+ role,
244
+ modelID,
245
+ providerID,
246
+ tokens: { input: n(input), output: n(output), cache: { read: n(cacheRead), write: n(cacheWrite) } },
247
+ time: { created: n(tCreated), completed: n(tCompleted) },
248
+ cost: n(cost),
249
+ },
250
+ });
251
+ }
252
+ return rows;
253
+ } catch {
254
+ return [];
255
+ }
166
256
  }
167
257
 
168
258
  export function dayKey(ts: string | number): string | null {
@@ -172,7 +262,7 @@ export function dayKey(ts: string | number): string | null {
172
262
  const p = (n: number): string => String(n).padStart(2, '0');
173
263
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
174
264
  }
175
- export function walkJsonl(dir: string, out: string[], cap: number): void {
265
+ export function walkJsonl(dir: string, out: string[], cap: number, ext = '.jsonl'): void {
176
266
  let entries: fs.Dirent[];
177
267
  try {
178
268
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -182,36 +272,35 @@ export function walkJsonl(dir: string, out: string[], cap: number): void {
182
272
  for (const e of entries) {
183
273
  if (out.length >= cap) return;
184
274
  const full = path.join(dir, e.name);
185
- if (e.isDirectory()) walkJsonl(full, out, cap);
186
- else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(full);
275
+ if (e.isDirectory()) walkJsonl(full, out, cap, ext);
276
+ else if (e.isFile() && e.name.endsWith(ext)) out.push(full);
187
277
  }
188
278
  }
189
- // Recent requests get their own full-width table, so this bounds payload
190
- // rather than highlights; rows are small, so a few hundred cost little.
191
- export const RECENT_LIMIT = 200;
279
+ // Recent rows are a newest-first window over lifetime history; aggregates keep everything.
280
+ export const RECENT_LIMIT = 2000;
192
281
  const FREE_SUFFIX = /(?::free|-free)$/i;
193
282
  const RTK_ELIGIBLE_HEADS = new Set([
194
283
  'rtk', 'git', 'grep', 'rg', 'find', 'cat', 'ls', 'tree', 'diff', 'log',
195
284
  'npm', 'npx', 'pnpm', 'bun', 'bunx', 'cargo', 'go', 'python', 'pytest',
196
285
  'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
197
286
  ]);
198
- // Parse buffer shared by live reads and usage.db syncs, so the store is a
199
- // cache and never a fork.
287
+ // Parse buffer shared by live reads and usage.db syncs, so the store is a cache and never a fork.
200
288
  export interface SessionAccum {
201
289
  byModel: Record<string, TokenBreakdown>;
202
290
  byDay: Record<string, TokenBreakdown>;
203
291
  byDayModel: Record<string, Record<string, number>>;
204
292
  byTool: Record<string, number>;
205
293
  byModelMessages: Record<string, number>;
294
+ byHost: Record<string, Record<string, TokenBreakdown>>;
206
295
  totals: TokenBreakdown;
207
296
  messages: number;
208
297
  costMeasured: number;
209
298
  recent: RecentRequest[];
210
299
  }
211
300
  export function newSessionAccum(): SessionAccum {
212
- return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
301
+ return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
213
302
  }
214
- 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[]): boolean {
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 {
215
304
  const watermark = readResetWatermark();
216
305
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
217
306
  if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark)) return false;
@@ -220,10 +309,15 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
220
309
  accum.byModel[key] ??= zeroBreakdown();
221
310
  addInto(accum.byModel[key], usage);
222
311
  accum.byModelMessages[key] = (accum.byModelMessages[key] ?? 0) + 1;
312
+ if (host) {
313
+ const hs = (accum.byHost[key] ??= {});
314
+ hs[host] ??= zeroBreakdown();
315
+ addInto(hs[host], usage);
316
+ }
223
317
  const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
224
318
  if (Number.isFinite(ms)) {
225
319
  const outcome = run ?? { st: 'completed' as RunStatus };
226
- accum.recent.push({ m: key, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), cr: num(usage.cacheRead), cw: num(usage.cacheWrite), usd: costOf(usage), st: outcome.st, code: outcome.code, note: outcome.note });
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 });
227
321
  }
228
322
  const day = ts !== undefined ? dayKey(ts) : null;
229
323
  if (day) {
@@ -241,11 +335,15 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
241
335
  for (const name of toolNames ?? []) accum.byTool[name] = (accum.byTool[name] ?? 0) + 1;
242
336
  return true;
243
337
  }
244
- export type SessionLineKind = 'codex_provider' | 'token_row' | 'assistant_row' | 'skip';
245
- 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_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 } {
338
+ 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 } {
246
340
  const payload = row.payload;
247
341
  if (payload && typeof payload === 'object') {
248
342
  if (row.type === 'session_meta' && typeof payload.model_provider === 'string') return { kind: 'codex_provider', provider: payload.model_provider };
343
+ // A Codex transcript states the model once per turn, in turn_context.
344
+ if (row.type === 'turn_context' && typeof payload.model === 'string' && payload.model) {
345
+ return { kind: 'codex_model', model: payload.model };
346
+ }
249
347
  if (payload.type === 'token_count') {
250
348
  const last = payload.info?.last_token_usage;
251
349
  if (last) {
@@ -258,7 +356,10 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
258
356
  const msg = row.message;
259
357
  if (!msg || msg.role !== 'assistant' || !msg.usage) return { kind: 'skip' };
260
358
  const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
261
- const computedDur = (typeof msg.completedAt === 'number' && typeof msg.timestamp === 'number') ? msg.completedAt - msg.timestamp : undefined;
359
+ // pi omits duration, so derive Elapsed/Speed from the timestamp pair.
360
+ const startMs = typeof msg.timestamp === 'number' ? msg.timestamp : undefined;
361
+ const endMs = typeof msg.completedAt === 'number' ? msg.completedAt : stampMs(row.timestamp);
362
+ const computedDur = startMs !== undefined && Number.isFinite(endMs) && endMs > startMs ? endMs - startMs : undefined;
262
363
  const tools: string[] = [];
263
364
  for (const part of msg.content ?? []) {
264
365
  if (part?.type !== 'toolCall' || typeof part.name !== 'string' || !part.name) continue;
@@ -271,17 +372,139 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
271
372
  }
272
373
  return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
273
374
  }
375
+ // True when at least one token bucket holds a positive finite count.
376
+ export function hasPositiveUsage(usage: Record<string, unknown>): boolean {
377
+ for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
378
+ const v = usage[k];
379
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0) return true;
380
+ }
381
+ return false;
382
+ }
383
+
384
+ export interface OpencodeMessage {
385
+ role?: unknown;
386
+ tokens?: { input?: unknown; output?: unknown; reasoning?: unknown; cache?: { read?: unknown; write?: unknown } } | null;
387
+ modelID?: unknown;
388
+ providerID?: unknown;
389
+ time?: { created?: unknown; completed?: unknown } | null;
390
+ cost?: unknown;
391
+ finish?: unknown;
392
+ }
393
+
394
+ // 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 {
396
+ if (!obj || typeof obj !== 'object' || obj.role !== 'assistant') return null;
397
+ const t = obj.tokens;
398
+ if (!t || typeof t !== 'object') return null;
399
+ const usage: Record<string, unknown> = {
400
+ input: t.input,
401
+ output: t.output,
402
+ cacheRead: t.cache?.read,
403
+ cacheWrite: t.cache?.write,
404
+ cost: (obj as Record<string, unknown>).cost,
405
+ };
406
+ if (!hasPositiveUsage(usage)) return null;
407
+ const provider = typeof obj.providerID === 'string' && obj.providerID ? obj.providerID : 'unknown';
408
+ const model = typeof obj.modelID === 'string' && obj.modelID ? obj.modelID : 'unknown';
409
+ const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
410
+ const created = num(obj.time?.created);
411
+ const completed = num(obj.time?.completed);
412
+ return {
413
+ model: `opencode/${provider}/${model}`,
414
+ usage,
415
+ ms: created,
416
+ durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
417
+ };
418
+ }
419
+
420
+ // One OpenCode message file into the shared accum. The msg id is the file name.
421
+ export function processOpencodeFile(accum: SessionAccum, text: string, file?: string): void {
422
+ let obj: unknown;
423
+ try {
424
+ obj = JSON.parse(text);
425
+ } catch {
426
+ return;
427
+ }
428
+ const parsed = classifyOpencodeMessage(obj as OpencodeMessage);
429
+ if (!parsed) return;
430
+ 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);
432
+ }
433
+
434
+ // One v2 message row into the shared accum. The msg id is the row primary key.
435
+ export function ingestOpencodeDbRow(accum: SessionAccum, row: OpencodeDbRow): void {
436
+ const parsed = classifyOpencodeMessage(row.row);
437
+ if (!parsed) return;
438
+ ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, undefined, 'opencode', row.id);
439
+ }
440
+
274
441
  // Fold `:free`/`-free` suffixes and case variants into one chart key.
275
442
  export function canonicalModelId(model: string): string {
276
443
  return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
277
444
  }
278
- // LiteLLM-style display: lowercase namespace, title-cased model segments with
279
- // version dots kept.
445
+ // Transcript row id when the host provides one; anything else is not an id.
446
+ export function messageRowId(row: unknown): string | undefined {
447
+ const id = (row as { id?: unknown }).id;
448
+ return typeof id === 'string' && id ? id : undefined;
449
+ }
450
+
451
+ // Vendor names mirror the dashboard map; CLI needs names only. Family first:
452
+ // gateway segments shadow it (minimaxai holds xai, gemma rides nvidia).
453
+ const VENDOR_RES: Array<[RegExp, string]> = [
454
+ [/inclusionai|ling-/i, 'InclusionAI'],
455
+ [/minimax/i, 'MiniMax'],
456
+ [/grok|xai/i, 'xAI'],
457
+ [/stealth|space-bunny/i, 'Stealth'],
458
+ [/openai|codex|gpt-|o1/i, 'OpenAI'],
459
+ [/muse|llama/i, 'Meta'],
460
+ [/deepseek/i, 'DeepSeek'],
461
+ [/qwen|qwq/i, 'Alibaba'],
462
+ [/glm|z-ai|zhipu/i, 'Z.ai'],
463
+ [/mimo/i, 'Xiaomi'],
464
+ [/kimi|moonshot/i, 'Moonshot'],
465
+ [/mistral/i, 'Mistral'],
466
+ [/claude|anthropic/i, 'Anthropic'],
467
+ [/gemini|google|gemma/i, 'Google'],
468
+ [/nemotron|nvidia/i, 'NVIDIA'],
469
+ [/devin|cognition|^swe[-/]/i, 'Cognition'],
470
+ ];
471
+
472
+ function modelVendor(model: string): string {
473
+ for (const [re, name] of VENDOR_RES) {
474
+ if (re.test(model)) return name;
475
+ }
476
+ return 'Other';
477
+ }
478
+
479
+ // Gateway variants of one model share a row under the display label.
480
+ export function foldModelsByLabel(
481
+ byModel: Record<string, TokenBreakdown>,
482
+ byModelMessages: Record<string, number>,
483
+ ): { byModel: Record<string, TokenBreakdown>; byModelMessages: Record<string, number> } {
484
+ const folded: Record<string, TokenBreakdown> = {};
485
+ const messages: Record<string, number> = {};
486
+ for (const [model, t] of Object.entries(byModel)) {
487
+ const label = displayModelId(model);
488
+ folded[label] ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
489
+ const into = folded[label];
490
+ into.input += t.input;
491
+ into.output += t.output;
492
+ into.cacheRead += t.cacheRead;
493
+ into.cacheWrite += t.cacheWrite;
494
+ messages[label] = (messages[label] ?? 0) + (byModelMessages[model] ?? 0);
495
+ }
496
+ return { byModel: folded, byModelMessages: messages };
497
+ }
498
+ // Vendor-first labels, same fold as the dashboard: "Anthropic - Claude - Haiku-4.5".
280
499
  export function displayModelId(model: string): string {
281
500
  const bare = model.replace(FREE_SUFFIX, '');
501
+ // The report already folds keys; folding again would lowercase the caps.
502
+ if (bare.includes(' - ')) return bare;
282
503
  const cap = (s: string): string => {
283
504
  const low = s.toLowerCase();
284
505
  if (low === 'openai') return 'OpenAI';
506
+ if (low === 'gpt') return 'GPT';
507
+ if (low === 'swe') return 'SWE';
285
508
  if (low === 'ai') return 'AI';
286
509
  if (/^\d+[a-z]+$/.test(low)) return low.toUpperCase();
287
510
  if (s.length <= 2) return s.toUpperCase();
@@ -289,21 +512,42 @@ export function displayModelId(model: string): string {
289
512
  };
290
513
  const seg = (s: string): string => s.split('.').map(cap).join('.');
291
514
  const words = (s: string): string => s.split(/[-_:]+/).filter(Boolean).map(seg).join('-');
292
- const slash = bare.indexOf('/');
293
- if (slash >= 0) return `${bare.slice(0, slash).toLowerCase()}/${words(bare.slice(slash + 1))}`;
294
- return words(bare);
515
+ const segs = bare.split('/').filter(Boolean);
516
+ const tail = segs[segs.length - 1] ?? bare;
517
+ // One free model under two spellings; fold the alpha variant onto it.
518
+ const pretty = /^space-bunny(-alpha)?$/i.test(tail) ? 'Space-Bunny' : words(tail);
519
+ const vendor = modelVendor(bare);
520
+ if (vendor === 'Other') return segs.length > 1 ? `${words(segs[segs.length - 2])} - ${pretty}` : pretty;
521
+ const claude = pretty.match(/^claude[-_](.+)$/i);
522
+ if (vendor === 'Anthropic' && claude) return `Anthropic - Claude - ${claude[1]}`;
523
+ if (pretty.toLowerCase().startsWith(vendor.toLowerCase())) return pretty;
524
+ return `${vendor} - ${pretty}`;
295
525
  }
296
526
 
297
527
  export function importSessionTokens(): SessionTokens {
298
528
  const accum = newSessionAccum();
299
529
  const files: string[] = [];
300
- walkJsonl(sessionsDir(), files, 2000);
301
- // An override means an isolated environment (tests, fixtures): only walk the
302
- // real codex dir when one is explicitly set.
303
- if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
304
- walkJsonl(codexSessionsDir(), files, 2000);
530
+ for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
531
+ // OpenCode message bodies are single JSON documents, not JSONL.
532
+ const ocFiles: string[] = [];
533
+ if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
534
+ walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
535
+ }
536
+ for (const file of ocFiles) {
537
+ let text: string;
538
+ try {
539
+ text = fs.readFileSync(file, 'utf8');
540
+ } catch {
541
+ continue;
542
+ }
543
+ processOpencodeFile(accum, text, file);
544
+ }
545
+ // v2 hosts write sqlite, not the JSON tree above.
546
+ if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DB !== undefined) {
547
+ for (const row of readOpencodeDbRows(opencodeDbPath(), 0)) ingestOpencodeDbRow(accum, row);
305
548
  }
306
549
  let codexProvider: string | null = null;
550
+ let codexModel: string | null = null;
307
551
  for (const file of files) {
308
552
  let text: string;
309
553
  try {
@@ -311,10 +555,24 @@ export function importSessionTokens(): SessionTokens {
311
555
  } catch {
312
556
  continue;
313
557
  }
314
- processSessionText(accum, { get codexProvider() { return codexProvider; }, set codexProvider(v: string | null) { codexProvider = v; } }, text);
558
+ processSessionText(accum, {
559
+ get codexProvider() { return codexProvider; },
560
+ set codexProvider(v: string | null) { codexProvider = v; },
561
+ get codexModel() { return codexModel; },
562
+ set codexModel(v: string | null) { codexModel = v; },
563
+ }, text, hostOfSessionFile(file));
315
564
  }
316
565
  accum.recent.sort((a, b) => b.t - a.t);
317
- return { messages: accum.messages, totals: accum.totals, byModel: accum.byModel, byDay: accum.byDay, byDayModel: accum.byDayModel, byTool: accum.byTool, byModelMessages: accum.byModelMessages, costMeasured: accum.costMeasured, recent: accum.recent.slice(0, RECENT_LIMIT) };
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) };
567
+ }
568
+
569
+ // Which agent wrote a session file, from the directory it sits in.
570
+ export function hostOfSessionFile(file: string): string {
571
+ const norm = file.replace(/\\/g, '/');
572
+ if (norm.includes('/.omp/')) return 'omp';
573
+ if (norm.includes('/.codex/')) return 'codex';
574
+ if (norm.includes('/opencode/')) return 'opencode';
575
+ return 'pi';
318
576
  }
319
577
 
320
578
  const adoptionFileCache = new Map<string, { size: number; mtimeMs: number; counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number } }>();
@@ -355,10 +613,7 @@ export function clearRtkAdoptionCache(): void {
355
613
 
356
614
  export function readRtkAdoption(): RtkAdoption {
357
615
  const files: string[] = [];
358
- walkJsonl(sessionsDir(), files, 2000);
359
- if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
360
- walkJsonl(codexSessionsDir(), files, 2000);
361
- }
616
+ for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
362
617
  const live = new Set(files);
363
618
  for (const file of adoptionFileCache.keys()) {
364
619
  if (!live.has(file)) adoptionFileCache.delete(file);
@@ -390,23 +645,30 @@ export function readRtkAdoption(): RtkAdoption {
390
645
  const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
391
646
  return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
392
647
  }
393
- export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null }, text: string): void {
648
+ export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null; codexModel?: string | null }, text: string, host?: string): void {
394
649
  for (const line of text.split('\n')) {
395
650
  if (!line.trim()) continue;
396
651
  try {
397
652
  const row = JSON.parse(line) as Parameters<typeof classifySessionLine>[0];
398
653
  const parsed = classifySessionLine(row);
654
+ const rid = messageRowId(row);
399
655
  if (parsed.kind === 'codex_provider') {
400
656
  if (parsed.provider) state.codexProvider = parsed.provider;
401
657
  continue;
402
658
  }
659
+ if (parsed.kind === 'codex_model') {
660
+ if (parsed.model) state.codexModel = parsed.model;
661
+ continue;
662
+ }
403
663
  if (parsed.kind === 'token_row') {
404
- const model = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
405
- ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, undefined, { st: 'completed' as RunStatus });
664
+ const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
665
+ // The provider alone told you nothing about which model ran.
666
+ 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);
406
668
  continue;
407
669
  }
408
670
  if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage) continue;
409
- ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools);
671
+ ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid);
410
672
  } catch { /* skip corrupt lines */ }
411
673
  }
412
674
  }
@@ -416,8 +678,8 @@ export function readRtkRecallDiagnostics(binary: string | null = resolveRtkBinar
416
678
  try {
417
679
  const configPath = process.env.RTK_CONFIG
418
680
  || (process.platform === 'darwin'
419
- ? path.join(os.homedir(), 'Library', 'Application Support', 'rtk', 'config.toml')
420
- : path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'rtk', 'config.toml'));
681
+ ? path.join(homeDir(), 'Library', 'Application Support', 'rtk', 'config.toml')
682
+ : path.join(process.env.XDG_DATA_HOME || path.join(homeDir(), '.local', 'share'), 'rtk', 'config.toml'));
421
683
  let config = '';
422
684
  try { config = fs.readFileSync(configPath, 'utf8'); } catch { /* use RTK CLI fallback */ }
423
685
  const modeMatch = /^\s*mode\s*=\s*"(sqlite|tee|disabled)"/m.exec(config);
@@ -429,8 +691,7 @@ export function readRtkRecallDiagnostics(binary: string | null = resolveRtkBinar
429
691
  return { mode: 'unknown', entries: 0, available: false };
430
692
  }
431
693
  }
432
- // Lead binary of a shell string: first segment past `cd` chains and VAR=x
433
- // assignments. Falls back to `bash`.
694
+ // Lead binary of a shell string: first segment past `cd` chains and VAR=x assignments. Falls back to `bash`.
434
695
  const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
435
696
  export function leadBinary(command: unknown): string {
436
697
  if (typeof command !== 'string' || !command.trim()) return 'bash';
@@ -493,8 +754,7 @@ export function readUsage(): UsageRow[] {
493
754
  return rows;
494
755
  }
495
756
 
496
- // Delete the ledger file and return the rows cleared. Tersio-owned data only:
497
- // transcripts and the RTK database are never touched.
757
+ // Delete the ledger file and return the rows cleared. Tersio-owned data only: transcripts and the RTK database are never touched.
498
758
  export function clearUsageLedger(): number {
499
759
  const rows = readUsage().length;
500
760
  try {