@krtclcdy/tersio 2.23.0 → 2.24.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 (58) hide show
  1. package/README.md +56 -45
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/banner.js +2 -3
  5. package/cli/common.js +16 -20
  6. package/cli/currency.js +11 -19
  7. package/cli/dashboard.js +216 -40
  8. package/cli/doctor-fix.js +40 -26
  9. package/cli/doctor.js +41 -41
  10. package/cli/hosts.js +114 -0
  11. package/cli/install.js +284 -179
  12. package/cli/interactive.js +62 -2
  13. package/cli/layer.js +22 -0
  14. package/cli/manifest.js +33 -0
  15. package/cli/omp-layer.js +51 -0
  16. package/cli/pi-layer.js +37 -0
  17. package/cli/pi-wiring.js +92 -0
  18. package/cli/profile.js +48 -48
  19. package/cli/reset.js +5 -32
  20. package/cli/rtk-wiring.js +7 -17
  21. package/cli/settings.js +3 -8
  22. package/cli/uninstall.js +162 -52
  23. package/cli/update.js +19 -27
  24. package/cli/usage.js +15 -5
  25. package/dashboard/dist/index.html +18 -18
  26. package/extensions/ai-addons-updater/index.js +37 -29
  27. package/extensions/ai-addons-updater/index.ts +39 -38
  28. package/extensions/caveman-session/index.js +19 -34
  29. package/extensions/caveman-session/index.ts +20 -35
  30. package/extensions/combo-toggle/index.js +47 -49
  31. package/extensions/combo-toggle/index.ts +42 -58
  32. package/extensions/lib/utils.js +40 -9
  33. package/extensions/lib/utils.ts +40 -9
  34. package/extensions/rtk-session/index.js +34 -38
  35. package/extensions/rtk-session/index.ts +32 -41
  36. package/extensions/shared/carbon.js +3 -11
  37. package/extensions/shared/carbon.ts +4 -11
  38. package/extensions/shared/host.js +58 -0
  39. package/extensions/shared/host.ts +94 -0
  40. package/extensions/shared/omp-prompt.js +12 -0
  41. package/extensions/shared/plugin-settings.js +33 -30
  42. package/extensions/shared/plugin-settings.ts +35 -26
  43. package/extensions/shared/pricing.js +25 -16
  44. package/extensions/shared/pricing.ts +27 -17
  45. package/extensions/shared/rtk-gain.js +6 -22
  46. package/extensions/shared/rtk-gain.ts +6 -22
  47. package/extensions/shared/session-state.js +44 -36
  48. package/extensions/shared/session-state.ts +48 -35
  49. package/extensions/shared/types.ts +10 -3
  50. package/extensions/shared/usage-ledger.js +160 -51
  51. package/extensions/shared/usage-ledger.ts +170 -67
  52. package/extensions/shared/usage-store.js +178 -24
  53. package/extensions/shared/usage-store.ts +176 -25
  54. package/extensions/tersio-commands/index.js +3 -6
  55. package/extensions/tersio-commands/index.ts +3 -6
  56. package/package.json +6 -5
  57. package/tersio.js +4 -6
  58. package/extensions/shared/mode-reinforcement.js +0 -26
@@ -1,16 +1,12 @@
1
- // extensions/shared/usage-store.ts — tersio-owned usage.db.
2
- // Persists the same per-message rows importSessionTokens derives live (model,
3
- // tokens, cache, cost input, timing, status, tools), so top models, costing,
4
- // cache, CO2 inputs, and activity survive session-file rotation. Sync is
5
- // incremental: a files ledger (mtime+size) skips unchanged transcripts, and
6
- // every read applies the current reset watermark — the store is a cache,
7
- // never a fork. Missing sqlite3 CLI → sync false / read null, callers fall
8
- // back to the live path. Never touches RTK's history.db or host transcripts.
1
+ // extensions/shared/usage-store.ts — tersio-owned usage.db. A cache of the
2
+ // live parse, never a fork; missing sqlite3 → sync false / read null.
9
3
  import { execFileSync } from 'node:child_process';
10
4
  import fs from 'node:fs';
11
5
  import path from 'node:path';
