@krtclcdy/tersio 2.22.1 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +62 -46
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/common.js +55 -55
  5. package/cli/currency.js +8 -12
  6. package/cli/dashboard.js +76 -49
  7. package/cli/doctor-fix.js +61 -44
  8. package/cli/doctor.js +66 -44
  9. package/cli/hosts.js +118 -0
  10. package/cli/install.js +187 -143
  11. package/cli/interactive.js +27 -1
  12. package/cli/layer.js +24 -0
  13. package/cli/manifest.js +36 -0
  14. package/cli/omp-layer.js +51 -0
  15. package/cli/pi-layer.js +37 -0
  16. package/cli/pi-wiring.js +92 -0
  17. package/cli/profile.js +45 -46
  18. package/cli/reset.js +3 -26
  19. package/cli/rtk-wiring.js +10 -15
  20. package/cli/settings.js +1 -1
  21. package/cli/uninstall.js +135 -44
  22. package/cli/update.js +24 -30
  23. package/cli/usage.js +6 -1
  24. package/dashboard/dist/index.html +16 -16
  25. package/extensions/ai-addons-updater/index.js +36 -25
  26. package/extensions/ai-addons-updater/index.ts +36 -24
  27. package/extensions/caveman-session/index.js +23 -18
  28. package/extensions/caveman-session/index.ts +23 -18
  29. package/extensions/caveman-session/rule.md +90 -0
  30. package/extensions/combo-toggle/index.js +63 -41
  31. package/extensions/combo-toggle/index.ts +61 -48
  32. package/extensions/lib/utils.js +56 -13
  33. package/extensions/lib/utils.ts +55 -13
  34. package/extensions/rtk-session/index.js +20 -19
  35. package/extensions/rtk-session/index.ts +18 -24
  36. package/extensions/shared/carbon.js +5 -8
  37. package/extensions/shared/carbon.ts +5 -8
  38. package/extensions/shared/host.js +68 -0
  39. package/extensions/shared/host.ts +104 -0
  40. package/extensions/shared/omp-prompt.js +12 -0
  41. package/extensions/shared/plugin-settings.js +63 -16
  42. package/extensions/shared/plugin-settings.ts +67 -17
  43. package/extensions/shared/pricing.js +26 -15
  44. package/extensions/shared/pricing.ts +28 -16
  45. package/extensions/shared/rtk-gain.js +13 -24
  46. package/extensions/shared/rtk-gain.ts +14 -25
  47. package/extensions/shared/session-state.js +26 -28
  48. package/extensions/shared/session-state.ts +26 -29
  49. package/extensions/shared/types.ts +15 -3
  50. package/extensions/shared/usage-ledger.js +136 -38
  51. package/extensions/shared/usage-ledger.ts +149 -52
  52. package/extensions/shared/usage-store.js +14 -12
  53. package/extensions/shared/usage-store.ts +16 -14
  54. package/extensions/tersio-commands/index.js +2 -1
  55. package/extensions/tersio-commands/index.ts +2 -1
  56. package/package.json +15 -7
  57. package/tersio.js +6 -6
  58. package/extensions/shared/mode-reinforcement.js +0 -26
  59. package/extensions/shared/mode-reinforcement.ts +0 -29
@@ -1,11 +1,10 @@
1
- // Shared usage ledger: append-only JSON-lines file. One source of truth read
2
- // by `tersio usage`, `/tersio usage`, and the Dashboard.
3
- // Best-effort by design: a ledger failure never breaks the caller, and
4
- // corrupt lines are skipped on read (same pattern as the config normalizer).
1
+ // Shared usage ledger: append-only JSON lines, read by the CLI, `/tersio`, and
2
+ // the Dashboard. Best-effort; corrupt lines are skipped on read.
5
3
  import fs from 'node:fs';
6
4
  import os from 'node:os';
7
5
  import path from 'node:path';
