@krtclcdy/tersio 2.24.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 (49) hide show
  1. package/README.md +1 -3
  2. package/cli/banner.js +2 -3
  3. package/cli/currency.js +3 -7
  4. package/cli/dashboard.js +167 -3
  5. package/cli/doctor-fix.js +15 -12
  6. package/cli/doctor.js +1 -12
  7. package/cli/hosts.js +7 -11
  8. package/cli/install.js +113 -71
  9. package/cli/interactive.js +36 -2
  10. package/cli/layer.js +1 -3
  11. package/cli/manifest.js +1 -4
  12. package/cli/profile.js +9 -7
  13. package/cli/reset.js +3 -7
  14. package/cli/rtk-wiring.js +1 -6
  15. package/cli/settings.js +3 -8
  16. package/cli/uninstall.js +43 -24
  17. package/cli/update.js +7 -8
  18. package/cli/usage.js +15 -5
  19. package/dashboard/dist/index.html +18 -18
  20. package/extensions/ai-addons-updater/index.js +6 -5
  21. package/extensions/ai-addons-updater/index.ts +7 -6
  22. package/extensions/caveman-session/index.js +14 -29
  23. package/extensions/caveman-session/index.ts +15 -30
  24. package/extensions/combo-toggle/index.js +10 -14
  25. package/extensions/combo-toggle/index.ts +10 -15
  26. package/extensions/lib/utils.js +4 -0
  27. package/extensions/lib/utils.ts +3 -0
  28. package/extensions/rtk-session/index.js +27 -31
  29. package/extensions/rtk-session/index.ts +25 -29
  30. package/extensions/shared/carbon.js +3 -8
  31. package/extensions/shared/carbon.ts +4 -8
  32. package/extensions/shared/host.js +1 -11
  33. package/extensions/shared/host.ts +1 -11
  34. package/extensions/shared/plugin-settings.js +4 -14
  35. package/extensions/shared/plugin-settings.ts +4 -14
  36. package/extensions/shared/pricing.js +4 -6
  37. package/extensions/shared/pricing.ts +4 -6
  38. package/extensions/shared/rtk-gain.js +2 -7
  39. package/extensions/shared/rtk-gain.ts +2 -7
  40. package/extensions/shared/session-state.js +30 -18
  41. package/extensions/shared/session-state.ts +33 -16
  42. package/extensions/shared/usage-ledger.js +138 -17
  43. package/extensions/shared/usage-ledger.ts +143 -19
  44. package/extensions/shared/usage-store.js +175 -23
  45. package/extensions/shared/usage-store.ts +171 -22
  46. package/extensions/tersio-commands/index.js +1 -5
  47. package/extensions/tersio-commands/index.ts +1 -5
  48. package/package.json +6 -7
  49. package/tersio.js +1 -3
@@ -1,15 +1,12 @@
1
- // extensions/shared/usage-store.ts — tersio-owned usage.db.
2
- // Persists the rows importSessionTokens derives live, so reports survive
3
- // session-file rotation. Sync is incremental (a files ledger skips unchanged
4
- // transcripts) and every read applies the reset watermark, so the store is a
5
- // cache, never a fork. Missing sqlite3 → sync false / read null. Never touches
6
- // 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.
7
3
  import { execFileSync } from 'node:child_process';
8
4
  import fs from 'node:fs';
9
5
  import path from 'node:path';
10
- 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';
11
7
  import { tersioDataPath } from '../lib/utils.js';
12
8
  import { MODEL_ALIASES } from './pricing.js';
