@krtclcdy/tersio 2.25.3 → 2.25.5

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.
Files changed (61) hide show
  1. package/README.md +14 -31
  2. package/dashboard/dist/index.html +18 -16
  3. package/dist/cli/backups.js +194 -0
  4. package/dist/cli/common.js +2 -2
  5. package/dist/cli/dashboard.js +125 -98
  6. package/dist/cli/doctor-fix.js +42 -45
  7. package/dist/cli/doctor.js +70 -8
  8. package/dist/cli/hosts.js +4 -4
  9. package/dist/cli/install.js +106 -38
  10. package/dist/cli/manifest.js +45 -16
  11. package/dist/cli/profile.js +1 -1
  12. package/dist/cli/uninstall.js +21 -5
  13. package/dist/cli/update.js +3 -3
  14. package/dist/cli/usage.js +75 -4
  15. package/dist/extensions/{caveman-session → caveman}/index.js +10 -3
  16. package/dist/extensions/{combo-toggle → combo}/index.js +3 -3
  17. package/dist/extensions/{tersio-commands → commands}/index.js +1 -1
  18. package/dist/extensions/rtk/filter.js +87 -0
  19. package/dist/extensions/{rtk-session/index.js → rtk/session.js} +4 -3
  20. package/dist/extensions/{opencode/server.js → server.js} +52 -8
  21. package/dist/extensions/shared/agent-stats.js +1262 -0
  22. package/dist/extensions/shared/brand-marks.js +154 -0
  23. package/dist/extensions/shared/host.js +18 -12
  24. package/dist/extensions/shared/plugin-settings.js +1 -1
  25. package/dist/extensions/shared/pricing.js +91 -27
  26. package/dist/extensions/shared/session-state.js +3 -1
  27. package/dist/extensions/shared/usage-ledger.js +79 -17
  28. package/dist/extensions/shared/usage-store.js +106 -22
  29. package/dist/extensions/{lib → shared}/utils.js +1 -1
  30. package/dist/extensions/{ai-addons-updater → updater}/index.js +15 -5
  31. package/dist/probe-catalog.js +48 -0
  32. package/dist/probe-catalog188.js +44 -0
  33. package/dist/tersio.js +2 -2
  34. package/extensions/{caveman-session → caveman}/index.ts +10 -3
  35. package/extensions/{combo-toggle → combo}/index.ts +3 -3
  36. package/extensions/{tersio-commands → commands}/index.ts +1 -1
  37. package/extensions/rtk/filter.ts +90 -0
  38. package/extensions/{rtk-session/index.ts → rtk/session.ts} +4 -3
  39. package/extensions/{opencode/server.ts → server.ts} +46 -9
  40. package/extensions/shared/agent-stats.ts +1657 -0
  41. package/extensions/shared/brand-marks.ts +163 -0
  42. package/extensions/shared/host.ts +26 -13
  43. package/extensions/shared/plugin-settings.ts +1 -1
  44. package/extensions/shared/pricing.ts +107 -28
  45. package/extensions/shared/session-state.ts +3 -1
  46. package/extensions/shared/types.ts +10 -2
  47. package/extensions/shared/usage-ledger.ts +94 -19
  48. package/extensions/shared/usage-store.ts +106 -22
  49. package/extensions/{lib → shared}/utils.ts +1 -1
  50. package/extensions/{ai-addons-updater → updater}/index.ts +17 -7
  51. package/package.json +13 -12
  52. package/dist/cli/headroom-install.js +0 -266
  53. package/dist/cli/headroom.js +0 -492
  54. package/dist/extensions/headroom-session/index.js +0 -93
  55. package/dist/extensions/shared/headroom.js +0 -131
  56. /package/dist/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
  57. /package/dist/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
  58. /package/dist/extensions/{caveman-session → caveman}/rule.md +0 -0
  59. /package/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
  60. /package/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
  61. /package/extensions/{caveman-session → caveman}/rule.md +0 -0
@@ -2,8 +2,8 @@
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { RECENT_LIMIT, SQLITE_READ_BUFFER, canonicalModelId, classifySessionLine, classifyOpencodeMessage, costOf, durOf, hostOfSessionFile, ingestSessionRow, messageRowId, OPENCODE_DB_OVERLAP_MS, opencodeDbPath, opencodeSessionsDir, newSessionAccum, readOpencodeDbRows, sessionsDirs, walkJsonl, } from './usage-ledger.js';
6
- import { tersioDataPath } from '../lib/utils.js';
5
+ import { RECENT_LIMIT, SQLITE_READ_BUFFER, canonicalModelId, classifySessionLine, classifyOpencodeMessage, costOf, durOf, agentOfSessionText, hostOfSessionFile, ingestSessionRow, messageRowId, OPENCODE_DB_OVERLAP_MS, opencodeDbPath, opencodeSessionsDir, newSessionAccum, readOpencodeDbRows, sessionsDirs, walkJsonl, } from './usage-ledger.js';
6
+ import { tersioDataPath } from './utils.js';
7
7
  import { MODEL_ALIASES } from './pricing.js';