8
- import { tersioDataPath } from '../lib/utils.ts';
6
+ import { isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from '../lib/utils.ts';
7
+ import { execFileSync } from 'node:child_process';
9
8
 
10
9
  export type UsageKind = 'command' | 'toggle' | 'install' | 'update' | 'rtk-audit';
11
10
 
@@ -21,11 +20,7 @@ export function ledgerPath(): string {
21
20
  return tersioDataPath('usage.jsonl', 'tersio-usage.jsonl');
22
21
  }
23
22
 
24
- // Reset watermark: tersio-owned timestamp marking the last statistics reset.
25
- // Session transcripts and the RTK database are host/tool-owned and never
26
- // touched — instead, every derived view (token stats, command tools) filters
27
- // rows from before the watermark, so "reset" empties what tersio shows
28
- // without deleting anything it does not own.
23
+ // Reset watermark: views filter rows older than it, so nothing else is deleted.
29
24
  export function resetMarkerPath(): string {
30
25
  const override = process.env.TERSIO_RESET_FILE;
31
26
  if (override) return override;
@@ -58,10 +53,7 @@ export function appendUsage(kind: UsageKind, detail: string): void {
58
53
  }
59
54
 
60
55
  // --- Session tokens, tokscale-style ------------------------------------------
61
- // OMP writes per-assistant-message usage into ~/.omp/agent/sessions/**/*.jsonl:
62
- // message.role === 'assistant', message.usage = { input, output, cacheRead,
63
- // cacheWrite }, message.model, plus a per-line timestamp. Same source tokscale
64
- // parses for its oh-my-pi client. Best-effort: unreadable files are skipped.
56
+ // Assistant messages in the host transcripts carry usage, model, and a timestamp.
65
57
  export interface TokenBreakdown {
66
58
  input: number;
67
59
  output: number;
@@ -69,10 +61,7 @@ export interface TokenBreakdown {
69
61
  cacheWrite: number;
70
62
  }
71
63
 
72
- // Run outcome for one assistant message. OMP writes stopReason on every turn
73
- // (toolUse | stop | aborted | error) and, when the request failed, an HTTP
74
- // errorStatus plus errorMessage. `toolUse` is an ordinary turn that handed off
75
- // to tools, so it reads as completed rather than as a distinct state.
64
+ // `toolUse` is an ordinary turn that handed off to tools, so it reads completed.
76
65
  export type RunStatus = 'completed' | 'aborted' | 'error';
77
66
 
78
67
  export interface RecentRequest {
@@ -83,9 +72,7 @@ export interface RecentRequest {
83
72
  d?: number;
84
73
  cr?: number;
85
74
  cw?: number;
86
- // Measured spend for this message, from the transcript's usage.cost.total.
87
- // Left undefined when the host recorded no cost — never backfilled with an
88
- // estimate, so a measured figure is never mistaken for a modeled one.
75
+ // Undefined when the host recorded no cost; never backfilled.
89
76
  usd?: number;
90
77
  st: RunStatus;
91
78
  code?: number;
@@ -104,6 +91,21 @@ export interface SessionTokens {
104
91
  recent: RecentRequest[];
105
92
  }
106
93
 
94
+ export interface RtkAdoption {
95
+ sessions: number;
96
+ bashCalls: number;
97
+ eligibleCalls: number;
98
+ rtkCalls: number;
99
+ missedCalls: number;
100
+ adoptionPct: number;
101
+ }
102
+
103
+ export interface RtkRecallDiagnostics {
104
+ mode: 'sqlite' | 'tee' | 'disabled' | 'unknown';
105
+ entries: number;
106
+ available: boolean;
107
+ }
108
+
107
109
  function zeroBreakdown(): TokenBreakdown {
108
110
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
109
111
  }
@@ -111,10 +113,8 @@ export function durOf(v: unknown): number | undefined {
111
113
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
112
114
  }
113
115
 
114
- // Measured cost for one message. OMP writes usage.cost as
115
- // { input, output, cacheRead, cacheWrite, total }; a bare number shows up in
116
- // older fixtures. Anything else means "not recorded" — deliberately not 0, so
117
- // the dashboard can tell a genuinely free run from an unrecorded one.
116
+ // Measured cost: usage.cost.total, or a bare number in old fixtures. Anything
117
+ // else means unrecorded, which is deliberately not 0.
118
118
  export function costOf(usage: Record<string, unknown>): number | undefined {
119
119
  const c = usage.cost;
120
120
  if (typeof c === 'number') return Number.isFinite(c) ? c : undefined;
@@ -150,7 +150,11 @@ function addInto(into: TokenBreakdown, u: { input?: unknown; output?: unknown; c
150
150
  export function sessionsDir(): string {
151
151
  const override = process.env.TERSIO_SESSIONS_DIR;
152
152
  if (override) return override;
153
- return path.join(os.homedir(), '.omp', 'agent', 'sessions');
153
+ // The running host wins; the other host's directory is the fallback.
154
+ const dirs = isPiProcess()
155
+ ? [path.join(piAgentDir(), 'sessions'), path.join(os.homedir(), '.omp', 'agent', 'sessions')]
156
+ : [path.join(os.homedir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
157
+ return dirs.find((dir) => fs.existsSync(dir)) ?? dirs[0];
154
158
  }
155
159
 
156
160
  export function codexSessionsDir(): string {
@@ -182,13 +186,17 @@ export function walkJsonl(dir: string, out: string[], cap: number): void {
182
186
  else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(full);
183
187
  }
184
188
  }
185
- // Recent requests are their own full-width table in the dashboard, so this is
186
- // a payload bound rather than a "few highlights" bound. Rows are small
187
- // (model, tokens, timing, status), so a few hundred cost little to ship.
189
+ // Recent requests get their own full-width table, so this bounds payload
190
+ // rather than highlights; rows are small, so a few hundred cost little.
188
191
  export const RECENT_LIMIT = 200;
189
192
  const FREE_SUFFIX = /(?::free|-free)$/i;
190
- // Session-parse buffer shared by live reads and usage.db syncs: identical
191
- // inputs produce identical aggregates, so the store is a cache, never a fork.
193
+ const RTK_ELIGIBLE_HEADS = new Set([
194
+ 'rtk', 'git', 'grep', 'rg', 'find', 'cat', 'ls', 'tree', 'diff', 'log',
195
+ 'npm', 'npx', 'pnpm', 'bun', 'bunx', 'cargo', 'go', 'python', 'pytest',
196
+ 'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
197
+ ]);
198
+ // Parse buffer shared by live reads and usage.db syncs, so the store is a
199
+ // cache and never a fork.
192
200
  export interface SessionAccum {
193
201
  byModel: Record<string, TokenBreakdown>;
194
202
  byDay: Record<string, TokenBreakdown>;
@@ -253,19 +261,22 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
253
261
  const computedDur = (typeof msg.completedAt === 'number' && typeof msg.timestamp === 'number') ? msg.completedAt - msg.timestamp : undefined;
254
262
  const tools: string[] = [];
255
263
  for (const part of msg.content ?? []) {
256
- if (part?.type === 'toolCall' && typeof part.name === 'string' && part.name) {
257
- tools.push(part.name === 'bash' ? `bash:${leadBinary((part.arguments as { command?: unknown } | null)?.command)}` : part.name);
264
+ if (part?.type !== 'toolCall' || typeof part.name !== 'string' || !part.name) continue;
265
+ if (part.name !== 'bash') {
266
+ tools.push(part.name);
267
+ continue;
258
268
  }
269
+ const command = (part.arguments as { command?: unknown } | null)?.command;
270
+ tools.push(`bash:${leadBinary(command)}`);
259
271
  }
260
272
  return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
261
273
  }
262
- // Fold `:free`/`-free` suffixes and case variants into one chart key, so the
263
- // same model from two providers stops splitting into separate rows.
274
+ // Fold `:free`/`-free` suffixes and case variants into one chart key.
264
275
  export function canonicalModelId(model: string): string {
265
- return model.replace(FREE_SUFFIX, '').toLowerCase();
276
+ return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
266
277
  }
267
- // LiteLLM-style display: namespace stays lowercase, model segments title-case
268
- // with version dots kept (`deepseek-v4.1-flash` → `Deepseek-V4.1-Flash`).
278
+ // LiteLLM-style display: lowercase namespace, title-cased model segments with
279
+ // version dots kept.
269
280
  export function displayModelId(model: string): string {
270
281
  const bare = model.replace(FREE_SUFFIX, '');
271
282
  const cap = (s: string): string => {
@@ -287,8 +298,8 @@ export function importSessionTokens(): SessionTokens {
287
298
  const accum = newSessionAccum();
288
299
  const files: string[] = [];
289
300
  walkJsonl(sessionsDir(), files, 2000);
290
- // A sessions override signals an isolated environment (tests, fixtures):
291
- // only walk the real codex dir when it is explicitly set.
301
+ // An override means an isolated environment (tests, fixtures): only walk the
302
+ // real codex dir when one is explicitly set.
292
303
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
293
304
  walkJsonl(codexSessionsDir(), files, 2000);
294
305
  }
@@ -305,6 +316,80 @@ export function importSessionTokens(): SessionTokens {
305
316
  accum.recent.sort((a, b) => b.t - a.t);
306
317
  return { messages: accum.messages, totals: accum.totals, byModel: accum.byModel, byDay: accum.byDay, byDayModel: accum.byDayModel, byTool: accum.byTool, byModelMessages: accum.byModelMessages, costMeasured: accum.costMeasured, recent: accum.recent.slice(0, RECENT_LIMIT) };
307
318
  }
319
+
320
+ const adoptionFileCache = new Map<string, { size: number; mtimeMs: number; counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number } }>();
321
+
322
+ function parseAdoptionFile(file: string): { counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number }; sessions: number } {
323
+ const counts = { bashCalls: 0, eligibleCalls: 0, rtkCalls: 0 };
324
+ let text: string;
325
+ try {
326
+ text = fs.readFileSync(file, 'utf8');
327
+ } catch {
328
+ return { counts, sessions: 0 };
329
+ }
330
+ let sawBash = false;
331
+ for (const line of text.split('\n')) {
332
+ if (!line.includes('tool_execution_start')) continue;
333
+ try {
334
+ const row = JSON.parse(line) as {
335
+ type?: string;
336
+ customType?: string;
337
+ data?: { toolName?: unknown; args?: { command?: unknown } };
338
+ };
339
+ if (row.type !== 'custom' || row.customType !== 'tool_execution_start' || row.data?.toolName !== 'bash') continue;
340
+ const command = row.data.args?.command;
341
+ if (typeof command !== 'string') continue;
342
+ sawBash = true;
343
+ counts.bashCalls += 1;
344
+ const head = leadBinary(command);
345
+ if (RTK_ELIGIBLE_HEADS.has(head)) counts.eligibleCalls += 1;
346
+ if (head === 'rtk') counts.rtkCalls += 1;
347
+ } catch { /* skip corrupt line */ }
348
+ }
349
+ return { counts, sessions: sawBash ? 1 : 0 };
350
+ }
351
+
352
+ export function clearRtkAdoptionCache(): void {
353
+ adoptionFileCache.clear();
354
+ }
355
+
356
+ export function readRtkAdoption(): RtkAdoption {
357
+ const files: string[] = [];
358
+ walkJsonl(sessionsDir(), files, 2000);
359
+ if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
360
+ walkJsonl(codexSessionsDir(), files, 2000);
361
+ }
362
+ const live = new Set(files);
363
+ for (const file of adoptionFileCache.keys()) {
364
+ if (!live.has(file)) adoptionFileCache.delete(file);
365
+ }
366
+ let bashCalls = 0;
367
+ let eligibleCalls = 0;
368
+ let rtkCalls = 0;
369
+ let sessions = 0;
370
+ for (const file of files) {
371
+ let stat: fs.Stats;
372
+ try {
373
+ stat = fs.statSync(file);
374
+ } catch {
375
+ continue;
376
+ }
377
+ let entry = adoptionFileCache.get(file);
378
+ if (!entry || entry.size !== stat.size || entry.mtimeMs !== stat.mtimeMs) {
379
+ const parsed = parseAdoptionFile(file);
380
+ entry = { size: stat.size, mtimeMs: stat.mtimeMs, counts: parsed.counts };
381
+ adoptionFileCache.set(file, entry);
382
+ sessions += parsed.sessions;
383
+ } else if (entry.counts.bashCalls > 0) {
384
+ sessions += 1;
385
+ }
386
+ bashCalls += entry.counts.bashCalls;
387
+ eligibleCalls += entry.counts.eligibleCalls;
388
+ rtkCalls += entry.counts.rtkCalls;
389
+ }
390
+ const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
391
+ return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
392
+ }
308
393
  export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null }, text: string): void {
309
394
  for (const line of text.split('\n')) {
310
395
  if (!line.trim()) continue;
@@ -325,8 +410,27 @@ export function processSessionText(accum: SessionAccum, state: { codexProvider:
325
410
  } catch { /* skip corrupt lines */ }
326
411
  }
327
412
  }
328
- // Lead binary of a shell string: first segment head past `cd` chains and
329
- // VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
413
+
414
+ export function readRtkRecallDiagnostics(binary: string | null = resolveRtkBinary()): RtkRecallDiagnostics {
415
+ if (!binary) return { mode: 'unknown', entries: 0, available: false };
416
+ try {
417
+ const configPath = process.env.RTK_CONFIG
418
+ || (process.platform === 'darwin'
419
+ ? path.join(os.homedir(), 'Library', 'Application Support', 'rtk', 'config.toml')
420
+ : path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'rtk', 'config.toml'));
421
+ let config = '';
422
+ try { config = fs.readFileSync(configPath, 'utf8'); } catch { /* use RTK CLI fallback */ }
423
+ const modeMatch = /^\s*mode\s*=\s*"(sqlite|tee|disabled)"/m.exec(config);
424
+ const mode = (modeMatch?.[1] ?? 'unknown') as RtkRecallDiagnostics['mode'];
425
+ const output = execFileSync(binary, ['recall', '--list'], { encoding: 'utf8', timeout: 5000, windowsHide: true });
426
+ const entries = /\((\d+)\s+(?:entries?|stored)\)/i.exec(output)?.[1] ?? /^\s*(\d+)\s+entries?/im.exec(output)?.[1] ?? '0';
427
+ return { mode, entries: Number(entries) || 0, available: true };
428
+ } catch {
429
+ return { mode: 'unknown', entries: 0, available: false };
430
+ }
431
+ }
432
+ // Lead binary of a shell string: first segment past `cd` chains and VAR=x
433
+ // assignments. Falls back to `bash`.
330
434
  const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
331
435
  export function leadBinary(command: unknown): string {
332
436
  if (typeof command !== 'string' || !command.trim()) return 'bash';
@@ -341,7 +445,7 @@ export function leadBinary(command: unknown): string {
341
445
  }
342
446
 
343
447
  // --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
344
- import { DEFAULT_PRICE, priceFor, refreshPricesIfStale } from './pricing.ts';
448
+ import { DEFAULT_PRICE, canonicalPriceId, priceFor, refreshPricesIfStale } from './pricing.ts';
345
449
  import { co2GramsFor, energyWhFor } from './carbon.ts';
346
450
  import type { ModelPrice } from './pricing.ts';
347
451
 
@@ -365,12 +469,6 @@ export function usdCost(t: TokenBreakdown, model?: string): { usd: number; price
365
469
  };
366
470
  }
367
471
 
368
- // CO2 now model-differentiated via ./carbon.ts (EcoLogits 0.8.2 port).
369
- // Always render with ~est. and never merge with measured figures.
370
- // The constant below is the served gCO2eq per 1K output tokens for the
371
- // default (gpt-4o-class) model, kept so single-figure callers stay honest.
372
- export const CO2_G_PER_1K_OUTPUT = 0.2;
373
-
374
472
  export function co2Grams(outputTokens: number, model?: string): number {
375
473
  return co2GramsFor(model ?? 'gpt-4o', outputTokens);
376
474
  }
@@ -395,9 +493,8 @@ export function readUsage(): UsageRow[] {
395
493
  return rows;
396
494
  }
397
495
 
398
- // Delete the ledger file. Returns the rows cleared (0 when already absent).
399
- // Only tersio-owned data lives here; session transcripts and the RTK
400
- // database are never touched.
496
+ // Delete the ledger file and return the rows cleared. Tersio-owned data only:
497
+ // transcripts and the RTK database are never touched.
401
498
  export function clearUsageLedger(): number {
402
499
  const rows = readUsage().length;
403
500
  try {
@@ -1,16 +1,15 @@
1
1
  // extensions/shared/usage-store.ts — tersio-owned usage.db.
2
- // Persists the same per-message rows importSessionTokens derives live (model,
3
- // tokens, cache, cost input, timing, status, tools), so top models, costing,
4
- // cache, CO2 inputs, and activity survive session-file rotation. Sync is
5
- // incremental: a files ledger (mtime+size) skips unchanged transcripts, and
6
- // every read applies the current reset watermark — the store is a cache,
7
- // never a fork. Missing sqlite3 CLI → sync false / read null, callers fall
8
- // back to the live path. Never touches RTK's history.db or host transcripts.
2
+ // Persists the rows importSessionTokens derives live, so reports survive
3
+ // session-file rotation. Sync is incremental (a files ledger skips unchanged
4
+ // transcripts) and every read applies the reset watermark, so the store is a
5
+ // cache, never a fork. Missing sqlite3 → sync false / read null. Never touches
6
+ // RTK's history.db or host transcripts.
9
7
  import { execFileSync } from 'node:child_process';
10
8
  import fs from 'node:fs';
11
9
  import path from 'node:path';
12
10
  import { RECENT_LIMIT, canonicalModelId, classifySessionLine, codexSessionsDir, costOf, durOf, ingestSessionRow, newSessionAccum, sessionsDir, walkJsonl, } from './usage-ledger.js';
13
11
  import { tersioDataPath } from '../lib/utils.js';
12
+ import { MODEL_ALIASES } from './pricing.js';
14
13
  export function usageDbPath() {
15
14
  const override = process.env.TERSIO_USAGE_DB;
16
15
  if (override)
@@ -45,12 +44,16 @@ function query(db, sql) {
45
44
  }
46
45
  function ensureSchema(db) {
47
46
  fs.mkdirSync(path.dirname(db), { recursive: true });
47
+ // Rows imported before an alias existed keep the spelling they were stored
48
+ // with, and the mtime ledger will not re-read those transcripts. Fold them in
49
+ // once, so the merge shows up without a reset.
50
+ const rekeys = MODEL_ALIASES.map((a) => `UPDATE messages SET model='${a.id}' WHERE model='${a.feed}';`).join('');
48
51
  run(db, `CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);` +
49
52
  `CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
50
53
  `CREATE TABLE IF NOT EXISTS messages (file TEXT NOT NULL, t REAL, model TEXT NOT NULL,` +
51
54
  ` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
52
55
  ` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]');` +
53
- `CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file);`);
56
+ `CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file);` + rekeys);
54
57
  }
55
58
  function readFiles(db) {
56
59
  const known = {};
@@ -129,10 +132,9 @@ function insertSql(file, r) {
129
132
  `${nullNum(r.d)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
130
133
  `${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))});`;
131
134
  }
132
- // Incremental sync: unchanged transcripts are skipped via mtime+size,
133
- // changed ones are deleted and re-inserted. Rows for deleted transcripts
134
- // are kept, so OMP session rotation never erases history. True on success
135
- // (including nothing-to-do), false when sqlite3 or disk is unavailable.
135
+ // Incremental sync: unchanged transcripts are skipped via mtime+size, changed
136
+ // ones re-inserted. Rows for deleted transcripts are kept, so rotation never
137
+ // erases history. False when sqlite3 or disk is unavailable.
136
138
  export function syncUsageDb() {
137
139
  let db;
138
140
  try {
@@ -1,11 +1,9 @@
1
1
  // extensions/shared/usage-store.ts — tersio-owned usage.db.
2
- // Persists the same per-message rows importSessionTokens derives live (model,
3
- // tokens, cache, cost input, timing, status, tools), so top models, costing,
4
- // cache, CO2 inputs, and activity survive session-file rotation. Sync is
5
- // incremental: a files ledger (mtime+size) skips unchanged transcripts, and
6
- // every read applies the current reset watermark — the store is a cache,
7
- // never a fork. Missing sqlite3 CLI → sync false / read null, callers fall
8
- // back to the live path. Never touches RTK's history.db or host transcripts.
2
+ // Persists the rows importSessionTokens derives live, so reports survive
3
+ // session-file rotation. Sync is incremental (a files ledger skips unchanged
4
+ // transcripts) and every read applies the reset watermark, so the store is a
5
+ // cache, never a fork. Missing sqlite3 → sync false / read null. Never touches
6
+ // RTK's history.db or host transcripts.
9
7
  import { execFileSync } from 'node:child_process';
10
8
  import fs from 'node:fs';
11
9
  import os from 'node:os';
@@ -23,6 +21,7 @@ import {
23
21
  walkJsonl,
24
22
  } from './usage-ledger.ts';
25
23
  import { tersioDataPath } from '../lib/utils.ts';
24
+ import { MODEL_ALIASES } from './pricing.ts';
26
25
  import type { RunStatus, SessionTokens } from './usage-ledger.ts';
27
26
 
28
27
  export function usageDbPath(): string {
@@ -70,6 +69,10 @@ function query(db: string, sql: string): string[][] {
70
69
 
71
70
  function ensureSchema(db: string): void {
72
71
  fs.mkdirSync(path.dirname(db), { recursive: true });
72
+ // Rows imported before an alias existed keep the spelling they were stored
73
+ // with, and the mtime ledger will not re-read those transcripts. Fold them in
74
+ // once, so the merge shows up without a reset.
75
+ const rekeys = MODEL_ALIASES.map((a) => `UPDATE messages SET model='${a.id}' WHERE model='${a.feed}';`).join('');
73
76
  run(
74
77
  db,
75
78
  `CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);` +
@@ -77,7 +80,7 @@ function ensureSchema(db: string): void {
77
80
  `CREATE TABLE IF NOT EXISTS messages (file TEXT NOT NULL, t REAL, model TEXT NOT NULL,` +
78
81
  ` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
79
82
  ` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]');` +
80
- `CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file);`,
83
+ `CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file);` + rekeys,
81
84
  );
82
85
  }
83
86
 
@@ -91,8 +94,8 @@ function readFiles(db: string): Record<string, { mtime: number; size: number }>
91
94
  return known;
92
95
  }
93
96
 
94
- // One parsed assistant message, as stored. `t` is epoch ms or null when the
95
- // source row carries no usable timestamp (live counts it, skips its recent).
97
+ // One parsed assistant message, as stored. `t` is null when the source row has
98
+ // no usable timestamp: counted, but skipped in the recent list.
96
99
  interface StoredRow {
97
100
  t: number | null;
98
101
  model: string;
@@ -173,10 +176,9 @@ function insertSql(file: string, r: StoredRow): string {
173
176
  `${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))});`;
174
177
  }
175
178
 
176
- // Incremental sync: unchanged transcripts are skipped via mtime+size,
177
- // changed ones are deleted and re-inserted. Rows for deleted transcripts
178
- // are kept, so OMP session rotation never erases history. True on success
179
- // (including nothing-to-do), false when sqlite3 or disk is unavailable.
179
+ // Incremental sync: unchanged transcripts are skipped via mtime+size, changed
180
+ // ones re-inserted. Rows for deleted transcripts are kept, so rotation never
181
+ // erases history. False when sqlite3 or disk is unavailable.
180
182
  export function syncUsageDb(): boolean {
181
183
  let db: string;
182
184
  try {
@@ -4,6 +4,7 @@
4
4
  // publishes sync sibling mirrors live, so switches notify and take effect
5
5
  // next turn — no reload.
6
6
  import { getSharedComboState, reconcileSharedComboEntries, sessionEntries, } from '../shared/session-state.js';
7
+ import { setExtensionLabel } from '../shared/host.js';
7
8
  import os from 'node:os';
8
9
  import path from 'node:path';
9
10
  import { appendUsage, readUsage } from '../shared/usage-ledger.js';
@@ -48,7 +49,7 @@ async function openDashboard(pi, ctx) {
48
49
  }
49
50
  }
50
51
  export default function tersioCommandsExtension(pi) {
51
- pi.setLabel?.('Tersio unified root command');
52
+ setExtensionLabel(pi, 'Tersio unified root command');
52
53
  pi.registerCommand?.('tersio', {
53
54
  description: 'Tersio root: status|check|update|dashboard|usage|help',
54
55
  handler: async (args, ctx) => {
@@ -8,6 +8,7 @@ import {
8
8
  reconcileSharedComboEntries,
9
9
  sessionEntries,
10
10
  } from '../shared/session-state.ts';
11
+ import { setExtensionLabel } from '../shared/host.ts';
11
12
  import os from 'node:os';
12
13
  import path from 'node:path';
13
14
  import { appendUsage, readUsage } from '../shared/usage-ledger.ts';
@@ -54,7 +55,7 @@ async function openDashboard(pi: ExtensionApi, ctx?: ExtensionCtx): Promise<void
54
55
  }
55
56
 
56
57
  export default function tersioCommandsExtension(pi: ExtensionApi): void {
57
- pi.setLabel?.('Tersio unified root command');
58
+ setExtensionLabel(pi, 'Tersio unified root command');
58
59
 
59
60
 
60
61
  pi.registerCommand?.('tersio', {
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "@krtclcdy/tersio",
3
- "version": "2.22.1",
4
- "description": "✂️ Token-saving OMP add-ons: terse Caveman replies, compact RTK shell output, lean Ponytail code calls — one-command combo presets.",
3
+ "version": "2.24.0",
4
+ "description": "\u2702\ufe0f Token-saving OMP add-ons: terse Caveman replies, compact RTK shell output, lean Ponytail code calls \u2014 one-command combo presets.",
5
5
  "keywords": [
6
6
  "omp",
7
+ "pi",
8
+ "pi-package",
7
9
  "token-saving",
8
10
  "ai-tools",
9
11
  "developer-tools",
@@ -29,7 +31,6 @@
29
31
  "./extensions/caveman-session/index.ts",
30
32
  "./extensions/rtk-session/index.ts",
31
33
  "./extensions/combo-toggle/index.ts",
32
- "./extensions/shared/mode-reinforcement.ts",
33
34
  "./extensions/tersio-commands/index.ts"
34
35
  ],
35
36
  "skills": [
@@ -56,6 +57,11 @@
56
57
  ],
57
58
  "default": "off"
58
59
  },
60
+ "comboSetupComplete": {
61
+ "type": "boolean",
62
+ "description": "Whether the first interactive OMP launch has completed the Combo preset prompt",
63
+ "default": false
64
+ },
59
65
  "cavemanDefault": {
60
66
  "type": "enum",
61
67
  "description": "Session-start default (no saved state)",
@@ -64,7 +70,9 @@
64
70
  "lite",
65
71
  "full",
66
72
  "ultra",
67
- "wenyan"
73
+ "wenyan-lite",
74
+ "wenyan-full",
75
+ "wenyan-ultra"
68
76
  ],
69
77
  "default": "off"
70
78
  },
@@ -80,8 +88,7 @@
80
88
  "off",
81
89
  "lite",
82
90
  "full",
83
- "ultra",
84
- "review"
91
+ "ultra"
85
92
  ],
86
93
  "default": "off"
87
94
  },
@@ -109,7 +116,8 @@
109
116
  "cli/**/*.js",
110
117
  "dashboard/dist/**/*",
111
118
  "extensions/**/*.js",
112
- "extensions/**/*.ts"
119
+ "extensions/**/*.ts",
120
+ "extensions/caveman-session/rule.md"
113
121
  ],
114
122
  "scripts": {
115
123
  "clean": "rm -f tersio.js cli/*.js extensions/**/*.js && rm -rf dashboard/dist",
package/tersio.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // tersio.ts — Install Tersio (caveman/rtk/ponytail) add-ons on any OMP device.
3
- // Usage: node tersio.js [install|update|reinstall|doctor|dashboard|uninstall|version|help] [options]
3
+ // Usage: node tersio.js [install|update|doctor|dashboard|uninstall|version|help] [options]
4
4
  // Requires: node/npm and omp CLI
5
5
  import { PACKAGE_BIN, PACKAGE_VERSION, applyUpdate, commandArg, dashboard, dashboardExport, dashboardOpen, dashboardPort, doctor, reset, settings, showHelp, showVersion, uninstall, unknownCommand, update, usage, } from './cli/common.js';
6
6
  import { closeRL } from './cli/interactive.js';
@@ -16,9 +16,8 @@ function printHelp() {
16
16
  console.log(`Usage: ${PACKAGE_BIN} [command] [options]
17
17
 
18
18
  Commands:
19
- install Install the add-ons (user scope: all OMP sessions)
19
+ install Install the add-ons for one agent (--host omp|pi, or ask at a terminal)
20
20
  update Refresh the CLI and add-ons (RTK binary, Caveman rule, Ponytail)
21
- reinstall Clean and reinstall the user-scope add-ons
22
21
  doctor Check the current installation (--fix repairs, --dry-run previews)
23
22
  usage Ledger-backed usage + savings report
24
23
  dashboard Open the Dashboard (localhost only)
@@ -31,11 +30,12 @@ Commands:
31
30
  Options:
32
31
  --fix (doctor: repairs all; --fix=<scope> repairs one of extensions, registrations, rtk, ponytail, cli)
33
32
  --scope user (legacy; accepted and ignored — user scope is the only scope)
33
+ --host omp|pi (install|uninstall: target agent; a terminal asks instead)
34
34
  --keep-ponytail (uninstall: keep the bundled Ponytail copy — removed by default)
35
- --remove-rtk (uninstall: also remove the RTK binary)
35
+ --remove-rtk (uninstall: also remove the RTK binary; its OMP wiring always goes)
36
36
  --combo-default off|medium|balanced|max (implies caveman, rtk, ponytail)
37
- --caveman-default off|lite|full|ultra|wenyan (override; default follows combo)
38
- --ponytail-default off|lite|full|ultra|review (override; default follows combo)
37
+ --caveman-default off|lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra (override; default follows combo)
38
+ --ponytail-default off|lite|full|ultra (override; default follows combo)
39
39
  --rtk-default on|off (override; default follows combo)
40
40
  --yes, -y
41
41
  --dry-run
@@ -1,26 +0,0 @@
1
- import { asPromptArray, reconcileSharedComboEntries, sessionEntries, systemPromptIncludes } from './session-state.js';
2
- const MARKER = 'SUPREME TOKEN SAVER MODES ACTIVE';
3
- function instruction(state) {
4
- const modes = [
5
- state.caveman !== 'off' && `caveman=${state.caveman}`,
6
- state.rtk === 'on' && 'rtk=on',
7
- state.ponytail !== 'off' && `ponytail=${state.ponytail}`,
8
- ].filter(Boolean);
9
- if (!modes.length)
10
- return null;
11
- return `${MARKER}: ${modes.join(' · ')}. Keep these active for the entire response: concise Caveman prose, RTK for eligible noisy shell output, and the smallest correct Ponytail solution. Do not weaken or disable a mode unless the user explicitly asks.`;
12
- }
13
- export default function modeReinforcementExtension(pi) {
14
- pi.setLabel?.('Tersio mode reinforcement');
15
- pi.on('before_agent_start', async (event, ctx) => {
16
- if (!ctx?.hasUI)
17
- return;
18
- const text = instruction(reconcileSharedComboEntries(sessionEntries(ctx)));
19
- if (!text)
20
- return;
21
- const base = asPromptArray(event.systemPrompt);
22
- if (systemPromptIncludes(base, MARKER))
23
- return;
24
- return { systemPrompt: [...base, text] };
25
- });
26
- }
@@ -1,29 +0,0 @@
1
- import { asPromptArray, reconcileSharedComboEntries, sessionEntries, systemPromptIncludes } from './session-state.ts';
2
- import type { ExtensionApi, SystemPromptEvent } from './types.ts';
3
-
4
- const MARKER = 'SUPREME TOKEN SAVER MODES ACTIVE';
5
-
6
- function instruction(state: { caveman: string; rtk: string; ponytail: string }): string | null {
7
- const modes = [
8
- state.caveman !== 'off' && `caveman=${state.caveman}`,
9
- state.rtk === 'on' && 'rtk=on',
10
- state.ponytail !== 'off' && `ponytail=${state.ponytail}`,
11
- ].filter(Boolean);
12
- if (!modes.length) return null;
13
-
14
- return `${MARKER}: ${modes.join(' · ')}. Keep these active for the entire response: concise Caveman prose, RTK for eligible noisy shell output, and the smallest correct Ponytail solution. Do not weaken or disable a mode unless the user explicitly asks.`;
15
- }
16
-
17
- export default function modeReinforcementExtension(pi: ExtensionApi): void {
18
- pi.setLabel?.('Tersio mode reinforcement');
19
-
20
- pi.on('before_agent_start', async (event: SystemPromptEvent, ctx) => {
21
- if (!ctx?.hasUI) return;
22
- const text = instruction(reconcileSharedComboEntries(sessionEntries(ctx)));
23
- if (!text) return;
24
-
25
- const base = asPromptArray(event.systemPrompt);
26
- if (systemPromptIncludes(base, MARKER)) return;
27
- return { systemPrompt: [...base, text] };
28
- });
29
- }