@krtclcdy/tersio 2.14.0 → 2.16.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/cli/update.js CHANGED
@@ -116,15 +116,28 @@ async function probeUpdatePlan(cliLatest) {
116
116
  ponytail: [ponytailLocalText ? parseJsonObject(ponytailLocalText)?.version ?? null : null, ponytailLatest],
117
117
  };
118
118
  }
119
- function planLine(name, current, latest) {
120
- if (!current && !latest)
121
- console.log(` ${name}: unknown`);
122
- else if (!latest)
123
- console.log(` ${name}: ${current} (latest unknown)`);
124
- else if (current === latest)
125
- console.log(` ${name}: ${current} (up to date)`);
126
- else
127
- console.log(` ${name}: ${current ?? 'missing'} → ${latest}`);
119
+ // Every add-on prints its status: version jump when stale, "up to date"
120
+ // otherwise, honest "unknown" when the probe cannot reach its source.
121
+ function planLines(plan) {
122
+ const stale = [];
123
+ const status = [];
124
+ const push = (name, current, latest) => {
125
+ if (!current && !latest)
126
+ status.push(`${name}: unknown`);
127
+ else if (!latest)
128
+ status.push(`${name}: ${current} (latest unknown)`);
129
+ else if (current === latest)
130
+ status.push(`${name}: ${current} (up to date)`);
131
+ else {
132
+ status.push(`${name}: ${current ?? 'missing'} → ${latest}`);
133
+ stale.push(`${name}: ${current ?? 'missing'} → ${latest}`);
134
+ }
135
+ };
136
+ push('Tersio', PACKAGE_VERSION, plan.cli);
137
+ push('RTK', plan.rtk[0], plan.rtk[1]);
138
+ push('Caveman rule', plan.rule[0], plan.rule[1]);
139
+ push('Ponytail', plan.ponytail[0], plan.ponytail[1]);
140
+ return { stale, status };
128
141
  }
129
142
  async function runLatestUpdate() {
130
143
  const forwardedArgs = ['--yes'];
@@ -133,7 +146,6 @@ async function runLatestUpdate() {
133
146
  if (verbose)
134
147
  forwardedArgs.push('--verbose');
135
148
  const npmCommand = IS_WINDOWS ? process.env.ComSpec || 'cmd.exe' : 'npm';
136
- console.log('=== Updating Tersio ===');
137
149
  // Resolve the target explicitly: `npm view --prefer-online` beats the local
138
150
  // metadata cache, so a stale `@latest` can never pin an older release.
139
151
  const cliLatest = await latestPublishedVersion();
@@ -141,37 +153,38 @@ async function runLatestUpdate() {
141
153
  // Current → latest per add-on. Best-effort; unreachable probes print as
142
154
  // unknown and never block the update.
143
155
  const plan = await probeUpdatePlan(cliLatest);
144
- planLine('Tersio', PACKAGE_VERSION, plan.cli);
145
- planLine('RTK', plan.rtk[0], plan.rtk[1]);
146
- planLine('Caveman rule', plan.rule[0], plan.rule[1]);
147
- planLine('Ponytail', plan.ponytail[0], plan.ponytail[1]);
148
- console.log('');
156
+ const { stale, status } = planLines(plan);
157
+ if (dryRun) {
158
+ console.log(`tersio update (dry-run):\n ${status.join('\n ')}`);
159
+ console.log(` [dry-run] would run: npm install -g ${PACKAGE_NAME}${target} --no-audit --no-fund --prefer-online`);
160
+ console.log(` [dry-run] would delegate: npm exec --yes --prefer-online --package=${PACKAGE_NAME}${target} -- tersio --apply-update ${forwardedArgs.join(' ')}`);
161
+ return;
162
+ }
163
+ console.log(`Checking for updates:\n ${status.join('\n ')}`);
164
+ if (stale.length === 0) {
165
+ console.log(`tersio ${PACKAGE_VERSION} — up to date`);
166
+ return;
167
+ }
168
+ console.log(`Updating ${stale.join(', ')}`);
149
169
  // The npx delegation below only refreshes the OMP-side files; the globally
150
170
  // installed CLI keeps its old version until npm -g runs. Refresh both.
151
171
  const globalArgs = IS_WINDOWS
152
172
  ? ['/d', '/s', '/c', 'npm', 'install', '-g', `${PACKAGE_NAME}${target}`, '--no-audit', '--no-fund', '--prefer-online']
153
173
  : ['install', '-g', `${PACKAGE_NAME}${target}`, '--no-audit', '--no-fund', '--prefer-online'];
154
- if (dryRun) {
155
- console.log(` [dry-run] would run: npm install -g ${PACKAGE_NAME}${target} --no-audit --no-fund --prefer-online`);
174
+ try {
175
+ const globalResult = await execNetwork(`Updating global Tersio CLI to ${PACKAGE_NAME}${target}`, npmCommand, globalArgs, {
176
+ timeout: 300000,
177
+ maxBuffer: 10 * 1024 * 1024,
178
+ windowsHide: true,
179
+ shell: false,
180
+ });
181
+ // npm -g echoes its own added/changed summary; only failures surface here.
182
+ if (globalResult.stderr)
183
+ process.stderr.write(globalResult.stderr);
156
184
  }
157
- else {
158
- try {
159
- const globalResult = await execNetwork(`Updating global Tersio CLI to ${PACKAGE_NAME}${target}`, npmCommand, globalArgs, {
160
- timeout: 300000,
161
- maxBuffer: 10 * 1024 * 1024,
162
- windowsHide: true,
163
- shell: false,
164
- });
165
- if (globalResult.stdout)
166
- process.stdout.write(globalResult.stdout);
167
- if (globalResult.stderr)
168
- process.stderr.write(globalResult.stderr);
169
- console.log(` [ok] CLI updated to ${PACKAGE_NAME}${target}`);
170
- }
171
- catch (e) {
172
- console.log(` [warn] Global CLI update skipped: ${e.message}`);
173
- console.log(` [hint] Manual: npm install -g ${PACKAGE_NAME}${target} --no-audit --no-fund --prefer-online`);
174
- }
185
+ catch (e) {
186
+ console.log(` [warn] Global CLI update skipped: ${e.message}`);
187
+ console.log(` [hint] Manual: npm install -g ${PACKAGE_NAME}${target} --no-audit --no-fund --prefer-online`);
175
188
  }
176
189
  const npmArgs = [
177
190
  'exec',
@@ -184,23 +197,22 @@ async function runLatestUpdate() {
184
197
  ...forwardedArgs,
185
198
  ];
186
199
  const npmCommandArgs = IS_WINDOWS ? ['/d', '/s', '/c', 'npm', ...npmArgs] : npmArgs;
187
- // Inherited stdio, no outer spinner: the delegated installer renders its
188
- // own Clack UI live, which capture-then-dump would corrupt into stray bars.
189
- console.log(` Running ${PACKAGE_NAME}${target} installer...`);
200
+ // Inherited stdio, no outer spinner. The delegated installer runs quiet
201
+ // (banner and per-file writes suppressed); this parent owns both the plan
202
+ // line above and the closing summary below — one voice, no repeats.
190
203
  try {
191
204
  await execInherit(npmCommand, npmCommandArgs);
192
- console.log('\n=== Update complete ===');
205
+ console.log(`Done — tersio ${plan.cli ?? PACKAGE_VERSION}. Restart OMP.`);
193
206
  if (!dryRun) {
194
207
  try {
195
- if (await refreshPrices())
196
- console.log(' [ok] model price table refreshed');
208
+ await refreshPrices();
197
209
  }
198
210
  catch { /* pricing is best-effort; update already succeeded */ }
199
211
  }
200
212
  }
201
213
  catch (e) {
202
214
  const err = e;
203
- console.error(`\n[fail] Could not run ${PACKAGE_NAME}${target}: ${err.message}`);
215
+ console.error(`[fail] Could not run ${PACKAGE_NAME}${target}: ${err.message}`);
204
216
  process.exitCode = 1;
205
217
  }
206
218
  }
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.16.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",