@krtclcdy/tersio 2.24.0 → 2.25.0
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 +28 -24
- package/cli/banner.js +7 -5
- package/cli/common.js +14 -13
- package/cli/currency.js +2 -7
- package/cli/dashboard.js +220 -13
- package/cli/doctor-fix.js +99 -29
- package/cli/doctor.js +32 -35
- package/cli/hosts.js +23 -26
- package/cli/install.js +228 -115
- package/cli/interactive.js +65 -21
- package/cli/manifest.js +8 -4
- package/cli/profile.js +49 -16
- package/cli/reset.js +3 -7
- package/cli/rtk-wiring.js +29 -32
- package/cli/settings.js +40 -16
- package/cli/uninstall.js +79 -29
- package/cli/update.js +27 -20
- package/cli/usage.js +54 -19
- package/dashboard/dist/index.html +18 -18
- package/extensions/ai-addons-updater/index.js +75 -20
- package/extensions/ai-addons-updater/index.ts +67 -23
- package/extensions/caveman-session/index.js +33 -75
- package/extensions/caveman-session/index.ts +35 -77
- package/extensions/combo-toggle/index.js +40 -62
- package/extensions/combo-toggle/index.ts +38 -72
- package/extensions/lib/utils.js +64 -15
- package/extensions/lib/utils.ts +62 -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 +38 -57
- package/extensions/rtk-session/index.ts +36 -55
- package/extensions/shared/carbon.js +4 -10
- package/extensions/shared/carbon.ts +5 -10
- package/extensions/shared/host.js +95 -56
- package/extensions/shared/host.ts +106 -60
- package/extensions/shared/plugin-settings.js +6 -18
- package/extensions/shared/plugin-settings.ts +6 -18
- package/extensions/shared/pricing.js +79 -27
- package/extensions/shared/pricing.ts +80 -28
- package/extensions/shared/rtk-gain.js +4 -11
- package/extensions/shared/rtk-gain.ts +4 -11
- package/extensions/shared/session-state.js +28 -39
- package/extensions/shared/session-state.ts +29 -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 +312 -57
- package/extensions/shared/usage-ledger.ts +321 -61
- package/extensions/shared/usage-store.js +354 -38
- package/extensions/shared/usage-store.ts +360 -43
- package/extensions/tersio-commands/index.js +5 -14
- package/extensions/tersio-commands/index.ts +5 -14
- package/package.json +12 -7
- package/tersio.js +4 -6
- package/cli/agent-hosts.js +0 -42
- package/cli/agents.js +0 -266
- package/cli/layer.js +0 -24
- 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,27 +1,32 @@
|
|
|
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 live parse, never a fork; missing sqlite3 → sync false / read null.
|
|
7
2
|
import { execFileSync } from 'node:child_process';
|
|
8
3
|
import fs from 'node:fs';
|
|
9
4
|
import os from 'node:os';
|
|
10
5
|
import path from 'node:path';
|
|
11
6
|
import {
|
|
12
7
|
RECENT_LIMIT,
|
|
8
|
+
SQLITE_READ_BUFFER,
|
|
13
9
|
canonicalModelId,
|
|
14
10
|
classifySessionLine,
|
|
15
|
-
|
|
11
|
+
OpencodeMessage,
|
|
12
|
+
classifyOpencodeMessage,
|
|
16
13
|
costOf,
|
|
17
14
|
durOf,
|
|
15
|
+
hostOfSessionFile,
|
|
18
16
|
ingestSessionRow,
|
|
17
|
+
messageRowId,
|
|
18
|
+
OpencodeDbRow,
|
|
19
|
+
OPENCODE_DB_OVERLAP_MS,
|
|
20
|
+
opencodeDbPath,
|
|
21
|
+
opencodeSessionsDir,
|
|
19
22
|
newSessionAccum,
|
|
20
|
-
|
|
23
|
+
readOpencodeDbRows,
|
|
24
|
+
sessionsDirs,
|
|
21
25
|
walkJsonl,
|
|
22
26
|
} from './usage-ledger.ts';
|
|
23
27
|
import { tersioDataPath } from '../lib/utils.ts';
|
|
24
28
|
import { MODEL_ALIASES } from './pricing.ts';
|
|
29
|
+
import { tersioSettingsFile } from './plugin-settings.ts';
|
|
25
30
|
import type { RunStatus, SessionTokens } from './usage-ledger.ts';
|
|
26
31
|
|
|
27
32
|
export function usageDbPath(): string {
|
|
@@ -52,7 +57,8 @@ function nullStr(v: string | undefined): string {
|
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
function run(db: string, sql: string): void {
|
|
55
|
-
|
|
60
|
+
// -bail stops the script before COMMIT, so a failed INSERT never leaves DELETEs behind.
|
|
61
|
+
execFileSync('sqlite3', ['-bail', db, sql], { stdio: ['ignore', 'ignore', 'ignore'], timeout: 30000 });
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
function query(db: string, sql: string): string[][] {
|
|
@@ -60,6 +66,7 @@ function query(db: string, sql: string): string[][] {
|
|
|
60
66
|
encoding: 'utf8',
|
|
61
67
|
timeout: 30000,
|
|
62
68
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
69
|
+
maxBuffer: SQLITE_READ_BUFFER,
|
|
63
70
|
});
|
|
64
71
|
return out
|
|
65
72
|
.split('\n')
|
|
@@ -67,21 +74,240 @@ function query(db: string, sql: string): string[][] {
|
|
|
67
74
|
.map((line) => line.split('\t'));
|
|
68
75
|
}
|
|
69
76
|
|
|
77
|
+
// Bump on a parse change: unchanged transcripts are never re-read.
|
|
78
|
+
const PARSER_VERSION = '11';
|
|
79
|
+
|
|
80
|
+
// A re-parse that keeps less than half the rows means transcripts vanished mid-migration; the backup is restored instead of publishing the loss.
|
|
81
|
+
const REPARSE_GUARD_RATIO = 0.5;
|
|
82
|
+
|
|
83
|
+
let guardReport: string | null = null;
|
|
84
|
+
|
|
85
|
+
/** Why the last sync refused to publish a re-parse, if it did. */
|
|
86
|
+
export function reparseGuardReport(): string | null {
|
|
87
|
+
return guardReport;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const SCHEMA_SQL =
|
|
91
|
+
`CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);` +
|
|
92
|
+
`CREATE TABLE IF NOT EXISTS files (id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT UNIQUE NOT NULL, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
|
|
93
|
+
`CREATE TABLE IF NOT EXISTS messages (file_id INTEGER NOT NULL REFERENCES files(id), t REAL, model TEXT NOT NULL,` +
|
|
94
|
+
` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
|
|
95
|
+
` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT, id TEXT);` +
|
|
96
|
+
`CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file_id);`;
|
|
97
|
+
|
|
98
|
+
function storedParserVersion(db: string): string | null {
|
|
99
|
+
try {
|
|
100
|
+
const stored = query(db, `SELECT v FROM meta WHERE k='parser_version';`);
|
|
101
|
+
return stored.length ? stored[0][0] : null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function countMessages(db: string): number {
|
|
108
|
+
try {
|
|
109
|
+
const rows = query(db, `SELECT COUNT(*) FROM messages;`);
|
|
110
|
+
return Number(rows[0]?.[0]) || 0;
|
|
111
|
+
} catch {
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Watermark for the sqlite scan: the newest opencode row already stored.
|
|
117
|
+
function newestOpencodeMs(db: string): number | null {
|
|
118
|
+
try {
|
|
119
|
+
const rows = query(db, `SELECT MAX(t) FROM messages WHERE h='opencode';`);
|
|
120
|
+
const v = Number(rows[0]?.[0]);
|
|
121
|
+
return Number.isFinite(v) && v > 0 ? v : null;
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Same pre-migration state as the newest backup: another copy churns the 3-slot rotation for nothing.
|
|
128
|
+
function newestBackupMatches(stored: string | null): boolean {
|
|
129
|
+
if (stored === null) return false;
|
|
130
|
+
try {
|
|
131
|
+
const newest = listUsageBackups()[0];
|
|
132
|
+
if (!newest) return false;
|
|
133
|
+
const out = query(newest.file, `SELECT v FROM meta WHERE k='parser_version';`);
|
|
134
|
+
return out.length > 0 && out[0][0] === stored;
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// v9 shape (path-keyed rows) migrates in place with zero re-parse, so history survives even when transcripts are gone.
|
|
141
|
+
function canMigrateInPlace(db: string): boolean {
|
|
142
|
+
try {
|
|
143
|
+
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
144
|
+
return names.includes('file') && names.includes('h') && !names.includes('file_id');
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function migrateInPlace(db: string, preCount: number): boolean {
|
|
151
|
+
const cols = `t, model, i, o, d, cr, cw, usd, st, code, note, tools, h`;
|
|
152
|
+
try {
|
|
153
|
+
run(db,
|
|
154
|
+
`BEGIN;` +
|
|
155
|
+
`CREATE TABLE files_new (id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT UNIQUE NOT NULL, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
|
|
156
|
+
`INSERT INTO files_new (path, mtime, size) SELECT path, mtime, size FROM files;` +
|
|
157
|
+
`CREATE TABLE messages_new (file_id INTEGER NOT NULL REFERENCES files_new(id), t REAL, model TEXT NOT NULL,` +
|
|
158
|
+
` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
|
|
159
|
+
` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT, id TEXT);` +
|
|
160
|
+
`INSERT INTO messages_new (file_id, ${cols}) SELECT (SELECT id FROM files_new WHERE files_new.path = messages.file), ${cols} FROM messages;` +
|
|
161
|
+
`DROP TABLE messages;DROP TABLE files;` +
|
|
162
|
+
`ALTER TABLE files_new RENAME TO files;ALTER TABLE messages_new RENAME TO messages;` +
|
|
163
|
+
`CREATE INDEX idx_messages_file ON messages(file_id);` +
|
|
164
|
+
`COMMIT;`);
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
170
|
+
return names.includes('file_id') && !names.includes('file') && countMessages(db) === preCount;
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// v10 shape gains the message id with a bare ALTER: no rows move, so no re-parse and no guard trip.
|
|
177
|
+
function canAlterInPlace(db: string): boolean {
|
|
178
|
+
try {
|
|
179
|
+
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
180
|
+
return names.includes('file_id') && !names.includes('id');
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function alterInPlace(db: string): boolean {
|
|
187
|
+
try {
|
|
188
|
+
run(db, `ALTER TABLE messages ADD COLUMN id TEXT;`);
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
return !canAlterInPlace(db);
|
|
193
|
+
}
|
|
194
|
+
|
|
70
195
|
function ensureSchema(db: string): void {
|
|
71
196
|
fs.mkdirSync(path.dirname(db), { recursive: true });
|
|
72
|
-
//
|
|
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.
|
|
197
|
+
// Fold in rows stored under a pre-alias spelling; the mtime ledger will not re-read those transcripts.
|
|
75
198
|
const rekeys = MODEL_ALIASES.map((a) => `UPDATE messages SET model='${a.id}' WHERE model='${a.feed}';`).join('');
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
199
|
+
const stored = storedParserVersion(db);
|
|
200
|
+
if (stored === PARSER_VERSION) {
|
|
201
|
+
run(db, SCHEMA_SQL + rekeys);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// A version bump rebuilds both tables, so no ALTER patchwork survives a migration.
|
|
205
|
+
try {
|
|
206
|
+
if (countMessages(db) > 0 && !newestBackupMatches(stored)) backupUsageDb(db);
|
|
207
|
+
if (stored !== null && canMigrateInPlace(db) && migrateInPlace(db, countMessages(db))) {
|
|
208
|
+
run(db, `INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
209
|
+
try { run(db, `VACUUM;`); } catch { /* best-effort */ }
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (stored !== null && canAlterInPlace(db) && alterInPlace(db)) {
|
|
213
|
+
run(db, `INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
run(db, `DROP TABLE IF EXISTS messages;DROP TABLE IF EXISTS files;` + SCHEMA_SQL +
|
|
217
|
+
`INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
218
|
+
} catch { /* fresh db, nothing to invalidate */ }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// The one destructive thing we do; keep the last few so a bad bump is recoverable.
|
|
222
|
+
const BACKUP_KEEP = 3;
|
|
223
|
+
|
|
224
|
+
export function backupUsageDir(): string {
|
|
225
|
+
return path.join(path.dirname(usageDbPath()), 'backups');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function listUsageBackups(): Array<{ file: string; mtime: number; size: number }> {
|
|
229
|
+
try {
|
|
230
|
+
return fs.readdirSync(backupUsageDir())
|
|
231
|
+
.filter((n) => /^usage-\d{14}\.db$/.test(n))
|
|
232
|
+
.map((n) => {
|
|
233
|
+
const full = path.join(backupUsageDir(), n);
|
|
234
|
+
const st = fs.statSync(full);
|
|
235
|
+
return { file: full, mtime: st.mtimeMs, size: st.size };
|
|
236
|
+
})
|
|
237
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
238
|
+
} catch {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function backupUsageDb(db: string): void {
|
|
244
|
+
try {
|
|
245
|
+
const dir = backupUsageDir();
|
|
246
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
247
|
+
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
248
|
+
fs.copyFileSync(db, path.join(dir, `usage-${stamp}.db`));
|
|
249
|
+
for (const old of listUsageBackups().slice(BACKUP_KEEP)) {
|
|
250
|
+
fs.rmSync(old.file, { force: true });
|
|
251
|
+
}
|
|
252
|
+
} catch { /* a missing backup must not block the sync */ }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const SCHEDULE_MS: Record<string, number> = { daily: 86_400_000, weekly: 604_800_000, monthly: 2_592_000_000 };
|
|
256
|
+
|
|
257
|
+
/** Read the schedule straight from settings; this module must not import cli/. */
|
|
258
|
+
export function readBackupSchedule(): string {
|
|
259
|
+
try {
|
|
260
|
+
const raw = JSON.parse(fs.readFileSync(tersioSettingsFile(), 'utf8')) as { backupSchedule?: unknown };
|
|
261
|
+
return typeof raw?.backupSchedule === 'string' ? raw.backupSchedule : 'monthly';
|
|
262
|
+
} catch {
|
|
263
|
+
return 'monthly';
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Snapshot on the schedule, skipping when the newest one is still fresh. */
|
|
268
|
+
export function maybeScheduledBackup(db: string): boolean {
|
|
269
|
+
const every = SCHEDULE_MS[readBackupSchedule()];
|
|
270
|
+
if (!every) return false;
|
|
271
|
+
try {
|
|
272
|
+
const newest = listUsageBackups()[0];
|
|
273
|
+
if (newest && Date.now() - newest.mtime < every) return false;
|
|
274
|
+
backupUsageDb(db);
|
|
275
|
+
return true;
|
|
276
|
+
} catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// basename only: a crafted `../` must not escape the backup dir.
|
|
282
|
+
function resolveBackupFile(file: string): string | null {
|
|
283
|
+
const dir = path.resolve(backupUsageDir());
|
|
284
|
+
const target = path.join(dir, path.basename(String(file || '')));
|
|
285
|
+
if (!target.startsWith(dir + path.sep)) return null;
|
|
286
|
+
return fs.existsSync(target) ? target : null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Put a backup back in place. The next sync then re-parses over it. */
|
|
290
|
+
export function restoreUsageBackup(file: string): boolean {
|
|
291
|
+
try {
|
|
292
|
+
const src = resolveBackupFile(file);
|
|
293
|
+
if (!src) return false;
|
|
294
|
+
fs.copyFileSync(src, usageDbPath());
|
|
295
|
+
return true;
|
|
296
|
+
} catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Delete one snapshot. Only ever touches a file inside the backup dir. */
|
|
302
|
+
export function deleteUsageBackup(file: string): boolean {
|
|
303
|
+
try {
|
|
304
|
+
const target = resolveBackupFile(file);
|
|
305
|
+
if (!target) return false;
|
|
306
|
+
fs.rmSync(target, { force: true });
|
|
307
|
+
return true;
|
|
308
|
+
} catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
85
311
|
}
|
|
86
312
|
|
|
87
313
|
function readFiles(db: string): Record<string, { mtime: number; size: number }> {
|
|
@@ -94,8 +320,7 @@ function readFiles(db: string): Record<string, { mtime: number; size: number }>
|
|
|
94
320
|
return known;
|
|
95
321
|
}
|
|
96
322
|
|
|
97
|
-
// One parsed assistant message, as stored. `t` is null when the source row has
|
|
98
|
-
// no usable timestamp: counted, but skipped in the recent list.
|
|
323
|
+
// One parsed assistant message, as stored. `t` is null when the source row has no usable timestamp: counted, but skipped in the recent list.
|
|
99
324
|
interface StoredRow {
|
|
100
325
|
t: number | null;
|
|
101
326
|
model: string;
|
|
@@ -109,20 +334,55 @@ interface StoredRow {
|
|
|
109
334
|
code: number | undefined;
|
|
110
335
|
note: string | undefined;
|
|
111
336
|
tools: string[];
|
|
337
|
+
host?: string;
|
|
338
|
+
id?: string;
|
|
112
339
|
}
|
|
113
340
|
|
|
114
|
-
|
|
341
|
+
// OpenCode rows carry no status, code, note, or tool list; only tokens and the msg id.
|
|
342
|
+
function opencodeStoredRow(oc: NonNullable<ReturnType<typeof classifyOpencodeMessage>>, host: string | undefined, id: string | undefined): StoredRow {
|
|
343
|
+
return {
|
|
344
|
+
t: oc.ms ?? null,
|
|
345
|
+
model: canonicalModelId(oc.model),
|
|
346
|
+
i: intOf(oc.usage.input),
|
|
347
|
+
o: intOf(oc.usage.output),
|
|
348
|
+
d: durOf(oc.durMs),
|
|
349
|
+
cr: intOf(oc.usage.cacheRead),
|
|
350
|
+
cw: intOf(oc.usage.cacheWrite),
|
|
351
|
+
usd: costOf(oc.usage),
|
|
352
|
+
st: 'completed',
|
|
353
|
+
code: undefined,
|
|
354
|
+
note: undefined,
|
|
355
|
+
tools: [],
|
|
356
|
+
host,
|
|
357
|
+
id,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function parseFile(text: string, host?: string, file?: string): StoredRow[] {
|
|
115
362
|
const rows: StoredRow[] = [];
|
|
363
|
+
// Guarded: a JSONL transcript must fall through, not abort the sync.
|
|
364
|
+
let oc: ReturnType<typeof classifyOpencodeMessage> = null;
|
|
365
|
+
try { oc = classifyOpencodeMessage(JSON.parse(text) as OpencodeMessage); } catch { /* JSONL */ }
|
|
366
|
+
if (oc) {
|
|
367
|
+
rows.push(opencodeStoredRow(oc, host, file ? path.basename(file).replace(/\.[^.]+$/, '') : undefined));
|
|
368
|
+
return rows;
|
|
369
|
+
}
|
|
116
370
|
let codexProvider: string | null = null;
|
|
371
|
+
let codexModel: string | null = null;
|
|
117
372
|
for (const line of text.split('\n')) {
|
|
118
373
|
if (!line.trim()) continue;
|
|
119
374
|
try {
|
|
120
375
|
const row = JSON.parse(line) as Parameters<typeof classifySessionLine>[0];
|
|
376
|
+
const rid = messageRowId(row);
|
|
121
377
|
const parsed = classifySessionLine(row);
|
|
122
378
|
if (parsed.kind === 'codex_provider') {
|
|
123
379
|
if (parsed.provider) codexProvider = parsed.provider;
|
|
124
380
|
continue;
|
|
125
381
|
}
|
|
382
|
+
if (parsed.kind === 'codex_model') {
|
|
383
|
+
if (parsed.model) codexModel = parsed.model;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
126
386
|
const ts = row.timestamp;
|
|
127
387
|
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
128
388
|
const t = Number.isFinite(ms) ? ms : null;
|
|
@@ -131,7 +391,7 @@ function parseFile(text: string): StoredRow[] {
|
|
|
131
391
|
const usage = parsed.usage ?? {};
|
|
132
392
|
rows.push({
|
|
133
393
|
t,
|
|
134
|
-
model: canonicalModelId(provider ? `codex/${provider}` : 'codex'),
|
|
394
|
+
model: canonicalModelId(provider ? (codexModel ? `codex/${provider}/${codexModel}` : `codex/${provider}`) : 'codex'),
|
|
135
395
|
i: intOf(usage.input),
|
|
136
396
|
o: intOf(usage.output),
|
|
137
397
|
d: undefined,
|
|
@@ -142,6 +402,8 @@ function parseFile(text: string): StoredRow[] {
|
|
|
142
402
|
code: undefined,
|
|
143
403
|
note: undefined,
|
|
144
404
|
tools: [],
|
|
405
|
+
host,
|
|
406
|
+
id: rid,
|
|
145
407
|
});
|
|
146
408
|
continue;
|
|
147
409
|
}
|
|
@@ -163,6 +425,8 @@ function parseFile(text: string): StoredRow[] {
|
|
|
163
425
|
code: run.code,
|
|
164
426
|
note,
|
|
165
427
|
tools: parsed.tools ?? [],
|
|
428
|
+
host,
|
|
429
|
+
id: rid,
|
|
166
430
|
});
|
|
167
431
|
} catch { /* skip corrupt lines */ }
|
|
168
432
|
}
|
|
@@ -170,32 +434,51 @@ function parseFile(text: string): StoredRow[] {
|
|
|
170
434
|
}
|
|
171
435
|
|
|
172
436
|
function insertSql(file: string, r: StoredRow): string {
|
|
173
|
-
return `INSERT INTO messages (
|
|
174
|
-
|
|
437
|
+
return `INSERT INTO messages (file_id, t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, id) VALUES (` +
|
|
438
|
+
`(SELECT id FROM files WHERE path=${esc(file)}),${r.t === null ? 'NULL' : String(r.t)},${esc(r.model)},${r.i},${r.o},` +
|
|
175
439
|
`${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))});`;
|
|
440
|
+
`${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))},${nullStr(r.host)},${nullStr(r.id)});`;
|
|
177
441
|
}
|
|
178
442
|
|
|
179
|
-
//
|
|
180
|
-
// ones re-inserted. Rows for deleted transcripts are kept, so rotation never
|
|
181
|
-
// erases history. False when sqlite3 or disk is unavailable.
|
|
443
|
+
// Unchanged transcripts are skipped via mtime+size; rows for deleted ones are kept so rotation never erases history. False when sqlite3 is unavailable.
|
|
182
444
|
export function syncUsageDb(): boolean {
|
|
183
445
|
let db: string;
|
|
446
|
+
guardReport = null;
|
|
447
|
+
let preWipe = -1;
|
|
448
|
+
let migrated = false;
|
|
184
449
|
try {
|
|
185
450
|
db = usageDbPath();
|
|
451
|
+
const stored = storedParserVersion(db);
|
|
452
|
+
migrated = stored !== null && stored !== PARSER_VERSION;
|
|
453
|
+
// The operator deleted sessions on purpose and wants the mirror to match: bypass the guard.
|
|
454
|
+
const forced = process.env.TERSIO_FORCE_REPARSE === '1';
|
|
455
|
+
if (migrated && !forced) preWipe = countMessages(db);
|
|
186
456
|
ensureSchema(db);
|
|
457
|
+
maybeScheduledBackup(db);
|
|
187
458
|
} catch {
|
|
188
459
|
return false;
|
|
189
460
|
}
|
|
190
461
|
const files: string[] = [];
|
|
462
|
+
const ocFiles: string[] = [];
|
|
191
463
|
try {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
464
|
+
for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
|
|
465
|
+
// OpenCode stores one JSON document per message, not JSONL.
|
|
466
|
+
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
|
|
467
|
+
walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
|
|
195
468
|
}
|
|
196
469
|
} catch {
|
|
197
470
|
return false;
|
|
198
471
|
}
|
|
472
|
+
files.push(...ocFiles);
|
|
473
|
+
// v2 hosts keep messages in sqlite; rows past the watermark are new. Overlap re-reads the tail idempotently.
|
|
474
|
+
const ocDbRows: OpencodeDbRow[] = [];
|
|
475
|
+
const ocDbPath = opencodeDbPath();
|
|
476
|
+
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DB !== undefined) {
|
|
477
|
+
const newest = newestOpencodeMs(db);
|
|
478
|
+
if (newest !== null || fs.existsSync(ocDbPath)) {
|
|
479
|
+
ocDbRows.push(...readOpencodeDbRows(ocDbPath, newest === null ? 0 : newest - OPENCODE_DB_OVERLAP_MS));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
199
482
|
const known = readFiles(db);
|
|
200
483
|
const current: Record<string, { mtime: number; size: number }> = {};
|
|
201
484
|
const changed: string[] = [];
|
|
@@ -211,7 +494,7 @@ export function syncUsageDb(): boolean {
|
|
|
211
494
|
const prev = known[file];
|
|
212
495
|
if (!prev || prev.mtime !== entry.mtime || prev.size !== entry.size) changed.push(file);
|
|
213
496
|
}
|
|
214
|
-
if (!changed.length) return true;
|
|
497
|
+
if (!changed.length && !ocDbRows.length) return true;
|
|
215
498
|
const chunks: string[] = [];
|
|
216
499
|
let chunkBytes = 0;
|
|
217
500
|
const flush = (): boolean => {
|
|
@@ -228,26 +511,57 @@ export function syncUsageDb(): boolean {
|
|
|
228
511
|
const push = (sql: string): boolean => {
|
|
229
512
|
chunks.push(sql);
|
|
230
513
|
chunkBytes += sql.length;
|
|
231
|
-
|
|
514
|
+
// One argv string caps at 128KB on Linux (E2BIG); stay well under it.
|
|
515
|
+
if (chunkBytes < 100_000) return true;
|
|
232
516
|
return flush();
|
|
233
517
|
};
|
|
234
518
|
for (const file of changed) {
|
|
235
|
-
|
|
519
|
+
const entry = current[file];
|
|
520
|
+
if (!push(
|
|
521
|
+
`INSERT INTO files (path, mtime, size) VALUES (${esc(file)},${entry.mtime},${entry.size})` +
|
|
522
|
+
` ON CONFLICT(path) DO UPDATE SET mtime=excluded.mtime,size=excluded.size;` +
|
|
523
|
+
`DELETE FROM messages WHERE file_id=(SELECT id FROM files WHERE path=${esc(file)});`,
|
|
524
|
+
)) return false;
|
|
236
525
|
}
|
|
237
526
|
try {
|
|
238
527
|
for (const file of changed) {
|
|
239
|
-
const entry = current[file];
|
|
240
528
|
const text = fs.readFileSync(file, 'utf8');
|
|
241
|
-
for (const r of parseFile(text)) {
|
|
529
|
+
for (const r of parseFile(text, hostOfSessionFile(file), file)) {
|
|
242
530
|
if (!push(insertSql(file, r))) return false;
|
|
243
531
|
}
|
|
244
|
-
|
|
532
|
+
}
|
|
533
|
+
for (const row of ocDbRows) {
|
|
534
|
+
const parsed = classifyOpencodeMessage(row.row);
|
|
535
|
+
if (!parsed) continue;
|
|
536
|
+
// One synthetic path per row id, so the delete-and-reinsert that follows is idempotent.
|
|
537
|
+
const key = `${ocDbPath}#${row.id}`;
|
|
538
|
+
if (!push(
|
|
539
|
+
`INSERT INTO files (path, mtime, size) VALUES (${esc(key)},${row.created},0)` +
|
|
540
|
+
` ON CONFLICT(path) DO UPDATE SET mtime=excluded.mtime;` +
|
|
541
|
+
`DELETE FROM messages WHERE file_id=(SELECT id FROM files WHERE path=${esc(key)});` +
|
|
542
|
+
insertSql(key, opencodeStoredRow(parsed, 'opencode', row.id)),
|
|
543
|
+
)) return false;
|
|
245
544
|
}
|
|
246
545
|
} catch {
|
|
247
546
|
return false;
|
|
248
547
|
}
|
|
249
548
|
if (!push(`INSERT OR REPLACE INTO meta (k, v) VALUES ('last_sync',${esc(String(Date.now()))});`)) return false;
|
|
250
|
-
|
|
549
|
+
if (!flush()) return false;
|
|
550
|
+
if (preWipe > 0) {
|
|
551
|
+
const kept = countMessages(db);
|
|
552
|
+
if (kept < preWipe * REPARSE_GUARD_RATIO) {
|
|
553
|
+
const newest = listUsageBackups()[0];
|
|
554
|
+
if (newest && restoreUsageBackup(path.basename(newest.file))) {
|
|
555
|
+
guardReport = `re-parse kept ${kept} of ${preWipe} rows; restored ${path.basename(newest.file)} (set TERSIO_FORCE_REPARSE=1 to accept the loss)`;
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// Reclaim the migration's DELETE storm so the file stays small.
|
|
561
|
+
if (migrated) {
|
|
562
|
+
try { run(db, `VACUUM;`); } catch { /* best-effort */ }
|
|
563
|
+
}
|
|
564
|
+
return true;
|
|
251
565
|
}
|
|
252
566
|
|
|
253
567
|
export function readUsageDb(): StoredUsage | null {
|
|
@@ -259,12 +573,12 @@ export function readUsageDb(): StoredUsage | null {
|
|
|
259
573
|
}
|
|
260
574
|
let rows: string[][];
|
|
261
575
|
try {
|
|
262
|
-
rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools FROM messages;`);
|
|
576
|
+
rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, id FROM messages;`);
|
|
263
577
|
} catch {
|
|
264
578
|
return null;
|
|
265
579
|
}
|
|
266
580
|
const accum = newSessionAccum();
|
|
267
|
-
for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools] of rows) {
|
|
581
|
+
for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, id] of rows) {
|
|
268
582
|
const ts = t === '' ? undefined : Number(t);
|
|
269
583
|
let toolNames: string[] = [];
|
|
270
584
|
try {
|
|
@@ -293,6 +607,8 @@ export function readUsageDb(): StoredUsage | null {
|
|
|
293
607
|
note: note === '' ? undefined : note,
|
|
294
608
|
},
|
|
295
609
|
toolNames,
|
|
610
|
+
h === '' ? undefined : h,
|
|
611
|
+
id === '' || id === undefined ? undefined : id,
|
|
296
612
|
);
|
|
297
613
|
}
|
|
298
614
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
@@ -310,6 +626,7 @@ export function readUsageDb(): StoredUsage | null {
|
|
|
310
626
|
byDayModel: accum.byDayModel,
|
|
311
627
|
byTool: accum.byTool,
|
|
312
628
|
byModelMessages: accum.byModelMessages,
|
|
629
|
+
byHost: accum.byHost,
|
|
313
630
|
costMeasured: accum.costMeasured,
|
|
314
631
|
recent: accum.recent.slice(0, RECENT_LIMIT),
|
|
315
632
|
},
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
// publishes sync sibling mirrors live, so switches notify and take effect
|
|
5
|
-
// next turn — no reload.
|
|
6
|
-
import { getSharedComboState, reconcileSharedComboEntries, sessionEntries, } from '../shared/session-state.js';
|
|
1
|
+
// Root command plus mode switches; sibling mirrors sync with no reload.
|
|
2
|
+
import { reconcileSharedComboEntries, sessionEntries, } from '../shared/session-state.js';
|
|
3
|
+
import { announceStatus } from '../shared/status.js';
|
|
7
4
|
import { setExtensionLabel } from '../shared/host.js';
|
|
8
5
|
import os from 'node:os';
|
|
9
6
|
import path from 'node:path';
|
|
@@ -18,11 +15,6 @@ const HELP = [
|
|
|
18
15
|
'/tersio help — this table',
|
|
19
16
|
'mode switches: /caveman /rtk /combo (tersio) + /ponytail (upstream)',
|
|
20
17
|
].join('\n');
|
|
21
|
-
function statusLine() {
|
|
22
|
-
const s = getSharedComboState();
|
|
23
|
-
const level = s.level === 'custom' ? 'INACTIVE' : s.level.toUpperCase();
|
|
24
|
-
return `tersio — caveman=${s.caveman.toUpperCase()} · rtk=${s.rtk.toUpperCase()} · ponytail=${s.ponytail.toUpperCase()} (combo ${level})`;
|
|
25
|
-
}
|
|
26
18
|
function usageSummary() {
|
|
27
19
|
const rows = readUsage();
|
|
28
20
|
if (rows.length === 0)
|
|
@@ -59,7 +51,7 @@ export default function tersioCommandsExtension(pi) {
|
|
|
59
51
|
if (!sub || sub === 'status') {
|
|
60
52
|
if (ctx?.hasUI)
|
|
61
53
|
reconcileSharedComboEntries(sessionEntries(ctx));
|
|
62
|
-
ctx
|
|
54
|
+
announceStatus(ctx);
|
|
63
55
|
return;
|
|
64
56
|
}
|
|
65
57
|
if (sub === 'help') {
|
|
@@ -88,8 +80,7 @@ export default function tersioCommandsExtension(pi) {
|
|
|
88
80
|
ctx?.ui?.notify?.(usageSummary(), 'info');
|
|
89
81
|
return;
|
|
90
82
|
}
|
|
91
|
-
// Removed mode switches redirect to their own commands so only one
|
|
92
|
-
// spelling exists to learn: /caveman, /rtk, /combo, /ponytail.
|
|
83
|
+
// Removed mode switches redirect to their own commands so only one spelling exists to learn: /caveman, /rtk, /combo, /ponytail.
|
|
93
84
|
if (sub === 'caveman' || sub === 'rtk' || sub === 'combo' || sub === 'ponytail') {
|
|
94
85
|
ctx?.ui?.notify?.(`Use /${sub} instead — /tersio no longer duplicates mode switches.\n${HELP}`, 'warning');
|
|
95
86
|
return;
|
|
@@ -1,13 +1,10 @@
|
|
|
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
|
+
|
|
8
4
|
reconcileSharedComboEntries,
|
|
9
5
|
sessionEntries,
|
|
10
6
|
} from '../shared/session-state.ts';
|
|
7
|
+
import { announceStatus } from '../shared/status.ts';
|
|
11
8
|
import { setExtensionLabel } from '../shared/host.ts';
|
|
12
9
|
import os from 'node:os';
|
|
13
10
|
import path from 'node:path';
|
|
@@ -25,11 +22,6 @@ const HELP = [
|
|
|
25
22
|
'mode switches: /caveman /rtk /combo (tersio) + /ponytail (upstream)',
|
|
26
23
|
].join('\n');
|
|
27
24
|
|
|
28
|
-
function statusLine(): string {
|
|
29
|
-
const s = getSharedComboState();
|
|
30
|
-
const level = s.level === 'custom' ? 'INACTIVE' : s.level.toUpperCase();
|
|
31
|
-
return `tersio — caveman=${s.caveman.toUpperCase()} · rtk=${s.rtk.toUpperCase()} · ponytail=${s.ponytail.toUpperCase()} (combo ${level})`;
|
|
32
|
-
}
|
|
33
25
|
|
|
34
26
|
function usageSummary(): string {
|
|
35
27
|
const rows = readUsage();
|
|
@@ -67,7 +59,7 @@ export default function tersioCommandsExtension(pi: ExtensionApi): void {
|
|
|
67
59
|
|
|
68
60
|
if (!sub || sub === 'status') {
|
|
69
61
|
if (ctx?.hasUI) reconcileSharedComboEntries(sessionEntries(ctx));
|
|
70
|
-
ctx
|
|
62
|
+
announceStatus(ctx);
|
|
71
63
|
return;
|
|
72
64
|
}
|
|
73
65
|
if (sub === 'help') {
|
|
@@ -96,8 +88,7 @@ export default function tersioCommandsExtension(pi: ExtensionApi): void {
|
|
|
96
88
|
ctx?.ui?.notify?.(usageSummary(), 'info');
|
|
97
89
|
return;
|
|
98
90
|
}
|
|
99
|
-
// Removed mode switches redirect to their own commands so only one
|
|
100
|
-
// spelling exists to learn: /caveman, /rtk, /combo, /ponytail.
|
|
91
|
+
// Removed mode switches redirect to their own commands so only one spelling exists to learn: /caveman, /rtk, /combo, /ponytail.
|
|
101
92
|
if (sub === 'caveman' || sub === 'rtk' || sub === 'combo' || sub === 'ponytail') {
|
|
102
93
|
ctx?.ui?.notify?.(`Use /${sub} instead — /tersio no longer duplicates mode switches.\n${HELP}`, 'warning');
|
|
103
94
|
return;
|