@krtclcdy/tersio 2.23.0 → 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.
Files changed (55) hide show
  1. package/README.md +58 -45
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/common.js +16 -20
  5. package/cli/currency.js +8 -12
  6. package/cli/dashboard.js +49 -37
  7. package/cli/doctor-fix.js +28 -17
  8. package/cli/doctor.js +52 -41
  9. package/cli/hosts.js +118 -0
  10. package/cli/install.js +180 -117
  11. package/cli/interactive.js +27 -1
  12. package/cli/layer.js +24 -0
  13. package/cli/manifest.js +36 -0
  14. package/cli/omp-layer.js +51 -0
  15. package/cli/pi-layer.js +37 -0
  16. package/cli/pi-wiring.js +92 -0
  17. package/cli/profile.js +44 -46
  18. package/cli/reset.js +3 -26
  19. package/cli/rtk-wiring.js +10 -15
  20. package/cli/uninstall.js +135 -44
  21. package/cli/update.js +12 -19
  22. package/dashboard/dist/index.html +16 -16
  23. package/extensions/ai-addons-updater/index.js +31 -24
  24. package/extensions/ai-addons-updater/index.ts +32 -32
  25. package/extensions/caveman-session/index.js +6 -6
  26. package/extensions/caveman-session/index.ts +6 -6
  27. package/extensions/combo-toggle/index.js +42 -40
  28. package/extensions/combo-toggle/index.ts +36 -47
  29. package/extensions/lib/utils.js +36 -9
  30. package/extensions/lib/utils.ts +37 -9
  31. package/extensions/rtk-session/index.js +9 -9
  32. package/extensions/rtk-session/index.ts +9 -14
  33. package/extensions/shared/carbon.js +5 -8
  34. package/extensions/shared/carbon.ts +5 -8
  35. package/extensions/shared/host.js +68 -0
  36. package/extensions/shared/host.ts +104 -0
  37. package/extensions/shared/omp-prompt.js +12 -0
  38. package/extensions/shared/plugin-settings.js +43 -30
  39. package/extensions/shared/plugin-settings.ts +45 -26
  40. package/extensions/shared/pricing.js +26 -15
  41. package/extensions/shared/pricing.ts +28 -16
  42. package/extensions/shared/rtk-gain.js +11 -22
  43. package/extensions/shared/rtk-gain.ts +11 -22
  44. package/extensions/shared/session-state.js +21 -25
  45. package/extensions/shared/session-state.ts +22 -26
  46. package/extensions/shared/types.ts +10 -3
  47. package/extensions/shared/usage-ledger.js +24 -36
  48. package/extensions/shared/usage-ledger.ts +29 -50
  49. package/extensions/shared/usage-store.js +14 -12
  50. package/extensions/shared/usage-store.ts +16 -14
  51. package/extensions/tersio-commands/index.js +2 -1
  52. package/extensions/tersio-commands/index.ts +2 -1
  53. package/package.json +4 -2
  54. package/tersio.js +4 -4
  55. package/extensions/shared/mode-reinforcement.js +0 -26
