@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.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;
@@ -52,8 +50,7 @@ function stampMs(ts) {
52
50
  export function durOf(v) {
53
51
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
54
52
  }
55
- // Measured cost: usage.cost.total, or a bare number in old fixtures. Anything
56
- // 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.
57
54
  export function costOf(usage) {
58
55
  const c = usage.cost;
59
56
  if (typeof c === 'number')
@@ -65,8 +62,7 @@ export function costOf(usage) {
65
62
  }
66
63
  return undefined;
67
64
  }
68
- // First line only: real messages carry multi-line provider errors, and this
69
- // ends up in a tooltip.
65
+ // First line only: real messages carry multi-line provider errors, and this ends up in a tooltip.
70
66
  export function statusOf(msg) {
71
67
  const stop = typeof msg.stopReason === 'string' ? msg.stopReason : '';
72
68
  const note = typeof msg.errorMessage === 'string' && msg.errorMessage.trim()
@@ -91,26 +87,16 @@ export function sessionsDirs() {
91
87
  const override = process.env.TERSIO_SESSIONS_DIR;
92
88
  if (override)
93
89
  return [override];
94
- // Both hosts, not just the running one: a session on pi is missing from the
95
- // dashboard entirely if we stop at the first directory that exists.
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.
96
91
  const dirs = isPiProcess()
97
- ? [path.join(piAgentDir(), 'sessions'), path.join(os.homedir(), '.omp', 'agent', 'sessions')]
98
- : [path.join(os.homedir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
92
+ ? [path.join(piAgentDir(), 'sessions'), path.join(homeDir(), '.omp', 'agent', 'sessions')]
93
+ : [path.join(homeDir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
99
94
  const found = dirs.filter((dir) => fs.existsSync(dir));
100
95
  return found.length > 0 ? found : [dirs[0]];
101
96
  }
102
97
  export function sessionsDir() {
103
98
  return sessionsDirs()[0];
104
99
  }
105
- export function codexSessionsDir() {
106
- const override = process.env.TERSIO_CODEX_DIR;
107
- if (override)
108
- return override;
109
- const codexHome = process.env.CODEX_HOME;
110
- if (codexHome)
111
- return path.join(codexHome, 'sessions');
112
- return path.join(os.homedir(), '.codex', 'sessions');
113
- }
114
100
  // One JSON file per message; probe XDG, then local, then macOS default.
115
101
  export function opencodeSessionsDir() {
116
102
  const override = process.env.TERSIO_OPENCODE_DIR;
@@ -119,13 +105,88 @@ export function opencodeSessionsDir() {
119
105
  const xdg = process.env.XDG_DATA_HOME;
120
106
  if (xdg && xdg.trim() !== '')
121
107
  return path.join(xdg, 'opencode', 'storage', 'message');
122
- const local = path.join(os.homedir(), '.local', 'share', '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');
123
125
  try {
124
126
  if (fs.existsSync(local))
125
127
  return local;
126
128
  }
127
129
  catch { /* fall through to the platform default */ }
128
- return path.join(os.homedir(), 'Library', 'Application Support', 'opencode', 'storage', 'message');
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
+ }
129
190
  }
130
191
  export function dayKey(ts) {
131
192
  const ms = typeof ts === 'number' ? ts : Date.parse(ts);
@@ -153,9 +214,8 @@ export function walkJsonl(dir, out, cap, ext = '.jsonl') {
153
214
  out.push(full);
154
215
  }
155
216
  }
156
- // Recent requests get their own full-width table, so this bounds payload
157
- // rather than highlights; rows are small, so a few hundred cost little.
158
- 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;
159
219
  const FREE_SUFFIX = /(?::free|-free)$/i;
160
220
  const RTK_ELIGIBLE_HEADS = new Set([
161
221
  'rtk', 'git', 'grep', 'rg', 'find', 'cat', 'ls', 'tree', 'diff', 'log',
@@ -163,9 +223,9 @@ const RTK_ELIGIBLE_HEADS = new Set([
163
223
  'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
164
224
  ]);
165
225
  export function newSessionAccum() {
166
- 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: [] };
167
227
  }
168
- export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host) {
228
+ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host, id) {
169
229
  const watermark = readResetWatermark();
170
230
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
171
231
  if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
@@ -175,10 +235,15 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames,
175
235
  accum.byModel[key] ??= zeroBreakdown();
176
236
  addInto(accum.byModel[key], usage);
177
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
+ }
178
243
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
179
244
  if (Number.isFinite(ms)) {
180
245
  const outcome = run ?? { st: 'completed' };
181
- 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 });
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 });
182
247
  }
183
248
  const day = ts !== undefined ? dayKey(ts) : null;
184
249
  if (day) {
@@ -274,8 +339,8 @@ export function classifyOpencodeMessage(obj) {
274
339
  durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
275
340
  };
276
341
  }
277
- // One OpenCode message file into the shared accum.
278
- export function processOpencodeFile(accum, text) {
342
+ // One OpenCode message file into the shared accum. The msg id is the file name.
343
+ export function processOpencodeFile(accum, text, file) {
279
344
  let obj;
280
345
  try {
281
346
  obj = JSON.parse(text);
@@ -286,20 +351,82 @@ export function processOpencodeFile(accum, text) {
286
351
  const parsed = classifyOpencodeMessage(obj);
287
352
  if (!parsed)
288
353
  return;
289
- ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode');
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);
290
363
  }
291
364
  // Fold `:free`/`-free` suffixes and case variants into one chart key.
292
365
  export function canonicalModelId(model) {
293
366
  return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
294
367
  }
295
- // LiteLLM-style display: lowercase namespace, title-cased model segments with
296
- // 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".
297
417
  export function displayModelId(model) {
298
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;
299
422
  const cap = (s) => {
300
423
  const low = s.toLowerCase();
301
424
  if (low === 'openai')
302
425
  return 'OpenAI';
426
+ if (low === 'gpt')
427
+ return 'GPT';
428
+ if (low === 'swe')
429
+ return 'SWE';
303
430
  if (low === 'ai')
304
431
  return 'AI';
305
432
  if (/^\d+[a-z]+$/.test(low))
@@ -310,21 +437,25 @@ export function displayModelId(model) {
310
437
  };
311
438
  const seg = (s) => s.split('.').map(cap).join('.');
312
439
  const words = (s) => s.split(/[-_:]+/).filter(Boolean).map(seg).join('-');
313
- const slash = bare.indexOf('/');
314
- if (slash >= 0)
315
- return `${bare.slice(0, slash).toLowerCase()}/${words(bare.slice(slash + 1))}`;
316
- 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}`;
317
453
  }
318
454
  export function importSessionTokens() {
319
455
  const accum = newSessionAccum();
320
456
  const files = [];
321
457
  for (const dir of sessionsDirs())
322
458
  walkJsonl(dir, files, 2000);
323
- // An override means an isolated environment (tests, fixtures): only walk the
324
- // real codex dir when one is explicitly set.
325
- if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
326
- walkJsonl(codexSessionsDir(), files, 2000);
327
- }
328
459
  // OpenCode message bodies are single JSON documents, not JSONL.
329
460
  const ocFiles = [];
330
461
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
@@ -338,7 +469,12 @@ export function importSessionTokens() {
338
469
  catch {
339
470
  continue;
340
471
  }
341
- processOpencodeFile(accum, text);
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);
342
478
  }
343
479
  let codexProvider = null;
344
480
  let codexModel = null;
@@ -358,7 +494,7 @@ export function importSessionTokens() {
358
494
  }, text, hostOfSessionFile(file));
359
495
  }
360
496
  accum.recent.sort((a, b) => b.t - a.t);
361
- 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) };
362
498
  }
363
499
  // Which agent wrote a session file, from the directory it sits in.
364
500
  export function hostOfSessionFile(file) {
@@ -367,6 +503,8 @@ export function hostOfSessionFile(file) {
367
503
  return 'omp';
368
504
  if (norm.includes('/.codex/'))
369
505
  return 'codex';
506
+ if (norm.includes('/opencode/'))
507
+ return 'opencode';
370
508
  return 'pi';
371
509
  }
372
510
  const adoptionFileCache = new Map();
@@ -409,9 +547,6 @@ export function readRtkAdoption() {
409
547
  const files = [];
410
548
  for (const dir of sessionsDirs())
411
549
  walkJsonl(dir, files, 2000);
412
- if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
413
- walkJsonl(codexSessionsDir(), files, 2000);
414
- }
415
550
  const live = new Set(files);
416
551
  for (const file of adoptionFileCache.keys()) {
417
552
  if (!live.has(file))
@@ -453,6 +588,7 @@ export function processSessionText(accum, state, text, host) {
453
588
  try {
454
589
  const row = JSON.parse(line);
455
590
  const parsed = classifySessionLine(row);
591
+ const rid = messageRowId(row);
456
592
  if (parsed.kind === 'codex_provider') {
457
593
  if (parsed.provider)
458
594
  state.codexProvider = parsed.provider;
@@ -467,12 +603,12 @@ export function processSessionText(accum, state, text, host) {
467
603
  const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
468
604
  // The provider alone told you nothing about which model ran.
469
605
  const model = state.codexModel ? `${base}/${state.codexModel}` : base;
470
- ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' }, undefined, host ?? 'codex');
606
+ ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' }, undefined, host ?? 'codex', rid);
471
607
  continue;
472
608
  }
473
609
  if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
474
610
  continue;
475
- ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host);
611
+ ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid);
476
612
  }
477
613
  catch { /* skip corrupt lines */ }
478
614
  }
@@ -483,8 +619,8 @@ export function readRtkRecallDiagnostics(binary = resolveRtkBinary()) {
483
619
  try {
484
620
  const configPath = process.env.RTK_CONFIG
485
621
  || (process.platform === 'darwin'
486
- ? path.join(os.homedir(), 'Library', 'Application Support', 'rtk', 'config.toml')
487
- : 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'));
488
624
  let config = '';
489
625
  try {
490
626
  config = fs.readFileSync(configPath, 'utf8');
@@ -500,8 +636,7 @@ export function readRtkRecallDiagnostics(binary = resolveRtkBinary()) {
500
636
  return { mode: 'unknown', entries: 0, available: false };
501
637
  }
502
638
  }
503
- // Lead binary of a shell string: first segment past `cd` chains and VAR=x
504
- // 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`.
505
640
  const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
506
641
  export function leadBinary(command) {
507
642
  if (typeof command !== 'string' || !command.trim())
@@ -562,8 +697,7 @@ export function readUsage() {
562
697
  }
563
698
  return rows;
564
699
  }
565
- // Delete the ledger file and return the rows cleared. Tersio-owned data only:
566
- // 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.
567
701
  export function clearUsageLedger() {
568
702
  const rows = readUsage().length;
569
703
  try {