@krtclcdy/tersio 2.24.1 → 2.25.1

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 (62) hide show
  1. package/README.md +27 -21
  2. package/cli/banner.js +7 -4
  3. package/cli/common.js +14 -13
  4. package/cli/currency.js +1 -2
  5. package/cli/dashboard.js +57 -14
  6. package/cli/doctor-fix.js +88 -21
  7. package/cli/doctor.js +32 -24
  8. package/cli/hosts.js +19 -18
  9. package/cli/install.js +123 -52
  10. package/cli/interactive.js +29 -19
  11. package/cli/manifest.js +7 -0
  12. package/cli/profile.js +42 -11
  13. package/cli/rtk-wiring.js +28 -26
  14. package/cli/settings.js +37 -8
  15. package/cli/uninstall.js +40 -7
  16. package/cli/update.js +22 -14
  17. package/cli/usage.js +39 -14
  18. package/dashboard/dist/index.html +16 -16
  19. package/extensions/ai-addons-updater/index.js +69 -15
  20. package/extensions/ai-addons-updater/index.ts +60 -17
  21. package/extensions/caveman-session/index.js +28 -55
  22. package/extensions/caveman-session/index.ts +28 -55
  23. package/extensions/combo-toggle/index.js +40 -58
  24. package/extensions/combo-toggle/index.ts +38 -67
  25. package/extensions/lib/utils.js +60 -15
  26. package/extensions/lib/utils.ts +59 -15
  27. package/extensions/opencode/index.js +104 -0
  28. package/extensions/opencode/server.js +190 -0
  29. package/extensions/opencode/server.ts +200 -0
  30. package/extensions/rtk-session/index.js +20 -35
  31. package/extensions/rtk-session/index.ts +19 -34
  32. package/extensions/shared/carbon.js +1 -2
  33. package/extensions/shared/carbon.ts +1 -2
  34. package/extensions/shared/host.js +95 -46
  35. package/extensions/shared/host.ts +106 -50
  36. package/extensions/shared/plugin-settings.js +4 -6
  37. package/extensions/shared/plugin-settings.ts +4 -6
  38. package/extensions/shared/pricing.js +79 -25
  39. package/extensions/shared/pricing.ts +80 -26
  40. package/extensions/shared/rtk-gain.js +3 -5
  41. package/extensions/shared/rtk-gain.ts +3 -5
  42. package/extensions/shared/session-state.js +10 -33
  43. package/extensions/shared/session-state.ts +12 -38
  44. package/extensions/shared/status.js +38 -0
  45. package/extensions/shared/status.ts +45 -0
  46. package/extensions/shared/types.js +1 -2
  47. package/extensions/shared/types.ts +2 -2
  48. package/extensions/shared/usage-ledger.js +190 -56
  49. package/extensions/shared/usage-ledger.ts +194 -58
  50. package/extensions/shared/usage-store.js +228 -64
  51. package/extensions/shared/usage-store.ts +235 -67
  52. package/extensions/tersio-commands/index.js +4 -9
  53. package/extensions/tersio-commands/index.ts +4 -9
  54. package/package.json +8 -2
  55. package/tersio.js +3 -3
  56. package/cli/agent-hosts.js +0 -42
  57. package/cli/agents.js +0 -266
  58. package/cli/layer.js +0 -22
  59. package/cli/omp-layer.js +0 -51
  60. package/cli/pi-layer.js +0 -37
  61. package/cli/pi-wiring.js +0 -92
  62. 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;
@@ -79,6 +76,8 @@ export interface RecentRequest {
79
76
  st: RunStatus;
80
77
  code?: number;
81
78
  note?: string;
79
+ /** Message id when the host provides one (transcript row id, opencode msg file). */
80
+ id?: string;
82
81
  }
83
82
 
