@krtclcdy/tersio 2.18.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.
- package/README.md +2 -2
- package/cli/common.js +21 -3
- package/cli/currency.js +102 -0
- package/cli/dashboard.js +54 -2
- package/cli/doctor.js +9 -7
- package/cli/install.js +35 -101
- package/cli/profile.js +92 -0
- package/cli/reset.js +4 -2
- package/cli/settings.js +122 -0
- package/cli/usage.js +38 -24
- package/dashboard/app.js +297 -70
- package/dashboard/styles.css +58 -1
- package/dashboard/template.html +75 -3
- package/extensions/ai-addons-updater/index.ts +377 -0
- package/extensions/caveman-session/index.ts +161 -0
- package/extensions/combo-toggle/index.ts +217 -0
- package/extensions/lib/utils.ts +162 -0
- package/extensions/rtk-session/index.ts +164 -0
- package/extensions/shared/carbon.ts +123 -0
- package/extensions/shared/mode-reinforcement.ts +29 -0
- package/extensions/shared/plugin-settings.ts +57 -0
- package/extensions/shared/pricing.ts +168 -0
- package/extensions/shared/rtk-gain.ts +105 -0
- package/extensions/shared/session-state.ts +201 -0
- package/extensions/shared/types.ts +85 -0
- package/extensions/shared/usage-ledger.ts +406 -0
- package/extensions/shared/usage-store.ts +325 -0
- package/extensions/tersio-commands/index.ts +110 -0
- package/package.json +36 -24
- package/tersio.js +8 -1
- package/extensions/ai-addons-updater/index.js +0 -331
- package/extensions/caveman-session/index.js +0 -152
- package/extensions/combo-toggle/index.js +0 -165
- package/extensions/lib/utils.js +0 -154
- package/extensions/rtk-session/index.js +0 -148
- package/extensions/shared/carbon.js +0 -88
- package/extensions/shared/mode-reinforcement.js +0 -26
- package/extensions/shared/plugin-settings.js +0 -51
- package/extensions/shared/pricing.js +0 -163
- package/extensions/shared/rtk-gain.js +0 -82
- package/extensions/shared/session-state.js +0 -177
- package/extensions/shared/types.js +0 -3
- package/extensions/shared/usage-ledger.js +0 -333
- package/extensions/tersio-commands/index.js +0 -102
|
@@ -1,82 +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 are grouped by (command, project) rather than command alone: RTK's
|
|
12
|
-
// history is machine-wide, so fusing projects made one busy repo's `rtk grep`
|
|
13
|
-
// count as everyone's and let high-saving commands from other projects crowd
|
|
14
|
-
// every other project out of the table entirely.
|
|
15
|
-
import { execFileSync } from 'node:child_process';
|
|
16
|
-
import fs from 'node:fs';
|
|
17
|
-
import os from 'node:os';
|
|
18
|
-
import path from 'node:path';
|
|
19
|
-
const EMPTY = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
|
|
20
|
-
// RTK stores the whole command line, and `rtk ls -d` with a long path list
|
|
21
|
-
// runs to tens of KB. The table shows one clipped cell, so bound the string
|
|
22
|
-
// before it crosses the sqlite3 CLI and lands in every /data.json poll.
|
|
23
|
-
const MAX_CMD_CHARS = 200;
|
|
24
|
-
// The query() reader is line- and tab-delimited, and RTK stores heredocs and
|
|
25
|
-
// `node -e` scripts verbatim (~67 rows here), so one logical row can arrive as
|
|
26
|
-
// several lines and derail every row after it. Fold whitespace into spaces
|
|
27
|
-
// first: the stored command keeps its newlines, only the read view flattens.
|
|
28
|
-
const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
|
|
29
|
-
export function rtkDbPath() {
|
|
30
|
-
const override = process.env.TERSIO_RTK_DB;
|
|
31
|
-
if (override)
|
|
32
|
-
return override;
|
|
33
|
-
if (process.platform === 'darwin') {
|
|
34
|
-
return path.join(os.homedir(), 'Library', 'Application Support', 'rtk', 'history.db');
|
|
35
|
-
}
|
|
36
|
-
const xdg = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
|
37
|
-
return path.join(xdg, 'rtk', 'history.db');
|
|
38
|
-
}
|
|
39
|
-
function query(db, sql) {
|
|
40
|
-
const out = execFileSync('sqlite3', ['-separator', '\t', db, sql], {
|
|
41
|
-
encoding: 'utf8',
|
|
42
|
-
timeout: 5000,
|
|
43
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
44
|
-
});
|
|
45
|
-
return out
|
|
46
|
-
.split('\n')
|
|
47
|
-
.filter((line) => line.trim())
|
|
48
|
-
.map((line) => line.split('\t'));
|
|
49
|
-
}
|
|
50
|
-
export function readRtkGain(limit = 10, cutoffMs) {
|
|
51
|
-
try {
|
|
52
|
-
if (!fs.existsSync(rtkDbPath()))
|
|
53
|
-
return EMPTY;
|
|
54
|
-
// View-level cutoff only: rows stay in rtk's own database untouched.
|
|
55
|
-
// strftime('%s') normalizes ISO-8601 timestamps to epoch seconds; rows
|
|
56
|
-
// with unparseable timestamps (0) drop out of a filtered view.
|
|
57
|
-
const where = cutoffMs && cutoffMs > 0
|
|
58
|
-
? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
|
|
59
|
-
: '';
|
|
60
|
-
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};`);
|
|
61
|
-
const byCommand = query(rtkDbPath(), `SELECT substr(${CMD_ONE_LINE}, 1, ${MAX_CMD_CHARS}), COALESCE(project_path, ''), COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(AVG(savings_pct),0), COALESCE(AVG(exec_time_ms),0) FROM commands ${where} GROUP BY rtk_cmd, project_path ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`).map(([command, project, count, savedRow, pct, ms]) => ({
|
|
62
|
-
command,
|
|
63
|
-
project,
|
|
64
|
-
count: Number(count) || 0,
|
|
65
|
-
saved: Number(savedRow) || 0,
|
|
66
|
-
avgPct: Number(pct) || 0,
|
|
67
|
-
avgMs: Number(ms) || 0,
|
|
68
|
-
}));
|
|
69
|
-
const totalIn = Number(input) || 0;
|
|
70
|
-
return {
|
|
71
|
-
commands: Number(commands) || 0,
|
|
72
|
-
saved: Number(saved) || 0,
|
|
73
|
-
input: totalIn,
|
|
74
|
-
avgPct: totalIn ? ((Number(saved) || 0) / totalIn) * 100 : 0,
|
|
75
|
-
totalMs: Number(totalMs) || 0,
|
|
76
|
-
byCommand,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
return EMPTY;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
@@ -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,333 +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
|
-
// Message-level generation time in ms. OMP writes `duration` (ms, float);
|
|
54
|
-
// codex token_count rows carry none. Non-positive/non-finite → undefined
|
|
55
|
-
// so renderers print – instead of a fake 0.0s.
|
|
56
|
-
function durOf(v) {
|
|
57
|
-
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
58
|
-
}
|
|
59
|
-
// Measured cost for one message. OMP writes usage.cost as
|
|
60
|
-
// { input, output, cacheRead, cacheWrite, total }; a bare number shows up in
|
|
61
|
-
// older fixtures. Anything else means "not recorded" — deliberately not 0, so
|
|
62
|
-
// the dashboard can tell a genuinely free run from an unrecorded one.
|
|
63
|
-
function costOf(usage) {
|
|
64
|
-
const c = usage.cost;
|
|
65
|
-
if (typeof c === 'number')
|
|
66
|
-
return Number.isFinite(c) ? c : undefined;
|
|
67
|
-
if (c && typeof c === 'object') {
|
|
68
|
-
const total = c.total;
|
|
69
|
-
if (typeof total === 'number' && Number.isFinite(total))
|
|
70
|
-
return total;
|
|
71
|
-
}
|
|
72
|
-
return undefined;
|
|
73
|
-
}
|
|
74
|
-
// First line only: real messages carry multi-line provider errors, and this
|
|
75
|
-
// ends up in a tooltip.
|
|
76
|
-
function statusOf(msg) {
|
|
77
|
-
const stop = typeof msg.stopReason === 'string' ? msg.stopReason : '';
|
|
78
|
-
const note = typeof msg.errorMessage === 'string' && msg.errorMessage.trim()
|
|
79
|
-
? msg.errorMessage.split('\n')[0].trim().slice(0, 180)
|
|
80
|
-
: undefined;
|
|
81
|
-
if (stop === 'error' || msg.isError === true) {
|
|
82
|
-
const code = typeof msg.errorStatus === 'number' && Number.isFinite(msg.errorStatus) ? msg.errorStatus : undefined;
|
|
83
|
-
return { st: 'error', code, note };
|
|
84
|
-
}
|
|
85
|
-
if (stop === 'aborted')
|
|
86
|
-
return { st: 'aborted', note };
|
|
87
|
-
return { st: 'completed', note };
|
|
88
|
-
}
|
|
89
|
-
function addInto(into, u) {
|
|
90
|
-
for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
|
|
91
|
-
const v = u[k];
|
|
92
|
-
if (typeof v === 'number' && Number.isFinite(v) && v > 0)
|
|
93
|
-
into[k] += Math.floor(v);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
export function sessionsDir() {
|
|
97
|
-
const override = process.env.TERSIO_SESSIONS_DIR;
|
|
98
|
-
if (override)
|
|
99
|
-
return override;
|
|
100
|
-
return path.join(os.homedir(), '.omp', 'agent', 'sessions');
|
|
101
|
-
}
|
|
102
|
-
export function codexSessionsDir() {
|
|
103
|
-
const override = process.env.TERSIO_CODEX_DIR;
|
|
104
|
-
if (override)
|
|
105
|
-
return override;
|
|
106
|
-
const codexHome = process.env.CODEX_HOME;
|
|
107
|
-
if (codexHome)
|
|
108
|
-
return path.join(codexHome, 'sessions');
|
|
109
|
-
return path.join(os.homedir(), '.codex', 'sessions');
|
|
110
|
-
}
|
|
111
|
-
function dayKey(ts) {
|
|
112
|
-
const ms = typeof ts === 'number' ? ts : Date.parse(ts);
|
|
113
|
-
if (!Number.isFinite(ms))
|
|
114
|
-
return null;
|
|
115
|
-
const d = new Date(ms);
|
|
116
|
-
const p = (n) => String(n).padStart(2, '0');
|
|
117
|
-
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
118
|
-
}
|
|
119
|
-
function walkJsonl(dir, out, cap) {
|
|
120
|
-
let entries;
|
|
121
|
-
try {
|
|
122
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
123
|
-
}
|
|
124
|
-
catch {
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
for (const e of entries) {
|
|
128
|
-
if (out.length >= cap)
|
|
129
|
-
return;
|
|
130
|
-
const full = path.join(dir, e.name);
|
|
131
|
-
if (e.isDirectory())
|
|
132
|
-
walkJsonl(full, out, cap);
|
|
133
|
-
else if (e.isFile() && e.name.endsWith('.jsonl'))
|
|
134
|
-
out.push(full);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
// Recent requests are their own full-width table in the dashboard, so this is
|
|
138
|
-
// a payload bound rather than a "few highlights" bound. Rows are small
|
|
139
|
-
// (model, tokens, timing, status), so a few hundred cost little to ship.
|
|
140
|
-
const RECENT_LIMIT = 200;
|
|
141
|
-
export function importSessionTokens() {
|
|
142
|
-
const watermark = readResetWatermark();
|
|
143
|
-
const byModel = {};
|
|
144
|
-
const byDay = {};
|
|
145
|
-
const byDayModel = {};
|
|
146
|
-
const byTool = {};
|
|
147
|
-
const byModelMessages = {};
|
|
148
|
-
const totals = zeroBreakdown();
|
|
149
|
-
let messages = 0;
|
|
150
|
-
let costMeasured = 0;
|
|
151
|
-
const files = [];
|
|
152
|
-
walkJsonl(sessionsDir(), files, 2000);
|
|
153
|
-
// A sessions override signals an isolated environment (tests, fixtures):
|
|
154
|
-
// only walk the real codex dir when it is explicitly set.
|
|
155
|
-
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
156
|
-
walkJsonl(codexSessionsDir(), files, 2000);
|
|
157
|
-
}
|
|
158
|
-
let codexProvider = null;
|
|
159
|
-
const recent = [];
|
|
160
|
-
// Rows from before the reset watermark (and rows with no usable timestamp,
|
|
161
|
-
function ingest(model, usage, ts, durMs, run) {
|
|
162
|
-
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
163
|
-
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
|
|
164
|
-
return false;
|
|
165
|
-
addInto(totals, usage);
|
|
166
|
-
byModel[model] ??= zeroBreakdown();
|
|
167
|
-
addInto(byModel[model], usage);
|
|
168
|
-
byModelMessages[model] = (byModelMessages[model] ?? 0) + 1;
|
|
169
|
-
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
170
|
-
if (Number.isFinite(ms)) {
|
|
171
|
-
const outcome = run ?? { st: 'completed' };
|
|
172
|
-
recent.push({
|
|
173
|
-
m: model,
|
|
174
|
-
i: num(usage.input),
|
|
175
|
-
o: num(usage.output),
|
|
176
|
-
t: ms,
|
|
177
|
-
d: durOf(durMs),
|
|
178
|
-
cr: num(usage.cacheRead),
|
|
179
|
-
cw: num(usage.cacheWrite),
|
|
180
|
-
usd: costOf(usage),
|
|
181
|
-
st: outcome.st,
|
|
182
|
-
code: outcome.code,
|
|
183
|
-
note: outcome.note,
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
const day = ts !== undefined ? dayKey(ts) : null;
|
|
187
|
-
if (day) {
|
|
188
|
-
byDay[day] ??= zeroBreakdown();
|
|
189
|
-
addInto(byDay[day], usage);
|
|
190
|
-
const sum = ['input', 'output', 'cacheRead', 'cacheWrite'].reduce((a, k) => a + (typeof usage[k] === 'number' && Number.isFinite(usage[k]) ? Math.floor(usage[k]) : 0), 0);
|
|
191
|
-
if (sum > 0) {
|
|
192
|
-
byDayModel[day] ??= {};
|
|
193
|
-
byDayModel[day][model] = (byDayModel[day][model] ?? 0) + sum;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
messages += 1;
|
|
197
|
-
return true;
|
|
198
|
-
}
|
|
199
|
-
for (const file of files) {
|
|
200
|
-
let text;
|
|
201
|
-
try {
|
|
202
|
-
text = fs.readFileSync(file, 'utf8');
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
for (const line of text.split('\n')) {
|
|
208
|
-
if (!line.trim())
|
|
209
|
-
continue;
|
|
210
|
-
try {
|
|
211
|
-
const row = JSON.parse(line);
|
|
212
|
-
const codexRow = row;
|
|
213
|
-
const payload = codexRow.payload;
|
|
214
|
-
if (payload && typeof payload === 'object') {
|
|
215
|
-
if (codexRow.type === 'session_meta' && typeof payload.model_provider === 'string') {
|
|
216
|
-
codexProvider = payload.model_provider;
|
|
217
|
-
}
|
|
218
|
-
else if (payload.type === 'token_count') {
|
|
219
|
-
const last = payload.info?.last_token_usage;
|
|
220
|
-
if (last) {
|
|
221
|
-
ingest(codexProvider ? `codex/${codexProvider}` : 'codex', {
|
|
222
|
-
input: last['input_tokens'],
|
|
223
|
-
output: last['output_tokens'],
|
|
224
|
-
cacheRead: last['cached_input_tokens'],
|
|
225
|
-
cacheWrite: last['cache_write_input_tokens'],
|
|
226
|
-
}, row.timestamp);
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
const msg = row.message;
|
|
232
|
-
if (!msg || msg.role !== 'assistant' || !msg.usage)
|
|
233
|
-
continue;
|
|
234
|
-
const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
|
|
235
|
-
const explicitDur = msg.duration;
|
|
236
|
-
const computedDur = (typeof msg.completedAt === 'number' && typeof msg.timestamp === 'number') ? msg.completedAt - msg.timestamp : undefined;
|
|
237
|
-
const counted = ingest(model, msg.usage, row.timestamp, typeof explicitDur === 'number' ? explicitDur : computedDur, statusOf(msg));
|
|
238
|
-
const measured = costOf(msg.usage);
|
|
239
|
-
if (counted && measured !== undefined)
|
|
240
|
-
costMeasured += measured;
|
|
241
|
-
if (!counted)
|
|
242
|
-
continue;
|
|
243
|
-
for (const part of msg.content ?? []) {
|
|
244
|
-
if (part?.type === 'toolCall' && typeof part.name === 'string' && part.name) {
|
|
245
|
-
const key = part.name === 'bash' ? `bash:${leadBinary(part.arguments?.command)}` : part.name;
|
|
246
|
-
byTool[key] = (byTool[key] ?? 0) + 1;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
catch { /* skip corrupt lines */ }
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
recent.sort((a, b) => b.t - a.t);
|
|
254
|
-
return { messages, totals, byModel, byDay, byDayModel, byTool, byModelMessages, costMeasured, recent: recent.slice(0, RECENT_LIMIT) };
|
|
255
|
-
}
|
|
256
|
-
// Lead binary of a shell string: first segment head past `cd` chains and
|
|
257
|
-
// VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
|
|
258
|
-
const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
|
|
259
|
-
function leadBinary(command) {
|
|
260
|
-
if (typeof command !== 'string' || !command.trim())
|
|
261
|
-
return 'bash';
|
|
262
|
-
for (const seg of command.split(/&&|;|\n|\|(?!\|)/)) {
|
|
263
|
-
const toks = seg.trim().split(/\s+/).filter(Boolean);
|
|
264
|
-
let i = 0;
|
|
265
|
-
while (i < toks.length && (toks[i] === 'sudo' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])))
|
|
266
|
-
i++;
|
|
267
|
-
const head = (toks[i] ?? '').replace(/^['"]|['"]$/g, '').split('/').pop() ?? '';
|
|
268
|
-
if (head && !SKIP_HEADS.includes(head))
|
|
269
|
-
return head;
|
|
270
|
-
}
|
|
271
|
-
return 'bash';
|
|
272
|
-
}
|
|
273
|
-
// --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
|
|
274
|
-
import { DEFAULT_PRICE, priceFor, refreshPricesIfStale } from './pricing.js';
|
|
275
|
-
import { co2GramsFor, energyWhFor } from './carbon.js';
|
|
276
|
-
export { DEFAULT_PRICE, priceFor, refreshPricesIfStale };
|
|
277
|
-
export { co2GramsFor, energyWhFor };
|
|
278
|
-
export function usdCost(t, model) {
|
|
279
|
-
const { price, known } = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false };
|
|
280
|
-
const m = 1 / 1_000_000;
|
|
281
|
-
const buckets = {
|
|
282
|
-
input: t.input * price.input * m,
|
|
283
|
-
output: t.output * price.output * m,
|
|
284
|
-
cacheRead: t.cacheRead * price.cacheRead * m,
|
|
285
|
-
cacheWrite: t.cacheWrite * price.cacheWrite * m,
|
|
286
|
-
};
|
|
287
|
-
return {
|
|
288
|
-
usd: buckets.input + buckets.output + buckets.cacheRead + buckets.cacheWrite,
|
|
289
|
-
priced: model ? known : false,
|
|
290
|
-
buckets,
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
// CO2 now model-differentiated via ./carbon.ts (EcoLogits 0.8.2 port).
|
|
294
|
-
// Always render with ~est. and never merge with measured figures.
|
|
295
|
-
// The constant below is the served gCO2eq per 1K output tokens for the
|
|
296
|
-
// default (gpt-4o-class) model, kept so single-figure callers stay honest.
|
|
297
|
-
export const CO2_G_PER_1K_OUTPUT = 0.2;
|
|
298
|
-
export function co2Grams(outputTokens, model) {
|
|
299
|
-
return co2GramsFor(model ?? 'gpt-4o', outputTokens);
|
|
300
|
-
}
|
|
301
|
-
export function readUsage() {
|
|
302
|
-
let text;
|
|
303
|
-
try {
|
|
304
|
-
text = fs.readFileSync(ledgerPath(), 'utf8');
|
|
305
|
-
}
|
|
306
|
-
catch {
|
|
307
|
-
return [];
|
|
308
|
-
}
|
|
309
|
-
const rows = [];
|
|
310
|
-
for (const line of text.split('\n')) {
|
|
311
|
-
if (!line.trim())
|
|
312
|
-
continue;
|
|
313
|
-
try {
|
|
314
|
-
const row = JSON.parse(line);
|
|
315
|
-
if (typeof row.ts === 'number' && typeof row.kind === 'string' && typeof row.detail === 'string') {
|
|
316
|
-
rows.push({ ts: row.ts, kind: row.kind, detail: row.detail });
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
catch { /* skip corrupt lines */ }
|
|
320
|
-
}
|
|
321
|
-
return rows;
|
|
322
|
-
}
|
|
323
|
-
// Delete the ledger file. Returns the rows cleared (0 when already absent).
|
|
324
|
-
// Only tersio-owned data lives here; session transcripts and the RTK
|
|
325
|
-
// database are never touched.
|
|
326
|
-
export function clearUsageLedger() {
|
|
327
|
-
const rows = readUsage().length;
|
|
328
|
-
try {
|
|
329
|
-
fs.unlinkSync(ledgerPath());
|
|
330
|
-
}
|
|
331
|
-
catch { /* already absent */ }
|
|
332
|
-
return rows;
|
|
333
|
-
}
|