@@ -0,0 +1,104 @@
1
+ // extensions/shared/host.ts — one extension source, two agent hosts.
2
+ //
3
+ // OMP is a fork of pi, and six surfaces differ: tool schemas (OMP injects
4
+ // `pi.zod`, pi takes any object it forwards as JSON Schema), `setLabel`
5
+ // (pi's takes an entryId, so an extension name must never reach it),
6
+ // `session_branch` (pi reports that switch as `session_tree`), select options
7
+ // (pi takes plain strings), the status-bar theme (pi has no ctx.ui.theme), and
8
+ // prompt injection (pi appends to its sections rather than replacing them).
9
+ //
10
+ // The host is read from the API shape, never a name or an env marker: OMP is
11
+ // the fork that injects zod, and pi does not, including through the SDK.
12
+
13
+ import { asPromptArray } from './session-state.ts';
14
+ import type { ExtensionApi, ExtensionCtx, PromptInjection, SystemPromptEvent, ToolParams, UiApi } from './types.ts';
15
+
16
+ /** The zod subset the one tool schema needs. */
17
+ export interface ZodChain {
18
+ min: (n: number) => ZodChain;
19
+ describe: (text: string) => ZodChain;
20
+ }
21
+
22
+ export interface ZodFactory {
23
+ z: {
24
+ object: (shape: Record<string, unknown>) => ToolParams;
25
+ array: (element: unknown) => ZodChain;
26
+ string: () => ZodChain;
27
+ };
28
+ }
29
+
30
+ /** The host fields these adapters read. */
31
+ export interface HostHandle {
32
+ zod?: unknown;
33
+ setLabel?: (...args: string[]) => void;
34
+ }
35
+
36
+ export function isPiHost(pi: HostHandle): boolean {
37
+ return !pi.zod;
38
+ }
39
+
40
+ export function setExtensionLabel(pi: HostHandle, label: string): void {
41
+ if (isPiHost(pi)) return;
42
+ pi.setLabel?.(label);
43
+ }
44
+
45
+ export function onHostEvent<E>(pi: ExtensionApi, event: string, handler: (event: E, ctx: ExtensionCtx) => unknown): void {
46
+ if (event === 'session_branch' && isPiHost(pi)) return;
47
+ pi.on<E>(event, handler);
48
+ }
49
+
50
+ export interface SelectOption {
51
+ label: string;
52
+ description?: string;
53
+ }
54
+
55
+ // Returns the option's label on both hosts: pi renders plain strings, so the
56
+ // description rides along inside the label and is stripped back out.
57
+ export async function hostSelect(
58
+ pi: HostHandle,
59
+ ui: UiApi | undefined,
60
+ title: string,
61
+ options: SelectOption[],
62
+ dialogOptions?: Record<string, unknown>,
63
+ ): Promise<string | undefined> {
64
+ if (!ui?.select) return undefined;
65
+ if (!isPiHost(pi)) return ui.select(title, options, dialogOptions);
66
+ const shown = options.map((option) => (option.description ? `${option.label} — ${option.description}` : option.label));
67
+ const picked = await ui.select(title, shown, dialogOptions);
68
+ if (picked === undefined) return undefined;
69
+ return options[shown.indexOf(picked)]?.label ?? picked;
70
+ }
71
+
72
+ // What a `before_agent_start` handler hands back; undefined once pi was
73
+ // updated in place.
74
+ export function injectPromptText(pi: HostHandle, event: SystemPromptEvent, text: string): PromptInjection | undefined {
75
+ if (!isPiHost(pi)) return { systemPrompt: [...asPromptArray(event.systemPrompt), text] };
76
+ // Appending keeps pi's sections and the transcript diff; replacing the
77
+ // prompt the OMP way would drop every section.
78
+ const options = event.systemPromptOptions;
79
+ if (options && typeof options.appendSystemPrompt === 'string') {
80
+ options.appendSystemPrompt = [options.appendSystemPrompt, text].filter(Boolean).join('\n\n');
81
+ return undefined;
82
+ }
83
+ return { systemPrompt: `${asPromptArray(event.systemPrompt).join('\n\n')}\n\n${text}` };
84
+ }
85
+
86
+ export interface StringArraySpec {
87
+ minItems?: number;
88
+ description?: string;
89
+ }
90
+
91
+ // One string-array argument in the active host's dialect. pi only requires an
92
+ // object, so a JSON Schema literal suffices and no `typebox` import is needed.
93
+ export function stringArrayToolParams(pi: ExtensionApi, name: string, spec: StringArraySpec = {}): ToolParams {
94
+ if (!isPiHost(pi)) {
95
+ const { z } = pi.zod as { z: ZodFactory['z'] };
96
+ const array = z.array(z.string());
97
+ const constrained = spec.minItems ? array.min(spec.minItems) : array;
98
+ return z.object({ [name]: spec.description ? constrained.describe(spec.description) : constrained });
99
+ }
100
+ const array: Record<string, unknown> = { type: 'array', items: { type: 'string' } };
101
+ if (spec.minItems) array.minItems = spec.minItems;
102
+ if (spec.description) array.description = spec.description;
103
+ return { type: 'object', properties: { [name]: array }, required: [name] };
104
+ }
@@ -0,0 +1,12 @@
1
+ // extensions/omp/omp-prompt.ts — OMP's system-prompt surface.
2
+ // The subagent marker OMP puts in a delegated agent's prompt.
3
+ export const OMP_SUBAGENT_MARKER = 'You are operating on a piece of work assigned to you by the main agent.';
4
+ export function asPromptArray(systemPrompt) {
5
+ return Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt];
6
+ }
7
+ export function systemPromptIncludes(systemPrompt, marker) {
8
+ return asPromptArray(systemPrompt).some((prompt) => typeof prompt === 'string' && prompt.includes(marker));
9
+ }
10
+ export function isOmpSubagentPrompt(systemPrompt) {
11
+ return systemPromptIncludes(systemPrompt, OMP_SUBAGENT_MARKER);
12
+ }
@@ -1,22 +1,31 @@
1
- // Plugin settings reader for the Tersio extensions.
2
- // Reads omp's persisted plugin state (~/.omp/plugins/omp-plugins.lock.json),
3
- // written by `omp plugin config set @krtclcdy/tersio <key> <value>` and by
4
- // the installer profile step. Tolerant: any parse failure yields {} so every
5
- // caller falls back to its own default.
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
  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