84
83
  export interface SessionTokens {
@@ -89,6 +88,7 @@ export interface SessionTokens {
89
88
  byDayModel: Record<string, Record<string, number>>;
90
89
  byTool: Record<string, number>;
91
90
  byModelMessages: Record<string, number>;
91
+ byHost: Record<string, Record<string, TokenBreakdown>>;
92
92
  costMeasured: number;
93
93
  recent: RecentRequest[];
94
94
  }
@@ -119,8 +119,7 @@ export function durOf(v: unknown): number | undefined {
119
119
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
120
120
  }
121
121
 
122
- // Measured cost: usage.cost.total, or a bare number in old fixtures. Anything
123
- // 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.
124
123
  export function costOf(usage: Record<string, unknown>): number | undefined {
125
124
  const c = usage.cost;
126
125
  if (typeof c === 'number') return Number.isFinite(c) ? c : undefined;
@@ -131,8 +130,7 @@ export function costOf(usage: Record<string, unknown>): number | undefined {
131
130
  return undefined;
132
131
  }
133
132
 
134
- // First line only: real messages carry multi-line provider errors, and this
135
- // ends up in a tooltip.
133
+ // First line only: real messages carry multi-line provider errors, and this ends up in a tooltip.
136
134
  export function statusOf(msg: { stopReason?: unknown; errorStatus?: unknown; errorMessage?: unknown; isError?: unknown }): { st: RunStatus; code?: number; note?: string } {
137
135
  const stop = typeof msg.stopReason === 'string' ? msg.stopReason : '';
138
136
  const note = typeof msg.errorMessage === 'string' && msg.errorMessage.trim()
@@ -156,11 +154,10 @@ function addInto(into: TokenBreakdown, u: { input?: unknown; output?: unknown; c
156
154
  export function sessionsDirs(): string[] {
157
155
  const override = process.env.TERSIO_SESSIONS_DIR;
158
156
  if (override) return [override];
159
- // Both hosts, not just the running one: a session on pi is missing from the
160
- // dashboard entirely if we stop at the first directory that exists.
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.
161
158
  const dirs = isPiProcess()
162
- ? [path.join(piAgentDir(), 'sessions'), path.join(os.homedir(), '.omp', 'agent', 'sessions')]
163
- : [path.join(os.homedir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
159
+ ? [path.join(piAgentDir(), 'sessions'), path.join(homeDir(), '.omp', 'agent', 'sessions')]
160
+ : [path.join(homeDir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
164
161
  const found = dirs.filter((dir) => fs.existsSync(dir));
165
162
  return found.length > 0 ? found : [dirs[0]];
166
163
  }
@@ -169,25 +166,93 @@ export function sessionsDir(): string {
169
166
  return sessionsDirs()[0];
170
167
  }
171
168
 
172
- export function codexSessionsDir(): string {
173
- const override = process.env.TERSIO_CODEX_DIR;
174
- if (override) return override;
175
- const codexHome = process.env.CODEX_HOME;
176
- if (codexHome) return path.join(codexHome, 'sessions');
177
- return path.join(os.homedir(), '.codex', 'sessions');
178
- }
179
-
180
169
  // One JSON file per message; probe XDG, then local, then macOS default.
181
170
  export function opencodeSessionsDir(): string {
182
171
  const override = process.env.TERSIO_OPENCODE_DIR;
183
172
  if (override) return override;
184
173
  const xdg = process.env.XDG_DATA_HOME;
185
174
  if (xdg && xdg.trim() !== '') return path.join(xdg, 'opencode', 'storage', 'message');
186
- const local = path.join(os.homedir(), '.local', 'share', 'opencode', 'storage', 'message');
175
+ const local = path.join(homeDir(), '.local', 'share', 'opencode', 'storage', 'message');
187
176
  try {
188
177
  if (fs.existsSync(local)) return local;
189
178
  } catch { /* fall through to the platform default */ }
190
- return path.join(os.homedir(), 'Library', 'Application Support', 'opencode', 'storage', 'message');
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
+ }
191
256
  }
192
257
 
193
258
  export function dayKey(ts: string | number): string | null {
@@ -211,32 +276,31 @@ export function walkJsonl(dir: string, out: string[], cap: number, ext = '.jsonl
211
276
  else if (e.isFile() && e.name.endsWith(ext)) out.push(full);
212
277
  }
213
278
  }
214
- // Recent requests get their own full-width table, so this bounds payload
215
- // rather than highlights; rows are small, so a few hundred cost little.
216
- 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;
217
281
  const FREE_SUFFIX = /(?::free|-free)$/i;
218
282
  const RTK_ELIGIBLE_HEADS = new Set([
219
283
  'rtk', 'git', 'grep', 'rg', 'find', 'cat', 'ls', 'tree', 'diff', 'log',
220
284
  'npm', 'npx', 'pnpm', 'bun', 'bunx', 'cargo', 'go', 'python', 'pytest',
221
285
  'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
222
286
  ]);
223
- // Parse buffer shared by live reads and usage.db syncs, so the store is a
224
- // 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.
225
288
  export interface SessionAccum {
226
289
  byModel: Record<string, TokenBreakdown>;
227
290
  byDay: Record<string, TokenBreakdown>;
228
291
  byDayModel: Record<string, Record<string, number>>;
229
292
  byTool: Record<string, number>;
230
293
  byModelMessages: Record<string, number>;
294
+ byHost: Record<string, Record<string, TokenBreakdown>>;
231
295
  totals: TokenBreakdown;
232
296
  messages: number;
233
297
  costMeasured: number;
234
298
  recent: RecentRequest[];
235
299
  }
236
300
  export function newSessionAccum(): SessionAccum {
237
- 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: [] };
238
302
  }
239
- 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): 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 {
240
304
  const watermark = readResetWatermark();
241
305
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
242
306
  if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark)) return false;
@@ -245,10 +309,15 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
245
309
  accum.byModel[key] ??= zeroBreakdown();
246
310
  addInto(accum.byModel[key], usage);
247
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
+ }
248
317
  const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
249
318
  if (Number.isFinite(ms)) {
250
319
  const outcome = run ?? { st: 'completed' as RunStatus };
251
- 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 });
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 });
252
321
  }
