@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.
- package/cli/common.js +13 -1
- package/cli/doctor.js +7 -7
- package/cli/install.js +28 -28
- 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 +19 -24
- 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 -79
- package/extensions/shared/session-state.js +0 -177
- package/extensions/shared/types.js +0 -3
- package/extensions/shared/usage-ledger.js +0 -354
- package/extensions/shared/usage-store.js +0 -294
- package/extensions/tersio-commands/index.js +0 -102
|
@@ -0,0 +1,406 @@
|
|
|
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
|
+
|
|
9
|
+
export type UsageKind = 'command' | 'toggle' | 'install' | 'update' | 'rtk-audit';
|
|
10
|
+
|
|
11
|
+
export interface UsageRow {
|
|
12
|
+
ts: number;
|
|
13
|
+
kind: UsageKind;
|
|
14
|
+
detail: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ledgerPath(): string {
|
|
18
|
+
const override = process.env.TERSIO_USAGE_FILE;
|
|
19
|
+
if (override) return override;
|
|
20
|
+
return path.join(os.homedir(), '.omp', 'plugins', 'tersio-usage.jsonl');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Reset watermark: tersio-owned timestamp marking the last statistics reset.
|
|
24
|
+
// Session transcripts and the RTK database are host/tool-owned and never
|
|
25
|
+
// touched — instead, every derived view (token stats, command tools) filters
|
|
26
|
+
// rows from before the watermark, so "reset" empties what tersio shows
|
|
27
|
+
// without deleting anything it does not own.
|
|
28
|
+
export function resetMarkerPath(): string {
|
|
29
|
+
const override = process.env.TERSIO_RESET_FILE;
|
|
30
|
+
if (override) return override;
|
|
31
|
+
return path.join(os.homedir(), '.omp', 'plugins', 'tersio-reset.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readResetWatermark(): number {
|
|
35
|
+
try {
|
|
36
|
+
const raw = JSON.parse(fs.readFileSync(resetMarkerPath(), 'utf8')) as { ts?: unknown };
|
|
37
|
+
return typeof raw.ts === 'number' && Number.isFinite(raw.ts) && raw.ts > 0 ? raw.ts : 0;
|
|
38
|
+
} catch {
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function markReset(now = Date.now()): number {
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(path.dirname(resetMarkerPath()), { recursive: true });
|
|
46
|
+
fs.writeFileSync(resetMarkerPath(), JSON.stringify({ ts: now }) + '\n', 'utf8');
|
|
47
|
+
} catch { /* best-effort; never break the caller */ }
|
|
48
|
+
return now;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function appendUsage(kind: UsageKind, detail: string): void {
|
|
52
|
+
const row = JSON.stringify({ ts: Date.now(), kind, detail }) + '\n';
|
|
53
|
+
try {
|
|
54
|
+
fs.mkdirSync(path.dirname(ledgerPath()), { recursive: true });
|
|
55
|
+
fs.appendFileSync(ledgerPath(), row, 'utf8');
|
|
56
|
+
} catch { /* ledger is best-effort; never break the caller */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- Session tokens, tokscale-style ------------------------------------------
|
|
60
|
+
// OMP writes per-assistant-message usage into ~/.omp/agent/sessions/**/*.jsonl:
|
|
61
|
+
// message.role === 'assistant', message.usage = { input, output, cacheRead,
|
|
62
|
+
// cacheWrite }, message.model, plus a per-line timestamp. Same source tokscale
|
|
63
|
+
// parses for its oh-my-pi client. Best-effort: unreadable files are skipped.
|
|
64
|
+
export interface TokenBreakdown {
|
|
65
|
+
input: number;
|
|
66
|
+
output: number;
|
|
67
|
+
cacheRead: number;
|
|
68
|
+
cacheWrite: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Run outcome for one assistant message. OMP writes stopReason on every turn
|
|
72
|
+
// (toolUse | stop | aborted | error) and, when the request failed, an HTTP
|
|
73
|
+
// errorStatus plus errorMessage. `toolUse` is an ordinary turn that handed off
|
|
74
|
+
// to tools, so it reads as completed rather than as a distinct state.
|
|
75
|
+
export type RunStatus = 'completed' | 'aborted' | 'error';
|
|
76
|
+
|
|
77
|
+
export interface RecentRequest {
|
|
78
|
+
m: string;
|
|
79
|
+
i: number;
|
|
80
|
+
o: number;
|
|
81
|
+
t: number;
|
|
82
|
+
d?: number;
|
|
83
|
+
cr?: number;
|
|
84
|
+
cw?: number;
|
|
85
|
+
// Measured spend for this message, from the transcript's usage.cost.total.
|
|
86
|
+
// Left undefined when the host recorded no cost — never backfilled with an
|
|
87
|
+
// estimate, so a measured figure is never mistaken for a modeled one.
|
|
88
|
+
usd?: number;
|
|
89
|
+
st: RunStatus;
|
|
90
|
+
code?: number;
|
|
91
|
+
note?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SessionTokens {
|
|
95
|
+
messages: number;
|
|
96
|
+
totals: TokenBreakdown;
|
|
97
|
+
byModel: Record<string, TokenBreakdown>;
|
|
98
|
+
byDay: Record<string, TokenBreakdown>;
|
|
99
|
+
byDayModel: Record<string, Record<string, number>>;
|
|
100
|
+
byTool: Record<string, number>;
|
|
101
|
+
byModelMessages: Record<string, number>;
|
|
102
|
+
costMeasured: number;
|
|
103
|
+
recent: RecentRequest[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function zeroBreakdown(): TokenBreakdown {
|
|
107
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
108
|
+
}
|
|
109
|
+
export function durOf(v: unknown): number | undefined {
|
|
110
|
+
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Measured cost for one message. OMP writes usage.cost as
|
|
114
|
+
// { input, output, cacheRead, cacheWrite, total }; a bare number shows up in
|
|
115
|
+
// older fixtures. Anything else means "not recorded" — deliberately not 0, so
|
|
116
|
+
// the dashboard can tell a genuinely free run from an unrecorded one.
|
|
117
|
+
export function costOf(usage: Record<string, unknown>): number | undefined {
|
|
118
|
+
const c = usage.cost;
|
|
119
|
+
if (typeof c === 'number') return Number.isFinite(c) ? c : undefined;
|
|
120
|
+
if (c && typeof c === 'object') {
|
|
121
|
+
const total = (c as { total?: unknown }).total;
|
|
122
|
+
if (typeof total === 'number' && Number.isFinite(total)) return total;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// First line only: real messages carry multi-line provider errors, and this
|
|
128
|
+
// ends up in a tooltip.
|
|
129
|
+
export function statusOf(msg: { stopReason?: unknown; errorStatus?: unknown; errorMessage?: unknown; isError?: unknown }): { st: RunStatus; code?: number; note?: string } {
|
|
130
|
+
const stop = typeof msg.stopReason === 'string' ? msg.stopReason : '';
|
|
131
|
+
const note = typeof msg.errorMessage === 'string' && msg.errorMessage.trim()
|
|
132
|
+
? msg.errorMessage.split('\n')[0].trim().slice(0, 180)
|
|
133
|
+
: undefined;
|
|
134
|
+
if (stop === 'error' || msg.isError === true) {
|
|
135
|
+
const code = typeof msg.errorStatus === 'number' && Number.isFinite(msg.errorStatus) ? msg.errorStatus : undefined;
|
|
136
|
+
return { st: 'error', code, note };
|
|
137
|
+
}
|
|
138
|
+
if (stop === 'aborted') return { st: 'aborted', note };
|
|
139
|
+
return { st: 'completed', note };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function addInto(into: TokenBreakdown, u: { input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown }): void {
|
|
143
|
+
for (const k of ['input', 'output', 'cacheRead', 'cacheWrite'] as const) {
|
|
144
|
+
const v = u[k];
|
|
145
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0) into[k] += Math.floor(v);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function sessionsDir(): string {
|
|
150
|
+
const override = process.env.TERSIO_SESSIONS_DIR;
|
|
151
|
+
if (override) return override;
|
|
152
|
+
return path.join(os.homedir(), '.omp', 'agent', 'sessions');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function codexSessionsDir(): string {
|
|
156
|
+
const override = process.env.TERSIO_CODEX_DIR;
|
|
157
|
+
if (override) return override;
|
|
158
|
+
const codexHome = process.env.CODEX_HOME;
|
|
159
|
+
if (codexHome) return path.join(codexHome, 'sessions');
|
|
160
|
+
return path.join(os.homedir(), '.codex', 'sessions');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function dayKey(ts: string | number): string | null {
|
|
164
|
+
const ms = typeof ts === 'number' ? ts : Date.parse(ts);
|
|
165
|
+
if (!Number.isFinite(ms)) return null;
|
|
166
|
+
const d = new Date(ms);
|
|
167
|
+
const p = (n: number): string => String(n).padStart(2, '0');
|
|
168
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
169
|
+
}
|
|
170
|
+
export function walkJsonl(dir: string, out: string[], cap: number): void {
|
|
171
|
+
let entries: fs.Dirent[];
|
|
172
|
+
try {
|
|
173
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
174
|
+
} catch {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
for (const e of entries) {
|
|
178
|
+
if (out.length >= cap) return;
|
|
179
|
+
const full = path.join(dir, e.name);
|
|
180
|
+
if (e.isDirectory()) walkJsonl(full, out, cap);
|
|
181
|
+
else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(full);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Recent requests are their own full-width table in the dashboard, so this is
|
|
185
|
+
// a payload bound rather than a "few highlights" bound. Rows are small
|
|
186
|
+
// (model, tokens, timing, status), so a few hundred cost little to ship.
|
|
187
|
+
export const RECENT_LIMIT = 200;
|
|
188
|
+
const FREE_SUFFIX = /(?::free|-free)$/i;
|
|
189
|
+
// Session-parse buffer shared by live reads and usage.db syncs: identical
|
|
190
|
+
// inputs produce identical aggregates, so the store is a cache, never a fork.
|
|
191
|
+
export interface SessionAccum {
|
|
192
|
+
byModel: Record<string, TokenBreakdown>;
|
|
193
|
+
byDay: Record<string, TokenBreakdown>;
|
|
194
|
+
byDayModel: Record<string, Record<string, number>>;
|
|
195
|
+
byTool: Record<string, number>;
|
|
196
|
+
byModelMessages: Record<string, number>;
|
|
197
|
+
totals: TokenBreakdown;
|
|
198
|
+
messages: number;
|
|
199
|
+
costMeasured: number;
|
|
200
|
+
recent: RecentRequest[];
|
|
201
|
+
}
|
|
202
|
+
export function newSessionAccum(): SessionAccum {
|
|
203
|
+
return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
|
|
204
|
+
}
|
|
205
|
+
export function ingestSessionRow(accum: SessionAccum, model: string, usage: Record<string, unknown>, ts: string | number | undefined, durMs?: unknown, run?: { st: RunStatus; code?: number; note?: string }, toolNames?: string[]): boolean {
|
|
206
|
+
const watermark = readResetWatermark();
|
|
207
|
+
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
208
|
+
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark)) return false;
|
|
209
|
+
addInto(accum.totals, usage);
|
|
210
|
+
const key = model.replace(FREE_SUFFIX, '').toLowerCase();
|
|
211
|
+
accum.byModel[key] ??= zeroBreakdown();
|
|
212
|
+
addInto(accum.byModel[key], usage);
|
|
213
|
+
accum.byModelMessages[key] = (accum.byModelMessages[key] ?? 0) + 1;
|
|
214
|
+
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
215
|
+
if (Number.isFinite(ms)) {
|
|
216
|
+
const outcome = run ?? { st: 'completed' as RunStatus };
|
|
217
|
+
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 });
|
|
218
|
+
}
|
|
219
|
+
const day = ts !== undefined ? dayKey(ts) : null;
|
|
220
|
+
if (day) {
|
|
221
|
+
accum.byDay[day] ??= zeroBreakdown();
|
|
222
|
+
addInto(accum.byDay[day], usage);
|
|
223
|
+
const sum = ['input', 'output', 'cacheRead', 'cacheWrite'].reduce((a, k) => a + (typeof usage[k] === 'number' && Number.isFinite(usage[k]) ? Math.floor(usage[k] as number) : 0), 0);
|
|
224
|
+
if (sum > 0) {
|
|
225
|
+
accum.byDayModel[day] ??= {};
|
|
226
|
+
accum.byDayModel[day][key] = (accum.byDayModel[day][key] ?? 0) + sum;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
accum.messages += 1;
|
|
230
|
+
const measured = costOf(usage);
|
|
231
|
+
if (measured !== undefined) accum.costMeasured += measured;
|
|
232
|
+
for (const name of toolNames ?? []) accum.byTool[name] = (accum.byTool[name] ?? 0) + 1;
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
export type SessionLineKind = 'codex_provider' | 'token_row' | 'assistant_row' | 'skip';
|
|
236
|
+
export function classifySessionLine(row: { timestamp?: string | number; type?: unknown; message?: { role?: unknown; model?: unknown; usage?: Record<string, unknown>; duration?: unknown; completedAt?: unknown; timestamp?: unknown; stopReason?: unknown; errorStatus?: unknown; errorMessage?: unknown; isError?: unknown; content?: Array<{ type?: unknown; name?: unknown; arguments?: unknown }> }; payload?: { type?: unknown; model_provider?: unknown; info?: { last_token_usage?: Record<string, unknown> } } }): { kind: SessionLineKind; provider?: string; model?: string; usage?: Record<string, unknown>; durMs?: number; run?: { st: RunStatus; code?: number; note?: string }; tools?: string[]; ms?: number } {
|
|
237
|
+
const payload = row.payload;
|
|
238
|
+
if (payload && typeof payload === 'object') {
|
|
239
|
+
if (row.type === 'session_meta' && typeof payload.model_provider === 'string') return { kind: 'codex_provider', provider: payload.model_provider };
|
|
240
|
+
if (payload.type === 'token_count') {
|
|
241
|
+
const last = payload.info?.last_token_usage;
|
|
242
|
+
if (last) {
|
|
243
|
+
const provider = typeof payload.model_provider === 'string' ? payload.model_provider : undefined;
|
|
244
|
+
const ts = row.timestamp === undefined ? NaN : typeof row.timestamp === 'number' ? row.timestamp : Date.parse(row.timestamp);
|
|
245
|
+
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 };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const msg = row.message;
|
|
250
|
+
if (!msg || msg.role !== 'assistant' || !msg.usage) return { kind: 'skip' };
|
|
251
|
+
const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
|
|
252
|
+
const computedDur = (typeof msg.completedAt === 'number' && typeof msg.timestamp === 'number') ? msg.completedAt - msg.timestamp : undefined;
|
|
253
|
+
const tools: string[] = [];
|
|
254
|
+
for (const part of msg.content ?? []) {
|
|
255
|
+
if (part?.type === 'toolCall' && typeof part.name === 'string' && part.name) {
|
|
256
|
+
tools.push(part.name === 'bash' ? `bash:${leadBinary((part.arguments as { command?: unknown } | null)?.command)}` : part.name);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
|
|
260
|
+
}
|
|
261
|
+
// Fold `:free`/`-free` suffixes and case variants into one chart key, so the
|
|
262
|
+
// same model from two providers stops splitting into separate rows.
|
|
263
|
+
export function canonicalModelId(model: string): string {
|
|
264
|
+
return model.replace(FREE_SUFFIX, '').toLowerCase();
|
|
265
|
+
}
|
|
266
|
+
// LiteLLM-style display: namespace stays lowercase, model segments title-case
|
|
267
|
+
// with version dots kept (`deepseek-v4.1-flash` → `Deepseek-V4.1-Flash`).
|
|
268
|
+
export function displayModelId(model: string): string {
|
|
269
|
+
const bare = model.replace(FREE_SUFFIX, '');
|
|
270
|
+
const cap = (s: string): string => {
|
|
271
|
+
const low = s.toLowerCase();
|
|
272
|
+
if (low === 'openai') return 'OpenAI';
|
|
273
|
+
if (low === 'ai') return 'AI';
|
|
274
|
+
if (/^\d+[a-z]+$/.test(low)) return low.toUpperCase();
|
|
275
|
+
if (s.length <= 2) return s.toUpperCase();
|
|
276
|
+
return s[0].toUpperCase() + s.slice(1).toLowerCase();
|
|
277
|
+
};
|
|
278
|
+
const seg = (s: string): string => s.split('.').map(cap).join('.');
|
|
279
|
+
const words = (s: string): string => s.split(/[-_:]+/).filter(Boolean).map(seg).join('-');
|
|
280
|
+
const slash = bare.indexOf('/');
|
|
281
|
+
if (slash >= 0) return `${bare.slice(0, slash).toLowerCase()}/${words(bare.slice(slash + 1))}`;
|
|
282
|
+
return words(bare);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function importSessionTokens(): SessionTokens {
|
|
286
|
+
const accum = newSessionAccum();
|
|
287
|
+
const files: string[] = [];
|
|
288
|
+
walkJsonl(sessionsDir(), files, 2000);
|
|
289
|
+
// A sessions override signals an isolated environment (tests, fixtures):
|
|
290
|
+
// only walk the real codex dir when it is explicitly set.
|
|
291
|
+
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
292
|
+
walkJsonl(codexSessionsDir(), files, 2000);
|
|
293
|
+
}
|
|
294
|
+
let codexProvider: string | null = null;
|
|
295
|
+
for (const file of files) {
|
|
296
|
+
let text: string;
|
|
297
|
+
try {
|
|
298
|
+
text = fs.readFileSync(file, 'utf8');
|
|
299
|
+
} catch {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
processSessionText(accum, { get codexProvider() { return codexProvider; }, set codexProvider(v: string | null) { codexProvider = v; } }, text);
|
|
303
|
+
}
|
|
304
|
+
accum.recent.sort((a, b) => b.t - a.t);
|
|
305
|
+
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) };
|
|
306
|
+
}
|
|
307
|
+
export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null }, text: string): void {
|
|
308
|
+
for (const line of text.split('\n')) {
|
|
309
|
+
if (!line.trim()) continue;
|
|
310
|
+
try {
|
|
311
|
+
const row = JSON.parse(line) as Parameters<typeof classifySessionLine>[0];
|
|
312
|
+
const parsed = classifySessionLine(row);
|
|
313
|
+
if (parsed.kind === 'codex_provider') {
|
|
314
|
+
if (parsed.provider) state.codexProvider = parsed.provider;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (parsed.kind === 'token_row') {
|
|
318
|
+
const model = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
|
|
319
|
+
ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, undefined, { st: 'completed' as RunStatus });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage) continue;
|
|
323
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools);
|
|
324
|
+
} catch { /* skip corrupt lines */ }
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// Lead binary of a shell string: first segment head past `cd` chains and
|
|
328
|
+
// VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
|
|
329
|
+
const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
|
|
330
|
+
export function leadBinary(command: unknown): string {
|
|
331
|
+
if (typeof command !== 'string' || !command.trim()) return 'bash';
|
|
332
|
+
for (const seg of command.split(/&&|;|\n|\|(?!\|)/)) {
|
|
333
|
+
const toks = seg.trim().split(/\s+/).filter(Boolean);
|
|
334
|
+
let i = 0;
|
|
335
|
+
while (i < toks.length && (toks[i] === 'sudo' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i]))) i++;
|
|
336
|
+
const head = (toks[i] ?? '').replace(/^['"]|['"]$/g, '').split('/').pop() ?? '';
|
|
337
|
+
if (head && !SKIP_HEADS.includes(head)) return head;
|
|
338
|
+
}
|
|
339
|
+
return 'bash';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
|
|
343
|
+
import { DEFAULT_PRICE, priceFor, refreshPricesIfStale } from './pricing.ts';
|
|
344
|
+
import { co2GramsFor, energyWhFor } from './carbon.ts';
|
|
345
|
+
import type { ModelPrice } from './pricing.ts';
|
|
346
|
+
|
|
347
|
+
export { DEFAULT_PRICE, priceFor, refreshPricesIfStale };
|
|
348
|
+
export { co2GramsFor, energyWhFor };
|
|
349
|
+
export type { ModelPrice };
|
|
350
|
+
|
|
351
|
+
export function usdCost(t: TokenBreakdown, model?: string): { usd: number; priced: boolean; buckets: { input: number; output: number; cacheRead: number; cacheWrite: number } } {
|
|
352
|
+
const { price, known } = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false };
|
|
353
|
+
const m = 1 / 1_000_000;
|
|
354
|
+
const buckets = {
|
|
355
|
+
input: t.input * price.input * m,
|
|
356
|
+
output: t.output * price.output * m,
|
|
357
|
+
cacheRead: t.cacheRead * price.cacheRead * m,
|
|
358
|
+
cacheWrite: t.cacheWrite * price.cacheWrite * m,
|
|
359
|
+
};
|
|
360
|
+
return {
|
|
361
|
+
usd: buckets.input + buckets.output + buckets.cacheRead + buckets.cacheWrite,
|
|
362
|
+
priced: model ? known : false,
|
|
363
|
+
buckets,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// CO2 now model-differentiated via ./carbon.ts (EcoLogits 0.8.2 port).
|
|
368
|
+
// Always render with ~est. and never merge with measured figures.
|
|
369
|
+
// The constant below is the served gCO2eq per 1K output tokens for the
|
|
370
|
+
// default (gpt-4o-class) model, kept so single-figure callers stay honest.
|
|
371
|
+
export const CO2_G_PER_1K_OUTPUT = 0.2;
|
|
372
|
+
|
|
373
|
+
export function co2Grams(outputTokens: number, model?: string): number {
|
|
374
|
+
return co2GramsFor(model ?? 'gpt-4o', outputTokens);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function readUsage(): UsageRow[] {
|
|
378
|
+
let text: string;
|
|
379
|
+
try {
|
|
380
|
+
text = fs.readFileSync(ledgerPath(), 'utf8');
|
|
381
|
+
} catch {
|
|
382
|
+
return [];
|
|
383
|
+
}
|
|
384
|
+
const rows: UsageRow[] = [];
|
|
385
|
+
for (const line of text.split('\n')) {
|
|
386
|
+
if (!line.trim()) continue;
|
|
387
|
+
try {
|
|
388
|
+
const row = JSON.parse(line) as Partial<UsageRow>;
|
|
389
|
+
if (typeof row.ts === 'number' && typeof row.kind === 'string' && typeof row.detail === 'string') {
|
|
390
|
+
rows.push({ ts: row.ts, kind: row.kind as UsageKind, detail: row.detail });
|
|
391
|
+
}
|
|
392
|
+
} catch { /* skip corrupt lines */ }
|
|
393
|
+
}
|
|
394
|
+
return rows;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Delete the ledger file. Returns the rows cleared (0 when already absent).
|
|
398
|
+
// Only tersio-owned data lives here; session transcripts and the RTK
|
|
399
|
+
// database are never touched.
|
|
400
|
+
export function clearUsageLedger(): number {
|
|
401
|
+
const rows = readUsage().length;
|
|
402
|
+
try {
|
|
403
|
+
fs.unlinkSync(ledgerPath());
|
|
404
|
+
} catch { /* already absent */ }
|
|
405
|
+
return rows;
|
|
406
|
+
}
|