@krtclcdy/tersio 2.9.0 → 2.11.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.
@@ -0,0 +1,88 @@
1
+ // extensions/shared/carbon.ts — CO2/energy footprints, EcoLogits-style.
2
+ //
3
+ // Port of the EcoLogits v0.8.2 LLM impact model (genai-impact/ecologits,
4
+ // MPL-2.0 — see ECO_NOTICE.md), following the same served-estimate approach
5
+ // as honey-for-devs' hooks/eco.js: the faithful single-stream (batch-1)
6
+ // ceiling divided by serving_concurrency for continuous-batching amortization.
7
+ //
8
+ // Model params come from EcoLogits' registry where it covers the model;
9
+ // otherwise a documented class borrow (never a silent guess — every borrow
10
+ // carries its source). Unknown ids fall to the gpt-4o default. Everything
11
+ // rendered from here must carry ~est. and never merge with measured figures.
12
+ // EcoLogits 0.8.2 constants — kWh, kgCO2eq, seconds, GB.
13
+ const Q_BITS = 4;
14
+ const E_ALPHA = 8.91e-8;
15
+ const E_BETA = 1.43e-6;
16
+ const L_ALPHA = 8.02e-4;
17
+ const L_BETA = 2.23e-2;
18
+ const GPU_MEM = 80;
19
+ const GPU_EMB_GWP = 143;
20
+ const SRV_GPUS = 8;
21
+ const SRV_POWER = 1;
22
+ const SRV_EMB_GWP = 3000;
23
+ const LIFETIME = 5 * 365 * 24 * 3600;
24
+ const PUE = 1.2;
25
+ // Per-provider grid intensity, gCO2eq/kWh (location-based research values).
26
+ const GRIDS = {
27
+ anthropic: 500,
28
+ openai: 400,
29
+ google: 330,
30
+ default: 450,
31
+ };
32
+ // Serving amortization: single-stream ceiling ÷ concurrency ≈ served impact.
33
+ export const SERVING_CONCURRENCY = 32;
34
+ // Registry values verbatim from EcoLogits' model_repository; borrows cite
35
+ // the public param disclosure they stand in for (MoE active/total).
36
+ const TABLE = [
37
+ { match: 'opus', active: 625.0, total: 2000, provider: 'anthropic', source: 'registry' },
38
+ { match: 'sonnet', active: 137.5, total: 440, provider: 'anthropic', source: 'registry' },
39
+ { match: 'haiku', active: 18.0, total: 18.0, provider: 'anthropic', source: 'registry' },
40
+ { match: 'gpt-5', active: 93.75, total: 300, provider: 'openai', source: 'registry' },
41
+ { match: 'gpt', active: 137.5, total: 440, provider: 'openai', source: 'registry' },
42
+ { match: 'gemini', active: 137.5, total: 440, provider: 'google', source: 'registry' },
43
+ { match: 'deepseek', active: 37, total: 671, provider: 'default', source: 'borrowed' },
44
+ { match: 'qwen', active: 22, total: 235, provider: 'default', source: 'borrowed' },
45
+ { match: 'glm', active: 37, total: 671, provider: 'default', source: 'borrowed' },
46
+ { match: 'kimi', active: 32, total: 1000, provider: 'default', source: 'borrowed' },
47
+ { match: 'muse', active: 70, total: 70, provider: 'default', source: 'borrowed' },
48
+ { match: 'flash', active: 18.0, total: 18.0, provider: 'default', source: 'borrowed' },
49
+ ];
50
+ const DEFAULT_ENTRY = { active: 137.5, total: 440, provider: 'openai' };
51
+ export function carbonParamsFor(model) {
52
+ const name = model.toLowerCase();
53
+ for (const row of TABLE) {
54
+ if (row.match && name.includes(row.match)) {
55
+ return { activeB: row.active, totalB: row.total, provider: row.provider, source: row.source };
56
+ }
57
+ }
58
+ return { activeB: DEFAULT_ENTRY.active, totalB: DEFAULT_ENTRY.total, provider: DEFAULT_ENTRY.provider, source: 'default' };
59
+ }
60
+ function impacts(activeB, totalB, outTokens, gridKg) {
61
+ const gpuEnergy = outTokens * (E_ALPHA * activeB + E_BETA);
62
+ const latency = outTokens * (L_ALPHA * activeB + L_BETA);
63
+ const gpuCount = Math.ceil(((1.2 * totalB * Q_BITS) / 8 / GPU_MEM));
64
+ const serverEnergy = (latency / 3600) * SRV_POWER * (gpuCount / SRV_GPUS);
65
+ const energyKwh = PUE * (serverEnergy + gpuCount * gpuEnergy);
66
+ const embGwp = (latency / LIFETIME) * ((gpuCount / SRV_GPUS) * SRV_EMB_GWP + gpuCount * GPU_EMB_GWP);
67
+ return { energyKwh, gco2: (energyKwh * gridKg + embGwp) * 1000 };
68
+ }
69
+ export function footprintFor(model, outputTokens) {
70
+ const p = carbonParamsFor(model);
71
+ const gridKg = (GRIDS[p.provider] ?? GRIDS.default) / 1000;
72
+ const ceiling = impacts(p.activeB, p.totalB, Math.max(0, Math.floor(outputTokens)), gridKg);
73
+ const b = SERVING_CONCURRENCY > 0 ? SERVING_CONCURRENCY : 1;
74
+ return {
75
+ energyWh: (ceiling.energyKwh / b) * 1000,
76
+ gco2: ceiling.gco2 / b,
77
+ gco2Ceiling: ceiling.gco2,
78
+ concurrency: b,
79
+ provider: p.provider,
80
+ paramSource: p.source,
81
+ };
82
+ }
83
+ export function co2GramsFor(model, outputTokens) {
84
+ return footprintFor(model, outputTokens).gco2;
85
+ }
86
+ export function energyWhFor(model, outputTokens) {
87
+ return footprintFor(model, outputTokens).energyWh;
88
+ }
@@ -0,0 +1,121 @@
1
+ // extensions/shared/pricing.ts — model pricing with live LiteLLM refresh.
2
+ // Lookup order: exact id in the live cache → built-in substring table → default.
3
+ // Network only in refreshPrices (called from `tersio update`); every reader is
4
+ // sync and offline-safe. Best-effort throughout: pricing never breaks a caller.
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ // Rough list rates (USD per 1M tokens) for the models OMP sessions cite.
9
+ // Refresh against LiteLLM pricing data when adding families.
10
+ const PRICE_TABLE = [
11
+ { match: ':free', price: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } },
12
+ { match: 'opus', price: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 } },
13
+ { match: 'sonnet', price: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 } },
14
+ { match: 'haiku', price: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 } },
15
+ { match: 'gpt', price: { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 2.5 } },
16
+ { match: 'gemini', price: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 } },
17
+ { match: 'glm', price: { input: 0.5, output: 1, cacheRead: 0.05, cacheWrite: 0.5 } },
18
+ ];
19
+ export const DEFAULT_PRICE = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 };
20
+ const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
21
+ const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
22
+ const KEEP_RE = /claude|sonnet|opus|haiku|gpt|gemini|glm|deepseek|qwen|kimi|mistral|grok|llama|muse|nemotron|ministral|codestral|qwen/i;
23
+ export function pricesCachePath() {
24
+ const override = process.env.TERSIO_PRICES_FILE;
25
+ if (override)
26
+ return override;
27
+ return path.join(os.homedir(), '.omp', 'plugins', 'tersio-prices.json');
28
+ }
29
+ export function pricesUrl() {
30
+ return process.env.TERSIO_PRICES_URL || LITELLM_URL;
31
+ }
32
+ export function loadLivePrices() {
33
+ let raw;
34
+ try {
35
+ raw = fs.readFileSync(pricesCachePath(), 'utf8');
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ try {
41
+ const parsed = JSON.parse(raw);
42
+ if (typeof parsed.fetchedAt !== 'number' || !parsed.exact || typeof parsed.exact !== 'object')
43
+ return null;
44
+ if (Date.now() - parsed.fetchedAt > CACHE_TTL_MS)
45
+ return null;
46
+ return { fetchedAt: parsed.fetchedAt, exact: parsed.exact };
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ function num(v) {
53
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
54
+ }
55
+ export function priceFor(model, live) {
56
+ const table = live === undefined ? loadLivePrices() : live;
57
+ if (table) {
58
+ const hit = table.exact[model] ?? table.exact[model.toLowerCase()];
59
+ if (hit && [hit.input, hit.output, hit.cacheRead, hit.cacheWrite].every((v) => typeof v === 'number')) {
60
+ return { price: hit, known: true, live: true };
61
+ }
62
+ }
63
+ const name = model.toLowerCase();
64
+ for (const row of PRICE_TABLE) {
65
+ if (name.includes(row.match))
66
+ return { price: row.price, known: true, live: false };
67
+ }
68
+ return { price: DEFAULT_PRICE, known: false, live: false };
69
+ }
70
+ // Fetch LiteLLM pricing, keep relevant families, write the cache. False on
71
+ // any failure (offline, timeout, shape change) — callers fall back silently.
72
+ export async function refreshPrices() {
73
+ let res;
74
+ try {
75
+ const ctl = new AbortController();
76
+ const timer = setTimeout(() => ctl.abort(), 15000);
77
+ try {
78
+ res = await fetch(pricesUrl(), { signal: ctl.signal });
79
+ }
80
+ finally {
81
+ clearTimeout(timer);
82
+ }
83
+ if (!res.ok)
84
+ return false;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ let data;
90
+ try {
91
+ data = (await res.json());
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ const exact = {};
97
+ for (const [id, row] of Object.entries(data)) {
98
+ if (!row || typeof row !== 'object' || !KEEP_RE.test(id))
99
+ continue;
100
+ const input = num(row.input_cost_per_token);
101
+ const output = num(row.output_cost_per_token);
102
+ if (input === null || output === null)
103
+ continue;
104
+ exact[id] = {
105
+ input: input * 1e6,
106
+ output: output * 1e6,
107
+ cacheRead: (num(row.cache_read_input_token_cost) ?? input) * 1e6,
108
+ cacheWrite: (num(row.cache_creation_input_token_cost) ?? input) * 1e6,
109
+ };
110
+ }
111
+ if (!Object.keys(exact).length)
112
+ return false;
113
+ try {
114
+ fs.mkdirSync(path.dirname(pricesCachePath()), { recursive: true });
115
+ fs.writeFileSync(pricesCachePath(), JSON.stringify({ fetchedAt: Date.now(), exact }), 'utf8');
116
+ return true;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ }
@@ -0,0 +1,61 @@
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
+ import { execFileSync } from 'node:child_process';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ const EMPTY = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
15
+ export function rtkDbPath() {
16
+ const override = process.env.TERSIO_RTK_DB;
17
+ if (override)
18
+ return override;
19
+ if (process.platform === 'darwin') {
20
+ return path.join(os.homedir(), 'Library', 'Application Support', 'rtk', 'history.db');
21
+ }
22
+ const xdg = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
23
+ return path.join(xdg, 'rtk', 'history.db');
24
+ }
25
+ function query(db, sql) {
26
+ const out = execFileSync('sqlite3', ['-separator', '\t', db, sql], {
27
+ encoding: 'utf8',
28
+ timeout: 5000,
29
+ stdio: ['ignore', 'pipe', 'ignore'],
30
+ });
31
+ return out
32
+ .split('\n')
33
+ .filter((line) => line.trim())
34
+ .map((line) => line.split('\t'));
35
+ }
36
+ export function readRtkGain(limit = 10) {
37
+ try {
38
+ if (!fs.existsSync(rtkDbPath()))
39
+ return EMPTY;
40
+ const [[commands = '0', saved = '0', input = '0', avgPct = '0', totalMs = '0'] = []] = query(rtkDbPath(), '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;');
41
+ const byCommand = query(rtkDbPath(), `SELECT rtk_cmd, COUNT(*), SUM(saved_tokens), AVG(savings_pct), AVG(exec_time_ms) FROM commands GROUP BY rtk_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`).map(([command, count, savedRow, pct, ms]) => ({
42
+ command,
43
+ count: Number(count) || 0,
44
+ saved: Number(savedRow) || 0,
45
+ avgPct: Number(pct) || 0,
46
+ avgMs: Number(ms) || 0,
47
+ }));
48
+ const totalIn = Number(input) || 0;
49
+ return {
50
+ commands: Number(commands) || 0,
51
+ saved: Number(saved) || 0,
52
+ input: totalIn,
53
+ avgPct: totalIn ? ((Number(saved) || 0) / totalIn) * 100 : 0,
54
+ totalMs: Number(totalMs) || 0,
55
+ byCommand,
56
+ };
57
+ }
58
+ catch {
59
+ return EMPTY;
60
+ }
61
+ }
@@ -49,12 +49,13 @@ function bridge() {
49
49
  if (existing?.state)
50
50
  return existing;
51
51
  const initial = normalizedState((existing || COMBO_LEVELS.off));
52
- return (globalThis[BRIDGE_KEY] = { state: initial, listener: null });
52
+ return (globalThis[BRIDGE_KEY] = { state: initial, listeners: new Set() });
53
53
  }
54
54
  function publish(state) {
55
55
  const shared = bridge();
56
56
  shared.state = state;
57
- shared.listener?.(state);
57
+ for (const listener of shared.listeners)
58
+ listener(state);
58
59
  return state;
59
60
  }
60
61
  export function normalizeInputCommand(value) {
@@ -148,7 +149,13 @@ export function activeModesSummary(state) {
148
149
  return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
149
150
  }
150
151
  export function setSharedComboListener(listener) {
151
- bridge().listener = typeof listener === 'function' ? listener : null;
152
+ // Additive: every sibling extension syncs its local mirror on publish, so
153
+ // a /tersio or /combo switch takes effect next turn with no session reload.
154
+ // A null listener clears all (no current callers; reserved for teardown).
155
+ if (typeof listener === 'function')
156
+ bridge().listeners.add(listener);
157
+ else
158
+ bridge().listeners.clear();
152
159
  }
153
160
  export function resetSharedComboState() {
154
161
  return setSharedComboLevel('off');
@@ -35,6 +35,15 @@ export function sessionsDir() {
35
35
  return override;
36
36
  return path.join(os.homedir(), '.omp', 'agent', 'sessions');
37
37
  }
38
+ export function codexSessionsDir() {
39
+ const override = process.env.TERSIO_CODEX_DIR;
40
+ if (override)
41
+ return override;
42
+ const codexHome = process.env.CODEX_HOME;
43
+ if (codexHome)
44
+ return path.join(codexHome, 'sessions');
45
+ return path.join(os.homedir(), '.codex', 'sessions');
46
+ }
38
47
  function dayKey(ts) {
39
48
  const ms = typeof ts === 'number' ? ts : Date.parse(ts);
40
49
  if (!Number.isFinite(ms))
@@ -66,11 +75,40 @@ export function importSessionTokens() {
66
75
  const byDay = {};
67
76
  const byDayModel = {};
68
77
  const byTool = {};
78
+ const byModelMessages = {};
69
79
  const totals = zeroBreakdown();
70
80
  let messages = 0;
71
81
  let costMeasured = 0;
72
82
  const files = [];
73
83
  walkJsonl(sessionsDir(), files, 2000);
84
+ // A sessions override signals an isolated environment (tests, fixtures):
85
+ // only walk the real codex dir when it is explicitly set.
86
+ if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
87
+ walkJsonl(codexSessionsDir(), files, 2000);
88
+ }
89
+ let codexProvider = null;
90
+ const recent = [];
91
+ function ingest(model, usage, ts) {
92
+ addInto(totals, usage);
93
+ byModel[model] ??= zeroBreakdown();
94
+ addInto(byModel[model], usage);
95
+ byModelMessages[model] = (byModelMessages[model] ?? 0) + 1;
96
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
97
+ const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
98
+ if (Number.isFinite(ms))
99
+ recent.push({ m: model, i: num(usage.input), o: num(usage.output), t: ms });
100
+ const day = ts !== undefined ? dayKey(ts) : null;
101
+ if (day) {
102
+ byDay[day] ??= zeroBreakdown();
103
+ addInto(byDay[day], usage);
104
+ const sum = ['input', 'output', 'cacheRead', 'cacheWrite'].reduce((a, k) => a + (typeof usage[k] === 'number' && Number.isFinite(usage[k]) ? Math.floor(usage[k]) : 0), 0);
105
+ if (sum > 0) {
106
+ byDayModel[day] ??= {};
107
+ byDayModel[day][model] = (byDayModel[day][model] ?? 0) + sum;
108
+ }
109
+ }
110
+ messages += 1;
111
+ }
74
112
  for (const file of files) {
75
113
  let text;
76
114
  try {
@@ -84,24 +122,30 @@ export function importSessionTokens() {
84
122
  continue;
85
123
  try {
86
124
  const row = JSON.parse(line);
125
+ const codexRow = row;
126
+ const payload = codexRow.payload;
127
+ if (payload && typeof payload === 'object') {
128
+ if (codexRow.type === 'session_meta' && typeof payload.model_provider === 'string') {
129
+ codexProvider = payload.model_provider;
130
+ }
131
+ else if (payload.type === 'token_count') {
132
+ const last = payload.info?.last_token_usage;
133
+ if (last) {
134
+ ingest(codexProvider ? `codex/${codexProvider}` : 'codex', {
135
+ input: last['input_tokens'],
136
+ output: last['output_tokens'],
137
+ cacheRead: last['cached_input_tokens'],
138
+ cacheWrite: last['cache_write_input_tokens'],
139
+ }, row.timestamp);
140
+ continue;
141
+ }
142
+ }
143
+ }
87
144
  const msg = row.message;
88
145
  if (!msg || msg.role !== 'assistant' || !msg.usage)
89
146
  continue;
90
147
  const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
91
- addInto(totals, msg.usage);
92
- byModel[model] ??= zeroBreakdown();
93
- addInto(byModel[model], msg.usage);
94
- const day = row.timestamp !== undefined ? dayKey(row.timestamp) : null;
95
- if (day) {
96
- byDay[day] ??= zeroBreakdown();
97
- addInto(byDay[day], msg.usage);
98
- const n = msg.usage;
99
- const sum = ['input', 'output', 'cacheRead', 'cacheWrite'].reduce((a, k) => a + (typeof n[k] === 'number' && Number.isFinite(n[k]) ? Math.floor(n[k]) : 0), 0);
100
- if (sum > 0) {
101
- byDayModel[day] ??= {};
102
- byDayModel[day][model] = (byDayModel[day][model] ?? 0) + sum;
103
- }
104
- }
148
+ ingest(model, msg.usage, row.timestamp);
105
149
  if (typeof msg.usage.cost === 'number' && Number.isFinite(msg.usage.cost))
106
150
  costMeasured += msg.usage.cost;
107
151
  for (const part of msg.content ?? []) {
@@ -110,12 +154,12 @@ export function importSessionTokens() {
110
154
  byTool[key] = (byTool[key] ?? 0) + 1;
111
155
  }
112
156
  }
113
- messages += 1;
114
157
  }
115
158
  catch { /* skip corrupt lines */ }
116
159
  }
117
160
  }
118
- return { messages, totals, byModel, byDay, byDayModel, byTool, costMeasured };
161
+ recent.sort((a, b) => b.t - a.t);
162
+ return { messages, totals, byModel, byDay, byDayModel, byTool, byModelMessages, costMeasured, recent: recent.slice(0, 25) };
119
163
  }
120
164
  // Lead binary of a shell string: first segment head past `cd` chains and
121
165
  // VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
@@ -134,24 +178,11 @@ function leadBinary(command) {
134
178
  }
135
179
  return 'bash';
136
180
  }
137
- const PRICE_TABLE = [
138
- { match: ':free', price: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } },
139
- { match: 'opus', price: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 } },
140
- { match: 'sonnet', price: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 } },
141
- { match: 'haiku', price: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 } },
142
- { match: 'gpt', price: { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 2.5 } },
143
- { match: 'gemini', price: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 } },
144
- { match: 'glm', price: { input: 0.5, output: 1, cacheRead: 0.05, cacheWrite: 0.5 } },
145
- ];
146
- const DEFAULT_PRICE = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 };
147
- export function priceFor(model) {
148
- const name = model.toLowerCase();
149
- for (const row of PRICE_TABLE) {
150
- if (name.includes(row.match))
151
- return { price: row.price, known: true };
152
- }
153
- return { price: DEFAULT_PRICE, known: false };
154
- }
181
+ // --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
182
+ import { DEFAULT_PRICE, priceFor } from './pricing.js';
183
+ import { co2GramsFor, energyWhFor } from './carbon.js';
184
+ export { DEFAULT_PRICE, priceFor };
185
+ export { co2GramsFor, energyWhFor };
155
186
  export function usdCost(t, model) {
156
187
  const { price, known } = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false };
157
188
  const m = 1 / 1_000_000;
@@ -160,11 +191,13 @@ export function usdCost(t, model) {
160
191
  priced: model ? known : false,
161
192
  };
162
193
  }
163
- // ponytail: CO2 is a rough server-energy multiple on output tokens, not metered.
194
+ // CO2 now model-differentiated via ./carbon.ts (EcoLogits 0.8.2 port).
164
195
  // Always render with ~est. and never merge with measured figures.
196
+ // The constant below is the served gCO2eq per 1K output tokens for the
197
+ // default (gpt-4o-class) model, kept so single-figure callers stay honest.
165
198
  export const CO2_G_PER_1K_OUTPUT = 0.2;
166
- export function co2Grams(outputTokens) {
167
- return (outputTokens / 1000) * CO2_G_PER_1K_OUTPUT;
199
+ export function co2Grams(outputTokens, model) {
200
+ return co2GramsFor(model ?? 'gpt-4o', outputTokens);
168
201
  }
169
202
  export function readUsage() {
170
203
  let text;
@@ -188,3 +221,14 @@ export function readUsage() {
188
221
  }
189
222
  return rows;
190
223
  }
224
+ // Delete the ledger file. Returns the rows cleared (0 when already absent).
225
+ // Only tersio-owned data lives here; session transcripts and the RTK
226
+ // database are never touched.
227
+ export function clearUsageLedger() {
228
+ const rows = readUsage().length;
229
+ try {
230
+ fs.unlinkSync(ledgerPath());
231
+ }
232
+ catch { /* already absent */ }
233
+ return rows;
234
+ }
@@ -1,8 +1,8 @@
1
1
  // /tersio unified root command — routes every tersio capability through one
2
2
  // namespaced entrypoint. Aliases (/caveman, /rtk, /combo, /ai-addons) keep
3
3
  // working: this router drives the same shared bridge + session entries the
4
- // sibling extensions restore from, then reloads so their local mirrors sync
5
- // (same mechanism /combo already uses to drive its siblings).
4
+ // sibling extensions restore from. Shared-state publishes sync their local
5
+ // mirrors live, so switches notify and take effect next turn — no reload.
6
6
  import { COMBO_LEVELS, activeModesSummary, getSharedComboState, normalizeComboLevel, normalizeMode, reconcileSharedComboEntries, sessionEntries, setSharedComboLevel, setSharedComboMode, } from '../shared/session-state.js';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
@@ -56,10 +56,9 @@ async function openGainDashboard(pi, ctx) {
56
56
  }
57
57
  export default function tersioCommandsExtension(pi) {
58
58
  pi.setLabel?.('Tersio unified root command');
59
- async function reload(ctx) {
60
- if (ctx?.reload)
61
- await ctx.reload();
62
- }
59
+ // Mode switches never reload the session: entries persist, the shared
60
+ // bridge publishes to every sibling mirror, and the change injects on the
61
+ // next turn like a normal input message. Only notify below.
63
62
  pi.registerCommand?.('tersio', {
64
63
  description: 'Tersio root: caveman|combo|rtk|ponytail|status|check|update|gain|usage|help',
65
64
  handler: async (args, ctx) => {
@@ -86,7 +85,6 @@ export default function tersioCommandsExtension(pi) {
86
85
  setSharedComboMode('caveman', mode);
87
86
  appendUsage('toggle', `caveman=${mode}`);
88
87
  ctx?.ui?.notify?.(`Caveman ${mode} on — Active: ${activeModesSummary(getSharedComboState())}.`, 'info');
89
- await reload(ctx);
90
88
  return;
91
89
  }
92
90
  if (sub === 'rtk') {
@@ -104,7 +102,6 @@ export default function tersioCommandsExtension(pi) {
104
102
  setSharedComboMode('rtk', enabled);
105
103
  appendUsage('toggle', `rtk=${rest}`);
106
104
  ctx?.ui?.notify?.(`RTK ${rest} — Active: ${activeModesSummary(getSharedComboState())}.`, 'info');
107
- await reload(ctx);
108
105
  return;
109
106
  }
110
107
  if (sub === 'ponytail') {
@@ -117,7 +114,6 @@ export default function tersioCommandsExtension(pi) {
117
114
  setSharedComboMode('ponytail', mode);
118
115
  appendUsage('toggle', `ponytail=${mode}`);
119
116
  ctx?.ui?.notify?.(`Ponytail ${mode} — Active: ${activeModesSummary(getSharedComboState())}.`, 'info');
120
- await reload(ctx);
121
117
  return;
122
118
  }
123
119
  if (sub === 'combo') {
@@ -140,7 +136,6 @@ export default function tersioCommandsExtension(pi) {
140
136
  setSharedComboLevel(level);
141
137
  appendUsage('toggle', `combo=${level}`);
142
138
  ctx?.ui?.notify?.(`Combo ${level} on — ${activeModesSummary(getSharedComboState())} active for this session.`, 'info');
143
- await reload(ctx);
144
139
  return;
145
140
  }
146
141
  if (sub === 'check') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krtclcdy/tersio",
3
- "version": "2.9.0",
3
+ "version": "2.11.0",
4
4
  "description": "✂️ Token-saving OMP add-ons: terse Caveman replies, compact RTK shell output, lean Ponytail code calls — one-command combo presets.",
5
5
  "keywords": [
6
6
  "omp",
@@ -115,8 +115,7 @@
115
115
  "files": [
116
116
  "tersio.js",
117
117
  "cli/**/*.js",
118
- "cli/dashboard.html",
119
- "cli/brand.webp",
118
+ "dashboard/**/*",
120
119
  "extensions/**/*.js"
121
120
  ],
122
121
  "scripts": {
package/tersio.js CHANGED
@@ -2,13 +2,14 @@
2
2
  // tersio.ts — Install Tersio (caveman/rtk/ponytail) add-ons on any OMP device.
3
3
  // Usage: node tersio.js [install|update|reinstall|doctor|uninstall|version|help] [options]
4
4
  // Requires: node/npm and omp CLI
5
- import { PACKAGE_BIN, PACKAGE_VERSION, applyUpdate, commandArg, gain, dashboardExport, dashboardOpen, dashboardPort, doctor, showHelp, showVersion, uninstall, unknownCommand, update, usage, } from './cli/common.js';
5
+ import { PACKAGE_BIN, PACKAGE_VERSION, applyUpdate, commandArg, gain, dashboardExport, dashboardOpen, dashboardPort, doctor, reset, showHelp, showVersion, uninstall, unknownCommand, update, usage, } from './cli/common.js';
6
6
  import { closeRL } from './cli/interactive.js';
7
7
  import { runInstall } from './cli/install.js';
8
8
  import { runDoctor } from './cli/doctor.js';
9
9
  import { runLatestUpdate } from './cli/update.js';
10
10
  import { runUninstall } from './cli/uninstall.js';
11
11
  import { runUsage } from './cli/usage.js';
12
+ import { runReset } from './cli/reset.js';
12
13
  import { runDashboard } from './cli/dashboard.js';
13
14
  function printHelp() {
14
15
  console.log(`Usage: ${PACKAGE_BIN} [command] [options]
@@ -20,6 +21,7 @@ Commands:
20
21
  doctor Check the current installation
21
22
  usage Ledger-backed usage + savings report
22
23
  gain Open the gain dashboard (localhost only)
24
+ reset Clear tersio statistics (usage ledger)
23
25
  uninstall Remove the managed extensions
24
26
  version Print the package version
25
27
  help Show this help
@@ -79,6 +81,11 @@ async function main() {
79
81
  closeRL();
80
82
  return;
81
83
  }
84
+ if (reset) {
85
+ await runReset();
86
+ closeRL();
87
+ return;
88
+ }
82
89
  if (uninstall) {
83
90
  await runUninstall();
84
91
  closeRL();