@krtclcdy/tersio 2.25.4 → 2.25.6
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 +27 -63
- package/dashboard/dist/index.html +18 -16
- package/dist/cli/backups.js +187 -0
- package/dist/cli/banner.js +4 -3
- package/dist/cli/common.js +29 -13
- package/dist/cli/currency.js +1 -1
- package/dist/cli/dashboard.js +168 -110
- package/dist/cli/doctor-fix.js +232 -50
- package/dist/cli/doctor.js +281 -32
- package/dist/cli/hosts.js +29 -9
- package/dist/cli/install.js +204 -69
- package/dist/cli/interactive.js +3 -3
- package/dist/cli/manifest.js +125 -17
- package/dist/cli/profile.js +14 -9
- package/dist/cli/rtk-wiring.js +1 -1
- package/dist/cli/settings.js +20 -5
- package/dist/cli/uninstall.js +24 -8
- package/dist/cli/update.js +9 -10
- package/dist/cli/usage.js +75 -7
- package/dist/extensions/{caveman-session → caveman}/index.js +4 -2
- package/{extensions/caveman-session → dist/extensions/caveman}/rule-megacave.md +5 -3
- package/{extensions/caveman-session → dist/extensions/caveman}/rule-ultra.md +4 -4
- package/dist/extensions/caveman/rule.md +89 -0
- package/dist/extensions/{combo-toggle → combo}/index.js +62 -21
- package/dist/extensions/{tersio-commands → commands}/index.js +2 -2
- package/dist/extensions/pstack/index.js +119 -0
- package/dist/extensions/pstack/rule.md +65 -0
- package/dist/extensions/{rtk-filter/index.js → rtk/filter.js} +1 -2
- package/dist/extensions/{rtk-session/index.js → rtk/session.js} +5 -5
- package/dist/extensions/{opencode/server.js → server.js} +98 -14
- package/dist/extensions/shared/agent-stats.js +1198 -0
- package/dist/extensions/shared/brand-marks.js +153 -0
- package/dist/extensions/shared/carbon.js +1 -1
- package/dist/extensions/shared/plugin-settings.js +12 -7
- package/dist/extensions/shared/pricing.js +86 -30
- package/dist/extensions/shared/rtk-gain.js +4 -9
- package/dist/extensions/shared/session-state.js +15 -10
- package/dist/extensions/shared/sqlite.js +168 -0
- package/dist/extensions/shared/status.js +8 -8
- package/dist/extensions/shared/types.js +1 -1
- package/dist/extensions/shared/usage-ledger.js +113 -39
- package/dist/extensions/shared/usage-store.js +191 -48
- package/dist/extensions/{lib → shared}/utils.js +6 -6
- package/dist/extensions/timing/index.js +31 -0
- package/dist/extensions/{ai-addons-updater → updater}/index.js +13 -6
- package/dist/probe-catalog.js +48 -0
- package/dist/probe-catalog188.js +44 -0
- package/dist/tersio.js +9 -4
- package/extensions/{caveman-session → caveman}/index.ts +4 -2
- package/{dist/extensions/caveman-session → extensions/caveman}/rule-megacave.md +5 -3
- package/{dist/extensions/caveman-session → extensions/caveman}/rule-ultra.md +4 -4
- package/extensions/caveman/rule.md +89 -0
- package/extensions/{combo-toggle → combo}/index.ts +62 -19
- package/extensions/{tersio-commands → commands}/index.ts +2 -2
- package/extensions/pstack/index.ts +128 -0
- package/extensions/pstack/rule.md +65 -0
- package/extensions/{rtk-filter/index.ts → rtk/filter.ts} +1 -2
- package/extensions/{rtk-session/index.ts → rtk/session.ts} +5 -5
- package/extensions/{opencode/server.ts → server.ts} +81 -16
- package/extensions/shared/agent-stats.ts +1605 -0
- package/extensions/shared/brand-marks.ts +163 -0
- package/extensions/shared/carbon.ts +1 -1
- package/extensions/shared/plugin-settings.ts +13 -7
- package/extensions/shared/pricing.ts +95 -32
- package/extensions/shared/rtk-gain.ts +4 -9
- package/extensions/shared/session-state.ts +18 -13
- package/extensions/shared/sqlite.ts +168 -0
- package/extensions/shared/status.ts +9 -8
- package/extensions/shared/types.ts +2 -1
- package/extensions/shared/usage-ledger.ts +134 -46
- package/extensions/shared/usage-store.ts +202 -50
- package/extensions/{lib → shared}/utils.ts +6 -6
- package/extensions/timing/index.ts +45 -0
- package/extensions/{ai-addons-updater → updater}/index.ts +15 -9
- package/package.json +27 -18
- package/dist/extensions/caveman-session/rule.md +0 -79
- package/extensions/caveman-session/rule.md +0 -79
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Brand mark catalog: names not bytes; Simple Icons files are CC0-1.0, marks remain their owners'.
|
|
2
|
+
const SIMPLE_ICONS = "https://cdn.jsdelivr.net/npm/simple-icons@16/icons";
|
|
3
|
+
/** Slug to mark URL. Only slugs named here resolve to a mark; the rest get a monogram. */
|
|
4
|
+
export const REMOTE_MARKS = {
|
|
5
|
+
// Simple Icons, CC0-1.0.
|
|
6
|
+
anthropic: `${SIMPLE_ICONS}/anthropic.svg`,
|
|
7
|
+
google: `${SIMPLE_ICONS}/google.svg`,
|
|
8
|
+
meta: `${SIMPLE_ICONS}/meta.svg`,
|
|
9
|
+
deepseek: `${SIMPLE_ICONS}/deepseek.svg`,
|
|
10
|
+
qwen: `${SIMPLE_ICONS}/qwen.svg`,
|
|
11
|
+
x: `${SIMPLE_ICONS}/x.svg`,
|
|
12
|
+
mistralai: `${SIMPLE_ICONS}/mistralai.svg`,
|
|
13
|
+
minimax: `${SIMPLE_ICONS}/minimax.svg`,
|
|
14
|
+
zdotai: `${SIMPLE_ICONS}/zdotai.svg`,
|
|
15
|
+
xiaomi: `${SIMPLE_ICONS}/xiaomi.svg`,
|
|
16
|
+
kimi: `${SIMPLE_ICONS}/kimi.svg`,
|
|
17
|
+
moonshotai: `${SIMPLE_ICONS}/moonshotai.svg`,
|
|
18
|
+
nvidia: `${SIMPLE_ICONS}/nvidia.svg`,
|
|
19
|
+
amd: `${SIMPLE_ICONS}/amd.svg`,
|
|
20
|
+
ollama: `${SIMPLE_ICONS}/ollama.svg`,
|
|
21
|
+
cline: `${SIMPLE_ICONS}/cline.svg`,
|
|
22
|
+
githubcopilot: `${SIMPLE_ICONS}/githubcopilot.svg`,
|
|
23
|
+
openrouter: `${SIMPLE_ICONS}/openrouter.svg`,
|
|
24
|
+
github: `${SIMPLE_ICONS}/github.svg`,
|
|
25
|
+
instagram: `${SIMPLE_ICONS}/instagram.svg`,
|
|
26
|
+
reddit: `${SIMPLE_ICONS}/reddit.svg`,
|
|
27
|
+
// Owner-hosted, nominative use.
|
|
28
|
+
groq: "https://groq.com/favicon.svg",
|
|
29
|
+
poolside: "https://poolside.ai/favicon/favicon.svg",
|
|
30
|
+
// AWS owns Kiro; Wikimedia hosts its vector, as AWS serves only a 16x16 ICO that cannot grow into a tile.
|
|
31
|
+
amazonaws: "https://upload.wikimedia.org/wikipedia/commons/5/5c/AWS_Simple_Icons_AWS_Cloud.svg",
|
|
32
|
+
cerebras: "https://cdn.sanity.io/images/e4qjo92p/production/e7a55ae5ab7e2c4fdfd4e66a51f628d1f2f44207-967x967.png?w=256&h=256&fit=max",
|
|
33
|
+
inclusionai: "https://cdn-avatars.huggingface.co/v1/production/uploads/662e1f9da266499277937d33/fyKuazRifqiaIO34xrhhm.jpeg",
|
|
34
|
+
// Owner avatars: the vendor or gateway posts no standalone icon, so its account picture stands in.
|
|
35
|
+
stepfun: "https://cdn-avatars.huggingface.co/v1/production/uploads/644f7e6233ac8f46fa0b9e26/CmF2ocXhkr2UtHXgmwq7-.png",
|
|
36
|
+
// typesafe.ai serves one full-bleed tile: a dark square with pink art inside it.
|
|
37
|
+
typesafe: "https://framerusercontent.com/images/aNFzSFxM4fjICmnibw7npfZjcQ.png",
|
|
38
|
+
// A gateway's own brand, which names the service that served the request. Never another vendor's logo.
|
|
39
|
+
opencode: "https://opencode.ai/favicon.svg",
|
|
40
|
+
commandcode: "https://commandcode.ai/favicon.ico",
|
|
41
|
+
kilo: "https://kilo.ai/favicon.ico",
|
|
42
|
+
magpie: "https://usemagpie.ai/favicon.png",
|
|
43
|
+
gmicloud: "https://gmicloud.ai/favicon.ico",
|
|
44
|
+
charmhyper: "https://github.com/charmbracelet.png?size=80",
|
|
45
|
+
// WorkBuddy's own favicon, a hashed asset on the Tencent CDN its page serves.
|
|
46
|
+
workbuddy: "https://download.codebuddy.ai/web/workbuddy/913b2390aae396c66b937bd9143ae5259306d7d7/assets/logo.svg",
|
|
47
|
+
// Trae's own favicon: a dark tile carrying its green mark, so the tile matches the artwork.
|
|
48
|
+
trae: "https://lf16-web-neutral.traecdn.ai/obj/trae-ai-static/trae_website/favicon.png",
|
|
49
|
+
// Kiro's own icon, a purple tile with the mark drawn inside it.
|
|
50
|
+
kiro: "https://kiro.dev/icon.svg",
|
|
51
|
+
// Meituan's own icon: its yellow tile with the logotype.
|
|
52
|
+
meituan: "https://s3plus.meituan.net/v1/mss_e2821d7f0cfe4ac1bf9202ecf9590e67/cdn-prod/file:1040877d/favicon-mt.ico",
|
|
53
|
+
};
|
|
54
|
+
/** Tile fill per slug from the brand's own page, never a guess; an absent slug keeps the plain panel. */
|
|
55
|
+
export const MARK_BG = {
|
|
56
|
+
// Simple Icons marks: the vendor's brand color, taken from its own brand page.
|
|
57
|
+
anthropic: "#d97757",
|
|
58
|
+
meta: "#0082fb",
|
|
59
|
+
google: "#4285f4",
|
|
60
|
+
deepseek: "#4d6bfe",
|
|
61
|
+
qwen: "#6950ef",
|
|
62
|
+
mistralai: "#ff7000",
|
|
63
|
+
minimax: "#e11d48",
|
|
64
|
+
// Kimi's own favicon carries this blue, not the Moonshot violet.
|
|
65
|
+
kimi: "#1782fe",
|
|
66
|
+
xiaomi: "#ff6900",
|
|
67
|
+
nvidia: "#76b900",
|
|
68
|
+
amd: "#ed1c24",
|
|
69
|
+
x: "#000000",
|
|
70
|
+
ollama: "#000000",
|
|
71
|
+
zdotai: "#2d2d2d",
|
|
72
|
+
github: "#181717",
|
|
73
|
+
githubcopilot: "#181717",
|
|
74
|
+
cline: "#9f58fa",
|
|
75
|
+
openrouter: "#03080a",
|
|
76
|
+
instagram: "#e4405f",
|
|
77
|
+
reddit: "#ff4500",
|
|
78
|
+
linkedin: "#0a66c2",
|
|
79
|
+
// Owner-hosted marks match the asset's own square; art that already carries color takes a white tile.
|
|
80
|
+
groq: "#f43e01",
|
|
81
|
+
poolside: "#4137ff",
|
|
82
|
+
cerebras: "#ffffff",
|
|
83
|
+
typesafe: "#171717",
|
|
84
|
+
charmhyper: "#ffffff",
|
|
85
|
+
stepfun: "#ffffff",
|
|
86
|
+
kilo: "#f7f576",
|
|
87
|
+
opencode: "#131010",
|
|
88
|
+
// The AWS cloud mark draws its own orange and its own white, so the tile stays plain white.
|
|
89
|
+
amazonaws: "#ffffff",
|
|
90
|
+
// The mark's own artwork is white, so the tile is white and the glyph is black.
|
|
91
|
+
openai: "#ffffff",
|
|
92
|
+
commandcode: "#ffffff",
|
|
93
|
+
gmicloud: "#ffffff",
|
|
94
|
+
magpie: "#0b0b0c",
|
|
95
|
+
// WorkBuddy's mark is a full-bleed teal tile carrying its own white art.
|
|
96
|
+
workbuddy: "#01c886",
|
|
97
|
+
// The mark is a full-bleed tile, so the tile fill repeats the artwork's own background.
|
|
98
|
+
trae: "#000000",
|
|
99
|
+
kiro: "#9046ff",
|
|
100
|
+
meituan: "#ffd100",
|
|
101
|
+
};
|
|
102
|
+
/** Glyph scale per slug over the tile clip; the measured ink box decides, and one is the default. */
|
|
103
|
+
export const MARK_SCALE = {
|
|
104
|
+
kimi: 0.72,
|
|
105
|
+
zdotai: 0.72,
|
|
106
|
+
openrouter: 0.9,
|
|
107
|
+
magpie: 1.15,
|
|
108
|
+
};
|
|
109
|
+
/** WCAG relative luminance of a `#rrggbb` string. */
|
|
110
|
+
export function luminance(hex) {
|
|
111
|
+
const n = parseInt(hex.slice(1), 16);
|
|
112
|
+
const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
|
|
113
|
+
const s = v / 255;
|
|
114
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
115
|
+
});
|
|
116
|
+
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
|
|
117
|
+
}
|
|
118
|
+
/** The ink legible on a tile fill: luminance above 0.35 takes black, else white. */
|
|
119
|
+
export function contrastInk(hex) {
|
|
120
|
+
return luminance(hex) > 0.35 ? "#000000" : "#ffffff";
|
|
121
|
+
}
|
|
122
|
+
/** Marks carrying their own color or background; a filled-tile asset must draw as an image, not a mask. */
|
|
123
|
+
export const COLORED_MARKS = new Set([
|
|
124
|
+
"cerebras",
|
|
125
|
+
"inclusionai",
|
|
126
|
+
"typesafe",
|
|
127
|
+
"opencode",
|
|
128
|
+
"commandcode",
|
|
129
|
+
"kilo",
|
|
130
|
+
"magpie",
|
|
131
|
+
"charmhyper",
|
|
132
|
+
"stepfun",
|
|
133
|
+
// The ICO and inline PNGs are dark tiles with light art; flat-orange Groq draws as a mask instead.
|
|
134
|
+
"gmicloud",
|
|
135
|
+
"stealth",
|
|
136
|
+
"cognition",
|
|
137
|
+
// A full-bleed teal tile with its own white art, so a mask would flatten both into one ink.
|
|
138
|
+
"workbuddy",
|
|
139
|
+
// Each is a full-bleed brand tile with its own ink, so a mask would flatten both into one color.
|
|
140
|
+
"trae",
|
|
141
|
+
"kiro",
|
|
142
|
+
"meituan",
|
|
143
|
+
// Carries its own orange and its own white, so a mask would flatten both into one ink.
|
|
144
|
+
"amazonaws",
|
|
145
|
+
]);
|
|
146
|
+
/** The URL a mark comes from, or null when the name has no hosted mark. */
|
|
147
|
+
export function markUrl(slug) {
|
|
148
|
+
return REMOTE_MARKS[slug] ?? null;
|
|
149
|
+
}
|
|
150
|
+
/** True when the mark carries its own color and must draw as an image. */
|
|
151
|
+
export function isColoredMark(slug) {
|
|
152
|
+
return COLORED_MARKS.has(slug);
|
|
153
|
+
}
|
|
@@ -23,7 +23,7 @@ const GRIDS = {
|
|
|
23
23
|
export const SERVING_CONCURRENCY = 32;
|
|
24
24
|
/** EcoLogits release this port tracks, so a UI can name it instead of guessing. */
|
|
25
25
|
export const ECOLOGITS_VERSION = '0.8.2';
|
|
26
|
-
//
|
|
26
|
+
// Verbatim from EcoLogits' model_repository; borrows cite the param disclosure they replace (MoE active/total).
|
|
27
27
|
const TABLE = [
|
|
28
28
|
{ match: 'opus', active: 625.0, total: 2000, provider: 'anthropic', source: 'registry' },
|
|
29
29
|
{ match: 'sonnet', active: 137.5, total: 440, provider: 'anthropic', source: 'registry' },
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// One settings file for every host; parse failure yields {} with per-caller defaults.
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { homeDir, tersioHome } from '
|
|
4
|
+
import { homeDir, tersioHome } from './utils.js';
|
|
5
5
|
export const PLUGIN_NAME = '@krtclcdy/tersio';
|
|
6
6
|
// The one store both hosts read; this module owns its path.
|
|
7
7
|
export function tersioSettingsFile() {
|
|
8
8
|
return path.join(tersioHome(), 'settings.json');
|
|
9
9
|
}
|
|
10
|
-
// Older installs kept these as OMP plugin settings; still read so switching
|
|
10
|
+
// Older installs kept these as OMP plugin settings; still read so switching stores does not reset a user's defaults.
|
|
11
11
|
function legacyOmpSettings() {
|
|
12
12
|
const configHome = process.env.XDG_CONFIG_HOME || path.join(homeDir(), '.config');
|
|
13
13
|
for (const p of [
|
|
@@ -38,13 +38,15 @@ function readSettingsFile() {
|
|
|
38
38
|
return null;
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
-
// The stored settings;
|
|
41
|
+
// The stored settings; {} when the file is missing or corrupt.
|
|
42
42
|
export function readPluginSettings() {
|
|
43
43
|
return readSettingsFile() ?? legacyOmpSettings() ?? {};
|
|
44
44
|
}
|
|
45
45
|
const COMBO_LEVELS = new Set(['off', 'medium', 'balanced', 'max']);
|
|
46
46
|
const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'megacave', 'megacave-lite', 'megacave-full', 'megacave-ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']);
|
|
47
47
|
const PONYTAIL_MODES = new Set(['off', 'lite', 'full', 'ultra']);
|
|
48
|
+
// Opt-in only: no combo preset writes this, so it never reaches a user who did not ask for it.
|
|
49
|
+
const PSTACK_MODES = new Set(['off', 'on']);
|
|
48
50
|
function readStringDefault(key, valid) {
|
|
49
51
|
const raw = readPluginSettings()[key];
|
|
50
52
|
return typeof raw === 'string' && valid.has(raw) ? raw : 'off';
|
|
@@ -62,14 +64,17 @@ export function readRtkDefault() {
|
|
|
62
64
|
export function readPonytailDefault() {
|
|
63
65
|
return readStringDefault('ponytailDefault', PONYTAIL_MODES);
|
|
64
66
|
}
|
|
67
|
+
export function readPstackDefault() {
|
|
68
|
+
return readStringDefault('pstackDefault', PSTACK_MODES);
|
|
69
|
+
}
|
|
65
70
|
export function isComboSetupComplete() {
|
|
66
71
|
return readPluginSettings().comboSetupComplete === true;
|
|
67
72
|
}
|
|
68
73
|
const COMBO_MODE_DEFAULTS = {
|
|
69
|
-
off: { cavemanDefault: 'off', rtkDefault: false, ponytailDefault: 'off' },
|
|
70
|
-
medium: { cavemanDefault: 'lite', rtkDefault: true, ponytailDefault: 'lite' },
|
|
71
|
-
balanced: { cavemanDefault: 'full', rtkDefault: true, ponytailDefault: 'full' },
|
|
72
|
-
max: { cavemanDefault: 'ultra', rtkDefault: true, ponytailDefault: 'ultra' },
|
|
74
|
+
off: { cavemanDefault: 'off', rtkDefault: false, ponytailDefault: 'off', pstackDefault: 'off' },
|
|
75
|
+
medium: { cavemanDefault: 'lite', rtkDefault: true, ponytailDefault: 'lite', pstackDefault: 'on' },
|
|
76
|
+
balanced: { cavemanDefault: 'full', rtkDefault: true, ponytailDefault: 'full', pstackDefault: 'on' },
|
|
77
|
+
max: { cavemanDefault: 'ultra', rtkDefault: true, ponytailDefault: 'ultra', pstackDefault: 'on' },
|
|
73
78
|
};
|
|
74
79
|
export function saveComboSetup(level) {
|
|
75
80
|
if (!(level in COMBO_MODE_DEFAULTS))
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
//
|
|
1
|
+
// pricing.ts — model pricing from LiteLLM. Exact id → prefix strip → default, known:false if unpriced.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { tersioDataPath } from '
|
|
4
|
+
import { tersioDataPath } from './utils.js';
|
|
5
5
|
// Sonnet-class default for unpriced models; always paired with known:false.
|
|
6
6
|
export const DEFAULT_PRICE = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
|
|
7
|
+
/** Tiers firm enough to call a price; `tail` and `default` are estimates. */
|
|
8
|
+
export const PRICE_TIERS_FIRM = ['measured', 'override', 'exact', 'alias'];
|
|
7
9
|
const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
|
|
8
|
-
// Readers use the file even when stale
|
|
10
|
+
// Readers use the file even when stale; one refresh per process. `tersio update` still forces a fresh fetch.
|
|
9
11
|
const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
|
|
10
12
|
let refreshInflight = null;
|
|
11
13
|
export function pricesCachePath() {
|
|
@@ -14,6 +16,48 @@ export function pricesCachePath() {
|
|
|
14
16
|
return override;
|
|
15
17
|
return tersioDataPath('prices.json', 'tersio-prices.json');
|
|
16
18
|
}
|
|
19
|
+
/** Operator prices keyed as the transcript spells a model; a `*` prefix matches any gateway suffix. */
|
|
20
|
+
export function pricesOverridePath() {
|
|
21
|
+
const override = process.env.TERSIO_PRICES_OVERRIDE_FILE;
|
|
22
|
+
if (override)
|
|
23
|
+
return override;
|
|
24
|
+
return tersioDataPath('prices.local.json', 'tersio-prices.local.json');
|
|
25
|
+
}
|
|
26
|
+
/** The override file's mtime, or 0 when absent. Callers key their memo on it. */
|
|
27
|
+
function overrideStamp() {
|
|
28
|
+
try {
|
|
29
|
+
return fs.statSync(pricesOverridePath()).mtimeMs;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
let overrideCache = null;
|
|
36
|
+
/** Reads the override file, keeping a `*`-prefixed entry as a suffix matcher. */
|
|
37
|
+
function loadOverrides(stamp = overrideStamp()) {
|
|
38
|
+
if (stamp === 0)
|
|
39
|
+
return null;
|
|
40
|
+
const file = pricesOverridePath();
|
|
41
|
+
if (overrideCache && overrideCache.stamp === stamp)
|
|
42
|
+
return overrideCache;
|
|
43
|
+
const exact = new Map();
|
|
44
|
+
const tails = [];
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
47
|
+
for (const [id, row] of Object.entries(parsed ?? {})) {
|
|
48
|
+
const price = asPrice(row);
|
|
49
|
+
if (!price)
|
|
50
|
+
continue;
|
|
51
|
+
if (id.startsWith('*'))
|
|
52
|
+
tails.push([id.slice(1).toLowerCase(), price]);
|
|
53
|
+
else
|
|
54
|
+
exact.set(id, price);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch { /* an unreadable override is empty, not fatal */ }
|
|
58
|
+
overrideCache = { stamp, exact, tails };
|
|
59
|
+
return overrideCache;
|
|
60
|
+
}
|
|
17
61
|
export function pricesUrl() {
|
|
18
62
|
return process.env.TERSIO_PRICES_URL || LITELLM_URL;
|
|
19
63
|
}
|
|
@@ -94,26 +138,26 @@ export function canonicalPriceId(model) {
|
|
|
94
138
|
export function priceFor(model, live) {
|
|
95
139
|
const table = live === undefined ? loadLivePrices() : live;
|
|
96
140
|
if (live === undefined) {
|
|
97
|
-
|
|
141
|
+
// An operator who edits the override file expects the new price at once, not after a refresh.
|
|
142
|
+
const stamp = overrideStamp();
|
|
143
|
+
if (memoTable !== table || memoStamp !== stamp) {
|
|
144
|
+
memoTable = table;
|
|
145
|
+
memoStamp = stamp;
|
|
146
|
+
memo = new Map();
|
|
147
|
+
}
|
|
98
148
|
const hit = memo.get(model);
|
|
99
149
|
if (hit)
|
|
100
150
|
return hit;
|
|
101
|
-
const resolved = resolvePrice(model, table);
|
|
151
|
+
const resolved = resolvePrice(model, table, stamp);
|
|
102
152
|
memo.set(model, resolved);
|
|
103
153
|
return resolved;
|
|
104
154
|
}
|
|
105
|
-
return resolvePrice(model, table);
|
|
155
|
+
return resolvePrice(model, table, overrideStamp());
|
|
106
156
|
}
|
|
107
|
-
// Recent rows repeat a
|
|
157
|
+
// Recent rows repeat a few models; the memo turns thousands of lookups into one per model. Cleared on table change.
|
|
108
158
|
let memoTable;
|
|
159
|
+
let memoStamp = 0;
|
|
109
160
|
let memo = new Map();
|
|
110
|
-
function priceMemo(table) {
|
|
111
|
-
if (memoTable !== table) {
|
|
112
|
-
memoTable = table;
|
|
113
|
-
memo = new Map();
|
|
114
|
-
}
|
|
115
|
-
return memo;
|
|
116
|
-
}
|
|
117
161
|
// Validated once per table instead of once per lookup.
|
|
118
162
|
let compiledTable = null;
|
|
119
163
|
let compiledEntries = [];
|
|
@@ -130,28 +174,40 @@ function priceEntries(table) {
|
|
|
130
174
|
compiledEntries = entries;
|
|
131
175
|
return entries;
|
|
132
176
|
}
|
|
133
|
-
function resolvePrice(model, table) {
|
|
134
|
-
//
|
|
135
|
-
const
|
|
177
|
+
function resolvePrice(model, table, stamp = overrideStamp()) {
|
|
178
|
+
// The operator's file wins outright: it is the only source that can name what a gateway charges.
|
|
179
|
+
const overrides = loadOverrides(stamp);
|
|
180
|
+
if (overrides) {
|
|
181
|
+
const own = overrides.exact.get(model) ?? overrides.exact.get(model.toLowerCase());
|
|
182
|
+
if (own)
|
|
183
|
+
return { price: own, known: true, live: false, tier: 'override' };
|
|
184
|
+
const low = model.toLowerCase();
|
|
185
|
+
for (const [suffix, price] of overrides.tails) {
|
|
186
|
+
if (low === suffix || low.endsWith(`/${suffix}`)) {
|
|
187
|
+
return { price, known: true, live: false, tier: 'override' };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// An alias runs the same chain, so it still resolves when the feed keys it under a provider prefix.
|
|
192
|
+
const alias = MODEL_ALIASES.find((a) => a.id === model.toLowerCase());
|
|
193
|
+
const id = alias?.feed ?? model;
|
|
136
194
|
if (table) {
|
|
137
195
|
const entries = priceEntries(table);
|
|
138
|
-
|
|
139
|
-
const hit = entries.find(([k]) => k === id) ?? entries.find(([k]) => k ===
|
|
196
|
+
// The id verbatim, then case-folded; nothing guessed.
|
|
197
|
+
const hit = entries.find(([k]) => k === id) ?? entries.find(([k]) => k === id.toLowerCase());
|
|
140
198
|
if (hit)
|
|
141
|
-
return { price: hit[1], known: true, live: true };
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
//
|
|
145
|
-
const
|
|
146
|
-
const tail = name.split('/').pop() ?? name;
|
|
147
|
-
const key = entries.find(([k]) => {
|
|
199
|
+
return { price: hit[1], known: true, live: true, tier: alias ? 'alias' : 'exact' };
|
|
200
|
+
const lower = id.toLowerCase();
|
|
201
|
+
const tail = lower.split('/').pop() ?? lower;
|
|
202
|
+
// Bare name under any provider (need not be the billing one); tier `tail` keeps it out of a firm total.
|
|
203
|
+
const bare = entries.find(([k]) => {
|
|
148
204
|
const lk = k.toLowerCase();
|
|
149
|
-
return lk ===
|
|
205
|
+
return lk === tail || lk.endsWith(`/${tail}`);
|
|
150
206
|
});
|
|
151
|
-
if (
|
|
152
|
-
return { price:
|
|
207
|
+
if (bare)
|
|
208
|
+
return { price: bare[1], known: true, live: true, tier: 'tail' };
|
|
153
209
|
}
|
|
154
|
-
return { price: DEFAULT_PRICE, known: false, live: false };
|
|
210
|
+
return { price: DEFAULT_PRICE, known: false, live: false, tier: 'default' };
|
|
155
211
|
}
|
|
156
212
|
// Fetch LiteLLM pricing and write the cache. False on any failure — callers fall back silently.
|
|
157
213
|
export async function refreshPrices() {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Savings read from rtk history, never estimated; modes bench in docs/BENCHMARK.md.
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
2
|
import fs from 'node:fs';
|
|
4
3
|
import os from 'node:os';
|
|
5
4
|
import path from 'node:path';
|
|
5
|
+
import { sqliteRead } from './sqlite.js';
|
|
6
6
|
const EMPTY = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
|
|
7
|
-
// The table shows one clipped cell, so bound the command before it
|
|
7
|
+
// The table shows one clipped cell, so bound the command before it reaches /data.json.
|
|
8
8
|
const MAX_CMD_CHARS = 200;
|
|
9
9
|
// A multi-line stored command would derail every row after it; flatten on read.
|
|
10
10
|
const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
|
|
@@ -19,12 +19,7 @@ export function rtkDbPath() {
|
|
|
19
19
|
return path.join(xdg, 'rtk', 'history.db');
|
|
20
20
|
}
|
|
21
21
|
function query(db, sql) {
|
|
22
|
-
|
|
23
|
-
encoding: 'utf8',
|
|
24
|
-
timeout: 5000,
|
|
25
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
26
|
-
});
|
|
27
|
-
return out
|
|
22
|
+
return sqliteRead(db, sql)
|
|
28
23
|
.split('\n')
|
|
29
24
|
.filter((line) => line.trim())
|
|
30
25
|
.map((line) => line.split('\t'));
|
|
@@ -33,7 +28,7 @@ export function readRtkGain(limit = 10, cutoffMs) {
|
|
|
33
28
|
try {
|
|
34
29
|
if (!fs.existsSync(rtkDbPath()))
|
|
35
30
|
return EMPTY;
|
|
36
|
-
// View-level cutoff only: rtk's
|
|
31
|
+
// View-level cutoff only: rtk's rows stay untouched. Unparseable timestamps land on 0 and drop from filtered views.
|
|
37
32
|
const where = cutoffMs && cutoffMs > 0
|
|
38
33
|
? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
|
|
39
34
|
: '';
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { readPluginSettings } from './plugin-settings.js';
|
|
2
2
|
const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
|
|
3
|
-
// A verbatim sentence from omp's subagent prompt, so it can drift; read from the installed omp binary on 2026-10-01.
|
|
3
|
+
// A verbatim sentence from omp's subagent prompt, so it can drift; read from the installed omp binary on 2026-10-01.
|
|
4
4
|
export const OMP_SUBAGENT_MARKER = 'Worker agent: delegated tasks.';
|
|
5
|
+
// Every level above off turns pstack on with the other three; off is the one level without it.
|
|
5
6
|
export const COMBO_LEVELS = Object.freeze({
|
|
6
|
-
off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off' }),
|
|
7
|
-
medium: Object.freeze({ level: 'medium', caveman: 'lite', rtk: 'on', ponytail: 'lite' }),
|
|
8
|
-
balanced: Object.freeze({ level: 'balanced', caveman: 'full', rtk: 'on', ponytail: 'full' }),
|
|
9
|
-
max: Object.freeze({ level: 'max', caveman: 'ultra', rtk: 'on', ponytail: 'ultra' }),
|
|
7
|
+
off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off', pstack: 'off' }),
|
|
8
|
+
medium: Object.freeze({ level: 'medium', caveman: 'lite', rtk: 'on', ponytail: 'lite', pstack: 'on' }),
|
|
9
|
+
balanced: Object.freeze({ level: 'balanced', caveman: 'full', rtk: 'on', ponytail: 'full', pstack: 'on' }),
|
|
10
|
+
max: Object.freeze({ level: 'max', caveman: 'ultra', rtk: 'on', ponytail: 'ultra', pstack: 'on' }),
|
|
10
11
|
});
|
|
11
12
|
const MODE_VALUES = {
|
|
12
13
|
caveman: new Set(['off', 'lite', 'full', 'ultra', 'megacave', 'megacave-lite', 'megacave-full', 'megacave-ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']),
|
|
13
14
|
rtk: new Set(['off', 'on']),
|
|
14
15
|
ponytail: new Set(['off', 'lite', 'full', 'ultra']),
|
|
16
|
+
pstack: new Set(['off', 'on']),
|
|
15
17
|
};
|
|
16
18
|
// Upstream renamed wenyan to megacave and kept the old spelling as an alias.
|
|
17
19
|
const CAVEMAN_LEGACY_PREFIX = /^wenyan/;
|
|
@@ -19,6 +21,7 @@ const MODE_ENTRY_TYPES = {
|
|
|
19
21
|
'caveman-mode': 'caveman',
|
|
20
22
|
'rtk-mode': 'rtk',
|
|
21
23
|
'ponytail-mode': 'ponytail',
|
|
24
|
+
'pstack-mode': 'pstack',
|
|
22
25
|
};
|
|
23
26
|
/** True when the branch carries Tersio's own mode state; a subagent starts without it. */
|
|
24
27
|
export function hasModeState(entries) {
|
|
@@ -39,7 +42,7 @@ export function normalizeMode(name, value) {
|
|
|
39
42
|
export function deriveLevel(modes) {
|
|
40
43
|
for (const level of Object.keys(COMBO_LEVELS)) {
|
|
41
44
|
const preset = COMBO_LEVELS[level];
|
|
42
|
-
if (preset.caveman === modes?.caveman && preset.rtk === modes?.rtk && preset.ponytail === modes?.ponytail)
|
|
45
|
+
if (preset.caveman === modes?.caveman && preset.rtk === modes?.rtk && preset.ponytail === modes?.ponytail && preset.pstack === modes?.pstack)
|
|
43
46
|
return level;
|
|
44
47
|
}
|
|
45
48
|
return 'custom';
|
|
@@ -55,6 +58,7 @@ function normalizedState(modes, level = deriveLevel(modes)) {
|
|
|
55
58
|
caveman: normalizeMode('caveman', modes?.caveman) || 'off',
|
|
56
59
|
rtk: normalizeMode('rtk', modes?.rtk) || 'off',
|
|
57
60
|
ponytail: normalizeMode('ponytail', modes?.ponytail) || 'off',
|
|
61
|
+
pstack: normalizeMode('pstack', modes?.pstack) || 'off',
|
|
58
62
|
};
|
|
59
63
|
return Object.freeze({ level: isKnownLevel(level) ? level : deriveLevel(state), ...state });
|
|
60
64
|
}
|
|
@@ -98,7 +102,7 @@ export function normalizeComboLevel(value) {
|
|
|
98
102
|
return level;
|
|
99
103
|
return COMBO_ALIASES[level] ?? null;
|
|
100
104
|
}
|
|
101
|
-
// Last-wins scan for a custom session entry; skips
|
|
105
|
+
// Last-wins scan for a custom session entry; skips unparseable values so a corrupt write never shadows a valid one.
|
|
102
106
|
export function lastCustomValue(entries, customType, pick) {
|
|
103
107
|
if (!Array.isArray(entries))
|
|
104
108
|
return null;
|
|
@@ -131,7 +135,7 @@ export function setSharedComboMode(name, value) {
|
|
|
131
135
|
if (!mode)
|
|
132
136
|
return getSharedComboState();
|
|
133
137
|
const modes = { ...getSharedComboState(), [name]: mode };
|
|
134
|
-
// Derive the level from the final
|
|
138
|
+
// Derive the level from the final quartet, so a redundant same-value write cannot strand a preset at 'custom'.
|
|
135
139
|
return publish(normalizedState(modes));
|
|
136
140
|
}
|
|
137
141
|
export function setSharedComboModes(modes) {
|
|
@@ -139,6 +143,7 @@ export function setSharedComboModes(modes) {
|
|
|
139
143
|
caveman: normalizeMode('caveman', modes.caveman) || 'off',
|
|
140
144
|
rtk: normalizeMode('rtk', modes.rtk) || 'off',
|
|
141
145
|
ponytail: normalizeMode('ponytail', modes.ponytail) || 'off',
|
|
146
|
+
pstack: normalizeMode('pstack', modes.pstack) || 'off',
|
|
142
147
|
}));
|
|
143
148
|
}
|
|
144
149
|
export function reconcileSharedComboEntries(entries) {
|
|
@@ -162,12 +167,12 @@ export function reconcileSharedComboEntries(entries) {
|
|
|
162
167
|
modes[name] = mode;
|
|
163
168
|
}
|
|
164
169
|
}
|
|
165
|
-
// The level reflects the final
|
|
170
|
+
// The level reflects the final quartet, not write order, so a preset followed by the same values reconciles back.
|
|
166
171
|
return publish(normalizedState(modes));
|
|
167
172
|
}
|
|
168
173
|
// One-line session-wide mode summary for change confirmations.
|
|
169
174
|
export function activeModesSummary(state) {
|
|
170
|
-
return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
|
|
175
|
+
return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}, pstack=${state.pstack.toUpperCase()}`;
|
|
171
176
|
}
|
|
172
177
|
// Keyed registration drops stale closures; hosts offer no teardown hook.
|
|
173
178
|
export function setSharedComboListener(key, listener) {
|