@krtclcdy/tersio 2.19.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/cli/common.js +13 -1
  2. package/cli/doctor.js +7 -7
  3. package/cli/install.js +28 -28
  4. package/extensions/ai-addons-updater/index.ts +377 -0
  5. package/extensions/caveman-session/index.ts +161 -0
  6. package/extensions/combo-toggle/index.ts +217 -0
  7. package/extensions/lib/utils.ts +162 -0
  8. package/extensions/rtk-session/index.ts +164 -0
  9. package/extensions/shared/carbon.ts +123 -0
  10. package/extensions/shared/mode-reinforcement.ts +29 -0
  11. package/extensions/shared/plugin-settings.ts +57 -0
  12. package/extensions/shared/pricing.ts +168 -0
  13. package/extensions/shared/rtk-gain.ts +105 -0
  14. package/extensions/shared/session-state.ts +201 -0
  15. package/extensions/shared/types.ts +85 -0
  16. package/extensions/shared/usage-ledger.ts +406 -0
  17. package/extensions/shared/usage-store.ts +325 -0
  18. package/extensions/tersio-commands/index.ts +110 -0
  19. package/package.json +19 -24
  20. package/extensions/ai-addons-updater/index.js +0 -331
  21. package/extensions/caveman-session/index.js +0 -152
  22. package/extensions/combo-toggle/index.js +0 -165
  23. package/extensions/lib/utils.js +0 -154
  24. package/extensions/rtk-session/index.js +0 -148
  25. package/extensions/shared/carbon.js +0 -88
  26. package/extensions/shared/mode-reinforcement.js +0 -26
  27. package/extensions/shared/plugin-settings.js +0 -51
  28. package/extensions/shared/pricing.js +0 -163
  29. package/extensions/shared/rtk-gain.js +0 -79
  30. package/extensions/shared/session-state.js +0 -177
  31. package/extensions/shared/types.js +0 -3
  32. package/extensions/shared/usage-ledger.js +0 -354
  33. package/extensions/shared/usage-store.js +0 -294
  34. package/extensions/tersio-commands/index.js +0 -102
