@krtclcdy/tersio 2.19.0 → 2.20.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 (34) hide show
  1. package/cli/common.js +13 -1
  2. package/cli/doctor.js +7 -7
  3. package/cli/install.js +28 -28
  4. package/extensions/ai-addons-updater/index.ts +377 -0
  5. package/extensions/caveman-session/index.ts +161 -0
  6. package/extensions/combo-toggle/index.ts +217 -0
  7. package/extensions/lib/utils.ts +162 -0
  8. package/extensions/rtk-session/index.ts +164 -0
  9. package/extensions/shared/carbon.ts +123 -0
  10. package/extensions/shared/mode-reinforcement.ts +29 -0
  11. package/extensions/shared/plugin-settings.ts +57 -0
  12. package/extensions/shared/pricing.ts +168 -0
  13. package/extensions/shared/rtk-gain.ts +105 -0
  14. package/extensions/shared/session-state.ts +201 -0
  15. package/extensions/shared/types.ts +85 -0
  16. package/extensions/shared/usage-ledger.ts +406 -0
  17. package/extensions/shared/usage-store.ts +325 -0
  18. package/extensions/tersio-commands/index.ts +110 -0
  19. package/package.json +19 -24
  20. package/extensions/ai-addons-updater/index.js +0 -331
  21. package/extensions/caveman-session/index.js +0 -152
  22. package/extensions/combo-toggle/index.js +0 -165
  23. package/extensions/lib/utils.js +0 -154
  24. package/extensions/rtk-session/index.js +0 -148
  25. package/extensions/shared/carbon.js +0 -88
  26. package/extensions/shared/mode-reinforcement.js +0 -26
  27. package/extensions/shared/plugin-settings.js +0 -51
  28. package/extensions/shared/pricing.js +0 -163
  29. package/extensions/shared/rtk-gain.js +0 -79
  30. package/extensions/shared/session-state.js +0 -177
  31. package/extensions/shared/types.js +0 -3
  32. package/extensions/shared/usage-ledger.js +0 -354
  33. package/extensions/shared/usage-store.js +0 -294
  34. package/extensions/tersio-commands/index.js +0 -102
