@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/README.md +9 -27
- package/cli/banner.js +9 -2
- package/cli/common.js +17 -10
- package/cli/doctor.js +52 -60
- package/cli/install.js +144 -115
- package/cli/rtk-wiring.js +66 -8
- package/cli/update.js +54 -42
- package/cli/usage.js +2 -1
- package/dashboard/app.js +16 -1
- package/extensions/combo-toggle/index.js +20 -5
- package/extensions/shared/plugin-settings.js +4 -0
- package/extensions/shared/pricing.js +112 -70
- package/extensions/shared/usage-ledger.js +2 -2
- package/extensions/tersio-commands/index.js +15 -79
- package/package.json +1 -1
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
188
|
-
//
|
|
189
|
-
|
|
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(
|
|
205
|
+
console.log(`Done — tersio ${plan.cli ?? PACKAGE_VERSION}. Restart OMP.`);
|
|
193
206
|
if (!dryRun) {
|
|
194
207
|
try {
|
|
195
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
124
|
-
//
|
|
125
|
-
|
|
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
|
|
2
|
-
// Lookup order: exact id in the live cache →
|
|
3
|
-
//
|
|
4
|
-
// sync and offline-safe.
|
|
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
|
-
//
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
71
|
-
// any failure (offline, timeout, shape change) — callers
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
94
|
+
if (refreshInflight)
|
|
95
|
+
return refreshInflight;
|
|
96
|
+
refreshInflight = (async () => {
|
|
97
|
+
let res;
|
|
77
98
|
try {
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
113
|
+
let data;
|
|
114
|
+
try {
|
|
115
|
+
data = (await res.json());
|
|
82
116
|
}
|
|
83
|
-
|
|
117
|
+
catch {
|
|
84
118
|
return false;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
exact
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
fs.writeFileSync(pricesCachePath(), JSON.stringify({ fetchedAt: Date.now(), exact }), 'utf8');
|
|
116
|
-
return true;
|
|
150
|
+
return await refreshInflight;
|
|
117
151
|
}
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// sibling
|
|
5
|
-
//
|
|
6
|
-
import {
|
|
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
|
-
'
|
|
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:
|
|
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
|
|
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