@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,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
+ }
@@ -0,0 +1,201 @@
1
+ import type { ComboLevel, ComboState, ExtensionCtx, SessionEntry, UiApi } from './types.ts';
2
+
3
+ const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
4
+
5
+ export const OMP_SUBAGENT_MARKER = 'You are operating on a piece of work assigned to you by the main agent.';
6
+
7
+ export const COMBO_LEVELS: Record<string, Readonly<ComboState>> = Object.freeze({
8
+ off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off' }),
9
+ medium: Object.freeze({ level: 'medium', caveman: 'lite', rtk: 'on', ponytail: 'lite' }),
10
+ balanced: Object.freeze({ level: 'balanced', caveman: 'full', rtk: 'on', ponytail: 'full' }),
11
+ max: Object.freeze({ level: 'max', caveman: 'ultra', rtk: 'on', ponytail: 'ultra' }),
12
+ });
13
+
14
+ const MODE_VALUES: Record<string, Set<string>> = {
15
+ caveman: new Set(['off', 'lite', 'full', 'ultra', 'wenyan']),
16
+ rtk: new Set(['off', 'on']),
17
+ ponytail: new Set(['off', 'lite', 'full', 'ultra', 'review']),
18
+ };
19
+
20
+ type ModeName = 'caveman' | 'rtk' | 'ponytail';
21
+ type Modes = Record<ModeName, string>;
22
+
23
+ const MODE_ENTRY_TYPES: Record<string, ModeName> = {
24
+ 'caveman-mode': 'caveman',
25
+ 'rtk-mode': 'rtk',
26
+ 'ponytail-mode': 'ponytail',
27
+ };
28
+
29
+ interface Bridge {
30
+ state: Readonly<ComboState>;
31
+ listeners: Set<(state: Readonly<ComboState>) => void>;
32
+ }
33
+
34
+ export function normalizeMode(name: ModeName, value: unknown): string | null {
35
+ if (name === 'rtk' && typeof value === 'boolean') return value ? 'on' : 'off';
36
+ const mode = String(value ?? '').trim().toLowerCase();
37
+ return MODE_VALUES[name]?.has(mode) ? mode : null;
38
+ }
39
+
40
+ export function deriveLevel(modes: Modes | null | undefined): ComboLevel {
41
+ for (const level of Object.keys(COMBO_LEVELS) as ComboLevel[]) {
42
+ const preset = COMBO_LEVELS[level];
43
+ if (preset.caveman === modes?.caveman && preset.rtk === modes?.rtk && preset.ponytail === modes?.ponytail) return level;
44
+ }
45
+ return 'custom';
46
+ }
47
+
48
+ function isPresetLevel(value: string): value is ComboLevel {
49
+ return Object.prototype.hasOwnProperty.call(COMBO_LEVELS, value);
50
+ }
51
+
52
+ function isKnownLevel(value: string): boolean {
53
+ return value === 'custom' || isPresetLevel(value);
54
+ }
55
+
56
+ function normalizedState(modes: Partial<Modes> | null | undefined, level: ComboLevel = deriveLevel(modes as Modes)): Readonly<ComboState> {
57
+ const state: Modes = {
58
+ caveman: normalizeMode('caveman', modes?.caveman) || 'off',
59
+ rtk: normalizeMode('rtk', modes?.rtk) || 'off',
60
+ ponytail: normalizeMode('ponytail', modes?.ponytail) || 'off',
61
+ };
62
+ return Object.freeze({ level: isKnownLevel(level) ? level : deriveLevel(state), ...state });
63
+ }
64
+
65
+ function bridge(): Bridge {
66
+ const existing = (globalThis as Record<symbol, unknown>)[BRIDGE_KEY] as Bridge | undefined;
67
+ if (existing?.state) return existing;
68
+ const initial = normalizedState((existing || COMBO_LEVELS.off) as Partial<Modes>);
69
+ return ((globalThis as Record<symbol, unknown>)[BRIDGE_KEY] = { state: initial, listeners: new Set<(state: Readonly<ComboState>) => void>() });
70
+ }
71
+
72
+ function publish(state: Readonly<ComboState>): Readonly<ComboState> {
73
+ const shared = bridge();
74
+ shared.state = state;
75
+ for (const listener of shared.listeners) listener(state);
76
+ return state;
77
+ }
78
+
79
+ export function normalizeInputCommand(value: unknown): string {
80
+ return String(value || '').trim().toLowerCase().replace(/[.!?\s]+$/, '');
81
+ }
82
+
83
+ export function asPromptArray(systemPrompt: string | string[]): string[] {
84
+ return Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt];
85
+ }
86
+
87
+ export function systemPromptIncludes(systemPrompt: string | string[], marker: string): boolean {
88
+ return asPromptArray(systemPrompt).some((prompt) => typeof prompt === 'string' && prompt.includes(marker));
89
+ }
90
+
91
+ export function isOmpSubagentPrompt(systemPrompt: string | string[]): boolean {
92
+ return systemPromptIncludes(systemPrompt, OMP_SUBAGENT_MARKER);
93
+ }
94
+
95
+ export function normalizeComboLevel(value: unknown): ComboLevel | null {
96
+ const level = String(value || '').trim().toLowerCase();
97
+ return isPresetLevel(level) ? level : null;
98
+ }
99
+
100
+ export function paintStatusBar(ui: UiApi | undefined, key: string, emoji: string, label: string, isActive: boolean): void {
101
+ const theme = ui?.theme;
102
+ const indicator = isActive && theme?.fg ? theme.fg('accent', emoji) : emoji;
103
+ ui?.setStatus?.(key, theme?.fg ? `${indicator} ${theme.fg('muted', label)}` : `${indicator} ${label}`);
104
+ }
105
+
106
+ // Which context a status bar should remember to paint through.
107
+ //
108
+ // A UI-less event — a headless child session_start, or a top-level
109
+ // session_start that fires before the TUI attaches — must never become the
110
+ // remembered context. Two things went wrong when it did: the old
111
+ // `ctx || lastCtx` fallback only triggered for a *falsy* ctx, so a truthy
112
+ // ctx without `ui` returned early anyway, and remembering it destroyed the one
113
+ // paintable context we had. Every later bare syncStatus() then went mute —
114
+ // and the bare call is the bridge-listener path, which is how a mode change
115
+ // made by a sibling extension reaches this bar. The symptom is a bar frozen on
116
+ // a preset the session no longer has: it shows "combo BALANCED" while caveman
117
+ // and rtk are already off.
118
+ export function paintableCtx(remembered: ExtensionCtx | undefined, next: ExtensionCtx | undefined): ExtensionCtx | undefined {
119
+ return next?.ui?.setStatus ? next : remembered;
120
+ }
121
+
122
+ // Last-wins scan for a custom session entry; skips entries whose value fails
123
+ // to parse so a corrupt write never shadows an older valid one.
124
+ export function lastCustomValue<T>(entries: SessionEntry[] | null | undefined, customType: string, pick: (data: SessionEntry['data']) => T | null | undefined): T | null {
125
+ if (!Array.isArray(entries)) return null;
126
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
127
+ const entry = entries[i];
128
+ if (entry?.type !== 'custom' || entry?.customType !== customType) continue;
129
+ const value = pick(entry?.data);
130
+ if (value !== null && value !== undefined) return value;
131
+ }
132
+ return null;
133
+ }
134
+
135
+ export function sessionEntries(ctx: ExtensionCtx | undefined): SessionEntry[] {
136
+ return ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || [];
137
+ }
138
+
139
+ export function getSharedComboState(): Readonly<ComboState> {
140
+ return bridge().state;
141
+ }
142
+
143
+ export function isComboPresetActive(): boolean {
144
+ const level = getSharedComboState().level;
145
+ return level !== 'off' && isPresetLevel(level);
146
+ }
147
+
148
+ export function setSharedComboLevel(value: unknown): Readonly<ComboState> {
149
+ const level = normalizeComboLevel(value) || 'off';
150
+ return publish(normalizedState(COMBO_LEVELS[level] as Partial<Modes>, level));
151
+ }
152
+
153
+ export function setSharedComboMode(name: ModeName, value: unknown): Readonly<ComboState> {
154
+ const mode = normalizeMode(name, value);
155
+ if (!mode) return getSharedComboState();
156
+ const modes = { ...getSharedComboState(), [name]: mode } as Modes;
157
+ // Derive the level from the final triplet so a redundant same-value write
158
+ // (entry replay on resume, upstream ponytail re-affirming its mode) cannot
159
+ // strand a matching preset at 'custom' and drop the combo bar.
160
+ return publish(normalizedState(modes));
161
+ }
162
+
163
+ export function reconcileSharedComboEntries(entries: SessionEntry[] | null | undefined): Readonly<ComboState> {
164
+ let modes: Modes = { ...COMBO_LEVELS.off };
165
+ if (Array.isArray(entries)) {
166
+ for (const entry of entries) {
167
+ if (entry?.type !== 'custom') continue;
168
+ if (entry.customType === 'combo-level') {
169
+ const preset = normalizeComboLevel(entry?.data?.level);
170
+ if (preset) modes = { ...COMBO_LEVELS[preset] };
171
+ continue;
172
+ }
173
+ const name = MODE_ENTRY_TYPES[entry.customType ?? ''] ?? null;
174
+ if (!name) continue;
175
+ const value = name === 'rtk' ? entry?.data?.enabled : entry?.data?.mode;
176
+ const mode = normalizeMode(name, value);
177
+ if (mode) modes[name] = mode;
178
+ }
179
+ }
180
+ // The level always reflects the final mode triplet, never the write order:
181
+ // a preset entry followed by the same values reconciles back to the preset.
182
+ return publish(normalizedState(modes));
183
+ }
184
+
185
+ // One-line session-wide mode summary for change confirmations, e.g.
186
+ // `caveman=ULTRA, rtk=ON, ponytail=ULTRA`.
187
+ export function activeModesSummary(state: { caveman: string; rtk: string; ponytail: string }): string {
188
+ return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
189
+ }
190
+
191
+ export function setSharedComboListener(listener: ((state: Readonly<ComboState>) => void) | null): void {
192
+ // Additive: every sibling extension syncs its local mirror on publish, so
193
+ // a /tersio or /combo switch takes effect next turn with no session reload.
194
+ // A null listener clears all (no current callers; reserved for teardown).
195
+ if (typeof listener === 'function') bridge().listeners.add(listener);
196
+ else bridge().listeners.clear();
197
+ }
198
+
199
+ export function resetSharedComboState(): Readonly<ComboState> {
200
+ return setSharedComboLevel('off');
201
+ }
@@ -0,0 +1,85 @@
1
+ // Shared OMP extension host types. Structural — the real `pi` object comes
2
+ // from the OMP runtime; these describe the surface this package touches.
3
+
4
+ export interface ComboState {
5
+ level: string;
6
+ caveman: string;
7
+ rtk: string;
8
+ ponytail: string;
9
+ }
10
+
11
+ export type ComboLevel = 'off' | 'medium' | 'balanced' | 'max' | 'custom';
12
+
13
+ export type SessionEntry = {
14
+ type: string;
15
+ customType?: string;
16
+ data?: {
17
+ mode?: string;
18
+ enabled?: boolean;
19
+ level?: string;
20
+ [key: string]: unknown;
21
+ };
22
+ };
23
+
24
+ export interface UiApi {
25
+ setStatus?: (name: string, value: string | undefined) => void;
26
+ notify?: (message: string, level?: string) => void;
27
+ theme?: {
28
+ fg?: (role: string, text: string) => string;
29
+ };
30
+ }
31
+
32
+ export interface ExtensionCtx {
33
+ hasUI?: boolean;
34
+ cwd?: string;
35
+ sessionManager?: {
36
+ getBranch?: () => SessionEntry[];
37
+ getEntries?: () => SessionEntry[];
38
+ };
39
+ ui?: UiApi;
40
+ reload?: () => Promise<void>;
41
+ }
42
+
43
+ export interface SystemPromptEvent {
44
+ systemPrompt: string | string[];
45
+ }
46
+
47
+ export interface InputEvent {
48
+ text?: string;
49
+ source?: string;
50
+ }
51
+
52
+ export interface ExtensionApi {
53
+ setLabel?: (label: string) => void;
54
+ registerCommand?: (name: string, config: {
55
+ description: string;
56
+ handler: (args: string, ctx: ExtensionCtx) => Promise<void>;
57
+ }) => void;
58
+ registerTool?: (tool: {
59
+ name: string;
60
+ label: string;
61
+ description: string;
62
+ parameters: unknown;
63
+ execute: (
64
+ toolCallId: string,
65
+ params: { args: string[] },
66
+ signal: AbortSignal,
67
+ onUpdate: ((data: unknown) => void) | undefined,
68
+ ctx: ExtensionCtx | undefined,
69
+ ) => Promise<{
70
+ isError: boolean;
71
+ content: { type: string; text: string }[];
72
+ details: Record<string, unknown>;
73
+ }>;
74
+ }) => void;
75
+ appendEntry?: (customType: string, data: Record<string, unknown>) => void;
76
+ exec?: (cmd: string, args: string[], opts?: { signal?: AbortSignal; cwd?: string }) => Promise<{
77
+ stdout: string;
78
+ stderr: string;
79
+ code: number;
80
+ killed?: boolean;
81
+ }>;
82
+ cwd?: string;
83
+ zod?: { z: unknown };
84
+ on<E>(event: string, handler: (event: E, ctx: ExtensionCtx) => unknown): void;
85
+ }