@krtclcdy/tersio 2.18.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 (44) hide show
  1. package/README.md +2 -2
  2. package/cli/common.js +21 -3
  3. package/cli/currency.js +102 -0
  4. package/cli/dashboard.js +54 -2
  5. package/cli/doctor.js +9 -7
  6. package/cli/install.js +35 -101
  7. package/cli/profile.js +92 -0
  8. package/cli/reset.js +4 -2
  9. package/cli/settings.js +122 -0
  10. package/cli/usage.js +38 -24
  11. package/dashboard/app.js +297 -70
  12. package/dashboard/styles.css +58 -1
  13. package/dashboard/template.html +75 -3
  14. package/extensions/ai-addons-updater/index.ts +377 -0
  15. package/extensions/caveman-session/index.ts +161 -0
  16. package/extensions/combo-toggle/index.ts +217 -0
  17. package/extensions/lib/utils.ts +162 -0
  18. package/extensions/rtk-session/index.ts +164 -0
  19. package/extensions/shared/carbon.ts +123 -0
  20. package/extensions/shared/mode-reinforcement.ts +29 -0
  21. package/extensions/shared/plugin-settings.ts +57 -0
  22. package/extensions/shared/pricing.ts +168 -0
  23. package/extensions/shared/rtk-gain.ts +105 -0
  24. package/extensions/shared/session-state.ts +201 -0
  25. package/extensions/shared/types.ts +85 -0
  26. package/extensions/shared/usage-ledger.ts +406 -0
  27. package/extensions/shared/usage-store.ts +325 -0
  28. package/extensions/tersio-commands/index.ts +110 -0
  29. package/package.json +36 -24
  30. package/tersio.js +8 -1
  31. package/extensions/ai-addons-updater/index.js +0 -331
  32. package/extensions/caveman-session/index.js +0 -152
  33. package/extensions/combo-toggle/index.js +0 -165
  34. package/extensions/lib/utils.js +0 -154
  35. package/extensions/rtk-session/index.js +0 -148
  36. package/extensions/shared/carbon.js +0 -88
  37. package/extensions/shared/mode-reinforcement.js +0 -26
  38. package/extensions/shared/plugin-settings.js +0 -51
  39. package/extensions/shared/pricing.js +0 -163
  40. package/extensions/shared/rtk-gain.js +0 -82
  41. package/extensions/shared/session-state.js +0 -177
  42. package/extensions/shared/types.js +0 -3
  43. package/extensions/shared/usage-ledger.js +0 -333
  44. package/extensions/tersio-commands/index.js +0 -102
