@krtclcdy/tersio 2.14.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
 
@@ -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/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,10 +442,41 @@ 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.
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
+ }
447
476
  async function resolveProfile(forceReinstall = false) {
448
- const profile = defaultProfile();
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
@@ -456,7 +487,7 @@ async function resolveProfile(forceReinstall = false) {
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 {
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, readResetWatermark, readUsage, sessionsDir, usdCost, } from '../extensions/shared/usage-ledger.js';
2
+ import { co2GramsFor, energyWhFor, importSessionTokens, ledgerPath, priceFor, readResetWatermark, readUsage, refreshPricesIfStale, sessionsDir, usdCost, } from '../extensions/shared/usage-ledger.js';
3
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) {
package/dashboard/app.js CHANGED
@@ -879,14 +879,29 @@
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(); });
890
905
  function toast(title, desc, icon) {
891
906
  var wrap = document.querySelector('.toaster');
892
907
  if (!wrap) {
@@ -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
+ }
@@ -215,9 +215,9 @@ function leadBinary(command) {
215
215
  return 'bash';
216
216
  }
217
217
  // --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
218
- import { DEFAULT_PRICE, priceFor } from './pricing.js';
218
+ import { DEFAULT_PRICE, priceFor, refreshPricesIfStale } from './pricing.js';
219
219
  import { co2GramsFor, energyWhFor } from './carbon.js';
220
- export { DEFAULT_PRICE, priceFor };
220
+ export { DEFAULT_PRICE, priceFor, refreshPricesIfStale };
221
221
  export { co2GramsFor, energyWhFor };
222
222
  export function usdCost(t, model) {
223
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.14.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",