@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,22 +1,26 @@
|
|
|
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 os from 'node:os';
|
|
6
5
|
import path from 'node:path';
|
|
7
6
|
import {
|
|
8
7
|
RECENT_LIMIT,
|
|
8
|
+
SQLITE_READ_BUFFER,
|
|
9
9
|
canonicalModelId,
|
|
10
10
|
classifySessionLine,
|
|
11
11
|
OpencodeMessage,
|
|
12
12
|
classifyOpencodeMessage,
|
|
13
|
-
codexSessionsDir,
|
|
14
13
|
costOf,
|
|
15
14
|
durOf,
|
|
16
15
|
hostOfSessionFile,
|
|
17
16
|
ingestSessionRow,
|
|
17
|
+
messageRowId,
|
|
18
|
+
OpencodeDbRow,
|
|
19
|
+
OPENCODE_DB_OVERLAP_MS,
|
|
20
|
+
opencodeDbPath,
|
|
18
21
|
opencodeSessionsDir,
|
|
19
22
|
newSessionAccum,
|
|
23
|
+
readOpencodeDbRows,
|
|
20
24
|
sessionsDirs,
|
|
21
25
|
walkJsonl,
|
|
22
26
|
} from './usage-ledger.ts';
|
|
@@ -53,7 +57,8 @@ function nullStr(v: string | undefined): string {
|
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
function run(db: string, sql: string): void {
|
|
56
|
-
|
|
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 });
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
function query(db: string, sql: string): string[][] {
|
|
@@ -61,6 +66,7 @@ function query(db: string, sql: string): string[][] {
|
|
|
61
66
|
encoding: 'utf8',
|
|
62
67
|
timeout: 30000,
|
|
63
68
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
69
|
+
maxBuffer: SQLITE_READ_BUFFER,
|
|
64
70
|
});
|
|
65
71
|
return out
|
|
66
72
|
.split('\n')
|
|
@@ -69,39 +75,146 @@ function query(db: string, sql: string): string[][] {
|
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
// Bump on a parse change: unchanged transcripts are never re-read.
|
|
72
|
-
const PARSER_VERSION = '
|
|
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
|
+
}
|
|
73
194
|
|
|
74
195
|
function ensureSchema(db: string): void {
|
|
75
196
|
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.
|
|
197
|
+
// Fold in rows stored under a pre-alias spelling; the mtime ledger will not re-read those transcripts.
|
|
78
198
|
const rekeys = MODEL_ALIASES.map((a) => `UPDATE messages SET model='${a.id}' WHERE model='${a.feed}';`).join('');
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
93
205
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
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;
|
|
96
211
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
backupUsageDb(db);
|
|
104
|
-
run(db, `DELETE FROM messages;DELETE FROM files;INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
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}');`);
|
|
105
218
|
} catch { /* fresh db, nothing to invalidate */ }
|
|
106
219
|
}
|
|
107
220
|
|
|
@@ -127,7 +240,7 @@ export function listUsageBackups(): Array<{ file: string; mtime: number; size: n
|
|
|
127
240
|
}
|
|
128
241
|
}
|
|
129
242
|
|
|
130
|
-
function backupUsageDb(db: string): void {
|
|
243
|
+
export function backupUsageDb(db: string): void {
|
|
131
244
|
try {
|
|
132
245
|
const dir = backupUsageDir();
|
|
133
246
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -207,8 +320,7 @@ function readFiles(db: string): Record<string, { mtime: number; size: number }>
|
|
|
207
320
|
return known;
|
|
208
321
|
}
|
|
209
322
|
|
|
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.
|
|
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.
|
|
212
324
|
interface StoredRow {
|
|
213
325
|
t: number | null;
|
|
214
326
|
model: string;
|
|
@@ -223,29 +335,36 @@ interface StoredRow {
|
|
|
223
335
|
note: string | undefined;
|
|
224
336
|
tools: string[];
|
|
225
337
|
host?: string;
|
|
338
|
+
id?: string;
|
|
226
339
|
}
|
|
227
340
|
|
|
228
|
-
|
|
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[] {
|
|
229
362
|
const rows: StoredRow[] = [];
|
|
230
363
|
// Guarded: a JSONL transcript must fall through, not abort the sync.
|
|
231
364
|
let oc: ReturnType<typeof classifyOpencodeMessage> = null;
|
|
232
365
|
try { oc = classifyOpencodeMessage(JSON.parse(text) as OpencodeMessage); } catch { /* JSONL */ }
|
|
233
366
|
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
|
-
});
|
|
367
|
+
rows.push(opencodeStoredRow(oc, host, file ? path.basename(file).replace(/\.[^.]+$/, '') : undefined));
|
|
249
368
|
return rows;
|
|
250
369
|
}
|
|
251
370
|
let codexProvider: string | null = null;
|
|
@@ -254,6 +373,7 @@ function parseFile(text: string, host?: string): StoredRow[] {
|
|
|
254
373
|
if (!line.trim()) continue;
|
|
255
374
|
try {
|
|
256
375
|
const row = JSON.parse(line) as Parameters<typeof classifySessionLine>[0];
|
|
376
|
+
const rid = messageRowId(row);
|
|
257
377
|
const parsed = classifySessionLine(row);
|
|
258
378
|
if (parsed.kind === 'codex_provider') {
|
|
259
379
|
if (parsed.provider) codexProvider = parsed.provider;
|
|
@@ -283,6 +403,7 @@ function parseFile(text: string, host?: string): StoredRow[] {
|
|
|
283
403
|
note: undefined,
|
|
284
404
|
tools: [],
|
|
285
405
|
host,
|
|
406
|
+
id: rid,
|
|
286
407
|
});
|
|
287
408
|
continue;
|
|
288
409
|
}
|
|
@@ -305,6 +426,7 @@ function parseFile(text: string, host?: string): StoredRow[] {
|
|
|
305
426
|
note,
|
|
306
427
|
tools: parsed.tools ?? [],
|
|
307
428
|
host,
|
|
429
|
+
id: rid,
|
|
308
430
|
});
|
|
309
431
|
} catch { /* skip corrupt lines */ }
|
|
310
432
|
}
|
|
@@ -312,18 +434,25 @@ function parseFile(text: string, host?: string): StoredRow[] {
|
|
|
312
434
|
}
|
|
313
435
|
|
|
314
436
|
function insertSql(file: string, r: StoredRow): string {
|
|
315
|
-
return `INSERT INTO messages (
|
|
316
|
-
|
|
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},` +
|
|
317
439
|
`${nullNum(r.d)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
|
|
318
|
-
`${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))},${nullStr(r.host)});`;
|
|
440
|
+
`${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))},${nullStr(r.host)},${nullStr(r.id)});`;
|
|
319
441
|
}
|
|
320
442
|
|
|
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.
|
|
443
|
+
// Unchanged transcripts are skipped via mtime+size; rows for deleted ones are kept so rotation never erases history. False when sqlite3 is unavailable.
|
|
323
444
|
export function syncUsageDb(): boolean {
|
|
324
445
|
let db: string;
|
|
446
|
+
guardReport = null;
|
|
447
|
+
let preWipe = -1;
|
|
448
|
+
let migrated = false;
|
|
325
449
|
try {
|
|
326
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);
|
|
327
456
|
ensureSchema(db);
|
|
328
457
|
maybeScheduledBackup(db);
|
|
329
458
|
} catch {
|
|
@@ -333,9 +462,6 @@ export function syncUsageDb(): boolean {
|
|
|
333
462
|
const ocFiles: string[] = [];
|
|
334
463
|
try {
|
|
335
464
|
for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
|
|
336
|
-
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
337
|
-
walkJsonl(codexSessionsDir(), files, 2000);
|
|
338
|
-
}
|
|
339
465
|
// OpenCode stores one JSON document per message, not JSONL.
|
|
340
466
|
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
|
|
341
467
|
walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
|
|
@@ -344,6 +470,15 @@ export function syncUsageDb(): boolean {
|
|
|
344
470
|
return false;
|
|
345
471
|
}
|
|
346
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
|
+
}
|
|
347
482
|
const known = readFiles(db);
|
|
348
483
|
const current: Record<string, { mtime: number; size: number }> = {};
|
|
349
484
|
const changed: string[] = [];
|
|
@@ -359,7 +494,7 @@ export function syncUsageDb(): boolean {
|
|
|
359
494
|
const prev = known[file];
|
|
360
495
|
if (!prev || prev.mtime !== entry.mtime || prev.size !== entry.size) changed.push(file);
|
|
361
496
|
}
|
|
362
|
-
if (!changed.length) return true;
|
|
497
|
+
if (!changed.length && !ocDbRows.length) return true;
|
|
363
498
|
const chunks: string[] = [];
|
|
364
499
|
let chunkBytes = 0;
|
|
365
500
|
const flush = (): boolean => {
|
|
@@ -376,26 +511,57 @@ export function syncUsageDb(): boolean {
|
|
|
376
511
|
const push = (sql: string): boolean => {
|
|
377
512
|
chunks.push(sql);
|
|
378
513
|
chunkBytes += sql.length;
|
|
379
|
-
|
|
514
|
+
// One argv string caps at 128KB on Linux (E2BIG); stay well under it.
|
|
515
|
+
if (chunkBytes < 100_000) return true;
|
|
380
516
|
return flush();
|
|
381
517
|
};
|
|
382
518
|
for (const file of changed) {
|
|
383
|
-
|
|
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;
|
|
384
525
|
}
|
|
385
526
|
try {
|
|
386
527
|
for (const file of changed) {
|
|
387
|
-
const entry = current[file];
|
|
388
528
|
const text = fs.readFileSync(file, 'utf8');
|
|
389
|
-
for (const r of parseFile(text, hostOfSessionFile(file))) {
|
|
529
|
+
for (const r of parseFile(text, hostOfSessionFile(file), file)) {
|
|
390
530
|
if (!push(insertSql(file, r))) return false;
|
|
391
531
|
}
|
|
392
|
-
|
|
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;
|
|
393
544
|
}
|
|
394
545
|
} catch {
|
|
395
546
|
return false;
|
|
396
547
|
}
|
|
397
548
|
if (!push(`INSERT OR REPLACE INTO meta (k, v) VALUES ('last_sync',${esc(String(Date.now()))});`)) return false;
|
|
398
|
-
|
|
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;
|
|
399
565
|
}
|
|
400
566
|
|
|
401
567
|
export function readUsageDb(): StoredUsage | null {
|
|
@@ -407,12 +573,12 @@ export function readUsageDb(): StoredUsage | null {
|
|
|
407
573
|
}
|
|
408
574
|
let rows: string[][];
|
|
409
575
|
try {
|
|
410
|
-
rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools, h FROM messages;`);
|
|
576
|
+
rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, id FROM messages;`);
|
|
411
577
|
} catch {
|
|
412
578
|
return null;
|
|
413
579
|
}
|
|
414
580
|
const accum = newSessionAccum();
|
|
415
|
-
for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools, h] of rows) {
|
|
581
|
+
for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, id] of rows) {
|
|
416
582
|
const ts = t === '' ? undefined : Number(t);
|
|
417
583
|
let toolNames: string[] = [];
|
|
418
584
|
try {
|
|
@@ -442,6 +608,7 @@ export function readUsageDb(): StoredUsage | null {
|
|
|
442
608
|
},
|
|
443
609
|
toolNames,
|
|
444
610
|
h === '' ? undefined : h,
|
|
611
|
+
id === '' || id === undefined ? undefined : id,
|
|
445
612
|
);
|
|
446
613
|
}
|
|
447
614
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
@@ -459,6 +626,7 @@ export function readUsageDb(): StoredUsage | null {
|
|
|
459
626
|
byDayModel: accum.byDayModel,
|
|
460
627
|
byTool: accum.byTool,
|
|
461
628
|
byModelMessages: accum.byModelMessages,
|
|
629
|
+
byHost: accum.byHost,
|
|
462
630
|
costMeasured: accum.costMeasured,
|
|
463
631
|
recent: accum.recent.slice(0, RECENT_LIMIT),
|
|
464
632
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Root command plus mode switches; sibling mirrors sync with no reload.
|
|
2
|
-
import {
|
|
2
|
+
import { reconcileSharedComboEntries, sessionEntries, } from '../shared/session-state.js';
|
|
3
|
+
import { announceStatus } from '../shared/status.js';
|
|
3
4
|
import { setExtensionLabel } from '../shared/host.js';
|
|
4
5
|
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
@@ -14,11 +15,6 @@ const HELP = [
|
|
|
14
15
|
'/tersio help — this table',
|
|
15
16
|
'mode switches: /caveman /rtk /combo (tersio) + /ponytail (upstream)',
|
|
16
17
|
].join('\n');
|
|
17
|
-
function statusLine() {
|
|
18
|
-
const s = getSharedComboState();
|
|
19
|
-
const level = s.level === 'custom' ? 'INACTIVE' : s.level.toUpperCase();
|
|
20
|
-
return `tersio — caveman=${s.caveman.toUpperCase()} · rtk=${s.rtk.toUpperCase()} · ponytail=${s.ponytail.toUpperCase()} (combo ${level})`;
|
|
21
|
-
}
|
|
22
18
|
function usageSummary() {
|
|
23
19
|
const rows = readUsage();
|
|
24
20
|
if (rows.length === 0)
|
|
@@ -55,7 +51,7 @@ export default function tersioCommandsExtension(pi) {
|
|
|
55
51
|
if (!sub || sub === 'status') {
|
|
56
52
|
if (ctx?.hasUI)
|
|
57
53
|
reconcileSharedComboEntries(sessionEntries(ctx));
|
|
58
|
-
ctx
|
|
54
|
+
announceStatus(ctx);
|
|
59
55
|
return;
|
|
60
56
|
}
|
|
61
57
|
if (sub === 'help') {
|
|
@@ -84,8 +80,7 @@ export default function tersioCommandsExtension(pi) {
|
|
|
84
80
|
ctx?.ui?.notify?.(usageSummary(), 'info');
|
|
85
81
|
return;
|
|
86
82
|
}
|
|
87
|
-
// Removed mode switches redirect to their own commands so only one
|
|
88
|
-
// 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.
|
|
89
84
|
if (sub === 'caveman' || sub === 'rtk' || sub === 'combo' || sub === 'ponytail') {
|
|
90
85
|
ctx?.ui?.notify?.(`Use /${sub} instead — /tersio no longer duplicates mode switches.\n${HELP}`, 'warning');
|
|
91
86
|
return;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Root command plus mode switches; sibling mirrors sync with no reload.
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
|
|
4
4
|
reconcileSharedComboEntries,
|
|
5
5
|
sessionEntries,
|
|
6
6
|
} from '../shared/session-state.ts';
|
|
7
|
+
import { announceStatus } from '../shared/status.ts';
|
|
7
8
|
import { setExtensionLabel } from '../shared/host.ts';
|
|
8
9
|
import os from 'node:os';
|
|
9
10
|
import path from 'node:path';
|
|
@@ -21,11 +22,6 @@ const HELP = [
|
|
|
21
22
|
'mode switches: /caveman /rtk /combo (tersio) + /ponytail (upstream)',
|
|
22
23
|
].join('\n');
|
|
23
24
|
|
|
24
|
-
function statusLine(): string {
|
|
25
|
-
const s = getSharedComboState();
|
|
26
|
-
const level = s.level === 'custom' ? 'INACTIVE' : s.level.toUpperCase();
|
|
27
|
-
return `tersio — caveman=${s.caveman.toUpperCase()} · rtk=${s.rtk.toUpperCase()} · ponytail=${s.ponytail.toUpperCase()} (combo ${level})`;
|
|
28
|
-
}
|
|
29
25
|
|
|
30
26
|
function usageSummary(): string {
|
|
31
27
|
const rows = readUsage();
|
|
@@ -63,7 +59,7 @@ export default function tersioCommandsExtension(pi: ExtensionApi): void {
|
|
|
63
59
|
|
|
64
60
|
if (!sub || sub === 'status') {
|
|
65
61
|
if (ctx?.hasUI) reconcileSharedComboEntries(sessionEntries(ctx));
|
|
66
|
-
ctx
|
|
62
|
+
announceStatus(ctx);
|
|
67
63
|
return;
|
|
68
64
|
}
|
|
69
65
|
if (sub === 'help') {
|
|
@@ -92,8 +88,7 @@ export default function tersioCommandsExtension(pi: ExtensionApi): void {
|
|
|
92
88
|
ctx?.ui?.notify?.(usageSummary(), 'info');
|
|
93
89
|
return;
|
|
94
90
|
}
|
|
95
|
-
// Removed mode switches redirect to their own commands so only one
|
|
96
|
-
// 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.
|
|
97
92
|
if (sub === 'caveman' || sub === 'rtk' || sub === 'combo' || sub === 'ponytail') {
|
|
98
93
|
ctx?.ui?.notify?.(`Use /${sub} instead — /tersio no longer duplicates mode switches.\n${HELP}`, 'warning');
|
|
99
94
|
return;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krtclcdy/tersio",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.25.1",
|
|
4
|
+
"description": "✂️ Token saver and usage tracker for coding agents: terse Caveman replies, compact RTK shell output, lean Ponytail code calls, and a gain dashboard — one-command combo presets over one shared extension tree.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"token-saving",
|
|
7
7
|
"ai-tools",
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
"bin": {
|
|
24
24
|
"tersio": "tersio.js"
|
|
25
25
|
},
|
|
26
|
+
"exports": {
|
|
27
|
+
"./server": "./extensions/opencode/server.js"
|
|
28
|
+
},
|
|
26
29
|
"omp": {
|
|
27
30
|
"name": "Tersio",
|
|
28
31
|
"description": "Toggleable Caveman/RTK/Ponytail session modes with Combo presets",
|
|
@@ -124,6 +127,7 @@
|
|
|
124
127
|
"build:dashboard": "bun install --cwd dashboard/app --frozen-lockfile && bun run --cwd dashboard/app build",
|
|
125
128
|
"prepublishOnly": "bun run build",
|
|
126
129
|
"check": "tsc --noEmit",
|
|
130
|
+
"verify": "bun install --frozen-lockfile && bun run build && bun run test",
|
|
127
131
|
"doctor": "node tersio.js --doctor",
|
|
128
132
|
"dry-run": "node tersio.js --dry-run --scope user --yes",
|
|
129
133
|
"lint": "oxlint cli extensions test tersio.ts",
|
|
@@ -135,12 +139,14 @@
|
|
|
135
139
|
"node": ">=20.12.0"
|
|
136
140
|
},
|
|
137
141
|
"devDependencies": {
|
|
142
|
+
"@opencode/plugin": "^2.0.22",
|
|
138
143
|
"@types/node": "^26.4.1",
|
|
139
144
|
"oxlint": "^1.85.0",
|
|
140
145
|
"typescript": "^7.0.2",
|
|
141
146
|
"vitest": "^5.0.1"
|
|
142
147
|
},
|
|
143
148
|
"dependencies": {
|
|
149
|
+
"@clack/core": "^1.5.1",
|
|
144
150
|
"@clack/prompts": "^1.8.0",
|
|
145
151
|
"@dietrichgebert/ponytail": "^4.10.0"
|
|
146
152
|
}
|
package/tersio.js
CHANGED
|
@@ -14,8 +14,8 @@ function printHelp() {
|
|
|
14
14
|
console.log(`Usage: ${PACKAGE_BIN} [command] [options]
|
|
15
15
|
|
|
16
16
|
Commands:
|
|
17
|
-
install Install the add-ons for one agent (--host omp|pi, or ask at a terminal)
|
|
18
|
-
update Refresh the CLI and add-ons (RTK binary, Caveman rule, Ponytail)
|
|
17
|
+
install Install the add-ons for one agent (--host omp|pi|opencode, or ask at a terminal)
|
|
18
|
+
update Refresh the CLI and add-ons (RTK binary, Caveman rule, Ponytail; --host limits to one agent)
|
|
19
19
|
doctor Check the current installation (--fix repairs, --dry-run previews)
|
|
20
20
|
usage Ledger-backed usage + savings report
|
|
21
21
|
dashboard Open the Dashboard (localhost only)
|
|
@@ -28,7 +28,7 @@ Commands:
|
|
|
28
28
|
Options:
|
|
29
29
|
--fix (doctor: repairs all; --fix=<scope> repairs one of extensions, registrations, rtk, ponytail, cli)
|
|
30
30
|
--scope user (legacy; accepted and ignored — user scope is the only scope)
|
|
31
|
-
--host omp|pi (install|uninstall: target agent; a terminal asks instead)
|
|
31
|
+
--host omp|pi|opencode (install|uninstall|update: target agent; a terminal asks instead)
|
|
32
32
|
--keep-ponytail (uninstall: keep the bundled Ponytail copy — removed by default)
|
|
33
33
|
--remove-rtk (uninstall: also remove the RTK binary; its OMP wiring always goes)
|
|
34
34
|
--combo-default off|medium|balanced|max (implies caveman, rtk, ponytail)
|
package/cli/agent-hosts.js
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
// The host registry: one entry per agent, carrying only what the CLI needs to find it, install it and undo it.
|
|
2
|
-
// How the shell rewrite reaches the model. Every host routes it through rtk's own module (`rtk init -g --agent <id>`), so...
|
|
3
|
-
export const REWRITE_WIRING = 'rtk extension · auto-rewrite';
|
|
4
|
-
const HOSTS = [
|
|
5
|
-
{
|
|
6
|
-
id: 'omp',
|
|
7
|
-
label: 'Oh My Pi (OMP)',
|
|
8
|
-
autoDetect: false,
|
|
9
|
-
configDir: '.omp',
|
|
10
|
-
binaries: ['omp'],
|
|
11
|
-
// Earlier versions wrote these. The tree injects the modes every turn, so a skill is a second copy and a rules file is...
|
|
12
|
-
retired: [
|
|
13
|
-
{ path: '.omp/agent/AGENTS.md', kind: 'merged' },
|
|
14
|
-
{ path: '.omp/agent/skills/tersio-caveman', kind: 'ours' },
|
|
15
|
-
{ path: '.omp/agent/skills/tersio-ponytail', kind: 'ours' },
|
|
16
|
-
{ path: '.omp/agent/skills/tersio-rtk', kind: 'ours' },
|
|
17
|
-
],
|
|
18
|
-
caveats: 'Reference host. The modes are injected by the extension tree in ~/.omp/agent/extensions, so no static copy is written and the rewrite is rtk\'s own module (rtk init -g --agent omp).',
|
|
19
|
-
source: 'https://omp.sh/docs/context-files',
|
|
20
|
-
nativeInstall: { command: 'omp install @krtclcdy/tersio', note: 'or let `tersio install` write the layer for you' },
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
id: 'pi',
|
|
24
|
-
label: 'Pi',
|
|
25
|
-
configDir: '.pi/agent',
|
|
26
|
-
configDirEnv: 'PI_CODING_AGENT_DIR',
|
|
27
|
-
binaries: ['pi'],
|
|
28
|
-
retired: [
|
|
29
|
-
{ path: '.pi/agent/AGENTS.md', kind: 'merged' },
|
|
30
|
-
{ path: '.pi/agent/skills/tersio-caveman', kind: 'ours' },
|
|
31
|
-
{ path: '.pi/agent/skills/tersio-ponytail', kind: 'ours' },
|
|
32
|
-
{ path: '.pi/agent/skills/tersio-rtk', kind: 'ours' },
|
|
33
|
-
],
|
|
34
|
-
caveats: 'AGENTS.override.md replaces rather than merges. Never write SYSTEM.md — it replaces the default system prompt outright. The rewrite is rtk\'s own module, written by `rtk init -g --agent pi`; rtk owns that format, so no tersio release is needed when it changes.',
|
|
35
|
-
source: 'https://pi.dev/docs/latest/extensions',
|
|
36
|
-
nativeInstall: { command: 'pi install npm:@krtclcdy/tersio', note: 'declares the `pi` manifest in package.json' },
|
|
37
|
-
},
|
|
38
|
-
];
|
|
39
|
-
function byId(id) {
|
|
40
|
-
return HOSTS.find((h) => h.id === id);
|
|
41
|
-
}
|
|
42
|
-
export { HOSTS, byId };
|