@@ -1,154 +0,0 @@
1
- // Shared helpers for the installer and the AI add-ons updater.
2
- // Node built-ins only — this module must stay dependency-free.
3
- import { createHash } from 'node:crypto';
4
- import { createWriteStream } from 'node:fs';
5
- import fs from 'node:fs/promises';
6
- import https from 'node:https';
7
- import path from 'node:path';
8
- // Promise.withResolvers is Node 22+; the package supports Node 18+.
9
- // ponytail: swap for the built-in when engines bumps to >=22.
10
- export function withResolvers() {
11
- let resolve;
12
- let reject;
13
- const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
14
- return { promise, resolve, reject };
15
- }
16
- export const RTK_RELEASE_API = 'https://api.github.com/repos/rtk-ai/rtk/releases/latest';
17
- export const CAVEMAN_REMOTE_RULE = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/rules/caveman-activate.md';
18
- const RTK_PLATFORM_SPECS = {
19
- 'win32/x64': { triple: 'x86_64-pc-windows-msvc', ext: '.zip', binary: 'rtk.exe' },
20
- 'linux/x64': { triple: 'x86_64-unknown-linux-musl', ext: '.tar.gz', binary: 'rtk' },
21
- 'linux/arm64': { triple: 'aarch64-unknown-linux-gnu', ext: '.tar.gz', binary: 'rtk' },
22
- 'darwin/x64': { triple: 'x86_64-apple-darwin', ext: '.tar.gz', binary: 'rtk' },
23
- 'darwin/arm64': { triple: 'aarch64-apple-darwin', ext: '.tar.gz', binary: 'rtk' },
24
- };
25
- export function rtkPlatformSpec(platform = process.platform, arch = process.arch) {
26
- return RTK_PLATFORM_SPECS[`${platform}/${arch}`] || null;
27
- }
28
- // Single source for the 3xx+location redirect rule shared by httpsGet/httpsDownload.
29
- function redirectNext(res, url) {
30
- if (res.statusCode !== undefined && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
31
- return new URL(res.headers.location, url).href;
32
- }
33
- return null;
34
- }
35
- export function httpsGet(url, opts = {}) {
36
- const { promise, resolve, reject } = withResolvers();
37
- const maxRedirects = opts.maxRedirects ?? 5;
38
- const req = https.get(url, { headers: { 'User-Agent': 'tersio', Accept: 'application/json,*/*' } }, (res) => {
39
- const next = redirectNext(res, url);
40
- if (next) {
41
- if (maxRedirects <= 0) {
42
- res.resume();
43
- reject(new Error(`Too many redirects fetching ${url}`));
44
- return;
45
- }
46
- res.resume();
47
- resolve(httpsGet(next, { maxRedirects: maxRedirects - 1 }));
48
- return;
49
- }
50
- if (res.statusCode !== 200) {
51
- res.resume();
52
- reject(new Error(`HTTP ${res.statusCode} for ${url}`));
53
- return;
54
- }
55
- let body = '';
56
- // ponytail: streamed accumulation — fine for tens of KB; stream-pipe if assets ever exceed a few MB.
57
- res.setEncoding('utf8');
58
- res.on('data', (chunk) => { body += chunk; });
59
- res.on('end', () => resolve(body));
60
- });
61
- req.on('error', reject);
62
- req.setTimeout(30000, () => req.destroy(new Error(`Timeout fetching ${url}`)));
63
- return promise;
64
- }
65
- export function httpsDownload(url, dest, opts = {}) {
66
- const { promise, resolve, reject } = withResolvers();
67
- const maxRedirects = opts.maxRedirects ?? 5;
68
- const req = https.get(url, { headers: { 'User-Agent': 'tersio', Accept: '*/*' } }, (res) => {
69
- const next = redirectNext(res, url);
70
- if (next) {
71
- if (maxRedirects <= 0) {
72
- res.resume();
73
- reject(new Error(`Too many redirects downloading ${url}`));
74
- return;
75
- }
76
- res.resume();
77
- resolve(httpsDownload(next, dest, { maxRedirects: maxRedirects - 1 }));
78
- return;
79
- }
80
- if (res.statusCode !== 200) {
81
- res.resume();
82
- reject(new Error(`HTTP ${res.statusCode} for ${url}`));
83
- return;
84
- }
85
- const file = createWriteStream(dest);
86
- res.pipe(file);
87
- file.on('finish', () => file.close(() => resolve()));
88
- file.on('error', (err) => {
89
- req.destroy();
90
- res.resume();
91
- reject(err);
92
- });
93
- });
94
- req.on('error', reject);
95
- req.setTimeout(120000, () => req.destroy(new Error(`Timeout downloading ${url}`)));
96
- return promise;
97
- }
98
- export async function fetchJson(url) {
99
- return JSON.parse(await httpsGet(url));
100
- }
101
- export function sha256Hex(text) {
102
- return createHash('sha256').update(text, 'utf8').digest('hex');
103
- }
104
- export async function sha256File(filePath) {
105
- const buf = await fs.readFile(filePath);
106
- return createHash('sha256').update(buf).digest('hex');
107
- }
108
- export function parseChecksum(checksumsText, assetName) {
109
- const target = path.basename(assetName);
110
- for (const line of checksumsText.split(/\r?\n/)) {
111
- const m = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
112
- if (m && path.basename(m[2]) === target)
113
- return m[1].toLowerCase();
114
- }
115
- return null;
116
- }
117
- export async function findFile(dir, name) {
118
- try {
119
- const ents = await fs.readdir(dir, { withFileTypes: true });
120
- for (const e of ents) {
121
- const full = path.join(dir, e.name);
122
- if (e.isDirectory()) {
123
- const found = await findFile(full, name);
124
- if (found)
125
- return found;
126
- }
127
- else if (e.isFile()) {
128
- if (e.name === name)
129
- return full;
130
- // Match rtk-* asset names (e.g. rtk-x86_64-unknown-linux-musl).
131
- if (name === 'rtk' || name === 'rtk.exe') {
132
- const base = e.name.toLowerCase();
133
- if (!/\.(txt|md|json|sha256|sig|asc|pem|crt|license)$/i.test(base)) {
134
- if (base === 'rtk' || base === 'rtk.exe' || /^rtk[-_.]/.test(base))
135
- return full;
136
- }
137
- }
138
- }
139
- }
140
- }
141
- catch { /* ignore */ }
142
- return null;
143
- }
144
- export async function readTextIfExists(p) {
145
- try {
146
- return await fs.readFile(p, 'utf8');
147
- }
148
- catch {
149
- return null;
150
- }
151
- }
152
- export function normalizeRtkVersion(value) {
153
- return String(value || '').replace(/^rtk\s+/i, '').replace(/^v/i, '').trim();
154
- }
@@ -1,148 +0,0 @@
1
- import os from 'node:os';
2
- import path from 'node:path';
3
- import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.js';
4
- import { readRtkDefault } from '../shared/plugin-settings.js';
5
- const DEFAULT_ENABLED = false;
6
- function asBoolean(value) {
7
- if (typeof value === 'boolean')
8
- return value;
9
- if (value === 'on' || value === 'true')
10
- return true;
11
- if (value === 'off' || value === 'false')
12
- return false;
13
- return null;
14
- }
15
- function resolveEnabled(entries) {
16
- return lastCustomValue(entries, 'rtk-mode', (data) => asBoolean(data?.enabled));
17
- }
18
- const IS_WINDOWS = process.platform === 'win32';
19
- const HOME = os.homedir();
20
- const RTK_BINARY = path.join(HOME, '.bun', 'bin', IS_WINDOWS ? 'rtk.exe' : 'rtk');
21
- const RTK_PROMPT = `RTK mode active for this session.
22
- 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.
23
- 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.`;
24
- export default function rtkSessionExtension(pi) {
25
- const { z } = pi.zod;
26
- let enabled = DEFAULT_ENABLED;
27
- let isActive = false;
28
- let lastCtx = undefined;
29
- function syncStatus(ctx) {
30
- lastCtx = paintableCtx(lastCtx, ctx);
31
- const c = lastCtx;
32
- if (!c?.ui?.setStatus)
33
- return;
34
- // Combo owns the bar when any preset is active; keep ours empty to avoid duplication.
35
- if (isComboPresetActive() || !enabled) {
36
- c.ui.setStatus('rtk', undefined);
37
- return;
38
- }
39
- paintStatusBar(c.ui, 'rtk', '⚡', 'rtk: ON', isActive);
40
- }
41
- function setEnabled(next, ctx) {
42
- enabled = Boolean(next);
43
- pi.appendEntry?.('rtk-mode', { enabled });
44
- setSharedComboMode('rtk', enabled);
45
- syncStatus(ctx);
46
- const active = activeModesSummary(getSharedComboState());
47
- ctx?.ui?.notify?.(enabled ? `RTK on — compact shell output for this session. Active: ${active}.` : `RTK off. Active: ${active}.`, 'info');
48
- }
49
- pi.setLabel?.('RTK session toggle');
50
- // Live mirror: a /tersio or /combo switch publishes shared state — adopt
51
- // it at once so the next turn and the rtk_run gate see it, no reload.
52
- function syncFromShared(state) {
53
- enabled = state.rtk === 'on';
54
- syncStatus();
55
- }
56
- setSharedComboListener(syncFromShared);
57
- pi.registerCommand?.('rtk', {
58
- description: 'Toggle RTK compact shell-output guidance for this session',
59
- handler: async (args, ctx) => {
60
- const arg = String(args || '').trim().toLowerCase();
61
- if (!arg || arg === 'status') {
62
- ctx?.ui?.notify?.(`RTK: ${enabled ? 'on' : 'off'}`, 'info');
63
- return;
64
- }
65
- if (['on', 'enable', 'enabled', 'true'].includes(arg)) {
66
- setEnabled(true, ctx);
67
- return;
68
- }
69
- if (['off', 'disable', 'disabled', 'false'].includes(arg)) {
70
- setEnabled(false, ctx);
71
- return;
72
- }
73
- ctx?.ui?.notify?.('Usage: /rtk [on|off|status]', 'warning');
74
- },
75
- });
76
- pi.registerTool?.({
77
- name: 'rtk_run',
78
- label: 'RTK Run',
79
- description: 'Run the installed `rtk` binary for compact command output when RTK mode is enabled.',
80
- parameters: z.object({
81
- args: z.array(z.string()).min(1).describe("Arguments passed to rtk, e.g. ['git','status'] or ['read','src/index.ts']"),
82
- }),
83
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
84
- if (!enabled) {
85
- return {
86
- isError: true,
87
- content: [{ type: 'text', text: 'RTK mode is off. Run /rtk on for this session, or use bash explicitly.' }],
88
- details: { enabled },
89
- };
90
- }
91
- onUpdate?.({ content: [{ type: 'text', text: `rtk ${params.args.join(' ')}` }], details: { phase: 'start' } });
92
- const result = await pi.exec(RTK_BINARY, params.args, { signal, cwd: ctx?.cwd || pi.cwd });
93
- const text = [result.stdout, result.stderr].filter(Boolean).join('\n');
94
- return {
95
- isError: result.code !== 0,
96
- content: [{ type: 'text', text: text || `rtk exited ${result.code}` }],
97
- details: { code: result.code, killed: result.killed, enabled },
98
- };
99
- },
100
- });
101
- pi.on('input', async (event) => {
102
- if (event?.source === 'extension')
103
- return;
104
- const t = normalizeInputCommand(event?.text);
105
- if (t === 'rtk on' || t === 'use rtk')
106
- setEnabled(true);
107
- if (t === 'rtk off' || t === 'stop rtk')
108
- setEnabled(false);
109
- });
110
- function restoreEnabled(ctx) {
111
- const entries = sessionEntries(ctx);
112
- // Publish the persisted combo state (incl. combo-level) before painting:
113
- // bar suppression reads the in-process bridge, which is empty in a fresh
114
- // host until the combo extension reconciles — and its reconcile is
115
- // UI-gated. Deriving it here makes suppression independent of load order.
116
- reconcileSharedComboEntries(entries);
117
- // Persisted session state wins; a fresh session falls back to the
118
- // installer/user-configured default (off unless configured).
119
- const persisted = resolveEnabled(entries);
120
- enabled = typeof persisted === 'boolean' ? persisted : readRtkDefault();
121
- syncStatus(ctx);
122
- }
123
- pi.on('session_start', async (_event, ctx) => {
124
- restoreEnabled(ctx);
125
- ctx?.ui?.notify?.(`RTK loaded: ${enabled ? 'on' : 'off'}`, 'info');
126
- });
127
- pi.on('session_branch', async (_event, ctx) => {
128
- restoreEnabled(ctx);
129
- });
130
- pi.on('session_tree', async (_event, ctx) => {
131
- restoreEnabled(ctx);
132
- });
133
- pi.on('agent_start', async (_event, ctx) => {
134
- isActive = true;
135
- syncStatus(ctx);
136
- });
137
- pi.on('agent_end', async (_event, ctx) => {
138
- isActive = false;
139
- syncStatus(ctx);
140
- });
141
- pi.on('before_agent_start', async (event) => {
142
- const active = isOmpSubagentPrompt(event.systemPrompt) ? getSharedComboState().rtk === 'on' : enabled;
143
- if (!active)
144
- return;
145
- const base = asPromptArray(event.systemPrompt);
146
- return { systemPrompt: [...base, RTK_PROMPT] };
147
- });
148
- }
@@ -1,88 +0,0 @@
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
- // EcoLogits 0.8.2 constants — kWh, kgCO2eq, seconds, GB.
13
- const Q_BITS = 4;
14
- const E_ALPHA = 8.91e-8;
15
- const E_BETA = 1.43e-6;
16
- const L_ALPHA = 8.02e-4;
17
- const L_BETA = 2.23e-2;
18
- const GPU_MEM = 80;
19
- const GPU_EMB_GWP = 143;
20
- const SRV_GPUS = 8;
21
- const SRV_POWER = 1;
22
- const SRV_EMB_GWP = 3000;
23
- const LIFETIME = 5 * 365 * 24 * 3600;
24
- const PUE = 1.2;
25
- // Per-provider grid intensity, gCO2eq/kWh (location-based research values).
26
- const GRIDS = {
27
- anthropic: 500,
28
- openai: 400,
29
- google: 330,
30
- default: 450,
31
- };
32
- // Serving amortization: single-stream ceiling ÷ concurrency ≈ served impact.
33
- export const SERVING_CONCURRENCY = 32;
34
- // Registry values verbatim from EcoLogits' model_repository; borrows cite
35
- // the public param disclosure they stand in for (MoE active/total).
36
- const TABLE = [
37
- { match: 'opus', active: 625.0, total: 2000, provider: 'anthropic', source: 'registry' },
38
- { match: 'sonnet', active: 137.5, total: 440, provider: 'anthropic', source: 'registry' },
39
- { match: 'haiku', active: 18.0, total: 18.0, provider: 'anthropic', source: 'registry' },
40
- { match: 'gpt-5', active: 93.75, total: 300, provider: 'openai', source: 'registry' },
41
- { match: 'gpt', active: 137.5, total: 440, provider: 'openai', source: 'registry' },
42
- { match: 'gemini', active: 137.5, total: 440, provider: 'google', source: 'registry' },
43
- { match: 'deepseek', active: 37, total: 671, provider: 'default', source: 'borrowed' },
44
- { match: 'qwen', active: 22, total: 235, provider: 'default', source: 'borrowed' },
45
- { match: 'glm', active: 37, total: 671, provider: 'default', source: 'borrowed' },
46
- { match: 'kimi', active: 32, total: 1000, provider: 'default', source: 'borrowed' },
47
- { match: 'muse', active: 70, total: 70, provider: 'default', source: 'borrowed' },
48
- { match: 'flash', active: 18.0, total: 18.0, provider: 'default', source: 'borrowed' },
49
- ];
50
- const DEFAULT_ENTRY = { active: 137.5, total: 440, provider: 'openai' };
51
- export function carbonParamsFor(model) {
52
- const name = model.toLowerCase();
53
- for (const row of TABLE) {
54
- if (row.match && name.includes(row.match)) {
55
- return { activeB: row.active, totalB: row.total, provider: row.provider, source: row.source };
56
- }
57
- }
58
- return { activeB: DEFAULT_ENTRY.active, totalB: DEFAULT_ENTRY.total, provider: DEFAULT_ENTRY.provider, source: 'default' };
59
- }
60
- function impacts(activeB, totalB, outTokens, gridKg) {
61
- const gpuEnergy = outTokens * (E_ALPHA * activeB + E_BETA);
62
- const latency = outTokens * (L_ALPHA * activeB + L_BETA);
63
- const gpuCount = Math.ceil(((1.2 * totalB * Q_BITS) / 8 / GPU_MEM));
64
- const serverEnergy = (latency / 3600) * SRV_POWER * (gpuCount / SRV_GPUS);
65
- const energyKwh = PUE * (serverEnergy + gpuCount * gpuEnergy);
66
- const embGwp = (latency / LIFETIME) * ((gpuCount / SRV_GPUS) * SRV_EMB_GWP + gpuCount * GPU_EMB_GWP);
67
- return { energyKwh, gco2: (energyKwh * gridKg + embGwp) * 1000 };
68
- }
69
- export function footprintFor(model, outputTokens) {
70
- const p = carbonParamsFor(model);
71
- const gridKg = (GRIDS[p.provider] ?? GRIDS.default) / 1000;
72
- const ceiling = impacts(p.activeB, p.totalB, Math.max(0, Math.floor(outputTokens)), gridKg);
73
- const b = SERVING_CONCURRENCY > 0 ? SERVING_CONCURRENCY : 1;
74
- return {
75
- energyWh: (ceiling.energyKwh / b) * 1000,
76
- gco2: ceiling.gco2 / b,
77
- gco2Ceiling: ceiling.gco2,
78
- concurrency: b,
79
- provider: p.provider,
80
- paramSource: p.source,
81
- };
82
- }
83
- export function co2GramsFor(model, outputTokens) {
84
- return footprintFor(model, outputTokens).gco2;
85
- }
86
- export function energyWhFor(model, outputTokens) {
87
- return footprintFor(model, outputTokens).energyWh;
88
- }
@@ -1,26 +0,0 @@
1
- import { asPromptArray, reconcileSharedComboEntries, sessionEntries, systemPromptIncludes } from './session-state.js';
2
- const MARKER = 'SUPREME TOKEN SAVER MODES ACTIVE';
3
- function instruction(state) {
4
- const modes = [
5
- state.caveman !== 'off' && `caveman=${state.caveman}`,
6
- state.rtk === 'on' && 'rtk=on',
7
- state.ponytail !== 'off' && `ponytail=${state.ponytail}`,
8
- ].filter(Boolean);
9
- if (!modes.length)
10
- return null;
11
- 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.`;
12
- }
13
- export default function modeReinforcementExtension(pi) {
14
- pi.setLabel?.('Tersio mode reinforcement');
15
- pi.on('before_agent_start', async (event, ctx) => {
16
- if (!ctx?.hasUI)
17
- return;
18
- const text = instruction(reconcileSharedComboEntries(sessionEntries(ctx)));
19
- if (!text)
20
- return;
21
- const base = asPromptArray(event.systemPrompt);
22
- if (systemPromptIncludes(base, MARKER))
23
- return;
24
- return { systemPrompt: [...base, text] };
25
- });
26
- }
@@ -1,51 +0,0 @@
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
- import { existsSync, readFileSync } from 'node:fs';
7
- import os from 'node:os';
8
- import path from 'node:path';
9
- export const PLUGIN_NAME = '@krtclcdy/tersio';
10
- function lockPaths() {
11
- const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
12
- return [
13
- path.join(os.homedir(), '.omp', 'plugins', 'omp-plugins.lock.json'),
14
- 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()) {
20
- if (!existsSync(p))
21
- continue;
22
- try {
23
- const config = JSON.parse(readFileSync(p, 'utf8'));
24
- const values = config.settings?.[PLUGIN_NAME];
25
- if (values && typeof values === 'object' && !Array.isArray(values))
26
- return values;
27
- }
28
- catch { /* tolerate corrupt file */ }
29
- }
30
- return {};
31
- }
32
- const COMBO_LEVELS = new Set(['off', 'medium', 'balanced', 'max']);
33
- const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'wenyan']);
34
- const PONYTAIL_MODES = new Set(['off', 'lite', 'full', 'ultra', 'review']);
35
- function readStringDefault(key, valid) {
36
- const raw = readPluginSettings()[key];
37
- return typeof raw === 'string' && valid.has(raw) ? raw : 'off';
38
- }
39
- export function readComboDefault() {
40
- return readStringDefault('comboDefault', COMBO_LEVELS);
41
- }
42
- export function readCavemanDefault() {
43
- return readStringDefault('cavemanDefault', CAVEMAN_MODES);
44
- }
45
- export function readRtkDefault() {
46
- const raw = readPluginSettings().rtkDefault;
47
- return typeof raw === 'boolean' ? raw : false;
48
- }
49
- export function readPonytailDefault() {
50
- return readStringDefault('ponytailDefault', PONYTAIL_MODES);
51
- }
@@ -1,163 +0,0 @@
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
- // Sonnet-class default for unpriced models; always paired with known:false.
11
- export const DEFAULT_PRICE = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
12
- const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
13
- // Live cache refreshes lazily: readers use the file even when stale, and only
14
- // one refresh runs per process. `tersio update` still forces a fresh fetch.
15
- const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
16
- let refreshInflight = null;
17
- export function pricesCachePath() {
18
- const override = process.env.TERSIO_PRICES_FILE;
19
- if (override)
20
- return override;
21
- return path.join(os.homedir(), '.omp', 'plugins', 'tersio-prices.json');
22
- }
23
- export function pricesUrl() {
24
- return process.env.TERSIO_PRICES_URL || LITELLM_URL;
25
- }
26
- function toPrice(t) {
27
- return { input: t[0], output: t[1], cacheRead: t[2], cacheWrite: t[3] };
28
- }
29
- // Tolerate both cache shapes: compact tuples (new) and ModelPrice objects
30
- // (written by older refreshes) — a version bump never orphans the cache.
31
- function asPrice(v) {
32
- if (Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) {
33
- return toPrice(v);
34
- }
35
- if (v && typeof v === 'object') {
36
- const o = v;
37
- if (['input', 'output', 'cacheRead', 'cacheWrite'].every((k) => typeof o[k] === 'number' && Number.isFinite(o[k]) && o[k] >= 0)) {
38
- return o;
39
- }
40
- }
41
- return null;
42
- }
43
- export function loadLivePrices() {
44
- let raw;
45
- try {
46
- raw = fs.readFileSync(pricesCachePath(), 'utf8');
47
- }
48
- catch {
49
- return null;
50
- }
51
- try {
52
- const parsed = JSON.parse(raw);
53
- if (typeof parsed.fetchedAt !== 'number' || !parsed.exact || typeof parsed.exact !== 'object')
54
- return null;
55
- const exact = {};
56
- for (const [k, v] of Object.entries(parsed.exact)) {
57
- const p = asPrice(v);
58
- if (p)
59
- exact[k] = [p.input, p.output, p.cacheRead, p.cacheWrite];
60
- }
61
- if (!Object.keys(exact).length)
62
- return null;
63
- // Stale entries stay usable; refreshPricesIfStale refreshes in background.
64
- const deprecated = Array.isArray(parsed.deprecated) ? parsed.deprecated.filter((d) => typeof d === 'string') : [];
65
- return { fetchedAt: parsed.fetchedAt, exact, deprecated };
66
- }
67
- catch {
68
- return null;
69
- }
70
- }
71
- function num(v) {
72
- return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
73
- }
74
- export function priceFor(model, live) {
75
- const table = live === undefined ? loadLivePrices() : live;
76
- if (table) {
77
- const hit = asPrice(table.exact[model] ?? table.exact[model.toLowerCase()]);
78
- if (hit)
79
- return { price: hit, known: true, live: true };
80
- // Provider-prefixed ids ("azure/gpt-4o", "dashscope/qwen-max"): match the
81
- // first cached id that ends with the bare model name.
82
- const name = model.toLowerCase();
83
- const key = Object.keys(table.exact).find((k) => k.toLowerCase() === name || k.toLowerCase().endsWith(`/${name}`));
84
- const prefixed = key ? asPrice(table.exact[key]) : null;
85
- if (prefixed)
86
- return { price: prefixed, known: true, live: true };
87
- }
88
- return { price: DEFAULT_PRICE, known: false, live: false };
89
- }
90
- // Fetch LiteLLM pricing, keep every id with valid input/output rates, write
91
- // the cache. False on any failure (offline, timeout, shape change) — callers
92
- // fall back silently.
93
- export async function refreshPrices() {
94
- if (refreshInflight)
95
- return refreshInflight;
96
- refreshInflight = (async () => {
97
- let res;
98
- try {
99
- const ctl = new AbortController();
100
- const timer = setTimeout(() => ctl.abort(), 15000);
101
- try {
102
- res = await fetch(pricesUrl(), { signal: ctl.signal });
103
- }
104
- finally {
105
- clearTimeout(timer);
106
- }
107
- if (!res.ok)
108
- return false;
109
- }
110
- catch {
111
- return false;
112
- }
113
- let data;
114
- try {
115
- data = (await res.json());
116
- }
117
- catch {
118
- return false;
119
- }
120
- const exact = {};
121
- const deprecated = [];
122
- for (const [id, row] of Object.entries(data)) {
123
- if (!row || typeof row !== 'object')
124
- continue;
125
- const input = num(row.input_cost_per_token);
126
- const output = num(row.output_cost_per_token);
127
- if (input === null || output === null)
128
- continue;
129
- exact[id] = [
130
- input * 1e6,
131
- output * 1e6,
132
- (num(row.cache_read_input_token_cost) ?? input) * 1e6,
133
- (num(row.cache_creation_input_token_cost) ?? input) * 1e6,
134
- ];
135
- if (typeof row.deprecation_date === 'string' && row.deprecation_date)
136
- deprecated.push(id);
137
- }
138
- if (!Object.keys(exact).length)
139
- return false;
140
- try {
141
- fs.mkdirSync(path.dirname(pricesCachePath()), { recursive: true });
142
- fs.writeFileSync(pricesCachePath(), JSON.stringify({ fetchedAt: Date.now(), exact, deprecated }), 'utf8');
143
- return true;
144
- }
145
- catch {
146
- return false;
147
- }
148
- })();
149
- try {
150
- return await refreshInflight;
151
- }
152
- finally {
153
- refreshInflight = null;
154
- }
155
- }
156
- // Fire-and-forget refresh when the cache is stale or missing. Readers keep
157
- // using the stale file (or the built-in table) — pricing never blocks.
158
- export function refreshPricesIfStale() {
159
- const live = loadLivePrices();
160
- if (live && Date.now() - live.fetchedAt <= CACHE_TTL_MS)
161
- return;
162
- void refreshPrices().catch(() => false);
163
- }