@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.
- package/README.md +56 -45
- package/cli/agent-hosts.js +42 -0
- package/cli/agents.js +266 -0
- package/cli/banner.js +2 -3
- package/cli/common.js +16 -20
- package/cli/currency.js +11 -19
- package/cli/dashboard.js +216 -40
- package/cli/doctor-fix.js +40 -26
- package/cli/doctor.js +41 -41
- package/cli/hosts.js +114 -0
- package/cli/install.js +284 -179
- package/cli/interactive.js +62 -2
- package/cli/layer.js +22 -0
- package/cli/manifest.js +33 -0
- package/cli/omp-layer.js +51 -0
- package/cli/pi-layer.js +37 -0
- package/cli/pi-wiring.js +92 -0
- package/cli/profile.js +48 -48
- package/cli/reset.js +5 -32
- package/cli/rtk-wiring.js +7 -17
- package/cli/settings.js +3 -8
- package/cli/uninstall.js +162 -52
- package/cli/update.js +19 -27
- package/cli/usage.js +15 -5
- package/dashboard/dist/index.html +18 -18
- package/extensions/ai-addons-updater/index.js +37 -29
- package/extensions/ai-addons-updater/index.ts +39 -38
- package/extensions/caveman-session/index.js +19 -34
- package/extensions/caveman-session/index.ts +20 -35
- package/extensions/combo-toggle/index.js +47 -49
- package/extensions/combo-toggle/index.ts +42 -58
- package/extensions/lib/utils.js +40 -9
- package/extensions/lib/utils.ts +40 -9
- package/extensions/rtk-session/index.js +34 -38
- package/extensions/rtk-session/index.ts +32 -41
- package/extensions/shared/carbon.js +3 -11
- package/extensions/shared/carbon.ts +4 -11
- package/extensions/shared/host.js +58 -0
- package/extensions/shared/host.ts +94 -0
- package/extensions/shared/omp-prompt.js +12 -0
- package/extensions/shared/plugin-settings.js +33 -30
- package/extensions/shared/plugin-settings.ts +35 -26
- package/extensions/shared/pricing.js +25 -16
- package/extensions/shared/pricing.ts +27 -17
- package/extensions/shared/rtk-gain.js +6 -22
- package/extensions/shared/rtk-gain.ts +6 -22
- package/extensions/shared/session-state.js +44 -36
- package/extensions/shared/session-state.ts +48 -35
- package/extensions/shared/types.ts +10 -3
- package/extensions/shared/usage-ledger.js +160 -51
- package/extensions/shared/usage-ledger.ts +170 -67
- package/extensions/shared/usage-store.js +178 -24
- package/extensions/shared/usage-store.ts +176 -25
- package/extensions/tersio-commands/index.js +3 -6
- package/extensions/tersio-commands/index.ts +3 -6
- package/package.json +6 -5
- package/tersio.js +4 -6
- package/extensions/shared/mode-reinforcement.js +0 -26
|
@@ -1,16 +1,12 @@
|
|
|
1
|
-
// extensions/shared/usage-store.ts — tersio-owned usage.db.
|
|
2
|
-
//
|
|
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,
|
|
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
|
-
//
|
|
133
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
95
|
-
//
|
|
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
|
-
//
|
|
177
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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) => {
|