@krtclcdy/tersio 2.24.1 → 2.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -21
- package/cli/banner.js +7 -4
- package/cli/common.js +14 -13
- package/cli/currency.js +1 -2
- package/cli/dashboard.js +57 -14
- package/cli/doctor-fix.js +88 -21
- package/cli/doctor.js +32 -24
- package/cli/hosts.js +19 -18
- package/cli/install.js +123 -52
- package/cli/interactive.js +29 -19
- package/cli/manifest.js +7 -0
- package/cli/profile.js +42 -11
- package/cli/rtk-wiring.js +28 -26
- package/cli/settings.js +37 -8
- package/cli/uninstall.js +40 -7
- package/cli/update.js +22 -14
- package/cli/usage.js +39 -14
- package/dashboard/dist/index.html +16 -16
- package/extensions/ai-addons-updater/index.js +69 -15
- package/extensions/ai-addons-updater/index.ts +60 -17
- package/extensions/caveman-session/index.js +28 -55
- package/extensions/caveman-session/index.ts +28 -55
- package/extensions/combo-toggle/index.js +40 -58
- package/extensions/combo-toggle/index.ts +38 -67
- package/extensions/lib/utils.js +60 -15
- package/extensions/lib/utils.ts +59 -15
- package/extensions/opencode/index.js +104 -0
- package/extensions/opencode/server.js +190 -0
- package/extensions/opencode/server.ts +200 -0
- package/extensions/rtk-session/index.js +20 -35
- package/extensions/rtk-session/index.ts +19 -34
- package/extensions/shared/carbon.js +1 -2
- package/extensions/shared/carbon.ts +1 -2
- package/extensions/shared/host.js +95 -46
- package/extensions/shared/host.ts +106 -50
- package/extensions/shared/plugin-settings.js +4 -6
- package/extensions/shared/plugin-settings.ts +4 -6
- package/extensions/shared/pricing.js +79 -25
- package/extensions/shared/pricing.ts +80 -26
- package/extensions/shared/rtk-gain.js +3 -5
- package/extensions/shared/rtk-gain.ts +3 -5
- package/extensions/shared/session-state.js +10 -33
- package/extensions/shared/session-state.ts +12 -38
- package/extensions/shared/status.js +38 -0
- package/extensions/shared/status.ts +45 -0
- package/extensions/shared/types.js +1 -2
- package/extensions/shared/types.ts +2 -2
- package/extensions/shared/usage-ledger.js +190 -56
- package/extensions/shared/usage-ledger.ts +194 -58
- package/extensions/shared/usage-store.js +228 -64
- package/extensions/shared/usage-store.ts +235 -67
- package/extensions/tersio-commands/index.js +4 -9
- package/extensions/tersio-commands/index.ts +4 -9
- package/package.json +8 -2
- package/tersio.js +3 -3
- package/cli/agent-hosts.js +0 -42
- package/cli/agents.js +0 -266
- package/cli/layer.js +0 -22
- package/cli/omp-layer.js +0 -51
- package/cli/pi-layer.js +0 -37
- package/cli/pi-wiring.js +0 -92
- package/extensions/shared/omp-prompt.js +0 -12
|
@@ -1,9 +1,8 @@
|
|
|
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.
|
|
1
|
+
// extensions/shared/usage-store.ts — tersio-owned usage.db. A cache of the live parse, never a fork; missing sqlite3 → sync false / read null.
|
|
3
2
|
import { execFileSync } from 'node:child_process';
|
|
4
3
|
import fs from 'node:fs';
|
|
5
4
|
import path from 'node:path';
|
|
6
|
-
import { RECENT_LIMIT, canonicalModelId, classifySessionLine, classifyOpencodeMessage,
|
|
5
|
+
import { RECENT_LIMIT, SQLITE_READ_BUFFER, canonicalModelId, classifySessionLine, classifyOpencodeMessage, costOf, durOf, hostOfSessionFile, ingestSessionRow, messageRowId, OPENCODE_DB_OVERLAP_MS, opencodeDbPath, opencodeSessionsDir, newSessionAccum, readOpencodeDbRows, sessionsDirs, walkJsonl, } from './usage-ledger.js';
|
|
7
6
|
import { tersioDataPath } from '../lib/utils.js';
|
|
8
7
|
import { MODEL_ALIASES } from './pricing.js';
|
|
9
8
|
import { tersioSettingsFile } from './plugin-settings.js';
|
|
@@ -26,13 +25,15 @@ function nullStr(v) {
|
|
|
26
25
|
return v === undefined ? 'NULL' : esc(v);
|
|
27
26
|
}
|
|
28
27
|
function run(db, sql) {
|
|
29
|
-
|
|
28
|
+
// -bail stops the script before COMMIT, so a failed INSERT never leaves DELETEs behind.
|
|
29
|
+
execFileSync('sqlite3', ['-bail', db, sql], { stdio: ['ignore', 'ignore', 'ignore'], timeout: 30000 });
|
|
30
30
|
}
|
|
31
31
|
function query(db, sql) {
|
|
32
32
|
const out = execFileSync('sqlite3', ['-separator', '\t', db, sql], {
|
|
33
33
|
encoding: 'utf8',
|
|
34
34
|
timeout: 30000,
|
|
35
35
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
36
|
+
maxBuffer: SQLITE_READ_BUFFER,
|
|
36
37
|
});
|
|
37
38
|
return out
|
|
38
39
|
.split('\n')
|
|
@@ -40,38 +41,146 @@ function query(db, sql) {
|
|
|
40
41
|
.map((line) => line.split('\t'));
|
|
41
42
|
}
|
|
42
43
|
// Bump on a parse change: unchanged transcripts are never re-read.
|
|
43
|
-
const PARSER_VERSION = '
|
|
44
|
+
const PARSER_VERSION = '11';
|
|
45
|
+
// A re-parse that keeps less than half the rows means transcripts vanished mid-migration; the backup is restored instead of publishing the loss.
|
|
46
|
+
const REPARSE_GUARD_RATIO = 0.5;
|
|
47
|
+
let guardReport = null;
|
|
48
|
+
/** Why the last sync refused to publish a re-parse, if it did. */
|
|
49
|
+
export function reparseGuardReport() {
|
|
50
|
+
return guardReport;
|
|
51
|
+
}
|
|
52
|
+
const SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);` +
|
|
53
|
+
`CREATE TABLE IF NOT EXISTS files (id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT UNIQUE NOT NULL, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
|
|
54
|
+
`CREATE TABLE IF NOT EXISTS messages (file_id INTEGER NOT NULL REFERENCES files(id), t REAL, model TEXT NOT NULL,` +
|
|
55
|
+
` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
|
|
56
|
+
` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT, id TEXT);` +
|
|
57
|
+
`CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file_id);`;
|
|
58
|
+
function storedParserVersion(db) {
|
|
59
|
+
try {
|
|
60
|
+
const stored = query(db, `SELECT v FROM meta WHERE k='parser_version';`);
|
|
61
|
+
return stored.length ? stored[0][0] : null;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function countMessages(db) {
|
|
68
|
+
try {
|
|
69
|
+
const rows = query(db, `SELECT COUNT(*) FROM messages;`);
|
|
70
|
+
return Number(rows[0]?.[0]) || 0;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Watermark for the sqlite scan: the newest opencode row already stored.
|
|
77
|
+
function newestOpencodeMs(db) {
|
|
78
|
+
try {
|
|
79
|
+
const rows = query(db, `SELECT MAX(t) FROM messages WHERE h='opencode';`);
|
|
80
|
+
const v = Number(rows[0]?.[0]);
|
|
81
|
+
return Number.isFinite(v) && v > 0 ? v : null;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Same pre-migration state as the newest backup: another copy churns the 3-slot rotation for nothing.
|
|
88
|
+
function newestBackupMatches(stored) {
|
|
89
|
+
if (stored === null)
|
|
90
|
+
return false;
|
|
91
|
+
try {
|
|
92
|
+
const newest = listUsageBackups()[0];
|
|
93
|
+
if (!newest)
|
|
94
|
+
return false;
|
|
95
|
+
const out = query(newest.file, `SELECT v FROM meta WHERE k='parser_version';`);
|
|
96
|
+
return out.length > 0 && out[0][0] === stored;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// v9 shape (path-keyed rows) migrates in place with zero re-parse, so history survives even when transcripts are gone.
|
|
103
|
+
function canMigrateInPlace(db) {
|
|
104
|
+
try {
|
|
105
|
+
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
106
|
+
return names.includes('file') && names.includes('h') && !names.includes('file_id');
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function migrateInPlace(db, preCount) {
|
|
113
|
+
const cols = `t, model, i, o, d, cr, cw, usd, st, code, note, tools, h`;
|
|
114
|
+
try {
|
|
115
|
+
run(db, `BEGIN;` +
|
|
116
|
+
`CREATE TABLE files_new (id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT UNIQUE NOT NULL, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
|
|
117
|
+
`INSERT INTO files_new (path, mtime, size) SELECT path, mtime, size FROM files;` +
|
|
118
|
+
`CREATE TABLE messages_new (file_id INTEGER NOT NULL REFERENCES files_new(id), t REAL, model TEXT NOT NULL,` +
|
|
119
|
+
` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
|
|
120
|
+
` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT, id TEXT);` +
|
|
121
|
+
`INSERT INTO messages_new (file_id, ${cols}) SELECT (SELECT id FROM files_new WHERE files_new.path = messages.file), ${cols} FROM messages;` +
|
|
122
|
+
`DROP TABLE messages;DROP TABLE files;` +
|
|
123
|
+
`ALTER TABLE files_new RENAME TO files;ALTER TABLE messages_new RENAME TO messages;` +
|
|
124
|
+
`CREATE INDEX idx_messages_file ON messages(file_id);` +
|
|
125
|
+
`COMMIT;`);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
132
|
+
return names.includes('file_id') && !names.includes('file') && countMessages(db) === preCount;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// v10 shape gains the message id with a bare ALTER: no rows move, so no re-parse and no guard trip.
|
|
139
|
+
function canAlterInPlace(db) {
|
|
140
|
+
try {
|
|
141
|
+
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
142
|
+
return names.includes('file_id') && !names.includes('id');
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function alterInPlace(db) {
|
|
149
|
+
try {
|
|
150
|
+
run(db, `ALTER TABLE messages ADD COLUMN id TEXT;`);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
return !canAlterInPlace(db);
|
|
156
|
+
}
|
|
44
157
|
function ensureSchema(db) {
|
|
45
158
|
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.
|
|
159
|
+
// Fold in rows stored under a pre-alias spelling; the mtime ledger will not re-read those transcripts.
|
|
48
160
|
const rekeys = MODEL_ALIASES.map((a) => `UPDATE messages SET model='${a.id}' WHERE model='${a.feed}';`).join('');
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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;`);
|
|
161
|
+
const stored = storedParserVersion(db);
|
|
162
|
+
if (stored === PARSER_VERSION) {
|
|
163
|
+
run(db, SCHEMA_SQL + rekeys);
|
|
164
|
+
return;
|
|
58
165
|
}
|
|
59
|
-
|
|
60
|
-
// Host is derivable from the path, so backfill without re-reading transcripts.
|
|
166
|
+
// A version bump rebuilds both tables, so no ALTER patchwork survives a migration.
|
|
61
167
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
168
|
+
if (countMessages(db) > 0 && !newestBackupMatches(stored))
|
|
169
|
+
backupUsageDb(db);
|
|
170
|
+
if (stored !== null && canMigrateInPlace(db) && migrateInPlace(db, countMessages(db))) {
|
|
171
|
+
run(db, `INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
172
|
+
try {
|
|
173
|
+
run(db, `VACUUM;`);
|
|
174
|
+
}
|
|
175
|
+
catch { /* best-effort */ }
|
|
176
|
+
return;
|
|
64
177
|
}
|
|
65
|
-
|
|
66
|
-
|
|
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)
|
|
178
|
+
if (stored !== null && canAlterInPlace(db) && alterInPlace(db)) {
|
|
179
|
+
run(db, `INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
72
180
|
return;
|
|
73
|
-
|
|
74
|
-
run(db, `
|
|
181
|
+
}
|
|
182
|
+
run(db, `DROP TABLE IF EXISTS messages;DROP TABLE IF EXISTS files;` + SCHEMA_SQL +
|
|
183
|
+
`INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
75
184
|
}
|
|
76
185
|
catch { /* fresh db, nothing to invalidate */ }
|
|
77
186
|
}
|
|
@@ -95,7 +204,7 @@ export function listUsageBackups() {
|
|
|
95
204
|
return [];
|
|
96
205
|
}
|
|
97
206
|
}
|
|
98
|
-
function backupUsageDb(db) {
|
|
207
|
+
export function backupUsageDb(db) {
|
|
99
208
|
try {
|
|
100
209
|
const dir = backupUsageDir();
|
|
101
210
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -178,7 +287,26 @@ function readFiles(db) {
|
|
|
178
287
|
catch { /* fresh db */ }
|
|
179
288
|
return known;
|
|
180
289
|
}
|
|
181
|
-
|
|
290
|
+
// OpenCode rows carry no status, code, note, or tool list; only tokens and the msg id.
|
|
291
|
+
function opencodeStoredRow(oc, host, id) {
|
|
292
|
+
return {
|
|
293
|
+
t: oc.ms ?? null,
|
|
294
|
+
model: canonicalModelId(oc.model),
|
|
295
|
+
i: intOf(oc.usage.input),
|
|
296
|
+
o: intOf(oc.usage.output),
|
|
297
|
+
d: durOf(oc.durMs),
|
|
298
|
+
cr: intOf(oc.usage.cacheRead),
|
|
299
|
+
cw: intOf(oc.usage.cacheWrite),
|
|
300
|
+
usd: costOf(oc.usage),
|
|
301
|
+
st: 'completed',
|
|
302
|
+
code: undefined,
|
|
303
|
+
note: undefined,
|
|
304
|
+
tools: [],
|
|
305
|
+
host,
|
|
306
|
+
id,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function parseFile(text, host, file) {
|
|
182
310
|
const rows = [];
|
|
183
311
|
// Guarded: a JSONL transcript must fall through, not abort the sync.
|
|
184
312
|
let oc = null;
|
|
@@ -187,21 +315,7 @@ function parseFile(text, host) {
|
|
|
187
315
|
}
|
|
188
316
|
catch { /* JSONL */ }
|
|
189
317
|
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
|
-
});
|
|
318
|
+
rows.push(opencodeStoredRow(oc, host, file ? path.basename(file).replace(/\.[^.]+$/, '') : undefined));
|
|
205
319
|
return rows;
|
|
206
320
|
}
|
|
207
321
|
let codexProvider = null;
|
|
@@ -211,6 +325,7 @@ function parseFile(text, host) {
|
|
|
211
325
|
continue;
|
|
212
326
|
try {
|
|
213
327
|
const row = JSON.parse(line);
|
|
328
|
+
const rid = messageRowId(row);
|
|
214
329
|
const parsed = classifySessionLine(row);
|
|
215
330
|
if (parsed.kind === 'codex_provider') {
|
|
216
331
|
if (parsed.provider)
|
|
@@ -242,6 +357,7 @@ function parseFile(text, host) {
|
|
|
242
357
|
note: undefined,
|
|
243
358
|
tools: [],
|
|
244
359
|
host,
|
|
360
|
+
id: rid,
|
|
245
361
|
});
|
|
246
362
|
continue;
|
|
247
363
|
}
|
|
@@ -265,6 +381,7 @@ function parseFile(text, host) {
|
|
|
265
381
|
note,
|
|
266
382
|
tools: parsed.tools ?? [],
|
|
267
383
|
host,
|
|
384
|
+
id: rid,
|
|
268
385
|
});
|
|
269
386
|
}
|
|
270
387
|
catch { /* skip corrupt lines */ }
|
|
@@ -272,17 +389,25 @@ function parseFile(text, host) {
|
|
|
272
389
|
return rows;
|
|
273
390
|
}
|
|
274
391
|
function insertSql(file, r) {
|
|
275
|
-
return `INSERT INTO messages (
|
|
276
|
-
|
|
392
|
+
return `INSERT INTO messages (file_id, t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, id) VALUES (` +
|
|
393
|
+
`(SELECT id FROM files WHERE path=${esc(file)}),${r.t === null ? 'NULL' : String(r.t)},${esc(r.model)},${r.i},${r.o},` +
|
|
277
394
|
`${nullNum(r.d)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
|
|
278
|
-
`${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))},${nullStr(r.host)});`;
|
|
395
|
+
`${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))},${nullStr(r.host)},${nullStr(r.id)});`;
|
|
279
396
|
}
|
|
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.
|
|
397
|
+
// Unchanged transcripts are skipped via mtime+size; rows for deleted ones are kept so rotation never erases history. False when sqlite3 is unavailable.
|
|
282
398
|
export function syncUsageDb() {
|
|
283
399
|
let db;
|
|
400
|
+
guardReport = null;
|
|
401
|
+
let preWipe = -1;
|
|
402
|
+
let migrated = false;
|
|
284
403
|
try {
|
|
285
404
|
db = usageDbPath();
|
|
405
|
+
const stored = storedParserVersion(db);
|
|
406
|
+
migrated = stored !== null && stored !== PARSER_VERSION;
|
|
407
|
+
// The operator deleted sessions on purpose and wants the mirror to match: bypass the guard.
|
|
408
|
+
const forced = process.env.TERSIO_FORCE_REPARSE === '1';
|
|
409
|
+
if (migrated && !forced)
|
|
410
|
+
preWipe = countMessages(db);
|
|
286
411
|
ensureSchema(db);
|
|
287
412
|
maybeScheduledBackup(db);
|
|
288
413
|
}
|
|
@@ -294,9 +419,6 @@ export function syncUsageDb() {
|
|
|
294
419
|
try {
|
|
295
420
|
for (const dir of sessionsDirs())
|
|
296
421
|
walkJsonl(dir, files, 2000);
|
|
297
|
-
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
298
|
-
walkJsonl(codexSessionsDir(), files, 2000);
|
|
299
|
-
}
|
|
300
422
|
// OpenCode stores one JSON document per message, not JSONL.
|
|
301
423
|
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
|
|
302
424
|
walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
|
|
@@ -306,6 +428,15 @@ export function syncUsageDb() {
|
|
|
306
428
|
return false;
|
|
307
429
|
}
|
|
308
430
|
files.push(...ocFiles);
|
|
431
|
+
// v2 hosts keep messages in sqlite; rows past the watermark are new. Overlap re-reads the tail idempotently.
|
|
432
|
+
const ocDbRows = [];
|
|
433
|
+
const ocDbPath = opencodeDbPath();
|
|
434
|
+
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DB !== undefined) {
|
|
435
|
+
const newest = newestOpencodeMs(db);
|
|
436
|
+
if (newest !== null || fs.existsSync(ocDbPath)) {
|
|
437
|
+
ocDbRows.push(...readOpencodeDbRows(ocDbPath, newest === null ? 0 : newest - OPENCODE_DB_OVERLAP_MS));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
309
440
|
const known = readFiles(db);
|
|
310
441
|
const current = {};
|
|
311
442
|
const changed = [];
|
|
@@ -323,7 +454,7 @@ export function syncUsageDb() {
|
|
|
323
454
|
if (!prev || prev.mtime !== entry.mtime || prev.size !== entry.size)
|
|
324
455
|
changed.push(file);
|
|
325
456
|
}
|
|
326
|
-
if (!changed.length)
|
|
457
|
+
if (!changed.length && !ocDbRows.length)
|
|
327
458
|
return true;
|
|
328
459
|
const chunks = [];
|
|
329
460
|
let chunkBytes = 0;
|
|
@@ -343,23 +474,36 @@ export function syncUsageDb() {
|
|
|
343
474
|
const push = (sql) => {
|
|
344
475
|
chunks.push(sql);
|
|
345
476
|
chunkBytes += sql.length;
|
|
346
|
-
|
|
477
|
+
// One argv string caps at 128KB on Linux (E2BIG); stay well under it.
|
|
478
|
+
if (chunkBytes < 100_000)
|
|
347
479
|
return true;
|
|
348
480
|
return flush();
|
|
349
481
|
};
|
|
350
482
|
for (const file of changed) {
|
|
351
|
-
|
|
483
|
+
const entry = current[file];
|
|
484
|
+
if (!push(`INSERT INTO files (path, mtime, size) VALUES (${esc(file)},${entry.mtime},${entry.size})` +
|
|
485
|
+
` ON CONFLICT(path) DO UPDATE SET mtime=excluded.mtime,size=excluded.size;` +
|
|
486
|
+
`DELETE FROM messages WHERE file_id=(SELECT id FROM files WHERE path=${esc(file)});`))
|
|
352
487
|
return false;
|
|
353
488
|
}
|
|
354
489
|
try {
|
|
355
490
|
for (const file of changed) {
|
|
356
|
-
const entry = current[file];
|
|
357
491
|
const text = fs.readFileSync(file, 'utf8');
|
|
358
|
-
for (const r of parseFile(text, hostOfSessionFile(file))) {
|
|
492
|
+
for (const r of parseFile(text, hostOfSessionFile(file), file)) {
|
|
359
493
|
if (!push(insertSql(file, r)))
|
|
360
494
|
return false;
|
|
361
495
|
}
|
|
362
|
-
|
|
496
|
+
}
|
|
497
|
+
for (const row of ocDbRows) {
|
|
498
|
+
const parsed = classifyOpencodeMessage(row.row);
|
|
499
|
+
if (!parsed)
|
|
500
|
+
continue;
|
|
501
|
+
// One synthetic path per row id, so the delete-and-reinsert that follows is idempotent.
|
|
502
|
+
const key = `${ocDbPath}#${row.id}`;
|
|
503
|
+
if (!push(`INSERT INTO files (path, mtime, size) VALUES (${esc(key)},${row.created},0)` +
|
|
504
|
+
` ON CONFLICT(path) DO UPDATE SET mtime=excluded.mtime;` +
|
|
505
|
+
`DELETE FROM messages WHERE file_id=(SELECT id FROM files WHERE path=${esc(key)});` +
|
|
506
|
+
insertSql(key, opencodeStoredRow(parsed, 'opencode', row.id))))
|
|
363
507
|
return false;
|
|
364
508
|
}
|
|
365
509
|
}
|
|
@@ -368,7 +512,26 @@ export function syncUsageDb() {
|
|
|
368
512
|
}
|
|
369
513
|
if (!push(`INSERT OR REPLACE INTO meta (k, v) VALUES ('last_sync',${esc(String(Date.now()))});`))
|
|
370
514
|
return false;
|
|
371
|
-
|
|
515
|
+
if (!flush())
|
|
516
|
+
return false;
|
|
517
|
+
if (preWipe > 0) {
|
|
518
|
+
const kept = countMessages(db);
|
|
519
|
+
if (kept < preWipe * REPARSE_GUARD_RATIO) {
|
|
520
|
+
const newest = listUsageBackups()[0];
|
|
521
|
+
if (newest && restoreUsageBackup(path.basename(newest.file))) {
|
|
522
|
+
guardReport = `re-parse kept ${kept} of ${preWipe} rows; restored ${path.basename(newest.file)} (set TERSIO_FORCE_REPARSE=1 to accept the loss)`;
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// Reclaim the migration's DELETE storm so the file stays small.
|
|
528
|
+
if (migrated) {
|
|
529
|
+
try {
|
|
530
|
+
run(db, `VACUUM;`);
|
|
531
|
+
}
|
|
532
|
+
catch { /* best-effort */ }
|
|
533
|
+
}
|
|
534
|
+
return true;
|
|
372
535
|
}
|
|
373
536
|
export function readUsageDb() {
|
|
374
537
|
const db = usageDbPath();
|
|
@@ -381,13 +544,13 @@ export function readUsageDb() {
|
|
|
381
544
|
}
|
|
382
545
|
let rows;
|
|
383
546
|
try {
|
|
384
|
-
rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools, h FROM messages;`);
|
|
547
|
+
rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, id FROM messages;`);
|
|
385
548
|
}
|
|
386
549
|
catch {
|
|
387
550
|
return null;
|
|
388
551
|
}
|
|
389
552
|
const accum = newSessionAccum();
|
|
390
|
-
for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools, h] of rows) {
|
|
553
|
+
for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, id] of rows) {
|
|
391
554
|
const ts = t === '' ? undefined : Number(t);
|
|
392
555
|
let toolNames = [];
|
|
393
556
|
try {
|
|
@@ -411,7 +574,7 @@ export function readUsageDb() {
|
|
|
411
574
|
st: st === 'error' || st === 'aborted' ? st : 'completed',
|
|
412
575
|
code: code === '' ? undefined : Number(code),
|
|
413
576
|
note: note === '' ? undefined : note,
|
|
414
|
-
}, toolNames, h === '' ? undefined : h);
|
|
577
|
+
}, toolNames, h === '' ? undefined : h, id === '' || id === undefined ? undefined : id);
|
|
415
578
|
}
|
|
416
579
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
417
580
|
let syncedAt = 0;
|
|
@@ -430,6 +593,7 @@ export function readUsageDb() {
|
|
|
430
593
|
byDayModel: accum.byDayModel,
|
|
431
594
|
byTool: accum.byTool,
|
|
432
595
|
byModelMessages: accum.byModelMessages,
|
|
596
|
+
byHost: accum.byHost,
|
|
433
597
|
costMeasured: accum.costMeasured,
|
|
434
598
|
recent: accum.recent.slice(0, RECENT_LIMIT),
|
|
435
599
|
},
|