@krtclcdy/tersio 2.25.5 → 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 +17 -36
- package/dashboard/dist/index.html +15 -15
- package/dist/cli/backups.js +7 -14
- package/dist/cli/banner.js +4 -3
- package/dist/cli/common.js +28 -12
- package/dist/cli/currency.js +1 -1
- package/dist/cli/dashboard.js +66 -35
- package/dist/cli/doctor-fix.js +204 -19
- package/dist/cli/doctor.js +226 -39
- package/dist/cli/hosts.js +25 -5
- package/dist/cli/install.js +144 -48
- package/dist/cli/interactive.js +3 -3
- package/dist/cli/manifest.js +87 -7
- package/dist/cli/profile.js +13 -8
- package/dist/cli/rtk-wiring.js +1 -1
- package/dist/cli/settings.js +20 -5
- package/dist/cli/uninstall.js +3 -3
- package/dist/cli/update.js +6 -7
- package/dist/cli/usage.js +6 -9
- package/dist/extensions/caveman/index.js +4 -2
- package/dist/extensions/caveman/rule-megacave.md +5 -3
- package/dist/extensions/caveman/rule-ultra.md +4 -4
- package/dist/extensions/caveman/rule.md +55 -45
- package/dist/extensions/combo/index.js +62 -21
- package/dist/extensions/commands/index.js +1 -1
- package/dist/extensions/pstack/index.js +119 -0
- package/dist/extensions/pstack/rule.md +65 -0
- package/dist/extensions/rtk/filter.js +0 -1
- package/dist/extensions/rtk/session.js +4 -4
- package/dist/extensions/server.js +90 -6
- package/dist/extensions/shared/agent-stats.js +214 -278
- package/dist/extensions/shared/brand-marks.js +33 -34
- package/dist/extensions/shared/carbon.js +1 -1
- package/dist/extensions/shared/plugin-settings.js +11 -6
- package/dist/extensions/shared/pricing.js +8 -16
- 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 +53 -41
- package/dist/extensions/shared/usage-store.js +111 -52
- package/dist/extensions/shared/utils.js +5 -5
- package/dist/extensions/timing/index.js +31 -0
- package/dist/extensions/updater/index.js +3 -6
- package/dist/tersio.js +8 -3
- package/extensions/caveman/index.ts +4 -2
- package/extensions/caveman/rule-megacave.md +5 -3
- package/extensions/caveman/rule-ultra.md +4 -4
- package/extensions/caveman/rule.md +55 -45
- package/extensions/combo/index.ts +62 -19
- package/extensions/commands/index.ts +1 -1
- package/extensions/pstack/index.ts +128 -0
- package/extensions/pstack/rule.md +65 -0
- package/extensions/rtk/filter.ts +0 -1
- package/extensions/rtk/session.ts +4 -4
- package/extensions/server.ts +72 -7
- package/extensions/shared/agent-stats.ts +226 -278
- package/extensions/shared/brand-marks.ts +34 -34
- package/extensions/shared/carbon.ts +1 -1
- package/extensions/shared/plugin-settings.ts +12 -6
- package/extensions/shared/pricing.ts +10 -26
- 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 +60 -47
- package/extensions/shared/usage-store.ts +120 -52
- package/extensions/shared/utils.ts +5 -5
- package/extensions/timing/index.ts +45 -0
- package/extensions/updater/index.ts +3 -8
- package/package.json +17 -8
|
@@ -1,11 +1,10 @@
|
|
|
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
10
|
classifyOpencodeMessage,
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
opencodeDbPath,
|
|
20
19
|
opencodeSessionsDir,
|
|
21
20
|
newSessionAccum,
|
|
21
|
+
opencodeToolNames,
|
|
22
22
|
readOpencodeDbRows,
|
|
23
23
|
sessionsDirs,
|
|
24
24
|
walkJsonl,
|
|
@@ -55,28 +55,25 @@ function nullStr(v: string | undefined): string {
|
|
|
55
55
|
return v === undefined ? 'NULL' : esc(v);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// A failed INSERT stops before COMMIT, so it leaves no DELETEs behind.
|
|
58
59
|
function run(db: string, sql: string): void {
|
|
59
|
-
|
|
60
|
-
execFileSync('sqlite3', ['-bail', db, sql], { stdio: ['ignore', 'ignore', 'ignore'], timeout: 30000 });
|
|
60
|
+
sqliteExec(db, sql);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function query(db: string, sql: string): string[][] {
|
|
64
|
-
|
|
65
|
-
encoding: 'utf8',
|
|
66
|
-
timeout: 30000,
|
|
67
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
68
|
-
maxBuffer: SQLITE_READ_BUFFER,
|
|
69
|
-
});
|
|
70
|
-
return out
|
|
64
|
+
return sqliteRead(db, sql)
|
|
71
65
|
.split('\n')
|
|
72
66
|
.filter((line) => line.trim())
|
|
73
67
|
.map((line) => line.split('\t'));
|
|
74
68
|
}
|
|
75
69
|
|
|
76
70
|
// Bump on a parse change: unchanged transcripts are never re-read.
|
|
77
|
-
const PARSER_VERSION = '
|
|
71
|
+
const PARSER_VERSION = '13';
|
|
78
72
|
|
|
79
|
-
|
|
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.
|
|
80
77
|
const REPARSE_GUARD_RATIO = 0.5;
|
|
81
78
|
|
|
82
79
|
let guardReport: string | null = null;
|
|
@@ -90,19 +87,15 @@ const SCHEMA_SQL =
|
|
|
90
87
|
`CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);` +
|
|
91
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);` +
|
|
92
89
|
`CREATE TABLE IF NOT EXISTS messages (file_id INTEGER NOT NULL REFERENCES files(id), t REAL, model TEXT NOT NULL,` +
|
|
93
|
-
` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
|
|
90
|
+
` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, tf REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
|
|
94
91
|
` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT, a TEXT, id TEXT);` +
|
|
95
|
-
// Every read filters or orders by one of these three
|
|
96
|
-
// walks the whole table: the watermark scan, the day buckets, and the recent list all scan.
|
|
92
|
+
// Every read filters or orders by one of these three; without them each sync and read scans the table.
|
|
97
93
|
`CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file_id);` +
|
|
98
94
|
`CREATE INDEX IF NOT EXISTS idx_messages_t ON messages(t);` +
|
|
99
95
|
`CREATE INDEX IF NOT EXISTS idx_messages_h_t ON messages(h, t);` +
|
|
100
96
|
`CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);`;
|
|
101
97
|
|
|
102
|
-
/**
|
|
103
|
-
* WAL plus a checkpoint at the end of a sync: the write path stops paying an fsync per commit,
|
|
104
|
-
* and the main file still holds every row, so a backup that copies only usage.db loses nothing.
|
|
105
|
-
*/
|
|
98
|
+
/** WAL + a checkpoint: the write path avoids an fsync per commit, and usage.db alone holds every row. */
|
|
106
99
|
function pragmas(db: string): void {
|
|
107
100
|
run(db,
|
|
108
101
|
'PRAGMA journal_mode=WAL;' +
|
|
@@ -122,7 +115,7 @@ function storedParserVersion(db: string): string | null {
|
|
|
122
115
|
}
|
|
123
116
|
}
|
|
124
117
|
|
|
125
|
-
// A store written by a newer tersio:
|
|
118
|
+
// A store written by a newer tersio: no forward migration, so a re-parse would swap rotated history for what is left.
|
|
126
119
|
function isDowngrade(stored: string | null): boolean {
|
|
127
120
|
if (stored === null) return false;
|
|
128
121
|
const storedNum = Number(stored);
|
|
@@ -150,6 +143,16 @@ function newestOpencodeMs(db: string): number | null {
|
|
|
150
143
|
}
|
|
151
144
|
}
|
|
152
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
|
+
|
|
153
156
|
// Same pre-migration state as the newest backup: another copy churns the 3-slot rotation for nothing.
|
|
154
157
|
function newestBackupMatches(stored: string | null): boolean {
|
|
155
158
|
if (stored === null) return false;
|
|
@@ -163,7 +166,7 @@ function newestBackupMatches(stored: string | null): boolean {
|
|
|
163
166
|
}
|
|
164
167
|
}
|
|
165
168
|
|
|
166
|
-
// 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.
|
|
167
170
|
function canMigrateInPlace(db: string): boolean {
|
|
168
171
|
try {
|
|
169
172
|
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
@@ -181,7 +184,7 @@ function migrateInPlace(db: string, preCount: number): boolean {
|
|
|
181
184
|
`CREATE TABLE files_new (id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT UNIQUE NOT NULL, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
|
|
182
185
|
`INSERT INTO files_new (path, mtime, size) SELECT path, mtime, size FROM files;` +
|
|
183
186
|
`CREATE TABLE messages_new (file_id INTEGER NOT NULL REFERENCES files_new(id), t REAL, model TEXT NOT NULL,` +
|
|
184
|
-
` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
|
|
187
|
+
` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, tf REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
|
|
185
188
|
` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT, a TEXT, id TEXT);` +
|
|
186
189
|
`INSERT INTO messages_new (file_id, ${cols}) SELECT (SELECT id FROM files_new WHERE files_new.path = messages.file), ${cols} FROM messages;` +
|
|
187
190
|
`DROP TABLE messages;DROP TABLE files;` +
|
|
@@ -199,9 +202,7 @@ function migrateInPlace(db: string, preCount: number): boolean {
|
|
|
199
202
|
}
|
|
200
203
|
}
|
|
201
204
|
|
|
202
|
-
// v12
|
|
203
|
-
// store already holds. No rows move and no re-parse is forced, so the existing rows read
|
|
204
|
-
// `NULL` for it: a report then shows them under the main agent only, and new rows carry it.
|
|
205
|
+
// v12 adds the agent kind with a bare ALTER; existing rows read NULL and report under the main agent.
|
|
205
206
|
function canAddAgent(db: string): boolean {
|
|
206
207
|
try {
|
|
207
208
|
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
@@ -211,8 +212,6 @@ function canAddAgent(db: string): boolean {
|
|
|
211
212
|
}
|
|
212
213
|
}
|
|
213
214
|
|
|
214
|
-
// v12 shape gains the agent kind (`main`, `subagent`) with a bare ALTER: no rows move, so no
|
|
215
|
-
// re-parse is forced. Existing rows read NULL, which a report folds under the main agent.
|
|
216
215
|
function addAgentColumn(db: string): boolean {
|
|
217
216
|
try {
|
|
218
217
|
run(db, `ALTER TABLE messages ADD COLUMN a TEXT;`);
|
|
@@ -222,6 +221,45 @@ function addAgentColumn(db: string): boolean {
|
|
|
222
221
|
return true;
|
|
223
222
|
}
|
|
224
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
|
+
|
|
225
263
|
// v10 shape gains the message id with a bare ALTER: no rows move, so no re-parse and no guard trip.
|
|
226
264
|
function canAlterInPlace(db: string): boolean {
|
|
227
265
|
try {
|
|
@@ -268,10 +306,14 @@ function ensureSchema(db: string, forceWipe: boolean): void {
|
|
|
268
306
|
run(db, `INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
269
307
|
return;
|
|
270
308
|
}
|
|
271
|
-
// The agent column is additive
|
|
272
|
-
// unchanged transcripts that already hold their rows simply read NULL until a re-parse.
|
|
309
|
+
// The agent column is additive: every row survives and unchanged transcripts read NULL until a re-parse.
|
|
273
310
|
if (stored !== null && canAddAgent(db) && addAgentColumn(db)) {
|
|
274
|
-
run(db, `INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
|
|
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}');`);
|
|
275
317
|
return;
|
|
276
318
|
}
|
|
277
319
|
run(db, `DROP TABLE IF EXISTS messages;DROP TABLE IF EXISTS files;` + SCHEMA_SQL +
|
|
@@ -381,13 +423,15 @@ function readFiles(db: string): Record<string, { mtime: number; size: number }>
|
|
|
381
423
|
return known;
|
|
382
424
|
}
|
|
383
425
|
|
|
384
|
-
// One parsed assistant message
|
|
426
|
+
// One parsed assistant message. `t` is null with no usable timestamp: still counted, but skipped in the recent list.
|
|
385
427
|
interface StoredRow {
|
|
386
428
|
t: number | null;
|
|
387
429
|
model: string;
|
|
388
430
|
i: number;
|
|
389
431
|
o: number;
|
|
390
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;
|
|
391
435
|
cr: number;
|
|
392
436
|
cw: number;
|
|
393
437
|
usd: number | undefined;
|
|
@@ -401,22 +445,27 @@ interface StoredRow {
|
|
|
401
445
|
id?: string;
|
|
402
446
|
}
|
|
403
447
|
|
|
404
|
-
// OpenCode rows carry
|
|
405
|
-
|
|
406
|
-
|
|
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 {
|
|
407
455
|
return {
|
|
408
456
|
t: oc.ms ?? null,
|
|
409
457
|
model: canonicalModelId(oc.model),
|
|
410
458
|
i: intOf(oc.usage.input),
|
|
411
459
|
o: intOf(oc.usage.output),
|
|
412
460
|
d: durOf(oc.durMs),
|
|
461
|
+
tf: durOf(oc.ttftMs),
|
|
413
462
|
cr: intOf(oc.usage.cacheRead),
|
|
414
463
|
cw: intOf(oc.usage.cacheWrite),
|
|
415
464
|
usd: costOf(oc.usage),
|
|
416
465
|
st: 'completed',
|
|
417
466
|
code: undefined,
|
|
418
467
|
note: undefined,
|
|
419
|
-
tools
|
|
468
|
+
tools,
|
|
420
469
|
host,
|
|
421
470
|
agent: 'main',
|
|
422
471
|
id,
|
|
@@ -427,11 +476,14 @@ function parseFile(text: string, host?: string, file?: string, agent?: string):
|
|
|
427
476
|
const rows: StoredRow[] = [];
|
|
428
477
|
// Guarded: a JSONL transcript must fall through, not abort the sync.
|
|
429
478
|
let oc: ReturnType<typeof classifyOpencodeMessage> = null;
|
|
430
|
-
try {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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 */ }
|
|
435
487
|
let codexProvider: string | null = null;
|
|
436
488
|
let codexModel: string | null = null;
|
|
437
489
|
for (const line of text.split('\n')) {
|
|
@@ -460,6 +512,7 @@ function parseFile(text: string, host?: string, file?: string, agent?: string):
|
|
|
460
512
|
i: intOf(usage.input),
|
|
461
513
|
o: intOf(usage.output),
|
|
462
514
|
d: undefined,
|
|
515
|
+
tf: undefined,
|
|
463
516
|
cr: intOf(usage.cacheRead),
|
|
464
517
|
cw: intOf(usage.cacheWrite),
|
|
465
518
|
usd: costOf(usage),
|
|
@@ -484,6 +537,7 @@ function parseFile(text: string, host?: string, file?: string, agent?: string):
|
|
|
484
537
|
i: intOf(usage.input),
|
|
485
538
|
o: intOf(usage.output),
|
|
486
539
|
d: dur,
|
|
540
|
+
tf: durOf(parsed.ttftMs),
|
|
487
541
|
cr: intOf(usage.cacheRead),
|
|
488
542
|
cw: intOf(usage.cacheWrite),
|
|
489
543
|
usd: costOf(usage),
|
|
@@ -501,13 +555,13 @@ function parseFile(text: string, host?: string, file?: string, agent?: string):
|
|
|
501
555
|
}
|
|
502
556
|
|
|
503
557
|
function insertSql(file: string, r: StoredRow): string {
|
|
504
|
-
return `INSERT INTO messages (file_id, t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, a, 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 (` +
|
|
505
559
|
`(SELECT id FROM files WHERE path=${esc(file)}),${r.t === null ? 'NULL' : String(r.t)},${esc(r.model)},${r.i},${r.o},` +
|
|
506
|
-
`${nullNum(r.d)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
|
|
560
|
+
`${nullNum(r.d)},${nullNum(r.tf)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
|
|
507
561
|
`${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))},${nullStr(r.host)},${nullStr(r.agent ?? undefined)},${nullStr(r.id)});`;
|
|
508
562
|
}
|
|
509
563
|
|
|
510
|
-
// 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.
|
|
511
565
|
export function syncUsageDb(): boolean {
|
|
512
566
|
let db: string;
|
|
513
567
|
guardReport = null;
|
|
@@ -524,6 +578,8 @@ export function syncUsageDb(): boolean {
|
|
|
524
578
|
if (downgrade) guardReport = `store was written by a newer tersio (parser ${stored} > ${PARSER_VERSION}); kept its rows and skipped the re-parse`;
|
|
525
579
|
if (migrated && !forced) preWipe = countMessages(db);
|
|
526
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);
|
|
527
583
|
maybeScheduledBackup(db);
|
|
528
584
|
} catch {
|
|
529
585
|
return false;
|
|
@@ -545,8 +601,10 @@ export function syncUsageDb(): boolean {
|
|
|
545
601
|
const ocDbPath = opencodeDbPath();
|
|
546
602
|
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DB !== undefined) {
|
|
547
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;
|
|
548
606
|
if (newest !== null || fs.existsSync(ocDbPath)) {
|
|
549
|
-
ocDbRows.push(...readOpencodeDbRows(ocDbPath,
|
|
607
|
+
ocDbRows.push(...readOpencodeDbRows(ocDbPath, since));
|
|
550
608
|
}
|
|
551
609
|
}
|
|
552
610
|
const known = readFiles(db);
|
|
@@ -564,6 +622,7 @@ export function syncUsageDb(): boolean {
|
|
|
564
622
|
const prev = known[file];
|
|
565
623
|
if (!prev || prev.mtime !== entry.mtime || prev.size !== entry.size) changed.push(file);
|
|
566
624
|
}
|
|
625
|
+
// `ocDbRows` is non-empty whenever the backfill window is open, so this never skips it.
|
|
567
626
|
if (!changed.length && !ocDbRows.length) return true;
|
|
568
627
|
const chunks: string[] = [];
|
|
569
628
|
let chunkBytes = 0;
|
|
@@ -609,13 +668,21 @@ export function syncUsageDb(): boolean {
|
|
|
609
668
|
`INSERT INTO files (path, mtime, size) VALUES (${esc(key)},${row.created},0)` +
|
|
610
669
|
` ON CONFLICT(path) DO UPDATE SET mtime=excluded.mtime;` +
|
|
611
670
|
`DELETE FROM messages WHERE file_id=(SELECT id FROM files WHERE path=${esc(key)});` +
|
|
612
|
-
insertSql(key, opencodeStoredRow(parsed, 'opencode', row.id)),
|
|
671
|
+
insertSql(key, opencodeStoredRow(parsed, 'opencode', row.id, opencodeToolNames(row.row, row.tools))),
|
|
613
672
|
)) return false;
|
|
614
673
|
}
|
|
615
674
|
} catch {
|
|
616
675
|
return false;
|
|
617
676
|
}
|
|
618
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
|
+
}
|
|
619
686
|
if (!flush()) return false;
|
|
620
687
|
if (preWipe > 0) {
|
|
621
688
|
const kept = countMessages(db);
|
|
@@ -645,17 +712,17 @@ export function readUsageDb(): StoredUsage | null {
|
|
|
645
712
|
}
|
|
646
713
|
let rows: string[][];
|
|
647
714
|
try {
|
|
648
|
-
// The `tools` column is
|
|
649
|
-
// read as one column and skipped in JS rather than parsed per row. A store written before
|
|
650
|
-
// the agent column does not hold it, so the row is read without that column.
|
|
715
|
+
// The wide `tools` column is read as one string and skipped in JS; a pre-agent store is read without it.
|
|
651
716
|
const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
|
|
652
717
|
const agentCol = names.includes('a') ? 'a' : "''";
|
|
653
|
-
|
|
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;`);
|
|
654
721
|
} catch {
|
|
655
722
|
return null;
|
|
656
723
|
}
|
|
657
724
|
const accum = newSessionAccum();
|
|
658
|
-
for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, a, id] of rows) {
|
|
725
|
+
for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, a, id, tf] of rows) {
|
|
659
726
|
const ts = t === '' ? undefined : Number(t);
|
|
660
727
|
let toolNames: string[] = [];
|
|
661
728
|
if (tools && tools !== '[]' && tools !== '') {
|
|
@@ -689,6 +756,7 @@ export function readUsageDb(): StoredUsage | null {
|
|
|
689
756
|
h === '' ? undefined : h,
|
|
690
757
|
id === '' || id === undefined ? undefined : id,
|
|
691
758
|
a === '' ? undefined : a,
|
|
759
|
+
tf === undefined || tf === '' ? undefined : Number(tf),
|
|
692
760
|
);
|
|
693
761
|
}
|
|
694
762
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
@@ -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';
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// /ai-addons manual updater. Node built-ins only. RTK ships SHA256 checksums
|
|
1
|
+
// /ai-addons manual updater. Node built-ins only. RTK ships unsigned SHA256 checksums, so checksum-only it is.
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
@@ -32,9 +32,7 @@ const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
32
32
|
|
|
33
33
|
const PONYTAIL_REMOTE = 'https://raw.githubusercontent.com/DietrichGebert/ponytail/main/package.json';
|
|
34
34
|
const RTK_BINARY = resolveRtkBinary();
|
|
35
|
-
// Beside this file
|
|
36
|
-
// agent-tree copy has no `caveman` sibling, so the OMP plugin install is the
|
|
37
|
-
// fallback: it is the one the plugin manifest loads from.
|
|
35
|
+
// Beside this file: a retired agent-tree copy has no `caveman` sibling, so fall back to the OMP plugin.
|
|
38
36
|
const CAVEMAN_SIBLING = (name: string): string => path.join(EXTENSION_DIR, '..', 'caveman', name);
|
|
39
37
|
|
|
40
38
|
function cavemanRule(name: string): string {
|
|
@@ -52,13 +50,11 @@ const CAVEMAN_TRACKED: ReadonlyArray<{ file: string; remote: string }> = [
|
|
|
52
50
|
// Host-neutral: this updater runs inside pi, OMP, and OpenCode alike.
|
|
53
51
|
const RELOAD_MSG = 'Reminder: restart your agent (or reload extensions) for updates to take effect.';
|
|
54
52
|
|
|
55
|
-
// Ponytail is
|
|
53
|
+
// Ponytail is hoisted, so the installed copy is found by walking up, not by naming a host directory.
|
|
56
54
|
function ponytailLocal(): string | null {
|
|
57
55
|
return findHoistedPackage('@dietrichgebert/ponytail', EXTENSION_DIR, 'package.json');
|
|
58
56
|
}
|
|
59
57
|
|
|
60
|
-
// --- Types ---
|
|
61
|
-
|
|
62
58
|
interface AddonUpdaterCtx {
|
|
63
59
|
cwd?: string;
|
|
64
60
|
ui?: {
|
|
@@ -196,7 +192,6 @@ async function checkAddons(ctx: AddonUpdaterCtx): Promise<string> {
|
|
|
196
192
|
return lines.join('\n');
|
|
197
193
|
}
|
|
198
194
|
|
|
199
|
-
// Ponytail is a hoisted dependency; replace its package dir from the npm tarball.
|
|
200
195
|
async function updatePonytail(pi: AddonUpdaterPi, ctx: AddonUpdaterCtx, dryRun = false): Promise<string> {
|
|
201
196
|
void pi;
|
|
202
197
|
const localPkg = ponytailLocal();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krtclcdy/tersio",
|
|
3
|
-
"version": "2.25.
|
|
3
|
+
"version": "2.25.6",
|
|
4
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",
|
|
@@ -28,17 +28,15 @@
|
|
|
28
28
|
},
|
|
29
29
|
"omp": {
|
|
30
30
|
"name": "Tersio",
|
|
31
|
-
"description": "Toggleable Caveman/RTK/Ponytail session modes with Combo presets",
|
|
31
|
+
"description": "Toggleable Caveman/RTK/Ponytail session modes with Combo presets, plus the opt-in pstack rigor mode",
|
|
32
32
|
"extensions": [
|
|
33
33
|
"./extensions/caveman/index.ts",
|
|
34
|
+
"./extensions/pstack/index.ts",
|
|
34
35
|
"./extensions/rtk/session.ts",
|
|
35
36
|
"./extensions/rtk/filter.ts",
|
|
36
37
|
"./extensions/combo/index.ts",
|
|
37
38
|
"./extensions/commands/index.ts"
|
|
38
39
|
],
|
|
39
|
-
"skills": [
|
|
40
|
-
"./node_modules/@dietrichgebert/ponytail/skills"
|
|
41
|
-
],
|
|
42
40
|
"features": {
|
|
43
41
|
"updater": {
|
|
44
42
|
"description": "In-session updater for Ponytail, RTK, and Caveman (/ai-addons)",
|
|
@@ -95,6 +93,15 @@
|
|
|
95
93
|
],
|
|
96
94
|
"default": "off"
|
|
97
95
|
},
|
|
96
|
+
"pstackDefault": {
|
|
97
|
+
"type": "enum",
|
|
98
|
+
"description": "Session-start default; opt-in, never set by a combo preset",
|
|
99
|
+
"values": [
|
|
100
|
+
"off",
|
|
101
|
+
"on"
|
|
102
|
+
],
|
|
103
|
+
"default": "off"
|
|
104
|
+
},
|
|
98
105
|
"currency": {
|
|
99
106
|
"type": "enum",
|
|
100
107
|
"description": "Display currency for usage/dashboard reports (--currency overrides per run)",
|
|
@@ -117,15 +124,17 @@
|
|
|
117
124
|
"files": [
|
|
118
125
|
"dist/**/*.js",
|
|
119
126
|
"dist/extensions/caveman/*.md",
|
|
127
|
+
"dist/extensions/pstack/*.md",
|
|
120
128
|
"dashboard/dist/**/*",
|
|
121
129
|
"extensions/**/*.ts",
|
|
122
130
|
"extensions/caveman/rule.md",
|
|
123
131
|
"extensions/caveman/rule-ultra.md",
|
|
124
|
-
"extensions/caveman/rule-megacave.md"
|
|
132
|
+
"extensions/caveman/rule-megacave.md",
|
|
133
|
+
"extensions/pstack/rule.md"
|
|
125
134
|
],
|
|
126
135
|
"scripts": {
|
|
127
136
|
"clean": "rm -rf dist dashboard/dist",
|
|
128
|
-
"build": "tsc && bun run build:dashboard && cp extensions/caveman/*.md dist/extensions/caveman/",
|
|
137
|
+
"build": "tsc && bun run build:dashboard && cp extensions/caveman/*.md dist/extensions/caveman/ && cp extensions/pstack/*.md dist/extensions/pstack/",
|
|
129
138
|
"build:dashboard": "bun install --cwd dashboard/app --frozen-lockfile && bun run --cwd dashboard/app build",
|
|
130
139
|
"prepublishOnly": "bun run build",
|
|
131
140
|
"check": "tsc --noEmit",
|
|
@@ -151,6 +160,6 @@
|
|
|
151
160
|
"dependencies": {
|
|
152
161
|
"@clack/core": "^1.5.1",
|
|
153
162
|
"@clack/prompts": "^1.8.0",
|
|
154
|
-
"@dietrichgebert/ponytail": "^
|
|
163
|
+
"@dietrichgebert/ponytail": "^5.2.0"
|
|
155
164
|
}
|
|
156
165
|
}
|