@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
@@ -0,0 +1,217 @@
1
+ // /combo session toggle โ€” set all three (caveman, rtk, ponytail) at once.
2
+ // Modes: off | medium | balanced | max
3
+
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { createRequire } from 'node:module';
7
+ import {
8
+ activeModesSummary,
9
+ asPromptArray,
10
+ COMBO_LEVELS,
11
+ getSharedComboState,
12
+ isComboPresetActive,
13
+ isOmpSubagentPrompt,
14
+ normalizeComboLevel,
15
+ paintableCtx,
16
+ reconcileSharedComboEntries,
17
+ sessionEntries,
18
+ setSharedComboLevel,
19
+ setSharedComboListener,
20
+ setSharedComboMode,
21
+ systemPromptIncludes,
22
+ } from '../shared/session-state.ts';
23
+ import { readComboDefault, readPonytailDefault } from '../shared/plugin-settings.ts';
24
+ import type { ComboState, ExtensionApi, ExtensionCtx, SystemPromptEvent } from '../shared/types.ts';
25
+
26
+ const require = createRequire(import.meta.url);
27
+
28
+ const PONYTAIL_FALLBACK_INTENSITY: Record<string, string> = {
29
+ lite: 'Prefer the simplest correct solution.',
30
+ review: 'Review only for avoidable complexity; recommend the smallest correct replacement.',
31
+ };
32
+
33
+ function ponytailFallback(mode: string): string {
34
+ const intensity = PONYTAIL_FALLBACK_INTENSITY[mode] ?? 'Use the minimum correct solution. Delete or reuse before adding.';
35
+ return `๐Ÿฆฅ PONYTAIL MODE ACTIVE โ€” level: ${mode}\n${intensity} Understand the path first and fix root causes, not symptoms. Prefer the standard library and YAGNI. Avoid speculative abstractions and dependencies. Preserve correctness. Verify changed behavior.`;
36
+ }
37
+
38
+ function levelSummary(state: ComboState): string {
39
+ return `caveman=${state.caveman} rtk=${state.rtk} ponytail=${state.ponytail}`;
40
+ }
41
+
42
+ function loadPonytailInstructions(mode: string): string {
43
+ try {
44
+ const installed = path.join(
45
+ os.homedir(),
46
+ '.omp',
47
+ 'plugins',
48
+ 'node_modules',
49
+ '@dietrichgebert',
50
+ 'ponytail',
51
+ 'hooks',
52
+ 'ponytail-instructions.js'
53
+ );
54
+ const { getPonytailInstructions } = require(installed) as { getPonytailInstructions?: (mode: string) => string };
55
+ if (typeof getPonytailInstructions === 'function') return getPonytailInstructions(mode);
56
+ } catch { }
57
+ return ponytailFallback(mode);
58
+ }
59
+
60
+ export default function comboToggleExtension(pi: ExtensionApi): void {
61
+ pi.setLabel?.('Combo session toggle (all 3 add-ons)');
62
+
63
+ let lastCtx: ExtensionCtx | undefined = undefined;
64
+
65
+ function syncStatus(ctx?: ExtensionCtx): void {
66
+ lastCtx = paintableCtx(lastCtx, ctx);
67
+ const c = lastCtx;
68
+ if (!c?.ui?.setStatus) return;
69
+ // Single unified bar replaces the three per-extension bars while a preset is
70
+ // active. In custom/off, the individual bars come back and combo stays clear.
71
+ if (!isComboPresetActive()) {
72
+ c.ui.setStatus('combo', undefined);
73
+ return;
74
+ }
75
+ // Read the live bridge, not cached extension state: sibling extensions
76
+ // reconcile it from persisted entries before this one runs.
77
+ const state = getSharedComboState();
78
+ const theme = c.ui.theme;
79
+ const c0 = state.caveman.toUpperCase();
80
+ const r = state.rtk.toUpperCase();
81
+ const p = state.ponytail.toUpperCase();
82
+ const lvl = state.level.toUpperCase();
83
+ const label = `combo ${lvl}: ๐Ÿชจcaveman=${c0} โšกrtk=${r} ๐Ÿฆฅponytail=${p}`;
84
+ c.ui.setStatus('combo', theme?.fg ? `${theme.fg('accent', '๐Ÿงฉ')} ${theme.fg('muted', label)}` : `๐Ÿงฉ ${label}`);
85
+ // Clobber any sibling bars another extension may have painted in a race
86
+ // during session_start โ€” our bar is canonical for the duration of the preset.
87
+ c.ui.setStatus('caveman', undefined);
88
+ c.ui.setStatus('rtk', undefined);
89
+ c.ui.setStatus('ponytail', undefined);
90
+ }
91
+
92
+ // ponytail: same persistence as /combo โ€” siblings (incl. upstream ponytail)
93
+ // restore from these entries, so the fallback must write them too or the
94
+ // preset evaporates on resume and ponytail never activates.
95
+ function persistPreset(level: string): void {
96
+ const modes = COMBO_LEVELS[level];
97
+ pi.appendEntry?.('caveman-mode', { mode: modes.caveman });
98
+ pi.appendEntry?.('rtk-mode', { enabled: modes.rtk === 'on' });
99
+ pi.appendEntry?.('ponytail-mode', { mode: modes.ponytail });
100
+ pi.appendEntry?.('combo-level', { level });
101
+ }
102
+
103
+ function useState(state: Readonly<ComboState>, ctx?: ExtensionCtx): Readonly<ComboState> {
104
+ syncStatus(ctx);
105
+ return state;
106
+ }
107
+
108
+ function reconcile(ctx?: ExtensionCtx): Readonly<ComboState> {
109
+ if (!ctx?.hasUI) return getSharedComboState();
110
+ return useState(reconcileSharedComboEntries(sessionEntries(ctx)), ctx);
111
+ }
112
+ function listen(ctx?: ExtensionCtx): void {
113
+ // Stable identity: the bridge set dedupes, so repeated track()/command
114
+ // calls register once instead of stacking duplicate listeners.
115
+ if (ctx?.hasUI) setSharedComboListener(useState);
116
+ }
117
+
118
+ function track(ctx?: ExtensionCtx): void {
119
+ listen(ctx);
120
+ if (ctx?.hasUI) reconcile(ctx);
121
+ }
122
+
123
+ pi.registerCommand?.('combo', {
124
+ description: 'Toggle all 3 OMP add-ons at once. Usage: /combo <off|medium|balanced|max|status>',
125
+ handler: async (args, ctx) => {
126
+ listen(ctx);
127
+ const arg = String(args || '').trim().toLowerCase();
128
+
129
+ if (!arg || arg === 'status') {
130
+ const state = reconcile(ctx);
131
+ ctx?.ui?.notify?.(
132
+ `Combo: ${state.level === 'custom' ? 'INACTIVE' : state.level.toUpperCase()} (${levelSummary(state)})`,
133
+ 'info'
134
+ );
135
+ return;
136
+ }
137
+
138
+ if (arg === 'help') {
139
+ ctx?.ui?.notify?.(
140
+ '/combo off โ€” disables all 3 (caveman, rtk, ponytail)\n' +
141
+ '/combo medium โ€” light: caveman=lite, rtk=on, ponytail=lite\n' +
142
+ '/combo balanced โ€” middle: caveman=full, rtk=on, ponytail=full\n' +
143
+ '/combo max โ€” aggressive: caveman=ultra, rtk=on, ponytail=ultra',
144
+ 'info'
145
+ );
146
+ return;
147
+ }
148
+
149
+ const level = normalizeComboLevel(arg);
150
+ if (!level) {
151
+ ctx?.ui?.notify?.(
152
+ `Unknown combo level: ${arg}. Use: off | medium | balanced | max`,
153
+ 'warning'
154
+ );
155
+ return;
156
+ }
157
+
158
+ persistPreset(level);
159
+ useState(setSharedComboLevel(level), ctx);
160
+
161
+ const active = activeModesSummary(getSharedComboState());
162
+ ctx?.ui?.notify?.(
163
+ level === 'off' ? `Combo off โ€” all tersio modes inactive for this session. Active: ${active}.` : `Combo ${level} on โ€” ${active} active for this session.`,
164
+ 'info'
165
+ );
166
+ },
167
+ });
168
+
169
+ pi.on('session_start', async (_event, ctx) => {
170
+ track(ctx);
171
+ if (!ctx?.hasUI) syncStatus(ctx);
172
+ // Installer/user-configured default applies only when no persisted *mode*
173
+ // state exists โ€” unrelated session entries must not block it, or the
174
+ // combo bar never paints on sessions that already carry other entries.
175
+ const entries = sessionEntries(ctx);
176
+ const hasModeState = entries.some((e) => e?.type === 'custom' && (
177
+ e.customType === 'combo-level' || e.customType === 'caveman-mode' ||
178
+ e.customType === 'rtk-mode' || e.customType === 'ponytail-mode'));
179
+ if (getSharedComboState().level === 'off' && !hasModeState) {
180
+ const fallback = readComboDefault();
181
+ if (fallback !== 'off') {
182
+ persistPreset(fallback);
183
+ useState(setSharedComboLevel(fallback), ctx);
184
+ ctx?.ui?.notify?.(`Combo default applied: ${fallback} โ€” ${activeModesSummary(getSharedComboState())} active for this session.`, 'info');
185
+ }
186
+ }
187
+ // Standalone ponytail default: no sibling extension restores it (upstream
188
+ // owns the command), so apply it here when nothing persisted a
189
+ // ponytail-mode entry. Skipped when it matches the active preset โ€” the
190
+ // preset entry already covers that, no redundant write.
191
+ if (!entries.some((e) => e?.type === 'custom' && e.customType === 'ponytail-mode')) {
192
+ const ponytailFallback = readPonytailDefault();
193
+ if (ponytailFallback !== 'off' && ponytailFallback !== getSharedComboState().ponytail) {
194
+ pi.appendEntry?.('ponytail-mode', { mode: ponytailFallback });
195
+ useState(setSharedComboMode('ponytail', ponytailFallback), ctx);
196
+ }
197
+ }
198
+ });
199
+
200
+ for (const event of ['session_branch', 'session_tree', 'agent_start']) {
201
+ pi.on(event, async (_event, ctx) => {
202
+ track(ctx);
203
+ });
204
+ }
205
+
206
+ pi.on<SystemPromptEvent>('before_agent_start', async (event, ctx) => {
207
+ if (ctx?.hasUI) reconcile(ctx);
208
+ if (!isOmpSubagentPrompt(event.systemPrompt)) return;
209
+
210
+ const mode = getSharedComboState().ponytail;
211
+ if (mode === 'off' || systemPromptIncludes(event.systemPrompt, 'PONYTAIL MODE ACTIVE')) return;
212
+ const base = asPromptArray(event.systemPrompt);
213
+ return { systemPrompt: [...base, loadPonytailInstructions(mode)] };
214
+ });
215
+
216
+ // Slash commands only; natural-language input caused accidental toggles and has no reload context.
217
+ }
@@ -0,0 +1,162 @@
1
+ // Shared helpers for the installer and the AI add-ons updater.
2
+ // Node built-ins only โ€” this module must stay dependency-free.
3
+
4
+ import { createHash } from 'node:crypto';
5
+ import { createWriteStream } from 'node:fs';
6
+ import fs from 'node:fs/promises';
7
+ import https from 'node:https';
8
+ import path from 'node:path';
9
+
10
+ export interface HttpOptions {
11
+ maxRedirects?: number;
12
+ }
13
+
14
+ // Promise.withResolvers is Node 22+; the package supports Node 18+.
15
+ // ponytail: swap for the built-in when engines bumps to >=22.
16
+ export function withResolvers<T>(): PromiseWithResolvers<T> {
17
+ let resolve!: (value: T | PromiseLike<T>) => void;
18
+ let reject!: (reason?: unknown) => void;
19
+ const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
20
+ return { promise, resolve, reject };
21
+ }
22
+
23
+ export const RTK_RELEASE_API = 'https://api.github.com/repos/rtk-ai/rtk/releases/latest';
24
+ export const CAVEMAN_REMOTE_RULE = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/rules/caveman-activate.md';
25
+
26
+ export interface RtkPlatformSpec {
27
+ triple: string;
28
+ ext: string;
29
+ binary: string;
30
+ }
31
+
32
+ export interface RtkReleaseAsset {
33
+ name: string;
34
+ browser_download_url: string;
35
+ }
36
+
37
+ export interface RtkRelease {
38
+ tag_name?: string;
39
+ assets?: RtkReleaseAsset[];
40
+ }
41
+
42
+ const RTK_PLATFORM_SPECS: Record<string, RtkPlatformSpec> = {
43
+ 'win32/x64': { triple: 'x86_64-pc-windows-msvc', ext: '.zip', binary: 'rtk.exe' },
44
+ 'linux/x64': { triple: 'x86_64-unknown-linux-musl', ext: '.tar.gz', binary: 'rtk' },
45
+ 'linux/arm64': { triple: 'aarch64-unknown-linux-gnu', ext: '.tar.gz', binary: 'rtk' },
46
+ 'darwin/x64': { triple: 'x86_64-apple-darwin', ext: '.tar.gz', binary: 'rtk' },
47
+ 'darwin/arm64': { triple: 'aarch64-apple-darwin', ext: '.tar.gz', binary: 'rtk' },
48
+ };
49
+
50
+ export function rtkPlatformSpec(platform: string = process.platform, arch: string = process.arch): RtkPlatformSpec | null {
51
+ return RTK_PLATFORM_SPECS[`${platform}/${arch}`] || null;
52
+ }
53
+
54
+ // Single source for the 3xx+location redirect rule shared by httpsGet/httpsDownload.
55
+ function redirectNext(res: { statusCode?: number; headers: { location?: string } }, url: string): string | null {
56
+ if (res.statusCode !== undefined && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
57
+ return new URL(res.headers.location, url).href;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export function httpsGet(url: string, opts: HttpOptions = {}): Promise<string> {
63
+ const { promise, resolve, reject } = withResolvers<string>();
64
+ const maxRedirects = opts.maxRedirects ?? 5;
65
+ const req = https.get(url, { headers: { 'User-Agent': 'tersio', Accept: 'application/json,*/*' } }, (res) => {
66
+ const next = redirectNext(res, url);
67
+ if (next) {
68
+ if (maxRedirects <= 0) { res.resume(); reject(new Error(`Too many redirects fetching ${url}`)); return; }
69
+ res.resume();
70
+ resolve(httpsGet(next, { maxRedirects: maxRedirects - 1 }));
71
+ return;
72
+ }
73
+ if (res.statusCode !== 200) { res.resume(); reject(new Error(`HTTP ${res.statusCode} for ${url}`)); return; }
74
+ let body = '';
75
+ // ponytail: streamed accumulation โ€” fine for tens of KB; stream-pipe if assets ever exceed a few MB.
76
+ res.setEncoding('utf8');
77
+ res.on('data', (chunk) => { body += chunk; });
78
+ res.on('end', () => resolve(body));
79
+ });
80
+ req.on('error', reject);
81
+ req.setTimeout(30000, () => req.destroy(new Error(`Timeout fetching ${url}`)));
82
+ return promise;
83
+ }
84
+
85
+ export function httpsDownload(url: string, dest: string, opts: HttpOptions = {}): Promise<void> {
86
+ const { promise, resolve, reject } = withResolvers<void>();
87
+ const maxRedirects = opts.maxRedirects ?? 5;
88
+ const req = https.get(url, { headers: { 'User-Agent': 'tersio', Accept: '*/*' } }, (res) => {
89
+ const next = redirectNext(res, url);
90
+ if (next) {
91
+ if (maxRedirects <= 0) { res.resume(); reject(new Error(`Too many redirects downloading ${url}`)); return; }
92
+ res.resume();
93
+ resolve(httpsDownload(next, dest, { maxRedirects: maxRedirects - 1 }));
94
+ return;
95
+ }
96
+ if (res.statusCode !== 200) { res.resume(); reject(new Error(`HTTP ${res.statusCode} for ${url}`)); return; }
97
+ const file = createWriteStream(dest);
98
+ res.pipe(file);
99
+ file.on('finish', () => file.close(() => resolve()));
100
+ file.on('error', (err) => {
101
+ req.destroy();
102
+ res.resume();
103
+ reject(err);
104
+ });
105
+ });
106
+ req.on('error', reject);
107
+ req.setTimeout(120000, () => req.destroy(new Error(`Timeout downloading ${url}`)));
108
+ return promise;
109
+ }
110
+
111
+ export async function fetchJson<T>(url: string): Promise<T> {
112
+ return JSON.parse(await httpsGet(url)) as T;
113
+ }
114
+
115
+ export function sha256Hex(text: string): string {
116
+ return createHash('sha256').update(text, 'utf8').digest('hex');
117
+ }
118
+
119
+ export async function sha256File(filePath: string): Promise<string> {
120
+ const buf = await fs.readFile(filePath);
121
+ return createHash('sha256').update(buf).digest('hex');
122
+ }
123
+
124
+ export function parseChecksum(checksumsText: string, assetName: string): string | null {
125
+ const target = path.basename(assetName);
126
+ for (const line of checksumsText.split(/\r?\n/)) {
127
+ const m = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
128
+ if (m && path.basename(m[2]) === target) return m[1].toLowerCase();
129
+ }
130
+ return null;
131
+ }
132
+
133
+ export async function findFile(dir: string, name: string): Promise<string | null> {
134
+ try {
135
+ const ents = await fs.readdir(dir, { withFileTypes: true });
136
+ for (const e of ents) {
137
+ const full = path.join(dir, e.name);
138
+ if (e.isDirectory()) {
139
+ const found = await findFile(full, name);
140
+ if (found) return found;
141
+ } else if (e.isFile()) {
142
+ if (e.name === name) return full;
143
+ // Match rtk-* asset names (e.g. rtk-x86_64-unknown-linux-musl).
144
+ if (name === 'rtk' || name === 'rtk.exe') {
145
+ const base = e.name.toLowerCase();
146
+ if (!/\.(txt|md|json|sha256|sig|asc|pem|crt|license)$/i.test(base)) {
147
+ if (base === 'rtk' || base === 'rtk.exe' || /^rtk[-_.]/.test(base)) return full;
148
+ }
149
+ }
150
+ }
151
+ }
152
+ } catch { /* ignore */ }
153
+ return null;
154
+ }
155
+
156
+ export async function readTextIfExists(p: string): Promise<string | null> {
157
+ try { return await fs.readFile(p, 'utf8'); } catch { return null; }
158
+ }
159
+
160
+ export function normalizeRtkVersion(value: string | undefined): string {
161
+ return String(value || '').replace(/^rtk\s+/i, '').replace(/^v/i, '').trim();
162
+ }
@@ -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
+ }