@krtclcdy/tersio 2.24.0 → 2.25.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/README.md +28 -24
- package/cli/banner.js +7 -5
- package/cli/common.js +14 -13
- package/cli/currency.js +2 -7
- package/cli/dashboard.js +220 -13
- package/cli/doctor-fix.js +99 -29
- package/cli/doctor.js +32 -35
- package/cli/hosts.js +23 -26
- package/cli/install.js +228 -115
- package/cli/interactive.js +65 -21
- package/cli/manifest.js +8 -4
- package/cli/profile.js +49 -16
- package/cli/reset.js +3 -7
- package/cli/rtk-wiring.js +29 -32
- package/cli/settings.js +40 -16
- package/cli/uninstall.js +79 -29
- package/cli/update.js +27 -20
- package/cli/usage.js +54 -19
- package/dashboard/dist/index.html +18 -18
- package/extensions/ai-addons-updater/index.js +75 -20
- package/extensions/ai-addons-updater/index.ts +67 -23
- package/extensions/caveman-session/index.js +33 -75
- package/extensions/caveman-session/index.ts +35 -77
- package/extensions/combo-toggle/index.js +40 -62
- package/extensions/combo-toggle/index.ts +38 -72
- package/extensions/lib/utils.js +64 -15
- package/extensions/lib/utils.ts +62 -15
- package/extensions/opencode/index.js +104 -0
- package/extensions/opencode/server.js +190 -0
- package/extensions/opencode/server.ts +200 -0
- package/extensions/rtk-session/index.js +38 -57
- package/extensions/rtk-session/index.ts +36 -55
- package/extensions/shared/carbon.js +4 -10
- package/extensions/shared/carbon.ts +5 -10
- package/extensions/shared/host.js +95 -56
- package/extensions/shared/host.ts +106 -60
- package/extensions/shared/plugin-settings.js +6 -18
- package/extensions/shared/plugin-settings.ts +6 -18
- package/extensions/shared/pricing.js +79 -27
- package/extensions/shared/pricing.ts +80 -28
- package/extensions/shared/rtk-gain.js +4 -11
- package/extensions/shared/rtk-gain.ts +4 -11
- package/extensions/shared/session-state.js +28 -39
- package/extensions/shared/session-state.ts +29 -38
- package/extensions/shared/status.js +38 -0
- package/extensions/shared/status.ts +45 -0
- package/extensions/shared/types.js +1 -2
- package/extensions/shared/types.ts +2 -2
- package/extensions/shared/usage-ledger.js +312 -57
- package/extensions/shared/usage-ledger.ts +321 -61
- package/extensions/shared/usage-store.js +354 -38
- package/extensions/shared/usage-store.ts +360 -43
- package/extensions/tersio-commands/index.js +5 -14
- package/extensions/tersio-commands/index.ts +5 -14
- package/package.json +12 -7
- package/tersio.js +4 -6
- package/cli/agent-hosts.js +0 -42
- package/cli/agents.js +0 -266
- package/cli/layer.js +0 -24
- package/cli/omp-layer.js +0 -51
- package/cli/pi-layer.js +0 -37
- package/cli/pi-wiring.js +0 -92
- package/extensions/shared/omp-prompt.js +0 -12
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
// extensions/shared/pricing.ts — dynamic model pricing from LiteLLM.
|
|
2
|
-
// Exact id → provider-prefix strip → default, which reports with known:false so
|
|
3
|
-
// the Dashboard labels an unpriced model honestly. No static table: prices rot.
|
|
1
|
+
// extensions/shared/pricing.ts — dynamic model pricing from LiteLLM. Exact id → provider-prefix strip → default, reported with known:false when unpriced.
|
|
4
2
|
import fs from 'node:fs';
|
|
5
3
|
import path from 'node:path';
|
|
6
4
|
import { tersioDataPath } from '../lib/utils.ts';
|
|
@@ -16,8 +14,7 @@ export interface ModelPrice {
|
|
|
16
14
|
export const DEFAULT_PRICE: ModelPrice = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
|
|
17
15
|
|
|
18
16
|
const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
|
|
19
|
-
// Readers use the file even when stale, and only one refresh runs per process;
|
|
20
|
-
// `tersio update` still forces a fresh fetch.
|
|
17
|
+
// Readers use the file even when stale, and only one refresh runs per process; `tersio update` still forces a fresh fetch.
|
|
21
18
|
const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
|
|
22
19
|
let refreshInflight: Promise<boolean> | null = null;
|
|
23
20
|
|
|
@@ -33,8 +30,7 @@ export function pricesUrl(): string {
|
|
|
33
30
|
|
|
34
31
|
export interface LivePrices {
|
|
35
32
|
fetchedAt: number;
|
|
36
|
-
// Compact tuples [input, output, cacheRead, cacheWrite] per LiteLLM id —
|
|
37
|
-
// the full feed caches to ~1MB instead of multi-MB objects.
|
|
33
|
+
// Compact tuples [input, output, cacheRead, cacheWrite] per LiteLLM id — the full feed caches to ~1MB instead of multi-MB objects.
|
|
38
34
|
exact: Record<string, [number, number, number, number]>;
|
|
39
35
|
deprecated?: string[];
|
|
40
36
|
}
|
|
@@ -43,8 +39,7 @@ function toPrice(t: [number, number, number, number]): ModelPrice {
|
|
|
43
39
|
return { input: t[0], output: t[1], cacheRead: t[2], cacheWrite: t[3] };
|
|
44
40
|
}
|
|
45
41
|
|
|
46
|
-
// Tolerate both cache shapes (compact tuples and ModelPrice objects) so a
|
|
47
|
-
// version bump never orphans the cache.
|
|
42
|
+
// Tolerate both cache shapes (compact tuples and ModelPrice objects) so a version bump never orphans the cache.
|
|
48
43
|
function asPrice(v: unknown): ModelPrice | null {
|
|
49
44
|
if (Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) {
|
|
50
45
|
return toPrice(v as [number, number, number, number]);
|
|
@@ -52,15 +47,30 @@ function asPrice(v: unknown): ModelPrice | null {
|
|
|
52
47
|
if (v && typeof v === 'object') {
|
|
53
48
|
const o = v as Record<string, unknown>;
|
|
54
49
|
if (['input', 'output', 'cacheRead', 'cacheWrite'].every((k) => typeof o[k] === 'number' && Number.isFinite(o[k]) && (o[k] as number) >= 0)) {
|
|
55
|
-
// SAFETY: the line above verified all four keys are finite numbers >= 0,
|
|
56
|
-
// which is the whole of ModelPrice.
|
|
50
|
+
// SAFETY: the line above verified all four keys are finite numbers >= 0, which is the whole of ModelPrice.
|
|
57
51
|
return o as unknown as ModelPrice;
|
|
58
52
|
}
|
|
59
53
|
}
|
|
60
54
|
return null;
|
|
61
55
|
}
|
|
62
56
|
|
|
57
|
+
let liveCache: { path: string; mtime: number; prices: LivePrices } | null = null;
|
|
58
|
+
|
|
63
59
|
export function loadLivePrices(): LivePrices | null {
|
|
60
|
+
// One stat per call beats one full parse: the recent-rows map prices thousands of rows.
|
|
61
|
+
try {
|
|
62
|
+
const file = pricesCachePath();
|
|
63
|
+
const mtime = fs.statSync(file).mtimeMs;
|
|
64
|
+
if (liveCache && liveCache.path === file && liveCache.mtime === mtime) return liveCache.prices;
|
|
65
|
+
const prices = readLivePrices();
|
|
66
|
+
if (prices) liveCache = { path: file, mtime, prices };
|
|
67
|
+
return prices;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readLivePrices(): LivePrices | null {
|
|
64
74
|
let raw: string;
|
|
65
75
|
try {
|
|
66
76
|
raw = fs.readFileSync(pricesCachePath(), 'utf8');
|
|
@@ -88,37 +98,80 @@ function num(v: unknown): number | null {
|
|
|
88
98
|
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
|
|
89
99
|
}
|
|
90
100
|
|
|
91
|
-
//
|
|
92
|
-
// `openrouter/stealth/space-bunny-alpha` at zero. Aliases fold to the first, so
|
|
93
|
-
// a free stealth model never reports the default price.
|
|
101
|
+
// Transcripts and the feed spell one model differently; aliases stop a free model reporting the default price.
|
|
94
102
|
export const MODEL_ALIASES: ReadonlyArray<{ id: string; feed: string }> = [
|
|
95
103
|
{ id: 'space-bunny', feed: 'stealth/space-bunny-alpha' },
|
|
96
104
|
];
|
|
97
105
|
|
|
98
106
|
export function canonicalPriceId(model: string): string {
|
|
99
107
|
const key = model.toLowerCase();
|
|
100
|
-
|
|
108
|
+
// Suffix match: gateways prefix the id (opencode/kilo/stealth/space-bunny-alpha).
|
|
109
|
+
return MODEL_ALIASES.find((a) => a.id === key || a.feed === key || key.endsWith(`/${a.id}`) || key.endsWith(`/${a.feed}`))?.id ?? key;
|
|
101
110
|
}
|
|
102
111
|
|
|
103
112
|
export function priceFor(model: string, live?: LivePrices | null): { price: ModelPrice; known: boolean; live: boolean } {
|
|
104
113
|
const table = live === undefined ? loadLivePrices() : live;
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
if (live === undefined) {
|
|
115
|
+
const memo = priceMemo(table);
|
|
116
|
+
const hit = memo.get(model);
|
|
117
|
+
if (hit) return hit;
|
|
118
|
+
const resolved = resolvePrice(model, table);
|
|
119
|
+
memo.set(model, resolved);
|
|
120
|
+
return resolved;
|
|
121
|
+
}
|
|
122
|
+
return resolvePrice(model, table);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Recent rows repeat a handful of models; the memo turns thousands of lookups into one per model. Cleared whenever the table identity changes.
|
|
126
|
+
let memoTable: LivePrices | null | undefined;
|
|
127
|
+
let memo = new Map<string, { price: ModelPrice; known: boolean; live: boolean }>();
|
|
128
|
+
|
|
129
|
+
function priceMemo(table: LivePrices | null): Map<string, { price: ModelPrice; known: boolean; live: boolean }> {
|
|
130
|
+
if (memoTable !== table) {
|
|
131
|
+
memoTable = table;
|
|
132
|
+
memo = new Map();
|
|
133
|
+
}
|
|
134
|
+
return memo;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Validated once per table instead of once per lookup.
|
|
138
|
+
let compiledTable: LivePrices | null = null;
|
|
139
|
+
let compiledEntries: Array<[string, ModelPrice]> = [];
|
|
140
|
+
|
|
141
|
+
function priceEntries(table: LivePrices): Array<[string, ModelPrice]> {
|
|
142
|
+
if (compiledTable === table) return compiledEntries;
|
|
143
|
+
const entries: Array<[string, ModelPrice]> = [];
|
|
144
|
+
for (const [k, v] of Object.entries(table.exact)) {
|
|
145
|
+
const price = asPrice(v);
|
|
146
|
+
if (price) entries.push([k, price]);
|
|
147
|
+
}
|
|
148
|
+
compiledTable = table;
|
|
149
|
+
compiledEntries = entries;
|
|
150
|
+
return entries;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolvePrice(model: string, table: LivePrices | null): { price: ModelPrice; known: boolean; live: boolean } {
|
|
154
|
+
// An alias goes through the same exact-then-suffix chain as any other id, so it still resolves when the feed keys it under a provider prefix.
|
|
107
155
|
const id = MODEL_ALIASES.find((a) => a.id === model.toLowerCase())?.feed ?? model;
|
|
108
156
|
if (table) {
|
|
109
|
-
const
|
|
110
|
-
|
|
157
|
+
const entries = priceEntries(table);
|
|
158
|
+
const lower = id.toLowerCase();
|
|
159
|
+
const hit = entries.find(([k]) => k === id) ?? entries.find(([k]) => k === lower);
|
|
160
|
+
if (hit) return { price: hit[1], known: true, live: true };
|
|
111
161
|
// Provider-prefixed ids ("azure/gpt-4o", "dashscope/qwen-max"): match the
|
|
112
|
-
// first cached
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
|
|
162
|
+
// first cached key that ends with the full id, then with the bare tail
|
|
163
|
+
// segment, so "codex/openai/gpt-6-luna" still finds "gpt-6-luna".
|
|
164
|
+
const name = lower;
|
|
165
|
+
const tail = name.split('/').pop() ?? name;
|
|
166
|
+
const key = entries.find(([k]) => {
|
|
167
|
+
const lk = k.toLowerCase();
|
|
168
|
+
return lk === name || lk.endsWith(`/${name}`) || lk === tail || lk.endsWith(`/${tail}`);
|
|
169
|
+
});
|
|
170
|
+
if (key) return { price: key[1], known: true, live: true };
|
|
117
171
|
}
|
|
118
172
|
return { price: DEFAULT_PRICE, known: false, live: false };
|
|
119
173
|
}
|
|
120
|
-
// Fetch LiteLLM pricing and write the cache. False on any failure — callers
|
|
121
|
-
// fall back silently.
|
|
174
|
+
// Fetch LiteLLM pricing and write the cache. False on any failure — callers fall back silently.
|
|
122
175
|
export async function refreshPrices(): Promise<boolean> {
|
|
123
176
|
if (refreshInflight) return refreshInflight;
|
|
124
177
|
refreshInflight = (async () => {
|
|
@@ -172,8 +225,7 @@ export async function refreshPrices(): Promise<boolean> {
|
|
|
172
225
|
}
|
|
173
226
|
}
|
|
174
227
|
|
|
175
|
-
// Fire-and-forget refresh when the cache is stale; readers keep the stale
|
|
176
|
-
// file, so pricing never blocks.
|
|
228
|
+
// Fire-and-forget refresh when the cache is stale; readers keep the stale file, so pricing never blocks.
|
|
177
229
|
export function refreshPricesIfStale(): void {
|
|
178
230
|
const live = loadLivePrices();
|
|
179
231
|
if (live && Date.now() - live.fetchedAt <= CACHE_TTL_MS) return;
|
|
@@ -1,18 +1,12 @@
|
|
|
1
|
-
//
|
|
2
|
-
// history.db with the sqlite3 CLI, so the dashboard shows measured figures and
|
|
3
|
-
// never estimates. Missing DB or CLI returns empty. Caveman and Ponytail are
|
|
4
|
-
// bench-measured in BENCHMARK.md instead: instruction-following is not metered.
|
|
1
|
+
// Savings read from rtk history, never estimated; modes bench in docs/BENCHMARK.md.
|
|
5
2
|
import { execFileSync } from 'node:child_process';
|
|
6
3
|
import fs from 'node:fs';
|
|
7
4
|
import os from 'node:os';
|
|
8
5
|
import path from 'node:path';
|
|
9
6
|
const EMPTY = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
|
|
10
|
-
// The table shows one clipped cell, so bound the command before it crosses the
|
|
11
|
-
// sqlite3 CLI and lands in every /data.json poll.
|
|
7
|
+
// The table shows one clipped cell, so bound the command before it crosses the sqlite3 CLI and lands in every /data.json poll.
|
|
12
8
|
const MAX_CMD_CHARS = 200;
|
|
13
|
-
//
|
|
14
|
-
// several lines and derail every row after it. The stored command keeps its
|
|
15
|
-
// newlines; only the read view flattens.
|
|
9
|
+
// A multi-line stored command would derail every row after it; flatten on read.
|
|
16
10
|
const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
|
|
17
11
|
export function rtkDbPath() {
|
|
18
12
|
const override = process.env.TERSIO_RTK_DB;
|
|
@@ -39,8 +33,7 @@ export function readRtkGain(limit = 10, cutoffMs) {
|
|
|
39
33
|
try {
|
|
40
34
|
if (!fs.existsSync(rtkDbPath()))
|
|
41
35
|
return EMPTY;
|
|
42
|
-
// View-level cutoff only: rtk's own rows stay untouched. Timestamps that do
|
|
43
|
-
// not parse land on 0 and drop out of a filtered view.
|
|
36
|
+
// View-level cutoff only: rtk's own rows stay untouched. Timestamps that do not parse land on 0 and drop out of a filtered view.
|
|
44
37
|
const where = cutoffMs && cutoffMs > 0
|
|
45
38
|
? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
|
|
46
39
|
: '';
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// history.db with the sqlite3 CLI, so the dashboard shows measured figures and
|
|
3
|
-
// never estimates. Missing DB or CLI returns empty. Caveman and Ponytail are
|
|
4
|
-
// bench-measured in BENCHMARK.md instead: instruction-following is not metered.
|
|
1
|
+
// Savings read from rtk history, never estimated; modes bench in docs/BENCHMARK.md.
|
|
5
2
|
import { execFileSync } from 'node:child_process';
|
|
6
3
|
import fs from 'node:fs';
|
|
7
4
|
import os from 'node:os';
|
|
@@ -26,13 +23,10 @@ export interface RtkGain {
|
|
|
26
23
|
|
|
27
24
|
const EMPTY: RtkGain = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
|
|
28
25
|
|
|
29
|
-
// The table shows one clipped cell, so bound the command before it crosses the
|
|
30
|
-
// sqlite3 CLI and lands in every /data.json poll.
|
|
26
|
+
// The table shows one clipped cell, so bound the command before it crosses the sqlite3 CLI and lands in every /data.json poll.
|
|
31
27
|
const MAX_CMD_CHARS = 200;
|
|
32
28
|
|
|
33
|
-
//
|
|
34
|
-
// several lines and derail every row after it. The stored command keeps its
|
|
35
|
-
// newlines; only the read view flattens.
|
|
29
|
+
// A multi-line stored command would derail every row after it; flatten on read.
|
|
36
30
|
const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
|
|
37
31
|
|
|
38
32
|
export function rtkDbPath(): string {
|
|
@@ -60,8 +54,7 @@ function query(db: string, sql: string): string[][] {
|
|
|
60
54
|
export function readRtkGain(limit = 10, cutoffMs?: number): RtkGain {
|
|
61
55
|
try {
|
|
62
56
|
if (!fs.existsSync(rtkDbPath())) return EMPTY;
|
|
63
|
-
// View-level cutoff only: rtk's own rows stay untouched. Timestamps that do
|
|
64
|
-
// not parse land on 0 and drop out of a filtered view.
|
|
57
|
+
// View-level cutoff only: rtk's own rows stay untouched. Timestamps that do not parse land on 0 and drop out of a filtered view.
|
|
65
58
|
const where = cutoffMs && cutoffMs > 0
|
|
66
59
|
? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
|
|
67
60
|
: '';
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { readPluginSettings } from './plugin-settings.js';
|
|
1
2
|
const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
|
|
2
|
-
|
|
3
|
+
// A verbatim sentence from omp's subagent prompt, so it can drift; read from the installed omp binary on 2026-10-01. pi has no built-in subagent prompt.
|
|
4
|
+
export const OMP_SUBAGENT_MARKER = 'Worker agent: delegated tasks.';
|
|
3
5
|
export const COMBO_LEVELS = Object.freeze({
|
|
4
6
|
off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off' }),
|
|
5
7
|
medium: Object.freeze({ level: 'medium', caveman: 'lite', rtk: 'on', ponytail: 'lite' }),
|
|
@@ -16,6 +18,11 @@ const MODE_ENTRY_TYPES = {
|
|
|
16
18
|
'rtk-mode': 'rtk',
|
|
17
19
|
'ponytail-mode': 'ponytail',
|
|
18
20
|
};
|
|
21
|
+
/** True when the branch carries Tersio's own mode state; a subagent starts without it. */
|
|
22
|
+
export function hasModeState(entries) {
|
|
23
|
+
return Array.isArray(entries) && entries.some((entry) => entry?.type === 'custom'
|
|
24
|
+
&& (entry.customType === 'combo-level' || MODE_ENTRY_TYPES[entry.customType ?? '']));
|
|
25
|
+
}
|
|
19
26
|
export function normalizeMode(name, value) {
|
|
20
27
|
if (name === 'rtk' && typeof value === 'boolean')
|
|
21
28
|
return value ? 'on' : 'off';
|
|
@@ -51,12 +58,12 @@ function bridge() {
|
|
|
51
58
|
if (existing?.state)
|
|
52
59
|
return existing;
|
|
53
60
|
const initial = normalizedState((existing || COMBO_LEVELS.off));
|
|
54
|
-
return (globalThis[BRIDGE_KEY] = { state: initial, listeners: new
|
|
61
|
+
return (globalThis[BRIDGE_KEY] = { state: initial, listeners: new Map() });
|
|
55
62
|
}
|
|
56
63
|
function publish(state) {
|
|
57
64
|
const shared = bridge();
|
|
58
65
|
shared.state = state;
|
|
59
|
-
for (const listener of shared.listeners)
|
|
66
|
+
for (const listener of shared.listeners.values())
|
|
60
67
|
listener(state);
|
|
61
68
|
return state;
|
|
62
69
|
}
|
|
@@ -70,33 +77,21 @@ export function systemPromptIncludes(systemPrompt, marker) {
|
|
|
70
77
|
return asPromptArray(systemPrompt).some((prompt) => typeof prompt === 'string' && prompt.includes(marker));
|
|
71
78
|
}
|
|
72
79
|
export function isOmpSubagentPrompt(systemPrompt) {
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
const raw = readPluginSettings().subagentMarkers;
|
|
81
|
+
const configured = Array.isArray(raw) ? raw.filter((m) => typeof m === 'string' && m.trim() !== '') : [];
|
|
82
|
+
// A malformed setting falls back rather than matching every turn.
|
|
83
|
+
const markers = configured.length ? configured : [OMP_SUBAGENT_MARKER];
|
|
84
|
+
return markers.some((marker) => systemPromptIncludes(systemPrompt, marker));
|
|
85
|
+
}
|
|
86
|
+
// ultra maps to max, lite/full to nearest, rather than erroring.
|
|
87
|
+
const COMBO_ALIASES = { ultra: 'max', lite: 'medium', full: 'balanced' };
|
|
75
88
|
export function normalizeComboLevel(value) {
|
|
76
89
|
const level = String(value || '').trim().toLowerCase();
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const theme = ui?.theme;
|
|
83
|
-
if (!theme?.fg)
|
|
84
|
-
return `${indicator} ${label}`;
|
|
85
|
-
return `${highlight ? theme.fg('accent', indicator) : indicator} ${theme.fg('muted', label)}`;
|
|
86
|
-
}
|
|
87
|
-
export function paintStatusBar(ui, key, emoji, label, isActive) {
|
|
88
|
-
ui?.setStatus?.(key, themeStatus(ui, emoji, label, isActive));
|
|
89
|
-
}
|
|
90
|
-
// Which context a status bar paints through. A UI-less event (a headless child
|
|
91
|
-
// session_start, or a top-level one that fires before the TUI attaches) must
|
|
92
|
-
// never be remembered: doing so mutes every later bare syncStatus(), which is
|
|
93
|
-
// the bridge-listener path a sibling's mode change arrives on. The symptom is a
|
|
94
|
-
// bar frozen on a preset the session no longer has.
|
|
95
|
-
export function paintableCtx(remembered, next) {
|
|
96
|
-
return next?.ui?.setStatus ? next : remembered;
|
|
97
|
-
}
|
|
98
|
-
// Last-wins scan for a custom session entry; skips entries whose value fails
|
|
99
|
-
// to parse so a corrupt write never shadows an older valid one.
|
|
90
|
+
if (isPresetLevel(level))
|
|
91
|
+
return level;
|
|
92
|
+
return COMBO_ALIASES[level] ?? null;
|
|
93
|
+
}
|
|
94
|
+
// Last-wins scan for a custom session entry; skips entries whose value fails to parse so a corrupt write never shadows an older valid one.
|
|
100
95
|
export function lastCustomValue(entries, customType, pick) {
|
|
101
96
|
if (!Array.isArray(entries))
|
|
102
97
|
return null;
|
|
@@ -129,8 +124,7 @@ export function setSharedComboMode(name, value) {
|
|
|
129
124
|
if (!mode)
|
|
130
125
|
return getSharedComboState();
|
|
131
126
|
const modes = { ...getSharedComboState(), [name]: mode };
|
|
132
|
-
// Derive the level from the final triplet so a redundant same-value write
|
|
133
|
-
// cannot strand a matching preset at 'custom' and drop the combo bar.
|
|
127
|
+
// Derive the level from the final triplet so a redundant same-value write cannot strand a matching preset at 'custom' and drop the combo bar.
|
|
134
128
|
return publish(normalizedState(modes));
|
|
135
129
|
}
|
|
136
130
|
export function reconcileSharedComboEntries(entries) {
|
|
@@ -154,21 +148,16 @@ export function reconcileSharedComboEntries(entries) {
|
|
|
154
148
|
modes[name] = mode;
|
|
155
149
|
}
|
|
156
150
|
}
|
|
157
|
-
// The level reflects the final triplet, never the write order, so a preset
|
|
158
|
-
// entry followed by the same values reconciles back to the preset.
|
|
151
|
+
// The level reflects the final triplet, never the write order, so a preset entry followed by the same values reconciles back to the preset.
|
|
159
152
|
return publish(normalizedState(modes));
|
|
160
153
|
}
|
|
161
154
|
// One-line session-wide mode summary for change confirmations.
|
|
162
155
|
export function activeModesSummary(state) {
|
|
163
156
|
return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
|
|
164
157
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (typeof listener === 'function')
|
|
169
|
-
bridge().listeners.add(listener);
|
|
170
|
-
else
|
|
171
|
-
bridge().listeners.clear();
|
|
158
|
+
// Keyed registration drops stale closures; hosts offer no teardown hook.
|
|
159
|
+
export function setSharedComboListener(key, listener) {
|
|
160
|
+
bridge().listeners.set(key, listener);
|
|
172
161
|
}
|
|
173
162
|
export function resetSharedComboState() {
|
|
174
163
|
return setSharedComboLevel('off');
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { readPluginSettings } from './plugin-settings.ts';
|
|
2
|
+
import type { ComboLevel, ComboState, ExtensionCtx, SessionEntry } from './types.ts';
|
|
2
3
|
|
|
3
4
|
const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
// A verbatim sentence from omp's subagent prompt, so it can drift; read from the installed omp binary on 2026-10-01. pi has no built-in subagent prompt.
|
|
7
|
+
export const OMP_SUBAGENT_MARKER = 'Worker agent: delegated tasks.';
|
|
6
8
|
|
|
7
9
|
export const COMBO_LEVELS: Record<string, Readonly<ComboState>> = Object.freeze({
|
|
8
10
|
off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off' }),
|
|
@@ -26,9 +28,15 @@ const MODE_ENTRY_TYPES: Record<string, ModeName> = {
|
|
|
26
28
|
'ponytail-mode': 'ponytail',
|
|
27
29
|
};
|
|
28
30
|
|
|
31
|
+
/** True when the branch carries Tersio's own mode state; a subagent starts without it. */
|
|
32
|
+
export function hasModeState(entries: SessionEntry[] | null | undefined): boolean {
|
|
33
|
+
return Array.isArray(entries) && entries.some((entry) => entry?.type === 'custom'
|
|
34
|
+
&& (entry.customType === 'combo-level' || MODE_ENTRY_TYPES[entry.customType ?? '']));
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
interface Bridge {
|
|
30
38
|
state: Readonly<ComboState>;
|
|
31
|
-
listeners:
|
|
39
|
+
listeners: Map<string, (state: Readonly<ComboState>) => void>;
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
export function normalizeMode(name: ModeName, value: unknown): string | null {
|
|
@@ -67,13 +75,13 @@ function bridge(): Bridge {
|
|
|
67
75
|
const existing = (globalThis as Record<symbol, unknown>)[BRIDGE_KEY] as Bridge | undefined;
|
|
68
76
|
if (existing?.state) return existing;
|
|
69
77
|
const initial = normalizedState((existing || COMBO_LEVELS.off) as Partial<Modes>);
|
|
70
|
-
return ((globalThis as Record<symbol, unknown>)[BRIDGE_KEY] = { state: initial, listeners: new
|
|
78
|
+
return ((globalThis as Record<symbol, unknown>)[BRIDGE_KEY] = { state: initial, listeners: new Map() });
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
function publish(state: Readonly<ComboState>): Readonly<ComboState> {
|
|
74
82
|
const shared = bridge();
|
|
75
83
|
shared.state = state;
|
|
76
|
-
for (const listener of shared.listeners) listener(state);
|
|
84
|
+
for (const listener of shared.listeners.values()) listener(state);
|
|
77
85
|
return state;
|
|
78
86
|
}
|
|
79
87
|
|
|
@@ -90,37 +98,24 @@ export function systemPromptIncludes(systemPrompt: string | string[], marker: st
|
|
|
90
98
|
}
|
|
91
99
|
|
|
92
100
|
export function isOmpSubagentPrompt(systemPrompt: string | string[]): boolean {
|
|
93
|
-
|
|
101
|
+
const raw = readPluginSettings().subagentMarkers;
|
|
102
|
+
const configured = Array.isArray(raw) ? raw.filter((m): m is string => typeof m === 'string' && m.trim() !== '') : [];
|
|
103
|
+
// A malformed setting falls back rather than matching every turn.
|
|
104
|
+
const markers = configured.length ? configured : [OMP_SUBAGENT_MARKER];
|
|
105
|
+
return markers.some((marker) => systemPromptIncludes(systemPrompt, marker));
|
|
94
106
|
}
|
|
95
107
|
|
|
108
|
+
// ultra maps to max, lite/full to nearest, rather than erroring.
|
|
109
|
+
const COMBO_ALIASES: Record<string, ComboLevel> = { ultra: 'max', lite: 'medium', full: 'balanced' };
|
|
110
|
+
|
|
96
111
|
export function normalizeComboLevel(value: unknown): ComboLevel | null {
|
|
97
112
|
const level = String(value || '').trim().toLowerCase();
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// OMP themes status text through ctx.ui.theme.fg; pi has no ctx.ui.theme, so
|
|
102
|
-
// the same string renders plain there.
|
|
103
|
-
export function themeStatus(ui: UiApi | undefined, indicator: string, label: string, highlight = false): string {
|
|
104
|
-
const theme = ui?.theme;
|
|
105
|
-
if (!theme?.fg) return `${indicator} ${label}`;
|
|
106
|
-
return `${highlight ? theme.fg('accent', indicator) : indicator} ${theme.fg('muted', label)}`;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function paintStatusBar(ui: UiApi | undefined, key: string, emoji: string, label: string, isActive: boolean): void {
|
|
110
|
-
ui?.setStatus?.(key, themeStatus(ui, emoji, label, isActive));
|
|
113
|
+
if (isPresetLevel(level)) return level;
|
|
114
|
+
return COMBO_ALIASES[level] ?? null;
|
|
111
115
|
}
|
|
112
116
|
|
|
113
|
-
// Which context a status bar paints through. A UI-less event (a headless child
|
|
114
|
-
// session_start, or a top-level one that fires before the TUI attaches) must
|
|
115
|
-
// never be remembered: doing so mutes every later bare syncStatus(), which is
|
|
116
|
-
// the bridge-listener path a sibling's mode change arrives on. The symptom is a
|
|
117
|
-
// bar frozen on a preset the session no longer has.
|
|
118
|
-
export function paintableCtx(remembered: ExtensionCtx | undefined, next: ExtensionCtx | undefined): ExtensionCtx | undefined {
|
|
119
|
-
return next?.ui?.setStatus ? next : remembered;
|
|
120
|
-
}
|
|
121
117
|
|
|
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.
|
|
118
|
+
// Last-wins scan for a custom session entry; skips entries whose value fails to parse so a corrupt write never shadows an older valid one.
|
|
124
119
|
export function lastCustomValue<T>(entries: SessionEntry[] | null | undefined, customType: string, pick: (data: SessionEntry['data']) => T | null | undefined): T | null {
|
|
125
120
|
if (!Array.isArray(entries)) return null;
|
|
126
121
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
@@ -154,8 +149,7 @@ export function setSharedComboMode(name: ModeName, value: unknown): Readonly<Com
|
|
|
154
149
|
const mode = normalizeMode(name, value);
|
|
155
150
|
if (!mode) return getSharedComboState();
|
|
156
151
|
const modes = { ...getSharedComboState(), [name]: mode } as Modes;
|
|
157
|
-
// Derive the level from the final triplet so a redundant same-value write
|
|
158
|
-
// cannot strand a matching preset at 'custom' and drop the combo bar.
|
|
152
|
+
// Derive the level from the final triplet so a redundant same-value write cannot strand a matching preset at 'custom' and drop the combo bar.
|
|
159
153
|
return publish(normalizedState(modes));
|
|
160
154
|
}
|
|
161
155
|
|
|
@@ -176,8 +170,7 @@ export function reconcileSharedComboEntries(entries: SessionEntry[] | null | und
|
|
|
176
170
|
if (mode) modes[name] = mode;
|
|
177
171
|
}
|
|
178
172
|
}
|
|
179
|
-
// The level reflects the final triplet, never the write order, so a preset
|
|
180
|
-
// entry followed by the same values reconciles back to the preset.
|
|
173
|
+
// The level reflects the final triplet, never the write order, so a preset entry followed by the same values reconciles back to the preset.
|
|
181
174
|
return publish(normalizedState(modes));
|
|
182
175
|
}
|
|
183
176
|
|
|
@@ -186,11 +179,9 @@ export function activeModesSummary(state: { caveman: string; rtk: string; ponyta
|
|
|
186
179
|
return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
|
|
187
180
|
}
|
|
188
181
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (typeof listener === 'function') bridge().listeners.add(listener);
|
|
193
|
-
else bridge().listeners.clear();
|
|
182
|
+
// Keyed registration drops stale closures; hosts offer no teardown hook.
|
|
183
|
+
export function setSharedComboListener(key: string, listener: (state: Readonly<ComboState>) => void): void {
|
|
184
|
+
bridge().listeners.set(key, listener);
|
|
194
185
|
}
|
|
195
186
|
|
|
196
187
|
export function resetSharedComboState(): Readonly<ComboState> {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// One status string every surface renders: host footer bar, CLI menu title, dashboard banner. Nothing else formats mode state.
|
|
2
|
+
import { getSharedComboState } from './session-state.js';
|
|
3
|
+
/** OFF collapses the three modes into one word; a preset or a mix names itself. */
|
|
4
|
+
export function formatStatus(state) {
|
|
5
|
+
const { level, caveman, rtk, ponytail } = state;
|
|
6
|
+
if (level === 'off' && caveman === 'off' && rtk === 'off' && ponytail === 'off')
|
|
7
|
+
return '🧩 combo OFF';
|
|
8
|
+
const label = level === 'custom' ? 'CUSTOM' : level.toUpperCase();
|
|
9
|
+
return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()}`;
|
|
10
|
+
}
|
|
11
|
+
// Stale ctx access throws, so never let it abort a mode restore.
|
|
12
|
+
function statusUi(ctx) {
|
|
13
|
+
try {
|
|
14
|
+
const ui = ctx?.ui;
|
|
15
|
+
return ui?.setStatus ? ui : undefined;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Subagents and bare restores must not mute the bar on the real session.
|
|
22
|
+
let remembered;
|
|
23
|
+
let lastText;
|
|
24
|
+
let lastUi;
|
|
25
|
+
// Paint the host footer bar; same text on the same UI is skipped, a new ctx repaints it.
|
|
26
|
+
export function announceStatus(ctx) {
|
|
27
|
+
const text = formatStatus(getSharedComboState());
|
|
28
|
+
const ui = statusUi(ctx) ?? statusUi(remembered);
|
|
29
|
+
if (statusUi(ctx))
|
|
30
|
+
remembered = ctx;
|
|
31
|
+
if (!ui)
|
|
32
|
+
return;
|
|
33
|
+
if (text === lastText && ui === lastUi)
|
|
34
|
+
return;
|
|
35
|
+
lastText = text;
|
|
36
|
+
lastUi = ui;
|
|
37
|
+
ui.setStatus?.('tersio', text);
|
|
38
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// One status string every surface renders: host footer bar, CLI menu title, dashboard banner. Nothing else formats mode state.
|
|
2
|
+
import { getSharedComboState } from './session-state.ts';
|
|
3
|
+
import type { ExtensionCtx, UiApi } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export interface StatusState {
|
|
6
|
+
level: string;
|
|
7
|
+
caveman: string;
|
|
8
|
+
rtk: string;
|
|
9
|
+
ponytail: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** OFF collapses the three modes into one word; a preset or a mix names itself. */
|
|
13
|
+
export function formatStatus(state: StatusState): string {
|
|
14
|
+
const { level, caveman, rtk, ponytail } = state;
|
|
15
|
+
if (level === 'off' && caveman === 'off' && rtk === 'off' && ponytail === 'off') return '🧩 combo OFF';
|
|
16
|
+
const label = level === 'custom' ? 'CUSTOM' : level.toUpperCase();
|
|
17
|
+
return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Stale ctx access throws, so never let it abort a mode restore.
|
|
21
|
+
function statusUi(ctx: ExtensionCtx | undefined): UiApi | undefined {
|
|
22
|
+
try {
|
|
23
|
+
const ui = ctx?.ui;
|
|
24
|
+
return ui?.setStatus ? ui : undefined;
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Subagents and bare restores must not mute the bar on the real session.
|
|
31
|
+
let remembered: ExtensionCtx | undefined;
|
|
32
|
+
let lastText: string | undefined;
|
|
33
|
+
let lastUi: UiApi | undefined;
|
|
34
|
+
|
|
35
|
+
// Paint the host footer bar; same text on the same UI is skipped, a new ctx repaints it.
|
|
36
|
+
export function announceStatus(ctx: ExtensionCtx | undefined): void {
|
|
37
|
+
const text = formatStatus(getSharedComboState());
|
|
38
|
+
const ui = statusUi(ctx) ?? statusUi(remembered);
|
|
39
|
+
if (statusUi(ctx)) remembered = ctx;
|
|
40
|
+
if (!ui) return;
|
|
41
|
+
if (text === lastText && ui === lastUi) return;
|
|
42
|
+
lastText = text;
|
|
43
|
+
lastUi = ui;
|
|
44
|
+
ui.setStatus?.('tersio', text);
|
|
45
|
+
}
|
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
// Shared OMP extension host types. Structural — the real `pi` object comes
|
|
2
|
-
// from the OMP runtime; these describe the surface this package touches.
|
|
1
|
+
// Shared OMP extension host types. Structural — the real `pi` object comes from the OMP runtime; these describe the surface this package touches.
|
|
3
2
|
export {};
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// Shared OMP extension host types. Structural — the real `pi` object comes
|
|
2
|
-
// from the OMP runtime; these describe the surface this package touches.
|
|
1
|
+
// Shared OMP extension host types. Structural — the real `pi` object comes from the OMP runtime; these describe the surface this package touches.
|
|
3
2
|
|
|
4
3
|
export interface ComboState {
|
|
5
4
|
level: string;
|
|
@@ -94,4 +93,5 @@ export interface ExtensionApi {
|
|
|
94
93
|
}>;
|
|
95
94
|
cwd?: string;
|
|
96
95
|
zod?: { z: unknown };
|
|
96
|
+
hostId?: 'pi' | 'omp' | 'opencode';
|
|
97
97
|
on<E>(event: string, handler: (event: E, ctx: ExtensionCtx) => unknown): void;}
|