@krtclcdy/tersio 2.25.4 → 2.25.6
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 -63
- package/dashboard/dist/index.html +18 -16
- package/dist/cli/backups.js +187 -0
- package/dist/cli/banner.js +4 -3
- package/dist/cli/common.js +29 -13
- package/dist/cli/currency.js +1 -1
- package/dist/cli/dashboard.js +168 -110
- package/dist/cli/doctor-fix.js +232 -50
- package/dist/cli/doctor.js +281 -32
- package/dist/cli/hosts.js +29 -9
- package/dist/cli/install.js +204 -69
- package/dist/cli/interactive.js +3 -3
- package/dist/cli/manifest.js +125 -17
- package/dist/cli/profile.js +14 -9
- package/dist/cli/rtk-wiring.js +1 -1
- package/dist/cli/settings.js +20 -5
- package/dist/cli/uninstall.js +24 -8
- package/dist/cli/update.js +9 -10
- package/dist/cli/usage.js +75 -7
- package/dist/extensions/{caveman-session → caveman}/index.js +4 -2
- package/{extensions/caveman-session → dist/extensions/caveman}/rule-megacave.md +5 -3
- package/{extensions/caveman-session → dist/extensions/caveman}/rule-ultra.md +4 -4
- package/dist/extensions/caveman/rule.md +89 -0
- package/dist/extensions/{combo-toggle → combo}/index.js +62 -21
- package/dist/extensions/{tersio-commands → commands}/index.js +2 -2
- package/dist/extensions/pstack/index.js +119 -0
- package/dist/extensions/pstack/rule.md +65 -0
- package/dist/extensions/{rtk-filter/index.js → rtk/filter.js} +1 -2
- package/dist/extensions/{rtk-session/index.js → rtk/session.js} +5 -5
- package/dist/extensions/{opencode/server.js → server.js} +98 -14
- package/dist/extensions/shared/agent-stats.js +1198 -0
- package/dist/extensions/shared/brand-marks.js +153 -0
- package/dist/extensions/shared/carbon.js +1 -1
- package/dist/extensions/shared/plugin-settings.js +12 -7
- package/dist/extensions/shared/pricing.js +86 -30
- package/dist/extensions/shared/rtk-gain.js +4 -9
- package/dist/extensions/shared/session-state.js +15 -10
- package/dist/extensions/shared/sqlite.js +168 -0
- package/dist/extensions/shared/status.js +8 -8
- package/dist/extensions/shared/types.js +1 -1
- package/dist/extensions/shared/usage-ledger.js +113 -39
- package/dist/extensions/shared/usage-store.js +191 -48
- package/dist/extensions/{lib → shared}/utils.js +6 -6
- package/dist/extensions/timing/index.js +31 -0
- package/dist/extensions/{ai-addons-updater → updater}/index.js +13 -6
- package/dist/probe-catalog.js +48 -0
- package/dist/probe-catalog188.js +44 -0
- package/dist/tersio.js +9 -4
- package/extensions/{caveman-session → caveman}/index.ts +4 -2
- package/{dist/extensions/caveman-session → extensions/caveman}/rule-megacave.md +5 -3
- package/{dist/extensions/caveman-session → extensions/caveman}/rule-ultra.md +4 -4
- package/extensions/caveman/rule.md +89 -0
- package/extensions/{combo-toggle → combo}/index.ts +62 -19
- package/extensions/{tersio-commands → commands}/index.ts +2 -2
- package/extensions/pstack/index.ts +128 -0
- package/extensions/pstack/rule.md +65 -0
- package/extensions/{rtk-filter/index.ts → rtk/filter.ts} +1 -2
- package/extensions/{rtk-session/index.ts → rtk/session.ts} +5 -5
- package/extensions/{opencode/server.ts → server.ts} +81 -16
- package/extensions/shared/agent-stats.ts +1605 -0
- package/extensions/shared/brand-marks.ts +163 -0
- package/extensions/shared/carbon.ts +1 -1
- package/extensions/shared/plugin-settings.ts +13 -7
- package/extensions/shared/pricing.ts +95 -32
- package/extensions/shared/rtk-gain.ts +4 -9
- package/extensions/shared/session-state.ts +18 -13
- package/extensions/shared/sqlite.ts +168 -0
- package/extensions/shared/status.ts +9 -8
- package/extensions/shared/types.ts +2 -1
- package/extensions/shared/usage-ledger.ts +134 -46
- package/extensions/shared/usage-store.ts +202 -50
- package/extensions/{lib → shared}/utils.ts +6 -6
- package/extensions/timing/index.ts +45 -0
- package/extensions/{ai-addons-updater → updater}/index.ts +15 -9
- package/package.json +27 -18
- package/dist/extensions/caveman-session/rule.md +0 -79
- package/extensions/caveman-session/rule.md +0 -79
|
@@ -1,33 +1,32 @@
|
|
|
1
|
-
//
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
1
|
+
// usage-store.ts — tersio-owned usage.db. A cache of the live parse, not a fork; no SQLite → sync false / read null.
|
|
3
2
|
import fs from 'node:fs';
|
|
4
3
|
import os from 'node:os';
|
|
5
4
|
import path from 'node:path';
|
|
5
|
+
import { sqliteExec, sqliteRead } from './sqlite.ts';
|
|
6
6
|
import {
|
|
7
7
|
RECENT_LIMIT,
|
|
8
|
-
SQLITE_READ_BUFFER,
|
|
9
8
|
canonicalModelId,
|
|
10
9
|
classifySessionLine,
|
|
11
|
-
OpencodeMessage,
|
|
12
10
|
classifyOpencodeMessage,
|
|
13
11
|
costOf,
|
|
14
12
|
durOf,
|
|
13
|
+
agentOfSessionText,
|
|
15
14
|
hostOfSessionFile,
|
|
16
15
|
ingestSessionRow,
|
|
17
16
|
messageRowId,
|
|
18
|
-
OpencodeDbRow,
|
|
19
17
|
OPENCODE_DB_OVERLAP_MS,
|
|
20
18
|
opencodeDbPath,
|
|
21
19
|
opencodeSessionsDir,
|
|
22
20
|
newSessionAccum,
|
|
21
|
+
opencodeToolNames,
|
|
23
22
|
readOpencodeDbRows,
|
|
24
23
|
sessionsDirs,
|
|
25
24
|
walkJsonl,
|
|
26
25
|
} from './usage-ledger.ts';
|
|
27
|
-
import { tersioDataPath } from '
|
|
26
|
+
import { tersioDataPath } from './utils.ts';
|
|
28
27
|
import { MODEL_ALIASES } from './pricing.ts';
|
|
29
28
|
import { tersioSettingsFile } from './plugin-settings.ts';
|
|
30
|
-
import type { RunStatus, SessionTokens } from './usage-ledger.ts';
|
|
29
|
+
import type { OpencodeDbRow, OpencodeMessage, RunStatus, SessionTokens } from './usage-ledger.ts';
|
|
31
30
|
|
|
32
31
|
export function usageDbPath(): string {
|
|
33
32
|
const override = process.env.TERSIO_USAGE_DB;
|
|
@@ -56,28 +55,25 @@ function nullStr(v: string | undefined): string {
|
|
|
56
55
|
return v === undefined ? 'NULL' : esc(v);
|
|
57
56
|
}
|
|
58
57
|
|
|
58
|
+
// A failed INSERT stops before COMMIT, so it leaves no DELETEs behind.
|
|
59
59
|
function run(db: string, sql: string): void {
|
|
60
|
-
|
|
61
|
-
execFileSync('sqlite3', ['-bail', db, sql], { stdio: ['ignore', 'ignore', 'ignore'], timeout: 30000 });
|
|
60
|
+
sqliteExec(db, sql);
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
function query(db: string, sql: string): string[][] {
|
|
65
|
-
|
|
66
|
-
encoding: 'utf8',
|
|
67
|
-
timeout: 30000,
|
|
68
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
69
|
-
maxBuffer: SQLITE_READ_BUFFER,
|
|
70
|
-
});
|
|
71
|
-
return out
|
|
64
|
+
return sqliteRead(db, sql)
|
|
72
65
|
.split('\n')
|
|
73
66
|
.filter((line) => line.trim())
|
|
74
67
|
.map((line) => line.split('\t'));
|
|
75
68
|
}
|
|
76
69
|
|
|
77
70
|
// Bump on a parse change: unchanged transcripts are never re-read.
|
|
78
|
-
const PARSER_VERSION = '
|
|
71
|
+
const PARSER_VERSION = '13';
|
|
79
72
|
|
|
80
|
-
|
|
73
|
+
/** OpenCode rows gained tool names; re-read by id, since a PARSER_VERSION bump drops rotated history. */
|
|
74
|
+
const OPENCODE_TOOLS_VERSION = '1';
|
|
75
|
+
|
|
76
|
+
// A re-parse keeping under half the rows means transcripts vanished mid-migration; the backup is restored instead.
|
|
81
77
|
const REPARSE_GUARD_RATIO = 0.5;
|
|
82
78
|
|
|
83
79
|
let guardReport: string | null = null;
|
|
@@ -91,9 +87,24 @@ const SCHEMA_SQL =
|
|
|
91
87
|
`CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);` +
|
|
92
88
|
`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
89
|
`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
|
-
|
|
90
|
+
` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, tf REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
|
|
91
|
+
` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT, a TEXT, id TEXT);` +
|
|
92
|
+
// Every read filters or orders by one of these three; without them each sync and read scans the table.
|
|
93
|
+
`CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file_id);` +
|
|
94
|
+
`CREATE INDEX IF NOT EXISTS idx_messages_t ON messages(t);` +
|
|
95
|
+
`CREATE INDEX IF NOT EXISTS idx_messages_h_t ON messages(h, t);` +
|
|
96
|
+
`CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);`;
|
|
97
|
+
|
|
98
|
+
/** WAL + a checkpoint: the write path avoids an fsync per commit, and usage.db alone holds every row. */
|
|
99
|
+
function pragmas(db: string): void {
|
|
100
|
+
run(db,
|
|
101
|
+
'PRAGMA journal_mode=WAL;' +
|
|
102
|
+
'PRAGMA synchronous=NORMAL;' +
|
|
103
|
+
'PRAGMA wal_autocheckpoint=256;' +
|
|
104
|
+
'PRAGMA cache_size=-8000;' +
|
|
105
|
+
'PRAGMA temp_store=MEMORY;' +
|
|
106
|
+
'PRAGMA mmap_size=268435456;');
|
|
107
|
+
}
|
|
97
108
|
|
|
98
109
|
function storedParserVersion(db: string): string | null {
|
|
99
110
|
try {
|
|
@@ -104,6 +115,14 @@ function storedParserVersion(db: string): string | null {
|
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
117
|
|
|
118
|
+
// A store written by a newer tersio: no forward migration, so a re-parse would swap rotated history for what is left.
|
|
119
|
+
function isDowngrade(stored: string | null): boolean {
|
|
120
|
+
if (stored === null) return false;
|
|
121
|
+
const storedNum = Number(stored);
|
|
122
|
+
const currentNum = Number(PARSER_VERSION);
|
|
123
|
+
return Number.isFinite(storedNum) && Number.isFinite(currentNum) && storedNum > currentNum;
|
|
124
|
+
}
|
|
125
|
+
|
|
107
126
|
function countMessages(db: string): number {
|
|
108
127
|
try {
|
|
109
128
|
const rows = query(db, `SELECT COUNT(*) FROM messages;`);
|
|
@@ -124,6 +143,16 @@ function newestOpencodeMs(db: string): number | null {
|
|
|
124
143
|
}
|
|
125
144
|
}
|
|
126
145
|
|
|
146
|
+
/** Whether the OpenCode tools backfill ran; unfilled rows stay empty, so this widens the scan once. */
|
|
147
|
+
function opencodeToolsBackfilled(db: string): boolean {
|
|
148
|
+
try {
|
|
149
|
+
const rows = query(db, `SELECT v FROM meta WHERE k='opencode_tools';`);
|
|
150
|
+
return rows.length > 0 && rows[0][0] === OPENCODE_TOOLS_VERSION;
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
127
156
|
// Same pre-migration state as the newest backup: another copy churns the 3-slot rotation for nothing.
|
|
128
157
|
function newestBackupMatches(stored: string | null): boolean {
|
|
129
158
|
if (stored === null) return false;
|
|
@@ -137,7 +166,7 @@ function newestBackupMatches(stored: string | null): boolean {
|
|
|
137
166
|
}
|
|
138
167
|
}
|
|
139
168
|
|
|
140
|
-
// v9 shape (path-keyed rows) migrates in place with zero re-parse, so history survives even
|
|
169
|
+
// v9 shape (path-keyed rows) migrates in place with zero re-parse, so history survives even without transcripts.
|
|
141
170
|
function canMigrateInPlace(db: string): boolean {
|
|
142
171
|
try {
|
|
143
172
|
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
@@ -155,8 +184,8 @@ function migrateInPlace(db: string, preCount: number): boolean {
|
|
|
155
184
|
`CREATE TABLE files_new (id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT UNIQUE NOT NULL, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
|
|
156
185
|
`INSERT INTO files_new (path, mtime, size) SELECT path, mtime, size FROM files;` +
|
|
157
186
|
`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);` +
|
|
187
|
+
` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, tf REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
|
|
188
|
+
` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT, a TEXT, id TEXT);` +
|
|
160
189
|
`INSERT INTO messages_new (file_id, ${cols}) SELECT (SELECT id FROM files_new WHERE files_new.path = messages.file), ${cols} FROM messages;` +
|
|
161
190
|
`DROP TABLE messages;DROP TABLE files;` +
|
|
162
191
|
`ALTER TABLE files_new RENAME TO files;ALTER TABLE messages_new RENAME TO messages;` +
|
|
@@ -173,6 +202,64 @@ function migrateInPlace(db: string, preCount: number): boolean {
|
|
|
173
202
|
}
|
|
174
203
|
}
|
|
175
204
|
|
|
205
|
+
// v12 adds the agent kind with a bare ALTER; existing rows read NULL and report under the main agent.
|
|
206
|
+
function canAddAgent(db: string): boolean {
|
|
207
|
+
try {
|
|
208
|
+
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
209
|
+
return names.includes('file_id') && !names.includes('a');
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function addAgentColumn(db: string): boolean {
|
|
216
|
+
try {
|
|
217
|
+
run(db, `ALTER TABLE messages ADD COLUMN a TEXT;`);
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// v13 shape gains the first-token latency with a bare ALTER, so no row moves and no wipe runs.
|
|
225
|
+
function canAddTtft(db: string): boolean {
|
|
226
|
+
try {
|
|
227
|
+
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
228
|
+
return names.includes('file_id') && !names.includes('tf');
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The column is additive, and a rotated transcript cannot be re-parsed, so the ALTER precedes the backfill.
|
|
235
|
+
function addTtftColumn(db: string): boolean {
|
|
236
|
+
try {
|
|
237
|
+
run(db, `ALTER TABLE messages ADD COLUMN tf REAL;`);
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
return !canAddTtft(db);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** First-token-latency marker; unchanged transcripts are never revisited, so old stores stay null. */
|
|
245
|
+
const TTFT_VERSION = '1';
|
|
246
|
+
|
|
247
|
+
function ttftBackfilled(db: string): boolean {
|
|
248
|
+
try {
|
|
249
|
+
const rows = query(db, `SELECT v FROM meta WHERE k='ttft_backfill';`);
|
|
250
|
+
return rows.length > 0 && rows[0][0] === TTFT_VERSION;
|
|
251
|
+
} catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Clears ledger mtimes so every transcript re-reads; synthetic `db#row-id` OpenCode keys stay untouched. */
|
|
257
|
+
function markTranscriptsDirty(db: string): void {
|
|
258
|
+
try {
|
|
259
|
+
run(db, `UPDATE files SET mtime = 0 WHERE path NOT LIKE '%#%';`);
|
|
260
|
+
} catch { /* a store with no files table yet, or a read-only file: nothing to backfill */ }
|
|
261
|
+
}
|
|
262
|
+
|
|
176
263
|
// v10 shape gains the message id with a bare ALTER: no rows move, so no re-parse and no guard trip.
|
|
177
264
|
function canAlterInPlace(db: string): boolean {
|
|
178
265
|
try {
|
|
@@ -192,15 +279,21 @@ function alterInPlace(db: string): boolean {
|
|
|
192
279
|
return !canAlterInPlace(db);
|
|
193
280
|
}
|
|
194
281
|
|
|
195
|
-
function ensureSchema(db: string): void {
|
|
282
|
+
function ensureSchema(db: string, forceWipe: boolean): void {
|
|
196
283
|
fs.mkdirSync(path.dirname(db), { recursive: true });
|
|
197
284
|
// Fold in rows stored under a pre-alias spelling; the mtime ledger will not re-read those transcripts.
|
|
198
285
|
const rekeys = MODEL_ALIASES.map((a) => `UPDATE messages SET model='${a.id}' WHERE model='${a.feed}';`).join('');
|
|
199
286
|
const stored = storedParserVersion(db);
|
|
287
|
+
pragmas(db);
|
|
200
288
|
if (stored === PARSER_VERSION) {
|
|
201
289
|
run(db, SCHEMA_SQL + rekeys);
|
|
202
290
|
return;
|
|
203
291
|
}
|
|
292
|
+
// A newer store keeps its rows: only an explicit force re-parse may wipe it.
|
|
293
|
+
if (!forceWipe && isDowngrade(stored)) {
|
|
294
|
+
run(db, SCHEMA_SQL + rekeys);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
204
297
|
// A version bump rebuilds both tables, so no ALTER patchwork survives a migration.
|
|
205
298
|
try {
|
|
206
299
|
if (countMessages(db) > 0 && !newestBackupMatches(stored)) backupUsageDb(db);
|
|
@@ -213,6 +306,16 @@ function ensureSchema(db: string): void {
|
|
|
213
306
|
run(db, `INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
214
307
|
return;
|
|
215
308
|
}
|
|
309
|
+
// The agent column is additive: every row survives and unchanged transcripts read NULL until a re-parse.
|
|
310
|
+
if (stored !== null && canAddAgent(db) && addAgentColumn(db)) {
|
|
311
|
+
run(db, `INSERT OR REPLACE INTO meta (k, v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// The ttft column is additive too; its values come from the backfill in `syncUsageDb`.
|
|
315
|
+
if (stored !== null && canAddTtft(db) && addTtftColumn(db)) {
|
|
316
|
+
run(db, `INSERT OR REPLACE INTO meta (k, v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
216
319
|
run(db, `DROP TABLE IF EXISTS messages;DROP TABLE IF EXISTS files;` + SCHEMA_SQL +
|
|
217
320
|
`INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
218
321
|
} catch { /* fresh db, nothing to invalidate */ }
|
|
@@ -320,13 +423,15 @@ function readFiles(db: string): Record<string, { mtime: number; size: number }>
|
|
|
320
423
|
return known;
|
|
321
424
|
}
|
|
322
425
|
|
|
323
|
-
// One parsed assistant message
|
|
426
|
+
// One parsed assistant message. `t` is null with no usable timestamp: still counted, but skipped in the recent list.
|
|
324
427
|
interface StoredRow {
|
|
325
428
|
t: number | null;
|
|
326
429
|
model: string;
|
|
327
430
|
i: number;
|
|
328
431
|
o: number;
|
|
329
432
|
d: number | undefined;
|
|
433
|
+
/** First-token latency in ms. Only omp records it; the rest read null rather than a guess. */
|
|
434
|
+
tf: number | undefined;
|
|
330
435
|
cr: number;
|
|
331
436
|
cw: number;
|
|
332
437
|
usd: number | undefined;
|
|
@@ -335,38 +440,50 @@ interface StoredRow {
|
|
|
335
440
|
note: string | undefined;
|
|
336
441
|
tools: string[];
|
|
337
442
|
host?: string;
|
|
443
|
+
/** `main` or `subagent`; null on a row written before the store carried it. */
|
|
444
|
+
agent?: string | null;
|
|
338
445
|
id?: string;
|
|
339
446
|
}
|
|
340
447
|
|
|
341
|
-
// OpenCode rows carry
|
|
342
|
-
function opencodeStoredRow(
|
|
448
|
+
// OpenCode rows carry only tokens, msg id, and tools; they record as the main agent, so no NULL kind.
|
|
449
|
+
function opencodeStoredRow(
|
|
450
|
+
oc: NonNullable<ReturnType<typeof classifyOpencodeMessage>>,
|
|
451
|
+
host: string | undefined,
|
|
452
|
+
id: string | undefined,
|
|
453
|
+
tools: string[],
|
|
454
|
+
): StoredRow {
|
|
343
455
|
return {
|
|
344
456
|
t: oc.ms ?? null,
|
|
345
457
|
model: canonicalModelId(oc.model),
|
|
346
458
|
i: intOf(oc.usage.input),
|
|
347
459
|
o: intOf(oc.usage.output),
|
|
348
460
|
d: durOf(oc.durMs),
|
|
461
|
+
tf: durOf(oc.ttftMs),
|
|
349
462
|
cr: intOf(oc.usage.cacheRead),
|
|
350
463
|
cw: intOf(oc.usage.cacheWrite),
|
|
351
464
|
usd: costOf(oc.usage),
|
|
352
465
|
st: 'completed',
|
|
353
466
|
code: undefined,
|
|
354
467
|
note: undefined,
|
|
355
|
-
tools
|
|
468
|
+
tools,
|
|
356
469
|
host,
|
|
470
|
+
agent: 'main',
|
|
357
471
|
id,
|
|
358
472
|
};
|
|
359
473
|
}
|
|
360
474
|
|
|
361
|
-
function parseFile(text: string, host?: string, file?: string): StoredRow[] {
|
|
475
|
+
function parseFile(text: string, host?: string, file?: string, agent?: string): StoredRow[] {
|
|
362
476
|
const rows: StoredRow[] = [];
|
|
363
477
|
// Guarded: a JSONL transcript must fall through, not abort the sync.
|
|
364
478
|
let oc: ReturnType<typeof classifyOpencodeMessage> = null;
|
|
365
|
-
try {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
479
|
+
try {
|
|
480
|
+
const message = JSON.parse(text) as OpencodeMessage;
|
|
481
|
+
oc = classifyOpencodeMessage(message);
|
|
482
|
+
if (oc) {
|
|
483
|
+
rows.push(opencodeStoredRow(oc, host, file ? path.basename(file).replace(/\.[^.]+$/, '') : undefined, opencodeToolNames(message)));
|
|
484
|
+
return rows;
|
|
485
|
+
}
|
|
486
|
+
} catch { /* JSONL */ }
|
|
370
487
|
let codexProvider: string | null = null;
|
|
371
488
|
let codexModel: string | null = null;
|
|
372
489
|
for (const line of text.split('\n')) {
|
|
@@ -395,6 +512,7 @@ function parseFile(text: string, host?: string, file?: string): StoredRow[] {
|
|
|
395
512
|
i: intOf(usage.input),
|
|
396
513
|
o: intOf(usage.output),
|
|
397
514
|
d: undefined,
|
|
515
|
+
tf: undefined,
|
|
398
516
|
cr: intOf(usage.cacheRead),
|
|
399
517
|
cw: intOf(usage.cacheWrite),
|
|
400
518
|
usd: costOf(usage),
|
|
@@ -403,6 +521,7 @@ function parseFile(text: string, host?: string, file?: string): StoredRow[] {
|
|
|
403
521
|
note: undefined,
|
|
404
522
|
tools: [],
|
|
405
523
|
host,
|
|
524
|
+
agent,
|
|
406
525
|
id: rid,
|
|
407
526
|
});
|
|
408
527
|
continue;
|
|
@@ -418,6 +537,7 @@ function parseFile(text: string, host?: string, file?: string): StoredRow[] {
|
|
|
418
537
|
i: intOf(usage.input),
|
|
419
538
|
o: intOf(usage.output),
|
|
420
539
|
d: dur,
|
|
540
|
+
tf: durOf(parsed.ttftMs),
|
|
421
541
|
cr: intOf(usage.cacheRead),
|
|
422
542
|
cw: intOf(usage.cacheWrite),
|
|
423
543
|
usd: costOf(usage),
|
|
@@ -426,6 +546,7 @@ function parseFile(text: string, host?: string, file?: string): StoredRow[] {
|
|
|
426
546
|
note,
|
|
427
547
|
tools: parsed.tools ?? [],
|
|
428
548
|
host,
|
|
549
|
+
agent,
|
|
429
550
|
id: rid,
|
|
430
551
|
});
|
|
431
552
|
} catch { /* skip corrupt lines */ }
|
|
@@ -434,13 +555,13 @@ function parseFile(text: string, host?: string, file?: string): StoredRow[] {
|
|
|
434
555
|
}
|
|
435
556
|
|
|
436
557
|
function insertSql(file: string, r: StoredRow): string {
|
|
437
|
-
return `INSERT INTO messages (file_id, t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, id) VALUES (` +
|
|
558
|
+
return `INSERT INTO messages (file_id, t, model, i, o, d, tf, cr, cw, usd, st, code, note, tools, h, a, id) VALUES (` +
|
|
438
559
|
`(SELECT id FROM files WHERE path=${esc(file)}),${r.t === null ? 'NULL' : String(r.t)},${esc(r.model)},${r.i},${r.o},` +
|
|
439
|
-
`${nullNum(r.d)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
|
|
440
|
-
`${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))},${nullStr(r.host)},${nullStr(r.id)});`;
|
|
560
|
+
`${nullNum(r.d)},${nullNum(r.tf)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
|
|
561
|
+
`${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))},${nullStr(r.host)},${nullStr(r.agent ?? undefined)},${nullStr(r.id)});`;
|
|
441
562
|
}
|
|
442
563
|
|
|
443
|
-
// Unchanged transcripts are skipped via mtime+size; rows for deleted ones are kept so rotation never erases history.
|
|
564
|
+
// Unchanged transcripts are skipped via mtime+size; rows for deleted ones are kept so rotation never erases history.
|
|
444
565
|
export function syncUsageDb(): boolean {
|
|
445
566
|
let db: string;
|
|
446
567
|
guardReport = null;
|
|
@@ -449,11 +570,16 @@ export function syncUsageDb(): boolean {
|
|
|
449
570
|
try {
|
|
450
571
|
db = usageDbPath();
|
|
451
572
|
const stored = storedParserVersion(db);
|
|
452
|
-
migrated = stored !== null && stored !== PARSER_VERSION;
|
|
453
573
|
// The operator deleted sessions on purpose and wants the mirror to match: bypass the guard.
|
|
454
574
|
const forced = process.env.TERSIO_FORCE_REPARSE === '1';
|
|
575
|
+
// A downgrade keeps its rows: the wipe below cannot rebuild them from rotated transcripts.
|
|
576
|
+
const downgrade = !forced && isDowngrade(stored);
|
|
577
|
+
migrated = stored !== null && stored !== PARSER_VERSION && !downgrade;
|
|
578
|
+
if (downgrade) guardReport = `store was written by a newer tersio (parser ${stored} > ${PARSER_VERSION}); kept its rows and skipped the re-parse`;
|
|
455
579
|
if (migrated && !forced) preWipe = countMessages(db);
|
|
456
|
-
ensureSchema(db);
|
|
580
|
+
ensureSchema(db, forced);
|
|
581
|
+
// One rewrite so the additive column's NULL rows gain latency; runs while the marker is absent.
|
|
582
|
+
if (!downgrade && !ttftBackfilled(db)) markTranscriptsDirty(db);
|
|
457
583
|
maybeScheduledBackup(db);
|
|
458
584
|
} catch {
|
|
459
585
|
return false;
|
|
@@ -475,8 +601,10 @@ export function syncUsageDb(): boolean {
|
|
|
475
601
|
const ocDbPath = opencodeDbPath();
|
|
476
602
|
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DB !== undefined) {
|
|
477
603
|
const newest = newestOpencodeMs(db);
|
|
604
|
+
// A store predating the tool names re-reads the whole table once; the marker closes the window.
|
|
605
|
+
const since = newest === null || !opencodeToolsBackfilled(db) ? 0 : newest - OPENCODE_DB_OVERLAP_MS;
|
|
478
606
|
if (newest !== null || fs.existsSync(ocDbPath)) {
|
|
479
|
-
ocDbRows.push(...readOpencodeDbRows(ocDbPath,
|
|
607
|
+
ocDbRows.push(...readOpencodeDbRows(ocDbPath, since));
|
|
480
608
|
}
|
|
481
609
|
}
|
|
482
610
|
const known = readFiles(db);
|
|
@@ -494,6 +622,7 @@ export function syncUsageDb(): boolean {
|
|
|
494
622
|
const prev = known[file];
|
|
495
623
|
if (!prev || prev.mtime !== entry.mtime || prev.size !== entry.size) changed.push(file);
|
|
496
624
|
}
|
|
625
|
+
// `ocDbRows` is non-empty whenever the backfill window is open, so this never skips it.
|
|
497
626
|
if (!changed.length && !ocDbRows.length) return true;
|
|
498
627
|
const chunks: string[] = [];
|
|
499
628
|
let chunkBytes = 0;
|
|
@@ -526,7 +655,7 @@ export function syncUsageDb(): boolean {
|
|
|
526
655
|
try {
|
|
527
656
|
for (const file of changed) {
|
|
528
657
|
const text = fs.readFileSync(file, 'utf8');
|
|
529
|
-
for (const r of parseFile(text, hostOfSessionFile(file), file)) {
|
|
658
|
+
for (const r of parseFile(text, hostOfSessionFile(file), file, agentOfSessionText(text))) {
|
|
530
659
|
if (!push(insertSql(file, r))) return false;
|
|
531
660
|
}
|
|
532
661
|
}
|
|
@@ -539,13 +668,21 @@ export function syncUsageDb(): boolean {
|
|
|
539
668
|
`INSERT INTO files (path, mtime, size) VALUES (${esc(key)},${row.created},0)` +
|
|
540
669
|
` ON CONFLICT(path) DO UPDATE SET mtime=excluded.mtime;` +
|
|
541
670
|
`DELETE FROM messages WHERE file_id=(SELECT id FROM files WHERE path=${esc(key)});` +
|
|
542
|
-
insertSql(key, opencodeStoredRow(parsed, 'opencode', row.id)),
|
|
671
|
+
insertSql(key, opencodeStoredRow(parsed, 'opencode', row.id, opencodeToolNames(row.row, row.tools))),
|
|
543
672
|
)) return false;
|
|
544
673
|
}
|
|
545
674
|
} catch {
|
|
546
675
|
return false;
|
|
547
676
|
}
|
|
548
677
|
if (!push(`INSERT OR REPLACE INTO meta (k, v) VALUES ('last_sync',${esc(String(Date.now()))});`)) return false;
|
|
678
|
+
// Only once the rows are written, so a sync that fails midway widens the scan again.
|
|
679
|
+
if (ocDbRows.length > 0) {
|
|
680
|
+
if (!push(`INSERT OR REPLACE INTO meta (k, v) VALUES ('opencode_tools','${OPENCODE_TOOLS_VERSION}');`)) return false;
|
|
681
|
+
}
|
|
682
|
+
// Set only after the rewrite commits: a crash mid-backfill leaves mtimes cleared and the marker absent.
|
|
683
|
+
if (changed.length > 0) {
|
|
684
|
+
if (!push(`INSERT OR REPLACE INTO meta (k, v) VALUES ('ttft_backfill','${TTFT_VERSION}');`)) return false;
|
|
685
|
+
}
|
|
549
686
|
if (!flush()) return false;
|
|
550
687
|
if (preWipe > 0) {
|
|
551
688
|
const kept = countMessages(db);
|
|
@@ -561,6 +698,8 @@ export function syncUsageDb(): boolean {
|
|
|
561
698
|
if (migrated) {
|
|
562
699
|
try { run(db, `VACUUM;`); } catch { /* best-effort */ }
|
|
563
700
|
}
|
|
701
|
+
// Fold the WAL back into the main file: a backup copies one file, and a checkpoint keeps it whole.
|
|
702
|
+
try { run(db, `PRAGMA wal_checkpoint(TRUNCATE);`); } catch { /* best-effort */ }
|
|
564
703
|
return true;
|
|
565
704
|
}
|
|
566
705
|
|
|
@@ -573,18 +712,25 @@ export function readUsageDb(): StoredUsage | null {
|
|
|
573
712
|
}
|
|
574
713
|
let rows: string[][];
|
|
575
714
|
try {
|
|
576
|
-
|
|
715
|
+
// The wide `tools` column is read as one string and skipped in JS; a pre-agent store is read without it.
|
|
716
|
+
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
717
|
+
const agentCol = names.includes('a') ? 'a' : "''";
|
|
718
|
+
// A store predating first-token latency has no `tf` column; read NULL so the row still loads.
|
|
719
|
+
const tfCol = names.includes('tf') ? 'tf' : 'NULL';
|
|
720
|
+
rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, ${agentCol}, id, ${tfCol} FROM messages;`);
|
|
577
721
|
} catch {
|
|
578
722
|
return null;
|
|
579
723
|
}
|
|
580
724
|
const accum = newSessionAccum();
|
|
581
|
-
for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, id] of rows) {
|
|
725
|
+
for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, a, id, tf] of rows) {
|
|
582
726
|
const ts = t === '' ? undefined : Number(t);
|
|
583
727
|
let toolNames: string[] = [];
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
728
|
+
if (tools && tools !== '[]' && tools !== '') {
|
|
729
|
+
try {
|
|
730
|
+
const parsed: unknown = JSON.parse(tools);
|
|
731
|
+
if (Array.isArray(parsed)) toolNames = parsed.filter((x): x is string => typeof x === 'string');
|
|
732
|
+
} catch { /* keep empty */ }
|
|
733
|
+
}
|
|
588
734
|
const usage: Record<string, unknown> = {
|
|
589
735
|
input: Number(i) || 0,
|
|
590
736
|
output: Number(o) || 0,
|
|
@@ -609,6 +755,8 @@ export function readUsageDb(): StoredUsage | null {
|
|
|
609
755
|
toolNames,
|
|
610
756
|
h === '' ? undefined : h,
|
|
611
757
|
id === '' || id === undefined ? undefined : id,
|
|
758
|
+
a === '' ? undefined : a,
|
|
759
|
+
tf === undefined || tf === '' ? undefined : Number(tf),
|
|
612
760
|
);
|
|
613
761
|
}
|
|
614
762
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
@@ -627,6 +775,10 @@ export function readUsageDb(): StoredUsage | null {
|
|
|
627
775
|
byTool: accum.byTool,
|
|
628
776
|
byModelMessages: accum.byModelMessages,
|
|
629
777
|
byHost: accum.byHost,
|
|
778
|
+
byAgent: accum.byAgent,
|
|
779
|
+
byAgentMessages: accum.byAgentMessages,
|
|
780
|
+
byModelMeasured: accum.byModelMeasured,
|
|
781
|
+
byModelCostMessages: accum.byModelCostMessages,
|
|
630
782
|
costMeasured: accum.costMeasured,
|
|
631
783
|
recent: accum.recent.slice(0, RECENT_LIMIT),
|
|
632
784
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Shared helpers for the installer and
|
|
1
|
+
// Shared helpers for the installer and AI add-ons updater. Node built-ins only — must stay dependency-free.
|
|
2
2
|
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { accessSync, constants, createWriteStream, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
|
|
@@ -13,7 +13,7 @@ export interface HttpOptions {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
// Promise.withResolvers is Node 22+; the package supports Node 18+.
|
|
16
|
-
|
|
16
|
+
function withResolvers<T>(): PromiseWithResolvers<T> {
|
|
17
17
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
18
18
|
let reject!: (reason?: unknown) => void;
|
|
19
19
|
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
|
|
@@ -112,7 +112,7 @@ function redirectNext(res: { statusCode?: number; headers: { location?: string }
|
|
|
112
112
|
return null;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
// pi sets PI_CODING_AGENT; OMP does not.
|
|
115
|
+
// pi sets PI_CODING_AGENT; OMP does not. It reads PI_CODING_AGENT_DIR to relocate, so the override only counts there.
|
|
116
116
|
export function isPiProcess(): boolean {
|
|
117
117
|
return process.env.PI_CODING_AGENT === 'true';
|
|
118
118
|
}
|
|
@@ -122,7 +122,7 @@ export function piAgentDir(): string {
|
|
|
122
122
|
return isPiProcess() && override ? override : path.join(homeDir(), '.pi', 'agent');
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
// Ponytail is
|
|
125
|
+
// Ponytail is hoisted, so it sits in Tersio's node_modules, a host's plugin dir, or beside it; walking up covers all.
|
|
126
126
|
export function findHoistedPackage(pkg: string, fromDir: string, ...relInside: string[]): string | null {
|
|
127
127
|
let dir = fromDir;
|
|
128
128
|
for (;;) {
|
|
@@ -240,7 +240,7 @@ export async function readTextIfExists(p: string): Promise<string | null> {
|
|
|
240
240
|
try { return await fs.readFile(p, 'utf8'); } catch { return null; }
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
// Chunked
|
|
243
|
+
// Chunked: a 200 MiB binary never fits memory. `tail` keeps needle.length-1 bytes in case a match spans chunks.
|
|
244
244
|
export async function fileContains(filePath: string, needleText: string): Promise<boolean> {
|
|
245
245
|
const needle = Buffer.from(needleText, 'utf8');
|
|
246
246
|
if (needle.length === 0) return false;
|
|
@@ -267,7 +267,7 @@ export async function fileContains(filePath: string, needleText: string): Promis
|
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
// Tersio
|
|
270
|
+
// Tersio data home: ~/.tersio. First use moves legacy ~/.omp/plugins/tersio-* files over so installs keep history.
|
|
271
271
|
const migratedTersioFiles = new Set<string>();
|
|
272
272
|
|
|
273
273
|
export function tersioHome(): string {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// pi records a response's total duration but no first-token latency, so TTFT reads null there.
|
|
2
|
+
// omp writes its own `ttft` and OpenCode records `time.streamed`, so this fills the gap only for pi.
|
|
3
|
+
import type { ExtensionApi } from '../shared/types.ts';
|
|
4
|
+
|
|
5
|
+
interface AssistantMessage {
|
|
6
|
+
role?: unknown;
|
|
7
|
+
/** Unix ms when the request started, which pi sets before the stream opens. */
|
|
8
|
+
timestamp?: unknown;
|
|
9
|
+
ttft?: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface MessageEvent {
|
|
13
|
+
message?: AssistantMessage;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface UpdateEvent extends MessageEvent {
|
|
17
|
+
assistantMessageEvent?: { type?: unknown };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function timingExtension(pi: ExtensionApi): void {
|
|
21
|
+
// First token per request, keyed by the request's own start stamp, which survives every stream event.
|
|
22
|
+
const firstTokenAt = new Map<number, number>();
|
|
23
|
+
|
|
24
|
+
pi.on<UpdateEvent>('message_update', (event: UpdateEvent) => {
|
|
25
|
+
const message = event.message;
|
|
26
|
+
if (!message || message.role !== 'assistant') return;
|
|
27
|
+
const anchor = typeof message.timestamp === 'number' ? message.timestamp : 0;
|
|
28
|
+
if (anchor <= 0 || firstTokenAt.has(anchor)) return;
|
|
29
|
+
// `start` opens the stream with no token yet; the first delta is the first token.
|
|
30
|
+
if (!String(event.assistantMessageEvent?.type ?? '').endsWith('_delta')) return;
|
|
31
|
+
firstTokenAt.set(anchor, Date.now());
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
pi.on<MessageEvent>('message_end', (event: MessageEvent) => {
|
|
35
|
+
const message = event.message;
|
|
36
|
+
if (!message || message.role !== 'assistant') return;
|
|
37
|
+
const anchor = typeof message.timestamp === 'number' ? message.timestamp : 0;
|
|
38
|
+
const at = anchor > 0 ? firstTokenAt.get(anchor) : undefined;
|
|
39
|
+
if (anchor > 0) firstTokenAt.delete(anchor);
|
|
40
|
+
// A host that recorded its own latency wins; this only fills the gap.
|
|
41
|
+
if (at === undefined || message.ttft !== undefined) return;
|
|
42
|
+
const ttft = at - anchor;
|
|
43
|
+
if (ttft > 0) message.ttft = ttft;
|
|
44
|
+
});
|
|
45
|
+
}
|