@krtclcdy/tersio 2.13.0 → 2.15.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.
package/README.md CHANGED
@@ -52,10 +52,10 @@ omp plugin features @krtclcdy/tersio --disable rtk
52
52
  Then restart OMP and enable a preset:
53
53
 
54
54
  ```text
55
- /tersio combo medium
55
+ /combo balanced
56
56
  ```
57
57
 
58
- Individual toggles: `/tersio caveman full` · `/tersio rtk on` · `/tersio ponytail full`. Everything starts off until you enable it.
58
+ Individual toggles: `/caveman full` · `/rtk on` · `/ponytail full`. Everything starts off until you enable it.
59
59
 
60
60
  Session-start defaults (prompted during `install`, or flags):
61
61
 
@@ -63,7 +63,7 @@ Session-start defaults (prompted during `install`, or flags):
63
63
  tersio install --combo-default balanced --caveman-default lite --rtk-default on
64
64
  ```
65
65
 
66
- Defaults apply to fresh sessions only — anything persisted with `/tersio combo`, `/tersio caveman`, or `/tersio rtk` wins. Stored as plugin settings (`omp plugin config get @krtclcdy/tersio comboDefault`).
66
+ Defaults apply to fresh sessions only — anything persisted with `/combo`, `/caveman`, or `/rtk` wins. Stored as plugin settings (`omp plugin config get @krtclcdy/tersio comboDefault`).
67
67
 
68
68
  One-off use without installing:
69
69
 
@@ -108,7 +108,7 @@ Break-even math, balanced preset (caveman full + rtk + ponytail): a mixed turn c
108
108
  | `tersio doctor` | Check OMP, extension, Ponytail, and RTK health |
109
109
  | `tersio usage` | Ledger-backed usage + savings report |
110
110
  | `tersio gain` | Open the gain dashboard (`--open`, `--export <file>`, `--port <n>`; serves localhost only) |
111
- | `tersio reset` | Clear tersio statistics (usage ledger; `--dry-run`, `--yes`) — sessions and RTK history stay intact |
111
+ | `tersio reset` | Clear tersio statistics: usage ledger + a watermark that hides pre-reset rows from every derived view (Y/N confirm, `--dry-run`, `--yes`) — session transcripts and RTK history stay intact on disk |
112
112
  | `tersio uninstall` | Remove extensions, registration, and the Ponytail plugin (`--keep-ponytail` keeps Ponytail; `--remove-rtk` also removes the RTK binary and its `rtk.ts` OMP wiring) |
113
113
  | `tersio version` | Print version |
114
114
 
@@ -116,21 +116,21 @@ Flags: `--dry-run`, `--yes`/`-y`, `--verbose`, `--combo-default`/`--caveman-defa
116
116
 
117
117
  ## ⌨️ Commands reference
118
118
 
119
- Everything runs under one root. Bare `/tersio` prints status.
119
+ Mode switches live on their own commands; bare `/tersio` prints status.
120
120
 
121
121
  | Command | Purpose |
122
122
  |---|---|
123
- | `/tersio combo off\|medium\|balanced\|max\|status` | One preset for all three (start here): medium = lite/lite/on, balanced = full/full/on, max = ultra/ultra/on; per-mode tweaks drop to `custom`. |
124
- | `/tersio caveman lite\|full\|ultra\|wenyan\|off\|status` | Terse replies (substance stays); `ultra` uses fragments only, `wenyan` a compressed classical-Chinese-inspired register. |
125
- | `/tersio rtk on\|off\|status` | Compact shell output via the real `rtk` binary; exact bytes (checksums, patches) bypass it by policy. |
126
- | `/tersio ponytail off\|lite\|full\|ultra\|review\|status` | Minimal code (YAGNI): `lite` nudges, `full` enforces, `ultra` challenges the requirement, `review` audits for over-engineering. |
123
+ | `/combo off\|medium\|balanced\|max\|status` | One preset for all three (start here): medium = lite/lite/on, balanced = full/full/on, max = ultra/ultra/on; per-mode tweaks drop to `custom`. |
124
+ | `/caveman lite\|full\|ultra\|wenyan\|off\|status` | Terse replies (substance stays); `ultra` uses fragments only, `wenyan` a compressed classical-Chinese-inspired register. |
125
+ | `/rtk on\|off\|status` | Compact shell output via the real `rtk` binary; exact bytes (checksums, patches) bypass it by policy. |
126
+ | `/ponytail off\|lite\|full\|ultra\|review\|status` | Minimal code (YAGNI): `lite` nudges, `full` enforces, `ultra` challenges the requirement, `review` audits for over-engineering (upstream command). |
127
127
  | `/tersio status` | Active modes + combo level; state persists, propagates to subagents, and lights the Combo footer indicator. |
128
128
  | `/tersio check` | Add-on version check |
129
129
  | `/tersio update <ponytail\|rtk\|caveman\|all> [--dry-run]` | Update add-ons, preview with dry-run |
130
130
  | `/tersio gain` | Savings summary; the gain dashboard charts `~/.omp/plugins/tersio-usage.jsonl` (`tersio gain --open` serves on 127.0.0.1, `--export` writes one file). |
131
131
  | `/tersio usage` | Ledger report for this machine (rows in `~/.omp/plugins/tersio-usage.jsonl`, local only) |
132
132
  | `/tersio help` | This table |
133
- | `/caveman`, `/rtk`, `/combo`, `/ai-addons` | Legacy aliases, still work; prefer `/tersio`. |
133
+ | `/ai-addons` | Add-on updater alias, still works. |
134
134
 
135
135
  ## 🗂️ Files and backups
136
136
 
package/cli/banner.js CHANGED
@@ -72,13 +72,20 @@ function bannerLines(tier = bannerTier()) {
72
72
  return out;
73
73
  }
74
74
  let shown = false;
75
- // OMP-style welcome: intro line + note box with art and tips.
75
+ // OMP-style welcome: intro line + note box with art left, commands right.
76
76
  // No-op unless interactive (and never on --dry-run).
77
77
  function printWelcome() {
78
78
  if (shown || dryRun || !tty())
79
79
  return;
80
80
  shown = true;
81
81
  clackIntro(`✂ tersio v${PACKAGE_VERSION}`);
82
- clackNote(`${bannerLines().join('\n')}\n\n tersio install set up add-ons\n tersio update refresh CLI + add-ons\n tersio doctor verify the install`, 'cut token bloat');
82
+ const tips = [
83
+ 'tersio install set up add-ons',
84
+ 'tersio update refresh CLI + add-ons',
85
+ 'tersio doctor verify the install',
86
+ ];
87
+ const art = bannerLines();
88
+ const rows = art.map((line, i) => (i >= 1 && i <= 3 ? `${line} ${tips[i - 1]}` : line));
89
+ clackNote(rows.join('\n'));
83
90
  }
84
91
  export { bannerLines, bannerTier, printWelcome };
package/cli/dashboard.js CHANGED
@@ -8,7 +8,7 @@ import { promises as fs } from 'node:fs';
8
8
  import http from 'node:http';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
- import { clearUsageLedger, readUsage } from '../extensions/shared/usage-ledger.js';
11
+ import { clearUsageLedger, markReset, readUsage } from '../extensions/shared/usage-ledger.js';
12
12
  import { summarizeUsage } from './usage.js';
13
13
  import { withInteractiveSpinner } from './interactive.js';
14
14
  const DASH_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dashboard');