@@ -1,79 +0,0 @@
1
- // extensions/shared/rtk-gain.ts — measured RTK savings from its own store.
2
- // RTK records every invocation (input/output/saved tokens, savings %, exec
3
- // time) in history.db; `rtk gain` renders the same rows. We read them with
4
- // the sqlite3 CLI (present on macOS; best-effort elsewhere) so the dashboard
5
- // shows measured figures, never estimates. Missing DB/CLI → empty, no throw.
6
- //
7
- // Caveman and Ponytail have no per-command counters (instruction-following
8
- // isn't metered); their gains are bench-measured in BENCHMARK.md and cited
9
- // as static figures wherever RTK rows appear.
10
- //
11
- // Rows group by command alone, machine-wide: one row per command no matter
12
- // which repo ran it. Project filtering stays out — wasted column, same logic.
13
- import { execFileSync } from 'node:child_process';
14
- import fs from 'node:fs';
15
- import os from 'node:os';
16
- import path from 'node:path';
17
- const EMPTY = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
18
- // RTK stores the whole command line, and `rtk ls -d` with a long path list
19
- // runs to tens of KB. The table shows one clipped cell, so bound the string
20
- // before it crosses the sqlite3 CLI and lands in every /data.json poll.
21
- const MAX_CMD_CHARS = 200;
22
- // The query() reader is line- and tab-delimited, and RTK stores heredocs and
23
- // `node -e` scripts verbatim (~67 rows here), so one logical row can arrive as
24
- // several lines and derail every row after it. Fold whitespace into spaces
25
- // first: the stored command keeps its newlines, only the read view flattens.
26
- const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
27
- export function rtkDbPath() {
28
- const override = process.env.TERSIO_RTK_DB;
29
- if (override)
30
- return override;
31
- if (process.platform === 'darwin') {
32
- return path.join(os.homedir(), 'Library', 'Application Support', 'rtk', 'history.db');
33
- }
34
- const xdg = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
35
- return path.join(xdg, 'rtk', 'history.db');
36
- }
37
- function query(db, sql) {
38
- const out = execFileSync('sqlite3', ['-separator', '\t', db, sql], {
39
- encoding: 'utf8',
40
- timeout: 5000,
41
- stdio: ['ignore', 'pipe', 'ignore'],
42
- });
43
- return out
44
- .split('\n')
45
- .filter((line) => line.trim())
46
- .map((line) => line.split('\t'));
47
- }
48
- export function readRtkGain(limit = 10, cutoffMs) {
49
- try {
50
- if (!fs.existsSync(rtkDbPath()))
51
- return EMPTY;
52
- // View-level cutoff only: rows stay in rtk's own database untouched.
53
- // strftime('%s') normalizes ISO-8601 timestamps to epoch seconds; rows
54
- // with unparseable timestamps (0) drop out of a filtered view.
55
- const where = cutoffMs && cutoffMs > 0
56
- ? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
57
- : '';
58
- 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 ${where};`);
59
- const byCommand = query(rtkDbPath(), `SELECT substr(${CMD_ONE_LINE}, 1, ${MAX_CMD_CHARS}), COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(AVG(savings_pct),0), COALESCE(AVG(exec_time_ms),0) FROM commands ${where} GROUP BY rtk_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`).map(([command, count, savedRow, pct, ms]) => ({
60
- command,
61
- count: Number(count) || 0,
62
- saved: Number(savedRow) || 0,
63
- avgPct: Number(pct) || 0,
64
- avgMs: Number(ms) || 0,
65
- }));
66
- const totalIn = Number(input) || 0;
67
- return {
68
- commands: Number(commands) || 0,
69
- saved: Number(saved) || 0,
70
- input: totalIn,
71
- avgPct: totalIn ? ((Number(saved) || 0) / totalIn) * 100 : 0,
72
- totalMs: Number(totalMs) || 0,
73
- byCommand,
74
- };
75
- }
76
- catch {
77
- return EMPTY;
78
- }
79
- }
@@ -1,177 +0,0 @@
1
- const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
2
- export const OMP_SUBAGENT_MARKER = 'You are operating on a piece of work assigned to you by the main agent.';
3
- export const COMBO_LEVELS = Object.freeze({
4
- off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off' }),
5
- medium: Object.freeze({ level: 'medium', caveman: 'lite', rtk: 'on', ponytail: 'lite' }),
6
- balanced: Object.freeze({ level: 'balanced', caveman: 'full', rtk: 'on', ponytail: 'full' }),
7
- max: Object.freeze({ level: 'max', caveman: 'ultra', rtk: 'on', ponytail: 'ultra' }),
8
- });
9
- const MODE_VALUES = {
10
- caveman: new Set(['off', 'lite', 'full', 'ultra', 'wenyan']),
11
- rtk: new Set(['off', 'on']),
12
- ponytail: new Set(['off', 'lite', 'full', 'ultra', 'review']),
13
- };
14
- const MODE_ENTRY_TYPES = {
15
- 'caveman-mode': 'caveman',
16
- 'rtk-mode': 'rtk',
17
- 'ponytail-mode': 'ponytail',
18
- };
19
- export function normalizeMode(name, value) {
20
- if (name === 'rtk' && typeof value === 'boolean')
21
- return value ? 'on' : 'off';
22
- const mode = String(value ?? '').trim().toLowerCase();
23
- return MODE_VALUES[name]?.has(mode) ? mode : null;
24
- }
25
- export function deriveLevel(modes) {
26
- for (const level of Object.keys(COMBO_LEVELS)) {
27
- const preset = COMBO_LEVELS[level];
28
- if (preset.caveman === modes?.caveman && preset.rtk === modes?.rtk && preset.ponytail === modes?.ponytail)
29
- return level;
30
- }
31
- return 'custom';
32
- }
33
- function isPresetLevel(value) {
34
- return Object.prototype.hasOwnProperty.call(COMBO_LEVELS, value);
35
- }
36
- function isKnownLevel(value) {
37
- return value === 'custom' || isPresetLevel(value);
38
- }
39
- function normalizedState(modes, level = deriveLevel(modes)) {
40
- const state = {
41
- caveman: normalizeMode('caveman', modes?.caveman) || 'off',
42
- rtk: normalizeMode('rtk', modes?.rtk) || 'off',
43
- ponytail: normalizeMode('ponytail', modes?.ponytail) || 'off',
44
- };
45
- return Object.freeze({ level: isKnownLevel(level) ? level : deriveLevel(state), ...state });
46
- }
47
- function bridge() {
48
- const existing = globalThis[BRIDGE_KEY];
49
- if (existing?.state)
50
- return existing;
51
- const initial = normalizedState((existing || COMBO_LEVELS.off));
52
- return (globalThis[BRIDGE_KEY] = { state: initial, listeners: new Set() });
53
- }
54
- function publish(state) {
55
- const shared = bridge();
56
- shared.state = state;
57
- for (const listener of shared.listeners)
58
- listener(state);
59
- return state;
60
- }
61
- export function normalizeInputCommand(value) {
62
- return String(value || '').trim().toLowerCase().replace(/[.!?\s]+$/, '');
63
- }
64
- export function asPromptArray(systemPrompt) {
65
- return Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt];
66
- }
67
- export function systemPromptIncludes(systemPrompt, marker) {
68
- return asPromptArray(systemPrompt).some((prompt) => typeof prompt === 'string' && prompt.includes(marker));
69
- }
70
- export function isOmpSubagentPrompt(systemPrompt) {
71
- return systemPromptIncludes(systemPrompt, OMP_SUBAGENT_MARKER);
72
- }
73
- export function normalizeComboLevel(value) {
74
- const level = String(value || '').trim().toLowerCase();
75
- return isPresetLevel(level) ? level : null;
76
- }
77
- export function paintStatusBar(ui, key, emoji, label, isActive) {
78
- const theme = ui?.theme;
79
- const indicator = isActive && theme?.fg ? theme.fg('accent', emoji) : emoji;
80
- ui?.setStatus?.(key, theme?.fg ? `${indicator} ${theme.fg('muted', label)}` : `${indicator} ${label}`);
81
- }
82
- // Which context a status bar should remember to paint through.
83
- //
84
- // A UI-less event — a headless child session_start, or a top-level
85
- // session_start that fires before the TUI attaches — must never become the
86
- // remembered context. Two things went wrong when it did: the old
87
- // `ctx || lastCtx` fallback only triggered for a *falsy* ctx, so a truthy
88
- // ctx without `ui` returned early anyway, and remembering it destroyed the one
89
- // paintable context we had. Every later bare syncStatus() then went mute —
90
- // and the bare call is the bridge-listener path, which is how a mode change
91
- // made by a sibling extension reaches this bar. The symptom is a bar frozen on
92
- // a preset the session no longer has: it shows "combo BALANCED" while caveman
93
- // and rtk are already off.
94
- export function paintableCtx(remembered, next) {
95
- return next?.ui?.setStatus ? next : remembered;
96
- }
97
- // Last-wins scan for a custom session entry; skips entries whose value fails
98
- // to parse so a corrupt write never shadows an older valid one.
99
- export function lastCustomValue(entries, customType, pick) {
100
- if (!Array.isArray(entries))
101
- return null;
102
- for (let i = entries.length - 1; i >= 0; i -= 1) {
103
- const entry = entries[i];
104
- if (entry?.type !== 'custom' || entry?.customType !== customType)
105
- continue;
106
- const value = pick(entry?.data);
107
- if (value !== null && value !== undefined)
108
- return value;
109
- }
110
- return null;
111
- }
112
- export function sessionEntries(ctx) {
113
- return ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || [];
114
- }
115
- export function getSharedComboState() {
116
- return bridge().state;
117
- }
118
- export function isComboPresetActive() {
119
- const level = getSharedComboState().level;
120
- return level !== 'off' && isPresetLevel(level);
121
- }
122
- export function setSharedComboLevel(value) {
123
- const level = normalizeComboLevel(value) || 'off';
124
- return publish(normalizedState(COMBO_LEVELS[level], level));
125
- }
126
- export function setSharedComboMode(name, value) {
127
- const mode = normalizeMode(name, value);
128
- if (!mode)
129
- return getSharedComboState();
130
- const modes = { ...getSharedComboState(), [name]: mode };
131
- // Derive the level from the final triplet so a redundant same-value write
132
- // (entry replay on resume, upstream ponytail re-affirming its mode) cannot
133
- // strand a matching preset at 'custom' and drop the combo bar.
134
- return publish(normalizedState(modes));
135
- }
136
- export function reconcileSharedComboEntries(entries) {
137
- let modes = { ...COMBO_LEVELS.off };
138
- if (Array.isArray(entries)) {
139
- for (const entry of entries) {
140
- if (entry?.type !== 'custom')
141
- continue;
142
- if (entry.customType === 'combo-level') {
143
- const preset = normalizeComboLevel(entry?.data?.level);
144
- if (preset)
145
- modes = { ...COMBO_LEVELS[preset] };
146
- continue;
147
- }
148
- const name = MODE_ENTRY_TYPES[entry.customType ?? ''] ?? null;
149
- if (!name)
150
- continue;
151
- const value = name === 'rtk' ? entry?.data?.enabled : entry?.data?.mode;
152
- const mode = normalizeMode(name, value);
153
- if (mode)
154
- modes[name] = mode;
155
- }
156
- }
157
- // The level always reflects the final mode triplet, never the write order:
158
- // a preset entry followed by the same values reconciles back to the preset.
159
- return publish(normalizedState(modes));
160
- }
161
- // One-line session-wide mode summary for change confirmations, e.g.
162
- // `caveman=ULTRA, rtk=ON, ponytail=ULTRA`.
163
- export function activeModesSummary(state) {
164
- return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
165
- }
166
- export function setSharedComboListener(listener) {
167
- // Additive: every sibling extension syncs its local mirror on publish, so
168
- // a /tersio or /combo switch takes effect next turn with no session reload.
169
- // A null listener clears all (no current callers; reserved for teardown).
170
- if (typeof listener === 'function')
171
- bridge().listeners.add(listener);
172
- else
173
- bridge().listeners.clear();
174
- }
175
- export function resetSharedComboState() {
176
- return setSharedComboLevel('off');
177
- }
@@ -1,3 +0,0 @@
1
- // Shared OMP extension host types. Structural — the real `pi` object comes
2
- // from the OMP runtime; these describe the surface this package touches.
3
- export {};
@@ -1,354 +0,0 @@
1
- // Shared usage ledger: append-only JSON-lines file. One source of truth read
2
- // by `tersio usage`, `/tersio usage`, and the gain dashboard.
3
- // Best-effort by design: a ledger failure never breaks the caller, and
4
- // corrupt lines are skipped on read (same pattern as the config normalizer).
5
- import fs from 'node:fs';
6
- import os from 'node:os';
7
- import path from 'node:path';
8
- export function ledgerPath() {
9
- const override = process.env.TERSIO_USAGE_FILE;
10
- if (override)
11
- return override;
12
- return path.join(os.homedir(), '.omp', 'plugins', 'tersio-usage.jsonl');
13
- }
14
- // Reset watermark: tersio-owned timestamp marking the last statistics reset.
15
- // Session transcripts and the RTK database are host/tool-owned and never
16
- // touched — instead, every derived view (token stats, command tools) filters
17
- // rows from before the watermark, so "reset" empties what tersio shows
18
- // without deleting anything it does not own.
19
- export function resetMarkerPath() {
20
- const override = process.env.TERSIO_RESET_FILE;
21
- if (override)
22
- return override;
23
- return path.join(os.homedir(), '.omp', 'plugins', 'tersio-reset.json');
24
- }
25
- export function readResetWatermark() {
26
- try {
27
- const raw = JSON.parse(fs.readFileSync(resetMarkerPath(), 'utf8'));
28
- return typeof raw.ts === 'number' && Number.isFinite(raw.ts) && raw.ts > 0 ? raw.ts : 0;
29
- }
30
- catch {
31
- return 0;
32
- }
33
- }
34
- export function markReset(now = Date.now()) {
35
- try {
36
- fs.mkdirSync(path.dirname(resetMarkerPath()), { recursive: true });
37
- fs.writeFileSync(resetMarkerPath(), JSON.stringify({ ts: now }) + '\n', 'utf8');
38
- }
39
- catch { /* best-effort; never break the caller */ }
40
- return now;
41
- }
42
- export function appendUsage(kind, detail) {
43
- const row = JSON.stringify({ ts: Date.now(), kind, detail }) + '\n';
44
- try {
45
- fs.mkdirSync(path.dirname(ledgerPath()), { recursive: true });
46
- fs.appendFileSync(ledgerPath(), row, 'utf8');
47
- }
48
- catch { /* ledger is best-effort; never break the caller */ }
49
- }
50
- function zeroBreakdown() {
51
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
52
- }
53
- export function durOf(v) {
54
- return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
55
- }
56
- // Measured cost for one message. OMP writes usage.cost as
57
- // { input, output, cacheRead, cacheWrite, total }; a bare number shows up in
58
- // older fixtures. Anything else means "not recorded" — deliberately not 0, so
59
- // the dashboard can tell a genuinely free run from an unrecorded one.
60
- export function costOf(usage) {
61
- const c = usage.cost;
62
- if (typeof c === 'number')
63
- return Number.isFinite(c) ? c : undefined;
64
- if (c && typeof c === 'object') {
65
- const total = c.total;
66
- if (typeof total === 'number' && Number.isFinite(total))
67
- return total;
68
- }
69
- return undefined;
70
- }
71
- // First line only: real messages carry multi-line provider errors, and this
72
- // ends up in a tooltip.
73
- export function statusOf(msg) {
74
- const stop = typeof msg.stopReason === 'string' ? msg.stopReason : '';
75
- const note = typeof msg.errorMessage === 'string' && msg.errorMessage.trim()
76
- ? msg.errorMessage.split('\n')[0].trim().slice(0, 180)
77
- : undefined;
78
- if (stop === 'error' || msg.isError === true) {
79
- const code = typeof msg.errorStatus === 'number' && Number.isFinite(msg.errorStatus) ? msg.errorStatus : undefined;
80
- return { st: 'error', code, note };
81
- }
82
- if (stop === 'aborted')
83
- return { st: 'aborted', note };
84
- return { st: 'completed', note };
85
- }
86
- function addInto(into, u) {
87
- for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
88
- const v = u[k];
89
- if (typeof v === 'number' && Number.isFinite(v) && v > 0)
90
- into[k] += Math.floor(v);
91
- }
92
- }
93
- export function sessionsDir() {
94
- const override = process.env.TERSIO_SESSIONS_DIR;
95
- if (override)
96
- return override;
97
- return path.join(os.homedir(), '.omp', 'agent', 'sessions');
98
- }
99
- export function codexSessionsDir() {
100
- const override = process.env.TERSIO_CODEX_DIR;
101
- if (override)
102
- return override;
103
- const codexHome = process.env.CODEX_HOME;
104
- if (codexHome)
105
- return path.join(codexHome, 'sessions');
106
- return path.join(os.homedir(), '.codex', 'sessions');
107
- }
108
- export function dayKey(ts) {
109
- const ms = typeof ts === 'number' ? ts : Date.parse(ts);
110
- if (!Number.isFinite(ms))
111
- return null;
112
- const d = new Date(ms);
113
- const p = (n) => String(n).padStart(2, '0');
114
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
115
- }
116
- export function walkJsonl(dir, out, cap) {
117
- let entries;
118
- try {
119
- entries = fs.readdirSync(dir, { withFileTypes: true });
120
- }
121
- catch {
122
- return;
123
- }
124
- for (const e of entries) {
125
- if (out.length >= cap)
126
- return;
127
- const full = path.join(dir, e.name);
128
- if (e.isDirectory())
129
- walkJsonl(full, out, cap);
130
- else if (e.isFile() && e.name.endsWith('.jsonl'))
131
- out.push(full);
132
- }
133
- }
134
- // Recent requests are their own full-width table in the dashboard, so this is
135
- // a payload bound rather than a "few highlights" bound. Rows are small
136
- // (model, tokens, timing, status), so a few hundred cost little to ship.
137
- export const RECENT_LIMIT = 200;
138
- const FREE_SUFFIX = /(?::free|-free)$/i;
139
- export function newSessionAccum() {
140
- return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
141
- }
142
- export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames) {
143
- const watermark = readResetWatermark();
144
- const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
145
- if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
146
- return false;
147
- addInto(accum.totals, usage);
148
- const key = model.replace(FREE_SUFFIX, '').toLowerCase();
149
- accum.byModel[key] ??= zeroBreakdown();
150
- addInto(accum.byModel[key], usage);
151
- accum.byModelMessages[key] = (accum.byModelMessages[key] ?? 0) + 1;
152
- const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
153
- if (Number.isFinite(ms)) {
154
- const outcome = run ?? { st: 'completed' };
155
- accum.recent.push({ m: key, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), cr: num(usage.cacheRead), cw: num(usage.cacheWrite), usd: costOf(usage), st: outcome.st, code: outcome.code, note: outcome.note });
156
- }
157
- const day = ts !== undefined ? dayKey(ts) : null;
158
- if (day) {
159
- accum.byDay[day] ??= zeroBreakdown();
160
- addInto(accum.byDay[day], usage);
161
- const sum = ['input', 'output', 'cacheRead', 'cacheWrite'].reduce((a, k) => a + (typeof usage[k] === 'number' && Number.isFinite(usage[k]) ? Math.floor(usage[k]) : 0), 0);
162
- if (sum > 0) {
163
- accum.byDayModel[day] ??= {};
164
- accum.byDayModel[day][key] = (accum.byDayModel[day][key] ?? 0) + sum;
165
- }
166
- }
167
- accum.messages += 1;
168
- const measured = costOf(usage);
169
- if (measured !== undefined)
170
- accum.costMeasured += measured;
171
- for (const name of toolNames ?? [])
172
- accum.byTool[name] = (accum.byTool[name] ?? 0) + 1;
173
- return true;
174
- }
175
- export function classifySessionLine(row) {
176
- const payload = row.payload;
177
- if (payload && typeof payload === 'object') {
178
- if (row.type === 'session_meta' && typeof payload.model_provider === 'string')
179
- return { kind: 'codex_provider', provider: payload.model_provider };
180
- if (payload.type === 'token_count') {
181
- const last = payload.info?.last_token_usage;
182
- if (last) {
183
- const provider = typeof payload.model_provider === 'string' ? payload.model_provider : undefined;
184
- const ts = row.timestamp === undefined ? NaN : typeof row.timestamp === 'number' ? row.timestamp : Date.parse(row.timestamp);
185
- return { kind: 'token_row', provider, model: 'codex', usage: { input: last['input_tokens'], output: last['output_tokens'], cacheRead: last['cached_input_tokens'], cacheWrite: last['cache_write_input_tokens'] }, ms: Number.isFinite(ts) ? ts : undefined };
186
- }
187
- }
188
- }
189
- const msg = row.message;
190
- if (!msg || msg.role !== 'assistant' || !msg.usage)
191
- return { kind: 'skip' };
192
- const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
193
- const computedDur = (typeof msg.completedAt === 'number' && typeof msg.timestamp === 'number') ? msg.completedAt - msg.timestamp : undefined;
194
- const tools = [];
195
- for (const part of msg.content ?? []) {
196
- if (part?.type === 'toolCall' && typeof part.name === 'string' && part.name) {
197
- tools.push(part.name === 'bash' ? `bash:${leadBinary(part.arguments?.command)}` : part.name);
198
- }
199
- }
200
- return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
201
- }
202
- // Fold `:free`/`-free` suffixes and case variants into one chart key, so the
203
- // same model from two providers stops splitting into separate rows.
204
- export function canonicalModelId(model) {
205
- return model.replace(FREE_SUFFIX, '').toLowerCase();
206
- }
207
- // LiteLLM-style display: namespace stays lowercase, model segments title-case
208
- // with version dots kept (`deepseek-v4.1-flash` → `Deepseek-V4.1-Flash`).
209
- export function displayModelId(model) {
210
- const bare = model.replace(FREE_SUFFIX, '');
211
- const cap = (s) => {
212
- const low = s.toLowerCase();
213
- if (low === 'openai')
214
- return 'OpenAI';
215
- if (low === 'ai')
216
- return 'AI';
217
- if (/^\d+[a-z]+$/.test(low))
218
- return low.toUpperCase();
219
- if (s.length <= 2)
220
- return s.toUpperCase();
221
- return s[0].toUpperCase() + s.slice(1).toLowerCase();
222
- };
223
- const seg = (s) => s.split('.').map(cap).join('.');
224
- const words = (s) => s.split(/[-_:]+/).filter(Boolean).map(seg).join('-');
225
- const slash = bare.indexOf('/');
226
- if (slash >= 0)
227
- return `${bare.slice(0, slash).toLowerCase()}/${words(bare.slice(slash + 1))}`;
228
- return words(bare);
229
- }
230
- export function importSessionTokens() {
231
- const accum = newSessionAccum();
232
- const files = [];
233
- walkJsonl(sessionsDir(), files, 2000);
234
- // A sessions override signals an isolated environment (tests, fixtures):
235
- // only walk the real codex dir when it is explicitly set.
236
- if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
237
- walkJsonl(codexSessionsDir(), files, 2000);
238
- }
239
- let codexProvider = null;
240
- for (const file of files) {
241
- let text;
242
- try {
243
- text = fs.readFileSync(file, 'utf8');
244
- }
245
- catch {
246
- continue;
247
- }
248
- processSessionText(accum, { get codexProvider() { return codexProvider; }, set codexProvider(v) { codexProvider = v; } }, text);
249
- }
250
- accum.recent.sort((a, b) => b.t - a.t);
251
- return { messages: accum.messages, totals: accum.totals, byModel: accum.byModel, byDay: accum.byDay, byDayModel: accum.byDayModel, byTool: accum.byTool, byModelMessages: accum.byModelMessages, costMeasured: accum.costMeasured, recent: accum.recent.slice(0, RECENT_LIMIT) };
252
- }
253
- export function processSessionText(accum, state, text) {
254
- for (const line of text.split('\n')) {
255
- if (!line.trim())
256
- continue;
257
- try {
258
- const row = JSON.parse(line);
259
- const parsed = classifySessionLine(row);
260
- if (parsed.kind === 'codex_provider') {
261
- if (parsed.provider)
262
- state.codexProvider = parsed.provider;
263
- continue;
264
- }
265
- if (parsed.kind === 'token_row') {
266
- const model = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
267
- ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, undefined, { st: 'completed' });
268
- continue;
269
- }
270
- if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
271
- continue;
272
- ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools);
273
- }
274
- catch { /* skip corrupt lines */ }
275
- }
276
- }
277
- // Lead binary of a shell string: first segment head past `cd` chains and
278
- // VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
279
- const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
280
- export function leadBinary(command) {
281
- if (typeof command !== 'string' || !command.trim())
282
- return 'bash';
283
- for (const seg of command.split(/&&|;|\n|\|(?!\|)/)) {
284
- const toks = seg.trim().split(/\s+/).filter(Boolean);
285
- let i = 0;
286
- while (i < toks.length && (toks[i] === 'sudo' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])))
287
- i++;
288
- const head = (toks[i] ?? '').replace(/^['"]|['"]$/g, '').split('/').pop() ?? '';
289
- if (head && !SKIP_HEADS.includes(head))
290
- return head;
291
- }
292
- return 'bash';
293
- }
294
- // --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
295
- import { DEFAULT_PRICE, priceFor, refreshPricesIfStale } from './pricing.js';
296
- import { co2GramsFor, energyWhFor } from './carbon.js';
297
- export { DEFAULT_PRICE, priceFor, refreshPricesIfStale };
298
- export { co2GramsFor, energyWhFor };
299
- export function usdCost(t, model) {
300
- const { price, known } = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false };
301
- const m = 1 / 1_000_000;
302
- const buckets = {
303
- input: t.input * price.input * m,
304
- output: t.output * price.output * m,
305
- cacheRead: t.cacheRead * price.cacheRead * m,
306
- cacheWrite: t.cacheWrite * price.cacheWrite * m,
307
- };
308
- return {
309
- usd: buckets.input + buckets.output + buckets.cacheRead + buckets.cacheWrite,
310
- priced: model ? known : false,
311
- buckets,
312
- };
313
- }
314
- // CO2 now model-differentiated via ./carbon.ts (EcoLogits 0.8.2 port).
315
- // Always render with ~est. and never merge with measured figures.
316
- // The constant below is the served gCO2eq per 1K output tokens for the
317
- // default (gpt-4o-class) model, kept so single-figure callers stay honest.
318
- export const CO2_G_PER_1K_OUTPUT = 0.2;
319
- export function co2Grams(outputTokens, model) {
320
- return co2GramsFor(model ?? 'gpt-4o', outputTokens);
321
- }
322
- export function readUsage() {
323
- let text;
324
- try {
325
- text = fs.readFileSync(ledgerPath(), 'utf8');
326
- }
327
- catch {
328
- return [];
329
- }
330
- const rows = [];
331
- for (const line of text.split('\n')) {
332
- if (!line.trim())
333
- continue;
334
- try {
335
- const row = JSON.parse(line);
336
- if (typeof row.ts === 'number' && typeof row.kind === 'string' && typeof row.detail === 'string') {
337
- rows.push({ ts: row.ts, kind: row.kind, detail: row.detail });
338
- }
339
- }
340
- catch { /* skip corrupt lines */ }
341
- }
342
- return rows;
343
- }
344
- // Delete the ledger file. Returns the rows cleared (0 when already absent).
345
- // Only tersio-owned data lives here; session transcripts and the RTK
346
- // database are never touched.
347
- export function clearUsageLedger() {
348
- const rows = readUsage().length;
349
- try {
350
- fs.unlinkSync(ledgerPath());
351
- }
352
- catch { /* already absent */ }
353
- return rows;
354
- }