@krtclcdy/tersio 2.23.0 → 2.24.1
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 +56 -45
- package/cli/agent-hosts.js +42 -0
- package/cli/agents.js +266 -0
- package/cli/banner.js +2 -3
- package/cli/common.js +16 -20
- package/cli/currency.js +11 -19
- package/cli/dashboard.js +216 -40
- package/cli/doctor-fix.js +40 -26
- package/cli/doctor.js +41 -41
- package/cli/hosts.js +114 -0
- package/cli/install.js +284 -179
- package/cli/interactive.js +62 -2
- package/cli/layer.js +22 -0
- package/cli/manifest.js +33 -0
- package/cli/omp-layer.js +51 -0
- package/cli/pi-layer.js +37 -0
- package/cli/pi-wiring.js +92 -0
- package/cli/profile.js +48 -48
- package/cli/reset.js +5 -32
- package/cli/rtk-wiring.js +7 -17
- package/cli/settings.js +3 -8
- package/cli/uninstall.js +162 -52
- package/cli/update.js +19 -27
- package/cli/usage.js +15 -5
- package/dashboard/dist/index.html +18 -18
- package/extensions/ai-addons-updater/index.js +37 -29
- package/extensions/ai-addons-updater/index.ts +39 -38
- package/extensions/caveman-session/index.js +19 -34
- package/extensions/caveman-session/index.ts +20 -35
- package/extensions/combo-toggle/index.js +47 -49
- package/extensions/combo-toggle/index.ts +42 -58
- package/extensions/lib/utils.js +40 -9
- package/extensions/lib/utils.ts +40 -9
- package/extensions/rtk-session/index.js +34 -38
- package/extensions/rtk-session/index.ts +32 -41
- package/extensions/shared/carbon.js +3 -11
- package/extensions/shared/carbon.ts +4 -11
- package/extensions/shared/host.js +58 -0
- package/extensions/shared/host.ts +94 -0
- package/extensions/shared/omp-prompt.js +12 -0
- package/extensions/shared/plugin-settings.js +33 -30
- package/extensions/shared/plugin-settings.ts +35 -26
- package/extensions/shared/pricing.js +25 -16
- package/extensions/shared/pricing.ts +27 -17
- package/extensions/shared/rtk-gain.js +6 -22
- package/extensions/shared/rtk-gain.ts +6 -22
- package/extensions/shared/session-state.js +44 -36
- package/extensions/shared/session-state.ts +48 -35
- package/extensions/shared/types.ts +10 -3
- package/extensions/shared/usage-ledger.js +160 -51
- package/extensions/shared/usage-ledger.ts +170 -67
- package/extensions/shared/usage-store.js +178 -24
- package/extensions/shared/usage-store.ts +176 -25
- package/extensions/tersio-commands/index.js +3 -6
- package/extensions/tersio-commands/index.ts +3 -6
- package/package.json +6 -5
- package/tersio.js +4 -6
- package/extensions/shared/mode-reinforcement.js +0 -26
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
// Shared usage ledger: append-only JSON
|
|
2
|
-
//
|
|
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).
|
|
1
|
+
// Shared usage ledger: append-only JSON lines, read by the CLI, `/tersio`, and
|
|
2
|
+
// the Dashboard. Best-effort; corrupt lines are skipped on read.
|
|
5
3
|
import fs from 'node:fs';
|
|
6
4
|
import os from 'node:os';
|
|
7
5
|
import path from 'node:path';
|
|
8
|
-
import { resolveRtkBinary, tersioDataPath } from '../lib/utils.ts';
|
|
6
|
+
import { isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from '../lib/utils.ts';
|
|
9
7
|
import { execFileSync } from 'node:child_process';
|
|
10
8
|
|
|
11
9
|
export type UsageKind = 'command' | 'toggle' | 'install' | 'update' | 'rtk-audit';
|
|
@@ -22,11 +20,7 @@ export function ledgerPath(): string {
|
|
|
22
20
|
return tersioDataPath('usage.jsonl', 'tersio-usage.jsonl');
|
|
23
21
|
}
|
|
24
22
|
|
|
25
|
-
// Reset watermark:
|
|
26
|
-
// Session transcripts and the RTK database are host/tool-owned and never
|
|
27
|
-
// touched — instead, every derived view (token stats, command tools) filters
|
|
28
|
-
// rows from before the watermark, so "reset" empties what tersio shows
|
|
29
|
-
// without deleting anything it does not own.
|
|
23
|
+
// Reset watermark: views filter rows older than it, so nothing else is deleted.
|
|
30
24
|
export function resetMarkerPath(): string {
|
|
31
25
|
const override = process.env.TERSIO_RESET_FILE;
|
|
32
26
|
if (override) return override;
|
|
@@ -59,10 +53,7 @@ export function appendUsage(kind: UsageKind, detail: string): void {
|
|
|
59
53
|
}
|
|
60
54
|
|
|
61
55
|
// --- Session tokens, tokscale-style ------------------------------------------
|
|
62
|
-
//
|
|
63
|
-
// message.role === 'assistant', message.usage = { input, output, cacheRead,
|
|
64
|
-
// cacheWrite }, message.model, plus a per-line timestamp. Same source tokscale
|
|
65
|
-
// parses for its oh-my-pi client. Best-effort: unreadable files are skipped.
|
|
56
|
+
// Assistant messages in the host transcripts carry usage, model, and a timestamp.
|
|
66
57
|
export interface TokenBreakdown {
|
|
67
58
|
input: number;
|
|
68
59
|
output: number;
|
|
@@ -70,10 +61,7 @@ export interface TokenBreakdown {
|
|
|
70
61
|
cacheWrite: number;
|
|
71
62
|
}
|
|
72
63
|
|
|
73
|
-
//
|
|
74
|
-
// (toolUse | stop | aborted | error) and, when the request failed, an HTTP
|
|
75
|
-
// errorStatus plus errorMessage. `toolUse` is an ordinary turn that handed off
|
|
76
|
-
// to tools, so it reads as completed rather than as a distinct state.
|
|
64
|
+
// `toolUse` is an ordinary turn that handed off to tools, so it reads completed.
|
|
77
65
|
export type RunStatus = 'completed' | 'aborted' | 'error';
|
|
78
66
|
|
|
79
67
|
export interface RecentRequest {
|
|
@@ -82,11 +70,11 @@ export interface RecentRequest {
|
|
|
82
70
|
o: number;
|
|
83
71
|
t: number;
|
|
84
72
|
d?: number;
|
|
73
|
+
/** Which agent wrote the session: pi, omp, or codex. */
|
|
74
|
+
h?: string;
|
|
85
75
|
cr?: number;
|
|
86
76
|
cw?: number;
|
|
87
|
-
//
|
|
88
|
-
// Left undefined when the host recorded no cost — never backfilled with an
|
|
89
|
-
// estimate, so a measured figure is never mistaken for a modeled one.
|
|
77
|
+
// Undefined when the host recorded no cost; never backfilled.
|
|
90
78
|
usd?: number;
|
|
91
79
|
st: RunStatus;
|
|
92
80
|
code?: number;
|
|
@@ -123,14 +111,16 @@ export interface RtkRecallDiagnostics {
|
|
|
123
111
|
function zeroBreakdown(): TokenBreakdown {
|
|
124
112
|
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
125
113
|
}
|
|
114
|
+
function stampMs(ts: string | number | undefined): number {
|
|
115
|
+
return ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
116
|
+
}
|
|
117
|
+
|
|
126
118
|
export function durOf(v: unknown): number | undefined {
|
|
127
119
|
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
128
120
|
}
|
|
129
121
|
|
|
130
|
-
// Measured cost
|
|
131
|
-
//
|
|
132
|
-
// older fixtures. Anything else means "not recorded" — deliberately not 0, so
|
|
133
|
-
// the dashboard can tell a genuinely free run from an unrecorded one.
|
|
122
|
+
// Measured cost: usage.cost.total, or a bare number in old fixtures. Anything
|
|
123
|
+
// else means unrecorded, which is deliberately not 0.
|
|
134
124
|
export function costOf(usage: Record<string, unknown>): number | undefined {
|
|
135
125
|
const c = usage.cost;
|
|
136
126
|
if (typeof c === 'number') return Number.isFinite(c) ? c : undefined;
|
|
@@ -163,10 +153,20 @@ function addInto(into: TokenBreakdown, u: { input?: unknown; output?: unknown; c
|
|
|
163
153
|
}
|
|
164
154
|
}
|
|
165
155
|
|
|
166
|
-
export function
|
|
156
|
+
export function sessionsDirs(): string[] {
|
|
167
157
|
const override = process.env.TERSIO_SESSIONS_DIR;
|
|
168
|
-
if (override) return override;
|
|
169
|
-
|
|
158
|
+
if (override) return [override];
|
|
159
|
+
// Both hosts, not just the running one: a session on pi is missing from the
|
|
160
|
+
// dashboard entirely if we stop at the first directory that exists.
|
|
161
|
+
const dirs = isPiProcess()
|
|
162
|
+
? [path.join(piAgentDir(), 'sessions'), path.join(os.homedir(), '.omp', 'agent', 'sessions')]
|
|
163
|
+
: [path.join(os.homedir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
|
|
164
|
+
const found = dirs.filter((dir) => fs.existsSync(dir));
|
|
165
|
+
return found.length > 0 ? found : [dirs[0]];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function sessionsDir(): string {
|
|
169
|
+
return sessionsDirs()[0];
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
export function codexSessionsDir(): string {
|
|
@@ -177,6 +177,19 @@ export function codexSessionsDir(): string {
|
|
|
177
177
|
return path.join(os.homedir(), '.codex', 'sessions');
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
// One JSON file per message; probe XDG, then local, then macOS default.
|
|
181
|
+
export function opencodeSessionsDir(): string {
|
|
182
|
+
const override = process.env.TERSIO_OPENCODE_DIR;
|
|
183
|
+
if (override) return override;
|
|
184
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
185
|
+
if (xdg && xdg.trim() !== '') return path.join(xdg, 'opencode', 'storage', 'message');
|
|
186
|
+
const local = path.join(os.homedir(), '.local', 'share', 'opencode', 'storage', 'message');
|
|
187
|
+
try {
|
|
188
|
+
if (fs.existsSync(local)) return local;
|
|
189
|
+
} catch { /* fall through to the platform default */ }
|
|
190
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'opencode', 'storage', 'message');
|
|
191
|
+
}
|
|
192
|
+
|
|
180
193
|
export function dayKey(ts: string | number): string | null {
|
|
181
194
|
const ms = typeof ts === 'number' ? ts : Date.parse(ts);
|
|
182
195
|
if (!Number.isFinite(ms)) return null;
|
|
@@ -184,7 +197,7 @@ export function dayKey(ts: string | number): string | null {
|
|
|
184
197
|
const p = (n: number): string => String(n).padStart(2, '0');
|
|
185
198
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
186
199
|
}
|
|
187
|
-
export function walkJsonl(dir: string, out: string[], cap: number): void {
|
|
200
|
+
export function walkJsonl(dir: string, out: string[], cap: number, ext = '.jsonl'): void {
|
|
188
201
|
let entries: fs.Dirent[];
|
|
189
202
|
try {
|
|
190
203
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -194,13 +207,12 @@ export function walkJsonl(dir: string, out: string[], cap: number): void {
|
|
|
194
207
|
for (const e of entries) {
|
|
195
208
|
if (out.length >= cap) return;
|
|
196
209
|
const full = path.join(dir, e.name);
|
|
197
|
-
if (e.isDirectory()) walkJsonl(full, out, cap);
|
|
198
|
-
else if (e.isFile() && e.name.endsWith(
|
|
210
|
+
if (e.isDirectory()) walkJsonl(full, out, cap, ext);
|
|
211
|
+
else if (e.isFile() && e.name.endsWith(ext)) out.push(full);
|
|
199
212
|
}
|
|
200
213
|
}
|
|
201
|
-
// Recent requests
|
|
202
|
-
//
|
|
203
|
-
// (model, tokens, timing, status), so a few hundred cost little to ship.
|
|
214
|
+
// Recent requests get their own full-width table, so this bounds payload
|
|
215
|
+
// rather than highlights; rows are small, so a few hundred cost little.
|
|
204
216
|
export const RECENT_LIMIT = 200;
|
|
205
217
|
const FREE_SUFFIX = /(?::free|-free)$/i;
|
|
206
218
|
const RTK_ELIGIBLE_HEADS = new Set([
|
|
@@ -208,8 +220,8 @@ const RTK_ELIGIBLE_HEADS = new Set([
|
|
|
208
220
|
'npm', 'npx', 'pnpm', 'bun', 'bunx', 'cargo', 'go', 'python', 'pytest',
|
|
209
221
|
'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
|
|
210
222
|
]);
|
|
211
|
-
//
|
|
212
|
-
//
|
|
223
|
+
// Parse buffer shared by live reads and usage.db syncs, so the store is a
|
|
224
|
+
// cache and never a fork.
|
|
213
225
|
export interface SessionAccum {
|
|
214
226
|
byModel: Record<string, TokenBreakdown>;
|
|
215
227
|
byDay: Record<string, TokenBreakdown>;
|
|
@@ -224,7 +236,7 @@ export interface SessionAccum {
|
|
|
224
236
|
export function newSessionAccum(): SessionAccum {
|
|
225
237
|
return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
|
|
226
238
|
}
|
|
227
|
-
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 {
|
|
239
|
+
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[], host?: string): boolean {
|
|
228
240
|
const watermark = readResetWatermark();
|
|
229
241
|
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
230
242
|
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark)) return false;
|
|
@@ -236,7 +248,7 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
|
|
|
236
248
|
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
237
249
|
if (Number.isFinite(ms)) {
|
|
238
250
|
const outcome = run ?? { st: 'completed' as RunStatus };
|
|
239
|
-
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 });
|
|
251
|
+
accum.recent.push({ m: key, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), h: host, cr: num(usage.cacheRead), cw: num(usage.cacheWrite), usd: costOf(usage), st: outcome.st, code: outcome.code, note: outcome.note });
|
|
240
252
|
}
|
|
241
253
|
const day = ts !== undefined ? dayKey(ts) : null;
|
|
242
254
|
if (day) {
|
|
@@ -254,11 +266,15 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
|
|
|
254
266
|
for (const name of toolNames ?? []) accum.byTool[name] = (accum.byTool[name] ?? 0) + 1;
|
|
255
267
|
return true;
|
|
256
268
|
}
|
|
257
|
-
export type SessionLineKind = 'codex_provider' | 'token_row' | 'assistant_row' | 'skip';
|
|
258
|
-
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 } {
|
|
269
|
+
export type SessionLineKind = 'codex_provider' | 'codex_model' | 'token_row' | 'assistant_row' | 'skip';
|
|
270
|
+
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?: 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 } {
|
|
259
271
|
const payload = row.payload;
|
|
260
272
|
if (payload && typeof payload === 'object') {
|
|
261
273
|
if (row.type === 'session_meta' && typeof payload.model_provider === 'string') return { kind: 'codex_provider', provider: payload.model_provider };
|
|
274
|
+
// A Codex transcript states the model once per turn, in turn_context.
|
|
275
|
+
if (row.type === 'turn_context' && typeof payload.model === 'string' && payload.model) {
|
|
276
|
+
return { kind: 'codex_model', model: payload.model };
|
|
277
|
+
}
|
|
262
278
|
if (payload.type === 'token_count') {
|
|
263
279
|
const last = payload.info?.last_token_usage;
|
|
264
280
|
if (last) {
|
|
@@ -271,7 +287,10 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
|
|
|
271
287
|
const msg = row.message;
|
|
272
288
|
if (!msg || msg.role !== 'assistant' || !msg.usage) return { kind: 'skip' };
|
|
273
289
|
const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
|
|
274
|
-
|
|
290
|
+
// pi omits duration, so derive Elapsed/Speed from the timestamp pair.
|
|
291
|
+
const startMs = typeof msg.timestamp === 'number' ? msg.timestamp : undefined;
|
|
292
|
+
const endMs = typeof msg.completedAt === 'number' ? msg.completedAt : stampMs(row.timestamp);
|
|
293
|
+
const computedDur = startMs !== undefined && Number.isFinite(endMs) && endMs > startMs ? endMs - startMs : undefined;
|
|
275
294
|
const tools: string[] = [];
|
|
276
295
|
for (const part of msg.content ?? []) {
|
|
277
296
|
if (part?.type !== 'toolCall' || typeof part.name !== 'string' || !part.name) continue;
|
|
@@ -284,13 +303,70 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
|
|
|
284
303
|
}
|
|
285
304
|
return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
|
|
286
305
|
}
|
|
287
|
-
//
|
|
288
|
-
|
|
306
|
+
// True when at least one token bucket holds a positive finite count.
|
|
307
|
+
export function hasPositiveUsage(usage: Record<string, unknown>): boolean {
|
|
308
|
+
for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
|
|
309
|
+
const v = usage[k];
|
|
310
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return true;
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface OpencodeMessage {
|
|
316
|
+
role?: unknown;
|
|
317
|
+
tokens?: { input?: unknown; output?: unknown; reasoning?: unknown; cache?: { read?: unknown; write?: unknown } } | null;
|
|
318
|
+
modelID?: unknown;
|
|
319
|
+
providerID?: unknown;
|
|
320
|
+
time?: { created?: unknown; completed?: unknown } | null;
|
|
321
|
+
cost?: unknown;
|
|
322
|
+
finish?: unknown;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// One OpenCode message file; assistant rows carry the token buckets.
|
|
326
|
+
export function classifyOpencodeMessage(obj: OpencodeMessage | null | undefined): { model: string; usage: Record<string, unknown>; ms: number | undefined; durMs: number | undefined } | null {
|
|
327
|
+
if (!obj || typeof obj !== 'object' || obj.role !== 'assistant') return null;
|
|
328
|
+
const t = obj.tokens;
|
|
329
|
+
if (!t || typeof t !== 'object') return null;
|
|
330
|
+
const usage: Record<string, unknown> = {
|
|
331
|
+
input: t.input,
|
|
332
|
+
output: t.output,
|
|
333
|
+
cacheRead: t.cache?.read,
|
|
334
|
+
cacheWrite: t.cache?.write,
|
|
335
|
+
cost: (obj as Record<string, unknown>).cost,
|
|
336
|
+
};
|
|
337
|
+
if (!hasPositiveUsage(usage)) return null;
|
|
338
|
+
const provider = typeof obj.providerID === 'string' && obj.providerID ? obj.providerID : 'unknown';
|
|
339
|
+
const model = typeof obj.modelID === 'string' && obj.modelID ? obj.modelID : 'unknown';
|
|
340
|
+
const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
|
|
341
|
+
const created = num(obj.time?.created);
|
|
342
|
+
const completed = num(obj.time?.completed);
|
|
343
|
+
return {
|
|
344
|
+
model: `opencode/${provider}/${model}`,
|
|
345
|
+
usage,
|
|
346
|
+
ms: created,
|
|
347
|
+
durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// One OpenCode message file into the shared accum.
|
|
352
|
+
export function processOpencodeFile(accum: SessionAccum, text: string): void {
|
|
353
|
+
let obj: unknown;
|
|
354
|
+
try {
|
|
355
|
+
obj = JSON.parse(text);
|
|
356
|
+
} catch {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const parsed = classifyOpencodeMessage(obj as OpencodeMessage);
|
|
360
|
+
if (!parsed) return;
|
|
361
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, undefined, 'opencode');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Fold `:free`/`-free` suffixes and case variants into one chart key.
|
|
289
365
|
export function canonicalModelId(model: string): string {
|
|
290
|
-
return model.replace(FREE_SUFFIX, '')
|
|
366
|
+
return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
|
|
291
367
|
}
|
|
292
|
-
// LiteLLM-style display: namespace
|
|
293
|
-
//
|
|
368
|
+
// LiteLLM-style display: lowercase namespace, title-cased model segments with
|
|
369
|
+
// version dots kept.
|
|
294
370
|
export function displayModelId(model: string): string {
|
|
295
371
|
const bare = model.replace(FREE_SUFFIX, '');
|
|
296
372
|
const cap = (s: string): string => {
|
|
@@ -311,13 +387,28 @@ export function displayModelId(model: string): string {
|
|
|
311
387
|
export function importSessionTokens(): SessionTokens {
|
|
312
388
|
const accum = newSessionAccum();
|
|
313
389
|
const files: string[] = [];
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
//
|
|
390
|
+
for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
|
|
391
|
+
// An override means an isolated environment (tests, fixtures): only walk the
|
|
392
|
+
// real codex dir when one is explicitly set.
|
|
317
393
|
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
318
394
|
walkJsonl(codexSessionsDir(), files, 2000);
|
|
319
395
|
}
|
|
396
|
+
// OpenCode message bodies are single JSON documents, not JSONL.
|
|
397
|
+
const ocFiles: string[] = [];
|
|
398
|
+
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
|
|
399
|
+
walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
|
|
400
|
+
}
|
|
401
|
+
for (const file of ocFiles) {
|
|
402
|
+
let text: string;
|
|
403
|
+
try {
|
|
404
|
+
text = fs.readFileSync(file, 'utf8');
|
|
405
|
+
} catch {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
processOpencodeFile(accum, text);
|
|
409
|
+
}
|
|
320
410
|
let codexProvider: string | null = null;
|
|
411
|
+
let codexModel: string | null = null;
|
|
321
412
|
for (const file of files) {
|
|
322
413
|
let text: string;
|
|
323
414
|
try {
|
|
@@ -325,12 +416,25 @@ export function importSessionTokens(): SessionTokens {
|
|
|
325
416
|
} catch {
|
|
326
417
|
continue;
|
|
327
418
|
}
|
|
328
|
-
processSessionText(accum, {
|
|
419
|
+
processSessionText(accum, {
|
|
420
|
+
get codexProvider() { return codexProvider; },
|
|
421
|
+
set codexProvider(v: string | null) { codexProvider = v; },
|
|
422
|
+
get codexModel() { return codexModel; },
|
|
423
|
+
set codexModel(v: string | null) { codexModel = v; },
|
|
424
|
+
}, text, hostOfSessionFile(file));
|
|
329
425
|
}
|
|
330
426
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
331
427
|
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) };
|
|
332
428
|
}
|
|
333
429
|
|
|
430
|
+
// Which agent wrote a session file, from the directory it sits in.
|
|
431
|
+
export function hostOfSessionFile(file: string): string {
|
|
432
|
+
const norm = file.replace(/\\/g, '/');
|
|
433
|
+
if (norm.includes('/.omp/')) return 'omp';
|
|
434
|
+
if (norm.includes('/.codex/')) return 'codex';
|
|
435
|
+
return 'pi';
|
|
436
|
+
}
|
|
437
|
+
|
|
334
438
|
const adoptionFileCache = new Map<string, { size: number; mtimeMs: number; counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number } }>();
|
|
335
439
|
|
|
336
440
|
function parseAdoptionFile(file: string): { counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number }; sessions: number } {
|
|
@@ -369,7 +473,7 @@ export function clearRtkAdoptionCache(): void {
|
|
|
369
473
|
|
|
370
474
|
export function readRtkAdoption(): RtkAdoption {
|
|
371
475
|
const files: string[] = [];
|
|
372
|
-
|
|
476
|
+
for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
|
|
373
477
|
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
374
478
|
walkJsonl(codexSessionsDir(), files, 2000);
|
|
375
479
|
}
|
|
@@ -404,7 +508,7 @@ export function readRtkAdoption(): RtkAdoption {
|
|
|
404
508
|
const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
|
|
405
509
|
return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
|
|
406
510
|
}
|
|
407
|
-
export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null }, text: string): void {
|
|
511
|
+
export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null; codexModel?: string | null }, text: string, host?: string): void {
|
|
408
512
|
for (const line of text.split('\n')) {
|
|
409
513
|
if (!line.trim()) continue;
|
|
410
514
|
try {
|
|
@@ -414,13 +518,19 @@ export function processSessionText(accum: SessionAccum, state: { codexProvider:
|
|
|
414
518
|
if (parsed.provider) state.codexProvider = parsed.provider;
|
|
415
519
|
continue;
|
|
416
520
|
}
|
|
521
|
+
if (parsed.kind === 'codex_model') {
|
|
522
|
+
if (parsed.model) state.codexModel = parsed.model;
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
417
525
|
if (parsed.kind === 'token_row') {
|
|
418
|
-
const
|
|
419
|
-
|
|
526
|
+
const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
|
|
527
|
+
// The provider alone told you nothing about which model ran.
|
|
528
|
+
const model = state.codexModel ? `${base}/${state.codexModel}` : base;
|
|
529
|
+
ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' as RunStatus }, undefined, host ?? 'codex');
|
|
420
530
|
continue;
|
|
421
531
|
}
|
|
422
532
|
if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage) continue;
|
|
423
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools);
|
|
533
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host);
|
|
424
534
|
} catch { /* skip corrupt lines */ }
|
|
425
535
|
}
|
|
426
536
|
}
|
|
@@ -443,8 +553,8 @@ export function readRtkRecallDiagnostics(binary: string | null = resolveRtkBinar
|
|
|
443
553
|
return { mode: 'unknown', entries: 0, available: false };
|
|
444
554
|
}
|
|
445
555
|
}
|
|
446
|
-
// Lead binary of a shell string: first segment
|
|
447
|
-
//
|
|
556
|
+
// Lead binary of a shell string: first segment past `cd` chains and VAR=x
|
|
557
|
+
// assignments. Falls back to `bash`.
|
|
448
558
|
const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
|
|
449
559
|
export function leadBinary(command: unknown): string {
|
|
450
560
|
if (typeof command !== 'string' || !command.trim()) return 'bash';
|
|
@@ -459,7 +569,7 @@ export function leadBinary(command: unknown): string {
|
|
|
459
569
|
}
|
|
460
570
|
|
|
461
571
|
// --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
|
|
462
|
-
import { DEFAULT_PRICE, priceFor, refreshPricesIfStale } from './pricing.ts';
|
|
572
|
+
import { DEFAULT_PRICE, canonicalPriceId, priceFor, refreshPricesIfStale } from './pricing.ts';
|
|
463
573
|
import { co2GramsFor, energyWhFor } from './carbon.ts';
|
|
464
574
|
import type { ModelPrice } from './pricing.ts';
|
|
465
575
|
|
|
@@ -483,12 +593,6 @@ export function usdCost(t: TokenBreakdown, model?: string): { usd: number; price
|
|
|
483
593
|
};
|
|
484
594
|
}
|
|
485
595
|
|
|
486
|
-
// CO2 now model-differentiated via ./carbon.ts (EcoLogits 0.8.2 port).
|
|
487
|
-
// Always render with ~est. and never merge with measured figures.
|
|
488
|
-
// The constant below is the served gCO2eq per 1K output tokens for the
|
|
489
|
-
// default (gpt-4o-class) model, kept so single-figure callers stay honest.
|
|
490
|
-
export const CO2_G_PER_1K_OUTPUT = 0.2;
|
|
491
|
-
|
|
492
596
|
export function co2Grams(outputTokens: number, model?: string): number {
|
|
493
597
|
return co2GramsFor(model ?? 'gpt-4o', outputTokens);
|
|
494
598
|
}
|
|
@@ -513,9 +617,8 @@ export function readUsage(): UsageRow[] {
|
|
|
513
617
|
return rows;
|
|
514
618
|
}
|
|
515
619
|
|
|
516
|
-
// Delete the ledger file
|
|
517
|
-
//
|
|
518
|
-
// database are never touched.
|
|
620
|
+
// Delete the ledger file and return the rows cleared. Tersio-owned data only:
|
|
621
|
+
// transcripts and the RTK database are never touched.
|
|
519
622
|
export function clearUsageLedger(): number {
|
|
520
623
|
const rows = readUsage().length;
|
|
521
624
|
try {
|