@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.js';
4
+ import { homeDir, isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from '../lib/utils.js';
7
5
  import { execFileSync } from 'node:child_process';
8
6
  export function ledgerPath() {
9
7
  const override = process.env.TERSIO_USAGE_FILE;
@@ -46,11 +44,13 @@ export function appendUsage(kind, detail) {
46
44
  function zeroBreakdown() {
47
45
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
48
46
  }
47
+ function stampMs(ts) {
48
+ return ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
49
+ }
49
50
  export function durOf(v) {
50
51
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
51
52
  }
52
- // Measured cost: usage.cost.total, or a bare number in old fixtures. Anything
53
- // else means unrecorded, which is deliberately not 0.
53
+ // Measured cost: usage.cost.total, or a bare number in old fixtures. Anything else means unrecorded, which is deliberately not 0.
54
54
  export function costOf(usage) {
55
55
  const c = usage.cost;
56
56
  if (typeof c === 'number')
@@ -62,8 +62,7 @@ export function costOf(usage) {
62
62
  }
63
63
  return undefined;
64
64
  }
65
- // First line only: real messages carry multi-line provider errors, and this
66
- // ends up in a tooltip.
65
+ // First line only: real messages carry multi-line provider errors, and this ends up in a tooltip.
67
66
  export function statusOf(msg) {
68
67
  const stop = typeof msg.stopReason === 'string' ? msg.stopReason : '';
69
68
  const note = typeof msg.errorMessage === 'string' && msg.errorMessage.trim()
@@ -84,24 +83,110 @@ function addInto(into, u) {
84
83
  into[k] += Math.floor(v);
85
84
  }
86
85
  }
87
- export function sessionsDir() {
86
+ export function sessionsDirs() {
88
87
  const override = process.env.TERSIO_SESSIONS_DIR;
89
88
  if (override)
90
- return override;
91
- // The running host wins; the other host's directory is the fallback.
89
+ return [override];
90
+ // Both hosts, not just the running one: a session on pi is missing from the dashboard entirely if we stop at the first directory that exists.
92
91
  const dirs = isPiProcess()
93
- ? [path.join(piAgentDir(), 'sessions'), path.join(os.homedir(), '.omp', 'agent', 'sessions')]
94
- : [path.join(os.homedir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
95
- return dirs.find((dir) => fs.existsSync(dir)) ?? dirs[0];
92
+ ? [path.join(piAgentDir(), 'sessions'), path.join(homeDir(), '.omp', 'agent', 'sessions')]
93
+ : [path.join(homeDir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
94
+ const found = dirs.filter((dir) => fs.existsSync(dir));
95
+ return found.length > 0 ? found : [dirs[0]];
96
+ }
97
+ export function sessionsDir() {
98
+ return sessionsDirs()[0];
96
99
  }
97
- export function codexSessionsDir() {
98
- const override = process.env.TERSIO_CODEX_DIR;
100
+ // One JSON file per message; probe XDG, then local, then macOS default.
101
+ export function opencodeSessionsDir() {
102
+ const override = process.env.TERSIO_OPENCODE_DIR;
99
103
  if (override)
100
104
  return override;
101
- const codexHome = process.env.CODEX_HOME;
102
- if (codexHome)
103
- return path.join(codexHome, 'sessions');
104
- return path.join(os.homedir(), '.codex', 'sessions');
105
+ const xdg = process.env.XDG_DATA_HOME;
106
+ if (xdg && xdg.trim() !== '')
107
+ return path.join(xdg, 'opencode', 'storage', 'message');
108
+ const local = path.join(homeDir(), '.local', 'share', 'opencode', 'storage', 'message');
109
+ try {
110
+ if (fs.existsSync(local))
111
+ return local;
112
+ }
113
+ catch { /* fall through to the platform default */ }
114
+ return path.join(homeDir(), 'Library', 'Application Support', 'opencode', 'storage', 'message');
115
+ }
116
+ // OpenCode v2 keeps messages in sqlite; the JSON tree above is only the pre-v2 layout.
117
+ export function opencodeDbPath() {
118
+ const override = process.env.TERSIO_OPENCODE_DB;
119
+ if (override)
120
+ return override;
121
+ const xdg = process.env.XDG_DATA_HOME;
122
+ if (xdg && xdg.trim() !== '')
123
+ return path.join(xdg, 'opencode', 'opencode.db');
124
+ const local = path.join(homeDir(), '.local', 'share', 'opencode', 'opencode.db');
125
+ try {
126
+ if (fs.existsSync(local))
127
+ return local;
128
+ }
129
+ catch { /* fall through to the platform default */ }
130
+ return path.join(homeDir(), 'Library', 'Application Support', 'opencode', 'opencode.db');
131
+ }
132
+ // Two on-disk shapes: legacy `message` (role/modelID/providerID live inside `data`),
133
+ // current `session_message` (role in the `type` column, model under data.model).
134
+ // Scalars only: the `data` blob holds newlines and tabs, which the sqlite3 CLI would split.
135
+ const OC_DB_ORDER = ' ORDER BY time_created;';
136
+ const OC_DB_COLUMNS = 'json_extract(data,\'$.time.created\'), json_extract(data,\'$.time.completed\'),' +
137
+ ' json_extract(data,\'$.tokens.input\'), json_extract(data,\'$.tokens.output\'),' +
138
+ ' json_extract(data,\'$.tokens.cache.read\'), json_extract(data,\'$.tokens.cache.write\'), json_extract(data,\'$.cost\')';
139
+ const ocDbLegacy = (since) => `SELECT id, time_created, json_extract(data,'$.role'), json_extract(data,'$.providerID'), json_extract(data,'$.modelID'), ${OC_DB_COLUMNS}` +
140
+ ` FROM message WHERE json_extract(data,'$.role')='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
141
+ const ocDbCurrent = (since) => `SELECT id, time_created, 'assistant', json_extract(data,'$.model.providerID'), json_extract(data,'$.model.id'), ${OC_DB_COLUMNS}` +
142
+ ` FROM session_message WHERE type='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
143
+ // A re-scan window covers tokens that land after the row was created, so a late fill is not lost.
144
+ export const OPENCODE_DB_OVERLAP_MS = 3_600_000;
145
+ // execFileSync caps output at 1MB; a busy table clears it in one SELECT.
146
+ export const SQLITE_READ_BUFFER = 50 * 1024 * 1024;
147
+ // Empty when sqlite3 is absent or the db is unreadable; that is a sync skip, not a failure.
148
+ export function readOpencodeDbRows(db, sinceMs) {
149
+ if (!Number.isFinite(sinceMs))
150
+ return [];
151
+ try {
152
+ if (!fs.existsSync(db))
153
+ return [];
154
+ const since = Math.max(0, Math.floor(sinceMs));
155
+ // Separate calls: a db holding only one table must not cancel the other.
156
+ const run = (sql) => execFileSync('sqlite3', ['-separator', '\t', '-readonly', db, sql], { encoding: 'utf8', timeout: 30000, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: SQLITE_READ_BUFFER });
157
+ let out = '';
158
+ try {
159
+ out += run(ocDbLegacy(since));
160
+ }
161
+ catch { /* no legacy table or unreadable */ }
162
+ try {
163
+ out += run(ocDbCurrent(since));
164
+ }
165
+ catch { /* no session_message table or unreadable */ }
166
+ const rows = [];
167
+ for (const line of out.split('\n')) {
168
+ if (!line.trim())
169
+ continue;
170
+ const [id, created, role, providerID, modelID, tCreated, tCompleted, input, output, cacheRead, cacheWrite, cost] = line.split('\t');
171
+ const n = (v) => (v !== undefined && v !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined);
172
+ rows.push({
173
+ id,
174
+ created: Number(created),
175
+ row: {
176
+ role,
177
+ modelID,
178
+ providerID,
179
+ tokens: { input: n(input), output: n(output), cache: { read: n(cacheRead), write: n(cacheWrite) } },
180
+ time: { created: n(tCreated), completed: n(tCompleted) },
181
+ cost: n(cost),
182
+ },
183
+ });
184
+ }
185
+ return rows;
186
+ }
187
+ catch {
188
+ return [];
189
+ }
105
190
  }
106
191
  export function dayKey(ts) {
107
192
  const ms = typeof ts === 'number' ? ts : Date.parse(ts);
@@ -111,7 +196,7 @@ export function dayKey(ts) {
111
196
  const p = (n) => String(n).padStart(2, '0');
112
197
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
113
198
  }
114
- export function walkJsonl(dir, out, cap) {
199
+ export function walkJsonl(dir, out, cap, ext = '.jsonl') {
115
200
  let entries;
116
201
  try {
117
202
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -124,14 +209,13 @@ export function walkJsonl(dir, out, cap) {
124
209
  return;
125
210
  const full = path.join(dir, e.name);
126
211
  if (e.isDirectory())
127
- walkJsonl(full, out, cap);
128
- else if (e.isFile() && e.name.endsWith('.jsonl'))
212
+ walkJsonl(full, out, cap, ext);
213
+ else if (e.isFile() && e.name.endsWith(ext))
129
214
  out.push(full);
130
215
  }
131
216
  }
132
- // Recent requests get their own full-width table, so this bounds payload
133
- // rather than highlights; rows are small, so a few hundred cost little.
134
- export const RECENT_LIMIT = 200;
217
+ // Recent rows are a newest-first window over lifetime history; aggregates keep everything.
218
+ export const RECENT_LIMIT = 2000;
135
219
  const FREE_SUFFIX = /(?::free|-free)$/i;
136
220
  const RTK_ELIGIBLE_HEADS = new Set([
137
221
  'rtk', 'git', 'grep', 'rg', 'find', 'cat', 'ls', 'tree', 'diff', 'log',
@@ -139,9 +223,9 @@ const RTK_ELIGIBLE_HEADS = new Set([
139
223
  'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
140
224
  ]);
141
225
  export function newSessionAccum() {
142
- return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
226
+ return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
143
227
  }
144
- export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames) {
228
+ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host, id) {
145
229
  const watermark = readResetWatermark();
146
230
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
147
231
  if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
@@ -151,10 +235,15 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames)
151
235
  accum.byModel[key] ??= zeroBreakdown();
152
236
  addInto(accum.byModel[key], usage);
153
237
  accum.byModelMessages[key] = (accum.byModelMessages[key] ?? 0) + 1;
238
+ if (host) {
239
+ const hs = (accum.byHost[key] ??= {});
240
+ hs[host] ??= zeroBreakdown();
241
+ addInto(hs[host], usage);
242
+ }
154
243
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
155
244
  if (Number.isFinite(ms)) {
156
245
  const outcome = run ?? { st: 'completed' };
157
- 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 });
246
+ accum.recent.push({ m: key, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), h: host, cr: num(usage.cacheRead), cw: num(usage.cacheWrite), usd: costOf(usage), st: outcome.st, code: outcome.code, note: outcome.note, id });
158
247
  }
159
248
  const day = ts !== undefined ? dayKey(ts) : null;
160
249
  if (day) {
@@ -179,6 +268,10 @@ export function classifySessionLine(row) {
179
268
  if (payload && typeof payload === 'object') {
180
269
  if (row.type === 'session_meta' && typeof payload.model_provider === 'string')
181
270
  return { kind: 'codex_provider', provider: payload.model_provider };
271
+ // A Codex transcript states the model once per turn, in turn_context.
272
+ if (row.type === 'turn_context' && typeof payload.model === 'string' && payload.model) {
273
+ return { kind: 'codex_model', model: payload.model };
274
+ }
182
275
  if (payload.type === 'token_count') {
183
276
  const last = payload.info?.last_token_usage;
184
277
  if (last) {
@@ -192,7 +285,10 @@ export function classifySessionLine(row) {
192
285
  if (!msg || msg.role !== 'assistant' || !msg.usage)
193
286
  return { kind: 'skip' };
194
287
  const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
195
- const computedDur = (typeof msg.completedAt === 'number' && typeof msg.timestamp === 'number') ? msg.completedAt - msg.timestamp : undefined;
288
+ // pi omits duration, so derive Elapsed/Speed from the timestamp pair.
289
+ const startMs = typeof msg.timestamp === 'number' ? msg.timestamp : undefined;
290
+ const endMs = typeof msg.completedAt === 'number' ? msg.completedAt : stampMs(row.timestamp);
291
+ const computedDur = startMs !== undefined && Number.isFinite(endMs) && endMs > startMs ? endMs - startMs : undefined;
196
292
  const tools = [];
197
293
  for (const part of msg.content ?? []) {
198
294
  if (part?.type !== 'toolCall' || typeof part.name !== 'string' || !part.name)
@@ -206,18 +302,131 @@ export function classifySessionLine(row) {
206
302
  }
207
303
  return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
208
304
  }
305
+ // True when at least one token bucket holds a positive finite count.
306
+ export function hasPositiveUsage(usage) {
307
+ for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
308
+ const v = usage[k];
309
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0)
310
+ return true;
311
+ }
312
+ return false;
313
+ }
314
+ // One OpenCode message file; assistant rows carry the token buckets.
315
+ export function classifyOpencodeMessage(obj) {
316
+ if (!obj || typeof obj !== 'object' || obj.role !== 'assistant')
317
+ return null;
318
+ const t = obj.tokens;
319
+ if (!t || typeof t !== 'object')
320
+ return null;
321
+ const usage = {
322
+ input: t.input,
323
+ output: t.output,
324
+ cacheRead: t.cache?.read,
325
+ cacheWrite: t.cache?.write,
326
+ cost: obj.cost,
327
+ };
328
+ if (!hasPositiveUsage(usage))
329
+ return null;
330
+ const provider = typeof obj.providerID === 'string' && obj.providerID ? obj.providerID : 'unknown';
331
+ const model = typeof obj.modelID === 'string' && obj.modelID ? obj.modelID : 'unknown';
332
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
333
+ const created = num(obj.time?.created);
334
+ const completed = num(obj.time?.completed);
335
+ return {
336
+ model: `opencode/${provider}/${model}`,
337
+ usage,
338
+ ms: created,
339
+ durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
340
+ };
341
+ }
342
+ // One OpenCode message file into the shared accum. The msg id is the file name.
343
+ export function processOpencodeFile(accum, text, file) {
344
+ let obj;
345
+ try {
346
+ obj = JSON.parse(text);
347
+ }
348
+ catch {
349
+ return;
350
+ }
351
+ const parsed = classifyOpencodeMessage(obj);
352
+ if (!parsed)
353
+ return;
354
+ const id = file ? path.basename(file).replace(/\.[^.]+$/, '') || undefined : undefined;
355
+ ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode', id);
356
+ }
357
+ // One v2 message row into the shared accum. The msg id is the row primary key.
358
+ export function ingestOpencodeDbRow(accum, row) {
359
+ const parsed = classifyOpencodeMessage(row.row);
360
+ if (!parsed)
361
+ return;
362
+ ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode', row.id);
363
+ }
209
364
  // Fold `:free`/`-free` suffixes and case variants into one chart key.
210
365
  export function canonicalModelId(model) {
211
366
  return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
212
367
  }
213
- // LiteLLM-style display: lowercase namespace, title-cased model segments with
214
- // version dots kept.
368
+ // Transcript row id when the host provides one; anything else is not an id.
369
+ export function messageRowId(row) {
370
+ const id = row.id;
371
+ return typeof id === 'string' && id ? id : undefined;
372
+ }
373
+ // Vendor names mirror the dashboard map; CLI needs names only. Family first:
374
+ // gateway segments shadow it (minimaxai holds xai, gemma rides nvidia).
375
+ const VENDOR_RES = [
376
+ [/inclusionai|ling-/i, 'InclusionAI'],
377
+ [/minimax/i, 'MiniMax'],
378
+ [/grok|xai/i, 'xAI'],
379
+ [/stealth|space-bunny/i, 'Stealth'],
380
+ [/openai|codex|gpt-|o1/i, 'OpenAI'],
381
+ [/muse|llama/i, 'Meta'],
382
+ [/deepseek/i, 'DeepSeek'],
383
+ [/qwen|qwq/i, 'Alibaba'],
384
+ [/glm|z-ai|zhipu/i, 'Z.ai'],
385
+ [/mimo/i, 'Xiaomi'],
386
+ [/kimi|moonshot/i, 'Moonshot'],
387
+ [/mistral/i, 'Mistral'],
388
+ [/claude|anthropic/i, 'Anthropic'],
389
+ [/gemini|google|gemma/i, 'Google'],
390
+ [/nemotron|nvidia/i, 'NVIDIA'],
391
+ [/devin|cognition|^swe[-/]/i, 'Cognition'],
392
+ ];
393
+ function modelVendor(model) {
394
+ for (const [re, name] of VENDOR_RES) {
395
+ if (re.test(model))
396
+ return name;
397
+ }
398
+ return 'Other';
399
+ }
400
+ // Gateway variants of one model share a row under the display label.
401
+ export function foldModelsByLabel(byModel, byModelMessages) {
402
+ const folded = {};
403
+ const messages = {};
404
+ for (const [model, t] of Object.entries(byModel)) {
405
+ const label = displayModelId(model);
406
+ folded[label] ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
407
+ const into = folded[label];
408
+ into.input += t.input;
409
+ into.output += t.output;
410
+ into.cacheRead += t.cacheRead;
411
+ into.cacheWrite += t.cacheWrite;
412
+ messages[label] = (messages[label] ?? 0) + (byModelMessages[model] ?? 0);
413
+ }
414
+ return { byModel: folded, byModelMessages: messages };
415
+ }
416
+ // Vendor-first labels, same fold as the dashboard: "Anthropic - Claude - Haiku-4.5".
215
417
  export function displayModelId(model) {
216
418
  const bare = model.replace(FREE_SUFFIX, '');
419
+ // The report already folds keys; folding again would lowercase the caps.
420
+ if (bare.includes(' - '))
421
+ return bare;
217
422
  const cap = (s) => {
218
423
  const low = s.toLowerCase();
219
424
  if (low === 'openai')
220
425
  return 'OpenAI';
426
+ if (low === 'gpt')
427
+ return 'GPT';
428
+ if (low === 'swe')
429
+ return 'SWE';
221
430
  if (low === 'ai')
222
431
  return 'AI';
223
432
  if (/^\d+[a-z]+$/.test(low))
@@ -228,21 +437,47 @@ export function displayModelId(model) {
228
437
  };
229
438
  const seg = (s) => s.split('.').map(cap).join('.');
230
439
  const words = (s) => s.split(/[-_:]+/).filter(Boolean).map(seg).join('-');
231
- const slash = bare.indexOf('/');
232
- if (slash >= 0)
233
- return `${bare.slice(0, slash).toLowerCase()}/${words(bare.slice(slash + 1))}`;
234
- return words(bare);
440
+ const segs = bare.split('/').filter(Boolean);
441
+ const tail = segs[segs.length - 1] ?? bare;
442
+ // One free model under two spellings; fold the alpha variant onto it.
443
+ const pretty = /^space-bunny(-alpha)?$/i.test(tail) ? 'Space-Bunny' : words(tail);
444
+ const vendor = modelVendor(bare);
445
+ if (vendor === 'Other')
446
+ return segs.length > 1 ? `${words(segs[segs.length - 2])} - ${pretty}` : pretty;
447
+ const claude = pretty.match(/^claude[-_](.+)$/i);
448
+ if (vendor === 'Anthropic' && claude)
449
+ return `Anthropic - Claude - ${claude[1]}`;
450
+ if (pretty.toLowerCase().startsWith(vendor.toLowerCase()))
451
+ return pretty;
452
+ return `${vendor} - ${pretty}`;
235
453
  }
236
454
  export function importSessionTokens() {
237
455
  const accum = newSessionAccum();
238
456
  const files = [];
239
- walkJsonl(sessionsDir(), files, 2000);
240
- // An override means an isolated environment (tests, fixtures): only walk the
241
- // real codex dir when one is explicitly set.
242
- if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
243
- walkJsonl(codexSessionsDir(), files, 2000);
457
+ for (const dir of sessionsDirs())
458
+ walkJsonl(dir, files, 2000);
459
+ // OpenCode message bodies are single JSON documents, not JSONL.
460
+ const ocFiles = [];
461
+ if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
462
+ walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
463
+ }
464
+ for (const file of ocFiles) {
465
+ let text;
466
+ try {
467
+ text = fs.readFileSync(file, 'utf8');
468
+ }
469
+ catch {
470
+ continue;
471
+ }
472
+ processOpencodeFile(accum, text, file);
473
+ }
474
+ // v2 hosts write sqlite, not the JSON tree above.
475
+ if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DB !== undefined) {
476
+ for (const row of readOpencodeDbRows(opencodeDbPath(), 0))
477
+ ingestOpencodeDbRow(accum, row);
244
478
  }
245
479
  let codexProvider = null;
480
+ let codexModel = null;
246
481
  for (const file of files) {
247
482
  let text;
248
483
  try {
@@ -251,10 +486,26 @@ export function importSessionTokens() {
251
486
  catch {
252
487
  continue;
253
488
  }
254
- processSessionText(accum, { get codexProvider() { return codexProvider; }, set codexProvider(v) { codexProvider = v; } }, text);
489
+ processSessionText(accum, {
490
+ get codexProvider() { return codexProvider; },
491
+ set codexProvider(v) { codexProvider = v; },
492
+ get codexModel() { return codexModel; },
493
+ set codexModel(v) { codexModel = v; },
494
+ }, text, hostOfSessionFile(file));
255
495
  }
256
496
  accum.recent.sort((a, b) => b.t - a.t);
257
- 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) };
497
+ return { messages: accum.messages, totals: accum.totals, byModel: accum.byModel, byDay: accum.byDay, byDayModel: accum.byDayModel, byTool: accum.byTool, byModelMessages: accum.byModelMessages, byHost: accum.byHost, costMeasured: accum.costMeasured, recent: accum.recent.slice(0, RECENT_LIMIT) };
498
+ }
499
+ // Which agent wrote a session file, from the directory it sits in.
500
+ export function hostOfSessionFile(file) {
501
+ const norm = file.replace(/\\/g, '/');
502
+ if (norm.includes('/.omp/'))
503
+ return 'omp';
504
+ if (norm.includes('/.codex/'))
505
+ return 'codex';
506
+ if (norm.includes('/opencode/'))
507
+ return 'opencode';
508
+ return 'pi';
258
509
  }
259
510
  const adoptionFileCache = new Map();
260
511
  function parseAdoptionFile(file) {
@@ -294,10 +545,8 @@ export function clearRtkAdoptionCache() {
294
545
  }
295
546
  export function readRtkAdoption() {
296
547
  const files = [];
297
- walkJsonl(sessionsDir(), files, 2000);
298
- if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
299
- walkJsonl(codexSessionsDir(), files, 2000);
300
- }
548
+ for (const dir of sessionsDirs())
549
+ walkJsonl(dir, files, 2000);
301
550
  const live = new Set(files);
302
551
  for (const file of adoptionFileCache.keys()) {
303
552
  if (!live.has(file))
@@ -332,26 +581,34 @@ export function readRtkAdoption() {
332
581
  const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
333
582
  return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
334
583
  }
335
- export function processSessionText(accum, state, text) {
584
+ export function processSessionText(accum, state, text, host) {
336
585
  for (const line of text.split('\n')) {
337
586
  if (!line.trim())
338
587
  continue;
339
588
  try {
340
589
  const row = JSON.parse(line);
341
590
  const parsed = classifySessionLine(row);
591
+ const rid = messageRowId(row);
342
592
  if (parsed.kind === 'codex_provider') {
343
593
  if (parsed.provider)
344
594
  state.codexProvider = parsed.provider;
345
595
  continue;
346
596
  }
597
+ if (parsed.kind === 'codex_model') {
598
+ if (parsed.model)
599
+ state.codexModel = parsed.model;
600
+ continue;
601
+ }
347
602
  if (parsed.kind === 'token_row') {
348
- const model = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
349
- ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, undefined, { st: 'completed' });
603
+ const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
604
+ // The provider alone told you nothing about which model ran.
605
+ const model = state.codexModel ? `${base}/${state.codexModel}` : base;
606
+ ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' }, undefined, host ?? 'codex', rid);
350
607
  continue;
351
608
  }
352
609
  if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
353
610
  continue;
354
- ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools);
611
+ ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid);
355
612
  }
356
613
  catch { /* skip corrupt lines */ }
357
614
  }
@@ -362,8 +619,8 @@ export function readRtkRecallDiagnostics(binary = resolveRtkBinary()) {
362
619
  try {
363
620
  const configPath = process.env.RTK_CONFIG
364
621
  || (process.platform === 'darwin'
365
- ? path.join(os.homedir(), 'Library', 'Application Support', 'rtk', 'config.toml')
366
- : path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'rtk', 'config.toml'));
622
+ ? path.join(homeDir(), 'Library', 'Application Support', 'rtk', 'config.toml')
623
+ : path.join(process.env.XDG_DATA_HOME || path.join(homeDir(), '.local', 'share'), 'rtk', 'config.toml'));
367
624
  let config = '';
368
625
  try {
369
626
  config = fs.readFileSync(configPath, 'utf8');
@@ -379,8 +636,7 @@ export function readRtkRecallDiagnostics(binary = resolveRtkBinary()) {
379
636
  return { mode: 'unknown', entries: 0, available: false };
380
637
  }
381
638
  }
382
- // Lead binary of a shell string: first segment past `cd` chains and VAR=x
383
- // assignments. Falls back to `bash`.
639
+ // Lead binary of a shell string: first segment past `cd` chains and VAR=x assignments. Falls back to `bash`.
384
640
  const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
385
641
  export function leadBinary(command) {
386
642
  if (typeof command !== 'string' || !command.trim())
@@ -441,8 +697,7 @@ export function readUsage() {
441
697
  }
442
698
  return rows;
443
699
  }
444
- // Delete the ledger file and return the rows cleared. Tersio-owned data only:
445
- // transcripts and the RTK database are never touched.
700
+ // Delete the ledger file and return the rows cleared. Tersio-owned data only: transcripts and the RTK database are never touched.
446
701
  export function clearUsageLedger() {
447
702
  const rows = readUsage().length;
448
703
  try {