@krtclcdy/tersio 2.24.1 → 2.25.1
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 +27 -21
- package/cli/banner.js +7 -4
- package/cli/common.js +14 -13
- package/cli/currency.js +1 -2
- package/cli/dashboard.js +57 -14
- package/cli/doctor-fix.js +88 -21
- package/cli/doctor.js +32 -24
- package/cli/hosts.js +19 -18
- package/cli/install.js +123 -52
- package/cli/interactive.js +29 -19
- package/cli/manifest.js +7 -0
- package/cli/profile.js +42 -11
- package/cli/rtk-wiring.js +28 -26
- package/cli/settings.js +37 -8
- package/cli/uninstall.js +40 -7
- package/cli/update.js +22 -14
- package/cli/usage.js +39 -14
- package/dashboard/dist/index.html +16 -16
- package/extensions/ai-addons-updater/index.js +69 -15
- package/extensions/ai-addons-updater/index.ts +60 -17
- package/extensions/caveman-session/index.js +28 -55
- package/extensions/caveman-session/index.ts +28 -55
- package/extensions/combo-toggle/index.js +40 -58
- package/extensions/combo-toggle/index.ts +38 -67
- package/extensions/lib/utils.js +60 -15
- package/extensions/lib/utils.ts +59 -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 +20 -35
- package/extensions/rtk-session/index.ts +19 -34
- package/extensions/shared/carbon.js +1 -2
- package/extensions/shared/carbon.ts +1 -2
- package/extensions/shared/host.js +95 -46
- package/extensions/shared/host.ts +106 -50
- package/extensions/shared/plugin-settings.js +4 -6
- package/extensions/shared/plugin-settings.ts +4 -6
- package/extensions/shared/pricing.js +79 -25
- package/extensions/shared/pricing.ts +80 -26
- package/extensions/shared/rtk-gain.js +3 -5
- package/extensions/shared/rtk-gain.ts +3 -5
- package/extensions/shared/session-state.js +10 -33
- package/extensions/shared/session-state.ts +12 -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 +190 -56
- package/extensions/shared/usage-ledger.ts +194 -58
- package/extensions/shared/usage-store.js +228 -64
- package/extensions/shared/usage-store.ts +235 -67
- package/extensions/tersio-commands/index.js +4 -9
- package/extensions/tersio-commands/index.ts +4 -9
- package/package.json +8 -2
- package/tersio.js +3 -3
- package/cli/agent-hosts.js +0 -42
- package/cli/agents.js +0 -266
- package/cli/layer.js +0 -22
- 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,13 +1,11 @@
|
|
|
1
|
-
// extensions/shared/pricing.ts — dynamic model pricing from LiteLLM. Exact id
|
|
2
|
-
// → provider-prefix strip → default, reported with known:false when unpriced.
|
|
1
|
+
// extensions/shared/pricing.ts — dynamic model pricing from LiteLLM. Exact id → provider-prefix strip → default, reported with known:false when unpriced.
|
|
3
2
|
import fs from 'node:fs';
|
|
4
3
|
import path from 'node:path';
|
|
5
4
|
import { tersioDataPath } from '../lib/utils.js';
|
|
6
5
|
// Sonnet-class default for unpriced models; always paired with known:false.
|
|
7
6
|
export const DEFAULT_PRICE = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
|
|
8
7
|
const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
|
|
9
|
-
// Readers use the file even when stale, and only one refresh runs per process;
|
|
10
|
-
// `tersio update` still forces a fresh fetch.
|
|
8
|
+
// Readers use the file even when stale, and only one refresh runs per process; `tersio update` still forces a fresh fetch.
|
|
11
9
|
const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
|
|
12
10
|
let refreshInflight = null;
|
|
13
11
|
export function pricesCachePath() {
|
|
@@ -22,8 +20,7 @@ export function pricesUrl() {
|
|
|
22
20
|
function toPrice(t) {
|
|
23
21
|
return { input: t[0], output: t[1], cacheRead: t[2], cacheWrite: t[3] };
|
|
24
22
|
}
|
|
25
|
-
// Tolerate both cache shapes (compact tuples and ModelPrice objects) so a
|
|
26
|
-
// version bump never orphans the cache.
|
|
23
|
+
// Tolerate both cache shapes (compact tuples and ModelPrice objects) so a version bump never orphans the cache.
|
|
27
24
|
function asPrice(v) {
|
|
28
25
|
if (Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) {
|
|
29
26
|
return toPrice(v);
|
|
@@ -31,14 +28,30 @@ function asPrice(v) {
|
|
|
31
28
|
if (v && typeof v === 'object') {
|
|
32
29
|
const o = v;
|
|
33
30
|
if (['input', 'output', 'cacheRead', 'cacheWrite'].every((k) => typeof o[k] === 'number' && Number.isFinite(o[k]) && o[k] >= 0)) {
|
|
34
|
-
// SAFETY: the line above verified all four keys are finite numbers >= 0,
|
|
35
|
-
// which is the whole of ModelPrice.
|
|
31
|
+
// SAFETY: the line above verified all four keys are finite numbers >= 0, which is the whole of ModelPrice.
|
|
36
32
|
return o;
|
|
37
33
|
}
|
|
38
34
|
}
|
|
39
35
|
return null;
|
|
40
36
|
}
|
|
37
|
+
let liveCache = null;
|
|
41
38
|
export function loadLivePrices() {
|
|
39
|
+
// One stat per call beats one full parse: the recent-rows map prices thousands of rows.
|
|
40
|
+
try {
|
|
41
|
+
const file = pricesCachePath();
|
|
42
|
+
const mtime = fs.statSync(file).mtimeMs;
|
|
43
|
+
if (liveCache && liveCache.path === file && liveCache.mtime === mtime)
|
|
44
|
+
return liveCache.prices;
|
|
45
|
+
const prices = readLivePrices();
|
|
46
|
+
if (prices)
|
|
47
|
+
liveCache = { path: file, mtime, prices };
|
|
48
|
+
return prices;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function readLivePrices() {
|
|
42
55
|
let raw;
|
|
43
56
|
try {
|
|
44
57
|
raw = fs.readFileSync(pricesCachePath(), 'utf8');
|
|
@@ -69,36 +82,78 @@ export function loadLivePrices() {
|
|
|
69
82
|
function num(v) {
|
|
70
83
|
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
|
|
71
84
|
}
|
|
72
|
-
// Transcripts and the feed spell one model differently; aliases stop a free
|
|
73
|
-
// model reporting the default price.
|
|
85
|
+
// Transcripts and the feed spell one model differently; aliases stop a free model reporting the default price.
|
|
74
86
|
export const MODEL_ALIASES = [
|
|
75
87
|
{ id: 'space-bunny', feed: 'stealth/space-bunny-alpha' },
|
|
76
88
|
];
|
|
77
89
|
export function canonicalPriceId(model) {
|
|
78
90
|
const key = model.toLowerCase();
|
|
79
|
-
|
|
91
|
+
// Suffix match: gateways prefix the id (opencode/kilo/stealth/space-bunny-alpha).
|
|
92
|
+
return MODEL_ALIASES.find((a) => a.id === key || a.feed === key || key.endsWith(`/${a.id}`) || key.endsWith(`/${a.feed}`))?.id ?? key;
|
|
80
93
|
}
|
|
81
94
|
export function priceFor(model, live) {
|
|
82
95
|
const table = live === undefined ? loadLivePrices() : live;
|
|
83
|
-
|
|
84
|
-
|
|
96
|
+
if (live === undefined) {
|
|
97
|
+
const memo = priceMemo(table);
|
|
98
|
+
const hit = memo.get(model);
|
|
99
|
+
if (hit)
|
|
100
|
+
return hit;
|
|
101
|
+
const resolved = resolvePrice(model, table);
|
|
102
|
+
memo.set(model, resolved);
|
|
103
|
+
return resolved;
|
|
104
|
+
}
|
|
105
|
+
return resolvePrice(model, table);
|
|
106
|
+
}
|
|
107
|
+
// Recent rows repeat a handful of models; the memo turns thousands of lookups into one per model. Cleared whenever the table identity changes.
|
|
108
|
+
let memoTable;
|
|
109
|
+
let memo = new Map();
|
|
110
|
+
function priceMemo(table) {
|
|
111
|
+
if (memoTable !== table) {
|
|
112
|
+
memoTable = table;
|
|
113
|
+
memo = new Map();
|
|
114
|
+
}
|
|
115
|
+
return memo;
|
|
116
|
+
}
|
|
117
|
+
// Validated once per table instead of once per lookup.
|
|
118
|
+
let compiledTable = null;
|
|
119
|
+
let compiledEntries = [];
|
|
120
|
+
function priceEntries(table) {
|
|
121
|
+
if (compiledTable === table)
|
|
122
|
+
return compiledEntries;
|
|
123
|
+
const entries = [];
|
|
124
|
+
for (const [k, v] of Object.entries(table.exact)) {
|
|
125
|
+
const price = asPrice(v);
|
|
126
|
+
if (price)
|
|
127
|
+
entries.push([k, price]);
|
|
128
|
+
}
|
|
129
|
+
compiledTable = table;
|
|
130
|
+
compiledEntries = entries;
|
|
131
|
+
return entries;
|
|
132
|
+
}
|
|
133
|
+
function resolvePrice(model, table) {
|
|
134
|
+
// 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.
|
|
85
135
|
const id = MODEL_ALIASES.find((a) => a.id === model.toLowerCase())?.feed ?? model;
|
|
86
136
|
if (table) {
|
|
87
|
-
const
|
|
137
|
+
const entries = priceEntries(table);
|
|
138
|
+
const lower = id.toLowerCase();
|
|
139
|
+
const hit = entries.find(([k]) => k === id) ?? entries.find(([k]) => k === lower);
|
|
88
140
|
if (hit)
|
|
89
|
-
return { price: hit, known: true, live: true };
|
|
141
|
+
return { price: hit[1], known: true, live: true };
|
|
90
142
|
// Provider-prefixed ids ("azure/gpt-4o", "dashscope/qwen-max"): match the
|
|
91
|
-
// first cached
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
143
|
+
// first cached key that ends with the full id, then with the bare tail
|
|
144
|
+
// segment, so "codex/openai/gpt-6-luna" still finds "gpt-6-luna".
|
|
145
|
+
const name = lower;
|
|
146
|
+
const tail = name.split('/').pop() ?? name;
|
|
147
|
+
const key = entries.find(([k]) => {
|
|
148
|
+
const lk = k.toLowerCase();
|
|
149
|
+
return lk === name || lk.endsWith(`/${name}`) || lk === tail || lk.endsWith(`/${tail}`);
|
|
150
|
+
});
|
|
151
|
+
if (key)
|
|
152
|
+
return { price: key[1], known: true, live: true };
|
|
97
153
|
}
|
|
98
154
|
return { price: DEFAULT_PRICE, known: false, live: false };
|
|
99
155
|
}
|
|
100
|
-
// Fetch LiteLLM pricing and write the cache. False on any failure — callers
|
|
101
|
-
// fall back silently.
|
|
156
|
+
// Fetch LiteLLM pricing and write the cache. False on any failure — callers fall back silently.
|
|
102
157
|
export async function refreshPrices() {
|
|
103
158
|
if (refreshInflight)
|
|
104
159
|
return refreshInflight;
|
|
@@ -162,8 +217,7 @@ export async function refreshPrices() {
|
|
|
162
217
|
refreshInflight = null;
|
|
163
218
|
}
|
|
164
219
|
}
|
|
165
|
-
// Fire-and-forget refresh when the cache is stale; readers keep the stale
|
|
166
|
-
// file, so pricing never blocks.
|
|
220
|
+
// Fire-and-forget refresh when the cache is stale; readers keep the stale file, so pricing never blocks.
|
|
167
221
|
export function refreshPricesIfStale() {
|
|
168
222
|
const live = loadLivePrices();
|
|
169
223
|
if (live && Date.now() - live.fetchedAt <= CACHE_TTL_MS)
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// extensions/shared/pricing.ts — dynamic model pricing from LiteLLM. Exact id
|
|
2
|
-
// → provider-prefix strip → default, reported with known:false when unpriced.
|
|
1
|
+
// extensions/shared/pricing.ts — dynamic model pricing from LiteLLM. Exact id → provider-prefix strip → default, reported with known:false when unpriced.
|
|
3
2
|
import fs from 'node:fs';
|
|
4
3
|
import path from 'node:path';
|
|
5
4
|
import { tersioDataPath } from '../lib/utils.ts';
|
|
@@ -15,8 +14,7 @@ export interface ModelPrice {
|
|
|
15
14
|
export const DEFAULT_PRICE: ModelPrice = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
|
|
16
15
|
|
|
17
16
|
const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
|
|
18
|
-
// Readers use the file even when stale, and only one refresh runs per process;
|
|
19
|
-
// `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.
|
|
20
18
|
const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
|
|
21
19
|
let refreshInflight: Promise<boolean> | null = null;
|
|
22
20
|
|
|
@@ -32,8 +30,7 @@ export function pricesUrl(): string {
|
|
|
32
30
|
|
|
33
31
|
export interface LivePrices {
|
|
34
32
|
fetchedAt: number;
|
|
35
|
-
// Compact tuples [input, output, cacheRead, cacheWrite] per LiteLLM id —
|
|
36
|
-
// 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.
|
|
37
34
|
exact: Record<string, [number, number, number, number]>;
|
|
38
35
|
deprecated?: string[];
|
|
39
36
|
}
|
|
@@ -42,8 +39,7 @@ function toPrice(t: [number, number, number, number]): ModelPrice {
|
|
|
42
39
|
return { input: t[0], output: t[1], cacheRead: t[2], cacheWrite: t[3] };
|
|
43
40
|
}
|
|
44
41
|
|
|
45
|
-
// Tolerate both cache shapes (compact tuples and ModelPrice objects) so a
|
|
46
|
-
// version bump never orphans the cache.
|
|
42
|
+
// Tolerate both cache shapes (compact tuples and ModelPrice objects) so a version bump never orphans the cache.
|
|
47
43
|
function asPrice(v: unknown): ModelPrice | null {
|
|
48
44
|
if (Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) {
|
|
49
45
|
return toPrice(v as [number, number, number, number]);
|
|
@@ -51,15 +47,30 @@ function asPrice(v: unknown): ModelPrice | null {
|
|
|
51
47
|
if (v && typeof v === 'object') {
|
|
52
48
|
const o = v as Record<string, unknown>;
|
|
53
49
|
if (['input', 'output', 'cacheRead', 'cacheWrite'].every((k) => typeof o[k] === 'number' && Number.isFinite(o[k]) && (o[k] as number) >= 0)) {
|
|
54
|
-
// SAFETY: the line above verified all four keys are finite numbers >= 0,
|
|
55
|
-
// 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.
|
|
56
51
|
return o as unknown as ModelPrice;
|
|
57
52
|
}
|
|
58
53
|
}
|
|
59
54
|
return null;
|
|
60
55
|
}
|
|
61
56
|
|
|
57
|
+
let liveCache: { path: string; mtime: number; prices: LivePrices } | null = null;
|
|
58
|
+
|
|
62
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 {
|
|
63
74
|
let raw: string;
|
|
64
75
|
try {
|
|
65
76
|
raw = fs.readFileSync(pricesCachePath(), 'utf8');
|
|
@@ -87,36 +98,80 @@ function num(v: unknown): number | null {
|
|
|
87
98
|
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
|
|
88
99
|
}
|
|
89
100
|
|
|
90
|
-
// Transcripts and the feed spell one model differently; aliases stop a free
|
|
91
|
-
// model reporting the default price.
|
|
101
|
+
// Transcripts and the feed spell one model differently; aliases stop a free model reporting the default price.
|
|
92
102
|
export const MODEL_ALIASES: ReadonlyArray<{ id: string; feed: string }> = [
|
|
93
103
|
{ id: 'space-bunny', feed: 'stealth/space-bunny-alpha' },
|
|
94
104
|
];
|
|
95
105
|
|
|
96
106
|
export function canonicalPriceId(model: string): string {
|
|
97
107
|
const key = model.toLowerCase();
|
|
98
|
-
|
|
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;
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
export function priceFor(model: string, live?: LivePrices | null): { price: ModelPrice; known: boolean; live: boolean } {
|
|
102
113
|
const table = live === undefined ? loadLivePrices() : live;
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
105
155
|
const id = MODEL_ALIASES.find((a) => a.id === model.toLowerCase())?.feed ?? model;
|
|
106
156
|
if (table) {
|
|
107
|
-
const
|
|
108
|
-
|
|
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 };
|
|
109
161
|
// Provider-prefixed ids ("azure/gpt-4o", "dashscope/qwen-max"): match the
|
|
110
|
-
// first cached
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
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 };
|
|
115
171
|
}
|
|
116
172
|
return { price: DEFAULT_PRICE, known: false, live: false };
|
|
117
173
|
}
|
|
118
|
-
// Fetch LiteLLM pricing and write the cache. False on any failure — callers
|
|
119
|
-
// fall back silently.
|
|
174
|
+
// Fetch LiteLLM pricing and write the cache. False on any failure — callers fall back silently.
|
|
120
175
|
export async function refreshPrices(): Promise<boolean> {
|
|
121
176
|
if (refreshInflight) return refreshInflight;
|
|
122
177
|
refreshInflight = (async () => {
|
|
@@ -170,8 +225,7 @@ export async function refreshPrices(): Promise<boolean> {
|
|
|
170
225
|
}
|
|
171
226
|
}
|
|
172
227
|
|
|
173
|
-
// Fire-and-forget refresh when the cache is stale; readers keep the stale
|
|
174
|
-
// file, so pricing never blocks.
|
|
228
|
+
// Fire-and-forget refresh when the cache is stale; readers keep the stale file, so pricing never blocks.
|
|
175
229
|
export function refreshPricesIfStale(): void {
|
|
176
230
|
const live = loadLivePrices();
|
|
177
231
|
if (live && Date.now() - live.fetchedAt <= CACHE_TTL_MS) return;
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
// Savings read from rtk history, never estimated; modes bench in BENCHMARK.md.
|
|
1
|
+
// Savings read from rtk history, never estimated; modes bench in docs/BENCHMARK.md.
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
const EMPTY = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
|
|
7
|
-
// The table shows one clipped cell, so bound the command before it crosses the
|
|
8
|
-
// 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.
|
|
9
8
|
const MAX_CMD_CHARS = 200;
|
|
10
9
|
// A multi-line stored command would derail every row after it; flatten on read.
|
|
11
10
|
const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
|
|
@@ -34,8 +33,7 @@ export function readRtkGain(limit = 10, cutoffMs) {
|
|
|
34
33
|
try {
|
|
35
34
|
if (!fs.existsSync(rtkDbPath()))
|
|
36
35
|
return EMPTY;
|
|
37
|
-
// View-level cutoff only: rtk's own rows stay untouched. Timestamps that do
|
|
38
|
-
// 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.
|
|
39
37
|
const where = cutoffMs && cutoffMs > 0
|
|
40
38
|
? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
|
|
41
39
|
: '';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Savings read from rtk history, never estimated; modes bench in BENCHMARK.md.
|
|
1
|
+
// Savings read from rtk history, never estimated; modes bench in docs/BENCHMARK.md.
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
@@ -23,8 +23,7 @@ export interface RtkGain {
|
|
|
23
23
|
|
|
24
24
|
const EMPTY: RtkGain = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
|
|
25
25
|
|
|
26
|
-
// The table shows one clipped cell, so bound the command before it crosses the
|
|
27
|
-
// 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.
|
|
28
27
|
const MAX_CMD_CHARS = 200;
|
|
29
28
|
|
|
30
29
|
// A multi-line stored command would derail every row after it; flatten on read.
|
|
@@ -55,8 +54,7 @@ function query(db: string, sql: string): string[][] {
|
|
|
55
54
|
export function readRtkGain(limit = 10, cutoffMs?: number): RtkGain {
|
|
56
55
|
try {
|
|
57
56
|
if (!fs.existsSync(rtkDbPath())) return EMPTY;
|
|
58
|
-
// View-level cutoff only: rtk's own rows stay untouched. Timestamps that do
|
|
59
|
-
// 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.
|
|
60
58
|
const where = cutoffMs && cutoffMs > 0
|
|
61
59
|
? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
|
|
62
60
|
: '';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readPluginSettings } from './plugin-settings.js';
|
|
2
2
|
const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
|
|
3
|
-
// A verbatim sentence from
|
|
4
|
-
export const OMP_SUBAGENT_MARKER = '
|
|
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.';
|
|
5
5
|
export const COMBO_LEVELS = Object.freeze({
|
|
6
6
|
off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off' }),
|
|
7
7
|
medium: Object.freeze({ level: 'medium', caveman: 'lite', rtk: 'on', ponytail: 'lite' }),
|
|
@@ -18,6 +18,11 @@ const MODE_ENTRY_TYPES = {
|
|
|
18
18
|
'rtk-mode': 'rtk',
|
|
19
19
|
'ponytail-mode': 'ponytail',
|
|
20
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
|
+
}
|
|
21
26
|
export function normalizeMode(name, value) {
|
|
22
27
|
if (name === 'rtk' && typeof value === 'boolean')
|
|
23
28
|
return value ? 'on' : 'off';
|
|
@@ -86,33 +91,7 @@ export function normalizeComboLevel(value) {
|
|
|
86
91
|
return level;
|
|
87
92
|
return COMBO_ALIASES[level] ?? null;
|
|
88
93
|
}
|
|
89
|
-
//
|
|
90
|
-
// the same string renders plain there.
|
|
91
|
-
export function themeStatus(ui, indicator, label, highlight = false) {
|
|
92
|
-
const theme = ui?.theme;
|
|
93
|
-
if (!theme?.fg)
|
|
94
|
-
return `${indicator} ${label}`;
|
|
95
|
-
return `${highlight ? theme.fg('accent', indicator) : indicator} ${theme.fg('muted', label)}`;
|
|
96
|
-
}
|
|
97
|
-
export function paintStatusBar(ui, key, emoji, label, isActive) {
|
|
98
|
-
ui?.setStatus?.(key, themeStatus(ui, emoji, label, isActive));
|
|
99
|
-
}
|
|
100
|
-
export function statusUi(ctx) {
|
|
101
|
-
try {
|
|
102
|
-
const ui = ctx?.ui;
|
|
103
|
-
return ui?.setStatus ? ui : undefined;
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
return undefined;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
// Which context a status bar paints through. Never remember a UI-less event:
|
|
110
|
-
// that mutes every later bare syncStatus(), freezing the bar on a stale preset.
|
|
111
|
-
export function paintableCtx(remembered, next) {
|
|
112
|
-
return statusUi(next) ? next : remembered;
|
|
113
|
-
}
|
|
114
|
-
// Last-wins scan for a custom session entry; skips entries whose value fails
|
|
115
|
-
// to parse so a corrupt write never shadows an older valid one.
|
|
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.
|
|
116
95
|
export function lastCustomValue(entries, customType, pick) {
|
|
117
96
|
if (!Array.isArray(entries))
|
|
118
97
|
return null;
|
|
@@ -145,8 +124,7 @@ export function setSharedComboMode(name, value) {
|
|
|
145
124
|
if (!mode)
|
|
146
125
|
return getSharedComboState();
|
|
147
126
|
const modes = { ...getSharedComboState(), [name]: mode };
|
|
148
|
-
// Derive the level from the final triplet so a redundant same-value write
|
|
149
|
-
// 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.
|
|
150
128
|
return publish(normalizedState(modes));
|
|
151
129
|
}
|
|
152
130
|
export function reconcileSharedComboEntries(entries) {
|
|
@@ -170,8 +148,7 @@ export function reconcileSharedComboEntries(entries) {
|
|
|
170
148
|
modes[name] = mode;
|
|
171
149
|
}
|
|
172
150
|
}
|
|
173
|
-
// The level reflects the final triplet, never the write order, so a preset
|
|
174
|
-
// 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.
|
|
175
152
|
return publish(normalizedState(modes));
|
|
176
153
|
}
|
|
177
154
|
// One-line session-wide mode summary for change confirmations.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { readPluginSettings } from './plugin-settings.ts';
|
|
2
|
-
import type { ComboLevel, ComboState, ExtensionCtx, SessionEntry
|
|
2
|
+
import type { ComboLevel, ComboState, ExtensionCtx, SessionEntry } from './types.ts';
|
|
3
3
|
|
|
4
4
|
const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
|
|
5
5
|
|
|
6
|
-
// A verbatim sentence from
|
|
7
|
-
export const OMP_SUBAGENT_MARKER = '
|
|
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.';
|
|
8
8
|
|
|
9
9
|
export const COMBO_LEVELS: Record<string, Readonly<ComboState>> = Object.freeze({
|
|
10
10
|
off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off' }),
|
|
@@ -28,6 +28,12 @@ const MODE_ENTRY_TYPES: Record<string, ModeName> = {
|
|
|
28
28
|
'ponytail-mode': 'ponytail',
|
|
29
29
|
};
|
|
30
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
|
+
|
|
31
37
|
interface Bridge {
|
|
32
38
|
state: Readonly<ComboState>;
|
|
33
39
|
listeners: Map<string, (state: Readonly<ComboState>) => void>;
|
|
@@ -108,38 +114,8 @@ export function normalizeComboLevel(value: unknown): ComboLevel | null {
|
|
|
108
114
|
return COMBO_ALIASES[level] ?? null;
|
|
109
115
|
}
|
|
110
116
|
|
|
111
|
-
// OMP themes status text through ctx.ui.theme.fg; pi has no ctx.ui.theme, so
|
|
112
|
-
// the same string renders plain there.
|
|
113
|
-
export function themeStatus(ui: UiApi | undefined, indicator: string, label: string, highlight = false): string {
|
|
114
|
-
const theme = ui?.theme;
|
|
115
|
-
if (!theme?.fg) return `${indicator} ${label}`;
|
|
116
|
-
return `${highlight ? theme.fg('accent', indicator) : indicator} ${theme.fg('muted', label)}`;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export function paintStatusBar(ui: UiApi | undefined, key: string, emoji: string, label: string, isActive: boolean): void {
|
|
120
|
-
ui?.setStatus?.(key, themeStatus(ui, emoji, label, isActive));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Stale ctx access throws, so never let it abort a mode restore.
|
|
124
|
-
export type StatusUi = UiApi & { setStatus: NonNullable<UiApi['setStatus']> };
|
|
125
|
-
|
|
126
|
-
export function statusUi(ctx: ExtensionCtx | undefined): StatusUi | undefined {
|
|
127
|
-
try {
|
|
128
|
-
const ui = ctx?.ui;
|
|
129
|
-
return ui?.setStatus ? (ui as StatusUi) : undefined;
|
|
130
|
-
} catch {
|
|
131
|
-
return undefined;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Which context a status bar paints through. Never remember a UI-less event:
|
|
136
|
-
// that mutes every later bare syncStatus(), freezing the bar on a stale preset.
|
|
137
|
-
export function paintableCtx(remembered: ExtensionCtx | undefined, next: ExtensionCtx | undefined): ExtensionCtx | undefined {
|
|
138
|
-
return statusUi(next) ? next : remembered;
|
|
139
|
-
}
|
|
140
117
|
|
|
141
|
-
// Last-wins scan for a custom session entry; skips entries whose value fails
|
|
142
|
-
// 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.
|
|
143
119
|
export function lastCustomValue<T>(entries: SessionEntry[] | null | undefined, customType: string, pick: (data: SessionEntry['data']) => T | null | undefined): T | null {
|
|
144
120
|
if (!Array.isArray(entries)) return null;
|
|
145
121
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
@@ -173,8 +149,7 @@ export function setSharedComboMode(name: ModeName, value: unknown): Readonly<Com
|
|
|
173
149
|
const mode = normalizeMode(name, value);
|
|
174
150
|
if (!mode) return getSharedComboState();
|
|
175
151
|
const modes = { ...getSharedComboState(), [name]: mode } as Modes;
|
|
176
|
-
// Derive the level from the final triplet so a redundant same-value write
|
|
177
|
-
// 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.
|
|
178
153
|
return publish(normalizedState(modes));
|
|
179
154
|
}
|
|
180
155
|
|
|
@@ -195,8 +170,7 @@ export function reconcileSharedComboEntries(entries: SessionEntry[] | null | und
|
|
|
195
170
|
if (mode) modes[name] = mode;
|
|
196
171
|
}
|
|
197
172
|
}
|
|
198
|
-
// The level reflects the final triplet, never the write order, so a preset
|
|
199
|
-
// 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.
|
|
200
174
|
return publish(normalizedState(modes));
|
|
201
175
|
}
|
|
202
176
|
|
|
@@ -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;}
|