@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.
- package/cli/common.js +13 -1
- package/cli/doctor.js +7 -7
- package/cli/install.js +28 -28
- package/extensions/ai-addons-updater/index.ts +377 -0
- package/extensions/caveman-session/index.ts +161 -0
- package/extensions/combo-toggle/index.ts +217 -0
- package/extensions/lib/utils.ts +162 -0
- package/extensions/rtk-session/index.ts +164 -0
- package/extensions/shared/carbon.ts +123 -0
- package/extensions/shared/mode-reinforcement.ts +29 -0
- package/extensions/shared/plugin-settings.ts +57 -0
- package/extensions/shared/pricing.ts +168 -0
- package/extensions/shared/rtk-gain.ts +105 -0
- package/extensions/shared/session-state.ts +201 -0
- package/extensions/shared/types.ts +85 -0
- package/extensions/shared/usage-ledger.ts +406 -0
- package/extensions/shared/usage-store.ts +325 -0
- package/extensions/tersio-commands/index.ts +110 -0
- package/package.json +19 -24
- package/extensions/ai-addons-updater/index.js +0 -331
- package/extensions/caveman-session/index.js +0 -152
- package/extensions/combo-toggle/index.js +0 -165
- package/extensions/lib/utils.js +0 -154
- package/extensions/rtk-session/index.js +0 -148
- package/extensions/shared/carbon.js +0 -88
- package/extensions/shared/mode-reinforcement.js +0 -26
- package/extensions/shared/plugin-settings.js +0 -51
- package/extensions/shared/pricing.js +0 -163
- package/extensions/shared/rtk-gain.js +0 -79
- package/extensions/shared/session-state.js +0 -177
- package/extensions/shared/types.js +0 -3
- package/extensions/shared/usage-ledger.js +0 -354
- package/extensions/shared/usage-store.js +0 -294
- 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
|
+
}
|