253
322
  const day = ts !== undefined ? dayKey(ts) : null;
254
323
  if (day) {
@@ -348,8 +417,8 @@ export function classifyOpencodeMessage(obj: OpencodeMessage | null | undefined)
348
417
  };
349
418
  }
350
419
 
351
- // One OpenCode message file into the shared accum.
352
- export function processOpencodeFile(accum: SessionAccum, text: string): void {
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 {
353
422
  let obj: unknown;
354
423
  try {
355
424
  obj = JSON.parse(text);
@@ -358,20 +427,84 @@ export function processOpencodeFile(accum: SessionAccum, text: string): void {
358
427
  }
359
428
  const parsed = classifyOpencodeMessage(obj as OpencodeMessage);
360
429
  if (!parsed) return;
361
- ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, undefined, 'opencode');
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);
362
439
  }
363
440
 
364
441
  // Fold `:free`/`-free` suffixes and case variants into one chart key.
365
442
  export function canonicalModelId(model: string): string {
366
443
  return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
367
444
  }
368
- // LiteLLM-style display: lowercase namespace, title-cased model segments with
369
- // 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".
370
499
  export function displayModelId(model: string): string {
371
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;
372
503
  const cap = (s: string): string => {
373
504
  const low = s.toLowerCase();
374
505
  if (low === 'openai') return 'OpenAI';
506
+ if (low === 'gpt') return 'GPT';
507
+ if (low === 'swe') return 'SWE';
375
508
  if (low === 'ai') return 'AI';
376
509
  if (/^\d+[a-z]+$/.test(low)) return low.toUpperCase();
377
510
  if (s.length <= 2) return s.toUpperCase();
@@ -379,20 +512,22 @@ export function displayModelId(model: string): string {
379
512
  };
380
513
  const seg = (s: string): string => s.split('.').map(cap).join('.');
381
514
  const words = (s: string): string => s.split(/[-_:]+/).filter(Boolean).map(seg).join('-');
382
- const slash = bare.indexOf('/');
383
- if (slash >= 0) return `${bare.slice(0, slash).toLowerCase()}/${words(bare.slice(slash + 1))}`;
384
- 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}`;
385
525
  }
386
526
 
387
527
  export function importSessionTokens(): SessionTokens {
388
528
  const accum = newSessionAccum();
389
529
  const files: string[] = [];
390
530
  for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
391
- // An override means an isolated environment (tests, fixtures): only walk the
392
- // real codex dir when one is explicitly set.
393
- if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
394
- walkJsonl(codexSessionsDir(), files, 2000);
395
- }
396
531
  // OpenCode message bodies are single JSON documents, not JSONL.
397
532
  const ocFiles: string[] = [];
398
533
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
@@ -405,7 +540,11 @@ export function importSessionTokens(): SessionTokens {
405
540
  } catch {
406
541
  continue;
407
542
  }
408
- processOpencodeFile(accum, text);
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);
409
548
  }
410
549
  let codexProvider: string | null = null;
411
550
  let codexModel: string | null = null;
@@ -424,7 +563,7 @@ export function importSessionTokens(): SessionTokens {
424
563
  }, text, hostOfSessionFile(file));
425
564
  }
426
565
  accum.recent.sort((a, b) => b.t - a.t);
427
- 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) };
428
567
  }
429
568
 
430
569
  // Which agent wrote a session file, from the directory it sits in.
@@ -432,6 +571,7 @@ export function hostOfSessionFile(file: string): string {
432
571
  const norm = file.replace(/\\/g, '/');
433
572
  if (norm.includes('/.omp/')) return 'omp';
434
573
  if (norm.includes('/.codex/')) return 'codex';
574
+ if (norm.includes('/opencode/')) return 'opencode';
435
575
  return 'pi';
436
576
  }
437
577
 
@@ -474,9 +614,6 @@ export function clearRtkAdoptionCache(): void {
474
614
  export function readRtkAdoption(): RtkAdoption {
475
615
  const files: string[] = [];
476
616
  for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
477
- if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
478
- walkJsonl(codexSessionsDir(), files, 2000);
479
- }
480
617
  const live = new Set(files);
481
618
  for (const file of adoptionFileCache.keys()) {
482
619
  if (!live.has(file)) adoptionFileCache.delete(file);
@@ -514,6 +651,7 @@ export function processSessionText(accum: SessionAccum, state: { codexProvider:
514
651
  try {
515
652
  const row = JSON.parse(line) as Parameters<typeof classifySessionLine>[0];
516
653
  const parsed = classifySessionLine(row);
654
+ const rid = messageRowId(row);
517
655
  if (parsed.kind === 'codex_provider') {
518
656
  if (parsed.provider) state.codexProvider = parsed.provider;
519
657
  continue;
@@ -526,11 +664,11 @@ export function processSessionText(accum: SessionAccum, state: { codexProvider:
526
664
  const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
527
665
  // The provider alone told you nothing about which model ran.
528
666
  const model = state.codexModel ? `${base}/${state.codexModel}` : base;
529
- ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' as RunStatus }, undefined, host ?? 'codex');
667
+ ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' as RunStatus }, undefined, host ?? 'codex', rid);
530
668
  continue;
531
669
  }
532
670
  if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage) continue;
533
- ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host);
671
+ ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid);
534
672
  } catch { /* skip corrupt lines */ }
535
673
  }
536
674
  }
@@ -540,8 +678,8 @@ export function readRtkRecallDiagnostics(binary: string | null = resolveRtkBinar
540
678
  try {
541
679
  const configPath = process.env.RTK_CONFIG
542
680
  || (process.platform === 'darwin'
543
- ? path.join(os.homedir(), 'Library', 'Application Support', 'rtk', 'config.toml')
544
- : 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'));
545
683
  let config = '';
546
684
  try { config = fs.readFileSync(configPath, 'utf8'); } catch { /* use RTK CLI fallback */ }
547
685
  const modeMatch = /^\s*mode\s*=\s*"(sqlite|tee|disabled)"/m.exec(config);
@@ -553,8 +691,7 @@ export function readRtkRecallDiagnostics(binary: string | null = resolveRtkBinar
553
691
  return { mode: 'unknown', entries: 0, available: false };
554
692
  }
555
693
  }
556
- // Lead binary of a shell string: first segment past `cd` chains and VAR=x
557
- // 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`.
558
695
  const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
559
696
  export function leadBinary(command: unknown): string {
560
697
  if (typeof command !== 'string' || !command.trim()) return 'bash';
@@ -617,8 +754,7 @@ export function readUsage(): UsageRow[] {
617
754
  return rows;
618
755
  }
619
756
 
620
- // Delete the ledger file and return the rows cleared. Tersio-owned data only:
621
- // 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.
622
758
  export function clearUsageLedger(): number {
623
759
  const rows = readUsage().length;
624
760
  try {