9
+ import { tersioSettingsFile } from './plugin-settings.js';
13
10
  export function usageDbPath() {
14
11
  const override = process.env.TERSIO_USAGE_DB;
15
12
  if (override)
@@ -42,18 +39,134 @@ function query(db, sql) {
42
39
  .filter((line) => line.trim())
43
40
  .map((line) => line.split('\t'));
44
41
  }
42
+ // Bump on a parse change: unchanged transcripts are never re-read.
43
+ const PARSER_VERSION = '6';
45
44
  function ensureSchema(db) {
46
45
  fs.mkdirSync(path.dirname(db), { recursive: true });
47
- // Rows imported before an alias existed keep the spelling they were stored
48
- // with, and the mtime ledger will not re-read those transcripts. Fold them in
49
- // once, so the merge shows up without a reset.
46
+ // Fold in rows stored under a pre-alias spelling; the mtime ledger will
47
+ // not re-read those transcripts.
50
48
  const rekeys = MODEL_ALIASES.map((a) => `UPDATE messages SET model='${a.id}' WHERE model='${a.feed}';`).join('');
51
49
  run(db, `CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);` +
52
50
  `CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
53
51
  `CREATE TABLE IF NOT EXISTS messages (file TEXT NOT NULL, t REAL, model TEXT NOT NULL,` +
54
52
  ` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
55
- ` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]');` +
53
+ ` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT);` +
56
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
+ }
57
170
  }
58
171
  function readFiles(db) {
59
172
  const known = {};
@@ -65,9 +178,34 @@ function readFiles(db) {
65
178
  catch { /* fresh db */ }
66
179
  return known;
67
180
  }
68
- function parseFile(text) {
181
+ function parseFile(text, host) {
69
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
+ }
70
207
  let codexProvider = null;
208
+ let codexModel = null;
71
209
  for (const line of text.split('\n')) {
72
210
  if (!line.trim())
73
211
  continue;
@@ -79,6 +217,11 @@ function parseFile(text) {
79
217
  codexProvider = parsed.provider;
80
218
  continue;
81
219
  }
220
+ if (parsed.kind === 'codex_model') {
221
+ if (parsed.model)
222
+ codexModel = parsed.model;
223
+ continue;
224
+ }
82
225
  const ts = row.timestamp;
83
226
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
84
227
  const t = Number.isFinite(ms) ? ms : null;
@@ -87,7 +230,7 @@ function parseFile(text) {
87
230
  const usage = parsed.usage ?? {};
88
231
  rows.push({
89
232
  t,
90
- model: canonicalModelId(provider ? `codex/${provider}` : 'codex'),
233
+ model: canonicalModelId(provider ? (codexModel ? `codex/${provider}/${codexModel}` : `codex/${provider}`) : 'codex'),
91
234
  i: intOf(usage.input),
92
235
  o: intOf(usage.output),
93
236
  d: undefined,
@@ -98,6 +241,7 @@ function parseFile(text) {
98
241
  code: undefined,
99
242
  note: undefined,
100
243
  tools: [],
244
+ host,
101
245
  });
102
246
  continue;
103
247
  }
@@ -120,6 +264,7 @@ function parseFile(text) {
120
264
  code: run.code,
121
265
  note,
122
266
  tools: parsed.tools ?? [],
267
+ host,
123
268
  });
124
269
  }
125
270
  catch { /* skip corrupt lines */ }
@@ -127,33 +272,40 @@ function parseFile(text) {
127
272
  return rows;
128
273
  }
129
274
  function insertSql(file, r) {
130
- 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 (` +
131
276
  `${esc(file)},${r.t === null ? 'NULL' : String(r.t)},${esc(r.model)},${r.i},${r.o},` +
132
277
  `${nullNum(r.d)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
133
- `${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)});`;
134
279
  }
135
- // Incremental sync: unchanged transcripts are skipped via mtime+size, changed
136
- // ones re-inserted. Rows for deleted transcripts are kept, so rotation never
137
- // erases history. 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.
138
282
  export function syncUsageDb() {
139
283
  let db;
140
284
  try {
141
285
  db = usageDbPath();
142
286
  ensureSchema(db);
287
+ maybeScheduledBackup(db);
143
288
  }
144
289
  catch {
145
290
  return false;
146
291
  }
147
292
  const files = [];
293
+ const ocFiles = [];
148
294
  try {
149
- walkJsonl(sessionsDir(), files, 2000);
295
+ for (const dir of sessionsDirs())
296
+ walkJsonl(dir, files, 2000);
150
297
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
151
298
  walkJsonl(codexSessionsDir(), files, 2000);
152
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
+ }
153
304
  }
154
305
  catch {
155
306
  return false;
156
307
  }
308
+ files.push(...ocFiles);
157
309
  const known = readFiles(db);
158
310
  const current = {};
159
311
  const changed = [];
@@ -203,7 +355,7 @@ export function syncUsageDb() {
203
355
  for (const file of changed) {
204
356
  const entry = current[file];
205
357
  const text = fs.readFileSync(file, 'utf8');
206
- for (const r of parseFile(text)) {
358
+ for (const r of parseFile(text, hostOfSessionFile(file))) {
207
359
  if (!push(insertSql(file, r)))
208
360
  return false;
209
361
  }
@@ -229,13 +381,13 @@ export function readUsageDb() {
229
381
  }
230
382
  let rows;
231
383
  try {
232
- 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;`);
233
385
  }
234
386
  catch {
235
387
  return null;
236
388
  }
237
389
  const accum = newSessionAccum();
238
- 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) {
239
391
  const ts = t === '' ? undefined : Number(t);
240
392
  let toolNames = [];
241
393
  try {
@@ -259,7 +411,7 @@ export function readUsageDb() {
259
411
  st: st === 'error' || st === 'aborted' ? st : 'completed',
260
412
  code: code === '' ? undefined : Number(code),
261
413
  note: note === '' ? undefined : note,
262
- }, toolNames);
414
+ }, toolNames, h === '' ? undefined : h);
263
415
  }
264
416
  accum.recent.sort((a, b) => b.t - a.t);
265
417
  let syncedAt = 0;
@@ -1,9 +1,5 @@
1
- // extensions/shared/usage-store.ts — tersio-owned usage.db.
2
- // Persists the rows importSessionTokens derives live, so reports survive
3
- // session-file rotation. Sync is incremental (a files ledger skips unchanged
4
- // transcripts) and every read applies the reset watermark, so the store is a
5
- // cache, never a fork. Missing sqlite3 → sync false / read null. Never touches
6
- // 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.
7
3
  import { execFileSync } from 'node:child_process';
8
4
  import fs from 'node:fs';
9
5
  import os from 'node:os';
@@ -12,16 +8,21 @@ import {
12
8
  RECENT_LIMIT,
13
9
  canonicalModelId,
14
10
  classifySessionLine,
11
+ OpencodeMessage,
12
+ classifyOpencodeMessage,
15
13
  codexSessionsDir,
16
14
  costOf,
17
15
  durOf,
16
+ hostOfSessionFile,
18
17
  ingestSessionRow,
18
+ opencodeSessionsDir,
19
19
  newSessionAccum,
20
- sessionsDir,
20
+ sessionsDirs,
21
21
  walkJsonl,
22
22
  } from './usage-ledger.ts';
23
23
  import { tersioDataPath } from '../lib/utils.ts';
24
24
  import { MODEL_ALIASES } from './pricing.ts';
25
+ import { tersioSettingsFile } from './plugin-settings.ts';
25
26
  import type { RunStatus, SessionTokens } from './usage-ledger.ts';
26
27
 
27
28
  export function usageDbPath(): string {
@@ -67,11 +68,13 @@ function query(db: string, sql: string): string[][] {
67
68
  .map((line) => line.split('\t'));
68
69
  }
69
70
 
71
+ // Bump on a parse change: unchanged transcripts are never re-read.
72
+ const PARSER_VERSION = '6';
73
+
70
74
  function ensureSchema(db: string): void {
71
75
  fs.mkdirSync(path.dirname(db), { recursive: true });
72
- // Rows imported before an alias existed keep the spelling they were stored
73
- // with, and the mtime ledger will not re-read those transcripts. Fold them in
74
- // once, so the merge shows up without a reset.
76
+ // Fold in rows stored under a pre-alias spelling; the mtime ledger will
77
+ // not re-read those transcripts.
75
78
  const rekeys = MODEL_ALIASES.map((a) => `UPDATE messages SET model='${a.id}' WHERE model='${a.feed}';`).join('');
76
79
  run(
77
80
  db,
@@ -79,9 +82,119 @@ function ensureSchema(db: string): void {
79
82
  `CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
80
83
  `CREATE TABLE IF NOT EXISTS messages (file TEXT NOT NULL, t REAL, model TEXT NOT NULL,` +
81
84
  ` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
82
- ` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]');` +
85
+ ` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT);` +
83
86
  `CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file);` + rekeys,
84
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
+ }
85
198
  }
86
199
 
87
200
  function readFiles(db: string): Record<string, { mtime: number; size: number }> {
@@ -109,11 +222,34 @@ interface StoredRow {
109
222
  code: number | undefined;
110
223
  note: string | undefined;
111
224
  tools: string[];
225
+ host?: string;
112
226
  }
113
227
 
114
- function parseFile(text: string): StoredRow[] {
228
+ function parseFile(text: string, host?: string): StoredRow[] {
115
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
+ }
116
251
  let codexProvider: string | null = null;
252
+ let codexModel: string | null = null;
117
253
  for (const line of text.split('\n')) {
118
254
  if (!line.trim()) continue;
119
255
  try {
@@ -123,6 +259,10 @@ function parseFile(text: string): StoredRow[] {
123
259
  if (parsed.provider) codexProvider = parsed.provider;
124
260
  continue;
125
261
  }
262
+ if (parsed.kind === 'codex_model') {
263
+ if (parsed.model) codexModel = parsed.model;
264
+ continue;
265
+ }
126
266
  const ts = row.timestamp;
127
267
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
128
268
  const t = Number.isFinite(ms) ? ms : null;
@@ -131,7 +271,7 @@ function parseFile(text: string): StoredRow[] {
131
271
  const usage = parsed.usage ?? {};
132
272
  rows.push({
133
273
  t,
134
- model: canonicalModelId(provider ? `codex/${provider}` : 'codex'),
274
+ model: canonicalModelId(provider ? (codexModel ? `codex/${provider}/${codexModel}` : `codex/${provider}`) : 'codex'),
135
275
  i: intOf(usage.input),
136
276
  o: intOf(usage.output),
137
277
  d: undefined,
@@ -142,6 +282,7 @@ function parseFile(text: string): StoredRow[] {
142
282
  code: undefined,
143
283
  note: undefined,
144
284
  tools: [],
285
+ host,
145
286
  });
146
287
  continue;
147
288
  }
@@ -163,6 +304,7 @@ function parseFile(text: string): StoredRow[] {
163
304
  code: run.code,
164
305
  note,
165
306
  tools: parsed.tools ?? [],
307
+ host,
166
308
  });
167
309
  } catch { /* skip corrupt lines */ }
168
310
  }
@@ -170,32 +312,38 @@ function parseFile(text: string): StoredRow[] {
170
312
  }
171
313
 
172
314
  function insertSql(file: string, r: StoredRow): string {
173
- 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 (` +
174
316
  `${esc(file)},${r.t === null ? 'NULL' : String(r.t)},${esc(r.model)},${r.i},${r.o},` +
175
317
  `${nullNum(r.d)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
176
- `${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)});`;
177
319
  }
178
320
 
179
- // Incremental sync: unchanged transcripts are skipped via mtime+size, changed
180
- // ones re-inserted. Rows for deleted transcripts are kept, so rotation never
181
- // erases history. 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.
182
323
  export function syncUsageDb(): boolean {
183
324
  let db: string;
184
325
  try {
185
326
  db = usageDbPath();
186
327
  ensureSchema(db);
328
+ maybeScheduledBackup(db);
187
329
  } catch {
188
330
  return false;
189
331
  }
190
332
  const files: string[] = [];
333
+ const ocFiles: string[] = [];
191
334
  try {
192
- walkJsonl(sessionsDir(), files, 2000);
335
+ for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
193
336
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
194
337
  walkJsonl(codexSessionsDir(), files, 2000);
195
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
+ }
196
343
  } catch {
197
344
  return false;
198
345
  }
346
+ files.push(...ocFiles);
199
347
  const known = readFiles(db);
200
348
  const current: Record<string, { mtime: number; size: number }> = {};
201
349
  const changed: string[] = [];
@@ -238,7 +386,7 @@ export function syncUsageDb(): boolean {
238
386
  for (const file of changed) {
239
387
  const entry = current[file];
240
388
  const text = fs.readFileSync(file, 'utf8');
241
- for (const r of parseFile(text)) {
389
+ for (const r of parseFile(text, hostOfSessionFile(file))) {
242
390
  if (!push(insertSql(file, r))) return false;
243
391
  }
244
392
  if (!push(`INSERT OR REPLACE INTO files (path, mtime, size) VALUES (${esc(file)},${entry.mtime},${entry.size});`)) return false;
@@ -259,12 +407,12 @@ export function readUsageDb(): StoredUsage | null {
259
407
  }
260
408
  let rows: string[][];
261
409
  try {
262
- 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;`);
263
411
  } catch {
264
412
  return null;
265
413
  }
266
414
  const accum = newSessionAccum();
267
- 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) {
268
416
  const ts = t === '' ? undefined : Number(t);
269
417
  let toolNames: string[] = [];
270
418
  try {
@@ -293,6 +441,7 @@ export function readUsageDb(): StoredUsage | null {
293
441
  note: note === '' ? undefined : note,
294
442
  },
295
443
  toolNames,
444
+ h === '' ? undefined : h,
296
445
  );
297
446
  }
298
447
  accum.recent.sort((a, b) => b.t - a.t);
@@ -1,8 +1,4 @@
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';
7
3
  import { setExtensionLabel } from '../shared/host.js';
8
4
  import os from 'node:os';
@@ -1,8 +1,4 @@
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 {
7
3
  getSharedComboState,
8
4
  reconcileSharedComboEntries,
package/package.json CHANGED
@@ -1,18 +1,17 @@
1
1
  {
2
2
  "name": "@krtclcdy/tersio",
3
- "version": "2.24.0",
4
- "description": "\u2702\ufe0f Token-saving OMP add-ons: terse Caveman replies, compact RTK shell output, lean Ponytail code calls \u2014 one-command combo presets.",
3
+ "version": "2.24.1",
4
+ "description": "\u2702\ufe0f Token saver and usage tracker for coding agents: terse Caveman replies, compact RTK shell output, lean Ponytail code calls, and a gain dashboard \u2014 one-command combo presets over one shared extension tree.",
5
5
  "keywords": [
6
- "omp",
7
- "pi",
8
- "pi-package",
9
6
  "token-saving",
10
7
  "ai-tools",
11
8
  "developer-tools",
12
9
  "caveman-mode",
13
10
  "rtk-rust-token-killer",
14
- "ponytail-omp",
15
- "extensions"
11
+ "ponytail",
12
+ "extensions",
13
+ "pi",
14
+ "pi-package"
16
15
  ],
17
16
  "license": "MIT",
18
17
  "author": "KurutoDenzeru",