@krtclcdy/tersio 2.22.1 → 2.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -46
- package/cli/agent-hosts.js +42 -0
- package/cli/agents.js +266 -0
- package/cli/common.js +55 -55
- package/cli/currency.js +8 -12
- package/cli/dashboard.js +76 -49
- package/cli/doctor-fix.js +61 -44
- package/cli/doctor.js +66 -44
- package/cli/hosts.js +118 -0
- package/cli/install.js +187 -143
- package/cli/interactive.js +27 -1
- package/cli/layer.js +24 -0
- package/cli/manifest.js +36 -0
- package/cli/omp-layer.js +51 -0
- package/cli/pi-layer.js +37 -0
- package/cli/pi-wiring.js +92 -0
- package/cli/profile.js +45 -46
- package/cli/reset.js +3 -26
- package/cli/rtk-wiring.js +10 -15
- package/cli/settings.js +1 -1
- package/cli/uninstall.js +135 -44
- package/cli/update.js +24 -30
- package/cli/usage.js +6 -1
- package/dashboard/dist/index.html +16 -16
- package/extensions/ai-addons-updater/index.js +36 -25
- package/extensions/ai-addons-updater/index.ts +36 -24
- package/extensions/caveman-session/index.js +23 -18
- package/extensions/caveman-session/index.ts +23 -18
- package/extensions/caveman-session/rule.md +90 -0
- package/extensions/combo-toggle/index.js +63 -41
- package/extensions/combo-toggle/index.ts +61 -48
- package/extensions/lib/utils.js +56 -13
- package/extensions/lib/utils.ts +55 -13
- package/extensions/rtk-session/index.js +20 -19
- package/extensions/rtk-session/index.ts +18 -24
- package/extensions/shared/carbon.js +5 -8
- package/extensions/shared/carbon.ts +5 -8
- package/extensions/shared/host.js +68 -0
- package/extensions/shared/host.ts +104 -0
- package/extensions/shared/omp-prompt.js +12 -0
- package/extensions/shared/plugin-settings.js +63 -16
- package/extensions/shared/plugin-settings.ts +67 -17
- package/extensions/shared/pricing.js +26 -15
- package/extensions/shared/pricing.ts +28 -16
- package/extensions/shared/rtk-gain.js +13 -24
- package/extensions/shared/rtk-gain.ts +14 -25
- package/extensions/shared/session-state.js +26 -28
- package/extensions/shared/session-state.ts +26 -29
- package/extensions/shared/types.ts +15 -3
- package/extensions/shared/usage-ledger.js +136 -38
- package/extensions/shared/usage-ledger.ts +149 -52
- package/extensions/shared/usage-store.js +14 -12
- package/extensions/shared/usage-store.ts +16 -14
- package/extensions/tersio-commands/index.js +2 -1
- package/extensions/tersio-commands/index.ts +2 -1
- package/package.json +15 -7
- package/tersio.js +6 -6
- package/extensions/shared/mode-reinforcement.js +0 -26
- package/extensions/shared/mode-reinforcement.ts +0 -29
|
@@ -1,22 +1,31 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
1
|
+
// Settings reader for the Tersio extensions.
|
|
2
|
+
//
|
|
3
|
+
// One file, under Tersio's own home (~/.tersio/settings.json), serves every
|
|
4
|
+
// host: OMP has no reason to keep its own copy and pi never had a store. The
|
|
5
|
+
// profile CLI writes the same file, so the extensions, `tersio settings`, and
|
|
6
|
+
// both hosts always agree.
|
|
7
|
+
//
|
|
8
|
+
// Tolerant: any parse failure yields {} so every caller falls back to its own
|
|
9
|
+
// default.
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
11
|
import os from 'node:os';
|
|
8
12
|
import path from 'node:path';
|
|
13
|
+
import { tersioHome } from '../lib/utils.js';
|
|
9
14
|
export const PLUGIN_NAME = '@krtclcdy/tersio';
|
|
10
|
-
|
|
15
|
+
// Session-start defaults, shared by every host: one file under Tersio's own
|
|
16
|
+
// home, so OMP and pi read the same values. This module owns the path.
|
|
17
|
+
export function tersioSettingsFile() {
|
|
18
|
+
return path.join(tersioHome(), 'settings.json');
|
|
19
|
+
}
|
|
20
|
+
// Older installs stored the same values as OMP plugin settings, and an install
|
|
21
|
+
// that predates ~/.tersio still has them there. Read once and keep them, so
|
|
22
|
+
// switching the store does not reset a user's defaults.
|
|
23
|
+
function legacyOmpSettings() {
|
|
11
24
|
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
12
|
-
|
|
25
|
+
for (const p of [
|
|
13
26
|
path.join(os.homedir(), '.omp', 'plugins', 'omp-plugins.lock.json'),
|
|
14
27
|
path.join(configHome, 'omp', 'plugins', 'omp-plugins.lock.json'),
|
|
15
|
-
]
|
|
16
|
-
}
|
|
17
|
-
// `settings[PLUGIN_NAME]` in the lock file; empty object when missing.
|
|
18
|
-
export function readPluginSettings() {
|
|
19
|
-
for (const p of lockPaths()) {
|
|
28
|
+
]) {
|
|
20
29
|
if (!existsSync(p))
|
|
21
30
|
continue;
|
|
22
31
|
try {
|
|
@@ -27,11 +36,27 @@ export function readPluginSettings() {
|
|
|
27
36
|
}
|
|
28
37
|
catch { /* tolerate corrupt file */ }
|
|
29
38
|
}
|
|
30
|
-
return
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function readSettingsFile() {
|
|
42
|
+
const p = tersioSettingsFile();
|
|
43
|
+
if (!existsSync(p))
|
|
44
|
+
return null;
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
47
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// The stored settings; empty when the file is missing, corrupt, or empty.
|
|
54
|
+
export function readPluginSettings() {
|
|
55
|
+
return readSettingsFile() ?? legacyOmpSettings() ?? {};
|
|
31
56
|
}
|
|
32
57
|
const COMBO_LEVELS = new Set(['off', 'medium', 'balanced', 'max']);
|
|
33
|
-
const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'wenyan']);
|
|
34
|
-
const PONYTAIL_MODES = new Set(['off', 'lite', 'full', 'ultra'
|
|
58
|
+
const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']);
|
|
59
|
+
const PONYTAIL_MODES = new Set(['off', 'lite', 'full', 'ultra']);
|
|
35
60
|
function readStringDefault(key, valid) {
|
|
36
61
|
const raw = readPluginSettings()[key];
|
|
37
62
|
return typeof raw === 'string' && valid.has(raw) ? raw : 'off';
|
|
@@ -49,3 +74,25 @@ export function readRtkDefault() {
|
|
|
49
74
|
export function readPonytailDefault() {
|
|
50
75
|
return readStringDefault('ponytailDefault', PONYTAIL_MODES);
|
|
51
76
|
}
|
|
77
|
+
export function isComboSetupComplete() {
|
|
78
|
+
return readPluginSettings().comboSetupComplete === true;
|
|
79
|
+
}
|
|
80
|
+
const COMBO_MODE_DEFAULTS = {
|
|
81
|
+
off: { cavemanDefault: 'off', rtkDefault: false, ponytailDefault: 'off' },
|
|
82
|
+
medium: { cavemanDefault: 'lite', rtkDefault: true, ponytailDefault: 'lite' },
|
|
83
|
+
balanced: { cavemanDefault: 'full', rtkDefault: true, ponytailDefault: 'full' },
|
|
84
|
+
max: { cavemanDefault: 'ultra', rtkDefault: true, ponytailDefault: 'ultra' },
|
|
85
|
+
};
|
|
86
|
+
export function saveComboSetup(level) {
|
|
87
|
+
if (!(level in COMBO_MODE_DEFAULTS))
|
|
88
|
+
return false;
|
|
89
|
+
const file = tersioSettingsFile();
|
|
90
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
91
|
+
writeFileSync(file, `${JSON.stringify({
|
|
92
|
+
...readPluginSettings(),
|
|
93
|
+
comboDefault: level,
|
|
94
|
+
...COMBO_MODE_DEFAULTS[level],
|
|
95
|
+
comboSetupComplete: true,
|
|
96
|
+
}, null, 2)}\n`, 'utf8');
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
@@ -1,25 +1,35 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Settings reader for the Tersio extensions.
|
|
2
|
+
//
|
|
3
|
+
// One file, under Tersio's own home (~/.tersio/settings.json), serves every
|
|
4
|
+
// host: OMP has no reason to keep its own copy and pi never had a store. The
|
|
5
|
+
// profile CLI writes the same file, so the extensions, `tersio settings`, and
|
|
6
|
+
// both hosts always agree.
|
|
7
|
+
//
|
|
8
|
+
// Tolerant: any parse failure yields {} so every caller falls back to its own
|
|
9
|
+
// default.
|
|
6
10
|
|
|
7
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
12
|
import os from 'node:os';
|
|
9
13
|
import path from 'node:path';
|
|
14
|
+
import { tersioHome } from '../lib/utils.ts';
|
|
15
|
+
|
|
10
16
|
export const PLUGIN_NAME = '@krtclcdy/tersio';
|
|
11
17
|
|
|
12
|
-
|
|
18
|
+
// Session-start defaults, shared by every host: one file under Tersio's own
|
|
19
|
+
// home, so OMP and pi read the same values. This module owns the path.
|
|
20
|
+
export function tersioSettingsFile(): string {
|
|
21
|
+
return path.join(tersioHome(), 'settings.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Older installs stored the same values as OMP plugin settings, and an install
|
|
25
|
+
// that predates ~/.tersio still has them there. Read once and keep them, so
|
|
26
|
+
// switching the store does not reset a user's defaults.
|
|
27
|
+
function legacyOmpSettings(): Record<string, unknown> | null {
|
|
13
28
|
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
14
|
-
|
|
29
|
+
for (const p of [
|
|
15
30
|
path.join(os.homedir(), '.omp', 'plugins', 'omp-plugins.lock.json'),
|
|
16
31
|
path.join(configHome, 'omp', 'plugins', 'omp-plugins.lock.json'),
|
|
17
|
-
]
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// `settings[PLUGIN_NAME]` in the lock file; empty object when missing.
|
|
21
|
-
export function readPluginSettings(): Record<string, unknown> {
|
|
22
|
-
for (const p of lockPaths()) {
|
|
32
|
+
]) {
|
|
23
33
|
if (!existsSync(p)) continue;
|
|
24
34
|
try {
|
|
25
35
|
const config = JSON.parse(readFileSync(p, 'utf8')) as { settings?: Record<string, Record<string, unknown>> };
|
|
@@ -27,12 +37,28 @@ export function readPluginSettings(): Record<string, unknown> {
|
|
|
27
37
|
if (values && typeof values === 'object' && !Array.isArray(values)) return values;
|
|
28
38
|
} catch { /* tolerate corrupt file */ }
|
|
29
39
|
}
|
|
30
|
-
return
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readSettingsFile(): Record<string, unknown> | null {
|
|
44
|
+
const p = tersioSettingsFile();
|
|
45
|
+
if (!existsSync(p)) return null;
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown>;
|
|
48
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The stored settings; empty when the file is missing, corrupt, or empty.
|
|
55
|
+
export function readPluginSettings(): Record<string, unknown> {
|
|
56
|
+
return readSettingsFile() ?? legacyOmpSettings() ?? {};
|
|
31
57
|
}
|
|
32
58
|
|
|
33
59
|
const COMBO_LEVELS = new Set(['off', 'medium', 'balanced', 'max']);
|
|
34
|
-
const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'wenyan']);
|
|
35
|
-
const PONYTAIL_MODES = new Set(['off', 'lite', 'full', 'ultra'
|
|
60
|
+
const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']);
|
|
61
|
+
const PONYTAIL_MODES = new Set(['off', 'lite', 'full', 'ultra']);
|
|
36
62
|
|
|
37
63
|
function readStringDefault(key: string, valid: Set<string>): string {
|
|
38
64
|
const raw = readPluginSettings()[key];
|
|
@@ -55,3 +81,27 @@ export function readRtkDefault(): boolean {
|
|
|
55
81
|
export function readPonytailDefault(): string {
|
|
56
82
|
return readStringDefault('ponytailDefault', PONYTAIL_MODES);
|
|
57
83
|
}
|
|
84
|
+
|
|
85
|
+
export function isComboSetupComplete(): boolean {
|
|
86
|
+
return readPluginSettings().comboSetupComplete === true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const COMBO_MODE_DEFAULTS = {
|
|
90
|
+
off: { cavemanDefault: 'off', rtkDefault: false, ponytailDefault: 'off' },
|
|
91
|
+
medium: { cavemanDefault: 'lite', rtkDefault: true, ponytailDefault: 'lite' },
|
|
92
|
+
balanced: { cavemanDefault: 'full', rtkDefault: true, ponytailDefault: 'full' },
|
|
93
|
+
max: { cavemanDefault: 'ultra', rtkDefault: true, ponytailDefault: 'ultra' },
|
|
94
|
+
} as const;
|
|
95
|
+
|
|
96
|
+
export function saveComboSetup(level: string): boolean {
|
|
97
|
+
if (!(level in COMBO_MODE_DEFAULTS)) return false;
|
|
98
|
+
const file = tersioSettingsFile();
|
|
99
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
100
|
+
writeFileSync(file, `${JSON.stringify({
|
|
101
|
+
...readPluginSettings(),
|
|
102
|
+
comboDefault: level,
|
|
103
|
+
...COMBO_MODE_DEFAULTS[level as keyof typeof COMBO_MODE_DEFAULTS],
|
|
104
|
+
comboSetupComplete: true,
|
|
105
|
+
}, null, 2)}\n`, 'utf8');
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
// extensions/shared/pricing.ts — dynamic model pricing from LiteLLM.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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.
|
|
2
|
+
// Exact id → provider-prefix strip → default, which reports with known:false so
|
|
3
|
+
// the Dashboard labels an unpriced model honestly. No static table: prices rot.
|
|
7
4
|
import fs from 'node:fs';
|
|
8
5
|
import path from 'node:path';
|
|
9
6
|
import { tersioDataPath } from '../lib/utils.js';
|
|
10
7
|
// Sonnet-class default for unpriced models; always paired with known:false.
|
|
11
8
|
export const DEFAULT_PRICE = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
|
|
12
9
|
const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
|
|
13
|
-
//
|
|
14
|
-
//
|
|
10
|
+
// Readers use the file even when stale, and only one refresh runs per process;
|
|
11
|
+
// `tersio update` still forces a fresh fetch.
|
|
15
12
|
const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
|
|
16
13
|
let refreshInflight = null;
|
|
17
14
|
export function pricesCachePath() {
|
|
@@ -26,8 +23,8 @@ export function pricesUrl() {
|
|
|
26
23
|
function toPrice(t) {
|
|
27
24
|
return { input: t[0], output: t[1], cacheRead: t[2], cacheWrite: t[3] };
|
|
28
25
|
}
|
|
29
|
-
// Tolerate both cache shapes
|
|
30
|
-
//
|
|
26
|
+
// Tolerate both cache shapes (compact tuples and ModelPrice objects) so a
|
|
27
|
+
// version bump never orphans the cache.
|
|
31
28
|
function asPrice(v) {
|
|
32
29
|
if (Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) {
|
|
33
30
|
return toPrice(v);
|
|
@@ -35,6 +32,8 @@ function asPrice(v) {
|
|
|
35
32
|
if (v && typeof v === 'object') {
|
|
36
33
|
const o = v;
|
|
37
34
|
if (['input', 'output', 'cacheRead', 'cacheWrite'].every((k) => typeof o[k] === 'number' && Number.isFinite(o[k]) && o[k] >= 0)) {
|
|
35
|
+
// SAFETY: the line above verified all four keys are finite numbers >= 0,
|
|
36
|
+
// which is the whole of ModelPrice.
|
|
38
37
|
return o;
|
|
39
38
|
}
|
|
40
39
|
}
|
|
@@ -71,15 +70,28 @@ export function loadLivePrices() {
|
|
|
71
70
|
function num(v) {
|
|
72
71
|
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
|
|
73
72
|
}
|
|
73
|
+
// One model, two spellings: transcripts say `space-bunny`, the feed keys it
|
|
74
|
+
// `openrouter/stealth/space-bunny-alpha` at zero. Aliases fold to the first, so
|
|
75
|
+
// a free stealth model never reports the default price.
|
|
76
|
+
export const MODEL_ALIASES = [
|
|
77
|
+
{ id: 'space-bunny', feed: 'stealth/space-bunny-alpha' },
|
|
78
|
+
];
|
|
79
|
+
export function canonicalPriceId(model) {
|
|
80
|
+
const key = model.toLowerCase();
|
|
81
|
+
return MODEL_ALIASES.find((a) => a.id === key || a.feed === key)?.id ?? key;
|
|
82
|
+
}
|
|
74
83
|
export function priceFor(model, live) {
|
|
75
84
|
const table = live === undefined ? loadLivePrices() : live;
|
|
85
|
+
// An alias goes through the same exact-then-suffix chain as any other id, so
|
|
86
|
+
// it still resolves when the feed keys it under a provider prefix.
|
|
87
|
+
const id = MODEL_ALIASES.find((a) => a.id === model.toLowerCase())?.feed ?? model;
|
|
76
88
|
if (table) {
|
|
77
|
-
const hit = asPrice(table.exact[
|
|
89
|
+
const hit = asPrice(table.exact[id] ?? table.exact[id.toLowerCase()]);
|
|
78
90
|
if (hit)
|
|
79
91
|
return { price: hit, known: true, live: true };
|
|
80
92
|
// Provider-prefixed ids ("azure/gpt-4o", "dashscope/qwen-max"): match the
|
|
81
93
|
// first cached id that ends with the bare model name.
|
|
82
|
-
const name =
|
|
94
|
+
const name = id.toLowerCase();
|
|
83
95
|
const key = Object.keys(table.exact).find((k) => k.toLowerCase() === name || k.toLowerCase().endsWith(`/${name}`));
|
|
84
96
|
const prefixed = key ? asPrice(table.exact[key]) : null;
|
|
85
97
|
if (prefixed)
|
|
@@ -87,8 +99,7 @@ export function priceFor(model, live) {
|
|
|
87
99
|
}
|
|
88
100
|
return { price: DEFAULT_PRICE, known: false, live: false };
|
|
89
101
|
}
|
|
90
|
-
// Fetch LiteLLM pricing
|
|
91
|
-
// the cache. False on any failure (offline, timeout, shape change) — callers
|
|
102
|
+
// Fetch LiteLLM pricing and write the cache. False on any failure — callers
|
|
92
103
|
// fall back silently.
|
|
93
104
|
export async function refreshPrices() {
|
|
94
105
|
if (refreshInflight)
|
|
@@ -153,8 +164,8 @@ export async function refreshPrices() {
|
|
|
153
164
|
refreshInflight = null;
|
|
154
165
|
}
|
|
155
166
|
}
|
|
156
|
-
// Fire-and-forget refresh when the cache is stale
|
|
157
|
-
//
|
|
167
|
+
// Fire-and-forget refresh when the cache is stale; readers keep the stale
|
|
168
|
+
// file, so pricing never blocks.
|
|
158
169
|
export function refreshPricesIfStale() {
|
|
159
170
|
const live = loadLivePrices();
|
|
160
171
|
if (live && Date.now() - live.fetchedAt <= CACHE_TTL_MS)
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
// extensions/shared/pricing.ts — dynamic model pricing from LiteLLM.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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.
|
|
2
|
+
// Exact id → provider-prefix strip → default, which reports with known:false so
|
|
3
|
+
// the Dashboard labels an unpriced model honestly. No static table: prices rot.
|
|
7
4
|
import fs from 'node:fs';
|
|
8
|
-
import os from 'node:os';
|
|
9
5
|
import path from 'node:path';
|
|
10
6
|
import { tersioDataPath } from '../lib/utils.ts';
|
|
11
7
|
|
|
@@ -20,8 +16,8 @@ export interface ModelPrice {
|
|
|
20
16
|
export const DEFAULT_PRICE: ModelPrice = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
|
|
21
17
|
|
|
22
18
|
const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
|
|
23
|
-
//
|
|
24
|
-
//
|
|
19
|
+
// Readers use the file even when stale, and only one refresh runs per process;
|
|
20
|
+
// `tersio update` still forces a fresh fetch.
|
|
25
21
|
const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
|
|
26
22
|
let refreshInflight: Promise<boolean> | null = null;
|
|
27
23
|
|
|
@@ -47,8 +43,8 @@ function toPrice(t: [number, number, number, number]): ModelPrice {
|
|
|
47
43
|
return { input: t[0], output: t[1], cacheRead: t[2], cacheWrite: t[3] };
|
|
48
44
|
}
|
|
49
45
|
|
|
50
|
-
// Tolerate both cache shapes
|
|
51
|
-
//
|
|
46
|
+
// Tolerate both cache shapes (compact tuples and ModelPrice objects) so a
|
|
47
|
+
// version bump never orphans the cache.
|
|
52
48
|
function asPrice(v: unknown): ModelPrice | null {
|
|
53
49
|
if (Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) {
|
|
54
50
|
return toPrice(v as [number, number, number, number]);
|
|
@@ -56,6 +52,8 @@ function asPrice(v: unknown): ModelPrice | null {
|
|
|
56
52
|
if (v && typeof v === 'object') {
|
|
57
53
|
const o = v as Record<string, unknown>;
|
|
58
54
|
if (['input', 'output', 'cacheRead', 'cacheWrite'].every((k) => typeof o[k] === 'number' && Number.isFinite(o[k]) && (o[k] as number) >= 0)) {
|
|
55
|
+
// SAFETY: the line above verified all four keys are finite numbers >= 0,
|
|
56
|
+
// which is the whole of ModelPrice.
|
|
59
57
|
return o as unknown as ModelPrice;
|
|
60
58
|
}
|
|
61
59
|
}
|
|
@@ -90,22 +88,36 @@ function num(v: unknown): number | null {
|
|
|
90
88
|
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
|
|
91
89
|
}
|
|
92
90
|
|
|
91
|
+
// One model, two spellings: transcripts say `space-bunny`, the feed keys it
|
|
92
|
+
// `openrouter/stealth/space-bunny-alpha` at zero. Aliases fold to the first, so
|
|
93
|
+
// a free stealth model never reports the default price.
|
|
94
|
+
export const MODEL_ALIASES: ReadonlyArray<{ id: string; feed: string }> = [
|
|
95
|
+
{ id: 'space-bunny', feed: 'stealth/space-bunny-alpha' },
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
export function canonicalPriceId(model: string): string {
|
|
99
|
+
const key = model.toLowerCase();
|
|
100
|
+
return MODEL_ALIASES.find((a) => a.id === key || a.feed === key)?.id ?? key;
|
|
101
|
+
}
|
|
102
|
+
|
|
93
103
|
export function priceFor(model: string, live?: LivePrices | null): { price: ModelPrice; known: boolean; live: boolean } {
|
|
94
104
|
const table = live === undefined ? loadLivePrices() : live;
|
|
105
|
+
// An alias goes through the same exact-then-suffix chain as any other id, so
|
|
106
|
+
// it still resolves when the feed keys it under a provider prefix.
|
|
107
|
+
const id = MODEL_ALIASES.find((a) => a.id === model.toLowerCase())?.feed ?? model;
|
|
95
108
|
if (table) {
|
|
96
|
-
const hit = asPrice(table.exact[
|
|
109
|
+
const hit = asPrice(table.exact[id] ?? table.exact[id.toLowerCase()]);
|
|
97
110
|
if (hit) return { price: hit, known: true, live: true };
|
|
98
111
|
// Provider-prefixed ids ("azure/gpt-4o", "dashscope/qwen-max"): match the
|
|
99
112
|
// first cached id that ends with the bare model name.
|
|
100
|
-
const name =
|
|
113
|
+
const name = id.toLowerCase();
|
|
101
114
|
const key = Object.keys(table.exact).find((k) => k.toLowerCase() === name || k.toLowerCase().endsWith(`/${name}`));
|
|
102
115
|
const prefixed = key ? asPrice(table.exact[key]) : null;
|
|
103
116
|
if (prefixed) return { price: prefixed, known: true, live: true };
|
|
104
117
|
}
|
|
105
118
|
return { price: DEFAULT_PRICE, known: false, live: false };
|
|
106
119
|
}
|
|
107
|
-
// Fetch LiteLLM pricing
|
|
108
|
-
// the cache. False on any failure (offline, timeout, shape change) — callers
|
|
120
|
+
// Fetch LiteLLM pricing and write the cache. False on any failure — callers
|
|
109
121
|
// fall back silently.
|
|
110
122
|
export async function refreshPrices(): Promise<boolean> {
|
|
111
123
|
if (refreshInflight) return refreshInflight;
|
|
@@ -160,8 +172,8 @@ export async function refreshPrices(): Promise<boolean> {
|
|
|
160
172
|
}
|
|
161
173
|
}
|
|
162
174
|
|
|
163
|
-
// Fire-and-forget refresh when the cache is stale
|
|
164
|
-
//
|
|
175
|
+
// Fire-and-forget refresh when the cache is stale; readers keep the stale
|
|
176
|
+
// file, so pricing never blocks.
|
|
165
177
|
export function refreshPricesIfStale(): void {
|
|
166
178
|
const live = loadLivePrices();
|
|
167
179
|
if (live && Date.now() - live.fetchedAt <= CACHE_TTL_MS) return;
|
|
@@ -1,28 +1,18 @@
|
|
|
1
|
-
// extensions/shared/rtk-gain.ts — measured RTK savings from
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// shows measured figures, never estimates. Missing DB/CLI → empty, no throw.
|
|
6
|
-
//
|
|
7
|
-
// Caveman and Ponytail have no per-command counters (instruction-following
|
|
8
|
-
// isn't metered); their gains are bench-measured in BENCHMARK.md and cited
|
|
9
|
-
// as static figures wherever RTK rows appear.
|
|
10
|
-
//
|
|
11
|
-
// Rows group by command alone, machine-wide: one row per command no matter
|
|
12
|
-
// which repo ran it. Project filtering stays out — wasted column, same logic.
|
|
1
|
+
// extensions/shared/rtk-gain.ts — measured RTK savings read from rtk's own
|
|
2
|
+
// history.db with the sqlite3 CLI, so the dashboard shows measured figures and
|
|
3
|
+
// never estimates. Missing DB or CLI returns empty. Caveman and Ponytail are
|
|
4
|
+
// bench-measured in BENCHMARK.md instead: instruction-following is not metered.
|
|
13
5
|
import { execFileSync } from 'node:child_process';
|
|
14
6
|
import fs from 'node:fs';
|
|
15
7
|
import os from 'node:os';
|
|
16
8
|
import path from 'node:path';
|
|
17
9
|
const EMPTY = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// before it crosses the sqlite3 CLI and lands in every /data.json poll.
|
|
10
|
+
// The table shows one clipped cell, so bound the command before it crosses the
|
|
11
|
+
// sqlite3 CLI and lands in every /data.json poll.
|
|
21
12
|
const MAX_CMD_CHARS = 200;
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// first: the stored command keeps its newlines, only the read view flattens.
|
|
13
|
+
// RTK stores heredocs and `node -e` scripts verbatim, so one row can arrive as
|
|
14
|
+
// several lines and derail every row after it. The stored command keeps its
|
|
15
|
+
// newlines; only the read view flattens.
|
|
26
16
|
const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
|
|
27
17
|
export function rtkDbPath() {
|
|
28
18
|
const override = process.env.TERSIO_RTK_DB;
|
|
@@ -49,14 +39,13 @@ export function readRtkGain(limit = 10, cutoffMs) {
|
|
|
49
39
|
try {
|
|
50
40
|
if (!fs.existsSync(rtkDbPath()))
|
|
51
41
|
return EMPTY;
|
|
52
|
-
// View-level cutoff only:
|
|
53
|
-
//
|
|
54
|
-
// with unparseable timestamps (0) drop out of a filtered view.
|
|
42
|
+
// View-level cutoff only: rtk's own rows stay untouched. Timestamps that do
|
|
43
|
+
// not parse land on 0 and drop out of a filtered view.
|
|
55
44
|
const where = cutoffMs && cutoffMs > 0
|
|
56
45
|
? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
|
|
57
46
|
: '';
|
|
58
|
-
const [[commands = '0', saved = '0', input = '0',
|
|
59
|
-
const byCommand = query(rtkDbPath(), `SELECT substr(${CMD_ONE_LINE}, 1, ${MAX_CMD_CHARS}), COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(
|
|
47
|
+
const [[commands = '0', saved = '0', input = '0', totalMs = '0'] = []] = query(rtkDbPath(), `SELECT COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(SUM(input_tokens),0), COALESCE(SUM(exec_time_ms),0) FROM commands ${where};`);
|
|
48
|
+
const byCommand = query(rtkDbPath(), `SELECT substr(${CMD_ONE_LINE}, 1, ${MAX_CMD_CHARS}), COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(100.0 * SUM(saved_tokens) / NULLIF(SUM(input_tokens), 0), 0), COALESCE(AVG(exec_time_ms),0) FROM commands ${where} GROUP BY rtk_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`).map(([command, count, savedRow, pct, ms]) => ({
|
|
60
49
|
command,
|
|
61
50
|
count: Number(count) || 0,
|
|
62
51
|
saved: Number(savedRow) || 0,
|
|
@@ -1,15 +1,7 @@
|
|
|
1
|
-
// extensions/shared/rtk-gain.ts — measured RTK savings from
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// shows measured figures, never estimates. Missing DB/CLI → empty, no throw.
|
|
6
|
-
//
|
|
7
|
-
// Caveman and Ponytail have no per-command counters (instruction-following
|
|
8
|
-
// isn't metered); their gains are bench-measured in BENCHMARK.md and cited
|
|
9
|
-
// as static figures wherever RTK rows appear.
|
|
10
|
-
//
|
|
11
|
-
// Rows group by command alone, machine-wide: one row per command no matter
|
|
12
|
-
// which repo ran it. Project filtering stays out — wasted column, same logic.
|
|
1
|
+
// extensions/shared/rtk-gain.ts — measured RTK savings read from rtk's own
|
|
2
|
+
// history.db with the sqlite3 CLI, so the dashboard shows measured figures and
|
|
3
|
+
// never estimates. Missing DB or CLI returns empty. Caveman and Ponytail are
|
|
4
|
+
// bench-measured in BENCHMARK.md instead: instruction-following is not metered.
|
|
13
5
|
import { execFileSync } from 'node:child_process';
|
|
14
6
|
import fs from 'node:fs';
|
|
15
7
|
import os from 'node:os';
|
|
@@ -34,15 +26,13 @@ export interface RtkGain {
|
|
|
34
26
|
|
|
35
27
|
const EMPTY: RtkGain = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
|
|
36
28
|
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// before it crosses the sqlite3 CLI and lands in every /data.json poll.
|
|
29
|
+
// The table shows one clipped cell, so bound the command before it crosses the
|
|
30
|
+
// sqlite3 CLI and lands in every /data.json poll.
|
|
40
31
|
const MAX_CMD_CHARS = 200;
|
|
41
32
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// first: the stored command keeps its newlines, only the read view flattens.
|
|
33
|
+
// RTK stores heredocs and `node -e` scripts verbatim, so one row can arrive as
|
|
34
|
+
// several lines and derail every row after it. The stored command keeps its
|
|
35
|
+
// newlines; only the read view flattens.
|
|
46
36
|
const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
|
|
47
37
|
|
|
48
38
|
export function rtkDbPath(): string {
|
|
@@ -70,19 +60,18 @@ function query(db: string, sql: string): string[][] {
|
|
|
70
60
|
export function readRtkGain(limit = 10, cutoffMs?: number): RtkGain {
|
|
71
61
|
try {
|
|
72
62
|
if (!fs.existsSync(rtkDbPath())) return EMPTY;
|
|
73
|
-
// View-level cutoff only:
|
|
74
|
-
//
|
|
75
|
-
// with unparseable timestamps (0) drop out of a filtered view.
|
|
63
|
+
// View-level cutoff only: rtk's own rows stay untouched. Timestamps that do
|
|
64
|
+
// not parse land on 0 and drop out of a filtered view.
|
|
76
65
|
const where = cutoffMs && cutoffMs > 0
|
|
77
66
|
? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
|
|
78
67
|
: '';
|
|
79
|
-
const [[commands = '0', saved = '0', input = '0',
|
|
68
|
+
const [[commands = '0', saved = '0', input = '0', totalMs = '0'] = []] = query(
|
|
80
69
|
rtkDbPath(),
|
|
81
|
-
`SELECT COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(SUM(input_tokens),0), COALESCE(
|
|
70
|
+
`SELECT COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(SUM(input_tokens),0), COALESCE(SUM(exec_time_ms),0) FROM commands ${where};`,
|
|
82
71
|
);
|
|
83
72
|
const byCommand = query(
|
|
84
73
|
rtkDbPath(),
|
|
85
|
-
`SELECT substr(${CMD_ONE_LINE}, 1, ${MAX_CMD_CHARS}), COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(
|
|
74
|
+
`SELECT substr(${CMD_ONE_LINE}, 1, ${MAX_CMD_CHARS}), COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(100.0 * SUM(saved_tokens) / NULLIF(SUM(input_tokens), 0), 0), COALESCE(AVG(exec_time_ms),0) FROM commands ${where} GROUP BY rtk_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`,
|
|
86
75
|
).map(([command, count, savedRow, pct, ms]) => ({
|
|
87
76
|
command,
|
|
88
77
|
count: Number(count) || 0,
|
|
@@ -7,9 +7,9 @@ export const COMBO_LEVELS = Object.freeze({
|
|
|
7
7
|
max: Object.freeze({ level: 'max', caveman: 'ultra', rtk: 'on', ponytail: 'ultra' }),
|
|
8
8
|
});
|
|
9
9
|
const MODE_VALUES = {
|
|
10
|
-
caveman: new Set(['off', 'lite', 'full', 'ultra', 'wenyan']),
|
|
10
|
+
caveman: new Set(['off', 'lite', 'full', 'ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']),
|
|
11
11
|
rtk: new Set(['off', 'on']),
|
|
12
|
-
ponytail: new Set(['off', 'lite', 'full', 'ultra'
|
|
12
|
+
ponytail: new Set(['off', 'lite', 'full', 'ultra']),
|
|
13
13
|
};
|
|
14
14
|
const MODE_ENTRY_TYPES = {
|
|
15
15
|
'caveman-mode': 'caveman',
|
|
@@ -20,7 +20,9 @@ export function normalizeMode(name, value) {
|
|
|
20
20
|
if (name === 'rtk' && typeof value === 'boolean')
|
|
21
21
|
return value ? 'on' : 'off';
|
|
22
22
|
const mode = String(value ?? '').trim().toLowerCase();
|
|
23
|
-
|
|
23
|
+
if (!MODE_VALUES[name]?.has(mode))
|
|
24
|
+
return null;
|
|
25
|
+
return name === 'caveman' && mode === 'wenyan' ? 'wenyan-full' : mode;
|
|
24
26
|
}
|
|
25
27
|
export function deriveLevel(modes) {
|
|
26
28
|
for (const level of Object.keys(COMBO_LEVELS)) {
|
|
@@ -74,23 +76,22 @@ export function normalizeComboLevel(value) {
|
|
|
74
76
|
const level = String(value || '').trim().toLowerCase();
|
|
75
77
|
return isPresetLevel(level) ? level : null;
|
|
76
78
|
}
|
|
77
|
-
|
|
79
|
+
// OMP themes status text through ctx.ui.theme.fg; pi has no ctx.ui.theme, so
|
|
80
|
+
// the same string renders plain there.
|
|
81
|
+
export function themeStatus(ui, indicator, label, highlight = false) {
|
|
78
82
|
const theme = ui?.theme;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
// made by a sibling extension reaches this bar. The symptom is a bar frozen on
|
|
92
|
-
// a preset the session no longer has: it shows "combo BALANCED" while caveman
|
|
93
|
-
// and rtk are already off.
|
|
83
|
+
if (!theme?.fg)
|
|
84
|
+
return `${indicator} ${label}`;
|
|
85
|
+
return `${highlight ? theme.fg('accent', indicator) : indicator} ${theme.fg('muted', label)}`;
|
|
86
|
+
}
|
|
87
|
+
export function paintStatusBar(ui, key, emoji, label, isActive) {
|
|
88
|
+
ui?.setStatus?.(key, themeStatus(ui, emoji, label, isActive));
|
|
89
|
+
}
|
|
90
|
+
// Which context a status bar paints through. A UI-less event (a headless child
|
|
91
|
+
// session_start, or a top-level one that fires before the TUI attaches) must
|
|
92
|
+
// never be remembered: doing so mutes every later bare syncStatus(), which is
|
|
93
|
+
// the bridge-listener path a sibling's mode change arrives on. The symptom is a
|
|
94
|
+
// bar frozen on a preset the session no longer has.
|
|
94
95
|
export function paintableCtx(remembered, next) {
|
|
95
96
|
return next?.ui?.setStatus ? next : remembered;
|
|
96
97
|
}
|
|
@@ -129,8 +130,7 @@ export function setSharedComboMode(name, value) {
|
|
|
129
130
|
return getSharedComboState();
|
|
130
131
|
const modes = { ...getSharedComboState(), [name]: mode };
|
|
131
132
|
// Derive the level from the final triplet so a redundant same-value write
|
|
132
|
-
//
|
|
133
|
-
// strand a matching preset at 'custom' and drop the combo bar.
|
|
133
|
+
// cannot strand a matching preset at 'custom' and drop the combo bar.
|
|
134
134
|
return publish(normalizedState(modes));
|
|
135
135
|
}
|
|
136
136
|
export function reconcileSharedComboEntries(entries) {
|
|
@@ -154,19 +154,17 @@ export function reconcileSharedComboEntries(entries) {
|
|
|
154
154
|
modes[name] = mode;
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
|
-
// The level
|
|
158
|
-
//
|
|
157
|
+
// The level reflects the final triplet, never the write order, so a preset
|
|
158
|
+
// entry followed by the same values reconciles back to the preset.
|
|
159
159
|
return publish(normalizedState(modes));
|
|
160
160
|
}
|
|
161
|
-
// One-line session-wide mode summary for change confirmations
|
|
162
|
-
// `caveman=ULTRA, rtk=ON, ponytail=ULTRA`.
|
|
161
|
+
// One-line session-wide mode summary for change confirmations.
|
|
163
162
|
export function activeModesSummary(state) {
|
|
164
163
|
return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
|
|
165
164
|
}
|
|
166
165
|
export function setSharedComboListener(listener) {
|
|
167
|
-
// Additive: every sibling
|
|
168
|
-
//
|
|
169
|
-
// A null listener clears all (no current callers; reserved for teardown).
|
|
166
|
+
// Additive: every sibling syncs its mirror on publish, so a /tersio or
|
|
167
|
+
// /combo switch lands next turn with no reload. A null listener clears all.
|
|
170
168
|
if (typeof listener === 'function')
|
|
171
169
|
bridge().listeners.add(listener);
|
|
172
170
|
else
|