@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.
- package/README.md +1 -3
- package/cli/banner.js +2 -3
- package/cli/currency.js +3 -7
- package/cli/dashboard.js +167 -3
- package/cli/doctor-fix.js +15 -12
- package/cli/doctor.js +1 -12
- package/cli/hosts.js +7 -11
- package/cli/install.js +113 -71
- package/cli/interactive.js +36 -2
- package/cli/layer.js +1 -3
- package/cli/manifest.js +1 -4
- package/cli/profile.js +9 -7
- package/cli/reset.js +3 -7
- package/cli/rtk-wiring.js +1 -6
- package/cli/settings.js +3 -8
- package/cli/uninstall.js +43 -24
- package/cli/update.js +7 -8
- package/cli/usage.js +15 -5
- package/dashboard/dist/index.html +18 -18
- package/extensions/ai-addons-updater/index.js +6 -5
- package/extensions/ai-addons-updater/index.ts +7 -6
- package/extensions/caveman-session/index.js +14 -29
- package/extensions/caveman-session/index.ts +15 -30
- package/extensions/combo-toggle/index.js +10 -14
- package/extensions/combo-toggle/index.ts +10 -15
- package/extensions/lib/utils.js +4 -0
- package/extensions/lib/utils.ts +3 -0
- package/extensions/rtk-session/index.js +27 -31
- package/extensions/rtk-session/index.ts +25 -29
- package/extensions/shared/carbon.js +3 -8
- package/extensions/shared/carbon.ts +4 -8
- package/extensions/shared/host.js +1 -11
- package/extensions/shared/host.ts +1 -11
- package/extensions/shared/plugin-settings.js +4 -14
- package/extensions/shared/plugin-settings.ts +4 -14
- package/extensions/shared/pricing.js +4 -6
- package/extensions/shared/pricing.ts +4 -6
- package/extensions/shared/rtk-gain.js +2 -7
- package/extensions/shared/rtk-gain.ts +2 -7
- package/extensions/shared/session-state.js +30 -18
- package/extensions/shared/session-state.ts +33 -16
- package/extensions/shared/usage-ledger.js +138 -17
- package/extensions/shared/usage-ledger.ts +143 -19
- package/extensions/shared/usage-store.js +175 -23
- package/extensions/shared/usage-store.ts +171 -22
- package/extensions/tersio-commands/index.js +1 -5
- package/extensions/tersio-commands/index.ts +1 -5
- package/package.json +6 -7
- package/tersio.js +1 -3
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
// extensions/shared/usage-store.ts — tersio-owned usage.db.
|
|
2
|
-
//
|
|
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,
|
|
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
|
-
//
|
|
48
|
-
//
|
|
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
|
-
//
|
|
136
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
73
|
-
//
|
|
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
|
-
//
|
|
180
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
4
|
-
"description": "\u2702\ufe0f Token
|
|
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
|
|
15
|
-
"extensions"
|
|
11
|
+
"ponytail",
|
|
12
|
+
"extensions",
|
|
13
|
+
"pi",
|
|
14
|
+
"pi-package"
|
|
16
15
|
],
|
|
17
16
|
"license": "MIT",
|
|
18
17
|
"author": "KurutoDenzeru",
|