12
- import { RECENT_LIMIT, canonicalModelId, classifySessionLine, codexSessionsDir, costOf, durOf, ingestSessionRow, newSessionAccum, sessionsDir, walkJsonl, } from './usage-ledger.js';
6
+ import { RECENT_LIMIT, canonicalModelId, classifySessionLine, classifyOpencodeMessage, codexSessionsDir, costOf, durOf, hostOfSessionFile, ingestSessionRow, opencodeSessionsDir, newSessionAccum, sessionsDirs, walkJsonl, } from './usage-ledger.js';
13
7
  import { tersioDataPath } from '../lib/utils.js';
8
+ import { MODEL_ALIASES } from './pricing.js';
9
+ import { tersioSettingsFile } from './plugin-settings.js';
14
10
  export function usageDbPath() {
15
11
  const override = process.env.TERSIO_USAGE_DB;
16
12
  if (override)
@@ -43,14 +39,134 @@ function query(db, sql) {
43
39
  .filter((line) => line.trim())
44
40
  .map((line) => line.split('\t'));
45
41
  }
42
+ // Bump on a parse change: unchanged transcripts are never re-read.
43
+ const PARSER_VERSION = '6';
46
44
  function ensureSchema(db) {
47
45
  fs.mkdirSync(path.dirname(db), { recursive: true });
46
+ // Fold in rows stored under a pre-alias spelling; the mtime ledger will
47
+ // not re-read those transcripts.
48
+ const rekeys = MODEL_ALIASES.map((a) => `UPDATE messages SET model='${a.id}' WHERE model='${a.feed}';`).join('');
48
49
  run(db, `CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);` +
49
50
  `CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
50
51
  `CREATE TABLE IF NOT EXISTS messages (file TEXT NOT NULL, t REAL, model TEXT NOT NULL,` +
51
52
  ` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
52
- ` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]');` +
53
- `CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file);`);
53
+ ` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT);` +
54
+ `CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file);` + rekeys);
55
+ // An older db has no h column; CREATE TABLE IF NOT EXISTS will not add one.
56
+ try {
57
+ run(db, `ALTER TABLE messages ADD COLUMN h TEXT;`);
58
+ }
59
+ catch { /* already present */ }
60
+ // Host is derivable from the path, so backfill without re-reading transcripts.
61
+ try {
62
+ for (const [file] of query(db, `SELECT DISTINCT file FROM messages WHERE h IS NULL;`)) {
63
+ run(db, `UPDATE messages SET h='${esc(hostOfSessionFile(file))}' WHERE file=${esc(file)};`);
64
+ }
65
+ }
66
+ catch { /* fresh or unreadable db */ }
67
+ // Drop the cache when the parser has moved on, so every transcript is read
68
+ // again and the new columns actually fill.
69
+ try {
70
+ const stored = query(db, `SELECT v FROM meta WHERE k='parser_version';`);
71
+ if (stored.length && stored[0][0] === PARSER_VERSION)
72
+ return;
73
+ backupUsageDb(db);
74
+ run(db, `DELETE FROM messages;DELETE FROM files;INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
75
+ }
76
+ catch { /* fresh db, nothing to invalidate */ }
77
+ }
78
+ // The one destructive thing we do; keep the last few so a bad bump is recoverable.
79
+ const BACKUP_KEEP = 3;
80
+ export function backupUsageDir() {
81
+ return path.join(path.dirname(usageDbPath()), 'backups');
82
+ }
83
+ export function listUsageBackups() {
84
+ try {
85
+ return fs.readdirSync(backupUsageDir())
86
+ .filter((n) => /^usage-\d{14}\.db$/.test(n))
87
+ .map((n) => {
88
+ const full = path.join(backupUsageDir(), n);
89
+ const st = fs.statSync(full);
90
+ return { file: full, mtime: st.mtimeMs, size: st.size };
91
+ })
92
+ .sort((a, b) => b.mtime - a.mtime);
93
+ }
94
+ catch {
95
+ return [];
96
+ }
97
+ }
98
+ function backupUsageDb(db) {
99
+ try {
100
+ const dir = backupUsageDir();
101
+ fs.mkdirSync(dir, { recursive: true });
102
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
103
+ fs.copyFileSync(db, path.join(dir, `usage-${stamp}.db`));
104
+ for (const old of listUsageBackups().slice(BACKUP_KEEP)) {
105
+ fs.rmSync(old.file, { force: true });
106
+ }
107
+ }
108
+ catch { /* a missing backup must not block the sync */ }
109
+ }
110
+ const SCHEDULE_MS = { daily: 86_400_000, weekly: 604_800_000, monthly: 2_592_000_000 };
111
+ /** Read the schedule straight from settings; this module must not import cli/. */
112
+ export function readBackupSchedule() {
113
+ try {
114
+ const raw = JSON.parse(fs.readFileSync(tersioSettingsFile(), 'utf8'));
115
+ return typeof raw?.backupSchedule === 'string' ? raw.backupSchedule : 'monthly';
116
+ }
117
+ catch {
118
+ return 'monthly';
119
+ }
120
+ }
121
+ /** Snapshot on the schedule, skipping when the newest one is still fresh. */
122
+ export function maybeScheduledBackup(db) {
123
+ const every = SCHEDULE_MS[readBackupSchedule()];
124
+ if (!every)
125
+ return false;
126
+ try {
127
+ const newest = listUsageBackups()[0];
128
+ if (newest && Date.now() - newest.mtime < every)
129
+ return false;
130
+ backupUsageDb(db);
131
+ return true;
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ }
137
+ // basename only: a crafted `../` must not escape the backup dir.
138
+ function resolveBackupFile(file) {
139
+ const dir = path.resolve(backupUsageDir());
140
+ const target = path.join(dir, path.basename(String(file || '')));
141
+ if (!target.startsWith(dir + path.sep))
142
+ return null;
143
+ return fs.existsSync(target) ? target : null;
144
+ }
145
+ /** Put a backup back in place. The next sync then re-parses over it. */
146
+ export function restoreUsageBackup(file) {
147
+ try {
148
+ const src = resolveBackupFile(file);
149
+ if (!src)
150
+ return false;
151
+ fs.copyFileSync(src, usageDbPath());
152
+ return true;
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ }
158
+ /** Delete one snapshot. Only ever touches a file inside the backup dir. */
159
+ export function deleteUsageBackup(file) {
160
+ try {
161
+ const target = resolveBackupFile(file);
162
+ if (!target)
163
+ return false;
164
+ fs.rmSync(target, { force: true });
165
+ return true;
166
+ }
167
+ catch {
168
+ return false;
169
+ }
54
170
  }
55
171
  function readFiles(db) {
56
172
  const known = {};
@@ -62,9 +178,34 @@ function readFiles(db) {
62
178
  catch { /* fresh db */ }
63
179
  return known;
64
180
  }
65
- function parseFile(text) {
181
+ function parseFile(text, host) {
66
182
  const rows = [];
183
+ // Guarded: a JSONL transcript must fall through, not abort the sync.
184
+ let oc = null;
185
+ try {
186
+ oc = classifyOpencodeMessage(JSON.parse(text));
187
+ }
188
+ catch { /* JSONL */ }
189
+ if (oc) {
190
+ rows.push({
191
+ t: oc.ms ?? null,
192
+ model: canonicalModelId(oc.model),
193
+ i: intOf(oc.usage.input),
194
+ o: intOf(oc.usage.output),
195
+ d: durOf(oc.durMs),
196
+ cr: intOf(oc.usage.cacheRead),
197
+ cw: intOf(oc.usage.cacheWrite),
198
+ usd: costOf(oc.usage),
199
+ st: 'completed',
200
+ code: undefined,
201
+ note: undefined,
202
+ tools: [],
203
+ host,
204
+ });
205
+ return rows;
206
+ }
67
207
  let codexProvider = null;
208
+ let codexModel = null;
68
209
  for (const line of text.split('\n')) {
69
210
  if (!line.trim())
70
211
  continue;
@@ -76,6 +217,11 @@ function parseFile(text) {
76
217
  codexProvider = parsed.provider;
77
218
  continue;
78
219
  }
220
+ if (parsed.kind === 'codex_model') {
221
+ if (parsed.model)
222
+ codexModel = parsed.model;
223
+ continue;
224
+ }
79
225
  const ts = row.timestamp;
80
226
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
81
227
  const t = Number.isFinite(ms) ? ms : null;
@@ -84,7 +230,7 @@ function parseFile(text) {
84
230
  const usage = parsed.usage ?? {};
85
231
  rows.push({
86
232
  t,
87
- model: canonicalModelId(provider ? `codex/${provider}` : 'codex'),
233
+ model: canonicalModelId(provider ? (codexModel ? `codex/${provider}/${codexModel}` : `codex/${provider}`) : 'codex'),
88
234
  i: intOf(usage.input),
89
235
  o: intOf(usage.output),
90
236
  d: undefined,
@@ -95,6 +241,7 @@ function parseFile(text) {
95
241
  code: undefined,
96
242
  note: undefined,
97
243
  tools: [],
244
+ host,
98
245
  });
99
246
  continue;
100
247
  }
@@ -117,6 +264,7 @@ function parseFile(text) {
117
264
  code: run.code,
118
265
  note,
119
266
  tools: parsed.tools ?? [],
267
+ host,
120
268
  });
121
269
  }
122
270
  catch { /* skip corrupt lines */ }
@@ -124,34 +272,40 @@ function parseFile(text) {
124
272
  return rows;
125
273
  }
126
274
  function insertSql(file, r) {
127
- return `INSERT INTO messages (file, t, model, i, o, d, cr, cw, usd, st, code, note, tools) VALUES (` +
275
+ return `INSERT INTO messages (file, t, model, i, o, d, cr, cw, usd, st, code, note, tools, h) VALUES (` +
128
276
  `${esc(file)},${r.t === null ? 'NULL' : String(r.t)},${esc(r.model)},${r.i},${r.o},` +
129
277
  `${nullNum(r.d)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
130
- `${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))});`;
278
+ `${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))},${nullStr(r.host)});`;
131
279
  }
132
- // Incremental sync: unchanged transcripts are skipped via mtime+size,
133
- // changed ones are deleted and re-inserted. Rows for deleted transcripts
134
- // are kept, so OMP session rotation never erases history. True on success
135
- // (including nothing-to-do), false when sqlite3 or disk is unavailable.
280
+ // Unchanged transcripts are skipped via mtime+size; rows for deleted ones are
281
+ // kept so rotation never erases history. False when sqlite3 is unavailable.
136
282
  export function syncUsageDb() {
137
283
  let db;
138
284
  try {
139
285
  db = usageDbPath();
140
286
  ensureSchema(db);
287
+ maybeScheduledBackup(db);
141
288
  }
142
289
  catch {
143
290
  return false;
144
291
  }
145
292
  const files = [];
293
+ const ocFiles = [];
146
294
  try {
147
- walkJsonl(sessionsDir(), files, 2000);
295
+ for (const dir of sessionsDirs())
296
+ walkJsonl(dir, files, 2000);
148
297
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
149
298
  walkJsonl(codexSessionsDir(), files, 2000);
150
299
  }
300
+ // OpenCode stores one JSON document per message, not JSONL.
301
+ if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
302
+ walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
303
+ }
151
304
  }
152
305
  catch {
153
306
  return false;
154
307
  }
308
+ files.push(...ocFiles);
155
309
  const known = readFiles(db);
156
310
  const current = {};
157
311
  const changed = [];
@@ -201,7 +355,7 @@ export function syncUsageDb() {
201
355
  for (const file of changed) {
202
356
  const entry = current[file];
203
357
  const text = fs.readFileSync(file, 'utf8');
204
- for (const r of parseFile(text)) {
358
+ for (const r of parseFile(text, hostOfSessionFile(file))) {
205
359
  if (!push(insertSql(file, r)))
206
360
  return false;
207
361
  }
@@ -227,13 +381,13 @@ export function readUsageDb() {
227
381
  }
228
382
  let rows;
229
383
  try {
230
- rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools FROM messages;`);
384
+ rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools, h FROM messages;`);
231
385
  }
232
386
  catch {
233
387
  return null;
234
388
  }
235
389
  const accum = newSessionAccum();
236
- for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools] of rows) {
390
+ for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools, h] of rows) {
237
391
  const ts = t === '' ? undefined : Number(t);
238
392
  let toolNames = [];
239
393
  try {
@@ -257,7 +411,7 @@ export function readUsageDb() {
257
411
  st: st === 'error' || st === 'aborted' ? st : 'completed',
258
412
  code: code === '' ? undefined : Number(code),
259
413
  note: note === '' ? undefined : note,
260
- }, toolNames);
414
+ }, toolNames, h === '' ? undefined : h);
261
415
  }
262
416
  accum.recent.sort((a, b) => b.t - a.t);
263
417
  let syncedAt = 0;
@@ -1,11 +1,5 @@
1
- // extensions/shared/usage-store.ts — tersio-owned usage.db.
2
- // Persists the same per-message rows importSessionTokens derives live (model,
3
- // tokens, cache, cost input, timing, status, tools), so top models, costing,
4
- // cache, CO2 inputs, and activity survive session-file rotation. Sync is
5
- // incremental: a files ledger (mtime+size) skips unchanged transcripts, and
6
- // every read applies the current reset watermark — the store is a cache,
7
- // never a fork. Missing sqlite3 CLI → sync false / read null, callers fall
8
- // back to the live path. Never touches RTK's history.db or host transcripts.
1
+ // extensions/shared/usage-store.ts — tersio-owned usage.db. A cache of the
2
+ // live parse, never a fork; missing sqlite3 → sync false / read null.
9
3
  import { execFileSync } from 'node:child_process';
10
4
  import fs from 'node:fs';
11
5
  import os from 'node:os';
@@ -14,15 +8,21 @@ import {
14
8
  RECENT_LIMIT,
15
9
  canonicalModelId,
16
10
  classifySessionLine,
11
+ OpencodeMessage,
12
+ classifyOpencodeMessage,
17
13
  codexSessionsDir,
18
14
  costOf,
19
15
  durOf,
16
+ hostOfSessionFile,
20
17
  ingestSessionRow,
18
+ opencodeSessionsDir,
21
19
  newSessionAccum,
22
- sessionsDir,
20
+ sessionsDirs,
23
21
  walkJsonl,
24
22
  } from './usage-ledger.ts';
25
23
  import { tersioDataPath } from '../lib/utils.ts';
24
+ import { MODEL_ALIASES } from './pricing.ts';
25
+ import { tersioSettingsFile } from './plugin-settings.ts';
26
26
  import type { RunStatus, SessionTokens } from './usage-ledger.ts';
27
27
 
28
28
  export function usageDbPath(): string {
@@ -68,17 +68,133 @@ function query(db: string, sql: string): string[][] {
68
68
  .map((line) => line.split('\t'));
69
69
  }
70
70
 
71
+ // Bump on a parse change: unchanged transcripts are never re-read.
72
+ const PARSER_VERSION = '6';
73
+
71
74
  function ensureSchema(db: string): void {
72
75
  fs.mkdirSync(path.dirname(db), { recursive: true });
76
+ // Fold in rows stored under a pre-alias spelling; the mtime ledger will
77
+ // not re-read those transcripts.
78
+ const rekeys = MODEL_ALIASES.map((a) => `UPDATE messages SET model='${a.id}' WHERE model='${a.feed}';`).join('');
73
79
  run(
74
80
  db,
75
81
  `CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);` +
76
82
  `CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
77
83
  `CREATE TABLE IF NOT EXISTS messages (file TEXT NOT NULL, t REAL, model TEXT NOT NULL,` +
78
84
  ` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
79
- ` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]');` +
80
- `CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file);`,
85
+ ` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT);` +
86
+ `CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file);` + rekeys,
81
87
  );
88
+ // An older db has no h column; CREATE TABLE IF NOT EXISTS will not add one.
89
+ try {
90
+ run(db, `ALTER TABLE messages ADD COLUMN h TEXT;`);
91
+ } catch { /* already present */ }
92
+ // Host is derivable from the path, so backfill without re-reading transcripts.
93
+ try {
94
+ for (const [file] of query(db, `SELECT DISTINCT file FROM messages WHERE h IS NULL;`)) {
95
+ run(db, `UPDATE messages SET h='${esc(hostOfSessionFile(file))}' WHERE file=${esc(file)};`);
96
+ }
97
+ } catch { /* fresh or unreadable db */ }
98
+ // Drop the cache when the parser has moved on, so every transcript is read
99
+ // again and the new columns actually fill.
100
+ try {
101
+ const stored = query(db, `SELECT v FROM meta WHERE k='parser_version';`);
102
+ if (stored.length && stored[0][0] === PARSER_VERSION) return;
103
+ backupUsageDb(db);
104
+ run(db, `DELETE FROM messages;DELETE FROM files;INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
105
+ } catch { /* fresh db, nothing to invalidate */ }
106
+ }
107
+
108
+ // The one destructive thing we do; keep the last few so a bad bump is recoverable.
109
+ const BACKUP_KEEP = 3;
110
+
111
+ export function backupUsageDir(): string {
112
+ return path.join(path.dirname(usageDbPath()), 'backups');
113
+ }
114
+
115
+ export function listUsageBackups(): Array<{ file: string; mtime: number; size: number }> {
116
+ try {
117
+ return fs.readdirSync(backupUsageDir())
118
+ .filter((n) => /^usage-\d{14}\.db$/.test(n))
119
+ .map((n) => {
120
+ const full = path.join(backupUsageDir(), n);
121
+ const st = fs.statSync(full);
122
+ return { file: full, mtime: st.mtimeMs, size: st.size };
123
+ })
124
+ .sort((a, b) => b.mtime - a.mtime);
125
+ } catch {
126
+ return [];
127
+ }
128
+ }
129
+
130
+ function backupUsageDb(db: string): void {
131
+ try {
132
+ const dir = backupUsageDir();
133
+ fs.mkdirSync(dir, { recursive: true });
134
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
135
+ fs.copyFileSync(db, path.join(dir, `usage-${stamp}.db`));
136
+ for (const old of listUsageBackups().slice(BACKUP_KEEP)) {
137
+ fs.rmSync(old.file, { force: true });
138
+ }
139
+ } catch { /* a missing backup must not block the sync */ }
140
+ }
141
+
142
+ const SCHEDULE_MS: Record<string, number> = { daily: 86_400_000, weekly: 604_800_000, monthly: 2_592_000_000 };
143
+
144
+ /** Read the schedule straight from settings; this module must not import cli/. */
145
+ export function readBackupSchedule(): string {
146
+ try {
147
+ const raw = JSON.parse(fs.readFileSync(tersioSettingsFile(), 'utf8')) as { backupSchedule?: unknown };
148
+ return typeof raw?.backupSchedule === 'string' ? raw.backupSchedule : 'monthly';
149
+ } catch {
150
+ return 'monthly';
151
+ }
152
+ }
153
+
154
+ /** Snapshot on the schedule, skipping when the newest one is still fresh. */
155
+ export function maybeScheduledBackup(db: string): boolean {
156
+ const every = SCHEDULE_MS[readBackupSchedule()];
157
+ if (!every) return false;
158
+ try {
159
+ const newest = listUsageBackups()[0];
160
+ if (newest && Date.now() - newest.mtime < every) return false;
161
+ backupUsageDb(db);
162
+ return true;
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ // basename only: a crafted `../` must not escape the backup dir.
169
+ function resolveBackupFile(file: string): string | null {
170
+ const dir = path.resolve(backupUsageDir());
171
+ const target = path.join(dir, path.basename(String(file || '')));
172
+ if (!target.startsWith(dir + path.sep)) return null;
173
+ return fs.existsSync(target) ? target : null;
174
+ }
175
+
176
+ /** Put a backup back in place. The next sync then re-parses over it. */
177
+ export function restoreUsageBackup(file: string): boolean {
178
+ try {
179
+ const src = resolveBackupFile(file);
180
+ if (!src) return false;
181
+ fs.copyFileSync(src, usageDbPath());
182
+ return true;
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ /** Delete one snapshot. Only ever touches a file inside the backup dir. */
189
+ export function deleteUsageBackup(file: string): boolean {
190
+ try {
191
+ const target = resolveBackupFile(file);
192
+ if (!target) return false;
193
+ fs.rmSync(target, { force: true });
194
+ return true;
195
+ } catch {
196
+ return false;
197
+ }
82
198
  }
83
199
 
84
200
  function readFiles(db: string): Record<string, { mtime: number; size: number }> {
@@ -91,8 +207,8 @@ function readFiles(db: string): Record<string, { mtime: number; size: number }>
91
207
  return known;
92
208
  }
93
209
 
94
- // One parsed assistant message, as stored. `t` is epoch ms or null when the
95
- // source row carries no usable timestamp (live counts it, skips its recent).
210
+ // One parsed assistant message, as stored. `t` is null when the source row has
211
+ // no usable timestamp: counted, but skipped in the recent list.
96
212
  interface StoredRow {
97
213
  t: number | null;
98
214
  model: string;
@@ -106,11 +222,34 @@ interface StoredRow {
106
222
  code: number | undefined;
107
223
  note: string | undefined;
108
224
  tools: string[];
225
+ host?: string;
109
226
  }
110
227
 
111
- function parseFile(text: string): StoredRow[] {
228
+ function parseFile(text: string, host?: string): StoredRow[] {
112
229
  const rows: StoredRow[] = [];
230
+ // Guarded: a JSONL transcript must fall through, not abort the sync.
231
+ let oc: ReturnType<typeof classifyOpencodeMessage> = null;
232
+ try { oc = classifyOpencodeMessage(JSON.parse(text) as OpencodeMessage); } catch { /* JSONL */ }
233
+ if (oc) {
234
+ rows.push({
235
+ t: oc.ms ?? null,
236
+ model: canonicalModelId(oc.model),
237
+ i: intOf(oc.usage.input),
238
+ o: intOf(oc.usage.output),
239
+ d: durOf(oc.durMs),
240
+ cr: intOf(oc.usage.cacheRead),
241
+ cw: intOf(oc.usage.cacheWrite),
242
+ usd: costOf(oc.usage),
243
+ st: 'completed',
244
+ code: undefined,
245
+ note: undefined,
246
+ tools: [],
247
+ host,
248
+ });
249
+ return rows;
250
+ }
113
251
  let codexProvider: string | null = null;
252
+ let codexModel: string | null = null;
114
253
  for (const line of text.split('\n')) {
115
254
  if (!line.trim()) continue;
116
255
  try {
@@ -120,6 +259,10 @@ function parseFile(text: string): StoredRow[] {
120
259
  if (parsed.provider) codexProvider = parsed.provider;
121
260
  continue;
122
261
  }
262
+ if (parsed.kind === 'codex_model') {
263
+ if (parsed.model) codexModel = parsed.model;
264
+ continue;
265
+ }
123
266
  const ts = row.timestamp;
124
267
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
125
268
  const t = Number.isFinite(ms) ? ms : null;
@@ -128,7 +271,7 @@ function parseFile(text: string): StoredRow[] {
128
271
  const usage = parsed.usage ?? {};
129
272
  rows.push({
130
273
  t,
131
- model: canonicalModelId(provider ? `codex/${provider}` : 'codex'),
274
+ model: canonicalModelId(provider ? (codexModel ? `codex/${provider}/${codexModel}` : `codex/${provider}`) : 'codex'),
132
275
  i: intOf(usage.input),
133
276
  o: intOf(usage.output),
134
277
  d: undefined,
@@ -139,6 +282,7 @@ function parseFile(text: string): StoredRow[] {
139
282
  code: undefined,
140
283
  note: undefined,
141
284
  tools: [],
285
+ host,
142
286
  });
143
287
  continue;
144
288
  }
@@ -160,6 +304,7 @@ function parseFile(text: string): StoredRow[] {
160
304
  code: run.code,
161
305
  note,
162
306
  tools: parsed.tools ?? [],
307
+ host,
163
308
  });
164
309
  } catch { /* skip corrupt lines */ }
165
310
  }
@@ -167,33 +312,38 @@ function parseFile(text: string): StoredRow[] {
167
312
  }
168
313
 
169
314
  function insertSql(file: string, r: StoredRow): string {
170
- return `INSERT INTO messages (file, t, model, i, o, d, cr, cw, usd, st, code, note, tools) VALUES (` +
315
+ return `INSERT INTO messages (file, t, model, i, o, d, cr, cw, usd, st, code, note, tools, h) VALUES (` +
171
316
  `${esc(file)},${r.t === null ? 'NULL' : String(r.t)},${esc(r.model)},${r.i},${r.o},` +
172
317
  `${nullNum(r.d)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
173
- `${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))});`;
318
+ `${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))},${nullStr(r.host)});`;
174
319
  }
175
320
 
176
- // Incremental sync: unchanged transcripts are skipped via mtime+size,
177
- // changed ones are deleted and re-inserted. Rows for deleted transcripts
178
- // are kept, so OMP session rotation never erases history. True on success
179
- // (including nothing-to-do), false when sqlite3 or disk is unavailable.
321
+ // Unchanged transcripts are skipped via mtime+size; rows for deleted ones are
322
+ // kept so rotation never erases history. False when sqlite3 is unavailable.
180
323
  export function syncUsageDb(): boolean {
181
324
  let db: string;
182
325
  try {
183
326
  db = usageDbPath();
184
327
  ensureSchema(db);
328
+ maybeScheduledBackup(db);
185
329
  } catch {
186
330
  return false;
187
331
  }
188
332
  const files: string[] = [];
333
+ const ocFiles: string[] = [];
189
334
  try {
190
- walkJsonl(sessionsDir(), files, 2000);
335
+ for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
191
336
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
192
337
  walkJsonl(codexSessionsDir(), files, 2000);
193
338
  }
339
+ // OpenCode stores one JSON document per message, not JSONL.
340
+ if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
341
+ walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
342
+ }
194
343
  } catch {
195
344
  return false;
196
345
  }
346
+ files.push(...ocFiles);
197
347
  const known = readFiles(db);
198
348
  const current: Record<string, { mtime: number; size: number }> = {};
199
349
  const changed: string[] = [];
@@ -236,7 +386,7 @@ export function syncUsageDb(): boolean {
236
386
  for (const file of changed) {
237
387
  const entry = current[file];
238
388
  const text = fs.readFileSync(file, 'utf8');
239
- for (const r of parseFile(text)) {
389
+ for (const r of parseFile(text, hostOfSessionFile(file))) {
240
390
  if (!push(insertSql(file, r))) return false;
241
391
  }
242
392
  if (!push(`INSERT OR REPLACE INTO files (path, mtime, size) VALUES (${esc(file)},${entry.mtime},${entry.size});`)) return false;
@@ -257,12 +407,12 @@ export function readUsageDb(): StoredUsage | null {
257
407
  }
258
408
  let rows: string[][];
259
409
  try {
260
- rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools FROM messages;`);
410
+ rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools, h FROM messages;`);
261
411
  } catch {
262
412
  return null;
263
413
  }
264
414
  const accum = newSessionAccum();
265
- for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools] of rows) {
415
+ for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools, h] of rows) {
266
416
  const ts = t === '' ? undefined : Number(t);
267
417
  let toolNames: string[] = [];
268
418
  try {
@@ -291,6 +441,7 @@ export function readUsageDb(): StoredUsage | null {
291
441
  note: note === '' ? undefined : note,
292
442
  },
293
443
  toolNames,
444
+ h === '' ? undefined : h,
294
445
  );
295
446
  }
296
447
  accum.recent.sort((a, b) => b.t - a.t);
@@ -1,9 +1,6 @@
1
- // /tersio root command — status, check, update, dashboard, usage, help.
2
- // Mode switches live on their own commands (/caveman, /rtk, /combo, and the
3
- // upstream /ponytail): the router keeps no redundant copies. Shared-state
4
- // publishes sync sibling mirrors live, so switches notify and take effect
5
- // next turn — no reload.
1
+ // Root command plus mode switches; sibling mirrors sync with no reload.
6
2
  import { getSharedComboState, reconcileSharedComboEntries, sessionEntries, } from '../shared/session-state.js';
3
+ import { setExtensionLabel } from '../shared/host.js';
7
4
  import os from 'node:os';
8
5
  import path from 'node:path';
9
6
  import { appendUsage, readUsage } from '../shared/usage-ledger.js';
@@ -48,7 +45,7 @@ async function openDashboard(pi, ctx) {
48
45
  }
49
46
  }
50
47
  export default function tersioCommandsExtension(pi) {
51
- pi.setLabel?.('Tersio unified root command');
48
+ setExtensionLabel(pi, 'Tersio unified root command');
52
49
  pi.registerCommand?.('tersio', {
53
50
  description: 'Tersio root: status|check|update|dashboard|usage|help',
54
51
  handler: async (args, ctx) => {