8
8
  import { tersioSettingsFile } from './plugin-settings.js';
9
9
  export function usageDbPath() {
@@ -41,7 +41,7 @@ function query(db, sql) {
41
41
  .map((line) => line.split('\t'));
42
42
  }
43
43
  // Bump on a parse change: unchanged transcripts are never re-read.
44
- const PARSER_VERSION = '11';
44
+ const PARSER_VERSION = '12';
45
45
  // A re-parse that keeps less than half the rows means transcripts vanished mid-migration; the backup is restored instead of publishing the loss.
46
46
  const REPARSE_GUARD_RATIO = 0.5;
47
47
  let guardReport = null;
@@ -53,8 +53,25 @@ const SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT
53
53
  `CREATE TABLE IF NOT EXISTS files (id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT UNIQUE NOT NULL, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
54
54
  `CREATE TABLE IF NOT EXISTS messages (file_id INTEGER NOT NULL REFERENCES files(id), t REAL, model TEXT NOT NULL,` +
55
55
  ` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
56
- ` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT, id TEXT);` +
57
- `CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file_id);`;
56
+ ` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT, a TEXT, id TEXT);` +
57
+ // Every read filters or orders by one of these three. Without them each sync and each read
58
+ // walks the whole table: the watermark scan, the day buckets, and the recent list all scan.
59
+ `CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file_id);` +
60
+ `CREATE INDEX IF NOT EXISTS idx_messages_t ON messages(t);` +
61
+ `CREATE INDEX IF NOT EXISTS idx_messages_h_t ON messages(h, t);` +
62
+ `CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);`;
63
+ /**
64
+ * WAL plus a checkpoint at the end of a sync: the write path stops paying an fsync per commit,
65
+ * and the main file still holds every row, so a backup that copies only usage.db loses nothing.
66
+ */
67
+ function pragmas(db) {
68
+ run(db, 'PRAGMA journal_mode=WAL;' +
69
+ 'PRAGMA synchronous=NORMAL;' +
70
+ 'PRAGMA wal_autocheckpoint=256;' +
71
+ 'PRAGMA cache_size=-8000;' +
72
+ 'PRAGMA temp_store=MEMORY;' +
73
+ 'PRAGMA mmap_size=268435456;');
74
+ }
58
75
  function storedParserVersion(db) {
59
76
  try {
60
77
  const stored = query(db, `SELECT v FROM meta WHERE k='parser_version';`);
@@ -64,6 +81,14 @@ function storedParserVersion(db) {
64
81
  return null;
65
82
  }
66
83
  }
84
+ // A store written by a newer tersio: this code has no forward migration, so a re-parse would swap rotated history for whatever transcripts are left.
85
+ function isDowngrade(stored) {
86
+ if (stored === null)
87
+ return false;
88
+ const storedNum = Number(stored);
89
+ const currentNum = Number(PARSER_VERSION);
90
+ return Number.isFinite(storedNum) && Number.isFinite(currentNum) && storedNum > currentNum;
91
+ }
67
92
  function countMessages(db) {
68
93
  try {
69
94
  const rows = query(db, `SELECT COUNT(*) FROM messages;`);
@@ -117,7 +142,7 @@ function migrateInPlace(db, preCount) {
117
142
  `INSERT INTO files_new (path, mtime, size) SELECT path, mtime, size FROM files;` +
118
143
  `CREATE TABLE messages_new (file_id INTEGER NOT NULL REFERENCES files_new(id), t REAL, model TEXT NOT NULL,` +
119
144
  ` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
120
- ` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT, id TEXT);` +
145
+ ` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]', h TEXT, a TEXT, id TEXT);` +
121
146
  `INSERT INTO messages_new (file_id, ${cols}) SELECT (SELECT id FROM files_new WHERE files_new.path = messages.file), ${cols} FROM messages;` +
122
147
  `DROP TABLE messages;DROP TABLE files;` +
123
148
  `ALTER TABLE files_new RENAME TO files;ALTER TABLE messages_new RENAME TO messages;` +
@@ -135,6 +160,29 @@ function migrateInPlace(db, preCount) {
135
160
  return false;
136
161
  }
137
162
  }
163
+ // v12 shape gains the agent kind (`main`, `subagent`) with a bare ALTER for the two names the
164
+ // store already holds. No rows move and no re-parse is forced, so the existing rows read
165
+ // `NULL` for it: a report then shows them under the main agent only, and new rows carry it.
166
+ function canAddAgent(db) {
167
+ try {
168
+ const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
169
+ return names.includes('file_id') && !names.includes('a');
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ }
175
+ // v12 shape gains the agent kind (`main`, `subagent`) with a bare ALTER: no rows move, so no
176
+ // re-parse is forced. Existing rows read NULL, which a report folds under the main agent.
177
+ function addAgentColumn(db) {
178
+ try {
179
+ run(db, `ALTER TABLE messages ADD COLUMN a TEXT;`);
180
+ }
181
+ catch {
182
+ return false;
183
+ }
184
+ return true;
185
+ }
138
186
  // v10 shape gains the message id with a bare ALTER: no rows move, so no re-parse and no guard trip.
139
187
  function canAlterInPlace(db) {
140
188
  try {
@@ -154,15 +202,21 @@ function alterInPlace(db) {
154
202
  }
155
203
  return !canAlterInPlace(db);
156
204
  }
157
- function ensureSchema(db) {
205
+ function ensureSchema(db, forceWipe) {
158
206
  fs.mkdirSync(path.dirname(db), { recursive: true });
159
207
  // Fold in rows stored under a pre-alias spelling; the mtime ledger will not re-read those transcripts.
160
208
  const rekeys = MODEL_ALIASES.map((a) => `UPDATE messages SET model='${a.id}' WHERE model='${a.feed}';`).join('');
161
209
  const stored = storedParserVersion(db);
210
+ pragmas(db);
162
211
  if (stored === PARSER_VERSION) {
163
212
  run(db, SCHEMA_SQL + rekeys);
164
213
  return;
165
214
  }
215
+ // A newer store keeps its rows: only an explicit force re-parse may wipe it.
216
+ if (!forceWipe && isDowngrade(stored)) {
217
+ run(db, SCHEMA_SQL + rekeys);
218
+ return;
219
+ }
166
220
  // A version bump rebuilds both tables, so no ALTER patchwork survives a migration.
167
221
  try {
168
222
  if (countMessages(db) > 0 && !newestBackupMatches(stored))
@@ -179,6 +233,12 @@ function ensureSchema(db) {
179
233
  run(db, `INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
180
234
  return;
181
235
  }
236
+ // The agent column is additive on the current shape: adding it keeps every row, and the
237
+ // unchanged transcripts that already hold their rows simply read NULL until a re-parse.
238
+ if (stored !== null && canAddAgent(db) && addAgentColumn(db)) {
239
+ run(db, `INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
240
+ return;
241
+ }
182
242
  run(db, `DROP TABLE IF EXISTS messages;DROP TABLE IF EXISTS files;` + SCHEMA_SQL +
183
243
  `INSERT OR REPLACE INTO meta (k,v) VALUES ('parser_version','${PARSER_VERSION}');`);
184
244
  }
@@ -287,7 +347,8 @@ function readFiles(db) {
287
347
  catch { /* fresh db */ }
288
348
  return known;
289
349
  }
290
- // OpenCode rows carry no status, code, note, or tool list; only tokens and the msg id.
350
+ // OpenCode rows carry no status, code, note, tool list, or subagent marker: only tokens and the
351
+ // msg id. They record as the main agent, so a store never holds a NULL kind.
291
352
  function opencodeStoredRow(oc, host, id) {
292
353
  return {
293
354
  t: oc.ms ?? null,
@@ -303,10 +364,11 @@ function opencodeStoredRow(oc, host, id) {
303
364
  note: undefined,
304
365
  tools: [],
305
366
  host,
367
+ agent: 'main',
306
368
  id,
307
369
  };
308
370
  }
309
- function parseFile(text, host, file) {
371
+ function parseFile(text, host, file, agent) {
310
372
  const rows = [];
311
373
  // Guarded: a JSONL transcript must fall through, not abort the sync.
312
374
  let oc = null;
@@ -357,6 +419,7 @@ function parseFile(text, host, file) {
357
419
  note: undefined,
358
420
  tools: [],
359
421
  host,
422
+ agent,
360
423
  id: rid,
361
424
  });
362
425
  continue;
@@ -381,6 +444,7 @@ function parseFile(text, host, file) {
381
444
  note,
382
445
  tools: parsed.tools ?? [],
383
446
  host,
447
+ agent,
384
448
  id: rid,
385
449
  });
386
450
  }
@@ -389,10 +453,10 @@ function parseFile(text, host, file) {
389
453
  return rows;
390
454
  }
391
455
  function insertSql(file, r) {
392
- return `INSERT INTO messages (file_id, t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, id) VALUES (` +
456
+ return `INSERT INTO messages (file_id, t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, a, id) VALUES (` +
393
457
  `(SELECT id FROM files WHERE path=${esc(file)}),${r.t === null ? 'NULL' : String(r.t)},${esc(r.model)},${r.i},${r.o},` +
394
458
  `${nullNum(r.d)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
395
- `${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))},${nullStr(r.host)},${nullStr(r.id)});`;
459
+ `${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))},${nullStr(r.host)},${nullStr(r.agent ?? undefined)},${nullStr(r.id)});`;
396
460
  }
397
461
  // Unchanged transcripts are skipped via mtime+size; rows for deleted ones are kept so rotation never erases history. False when sqlite3 is unavailable.
398
462
  export function syncUsageDb() {
@@ -403,12 +467,16 @@ export function syncUsageDb() {
403
467
  try {
404
468
  db = usageDbPath();
405
469
  const stored = storedParserVersion(db);
406
- migrated = stored !== null && stored !== PARSER_VERSION;
407
470
  // The operator deleted sessions on purpose and wants the mirror to match: bypass the guard.
408
471
  const forced = process.env.TERSIO_FORCE_REPARSE === '1';
472
+ // A downgrade keeps its rows: the wipe below cannot rebuild them from rotated transcripts.
473
+ const downgrade = !forced && isDowngrade(stored);
474
+ migrated = stored !== null && stored !== PARSER_VERSION && !downgrade;
475
+ if (downgrade)
476
+ guardReport = `store was written by a newer tersio (parser ${stored} > ${PARSER_VERSION}); kept its rows and skipped the re-parse`;
409
477
  if (migrated && !forced)
410
478
  preWipe = countMessages(db);
411
- ensureSchema(db);
479
+ ensureSchema(db, forced);
412
480
  maybeScheduledBackup(db);
413
481
  }
414
482
  catch {
@@ -489,7 +557,7 @@ export function syncUsageDb() {
489
557
  try {
490
558
  for (const file of changed) {
491
559
  const text = fs.readFileSync(file, 'utf8');
492
- for (const r of parseFile(text, hostOfSessionFile(file), file)) {
560
+ for (const r of parseFile(text, hostOfSessionFile(file), file, agentOfSessionText(text))) {
493
561
  if (!push(insertSql(file, r)))
494
562
  return false;
495
563
  }
@@ -531,6 +599,11 @@ export function syncUsageDb() {
531
599
  }
532
600
  catch { /* best-effort */ }
533
601
  }
602
+ // Fold the WAL back into the main file: a backup copies one file, and a checkpoint keeps it whole.
603
+ try {
604
+ run(db, `PRAGMA wal_checkpoint(TRUNCATE);`);
605
+ }
606
+ catch { /* best-effort */ }
534
607
  return true;
535
608
  }
536
609
  export function readUsageDb() {
@@ -544,21 +617,28 @@ export function readUsageDb() {
544
617
  }
545
618
  let rows;
546
619
  try {
547
- rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, id FROM messages;`);
620
+ // The `tools` column is the widest by far and the common case is the literal `[]`, so it is
621
+ // read as one column and skipped in JS rather than parsed per row. A store written before
622
+ // the agent column does not hold it, so the row is read without that column.
623
+ const names = query(db, `PRAGMA table_info(messages);`).map((r) => r[1]);
624
+ const agentCol = names.includes('a') ? 'a' : "''";
625
+ rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, ${agentCol}, id FROM messages;`);
548
626
  }
549
627
  catch {
550
628
  return null;
551
629
  }
552
630
  const accum = newSessionAccum();
553
- for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, id] of rows) {
631
+ for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools, h, a, id] of rows) {
554
632
  const ts = t === '' ? undefined : Number(t);
555
633
  let toolNames = [];
556
- try {
557
- const parsed = JSON.parse(tools || '[]');
558
- if (Array.isArray(parsed))
559
- toolNames = parsed.filter((x) => typeof x === 'string');
634
+ if (tools && tools !== '[]' && tools !== '') {
635
+ try {
636
+ const parsed = JSON.parse(tools);
637
+ if (Array.isArray(parsed))
638
+ toolNames = parsed.filter((x) => typeof x === 'string');
639
+ }
640
+ catch { /* keep empty */ }
560
641
  }
561
- catch { /* keep empty */ }
562
642
  const usage = {
563
643
  input: Number(i) || 0,
564
644
  output: Number(o) || 0,
@@ -574,7 +654,7 @@ export function readUsageDb() {
574
654
  st: st === 'error' || st === 'aborted' ? st : 'completed',
575
655
  code: code === '' ? undefined : Number(code),
576
656
  note: note === '' ? undefined : note,
577
- }, toolNames, h === '' ? undefined : h, id === '' || id === undefined ? undefined : id);
657
+ }, toolNames, h === '' ? undefined : h, id === '' || id === undefined ? undefined : id, a === '' ? undefined : a);
578
658
  }
579
659
  accum.recent.sort((a, b) => b.t - a.t);
580
660
  let syncedAt = 0;
@@ -594,6 +674,10 @@ export function readUsageDb() {
594
674
  byTool: accum.byTool,
595
675
  byModelMessages: accum.byModelMessages,
596
676
  byHost: accum.byHost,
677
+ byAgent: accum.byAgent,
678
+ byAgentMessages: accum.byAgentMessages,
679
+ byModelMeasured: accum.byModelMeasured,
680
+ byModelCostMessages: accum.byModelCostMessages,
597
681
  costMeasured: accum.costMeasured,
598
682
  recent: accum.recent.slice(0, RECENT_LIMIT),
599
683
  },
@@ -6,7 +6,7 @@ import https from 'node:https';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  // Promise.withResolvers is Node 22+; the package supports Node 18+.
9
- export function withResolvers() {
9
+ function withResolvers() {
10
10
  let resolve;
11
11
  let reject;
12
12
  const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
@@ -1,25 +1,35 @@
1
1
  // /ai-addons manual updater. Node built-ins only. RTK ships SHA256 checksums with no signature, so checksum-only is the best available.
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { createHash } from 'node:crypto';
4
+ import fsSync from 'node:fs';
4
5
  import fs from 'node:fs/promises';
5
6
  import os from 'node:os';
6
7
  import path from 'node:path';
7
8
  import { fileURLToPath } from 'node:url';
8
9
  import { setExtensionLabel } from '../shared/host.js';
9
- import { CAVEMAN_REMOTE_ULTRA, CAVEMAN_REMOTE_MEGACAVE, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, httpsDownload, isPiProcess, sha256Hex, parseChecksum, normalizeRtkVersion, readTextIfExists, rtkPlatformSpec, resolveRtkBinary, } from '../lib/utils.js';
10
+ import { CAVEMAN_REMOTE_ULTRA, CAVEMAN_REMOTE_MEGACAVE, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, httpsDownload, sha256Hex, parseChecksum, normalizeRtkVersion, readTextIfExists, rtkPlatformSpec, resolveRtkBinary, } from '../shared/utils.js';
10
11
  const IS_WINDOWS = process.platform === 'win32';
11
12
  const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
12
13
  const PONYTAIL_REMOTE = 'https://raw.githubusercontent.com/DietrichGebert/ponytail/main/package.json';
13
14
  const RTK_BINARY = resolveRtkBinary();
14
- // Beside this file, not a host path: these are the rules /caveman reads.
15
- const cavemanRule = (name) => path.join(EXTENSION_DIR, '..', 'caveman-session', name);
15
+ // Beside this file, not a host path: these are the rules /caveman reads. A retired
16
+ // agent-tree copy has no `caveman` sibling, so the OMP plugin install is the
17
+ // fallback: it is the one the plugin manifest loads from.
18
+ const CAVEMAN_SIBLING = (name) => path.join(EXTENSION_DIR, '..', 'caveman', name);
19
+ function cavemanRule(name) {
20
+ const beside = CAVEMAN_SIBLING(name);
21
+ if (fsSync.existsSync(beside))
22
+ return beside;
23
+ const plugin = path.join(os.homedir(), '.omp', 'plugins', 'node_modules', '@krtclcdy', 'tersio', 'extensions', 'caveman', name);
24
+ return fsSync.existsSync(plugin) ? plugin : beside;
25
+ }
16
26
  const CAVEMAN_LOCAL = cavemanRule('rule.md');
17
27
  const CAVEMAN_TRACKED = [
18
28
  { file: cavemanRule('rule-ultra.md'), remote: CAVEMAN_REMOTE_ULTRA },
19
29
  { file: cavemanRule('rule-megacave.md'), remote: CAVEMAN_REMOTE_MEGACAVE },
20
30
  ];
21
- const HOST_NAME = isPiProcess() ? 'Pi' : 'OMP';
22
- const RELOAD_MSG = `Reminder: restart ${HOST_NAME} (or reload extensions) for updates to take effect.`;
31
+ // Host-neutral: this updater runs inside pi, OMP, and OpenCode alike.
32
+ const RELOAD_MSG = 'Reminder: restart your agent (or reload extensions) for updates to take effect.';
23
33
  // Ponytail is a hoisted dependency, so the installed copy is found by walking up rather than by naming a host directory.
24
34
  function ponytailLocal() {
25
35
  return findHoistedPackage('@dietrichgebert/ponytail', EXTENSION_DIR, 'package.json');
@@ -0,0 +1,48 @@
1
+ // Throwaway: read the real rate cards out of the pi-catalog omp already ships.
2
+ import { readFileSync } from "node:fs";
3
+ const root = process.env.HOME + "/.bun/install/global/node_modules/@oh-my-pi/pi-catalog/src";
4
+ const catalog = JSON.parse(readFileSync(root + "/models.json", "utf8"));
5
+ const wanted = [
6
+ "space-bunny",
7
+ "muse-spark-1.3-contributor",
8
+ "glm-5.3-flash",
9
+ "qwen38-flash-next",
10
+ "muse-spark-1.2-contributor",
11
+ "qwen3.8-27b",
12
+ "m3",
13
+ "ox-alpha",
14
+ "deepseek-v4-flash",
15
+ "ling-3.0-flash-sante",
16
+ "qwen3.6-35b-a3b-fp8",
17
+ "mimo-v2.6-flash",
18
+ "deepseek-v4.1-flash",
19
+ "step-5-preview",
20
+ "ling-3.1-flash",
21
+ "longcat-2.5-preview",
22
+ ];
23
+ let providers = 0;
24
+ let rows = 0;
25
+ const hits = new Map();
26
+ for (const [provider, models] of Object.entries(catalog)) {
27
+ if (typeof models !== "object" || models === null)
28
+ continue;
29
+ providers += 1;
30
+ for (const [id, row] of Object.entries(models)) {
31
+ rows += 1;
32
+ const bare = id.toLowerCase().split("/").pop();
33
+ if (wanted.includes(bare)) {
34
+ const list = hits.get(bare) ?? [];
35
+ list.push({ provider, id, cost: row.cost, status: row.pricingStatus });
36
+ hits.set(bare, list);
37
+ }
38
+ }
39
+ }
40
+ console.log("providers:", providers, "model rows:", rows, "\n");
41
+ for (const w of wanted) {
42
+ const list = hits.get(w) ?? [];
43
+ console.log(w.padEnd(26) + (list.length === 0 ? " — NOT IN CATALOG —" : " " + list.length + " row(s)"));
44
+ for (const h of list.slice(0, 4)) {
45
+ const c = h.cost;
46
+ console.log(" " + (h.provider + "/" + h.id).padEnd(52) + " in=" + String(c?.input ?? "?") + " out=" + String(c?.output ?? "?") + " status=" + JSON.stringify(h.status));
47
+ }
48
+ }
@@ -0,0 +1,44 @@
1
+ // Throwaway: the current omp catalog's real rates for the fifteen models in the screenshot.
2
+ import { readFileSync } from "node:fs";
3
+ const root = process.env.HOME + "/.bun/install/global/node_modules/@oh-my-pi/pi-catalog/src";
4
+ const catalog = JSON.parse(readFileSync(root + "/models.json", "utf8"));
5
+ const wanted = [
6
+ "space-bunny",
7
+ "muse-spark-1.3-contributor",
8
+ "glm-5.3-flash",
9
+ "qwen38-flash-next",
10
+ "muse-spark-1.2-contributor",
11
+ "qwen3.8-27b",
12
+ "m3",
13
+ "ox-alpha",
14
+ "deepseek-v4-flash",
15
+ "ling-3.0-flash-sante",
16
+ "qwen3.6-35b-a3b-fp8",
17
+ "mimo-v2.6-flash",
18
+ "deepseek-v4.1-flash",
19
+ "step-5-preview",
20
+ "ling-3.1-flash",
21
+ "longcat-2.5-preview",
22
+ ];
23
+ const hits = new Map();
24
+ for (const [provider, models] of Object.entries(catalog)) {
25
+ if (!models || typeof models !== "object")
26
+ continue;
27
+ for (const [id, row] of Object.entries(models)) {
28
+ const bare = id.toLowerCase().split("/").pop();
29
+ if (!wanted.includes(bare))
30
+ continue;
31
+ const list = hits.get(bare) ?? [];
32
+ list.push({ key: provider + "/" + id, cost: row.cost, status: row.pricingStatus });
33
+ hits.set(bare, list);
34
+ }
35
+ }
36
+ for (const w of wanted) {
37
+ const list = hits.get(w) ?? [];
38
+ console.log("\n" + w.toUpperCase() + (list.length === 0 ? " -- NOT IN CATALOG --" : " -- " + list.length + " rows --"));
39
+ for (const h of list) {
40
+ const c = h.cost;
41
+ const zero = c && c.input === 0 && c.output === 0 && c.cacheRead === 0 && c.cacheWrite === 0;
42
+ console.log(" " + h.key.padEnd(50) + (zero ? "FREE ($0)" : "in=" + c?.input + " out=" + c?.output) + " status=" + JSON.stringify(h.status));
43
+ }
44
+ }
package/dist/tersio.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Install Tersio add-ons on any OMP device; see help for commands.
3
- import { PACKAGE_BIN, PACKAGE_VERSION, applyUpdate, commandArg, dashboard, dashboardExport, dashboardOpen, dashboardPort, doctor, reset, settings, showHelp, showVersion, uninstall, unknownCommand, update, usage, } from './cli/common.js';
3
+ import { PACKAGE_BIN, PACKAGE_VERSION, applyUpdate, commandArg, dashboard, dashboardExport, dashboardOpen, dashboardPort, doctor, reset, settings, showHelp, showVersion, uninstall, unknownCommand, update, usage, currency, } from './cli/common.js';
4
4
  import { closeRL } from './cli/interactive.js';
5
5
  import { runInstall } from './cli/install.js';
6
6
  import { runDoctor } from './cli/doctor.js';
@@ -79,7 +79,7 @@ async function main() {
79
79
  return;
80
80
  }
81
81
  if (dashboard) {
82
- await runDashboard({ port: dashboardPort, open: dashboardOpen, exportFile: dashboardExport });
82
+ await runDashboard({ port: dashboardPort, open: dashboardOpen, exportFile: dashboardExport, currency });
83
83
  closeRL();
84
84
  return;
85
85
  }
@@ -13,14 +13,21 @@ const RULE_FILES = { lite: 'rule.md', full: 'rule.md', ultra: 'rule-ultra.md', '
13
13
 
14
14
  const DEFAULT_MODE = 'off';
15
15
 
16
+ // A rule file is immutable for the process, so read each level once; reinstalling needs a restart.
17
+ const instructionCache = new Map<string, string>();
18
+
16
19
  function buildInstruction(mode: string): string {
20
+ const cached = instructionCache.get(mode);
21
+ if (cached) return cached;
17
22
  const name = RULE_FILES[mode as keyof typeof RULE_FILES] ?? RULE_FILES.full;
18
23
  // Fail loud: a wrong rule reads to the model as the real one.
19
24
  let body: string;
20
25
  try { body = readFileSync(join(CAVERN_DIR, name), 'utf8'); } catch {
21
- body = `Caveman ${mode} requested, but ${name} is missing. Run: tersio doctor --fix extensions`;
26
+ return `Caveman ${mode} requested, but ${name} is missing. Run: tersio doctor --fix extensions`;
22
27
  }
23
- return `Caveman ${mode} active for this session.\n${body}`;
28
+ const instruction = `Caveman ${mode} active for this session.\n${body}`;
29
+ instructionCache.set(mode, instruction);
30
+ return instruction;
24
31
  }
25
32
 
26
33
  function resolveMode(entries: SessionEntry[] | null | undefined, fallback: string = DEFAULT_MODE): string {
@@ -118,6 +125,6 @@ export default function cavemanSessionExtension(pi: ExtensionApi): void {
118
125
  // The previous level's block is replaced, not stacked on.
119
126
  const stale = lastInjected;
120
127
  lastInjected = instruction;
121
- return injectPromptText(pi, event, instruction, stale);
128
+ return injectPromptText(pi, event, { text: instruction, staleMarker: stale, sectionTag: 'caveman' });
122
129
  });
123
130
  }
@@ -27,7 +27,7 @@ import {
27
27
  readRtkDefault,
28
28
  saveComboSetup,
29
29
  } from '../shared/plugin-settings.ts';
30
- import { findHoistedPackage, isPiProcess } from '../lib/utils.ts';
30
+ import { findHoistedPackage, isPiProcess } from '../shared/utils.ts';
31
31
  import type { ComboState, ExtensionApi, ExtensionCtx, SystemPromptEvent } from '../shared/types.ts';
32
32
 
33
33
  const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -113,7 +113,7 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
113
113
  }
114
114
 
115
115
  pi.registerCommand?.('combo', {
116
- description: 'Toggle all 3 OMP add-ons at once. Usage: /combo <off|medium|balanced|max|status>',
116
+ description: 'Toggle all 3 Tersio modes at once. Usage: /combo <off|medium|balanced|max|status>',
117
117
  handler: async (args, ctx) => {
118
118
  listen(ctx);
119
119
  const arg = String(args || '').trim().toLowerCase();
@@ -197,7 +197,7 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
197
197
  // The previous level's block is replaced, not stacked on.
198
198
  const stale = lastInjected;
199
199
  lastInjected = instruction;
200
- return injectPromptText(pi, event, instruction, stale);
200
+ return injectPromptText(pi, event, { text: instruction, staleMarker: stale, sectionTag: 'ponytail' });
201
201
  });
202
202
 
203
203
  // Slash commands only; natural-language input caused accidental toggles and has no reload context.
@@ -9,7 +9,7 @@ import { setExtensionLabel } from '../shared/host.ts';
9
9
  import os from 'node:os';
10
10
  import path from 'node:path';
11
11
  import { appendUsage, readUsage } from '../shared/usage-ledger.ts';
12
- import { checkAddonsSummary, runAddonUpdate } from '../ai-addons-updater/index.ts';
12
+ import { checkAddonsSummary, runAddonUpdate } from '../updater/index.ts';
13
13
  import type { ExtensionApi, ExtensionCtx } from '../shared/types.ts';
14
14
 
15
15
  const HELP = [
@@ -0,0 +1,90 @@
1
+ // pi's built-in grep and glob never reach the bash hook; `read` is absent because rtk has no filter for it.
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { resolveRtkBinary } from '../shared/utils.ts';
6
+ import { readRtkDefault } from '../shared/plugin-settings.ts';
7
+ import { getSharedComboState, lastCustomValue, sessionEntries } from '../shared/session-state.ts';
8
+ import type { ExtensionApi, ExtensionCtx, SessionEntry, ToolResultEvent } from '../shared/types.ts';
9
+
10
+ /** Tool name to `rtk pipe --filter` name. Exported so the OpenCode host reuses one map. */
11
+ export const FILTER_BY_TOOL = new Map([
12
+ ['grep', 'grep'],
13
+ ['glob', 'find'],
14
+ ['find', 'find'],
15
+ ]);
16
+
17
+ /** Below this the rtk spawn costs more than the filter saves. */
18
+ export const MIN_FILTER_BYTES = 1200;
19
+ const FILTER_TIMEOUT_MS = 5000;
20
+
21
+ /** Absolute, so a minimal PATH in the host cannot break the spawn. */
22
+ const SHELL = '/bin/sh';
23
+
24
+ export type FilterExec = (
25
+ cmd: string,
26
+ args: string[],
27
+ opts?: { signal?: AbortSignal; cwd?: string; timeout?: number },
28
+ ) => Promise<{ stdout: string; code: number }>;
29
+
30
+ function modeOf(value: unknown): boolean | null {
31
+ if (value === true) return true;
32
+ if (value === false) return false;
33
+ return null;
34
+ }
35
+
36
+ /** True when rtk is on for the session: a persisted entry wins, then a live switch, then the configured default. */
37
+ export function rtkActive(entries: SessionEntry[] | null | undefined): boolean {
38
+ const persisted = lastCustomValue(entries, 'rtk-mode', (data) => modeOf(data?.enabled));
39
+ if (persisted !== null) return persisted;
40
+ return getSharedComboState().rtk === 'on' || readRtkDefault();
41
+ }
42
+
43
+ /** The text in a result's content parts, or null when the parts are not plain text. */
44
+ export function filterableText(parts: unknown): string | null {
45
+ if (!Array.isArray(parts) || parts.length === 0) return null;
46
+ // An image or a mixed result is not text to filter.
47
+ if (parts.some((part) => (part as { type?: unknown } | null)?.type !== 'text')) return null;
48
+ return parts.map((part) => (part as { text?: unknown } | null)?.text ?? '').join('\n');
49
+ }
50
+
51
+ /** Pipe text through one rtk filter; null on any failure, so the caller keeps its original. */
52
+ export async function filterThroughRtk(
53
+ exec: FilterExec,
54
+ request: { bin: string; filter: string; text: string; signal?: AbortSignal },
55
+ ): Promise<string | null> {
56
+ const { bin, filter, text, signal } = request;
57
+ let dir: string | undefined;
58
+ try {
59
+ dir = mkdtempSync(join(tmpdir(), 'tersio-rtk-'));
60
+ const file = join(dir, 'in.txt');
61
+ writeFileSync(file, text, 'utf8');
62
+ // `rtk pipe` reads stdin only and execFile has no stdin, so one redirect.
63
+ const result = await exec(SHELL, ['-c', `"${bin}" pipe -f ${filter} < "${file}"`], { signal, timeout: FILTER_TIMEOUT_MS });
64
+ if (result.code !== 0) return null;
65
+ const out = result.stdout;
66
+ if (!out.trim() || out.length >= text.length) return null;
67
+ return out;
68
+ } catch {
69
+ return null;
70
+ } finally {
71
+ if (dir) rmSync(dir, { recursive: true, force: true });
72
+ }
73
+ }
74
+
75
+ export default function rtkFilterExtension(pi: ExtensionApi): void {
76
+ const bin = resolveRtkBinary();
77
+ const exec = pi.exec;
78
+ if (!bin || !exec) return;
79
+ pi.on<ToolResultEvent>('tool_result', async (event, ctx: ExtensionCtx) => {
80
+ const filter = FILTER_BY_TOOL.get(String(event?.toolName ?? '').toLowerCase());
81
+ if (!filter || event?.isError) return;
82
+ const text = filterableText(event?.content);
83
+ if (text === null || text.length < MIN_FILTER_BYTES) return;
84
+ if (!rtkActive(sessionEntries(ctx))) return;
85
+ const filtered = await filterThroughRtk(exec, { bin, filter, text, signal: ctx?.signal });
86
+ if (!filtered) return;
87
+ // Built-in grep and glob declare no output schema, so no structuredContent is lost.
88
+ return { content: [{ type: 'text', text: filtered }] };
89
+ });
90
+ }
@@ -1,7 +1,7 @@
1
1
  import { getSharedComboState, hasModeState, isOmpSubagentPrompt, lastCustomValue, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode, systemPromptIncludes } from '../shared/session-state.ts';
2
2
  import { injectPromptText, onHostEvent, setExtensionLabel, stringArrayToolParams } from '../shared/host.ts';
3
3
  import { readRtkDefault } from '../shared/plugin-settings.ts';
4
- import { resolveRtkBinary } from '../lib/utils.ts';
4
+ import { resolveRtkBinary } from '../shared/utils.ts';
5
5
  import { announceStatus } from '../shared/status.ts';
6
6
  import type { ExtensionApi, ExtensionCtx, SessionEntry, SystemPromptEvent } from '../shared/types.ts';
7
7
 
@@ -23,7 +23,8 @@ function setRtkProcessEnabled(enabled: boolean): void {
23
23
  else process.env.RTK_DISABLED = '1';
24
24
  }
25
25
 
26
- const RTK_PROMPT = `RTK guidance active. RTK automatically rewrites eligible Bash calls through the installed rtk hook. Prefer rtk for noisy shell output, but use exact raw output for state changes, checksums, patches, and diagnostics that need full bytes.`;
26
+ // rtk's awareness contract: state the output contract, never tell the model to type `rtk`.
27
+ const RTK_PROMPT = `RTK guidance active. Bash output, and large grep and glob results, are condensed to save tokens: every signal kept, costly noise dropped. Treat a condensed result as complete. Re-run something unfiltered only when its result is empty, garbled, or contradicts its exit code.`;
27
28
 
28
29
  export default function rtkSessionExtension(pi: ExtensionApi): void {
29
30
  let enabled = DEFAULT_ENABLED;
@@ -134,6 +135,6 @@ export default function rtkSessionExtension(pi: ExtensionApi): void {
134
135
  if (!active) return;
135
136
  // Skip when already present, so a host that re-presents the prompt cannot stack it.
136
137
  if (systemPromptIncludes(event.systemPrompt, RTK_PROMPT)) return;
137
- return injectPromptText(pi, event, RTK_PROMPT);
138
+ return injectPromptText(pi, event, { text: RTK_PROMPT, sectionTag: 'rtk' });
138
139
  });
139
140
  }