@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.
- package/README.md +54 -36
- package/cli/banner.js +84 -0
- package/cli/common.js +3 -2
- package/cli/dashboard.js +37 -11
- package/cli/doctor.js +8 -1
- package/cli/install.js +27 -7
- package/cli/reset.js +51 -0
- package/cli/update.js +8 -0
- package/cli/usage.js +87 -22
- package/dashboard/app.js +922 -0
- package/dashboard/styles.css +194 -0
- package/dashboard/template.html +305 -0
- package/extensions/caveman-session/index.js +12 -1
- package/extensions/combo-toggle/index.js +3 -3
- package/extensions/rtk-session/index.js +8 -1
- package/extensions/shared/carbon.js +88 -0
- package/extensions/shared/pricing.js +121 -0
- package/extensions/shared/rtk-gain.js +61 -0
- package/extensions/shared/session-state.js +10 -3
- package/extensions/shared/usage-ledger.js +81 -37
- package/extensions/tersio-commands/index.js +5 -10
- package/package.json +2 -3
- package/tersio.js +8 -1
- package/cli/dashboard.html +0 -780
- /package/{cli → dashboard}/brand.webp +0 -0
|
@@ -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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
|
5
|
-
//
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
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
|
-
"
|
|
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();
|