- function lockPaths() {
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
- return [
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,7 +36,23 @@ 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
58
  const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']);
@@ -61,25 +86,13 @@ const COMBO_MODE_DEFAULTS = {
61
86
  export function saveComboSetup(level) {
62
87
  if (!(level in COMBO_MODE_DEFAULTS))
63
88
  return false;
64
- const lockPath = lockPaths()[0];
65
- let lock = {};
66
- if (existsSync(lockPath)) {
67
- try {
68
- lock = JSON.parse(readFileSync(lockPath, 'utf8'));
69
- }
70
- catch {
71
- lock = {};
72
- }
73
- }
74
- lock.plugins ||= {};
75
- lock.settings ||= {};
76
- lock.settings[PLUGIN_NAME] = {
77
- ...lock.settings[PLUGIN_NAME],
89
+ const file = tersioSettingsFile();
90
+ mkdirSync(path.dirname(file), { recursive: true });
91
+ writeFileSync(file, `${JSON.stringify({
92
+ ...readPluginSettings(),
78
93
  comboDefault: level,
79
94
  ...COMBO_MODE_DEFAULTS[level],
80
95
  comboSetupComplete: true,
81
- };
82
- mkdirSync(path.dirname(lockPath), { recursive: true });
83
- writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf8');
96
+ }, null, 2)}\n`, 'utf8');
84
97
  return true;
85
98
  }
@@ -1,25 +1,35 @@
1
- // Plugin settings reader for the Tersio extensions.
2
- // Reads omp's persisted plugin state (~/.omp/plugins/omp-plugins.lock.json),
3
- // written by `omp plugin config set @krtclcdy/tersio <key> <value>` and by
4
- // the installer profile step. Tolerant: any parse failure yields {} so every
5
- // caller falls back to its own default.
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
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
- function lockPaths(): string[] {
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
- return [
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,7 +37,23 @@ 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']);
@@ -69,20 +95,13 @@ const COMBO_MODE_DEFAULTS = {
69
95
 
70
96
  export function saveComboSetup(level: string): boolean {
71
97
  if (!(level in COMBO_MODE_DEFAULTS)) return false;
72
- const lockPath = lockPaths()[0];
73
- let lock: { plugins?: Record<string, unknown>; settings?: Record<string, Record<string, unknown>> } = {};
74
- if (existsSync(lockPath)) {
75
- try { lock = JSON.parse(readFileSync(lockPath, 'utf8')) as typeof lock; } catch { lock = {}; }
76
- }
77
- lock.plugins ||= {};
78
- lock.settings ||= {};
79
- lock.settings[PLUGIN_NAME] = {
80
- ...lock.settings[PLUGIN_NAME],
98
+ const file = tersioSettingsFile();
99
+ mkdirSync(path.dirname(file), { recursive: true });
100
+ writeFileSync(file, `${JSON.stringify({
101
+ ...readPluginSettings(),
81
102
  comboDefault: level,
82
103
  ...COMBO_MODE_DEFAULTS[level as keyof typeof COMBO_MODE_DEFAULTS],
83
104
  comboSetupComplete: true,
84
- };
85
- mkdirSync(path.dirname(lockPath), { recursive: true });
86
- writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf8');
105
+ }, null, 2)}\n`, 'utf8');
87
106
  return true;
88
107
  }
@@ -1,17 +1,14 @@
1
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.
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
- // 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.
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: compact tuples (new) and ModelPrice objects
30
- // (written by older refreshes) — a version bump never orphans the cache.
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[model] ?? table.exact[model.toLowerCase()]);
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 = model.toLowerCase();
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, keep every id with valid input/output rates, write
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 or missing. Readers keep
157
- // using the stale file (or the built-in table) — pricing never blocks.
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
- // 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.
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
- // Live cache refreshes lazily: readers use the file even when stale, and only
24
- // one refresh runs per process. `tersio update` still forces a fresh fetch.
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: compact tuples (new) and ModelPrice objects
51
- // (written by older refreshes) — a version bump never orphans the cache.
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[model] ?? table.exact[model.toLowerCase()]);
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 = model.toLowerCase();
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, keep every id with valid input/output rates, write
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 or missing. Readers keep
164
- // using the stale file (or the built-in table) — pricing never blocks.
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 its own store.
2
- // RTK records every invocation (input/output/saved tokens, savings %, exec
3
- // time) in history.db; `rtk gain` renders the same rows. We read them with
4
- // the sqlite3 CLI (present on macOS; best-effort elsewhere) so the dashboard
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
- // RTK stores the whole command line, and `rtk ls -d` with a long path list
19
- // runs to tens of KB. The table shows one clipped cell, so bound the string
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
- // The query() reader is line- and tab-delimited, and RTK stores heredocs and
23
- // `node -e` scripts verbatim (~67 rows here), so one logical row can arrive as
24
- // several lines and derail every row after it. Fold whitespace into spaces
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,9 +39,8 @@ export function readRtkGain(limit = 10, cutoffMs) {
49
39
  try {
50
40
  if (!fs.existsSync(rtkDbPath()))
51
41
  return EMPTY;
52
- // View-level cutoff only: rows stay in rtk's own database untouched.
53
- // strftime('%s') normalizes ISO-8601 timestamps to epoch seconds; rows
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
  : '';
@@ -1,15 +1,7 @@
1
- // extensions/shared/rtk-gain.ts — measured RTK savings from its own store.
2
- // RTK records every invocation (input/output/saved tokens, savings %, exec
3
- // time) in history.db; `rtk gain` renders the same rows. We read them with
4
- // the sqlite3 CLI (present on macOS; best-effort elsewhere) so the dashboard
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
- // RTK stores the whole command line, and `rtk ls -d` with a long path list
38
- // runs to tens of KB. The table shows one clipped cell, so bound the string
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
- // The query() reader is line- and tab-delimited, and RTK stores heredocs and
43
- // `node -e` scripts verbatim (~67 rows here), so one logical row can arrive as
44
- // several lines and derail every row after it. Fold whitespace into spaces
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,9 +60,8 @@ 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: rows stay in rtk's own database untouched.
74
- // strftime('%s') normalizes ISO-8601 timestamps to epoch seconds; rows
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
  : '';