@@ -0,0 +1,164 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, normalizeMode, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.ts';
4
+ import { readRtkDefault } from '../shared/plugin-settings.ts';
5
+ import type { ExtensionApi, ExtensionCtx, InputEvent, SessionEntry, SystemPromptEvent } from '../shared/types.ts';
6
+
7
+ const DEFAULT_ENABLED = false;
8
+
9
+ function asBoolean(value: unknown): boolean | null {
10
+ if (typeof value === 'boolean') return value;
11
+ if (value === 'on' || value === 'true') return true;
12
+ if (value === 'off' || value === 'false') return false;
13
+ return null;
14
+ }
15
+
16
+ function resolveEnabled(entries: SessionEntry[] | null | undefined): boolean | null {
17
+ return lastCustomValue(entries, 'rtk-mode', (data) => asBoolean(data?.enabled));
18
+ }
19
+
20
+ const IS_WINDOWS = process.platform === 'win32';
21
+ const HOME = os.homedir();
22
+ const RTK_BINARY = path.join(HOME, '.bun', 'bin', IS_WINDOWS ? 'rtk.exe' : 'rtk');
23
+
24
+ const RTK_PROMPT = `RTK mode active for this session.
25
+ Use Rust Token Killer for shell output that would otherwise be noisy. Prefer explicit RTK commands in bash: \`rtk git status\`, \`rtk git diff\`, \`rtk read <file>\`, \`rtk grep <pattern> <path>\`, \`rtk find <glob> <path>\`, \`rtk test <cmd...>\`, \`rtk tsc\`, \`rtk lint\`, or \`rtk <tool> ...\` for supported dev commands.
26
+ Do not use RTK when exact raw output is required, when a specialized OMP tool is required by system policy, or when the command changes state and RTK would hide important confirmation text. Specialized OMP tools still win: read/glob/grep/edit/lsp stay preferred over shell equivalents.`;
27
+
28
+ interface ZodChain {
29
+ min: (n: number) => ZodChain;
30
+ describe: (text: string) => ZodChain;
31
+ }
32
+
33
+ export default function rtkSessionExtension(pi: ExtensionApi): void {
34
+ const { z } = pi.zod as { z: { object: (shape: Record<string, unknown>) => Record<string, unknown>; array: (el: unknown) => ZodChain; string: () => ZodChain } };
35
+ let enabled = DEFAULT_ENABLED;
36
+ let isActive = false;
37
+ let lastCtx: ExtensionCtx | undefined = undefined;
38
+
39
+ function syncStatus(ctx?: ExtensionCtx): void {
40
+ lastCtx = paintableCtx(lastCtx, ctx);
41
+ const c = lastCtx;
42
+ if (!c?.ui?.setStatus) return;
43
+ // Combo owns the bar when any preset is active; keep ours empty to avoid duplication.
44
+ if (isComboPresetActive() || !enabled) {
45
+ c.ui.setStatus('rtk', undefined);
46
+ return;
47
+ }
48
+ paintStatusBar(c.ui, 'rtk', '⚡', 'rtk: ON', isActive);
49
+ }
50
+
51
+ function setEnabled(next: unknown, ctx?: ExtensionCtx): void {
52
+ enabled = Boolean(next);
53
+ pi.appendEntry?.('rtk-mode', { enabled });
54
+ setSharedComboMode('rtk', enabled);
55
+ syncStatus(ctx);
56
+ const active = activeModesSummary(getSharedComboState());
57
+ ctx?.ui?.notify?.(enabled ? `RTK on — compact shell output for this session. Active: ${active}.` : `RTK off. Active: ${active}.`, 'info');
58
+ }
59
+
60
+ pi.setLabel?.('RTK session toggle');
61
+
62
+ // Live mirror: a /tersio or /combo switch publishes shared state — adopt
63
+ // it at once so the next turn and the rtk_run gate see it, no reload.
64
+ function syncFromShared(state: { rtk: string }): void {
65
+ enabled = state.rtk === 'on';
66
+ syncStatus();
67
+ }
68
+ setSharedComboListener(syncFromShared);
69
+ pi.registerCommand?.('rtk', {
70
+ description: 'Toggle RTK compact shell-output guidance for this session',
71
+ handler: async (args, ctx) => {
72
+ const arg = String(args || '').trim().toLowerCase();
73
+ if (!arg || arg === 'status') {
74
+ ctx?.ui?.notify?.(`RTK: ${enabled ? 'on' : 'off'}`, 'info');
75
+ return;
76
+ }
77
+ if (['on', 'enable', 'enabled', 'true'].includes(arg)) {
78
+ setEnabled(true, ctx);
79
+ return;
80
+ }
81
+ if (['off', 'disable', 'disabled', 'false'].includes(arg)) {
82
+ setEnabled(false, ctx);
83
+ return;
84
+ }
85
+ ctx?.ui?.notify?.('Usage: /rtk [on|off|status]', 'warning');
86
+ },
87
+ });
88
+
89
+ pi.registerTool?.({
90
+ name: 'rtk_run',
91
+ label: 'RTK Run',
92
+ description: 'Run the installed `rtk` binary for compact command output when RTK mode is enabled.',
93
+ parameters: z.object({
94
+ args: z.array(z.string()).min(1).describe("Arguments passed to rtk, e.g. ['git','status'] or ['read','src/index.ts']"),
95
+ }),
96
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
97
+ if (!enabled) {
98
+ return {
99
+ isError: true,
100
+ content: [{ type: 'text', text: 'RTK mode is off. Run /rtk on for this session, or use bash explicitly.' }],
101
+ details: { enabled },
102
+ };
103
+ }
104
+ onUpdate?.({ content: [{ type: 'text', text: `rtk ${params.args.join(' ')}` }], details: { phase: 'start' } });
105
+ const result = await pi.exec!(RTK_BINARY, params.args, { signal, cwd: ctx?.cwd || pi.cwd });
106
+ const text = [result.stdout, result.stderr].filter(Boolean).join('\n');
107
+ return {
108
+ isError: result.code !== 0,
109
+ content: [{ type: 'text', text: text || `rtk exited ${result.code}` }],
110
+ details: { code: result.code, killed: result.killed, enabled },
111
+ };
112
+ },
113
+ });
114
+
115
+ pi.on<InputEvent>('input', async (event) => {
116
+ if (event?.source === 'extension') return;
117
+ const t = normalizeInputCommand(event?.text);
118
+ if (t === 'rtk on' || t === 'use rtk') setEnabled(true);
119
+ if (t === 'rtk off' || t === 'stop rtk') setEnabled(false);
120
+ });
121
+ function restoreEnabled(ctx?: ExtensionCtx): void {
122
+ const entries = sessionEntries(ctx);
123
+ // Publish the persisted combo state (incl. combo-level) before painting:
124
+ // bar suppression reads the in-process bridge, which is empty in a fresh
125
+ // host until the combo extension reconciles — and its reconcile is
126
+ // UI-gated. Deriving it here makes suppression independent of load order.
127
+ reconcileSharedComboEntries(entries);
128
+ // Persisted session state wins; a fresh session falls back to the
129
+ // installer/user-configured default (off unless configured).
130
+ const persisted = resolveEnabled(entries);
131
+ enabled = typeof persisted === 'boolean' ? persisted : readRtkDefault();
132
+ syncStatus(ctx);
133
+ }
134
+
135
+ pi.on('session_start', async (_event, ctx) => {
136
+ restoreEnabled(ctx);
137
+ ctx?.ui?.notify?.(`RTK loaded: ${enabled ? 'on' : 'off'}`, 'info');
138
+ });
139
+
140
+ pi.on('session_branch', async (_event, ctx) => {
141
+ restoreEnabled(ctx);
142
+ });
143
+
144
+ pi.on('session_tree', async (_event, ctx) => {
145
+ restoreEnabled(ctx);
146
+ });
147
+
148
+ pi.on('agent_start', async (_event, ctx) => {
149
+ isActive = true;
150
+ syncStatus(ctx);
151
+ });
152
+
153
+ pi.on('agent_end', async (_event, ctx) => {
154
+ isActive = false;
155
+ syncStatus(ctx);
156
+ });
157
+
158
+ pi.on<SystemPromptEvent>('before_agent_start', async (event) => {
159
+ const active = isOmpSubagentPrompt(event.systemPrompt) ? getSharedComboState().rtk === 'on' : enabled;
160
+ if (!active) return;
161
+ const base = asPromptArray(event.systemPrompt);
162
+ return { systemPrompt: [...base, RTK_PROMPT] };
163
+ });
164
+ }
@@ -0,0 +1,123 @@
1
+ // extensions/shared/carbon.ts — CO2/energy footprints, EcoLogits-style.
2
+ //
3
+ // Port of the EcoLogits v0.8.2 LLM impact model (genai-impact/ecologits,
4
+ // MPL-2.0 — see ECO_NOTICE.md), following the same served-estimate approach
5
+ // as honey-for-devs' hooks/eco.js: the faithful single-stream (batch-1)
6
+ // ceiling divided by serving_concurrency for continuous-batching amortization.
7
+ //
8
+ // Model params come from EcoLogits' registry where it covers the model;
9
+ // otherwise a documented class borrow (never a silent guess — every borrow
10
+ // carries its source). Unknown ids fall to the gpt-4o default. Everything
11
+ // rendered from here must carry ~est. and never merge with measured figures.
12
+
13
+ export interface ModelCarbon {
14
+ activeB: number;
15
+ totalB: number;
16
+ provider: string;
17
+ source: 'registry' | 'borrowed' | 'default';
18
+ }
19
+
20
+ // EcoLogits 0.8.2 constants — kWh, kgCO2eq, seconds, GB.
21
+ const Q_BITS = 4;
22
+ const E_ALPHA = 8.91e-8;
23
+ const E_BETA = 1.43e-6;
24
+ const L_ALPHA = 8.02e-4;
25
+ const L_BETA = 2.23e-2;
26
+ const GPU_MEM = 80;
27
+ const GPU_EMB_GWP = 143;
28
+ const SRV_GPUS = 8;
29
+ const SRV_POWER = 1;
30
+ const SRV_EMB_GWP = 3000;
31
+ const LIFETIME = 5 * 365 * 24 * 3600;
32
+ const PUE = 1.2;
33
+
34
+ // Per-provider grid intensity, gCO2eq/kWh (location-based research values).
35
+ const GRIDS: Record<string, number> = {
36
+ anthropic: 500,
37
+ openai: 400,
38
+ google: 330,
39
+ default: 450,
40
+ };
41
+
42
+ // Serving amortization: single-stream ceiling ÷ concurrency ≈ served impact.
43
+ export const SERVING_CONCURRENCY = 32;
44
+
45
+ interface RegistryEntry {
46
+ match?: string;
47
+ exact?: string;
48
+ active: number;
49
+ total: number;
50
+ provider: string;
51
+ source: 'registry' | 'borrowed';
52
+ }
53
+
54
+ // Registry values verbatim from EcoLogits' model_repository; borrows cite
55
+ // the public param disclosure they stand in for (MoE active/total).
56
+ const TABLE: RegistryEntry[] = [
57
+ { match: 'opus', active: 625.0, total: 2000, provider: 'anthropic', source: 'registry' },
58
+ { match: 'sonnet', active: 137.5, total: 440, provider: 'anthropic', source: 'registry' },
59
+ { match: 'haiku', active: 18.0, total: 18.0, provider: 'anthropic', source: 'registry' },
60
+ { match: 'gpt-5', active: 93.75, total: 300, provider: 'openai', source: 'registry' },
61
+ { match: 'gpt', active: 137.5, total: 440, provider: 'openai', source: 'registry' },
62
+ { match: 'gemini', active: 137.5, total: 440, provider: 'google', source: 'registry' },
63
+ { match: 'deepseek', active: 37, total: 671, provider: 'default', source: 'borrowed' },
64
+ { match: 'qwen', active: 22, total: 235, provider: 'default', source: 'borrowed' },
65
+ { match: 'glm', active: 37, total: 671, provider: 'default', source: 'borrowed' },
66
+ { match: 'kimi', active: 32, total: 1000, provider: 'default', source: 'borrowed' },
67
+ { match: 'muse', active: 70, total: 70, provider: 'default', source: 'borrowed' },
68
+ { match: 'flash', active: 18.0, total: 18.0, provider: 'default', source: 'borrowed' },
69
+ ];
70
+
71
+ const DEFAULT_ENTRY = { active: 137.5, total: 440, provider: 'openai' };
72
+
73
+ export function carbonParamsFor(model: string): ModelCarbon {
74
+ const name = model.toLowerCase();
75
+ for (const row of TABLE) {
76
+ if (row.match && name.includes(row.match)) {
77
+ return { activeB: row.active, totalB: row.total, provider: row.provider, source: row.source };
78
+ }
79
+ }
80
+ return { activeB: DEFAULT_ENTRY.active, totalB: DEFAULT_ENTRY.total, provider: DEFAULT_ENTRY.provider, source: 'default' };
81
+ }
82
+
83
+ export interface Footprint {
84
+ energyWh: number;
85
+ gco2: number;
86
+ gco2Ceiling: number;
87
+ concurrency: number;
88
+ provider: string;
89
+ paramSource: 'registry' | 'borrowed' | 'default';
90
+ }
91
+
92
+ function impacts(activeB: number, totalB: number, outTokens: number, gridKg: number): { energyKwh: number; gco2: number } {
93
+ const gpuEnergy = outTokens * (E_ALPHA * activeB + E_BETA);
94
+ const latency = outTokens * (L_ALPHA * activeB + L_BETA);
95
+ const gpuCount = Math.ceil(((1.2 * totalB * Q_BITS) / 8 / GPU_MEM));
96
+ const serverEnergy = (latency / 3600) * SRV_POWER * (gpuCount / SRV_GPUS);
97
+ const energyKwh = PUE * (serverEnergy + gpuCount * gpuEnergy);
98
+ const embGwp = (latency / LIFETIME) * ((gpuCount / SRV_GPUS) * SRV_EMB_GWP + gpuCount * GPU_EMB_GWP);
99
+ return { energyKwh, gco2: (energyKwh * gridKg + embGwp) * 1000 };
100
+ }
101
+
102
+ export function footprintFor(model: string, outputTokens: number): Footprint {
103
+ const p = carbonParamsFor(model);
104
+ const gridKg = (GRIDS[p.provider] ?? GRIDS.default) / 1000;
105
+ const ceiling = impacts(p.activeB, p.totalB, Math.max(0, Math.floor(outputTokens)), gridKg);
106
+ const b = SERVING_CONCURRENCY > 0 ? SERVING_CONCURRENCY : 1;
107
+ return {
108
+ energyWh: (ceiling.energyKwh / b) * 1000,
109
+ gco2: ceiling.gco2 / b,
110
+ gco2Ceiling: ceiling.gco2,
111
+ concurrency: b,
112
+ provider: p.provider,
113
+ paramSource: p.source,
114
+ };
115
+ }
116
+
117
+ export function co2GramsFor(model: string, outputTokens: number): number {
118
+ return footprintFor(model, outputTokens).gco2;
119
+ }
120
+
121
+ export function energyWhFor(model: string, outputTokens: number): number {
122
+ return footprintFor(model, outputTokens).energyWh;
123
+ }
@@ -0,0 +1,29 @@
1
+ import { asPromptArray, reconcileSharedComboEntries, sessionEntries, systemPromptIncludes } from './session-state.ts';
2
+ import type { ExtensionApi, SystemPromptEvent } from './types.ts';
3
+
4
+ const MARKER = 'SUPREME TOKEN SAVER MODES ACTIVE';
5
+
6
+ function instruction(state: { caveman: string; rtk: string; ponytail: string }): string | null {
7
+ const modes = [
8
+ state.caveman !== 'off' && `caveman=${state.caveman}`,
9
+ state.rtk === 'on' && 'rtk=on',
10
+ state.ponytail !== 'off' && `ponytail=${state.ponytail}`,
11
+ ].filter(Boolean);
12
+ if (!modes.length) return null;
13
+
14
+ return `${MARKER}: ${modes.join(' · ')}. Keep these active for the entire response: concise Caveman prose, RTK for eligible noisy shell output, and the smallest correct Ponytail solution. Do not weaken or disable a mode unless the user explicitly asks.`;
15
+ }
16
+
17
+ export default function modeReinforcementExtension(pi: ExtensionApi): void {
18
+ pi.setLabel?.('Tersio mode reinforcement');
19
+
20
+ pi.on('before_agent_start', async (event: SystemPromptEvent, ctx) => {
21
+ if (!ctx?.hasUI) return;
22
+ const text = instruction(reconcileSharedComboEntries(sessionEntries(ctx)));
23
+ if (!text) return;
24
+
25
+ const base = asPromptArray(event.systemPrompt);
26
+ if (systemPromptIncludes(base, MARKER)) return;
27
+ return { systemPrompt: [...base, text] };
28
+ });
29
+ }
@@ -0,0 +1,57 @@
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.
6
+
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ export const PLUGIN_NAME = '@krtclcdy/tersio';
11
+
12
+ function lockPaths(): string[] {
13
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
14
+ return [
15
+ path.join(os.homedir(), '.omp', 'plugins', 'omp-plugins.lock.json'),
16
+ 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()) {
23
+ if (!existsSync(p)) continue;
24
+ try {
25
+ const config = JSON.parse(readFileSync(p, 'utf8')) as { settings?: Record<string, Record<string, unknown>> };
26
+ const values = config.settings?.[PLUGIN_NAME];
27
+ if (values && typeof values === 'object' && !Array.isArray(values)) return values;
28
+ } catch { /* tolerate corrupt file */ }
29
+ }
30
+ return {};
31
+ }
32
+
33
+ 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', 'review']);
36
+
37
+ function readStringDefault(key: string, valid: Set<string>): string {
38
+ const raw = readPluginSettings()[key];
39
+ return typeof raw === 'string' && valid.has(raw) ? raw : 'off';
40
+ }
41
+
42
+ export function readComboDefault(): string {
43
+ return readStringDefault('comboDefault', COMBO_LEVELS);
44
+ }
45
+
46
+ export function readCavemanDefault(): string {
47
+ return readStringDefault('cavemanDefault', CAVEMAN_MODES);
48
+ }
49
+
50
+ export function readRtkDefault(): boolean {
51
+ const raw = readPluginSettings().rtkDefault;
52
+ return typeof raw === 'boolean' ? raw : false;
53
+ }
54
+
55
+ export function readPonytailDefault(): string {
56
+ return readStringDefault('ponytailDefault', PONYTAIL_MODES);
57
+ }
@@ -0,0 +1,168 @@
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.
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ export interface ModelPrice {
12
+ input: number;
13
+ output: number;
14
+ cacheRead: number;
15
+ cacheWrite: number;
16
+ }
17
+
18
+ // Sonnet-class default for unpriced models; always paired with known:false.
19
+ export const DEFAULT_PRICE: ModelPrice = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
20
+
21
+ const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
22
+ // Live cache refreshes lazily: readers use the file even when stale, and only
23
+ // one refresh runs per process. `tersio update` still forces a fresh fetch.
24
+ const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
25
+ let refreshInflight: Promise<boolean> | null = null;
26
+
27
+ export function pricesCachePath(): string {
28
+ const override = process.env.TERSIO_PRICES_FILE;
29
+ if (override) return override;
30
+ return path.join(os.homedir(), '.omp', 'plugins', 'tersio-prices.json');
31
+ }
32
+
33
+ export function pricesUrl(): string {
34
+ return process.env.TERSIO_PRICES_URL || LITELLM_URL;
35
+ }
36
+
37
+ export interface LivePrices {
38
+ fetchedAt: number;
39
+ // Compact tuples [input, output, cacheRead, cacheWrite] per LiteLLM id —
40
+ // the full feed caches to ~1MB instead of multi-MB objects.
41
+ exact: Record<string, [number, number, number, number]>;
42
+ deprecated?: string[];
43
+ }
44
+
45
+ function toPrice(t: [number, number, number, number]): ModelPrice {
46
+ return { input: t[0], output: t[1], cacheRead: t[2], cacheWrite: t[3] };
47
+ }
48
+
49
+ // Tolerate both cache shapes: compact tuples (new) and ModelPrice objects
50
+ // (written by older refreshes) — a version bump never orphans the cache.
51
+ function asPrice(v: unknown): ModelPrice | null {
52
+ if (Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) {
53
+ return toPrice(v as [number, number, number, number]);
54
+ }
55
+ if (v && typeof v === 'object') {
56
+ const o = v as Record<string, unknown>;
57
+ if (['input', 'output', 'cacheRead', 'cacheWrite'].every((k) => typeof o[k] === 'number' && Number.isFinite(o[k]) && (o[k] as number) >= 0)) {
58
+ return o as unknown as ModelPrice;
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+
64
+ export function loadLivePrices(): LivePrices | null {
65
+ let raw: string;
66
+ try {
67
+ raw = fs.readFileSync(pricesCachePath(), 'utf8');
68
+ } catch {
69
+ return null;
70
+ }
71
+ try {
72
+ const parsed = JSON.parse(raw) as { fetchedAt?: unknown; exact?: unknown; deprecated?: unknown };
73
+ if (typeof parsed.fetchedAt !== 'number' || !parsed.exact || typeof parsed.exact !== 'object') return null;
74
+ const exact: Record<string, [number, number, number, number]> = {};
75
+ for (const [k, v] of Object.entries(parsed.exact as Record<string, unknown>)) {
76
+ const p = asPrice(v);
77
+ if (p) exact[k] = [p.input, p.output, p.cacheRead, p.cacheWrite];
78
+ }
79
+ if (!Object.keys(exact).length) return null;
80
+ // Stale entries stay usable; refreshPricesIfStale refreshes in background.
81
+ const deprecated = Array.isArray(parsed.deprecated) ? (parsed.deprecated as unknown[]).filter((d): d is string => typeof d === 'string') : [];
82
+ return { fetchedAt: parsed.fetchedAt, exact, deprecated };
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function num(v: unknown): number | null {
89
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
90
+ }
91
+
92
+ export function priceFor(model: string, live?: LivePrices | null): { price: ModelPrice; known: boolean; live: boolean } {
93
+ const table = live === undefined ? loadLivePrices() : live;
94
+ if (table) {
95
+ const hit = asPrice(table.exact[model] ?? table.exact[model.toLowerCase()]);
96
+ if (hit) return { price: hit, known: true, live: true };
97
+ // Provider-prefixed ids ("azure/gpt-4o", "dashscope/qwen-max"): match the
98
+ // first cached id that ends with the bare model name.
99
+ const name = model.toLowerCase();
100
+ const key = Object.keys(table.exact).find((k) => k.toLowerCase() === name || k.toLowerCase().endsWith(`/${name}`));
101
+ const prefixed = key ? asPrice(table.exact[key]) : null;
102
+ if (prefixed) return { price: prefixed, known: true, live: true };
103
+ }
104
+ return { price: DEFAULT_PRICE, known: false, live: false };
105
+ }
106
+ // Fetch LiteLLM pricing, keep every id with valid input/output rates, write
107
+ // the cache. False on any failure (offline, timeout, shape change) — callers
108
+ // fall back silently.
109
+ export async function refreshPrices(): Promise<boolean> {
110
+ if (refreshInflight) return refreshInflight;
111
+ refreshInflight = (async () => {
112
+ let res: Response;
113
+ try {
114
+ const ctl = new AbortController();
115
+ const timer = setTimeout(() => ctl.abort(), 15000);
116
+ try {
117
+ res = await fetch(pricesUrl(), { signal: ctl.signal });
118
+ } finally {
119
+ clearTimeout(timer);
120
+ }
121
+ if (!res.ok) return false;
122
+ } catch {
123
+ return false;
124
+ }
125
+ let data: Record<string, Record<string, unknown>>;
126
+ try {
127
+ data = (await res.json()) as Record<string, Record<string, unknown>>;
128
+ } catch {
129
+ return false;
130
+ }
131
+ const exact: Record<string, [number, number, number, number]> = {};
132
+ const deprecated: string[] = [];
133
+ for (const [id, row] of Object.entries(data)) {
134
+ if (!row || typeof row !== 'object') continue;
135
+ const input = num(row.input_cost_per_token);
136
+ const output = num(row.output_cost_per_token);
137
+ if (input === null || output === null) continue;
138
+ exact[id] = [
139
+ input * 1e6,
140
+ output * 1e6,
141
+ (num(row.cache_read_input_token_cost) ?? input) * 1e6,
142
+ (num(row.cache_creation_input_token_cost) ?? input) * 1e6,
143
+ ];
144
+ if (typeof row.deprecation_date === 'string' && row.deprecation_date) deprecated.push(id);
145
+ }
146
+ if (!Object.keys(exact).length) return false;
147
+ try {
148
+ fs.mkdirSync(path.dirname(pricesCachePath()), { recursive: true });
149
+ fs.writeFileSync(pricesCachePath(), JSON.stringify({ fetchedAt: Date.now(), exact, deprecated }), 'utf8');
150
+ return true;
151
+ } catch {
152
+ return false;
153
+ }
154
+ })();
155
+ try {
156
+ return await refreshInflight;
157
+ } finally {
158
+ refreshInflight = null;
159
+ }
160
+ }
161
+
162
+ // Fire-and-forget refresh when the cache is stale or missing. Readers keep
163
+ // using the stale file (or the built-in table) — pricing never blocks.
164
+ export function refreshPricesIfStale(): void {
165
+ const live = loadLivePrices();
166
+ if (live && Date.now() - live.fetchedAt <= CACHE_TTL_MS) return;
167
+ void refreshPrices().catch(() => false);
168
+ }
@@ -0,0 +1,105 @@
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.
13
+ import { execFileSync } from 'node:child_process';
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+
18
+ export interface RtkCommandRow {
19
+ command: string;
20
+ count: number;
21
+ saved: number;
22
+ avgPct: number;
23
+ avgMs: number;
24
+ }
25
+
26
+ export interface RtkGain {
27
+ commands: number;
28
+ input: number;
29
+ saved: number;
30
+ avgPct: number;
31
+ totalMs: number;
32
+ byCommand: RtkCommandRow[];
33
+ }
34
+
35
+ const EMPTY: RtkGain = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
36
+
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.
40
+ const MAX_CMD_CHARS = 200;
41
+
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.
46
+ const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
47
+
48
+ export function rtkDbPath(): string {
49
+ const override = process.env.TERSIO_RTK_DB;
50
+ if (override) return override;
51
+ if (process.platform === 'darwin') {
52
+ return path.join(os.homedir(), 'Library', 'Application Support', 'rtk', 'history.db');
53
+ }
54
+ const xdg = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
55
+ return path.join(xdg, 'rtk', 'history.db');
56
+ }
57
+
58
+ function query(db: string, sql: string): string[][] {
59
+ const out = execFileSync('sqlite3', ['-separator', '\t', db, sql], {
60
+ encoding: 'utf8',
61
+ timeout: 5000,
62
+ stdio: ['ignore', 'pipe', 'ignore'],
63
+ });
64
+ return out
65
+ .split('\n')
66
+ .filter((line) => line.trim())
67
+ .map((line) => line.split('\t'));
68
+ }
69
+
70
+ export function readRtkGain(limit = 10, cutoffMs?: number): RtkGain {
71
+ try {
72
+ 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.
76
+ const where = cutoffMs && cutoffMs > 0
77
+ ? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
78
+ : '';
79
+ const [[commands = '0', saved = '0', input = '0', avgPct = '0', totalMs = '0'] = []] = query(
80
+ rtkDbPath(),
81
+ `SELECT COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(SUM(input_tokens),0), COALESCE(AVG(savings_pct),0), COALESCE(SUM(exec_time_ms),0) FROM commands ${where};`,
82
+ );
83
+ const byCommand = query(
84
+ rtkDbPath(),
85
+ `SELECT substr(${CMD_ONE_LINE}, 1, ${MAX_CMD_CHARS}), COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(AVG(savings_pct),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
+ ).map(([command, count, savedRow, pct, ms]) => ({
87
+ command,
88
+ count: Number(count) || 0,
89
+ saved: Number(savedRow) || 0,
90
+ avgPct: Number(pct) || 0,
91
+ avgMs: Number(ms) || 0,
92
+ }));
93
+ const totalIn = Number(input) || 0;
94
+ return {
95
+ commands: Number(commands) || 0,
96
+ saved: Number(saved) || 0,
97
+ input: totalIn,
98
+ avgPct: totalIn ? ((Number(saved) || 0) / totalIn) * 100 : 0,
99
+ totalMs: Number(totalMs) || 0,
100
+ byCommand,
101
+ };
102
+ } catch {
103
+ return EMPTY;
104
+ }
105
+ }