@@ -52,6 +52,7 @@ async function runDashboard(options) {
52
52
  const server = http.createServer(async (req, res) => {
53
53
  if (req.url === '/reset' && req.method === 'POST') {
54
54
  const rows = clearUsageLedger();
55
+ markReset();
55
56
  res.writeHead(200, { 'Content-Type': 'application/json' });
56
57
  res.end(JSON.stringify({ ok: true, rows }));
57
58
  return;
package/cli/doctor.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // cli/doctor.ts — installation health checks.
2
2
  import { promises as fs } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { BUN_BIN_DIR, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, execP, parseJsonObject, relTime, } from './common.js';
4
+ import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, execP, parseJsonObject, relTime, } from './common.js';
5
5
  import { runInteractivePhase } from './interactive.js';
6
6
  import { ledgerPath, readUsage, sessionsDir } from '../extensions/shared/usage-ledger.js';
7
7
  import { readRtkGain, rtkDbPath } from '../extensions/shared/rtk-gain.js';
@@ -56,50 +56,53 @@ async function runDoctor() {
56
56
  const err = e;
57
57
  return err.stdout?.trim() || err.stderr?.trim() || null;
58
58
  }));
59
- const [ompVersion, agentEntries, extEntries, sharedStateText, configText, updateVersion] = await runInteractivePhase('Checking environment and installation', () => Promise.all([
60
- probes.ompVersion,
61
- probes.agentEntries,
62
- probes.extEntries,
63
- probes.sharedStateText,
64
- probes.configText,
65
- probes.updateVersion,
59
+ const [[ompVersion, agentEntries, extEntries, sharedStateText, configText, updateVersion], [cavemanIndexText, rtkIndexText, updaterIndexText, comboIndexText, tersioIndexText, modeReinforcementText, ponytailPkgText, ponytailExtText, pluginsPkgRaw, selfPkgText], [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, rtkOmpText, ponytailMtime]] = await runInteractivePhase('Checking installation', () => Promise.all([
60
+ Promise.all([
61
+ probes.ompVersion,
62
+ probes.agentEntries,
63
+ probes.extEntries,
64
+ probes.sharedStateText,
65
+ probes.configText,
66
+ probes.updateVersion,
67
+ ]),
68
+ Promise.all([
69
+ probes.cavemanIndexText,
70
+ probes.rtkIndexText,
71
+ probes.updaterIndexText,
72
+ probes.comboIndexText,
73
+ probes.tersioIndexText,
74
+ probes.modeReinforcementText,
75
+ probes.ponytailPkgText,
76
+ probes.ponytailExtText,
77
+ probes.pluginsPkgRaw,
78
+ probes.selfPkgText,
79
+ ]),
80
+ Promise.all([
81
+ probes.cavemanRuleText,
82
+ probes.ruleMtime,
83
+ probes.rtkBinText,
84
+ probes.rtkMtime,
85
+ rtkVersionProbe,
86
+ probes.rtkOmpText,
87
+ probes.ponytailMtime,
88
+ ]),
66
89
  ]));
67
- const [cavemanIndexText, rtkIndexText, updaterIndexText, comboIndexText, tersioIndexText, modeReinforcementText, ponytailPkgText, ponytailExtText, pluginsPkgRaw, selfPkgText] = await runInteractivePhase('Checking extensions and plugins', () => Promise.all([
68
- probes.cavemanIndexText,
69
- probes.rtkIndexText,
70
- probes.updaterIndexText,
71
- probes.comboIndexText,
72
- probes.tersioIndexText,
73
- probes.modeReinforcementText,
74
- probes.ponytailPkgText,
75
- probes.ponytailExtText,
76
- probes.pluginsPkgRaw,
77
- probes.selfPkgText,
78
- ]));
79
- const [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, rtkOmpText, ponytailMtime] = await runInteractivePhase('Checking add-ons', () => Promise.all([
80
- probes.cavemanRuleText,
81
- probes.ruleMtime,
82
- probes.rtkBinText,
83
- probes.rtkMtime,
84
- rtkVersionProbe,
85
- probes.rtkOmpText,
86
- probes.ponytailMtime,
87
- ]));
88
- // Categorized output with a tally; section headers group related probes.
90
+ // Categorized output with a tally; success rows stay quiet (no path echoes)
91
+ // while failures print the expected path or fix so they stay actionable.
89
92
  const tally = { ok: 0, missing: 0, warn: 0 };
90
93
  function check(label, ok, detail = '') {
91
94
  if (ok) {
92
95
  tally.ok++;
93
- console.log(` ${label}: ok${detail ? ` ${detail}` : ''}`);
96
+ console.log(` ✅ ${label}: ok${detail ? ` ${detail}` : ''}`);
94
97
  }
95
98
  else {
96
99
  tally.missing++;
97
- console.log(` ${label}: MISSING${detail ? ` ${detail}` : ''}`);
100
+ console.log(` ❌ ${label}: MISSING${detail ? ` ${detail}` : ''}`);
98
101
  }
99
102
  }
100
103
  function warnLine(label, detail) {
101
104
  tally.warn++;
102
- console.log(` ${label}: warn ${detail}`);
105
+ console.log(` ⚠️ ${label}: warn ${detail}`);
103
106
  }
104
107
  section('Environment');
105
108
  check('Node', true, process.version);
@@ -110,33 +113,27 @@ async function runDoctor() {
110
113
  warnLine('Tersio CLI', `${PACKAGE_VERSION} (version check unreachable)`);
111
114
  else
112
115
  check('Tersio CLI', true, PACKAGE_VERSION);
113
- console.log(` Home: ${HOME}`);
114
116
  section('Installation');
115
- check('OMP agent dir', agentEntries !== null, agentDir);
116
- check('OMP extensions dir', extEntries !== null, extDir);
117
- check('OMP config.yml', configText !== null, configPath);
117
+ check('OMP agent dir', agentEntries !== null, agentEntries === null ? agentDir : '');
118
+ check('OMP extensions dir', extEntries !== null, extEntries === null ? extDir : '');
119
+ check('OMP config.yml', configText !== null, configText === null ? configPath : '');
118
120
  check('Shared session bridge', sharedStateText !== null);
119
- section('Extensions');
121
+ section('Extensions & plugins');
120
122
  check('Caveman extension', cavemanIndexText !== null);
121
123
  check('RTK extension', rtkIndexText !== null);
122
124
  check('Updater extension', updaterIndexText !== null);
123
125
  check('Combo extension', comboIndexText !== null);
124
126
  check('Tersio commands extension', tersioIndexText !== null);
125
127
  check('Mode reinforcement extension', modeReinforcementText !== null);
126
- section('Plugins');
127
- check('Ponytail package', ponytailPkgText !== null);
128
128
  check('Ponytail extension', ponytailExtText !== null);
129
- if (configText) {
130
- check('Ponytail in config.yml', configText.includes('ponytail') && configText.includes('pi-extension'));
131
- check('Combo in config.yml', configText.includes('combo-toggle'));
132
- }
129
+ check('Ponytail in config.yml', (configText ?? '').includes('ponytail') && (configText ?? '').includes('pi-extension'));
130
+ check('Combo in config.yml', (configText ?? '').includes('combo-toggle'));
133
131
  check('Self plugin package', selfPkgText !== null, parseJsonObject(selfPkgText)?.version ?? '');
134
132
  const selfDep = PACKAGE_NAME in (parseJsonObject(pluginsPkgRaw)?.dependencies || {});
135
133
  check('Self plugin in plugins/package.json', selfDep);
136
- section('Usage');
134
+ section('Usage & records');
137
135
  const usageRows = readUsage();
138
- check('Usage ledger', usageRows.length > 0, usageRows.length ? `${usageRows.length} rows · ${ledgerPath()}` : ledgerPath());
139
- section('Records');
136
+ check('Usage ledger', true, usageRows.length ? `${usageRows.length} rows` : 'empty — no records yet');
140
137
  console.log(` Usage ledger (tersio-owned, tersio reset clears): ${ledgerPath()} · ${usageRows.length} rows`);
141
138
  console.log(` Session transcripts (host-owned, never touched): ${sessionsDir()}`);
142
139
  const rtkDb = rtkDbPath();
@@ -146,22 +143,14 @@ async function runDoctor() {
146
143
  const ruleAge = ruleMtime ? `updated ${relTime(Date.now() - ruleMtime.mtimeMs)}` : '';
147
144
  check('Caveman rule', cavemanRuleText !== null, ruleAge);
148
145
  const rtkAge = rtkMtime ? `updated ${relTime(Date.now() - rtkMtime.mtimeMs)}` : '';
149
- if (rtkBinText === null)
150
- check('RTK binary', false, rtkBin);
151
- else {
152
- const bits = [rtkVersion, rtkAge].filter(Boolean).join(', ');
153
- check('RTK binary', true, bits);
154
- if (!rtkVersion)
155
- warnLine('RTK version', 'unavailable — binary may not be executable');
156
- if (rtkOmpText === null)
157
- check('RTK OMP wiring (rtk.ts)', false, `run: rtk init -g --agent omp`);
158
- else
159
- check('RTK OMP wiring (rtk.ts)', true, path.join(extDir, 'rtk.ts'));
160
- }
146
+ check('RTK binary', rtkBinText !== null, rtkBinText === null ? rtkBin : [rtkVersion, rtkAge].filter(Boolean).join(', '));
147
+ if (rtkBinText !== null && !rtkVersion)
148
+ warnLine('RTK version', 'unavailable — binary may not be executable');
149
+ check('RTK OMP wiring (rtk.ts)', rtkOmpText !== null, rtkOmpText === null ? 'run: rtk init -g --agent omp' : '');
161
150
  const ponytailAge = ponytailMtime ? `updated ${relTime(Date.now() - ponytailMtime.mtimeMs)}` : '';
162
151
  check('Ponytail', ponytailPkgText !== null, [parseJsonObject(ponytailPkgText)?.version ?? '', ponytailAge].filter(Boolean).join(', '));
163
152
  const total = tally.ok + tally.missing + tally.warn;
164
- console.log(`\n Summary: ${total} checks — ${tally.ok} ok, ${tally.warn} warn, ${tally.missing} missing`);
153
+ console.log(`\n Summary: ${total} checks — ✅ ${tally.ok} ok, ⚠️ ${tally.warn} warn, ❌ ${tally.missing} missing`);
165
154
  }
166
155
  function section(name) {
167
156
  console.log(`\n${name}`);
package/cli/install.js CHANGED
@@ -3,7 +3,7 @@ import { promises as fs } from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { BUN_BIN_DIR, COMBO_PRESET_MODES, EXTENSIONS_KEY_RE, HOME, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, install, ponytailDefaultFlag, profileFlagsGiven, reinstall, rtkDefaultFlag, verbose, yes, dashboardExport, dashboardPort, debug, ensureExtensionAfterConfigEntry, ensureExtensionInConfig, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
6
+ import { BUN_BIN_DIR, CAVEMAN_DEFAULTS, COMBO_PRESET_MODES, EXTENSIONS_KEY_RE, HOME, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, PONYTAIL_DEFAULTS, RTK_BINARY_NAME, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, install, ponytailDefaultFlag, profileFlagsGiven, reinstall, rtkDefaultFlag, verbose, yes, dashboardExport, dashboardPort, debug, ensureExtensionAfterConfigEntry, ensureExtensionInConfig, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
7
7
  import { askInteractiveChoice, askInteractiveConfirm, closeRL, execNetwork, tty, withInteractiveSpinner, } from './interactive.js';
8
8
  import { printWelcome } from './banner.js';
9
9
  import { checkForUpdate, runLatestUpdate } from './update.js';
@@ -442,21 +442,52 @@ function defaultProfile() {
442
442
  ponytailDefault: 'off',
443
443
  };
444
444
  }
445
- // Install is always user-level (all OMP sessions). Project scope was removed:
446
- // session extensions must live in ~/.omp/agent/extensions to load.
447
- async function resolveProfile() {
448
- const profile = defaultProfile();
445
+ async function storedProfile() {
446
+ const base = defaultProfile();
447
+ const raw = await readTextIfExists(path.join(OMP_PLUGINS_DIR, 'omp-plugins.lock.json'));
448
+ if (!raw)
449
+ return base;
450
+ let stored;
451
+ try {
452
+ const parsed = JSON.parse(raw);
453
+ if (!parsed || typeof parsed !== 'object' || !('settings' in parsed))
454
+ return base;
455
+ const settings = parsed.settings;
456
+ if (!settings || typeof settings !== 'object' || !(PACKAGE_NAME in settings))
457
+ return base;
458
+ const entry = settings[PACKAGE_NAME];
459
+ if (!entry || typeof entry !== 'object')
460
+ return base;
461
+ stored = entry;
462
+ }
463
+ catch {
464
+ return base;
465
+ }
466
+ if (typeof stored.comboDefault === 'string' && stored.comboDefault in COMBO_PRESET_MODES)
467
+ base.comboDefault = stored.comboDefault;
468
+ if (typeof stored.cavemanDefault === 'string' && CAVEMAN_DEFAULTS.has(stored.cavemanDefault))
469
+ base.cavemanDefault = stored.cavemanDefault;
470
+ if (typeof stored.rtkDefault === 'boolean')
471
+ base.rtkDefault = stored.rtkDefault;
472
+ if (typeof stored.ponytailDefault === 'string' && PONYTAIL_DEFAULTS.has(stored.ponytailDefault))
473
+ base.ponytailDefault = stored.ponytailDefault;
474
+ return base;
475
+ }
476
+ async function resolveProfile(forceReinstall = false) {
477
+ // Seed from the lock file so flag-less update/reinstall runs keep the
478
+ // user's configured defaults instead of resetting them to off.
479
+ const profile = await storedProfile();
449
480
  // Single interactive prompt: the Combo preset implies all three modes.
450
481
  // Numbered menu — no typing preset names.
451
482
  // Only for a real user at a terminal, only when no default flags were
452
483
  // given, and never for --apply-update runs.
453
- if (tty() && !profileFlagsGiven && !applyUpdate && (install || reinstall)) {
484
+ if (tty() && !profileFlagsGiven && !applyUpdate && (install || forceReinstall || reinstall)) {
454
485
  const choice = await askInteractiveChoice('Session-start defaults — Combo preset', [
455
486
  { value: 'off', label: 'off' },
456
487
  { value: 'medium', label: 'medium', hint: 'caveman=lite, rtk=on, ponytail=lite' },
457
488
  { value: 'balanced', label: 'balanced', hint: 'caveman=full, rtk=on, ponytail=full' },
458
489
  { value: 'max', label: 'max', hint: 'caveman=ultra, rtk=on, ponytail=ultra' },
459
- ], 'off');
490
+ ], profile.comboDefault);
460
491
  if (choice.status === 'selected')
461
492
  profile.comboDefault = choice.value;
462
493
  else {
@@ -558,11 +589,12 @@ async function runCommandMenu() {
558
589
  updatePromptDone = true;
559
590
  const choice = await askInteractiveChoice('Tersio — what next?', [
560
591
  { value: 'install', label: 'Install add-ons', hint: 'user scope + combo defaults' },
561
- { value: 'check', label: 'Check for updates', hint: 'check CLI version, then update ai-addons' },
592
+ { value: 'update', label: 'Update', hint: 'CLI version check, then refresh add-ons (RTK, Caveman rule, Ponytail)' },
593
+ { value: 'reinstall', label: 'Reinstall', hint: 'clean and reinstall the add-ons, Ponytail package kept' },
562
594
  { value: 'doctor', label: 'Doctor', hint: 'verify the installation' },
563
595
  { value: 'usage', label: 'Usage', hint: 'token usage and savings report' },
564
596
  { value: 'gain', label: 'Gain dashboard', hint: 'open the report in your browser' },
565
- { value: 'reset', label: 'Reset statistics', hint: 'clear the tersio usage ledger' },
597
+ { value: 'reset', label: 'Reset statistics', hint: 'clear statistics; transcripts and RTK history stay' },
566
598
  { value: 'uninstall', label: 'Uninstall', hint: 'remove tersio' },
567
599
  ], 'install');
568
600
  if (choice.status !== 'selected') {
@@ -573,8 +605,8 @@ async function runCommandMenu() {
573
605
  case 'install':
574
606
  await runInstall();
575
607
  break;
576
- case 'check': {
577
- // One bound flow: report the CLI version, then offer the ai-addons
608
+ case 'update': {
609
+ // One bound flow: report the CLI version, then offer the add-on
578
610
  // refresh in the same pass — no redundant second "update" option.
579
611
  const latest = await checkForUpdate(true);
580
612
  if (typeof latest === 'string')
@@ -583,7 +615,7 @@ async function runCommandMenu() {
583
615
  console.log(` [warn] could not reach the npm registry — run \`tersio update\` to retry`);
584
616
  else
585
617
  console.log(` [ok] tersio ${PACKAGE_VERSION} is the latest`);
586
- const go = await askInteractiveConfirm('Update ai-addons now (RTK, Caveman rule, Ponytail)?');
618
+ const go = await askInteractiveConfirm('Update add-ons now (RTK, Caveman rule, Ponytail)?');
587
619
  if (go.status === 'confirmed' && go.value) {
588
620
  await runLatestUpdate();
589
621
  }
@@ -597,6 +629,10 @@ async function runCommandMenu() {
597
629
  closeRL();
598
630
  break;
599
631
  }
632
+ case 'reinstall':
633
+ await runInstall({ reinstall: true });
634
+ closeRL();
635
+ break;
600
636
  case 'doctor':
601
637
  await runDoctor();
602
638
  closeRL();
@@ -619,7 +655,8 @@ async function runCommandMenu() {
619
655
  break;
620
656
  }
621
657
  }
622
- async function runInstall() {
658
+ async function runInstall(overrides = {}) {
659
+ const isReinstall = overrides.reinstall ?? reinstall;
623
660
  // Menu-driven installs must fall through: bare `tersio` re-enters here
624
661
  // with command === null after the picker, and without this guard the
625
662
  // choice loops straight back into runCommandMenu() forever.
@@ -628,8 +665,11 @@ async function runInstall() {
628
665
  return;
629
666
  }
630
667
  printWelcome();
631
- if (reinstall) {
632
- await runUninstall({ yes: true, removePonytail: false, removeRtk: true });
668
+ if (isReinstall) {
669
+ // removeRtk stays false: reinstall is about to replace the binary, and
670
+ // deleting it first would leave nothing to wire if the fresh download
671
+ // fails (rate limit, offline). rtk.ts is removed here and re-wired below.
672
+ await runUninstall({ yes: true, removePonytail: false, removeRtk: false });
633
673
  }
634
674
  if (dryRun)
635
675
  console.log('[dry-run] No changes will be written.\n');
@@ -665,13 +705,13 @@ async function runInstall() {
665
705
  }
666
706
  console.log(' Scope: user (all OMP sessions)');
667
707
  // Resolve session defaults: flags > interactive prompt > defaults.
668
- const profile = await resolveProfile();
708
+ const profile = await resolveProfile(isReinstall);
669
709
  const userDir = OMP_AGENT_DIR;
670
710
  const userExtDir = path.join(userDir, 'extensions');
671
711
  // apply-update is `tersio update`'s payload run: treat it like reinstall so
672
712
  // the add-ons refresh too — Ponytail package via npm, self plugin, RTK
673
713
  // binary (always re-downloaded), and the Caveman rule (always re-fetched).
674
- const options = { dryRun, verbose, yes, reinstall: reinstall || applyUpdate };
714
+ const options = { dryRun, verbose, yes, reinstall: isReinstall || applyUpdate };
675
715
  // Check prerequisites
676
716
  console.log('\nPrerequisites:');
677
717
  try {
package/cli/reset.js CHANGED
@@ -1,29 +1,33 @@
1
- // cli/reset.ts — clear tersio-owned statistics (usage ledger).
2
- // Session transcripts (~/.omp/agent/sessions) and the RTK database are
3
- // host/tool-owned and are never touched; doctor shows where they live.
1
+ // cli/reset.ts — clear tersio-owned statistics: the usage ledger plus a reset
2
+ // watermark that filters session-derived and RTK-metered rows out of every
3
+ // derived view. Session transcripts (~/.omp/agent/sessions) and the RTK
4
+ // database are host/tool-owned and are never touched; the watermark only
5
+ // changes what tersio shows.
4
6
  import { cancel as clackCancel, confirm as clackConfirm } from '@clack/prompts';
5
7
  import { dryRun, yes } from './common.js';
6
8
  import { ask, closeRL, tty } from './interactive.js';
7
- import { clearUsageLedger, ledgerPath, readUsage } from '../extensions/shared/usage-ledger.js';
8
- import { sessionsDir } from '../extensions/shared/usage-ledger.js';
9
- import { rtkDbPath } from '../extensions/shared/rtk-gain.js';
9
+ import { clearUsageLedger, importSessionTokens, ledgerPath, markReset, readUsage, sessionsDir } from '../extensions/shared/usage-ledger.js';
10
+ import { readRtkGain, rtkDbPath } from '../extensions/shared/rtk-gain.js';
10
11
  async function runReset() {
11
12
  console.log('\n=== Tersio Reset ===\n');
12
13
  const rows = readUsage().length;
13
- console.log(`Will remove: ${rows} usage rows · ${ledgerPath()}`);
14
- console.log(`Left intact: sessions ${sessionsDir()}, rtk ${rtkDbPath()}`);
14
+ const sessions = importSessionTokens();
15
+ const rtk = readRtkGain(1);
16
+ console.log(`Will clear: ${rows} usage ledger rows · ${ledgerPath()}`);
17
+ console.log(`Will hide (view-level watermark): session-derived statistics (${sessions.messages} messages) · RTK metered rows (${rtk.commands})`);
18
+ console.log(`Left intact on disk: sessions ${sessionsDir()}, rtk ${rtkDbPath()}`);
15
19
  if (dryRun) {
16
- console.log('[dry-run] ledger kept.');
20
+ console.log('[dry-run] nothing written.');
17
21
  return true;
18
22
  }
19
- if (rows === 0) {
20
- console.log('Nothing to reset — usage ledger is already empty.');
23
+ if (rows === 0 && sessions.messages === 0 && rtk.commands === 0) {
24
+ console.log('Nothing to reset — no statistics recorded.');
21
25
  return true;
22
26
  }
23
27
  if (!yes) {
24
28
  if (tty()) {
25
29
  closeRL();
26
- const confirmedChoice = await clackConfirm({ message: 'Clear the usage ledger?', initialValue: false });
30
+ const confirmedChoice = await clackConfirm({ message: 'Clear tersio statistics?', initialValue: false });
27
31
  if (typeof confirmedChoice !== 'boolean') {
28
32
  clackCancel('Aborted.');
29
33
  closeRL();
@@ -45,7 +49,8 @@ async function runReset() {
45
49
  }
46
50
  }
47
51
  const cleared = clearUsageLedger();
48
- console.log(`[ok] reset — removed ${cleared} usage rows`);
52
+ const ts = markReset();
53
+ console.log(`[ok] reset — removed ${cleared} usage rows; statistics view starts at ${new Date(ts).toISOString()}`);
49
54
  return true;
50
55
  }
51
56
  export { runReset };
package/cli/usage.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // cli/usage.ts — ledger + session-token usage report, tokscale-style.
2
- import { co2GramsFor, energyWhFor, importSessionTokens, ledgerPath, priceFor, readUsage, sessionsDir, usdCost, } from '../extensions/shared/usage-ledger.js';
3
- import { readRtkGain, rtkDbPath } from '../extensions/shared/rtk-gain.js';
2
+ import { co2GramsFor, energyWhFor, importSessionTokens, ledgerPath, priceFor, readResetWatermark, readUsage, refreshPricesIfStale, sessionsDir, usdCost, } from '../extensions/shared/usage-ledger.js';
3
+ import { readRtkGain } from '../extensions/shared/rtk-gain.js';
4
4
  import { withInteractiveSpinner } from './interactive.js';
5
5
  import { PACKAGE_VERSION } from './common.js';
6
6
  export function summarizeUsage(rows) {
7
+ refreshPricesIfStale();
7
8
  const byKind = {};
8
9
  const detailCounts = {};
9
10
  for (const r of rows) {
@@ -12,6 +13,7 @@ export function summarizeUsage(rows) {
12
13
  }
13
14
  const byDetail = Object.entries(detailCounts).sort((a, b) => b[1] - a[1]).slice(0, 10);
14
15
  const session = importSessionTokens();
16
+ const watermark = readResetWatermark();
15
17
  let usd = 0;
16
18
  let priced = true;
17
19
  let savedUsd = 0;
@@ -44,7 +46,7 @@ export function summarizeUsage(rows) {
44
46
  byDayModel: session.byDayModel,
45
47
  byTool,
46
48
  recent: session.recent,
47
- rtkGain: readRtkGain(),
49
+ rtkGain: readRtkGain(10, watermark || undefined),
48
50
  usd,
49
51
  priced,
50
52
  savedUsd,
@@ -52,7 +54,7 @@ export function summarizeUsage(rows) {
52
54
  co2g,
53
55
  energyWh,
54
56
  version: PACKAGE_VERSION,
55
- paths: { ledger: ledgerPath(), sessions: sessionsDir(), rtk: rtkDbPath() },
57
+ paths: { ledger: ledgerPath(), sessions: sessionsDir() },
56
58
  };
57
59
  }
58
60
  function fmt(n) {
package/dashboard/app.js CHANGED
@@ -778,7 +778,7 @@
778
778
  renderSpark(byDay, d.usd || 0, d.savedUsd || 0, total);
779
779
  document.getElementById('verChip').textContent = 'tersio v' + (d.version || '?');
780
780
  var dp = d.paths || {};
781
- [['pathLedger', dp.ledger], ['pathSessions', dp.sessions], ['pathRtk', dp.rtk]].forEach(function(pair) {
781
+ [['pathLedger', dp.ledger], ['pathSessions', dp.sessions]].forEach(function(pair) {
782
782
  var pel = document.getElementById(pair[0]);
783
783
  if (pel && pair[1]) { pel.textContent = pair[1]; pel.title = pair[1]; }
784
784
  });
@@ -879,14 +879,50 @@
879
879
  els.forEach(function(el) { if (!seen.has(el)) io.observe(el); });
880
880
  }
881
881
 
882
+ var lastJson = '';
882
883
  function load() {
883
- fetch('data.json').then(function(r) { return r.json(); }).then(render).catch(function() {
884
+ // Served mode only: file:// exports have no data.json endpoint, so
885
+ // polling there would just burn cycles on 404s.
886
+ if (window.location.protocol === 'file:') return;
887
+ // Skip background tabs: no render work while hidden, instant refresh on return.
888
+ if (document.hidden) return;
889
+ fetch('data.json').then(function(r) { return r.json(); }).then(function(d) {
890
+ var json = JSON.stringify(d);
891
+ if (json === lastJson) return;
892
+ lastJson = json;
893
+ render(d);
894
+ }).catch(function() {
884
895
  var cards = document.getElementById('modelCards');
885
896
  if (cards && !cards.children.length) cards.innerHTML = '<div class="empty md:col-span-3">' + emptyState('cloud-off', 'Could not load data', 'Serve with tersio gain instead of opening this file directly.') + '</div>';
886
897
  if (window.lucide) lucide.createIcons();
887
898
  observe();
888
899
  });
889
900
  }
901
+ // Live view: re-read data.json every 5s while served and visible; identical
902
+ // payloads skip render. The manual Reload button stays as an instant refresh.
903
+ setInterval(load, 5000);
904
+ document.addEventListener('visibilitychange', function() { if (!document.hidden) load(); });
905
+ function toast(title, desc, icon) {
906
+ var wrap = document.querySelector('.toaster');
907
+ if (!wrap) {
908
+ wrap = document.createElement('div');
909
+ wrap.className = 'toaster';
910
+ wrap.popover = 'manual';
911
+ document.body.appendChild(wrap);
912
+ if (wrap.showPopover) wrap.showPopover(); // top layer: paints above the native settings dialog
913
+ }
914
+ var el = document.createElement('div');
915
+ el.className = 'toast';
916
+ el.innerHTML = '<span class="toast-icon"><i data-lucide="' + (icon || 'check') + '" class="size-4"></i></span><div><p class="toast-title"></p><p class="toast-desc"></p></div>';
917
+ el.querySelector('.toast-title').textContent = title;
918
+ el.querySelector('.toast-desc').textContent = desc;
919
+ wrap.appendChild(el);
920
+ if (window.lucide) lucide.createIcons();
921
+ setTimeout(function() {
922
+ el.classList.add('out');
923
+ setTimeout(function() { el.remove(); }, 200);
924
+ }, 4000);
925
+ }
890
926
  document.getElementById('reload').addEventListener('click', load);
891
927
  (function() {
892
928
  var dlg = document.getElementById('settings');
@@ -909,7 +945,11 @@
909
945
  fetch('reset', { method: 'POST' }).then(function(r) { return r.json(); }).then(function() {
910
946
  label.textContent = 'Reset';
911
947
  load();
912
- }).catch(function() { label.textContent = 'Reset'; });
948
+ toast('Statistics reset', 'The usage statistics view now starts fresh. Transcripts and RTK history were never touched.', 'rotate-ccw');
949
+ }).catch(function() {
950
+ label.textContent = 'Reset';
951
+ toast('Reset failed', 'Could not reach the server. Try again.', 'circle-alert');
952
+ });
913
953
  } else {
914
954
  resetBtn.dataset.armed = '1';
915
955
  label.textContent = 'Sure?';
@@ -201,3 +201,13 @@
201
201
  #reset { color: var(--danger); }
202
202
  #reset:hover { border-color: var(--danger); box-shadow: 0 0 14px var(--danger-border); }
203
203
  .dlg .seg button { display: grid; place-items: center; }
204
+
205
+ /* Toaster (shadcn/ui sonner-style, dependency-free) */
206
+ .toaster { position: fixed; inset: auto 16px 16px auto; z-index: 100; display: flex; flex-direction: column; gap: 8px; width: min(360px, calc(100vw - 32px)); border: 0; padding: 0; background: transparent; overflow: visible; }
207
+ .toast { display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px; border: 1px solid var(--line); background: var(--panel); border-radius: 12px; box-shadow: var(--shadow-xs), 0 8px 24px rgba(0, 0, 0, .22); animation: toast-in .18s ease-out; }
208
+ .toast.out { animation: toast-out .18s ease-in forwards; }
209
+ .toast-icon { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; background: var(--accent-soft); color: var(--accent); flex: none; }
210
+ .toast-title { font-size: 13px; font-weight: 600; margin: 0; }
211
+ .toast-desc { font-size: 12px; color: var(--dim); margin: 1px 0 0; }
212
+ @keyframes toast-in { from { opacity: 0; transform: translateY(8px) scale(.98); } }
213
+ @keyframes toast-out { to { opacity: 0; transform: translateY(6px) scale(.98); } }
@@ -83,17 +83,13 @@
83
83
  <p class="dlg-name">Session transcripts <span class="dlg-own">host-owned · never touched</span></p>
84
84
  <p class="mono dlg-file" id="pathSessions" title="">–</p>
85
85
  </div>
86
- <div class="dlg-path">
87
- <p class="dlg-name">RTK history <span class="dlg-own">rtk-owned · never touched</span></p>
88
- <p class="mono dlg-file" id="pathRtk" title="">–</p>
89
- </div>
90
86
  </div>
91
87
  <div class="dlg-danger">
92
88
  <p class="dlg-label">Danger zone</p>
93
89
  <div class="dlg-row">
94
90
  <div class="min-w-0">
95
91
  <p class="dlg-name">Reset statistics</p>
96
- <p class="dlg-hint">Clears the usage ledger. Click twice to confirm.</p>
92
+ <p class="dlg-hint">Clears tersio statistics. Host-owned files stay intact. Click twice to confirm.</p>
97
93
  </div>
98
94
  <button id="reset" class="btn-push mono flex shrink-0 items-center gap-2 text-xs pl-2.5 pr-3 py-2 rounded-xl" style="border: 1px solid var(--danger-border)" aria-label="Reset tersio statistics">
99
95
  <i data-lucide="rotate-ccw" class="size-3.5"></i><span id="resetLabel">Reset</span>
@@ -292,7 +288,7 @@
292
288
  </span>
293
289
  <span class="ml-auto flex items-center gap-1" id="cmdPages" role="navigation" aria-label="Pages"></span>
294
290
  </div>
295
- <p class="mono text-[11px] mt-3" style="color: var(--dim)">saved / avg / time are RTK-metered per command — session tools without metering show –. caveman + ponytail gains are instruction-following, bench-measured in BENCHMARK.md.</p>
291
+ <p class="mono text-[11px] mt-3" style="color: var(--dim)">bash commands run through the rtk-wired OMP hook meter automatically; rows without metering show –. caveman + ponytail gains are instruction-following, bench-measured in BENCHMARK.md.</p>
296
292
  </section>
297
293
 
298
294
  <footer class="mono text-xs mt-12 pt-4" style="color: var(--dim); border-top: 1px solid var(--line)">
@@ -3,8 +3,8 @@
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { createRequire } from 'node:module';
6
- import { activeModesSummary, asPromptArray, COMBO_LEVELS, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, normalizeComboLevel, reconcileSharedComboEntries, sessionEntries, setSharedComboLevel, setSharedComboListener, systemPromptIncludes, } from '../shared/session-state.js';
7
- import { readComboDefault } from '../shared/plugin-settings.js';
6
+ import { activeModesSummary, asPromptArray, COMBO_LEVELS, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, normalizeComboLevel, reconcileSharedComboEntries, sessionEntries, setSharedComboLevel, setSharedComboListener, setSharedComboMode, systemPromptIncludes, } from '../shared/session-state.js';
7
+ import { readComboDefault, readPonytailDefault } from '../shared/plugin-settings.js';
8
8
  const require = createRequire(import.meta.url);
9
9
  const PONYTAIL_FALLBACK_INTENSITY = {
10
10
  lite: 'Prefer the simplest correct solution.',
@@ -120,9 +120,13 @@ export default function comboToggleExtension(pi) {
120
120
  track(ctx);
121
121
  if (!ctx?.hasUI)
122
122
  syncStatus(ctx);
123
- // Installer/user-configured default applies only to a fresh session with
124
- // no persisted state — real session state always wins.
125
- if (getSharedComboState().level === 'off' && !sessionEntries(ctx).length) {
123
+ // Installer/user-configured default applies only when no persisted *mode*
124
+ // state exists — unrelated session entries must not block it, or the
125
+ // combo bar never paints on sessions that already carry other entries.
126
+ const entries = sessionEntries(ctx);
127
+ const hasModeState = entries.some((e) => e?.type === 'custom' && (e.customType === 'combo-level' || e.customType === 'caveman-mode' ||
128
+ e.customType === 'rtk-mode' || e.customType === 'ponytail-mode'));
129
+ if (getSharedComboState().level === 'off' && !hasModeState) {
126
130
  const fallback = readComboDefault();
127
131
  if (fallback !== 'off') {
128
132
  persistPreset(fallback);
@@ -130,6 +134,17 @@ export default function comboToggleExtension(pi) {
130
134
  ctx?.ui?.notify?.(`Combo default applied: ${fallback} — ${activeModesSummary(getSharedComboState())} active for this session.`, 'info');
131
135
  }
132
136
  }
137
+ // Standalone ponytail default: no sibling extension restores it (upstream
138
+ // owns the command), so apply it here when nothing persisted a
139
+ // ponytail-mode entry. Skipped when it matches the active preset — the
140
+ // preset entry already covers that, no redundant write.
141
+ if (!entries.some((e) => e?.type === 'custom' && e.customType === 'ponytail-mode')) {
142
+ const ponytailFallback = readPonytailDefault();
143
+ if (ponytailFallback !== 'off' && ponytailFallback !== getSharedComboState().ponytail) {
144
+ pi.appendEntry?.('ponytail-mode', { mode: ponytailFallback });
145
+ useState(setSharedComboMode('ponytail', ponytailFallback), ctx);
146
+ }
147
+ }
133
148
  });
134
149
  for (const event of ['session_branch', 'session_tree', 'agent_start']) {
135
150
  pi.on(event, async (_event, ctx) => {
@@ -31,6 +31,7 @@ export function readPluginSettings() {
31
31
  }
32
32
  const COMBO_LEVELS = new Set(['off', 'medium', 'balanced', 'max']);
33
33
  const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'wenyan']);
34
+ const PONYTAIL_MODES = new Set(['off', 'lite', 'full', 'ultra', 'review']);
34
35
  function readStringDefault(key, valid) {
35
36
  const raw = readPluginSettings()[key];
36
37
  return typeof raw === 'string' && valid.has(raw) ? raw : 'off';
@@ -45,3 +46,6 @@ export function readRtkDefault() {
45
46
  const raw = readPluginSettings().rtkDefault;
46
47
  return typeof raw === 'boolean' ? raw : false;
47
48
  }
49
+ export function readPonytailDefault() {
50
+ return readStringDefault('ponytailDefault', PONYTAIL_MODES);
51
+ }
@@ -1,25 +1,19 @@
1
- // extensions/shared/pricing.ts — model pricing with live LiteLLM refresh.
2
- // Lookup order: exact id in the live cache → built-in substring table → default.
3
- // Network only in refreshPrices (called from `tersio update`); every reader is
4
- // sync and offline-safe. Best-effort throughout: pricing never breaks a caller.
1
+ // extensions/shared/pricing.ts — dynamic model pricing from LiteLLM.
2
+ // Lookup order: exact id in the live cache → provider-prefix strip → default.
3
+ // The cache is the full LiteLLM feed (3k+ ids), refreshed lazily in background
4
+ // and on `tersio update`; every reader is sync and offline-safe. No static
5
+ // per-model table — list prices rot, the feed does not. Unknown models report
6
+ // the default with known:false so the dashboard labels them honestly.
5
7
  import fs from 'node:fs';
6
8
  import os from 'node:os';
7
9
  import path from 'node:path';
8
- // Rough list rates (USD per 1M tokens) for the models OMP sessions cite.
9
- // Refresh against LiteLLM pricing data when adding families.
10
- const PRICE_TABLE = [
11
- { match: ':free', price: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } },
12
- { match: 'opus', price: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 } },
13
- { match: 'sonnet', price: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 } },
14
- { match: 'haiku', price: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 } },
15
- { match: 'gpt', price: { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 2.5 } },
16
- { match: 'gemini', price: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 } },
17
- { match: 'glm', price: { input: 0.5, output: 1, cacheRead: 0.05, cacheWrite: 0.5 } },
18
- ];
19
- export const DEFAULT_PRICE = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 };
10
+ // Sonnet-class default for unpriced models; always paired with known:false.
11
+ export const DEFAULT_PRICE = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
20
12
  const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
13
+ // Live cache refreshes lazily: readers use the file even when stale, and only
14
+ // one refresh runs per process. `tersio update` still forces a fresh fetch.
21
15
  const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
22
- const KEEP_RE = /claude|sonnet|opus|haiku|gpt|gemini|glm|deepseek|qwen|kimi|mistral|grok|llama|muse|nemotron|ministral|codestral|qwen/i;
16
+ let refreshInflight = null;
23
17
  export function pricesCachePath() {
24
18
  const override = process.env.TERSIO_PRICES_FILE;
25
19
  if (override)
@@ -29,6 +23,23 @@ export function pricesCachePath() {
29
23
  export function pricesUrl() {
30
24
  return process.env.TERSIO_PRICES_URL || LITELLM_URL;
31
25
  }
26
+ function toPrice(t) {
27
+ return { input: t[0], output: t[1], cacheRead: t[2], cacheWrite: t[3] };
28
+ }
29
+ // Tolerate both cache shapes: compact tuples (new) and ModelPrice objects
30
+ // (written by older refreshes) — a version bump never orphans the cache.
31
+ function asPrice(v) {
32
+ if (Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) {
33
+ return toPrice(v);
34
+ }
35
+ if (v && typeof v === 'object') {
36
+ const o = v;
37
+ if (['input', 'output', 'cacheRead', 'cacheWrite'].every((k) => typeof o[k] === 'number' && Number.isFinite(o[k]) && o[k] >= 0)) {
38
+ return o;
39
+ }
40
+ }
41
+ return null;
42
+ }
32
43
  export function loadLivePrices() {
33
44
  let raw;
34
45
  try {
@@ -41,9 +52,17 @@ export function loadLivePrices() {
41
52
  const parsed = JSON.parse(raw);
42
53
  if (typeof parsed.fetchedAt !== 'number' || !parsed.exact || typeof parsed.exact !== 'object')
43
54
  return null;
44
- if (Date.now() - parsed.fetchedAt > CACHE_TTL_MS)
55
+ const exact = {};
56
+ for (const [k, v] of Object.entries(parsed.exact)) {
57
+ const p = asPrice(v);
58
+ if (p)
59
+ exact[k] = [p.input, p.output, p.cacheRead, p.cacheWrite];
60
+ }
61
+ if (!Object.keys(exact).length)
45
62
  return null;
46
- return { fetchedAt: parsed.fetchedAt, exact: parsed.exact };
63
+ // Stale entries stay usable; refreshPricesIfStale refreshes in background.
64
+ const deprecated = Array.isArray(parsed.deprecated) ? parsed.deprecated.filter((d) => typeof d === 'string') : [];
65
+ return { fetchedAt: parsed.fetchedAt, exact, deprecated };
47
66
  }
48
67
  catch {
49
68
  return null;
@@ -55,67 +74,90 @@ function num(v) {
55
74
  export function priceFor(model, live) {
56
75
  const table = live === undefined ? loadLivePrices() : live;
57
76
  if (table) {
58
- const hit = table.exact[model] ?? table.exact[model.toLowerCase()];
59
- if (hit && [hit.input, hit.output, hit.cacheRead, hit.cacheWrite].every((v) => typeof v === 'number')) {
77
+ const hit = asPrice(table.exact[model] ?? table.exact[model.toLowerCase()]);
78
+ if (hit)
60
79
  return { price: hit, known: true, live: true };
61
- }
62
- }
63
- const name = model.toLowerCase();
64
- for (const row of PRICE_TABLE) {
65
- if (name.includes(row.match))
66
- return { price: row.price, known: true, live: false };
80
+ // Provider-prefixed ids ("azure/gpt-4o", "dashscope/qwen-max"): match the
81
+ // first cached id that ends with the bare model name.
82
+ const name = model.toLowerCase();
83
+ const key = Object.keys(table.exact).find((k) => k.toLowerCase() === name || k.toLowerCase().endsWith(`/${name}`));
84
+ const prefixed = key ? asPrice(table.exact[key]) : null;
85
+ if (prefixed)
86
+ return { price: prefixed, known: true, live: true };
67
87
  }
68
88
  return { price: DEFAULT_PRICE, known: false, live: false };
69
89
  }
70
- // Fetch LiteLLM pricing, keep relevant families, write the cache. False on
71
- // any failure (offline, timeout, shape change) — callers fall back silently.
90
+ // Fetch LiteLLM pricing, keep every id with valid input/output rates, write
91
+ // the cache. False on any failure (offline, timeout, shape change) — callers
92
+ // fall back silently.
72
93
  export async function refreshPrices() {
73
- let res;
74
- try {
75
- const ctl = new AbortController();
76
- const timer = setTimeout(() => ctl.abort(), 15000);
94
+ if (refreshInflight)
95
+ return refreshInflight;
96
+ refreshInflight = (async () => {
97
+ let res;
77
98
  try {
78
- res = await fetch(pricesUrl(), { signal: ctl.signal });
99
+ const ctl = new AbortController();
100
+ const timer = setTimeout(() => ctl.abort(), 15000);
101
+ try {
102
+ res = await fetch(pricesUrl(), { signal: ctl.signal });
103
+ }
104
+ finally {
105
+ clearTimeout(timer);
106
+ }
107
+ if (!res.ok)
108
+ return false;
109
+ }
110
+ catch {
111
+ return false;
79
112
  }
80
- finally {
81
- clearTimeout(timer);
113
+ let data;
114
+ try {
115
+ data = (await res.json());
82
116
  }
83
- if (!res.ok)
117
+ catch {
84
118
  return false;
85
- }
86
- catch {
87
- return false;
88
- }
89
- let data;
90
- try {
91
- data = (await res.json());
92
- }
93
- catch {
94
- return false;
95
- }
96
- const exact = {};
97
- for (const [id, row] of Object.entries(data)) {
98
- if (!row || typeof row !== 'object' || !KEEP_RE.test(id))
99
- continue;
100
- const input = num(row.input_cost_per_token);
101
- const output = num(row.output_cost_per_token);
102
- if (input === null || output === null)
103
- continue;
104
- exact[id] = {
105
- input: input * 1e6,
106
- output: output * 1e6,
107
- cacheRead: (num(row.cache_read_input_token_cost) ?? input) * 1e6,
108
- cacheWrite: (num(row.cache_creation_input_token_cost) ?? input) * 1e6,
109
- };
110
- }
111
- if (!Object.keys(exact).length)
112
- return false;
119
+ }
120
+ const exact = {};
121
+ const deprecated = [];
122
+ for (const [id, row] of Object.entries(data)) {
123
+ if (!row || typeof row !== 'object')
124
+ continue;
125
+ const input = num(row.input_cost_per_token);
126
+ const output = num(row.output_cost_per_token);
127
+ if (input === null || output === null)
128
+ continue;
129
+ exact[id] = [
130
+ input * 1e6,
131
+ output * 1e6,
132
+ (num(row.cache_read_input_token_cost) ?? input) * 1e6,
133
+ (num(row.cache_creation_input_token_cost) ?? input) * 1e6,
134
+ ];
135
+ if (typeof row.deprecation_date === 'string' && row.deprecation_date)
136
+ deprecated.push(id);
137
+ }
138
+ if (!Object.keys(exact).length)
139
+ return false;
140
+ try {
141
+ fs.mkdirSync(path.dirname(pricesCachePath()), { recursive: true });
142
+ fs.writeFileSync(pricesCachePath(), JSON.stringify({ fetchedAt: Date.now(), exact, deprecated }), 'utf8');
143
+ return true;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ })();
113
149
  try {
114
- fs.mkdirSync(path.dirname(pricesCachePath()), { recursive: true });
115
- fs.writeFileSync(pricesCachePath(), JSON.stringify({ fetchedAt: Date.now(), exact }), 'utf8');
116
- return true;
150
+ return await refreshInflight;
117
151
  }
118
- catch {
119
- return false;
152
+ finally {
153
+ refreshInflight = null;
120
154
  }
121
155
  }
156
+ // Fire-and-forget refresh when the cache is stale or missing. Readers keep
157
+ // using the stale file (or the built-in table) — pricing never blocks.
158
+ export function refreshPricesIfStale() {
159
+ const live = loadLivePrices();
160
+ if (live && Date.now() - live.fetchedAt <= CACHE_TTL_MS)
161
+ return;
162
+ void refreshPrices().catch(() => false);
163
+ }
@@ -33,12 +33,18 @@ function query(db, sql) {
33
33
  .filter((line) => line.trim())
34
34
  .map((line) => line.split('\t'));
35
35
  }
36
- export function readRtkGain(limit = 10) {
36
+ export function readRtkGain(limit = 10, cutoffMs) {
37
37
  try {
38
38
  if (!fs.existsSync(rtkDbPath()))
39
39
  return EMPTY;
40
- const [[commands = '0', saved = '0', input = '0', avgPct = '0', totalMs = '0'] = []] = query(rtkDbPath(), 'SELECT COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(SUM(input_tokens),0), COALESCE(AVG(savings_pct),0), COALESCE(SUM(exec_time_ms),0) FROM commands;');
41
- const byCommand = query(rtkDbPath(), `SELECT rtk_cmd, COUNT(*), SUM(saved_tokens), AVG(savings_pct), AVG(exec_time_ms) FROM commands GROUP BY rtk_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`).map(([command, count, savedRow, pct, ms]) => ({
40
+ // View-level cutoff only: rows stay in rtk's own database untouched.
41
+ // strftime('%s') normalizes ISO-8601 timestamps to epoch seconds; rows
42
+ // with unparseable timestamps (0) drop out of a filtered view.
43
+ const where = cutoffMs && cutoffMs > 0
44
+ ? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
45
+ : '';
46
+ const [[commands = '0', saved = '0', input = '0', avgPct = '0', totalMs = '0'] = []] = query(rtkDbPath(), `SELECT COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(SUM(input_tokens),0), COALESCE(AVG(savings_pct),0), COALESCE(SUM(exec_time_ms),0) FROM commands ${where};`);
47
+ const byCommand = query(rtkDbPath(), `SELECT rtk_cmd, COUNT(*), SUM(saved_tokens), AVG(savings_pct), AVG(exec_time_ms) FROM commands ${where} GROUP BY rtk_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`).map(([command, count, savedRow, pct, ms]) => ({
42
48
  command,
43
49
  count: Number(count) || 0,
44
50
  saved: Number(savedRow) || 0,
@@ -11,6 +11,34 @@ export function ledgerPath() {
11
11
  return override;
12
12
  return path.join(os.homedir(), '.omp', 'plugins', 'tersio-usage.jsonl');
13
13
  }
14
+ // Reset watermark: tersio-owned timestamp marking the last statistics reset.
15
+ // Session transcripts and the RTK database are host/tool-owned and never
16
+ // touched — instead, every derived view (token stats, command tools) filters
17
+ // rows from before the watermark, so "reset" empties what tersio shows
18
+ // without deleting anything it does not own.
19
+ export function resetMarkerPath() {
20
+ const override = process.env.TERSIO_RESET_FILE;
21
+ if (override)
22
+ return override;
23
+ return path.join(os.homedir(), '.omp', 'plugins', 'tersio-reset.json');
24
+ }
25
+ export function readResetWatermark() {
26
+ try {
27
+ const raw = JSON.parse(fs.readFileSync(resetMarkerPath(), 'utf8'));
28
+ return typeof raw.ts === 'number' && Number.isFinite(raw.ts) && raw.ts > 0 ? raw.ts : 0;
29
+ }
30
+ catch {
31
+ return 0;
32
+ }
33
+ }
34
+ export function markReset(now = Date.now()) {
35
+ try {
36
+ fs.mkdirSync(path.dirname(resetMarkerPath()), { recursive: true });
37
+ fs.writeFileSync(resetMarkerPath(), JSON.stringify({ ts: now }) + '\n', 'utf8');
38
+ }
39
+ catch { /* best-effort; never break the caller */ }
40
+ return now;
41
+ }
14
42
  export function appendUsage(kind, detail) {
15
43
  const row = JSON.stringify({ ts: Date.now(), kind, detail }) + '\n';
16
44
  try {
@@ -71,6 +99,7 @@ function walkJsonl(dir, out, cap) {
71
99
  }
72
100
  }
73
101
  export function importSessionTokens() {
102
+ const watermark = readResetWatermark();
74
103
  const byModel = {};
75
104
  const byDay = {};
76
105
  const byDayModel = {};
@@ -88,13 +117,17 @@ export function importSessionTokens() {
88
117
  }
89
118
  let codexProvider = null;
90
119
  const recent = [];
120
+ // Rows from before the reset watermark (and rows with no usable timestamp,
121
+ // which may predate it) stay out of every derived statistic.
91
122
  function ingest(model, usage, ts) {
123
+ const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
124
+ if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
125
+ return false;
92
126
  addInto(totals, usage);
93
127
  byModel[model] ??= zeroBreakdown();
94
128
  addInto(byModel[model], usage);
95
129
  byModelMessages[model] = (byModelMessages[model] ?? 0) + 1;
96
130
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
97
- const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
98
131
  if (Number.isFinite(ms))
99
132
  recent.push({ m: model, i: num(usage.input), o: num(usage.output), t: ms });
100
133
  const day = ts !== undefined ? dayKey(ts) : null;
@@ -108,6 +141,7 @@ export function importSessionTokens() {
108
141
  }
109
142
  }
110
143
  messages += 1;
144
+ return true;
111
145
  }
112
146
  for (const file of files) {
113
147
  let text;
@@ -145,9 +179,11 @@ export function importSessionTokens() {
145
179
  if (!msg || msg.role !== 'assistant' || !msg.usage)
146
180
  continue;
147
181
  const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
148
- ingest(model, msg.usage, row.timestamp);
149
- if (typeof msg.usage.cost === 'number' && Number.isFinite(msg.usage.cost))
182
+ const counted = ingest(model, msg.usage, row.timestamp);
183
+ if (counted && typeof msg.usage.cost === 'number' && Number.isFinite(msg.usage.cost))
150
184
  costMeasured += msg.usage.cost;
185
+ if (!counted)
186
+ continue;
151
187
  for (const part of msg.content ?? []) {
152
188
  if (part?.type === 'toolCall' && typeof part.name === 'string' && part.name) {
153
189
  const key = part.name === 'bash' ? `bash:${leadBinary(part.arguments?.command)}` : part.name;
@@ -179,9 +215,9 @@ function leadBinary(command) {
179
215
  return 'bash';
180
216
  }
181
217
  // --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
182
- import { DEFAULT_PRICE, priceFor } from './pricing.js';
218
+ import { DEFAULT_PRICE, priceFor, refreshPricesIfStale } from './pricing.js';
183
219
  import { co2GramsFor, energyWhFor } from './carbon.js';
184
- export { DEFAULT_PRICE, priceFor };
220
+ export { DEFAULT_PRICE, priceFor, refreshPricesIfStale };
185
221
  export { co2GramsFor, energyWhFor };
186
222
  export function usdCost(t, model) {
187
223
  const { price, known } = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false };
@@ -1,25 +1,21 @@
1
- // /tersio unified root command — routes every tersio capability through one
2
- // namespaced entrypoint. Aliases (/caveman, /rtk, /combo, /ai-addons) keep
3
- // working: this router drives the same shared bridge + session entries the
4
- // sibling extensions restore from. Shared-state publishes sync their local
5
- // mirrors live, so switches notify and take effect next turn — no reload.
6
- import { COMBO_LEVELS, activeModesSummary, getSharedComboState, normalizeComboLevel, normalizeMode, reconcileSharedComboEntries, sessionEntries, setSharedComboLevel, setSharedComboMode, } from '../shared/session-state.js';
1
+ // /tersio root command — status, check, update, gain, usage, help.
2
+ // Mode switches live on their own commands (/caveman, /rtk, /combo, and the
3
+ // upstream /ponytail): the router keeps no redundant copies. Shared-state
4
+ // publishes sync sibling mirrors live, so switches notify and take effect
5
+ // next turn — no reload.
6
+ import { getSharedComboState, reconcileSharedComboEntries, sessionEntries, } from '../shared/session-state.js';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { appendUsage, readUsage } from '../shared/usage-ledger.js';
10
10
  import { checkAddonsSummary, runAddonUpdate } from '../ai-addons-updater/index.js';
11
11
  const HELP = [
12
- '/tersio caveman lite|full|ultra|wenyan|off — terse-reply mode',
13
- '/tersio combo off|medium|balanced|max — preset for all three modes',
14
- '/tersio rtk on|off — compact shell output',
15
- '/tersio ponytail off|lite|full|ultra|review — minimal code',
16
12
  '/tersio status — active modes + combo level',
17
13
  '/tersio check — add-on version check',
18
14
  '/tersio update <ponytail|rtk|caveman|all> [--dry-run]',
19
15
  '/tersio gain — savings summary + dashboard hint',
20
16
  '/tersio usage — ledger report for this machine',
21
17
  '/tersio help — this table',
22
- 'aliases (still work): /caveman /rtk /combo /ai-addons',
18
+ 'mode switches: /caveman /rtk /combo (tersio) + /ponytail (upstream)',
23
19
  ].join('\n');
24
20
  function statusLine() {
25
21
  const s = getSharedComboState();
@@ -56,14 +52,11 @@ async function openGainDashboard(pi, ctx) {
56
52
  }
57
53
  export default function tersioCommandsExtension(pi) {
58
54
  pi.setLabel?.('Tersio unified root command');
59
- // Mode switches never reload the session: entries persist, the shared
60
- // bridge publishes to every sibling mirror, and the change injects on the
61
- // next turn like a normal input message. Only notify below.
62
55
  pi.registerCommand?.('tersio', {
63
- description: 'Tersio root: caveman|combo|rtk|ponytail|status|check|update|gain|usage|help',
56
+ description: 'Tersio root: status|check|update|gain|usage|help (modes: /caveman /rtk /combo /ponytail)',
64
57
  handler: async (args, ctx) => {
65
58
  const parts = String(args || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
66
- const [sub, rest] = [parts[0], parts.slice(1).join(' ')];
59
+ const [sub] = [parts[0]];
67
60
  appendUsage('command', `/tersio ${parts.join(' ')}`.trim());
68
61
  if (!sub || sub === 'status') {
69
62
  if (ctx?.hasUI)
@@ -75,69 +68,6 @@ export default function tersioCommandsExtension(pi) {
75
68
  ctx?.ui?.notify?.(HELP, 'info');
76
69
  return;
77
70
  }
78
- if (sub === 'caveman') {
79
- const mode = normalizeMode('caveman', rest);
80
- if (!mode) {
81
- ctx?.ui?.notify?.('Usage: /tersio caveman [lite|full|ultra|wenyan|off|status]', 'warning');
82
- return;
83
- }
84
- pi.appendEntry?.('caveman-mode', { mode });
85
- setSharedComboMode('caveman', mode);
86
- appendUsage('toggle', `caveman=${mode}`);
87
- ctx?.ui?.notify?.(`Caveman ${mode} on — Active: ${activeModesSummary(getSharedComboState())}.`, 'info');
88
- return;
89
- }
90
- if (sub === 'rtk') {
91
- if (!rest || rest === 'status') {
92
- const on = getSharedComboState().rtk === 'on';
93
- ctx?.ui?.notify?.(`RTK: ${on ? 'on' : 'off'}`, 'info');
94
- return;
95
- }
96
- if (!['on', 'off'].includes(rest)) {
97
- ctx?.ui?.notify?.('Usage: /tersio rtk [on|off|status]', 'warning');
98
- return;
99
- }
100
- const enabled = rest === 'on';
101
- pi.appendEntry?.('rtk-mode', { enabled });
102
- setSharedComboMode('rtk', enabled);
103
- appendUsage('toggle', `rtk=${rest}`);
104
- ctx?.ui?.notify?.(`RTK ${rest} — Active: ${activeModesSummary(getSharedComboState())}.`, 'info');
105
- return;
106
- }
107
- if (sub === 'ponytail') {
108
- const mode = normalizeMode('ponytail', rest);
109
- if (!mode) {
110
- ctx?.ui?.notify?.('Usage: /tersio ponytail [off|lite|full|ultra|review|status]', 'warning');
111
- return;
112
- }
113
- pi.appendEntry?.('ponytail-mode', { mode });
114
- setSharedComboMode('ponytail', mode);
115
- appendUsage('toggle', `ponytail=${mode}`);
116
- ctx?.ui?.notify?.(`Ponytail ${mode} — Active: ${activeModesSummary(getSharedComboState())}.`, 'info');
117
- return;
118
- }
119
- if (sub === 'combo') {
120
- if (!rest || rest === 'status') {
121
- if (ctx?.hasUI)
122
- reconcileSharedComboEntries(sessionEntries(ctx));
123
- ctx?.ui?.notify?.(statusLine(), 'info');
124
- return;
125
- }
126
- const level = normalizeComboLevel(rest);
127
- if (!level) {
128
- ctx?.ui?.notify?.('Unknown combo level. Use: off | medium | balanced | max', 'warning');
129
- return;
130
- }
131
- const modes = COMBO_LEVELS[level];
132
- pi.appendEntry?.('caveman-mode', { mode: modes.caveman });
133
- pi.appendEntry?.('rtk-mode', { enabled: modes.rtk === 'on' });
134
- pi.appendEntry?.('ponytail-mode', { mode: modes.ponytail });
135
- pi.appendEntry?.('combo-level', { level });
136
- setSharedComboLevel(level);
137
- appendUsage('toggle', `combo=${level}`);
138
- ctx?.ui?.notify?.(`Combo ${level} on — ${activeModesSummary(getSharedComboState())} active for this session.`, 'info');
139
- return;
140
- }
141
71
  if (sub === 'check') {
142
72
  await checkAddonsSummary(ctx);
143
73
  return;
@@ -160,6 +90,12 @@ export default function tersioCommandsExtension(pi) {
160
90
  ctx?.ui?.notify?.(usageSummary(), 'info');
161
91
  return;
162
92
  }
93
+ // Removed mode switches redirect to their own commands so only one
94
+ // spelling exists to learn: /caveman, /rtk, /combo, /ponytail.
95
+ if (sub === 'caveman' || sub === 'rtk' || sub === 'combo' || sub === 'ponytail') {
96
+ ctx?.ui?.notify?.(`Use /${sub} instead — /tersio no longer duplicates mode switches.\n${HELP}`, 'warning');
97
+ return;
98
+ }
163
99
  ctx?.ui?.notify?.(`Unknown subcommand: ${sub}.\n${HELP}`, 'warning');
164
100
  },
165
101
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krtclcdy/tersio",
3
- "version": "2.13.0",
3
+ "version": "2.15.0",
4
4
  "description": "✂️ Token-saving OMP add-ons: terse Caveman replies, compact RTK shell output, lean Ponytail code calls — one-command combo presets.",
5
5
  "keywords": [
6
6
  "omp",