@krtclcdy/tersio 2.24.0 → 2.25.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 +28 -24
- package/cli/banner.js +7 -5
- package/cli/common.js +14 -13
- package/cli/currency.js +2 -7
- package/cli/dashboard.js +220 -13
- package/cli/doctor-fix.js +99 -29
- package/cli/doctor.js +32 -35
- package/cli/hosts.js +23 -26
- package/cli/install.js +228 -115
- package/cli/interactive.js +65 -21
- package/cli/manifest.js +8 -4
- package/cli/profile.js +49 -16
- package/cli/reset.js +3 -7
- package/cli/rtk-wiring.js +29 -32
- package/cli/settings.js +40 -16
- package/cli/uninstall.js +79 -29
- package/cli/update.js +27 -20
- package/cli/usage.js +54 -19
- package/dashboard/dist/index.html +18 -18
- package/extensions/ai-addons-updater/index.js +75 -20
- package/extensions/ai-addons-updater/index.ts +67 -23
- package/extensions/caveman-session/index.js +33 -75
- package/extensions/caveman-session/index.ts +35 -77
- package/extensions/combo-toggle/index.js +40 -62
- package/extensions/combo-toggle/index.ts +38 -72
- package/extensions/lib/utils.js +64 -15
- package/extensions/lib/utils.ts +62 -15
- package/extensions/opencode/index.js +104 -0
- package/extensions/opencode/server.js +190 -0
- package/extensions/opencode/server.ts +200 -0
- package/extensions/rtk-session/index.js +38 -57
- package/extensions/rtk-session/index.ts +36 -55
- package/extensions/shared/carbon.js +4 -10
- package/extensions/shared/carbon.ts +5 -10
- package/extensions/shared/host.js +95 -56
- package/extensions/shared/host.ts +106 -60
- package/extensions/shared/plugin-settings.js +6 -18
- package/extensions/shared/plugin-settings.ts +6 -18
- package/extensions/shared/pricing.js +79 -27
- package/extensions/shared/pricing.ts +80 -28
- package/extensions/shared/rtk-gain.js +4 -11
- package/extensions/shared/rtk-gain.ts +4 -11
- package/extensions/shared/session-state.js +28 -39
- package/extensions/shared/session-state.ts +29 -38
- package/extensions/shared/status.js +38 -0
- package/extensions/shared/status.ts +45 -0
- package/extensions/shared/types.js +1 -2
- package/extensions/shared/types.ts +2 -2
- package/extensions/shared/usage-ledger.js +312 -57
- package/extensions/shared/usage-ledger.ts +321 -61
- package/extensions/shared/usage-store.js +354 -38
- package/extensions/shared/usage-store.ts +360 -43
- package/extensions/tersio-commands/index.js +5 -14
- package/extensions/tersio-commands/index.ts +5 -14
- package/package.json +12 -7
- package/tersio.js +4 -6
- package/cli/agent-hosts.js +0 -42
- package/cli/agents.js +0 -266
- package/cli/layer.js +0 -24
- package/cli/omp-layer.js +0 -51
- package/cli/pi-layer.js +0 -37
- package/cli/pi-wiring.js +0 -92
- package/extensions/shared/omp-prompt.js +0 -12
|
@@ -1,9 +1,7 @@
|
|
|
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.
|
|
1
|
+
// Shared usage ledger: append-only JSON lines, read by the CLI, `/tersio`, and the Dashboard. Best-effort; corrupt lines are skipped on read.
|
|
3
2
|
import fs from 'node:fs';
|
|
4
|
-
import os from 'node:os';
|
|
5
3
|
import path from 'node:path';
|
|
6
|
-
import { isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from '../lib/utils.ts';
|
|
4
|
+
import { homeDir, isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from '../lib/utils.ts';
|
|
7
5
|
import { execFileSync } from 'node:child_process';
|
|
8
6
|
|
|
9
7
|
export type UsageKind = 'command' | 'toggle' | 'install' | 'update' | 'rtk-audit';
|
|
@@ -52,8 +50,7 @@ export function appendUsage(kind: UsageKind, detail: string): void {
|
|
|
52
50
|
} catch { /* ledger is best-effort; never break the caller */ }
|
|
53
51
|
}
|
|
54
52
|
|
|
55
|
-
// --- Session tokens, tokscale-style ------------------------------------------
|
|
56
|
-
// Assistant messages in the host transcripts carry usage, model, and a timestamp.
|
|
53
|
+
// --- Session tokens, tokscale-style ------------------------------------------ Assistant messages in the host transcripts carry usage, model, and a timestamp.
|
|
57
54
|
export interface TokenBreakdown {
|
|
58
55
|
input: number;
|
|
59
56
|
output: number;
|
|
@@ -70,6 +67,8 @@ export interface RecentRequest {
|
|
|
70
67
|
o: number;
|
|
71
68
|
t: number;
|
|
72
69
|
d?: number;
|
|
70
|
+
/** Which agent wrote the session: pi, omp, or codex. */
|
|
71
|
+
h?: string;
|
|
73
72
|
cr?: number;
|
|
74
73
|
cw?: number;
|
|
75
74
|
// Undefined when the host recorded no cost; never backfilled.
|
|
@@ -77,6 +76,8 @@ export interface RecentRequest {
|
|
|
77
76
|
st: RunStatus;
|
|
78
77
|
code?: number;
|
|
79
78
|
note?: string;
|
|
79
|
+
/** Message id when the host provides one (transcript row id, opencode msg file). */
|
|
80
|
+
id?: string;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
export interface SessionTokens {
|
|
@@ -87,6 +88,7 @@ export interface SessionTokens {
|
|
|
87
88
|
byDayModel: Record<string, Record<string, number>>;
|
|
88
89
|
byTool: Record<string, number>;
|
|
89
90
|
byModelMessages: Record<string, number>;
|
|
91
|
+
byHost: Record<string, Record<string, TokenBreakdown>>;
|
|
90
92
|
costMeasured: number;
|
|
91
93
|
recent: RecentRequest[];
|
|
92
94
|
}
|
|
@@ -109,12 +111,15 @@ export interface RtkRecallDiagnostics {
|
|
|
109
111
|
function zeroBreakdown(): TokenBreakdown {
|
|
110
112
|
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
111
113
|
}
|
|
114
|
+
function stampMs(ts: string | number | undefined): number {
|
|
115
|
+
return ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
116
|
+
}
|
|
117
|
+
|
|
112
118
|
export function durOf(v: unknown): number | undefined {
|
|
113
119
|
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
114
120
|
}
|
|
115
121
|
|
|
116
|
-
// Measured cost: usage.cost.total, or a bare number in old fixtures. Anything
|
|
117
|
-
// else means unrecorded, which is deliberately not 0.
|
|
122
|
+
// Measured cost: usage.cost.total, or a bare number in old fixtures. Anything else means unrecorded, which is deliberately not 0.
|
|
118
123
|
export function costOf(usage: Record<string, unknown>): number | undefined {
|
|
119
124
|
const c = usage.cost;
|
|
120
125
|
if (typeof c === 'number') return Number.isFinite(c) ? c : undefined;
|
|
@@ -125,8 +130,7 @@ export function costOf(usage: Record<string, unknown>): number | undefined {
|
|
|
125
130
|
return undefined;
|
|
126
131
|
}
|
|
127
132
|
|
|
128
|
-
// First line only: real messages carry multi-line provider errors, and this
|
|
129
|
-
// ends up in a tooltip.
|
|
133
|
+
// First line only: real messages carry multi-line provider errors, and this ends up in a tooltip.
|
|
130
134
|
export function statusOf(msg: { stopReason?: unknown; errorStatus?: unknown; errorMessage?: unknown; isError?: unknown }): { st: RunStatus; code?: number; note?: string } {
|
|
131
135
|
const stop = typeof msg.stopReason === 'string' ? msg.stopReason : '';
|
|
132
136
|
const note = typeof msg.errorMessage === 'string' && msg.errorMessage.trim()
|
|
@@ -147,22 +151,108 @@ function addInto(into: TokenBreakdown, u: { input?: unknown; output?: unknown; c
|
|
|
147
151
|
}
|
|
148
152
|
}
|
|
149
153
|
|
|
150
|
-
export function
|
|
154
|
+
export function sessionsDirs(): string[] {
|
|
151
155
|
const override = process.env.TERSIO_SESSIONS_DIR;
|
|
152
|
-
if (override) return override;
|
|
153
|
-
//
|
|
156
|
+
if (override) return [override];
|
|
157
|
+
// Both hosts, not just the running one: a session on pi is missing from the dashboard entirely if we stop at the first directory that exists.
|
|
154
158
|
const dirs = isPiProcess()
|
|
155
|
-
? [path.join(piAgentDir(), 'sessions'), path.join(
|
|
156
|
-
: [path.join(
|
|
157
|
-
|
|
159
|
+
? [path.join(piAgentDir(), 'sessions'), path.join(homeDir(), '.omp', 'agent', 'sessions')]
|
|
160
|
+
: [path.join(homeDir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
|
|
161
|
+
const found = dirs.filter((dir) => fs.existsSync(dir));
|
|
162
|
+
return found.length > 0 ? found : [dirs[0]];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function sessionsDir(): string {
|
|
166
|
+
return sessionsDirs()[0];
|
|
158
167
|
}
|
|
159
168
|
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
// One JSON file per message; probe XDG, then local, then macOS default.
|
|
170
|
+
export function opencodeSessionsDir(): string {
|
|
171
|
+
const override = process.env.TERSIO_OPENCODE_DIR;
|
|
162
172
|
if (override) return override;
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
165
|
-
|
|
173
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
174
|
+
if (xdg && xdg.trim() !== '') return path.join(xdg, 'opencode', 'storage', 'message');
|
|
175
|
+
const local = path.join(homeDir(), '.local', 'share', 'opencode', 'storage', 'message');
|
|
176
|
+
try {
|
|
177
|
+
if (fs.existsSync(local)) return local;
|
|
178
|
+
} catch { /* fall through to the platform default */ }
|
|
179
|
+
return path.join(homeDir(), 'Library', 'Application Support', 'opencode', 'storage', 'message');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// OpenCode v2 keeps messages in sqlite; the JSON tree above is only the pre-v2 layout.
|
|
183
|
+
export function opencodeDbPath(): string {
|
|
184
|
+
const override = process.env.TERSIO_OPENCODE_DB;
|
|
185
|
+
if (override) return override;
|
|
186
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
187
|
+
if (xdg && xdg.trim() !== '') return path.join(xdg, 'opencode', 'opencode.db');
|
|
188
|
+
const local = path.join(homeDir(), '.local', 'share', 'opencode', 'opencode.db');
|
|
189
|
+
try {
|
|
190
|
+
if (fs.existsSync(local)) return local;
|
|
191
|
+
} catch { /* fall through to the platform default */ }
|
|
192
|
+
return path.join(homeDir(), 'Library', 'Application Support', 'opencode', 'opencode.db');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface OpencodeDbRow {
|
|
196
|
+
id: string;
|
|
197
|
+
created: number;
|
|
198
|
+
row: OpencodeMessage;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Two on-disk shapes: legacy `message` (role/modelID/providerID live inside `data`),
|
|
202
|
+
// current `session_message` (role in the `type` column, model under data.model).
|
|
203
|
+
// Scalars only: the `data` blob holds newlines and tabs, which the sqlite3 CLI would split.
|
|
204
|
+
const OC_DB_ORDER = ' ORDER BY time_created;';
|
|
205
|
+
const OC_DB_COLUMNS =
|
|
206
|
+
'json_extract(data,\'$.time.created\'), json_extract(data,\'$.time.completed\'),' +
|
|
207
|
+
' json_extract(data,\'$.tokens.input\'), json_extract(data,\'$.tokens.output\'),' +
|
|
208
|
+
' json_extract(data,\'$.tokens.cache.read\'), json_extract(data,\'$.tokens.cache.write\'), json_extract(data,\'$.cost\')';
|
|
209
|
+
const ocDbLegacy = (since: number): string =>
|
|
210
|
+
`SELECT id, time_created, json_extract(data,'$.role'), json_extract(data,'$.providerID'), json_extract(data,'$.modelID'), ${OC_DB_COLUMNS}` +
|
|
211
|
+
` FROM message WHERE json_extract(data,'$.role')='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
|
|
212
|
+
const ocDbCurrent = (since: number): string =>
|
|
213
|
+
`SELECT id, time_created, 'assistant', json_extract(data,'$.model.providerID'), json_extract(data,'$.model.id'), ${OC_DB_COLUMNS}` +
|
|
214
|
+
` FROM session_message WHERE type='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
|
|
215
|
+
|
|
216
|
+
// A re-scan window covers tokens that land after the row was created, so a late fill is not lost.
|
|
217
|
+
export const OPENCODE_DB_OVERLAP_MS = 3_600_000;
|
|
218
|
+
|
|
219
|
+
// execFileSync caps output at 1MB; a busy table clears it in one SELECT.
|
|
220
|
+
export const SQLITE_READ_BUFFER = 50 * 1024 * 1024;
|
|
221
|
+
|
|
222
|
+
// Empty when sqlite3 is absent or the db is unreadable; that is a sync skip, not a failure.
|
|
223
|
+
export function readOpencodeDbRows(db: string, sinceMs: number): OpencodeDbRow[] {
|
|
224
|
+
if (!Number.isFinite(sinceMs)) return [];
|
|
225
|
+
try {
|
|
226
|
+
if (!fs.existsSync(db)) return [];
|
|
227
|
+
const since = Math.max(0, Math.floor(sinceMs));
|
|
228
|
+
// Separate calls: a db holding only one table must not cancel the other.
|
|
229
|
+
const run = (sql: string): string =>
|
|
230
|
+
execFileSync('sqlite3', ['-separator', '\t', '-readonly', db, sql], { encoding: 'utf8', timeout: 30000, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: SQLITE_READ_BUFFER });
|
|
231
|
+
let out = '';
|
|
232
|
+
try { out += run(ocDbLegacy(since)); } catch { /* no legacy table or unreadable */ }
|
|
233
|
+
try { out += run(ocDbCurrent(since)); } catch { /* no session_message table or unreadable */ }
|
|
234
|
+
const rows: OpencodeDbRow[] = [];
|
|
235
|
+
for (const line of out.split('\n')) {
|
|
236
|
+
if (!line.trim()) continue;
|
|
237
|
+
const [id, created, role, providerID, modelID, tCreated, tCompleted, input, output, cacheRead, cacheWrite, cost] = line.split('\t');
|
|
238
|
+
const n = (v: string | undefined): unknown => (v !== undefined && v !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined);
|
|
239
|
+
rows.push({
|
|
240
|
+
id,
|
|
241
|
+
created: Number(created),
|
|
242
|
+
row: {
|
|
243
|
+
role,
|
|
244
|
+
modelID,
|
|
245
|
+
providerID,
|
|
246
|
+
tokens: { input: n(input), output: n(output), cache: { read: n(cacheRead), write: n(cacheWrite) } },
|
|
247
|
+
time: { created: n(tCreated), completed: n(tCompleted) },
|
|
248
|
+
cost: n(cost),
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return rows;
|
|
253
|
+
} catch {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
166
256
|
}
|
|
167
257
|
|
|
168
258
|
export function dayKey(ts: string | number): string | null {
|
|
@@ -172,7 +262,7 @@ export function dayKey(ts: string | number): string | null {
|
|
|
172
262
|
const p = (n: number): string => String(n).padStart(2, '0');
|
|
173
263
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
174
264
|
}
|
|
175
|
-
export function walkJsonl(dir: string, out: string[], cap: number): void {
|
|
265
|
+
export function walkJsonl(dir: string, out: string[], cap: number, ext = '.jsonl'): void {
|
|
176
266
|
let entries: fs.Dirent[];
|
|
177
267
|
try {
|
|
178
268
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -182,36 +272,35 @@ export function walkJsonl(dir: string, out: string[], cap: number): void {
|
|
|
182
272
|
for (const e of entries) {
|
|
183
273
|
if (out.length >= cap) return;
|
|
184
274
|
const full = path.join(dir, e.name);
|
|
185
|
-
if (e.isDirectory()) walkJsonl(full, out, cap);
|
|
186
|
-
else if (e.isFile() && e.name.endsWith(
|
|
275
|
+
if (e.isDirectory()) walkJsonl(full, out, cap, ext);
|
|
276
|
+
else if (e.isFile() && e.name.endsWith(ext)) out.push(full);
|
|
187
277
|
}
|
|
188
278
|
}
|
|
189
|
-
// Recent
|
|
190
|
-
|
|
191
|
-
export const RECENT_LIMIT = 200;
|
|
279
|
+
// Recent rows are a newest-first window over lifetime history; aggregates keep everything.
|
|
280
|
+
export const RECENT_LIMIT = 2000;
|
|
192
281
|
const FREE_SUFFIX = /(?::free|-free)$/i;
|
|
193
282
|
const RTK_ELIGIBLE_HEADS = new Set([
|
|
194
283
|
'rtk', 'git', 'grep', 'rg', 'find', 'cat', 'ls', 'tree', 'diff', 'log',
|
|
195
284
|
'npm', 'npx', 'pnpm', 'bun', 'bunx', 'cargo', 'go', 'python', 'pytest',
|
|
196
285
|
'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
|
|
197
286
|
]);
|
|
198
|
-
// Parse buffer shared by live reads and usage.db syncs, so the store is a
|
|
199
|
-
// cache and never a fork.
|
|
287
|
+
// Parse buffer shared by live reads and usage.db syncs, so the store is a cache and never a fork.
|
|
200
288
|
export interface SessionAccum {
|
|
201
289
|
byModel: Record<string, TokenBreakdown>;
|
|
202
290
|
byDay: Record<string, TokenBreakdown>;
|
|
203
291
|
byDayModel: Record<string, Record<string, number>>;
|
|
204
292
|
byTool: Record<string, number>;
|
|
205
293
|
byModelMessages: Record<string, number>;
|
|
294
|
+
byHost: Record<string, Record<string, TokenBreakdown>>;
|
|
206
295
|
totals: TokenBreakdown;
|
|
207
296
|
messages: number;
|
|
208
297
|
costMeasured: number;
|
|
209
298
|
recent: RecentRequest[];
|
|
210
299
|
}
|
|
211
300
|
export function newSessionAccum(): SessionAccum {
|
|
212
|
-
return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
|
|
301
|
+
return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
|
|
213
302
|
}
|
|
214
|
-
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 {
|
|
303
|
+
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, id?: string): boolean {
|
|
215
304
|
const watermark = readResetWatermark();
|
|
216
305
|
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
217
306
|
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark)) return false;
|
|
@@ -220,10 +309,15 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
|
|
|
220
309
|
accum.byModel[key] ??= zeroBreakdown();
|
|
221
310
|
addInto(accum.byModel[key], usage);
|
|
222
311
|
accum.byModelMessages[key] = (accum.byModelMessages[key] ?? 0) + 1;
|
|
312
|
+
if (host) {
|
|
313
|
+
const hs = (accum.byHost[key] ??= {});
|
|
314
|
+
hs[host] ??= zeroBreakdown();
|
|
315
|
+
addInto(hs[host], usage);
|
|
316
|
+
}
|
|
223
317
|
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
224
318
|
if (Number.isFinite(ms)) {
|
|
225
319
|
const outcome = run ?? { st: 'completed' as RunStatus };
|
|
226
|
-
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 });
|
|
320
|
+
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, id });
|
|
227
321
|
}
|
|
228
322
|
const day = ts !== undefined ? dayKey(ts) : null;
|
|
229
323
|
if (day) {
|
|
@@ -241,11 +335,15 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
|
|
|
241
335
|
for (const name of toolNames ?? []) accum.byTool[name] = (accum.byTool[name] ?? 0) + 1;
|
|
242
336
|
return true;
|
|
243
337
|
}
|
|
244
|
-
export type SessionLineKind = 'codex_provider' | 'token_row' | 'assistant_row' | 'skip';
|
|
245
|
-
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 } {
|
|
338
|
+
export type SessionLineKind = 'codex_provider' | 'codex_model' | 'token_row' | 'assistant_row' | 'skip';
|
|
339
|
+
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 } {
|
|
246
340
|
const payload = row.payload;
|
|
247
341
|
if (payload && typeof payload === 'object') {
|
|
248
342
|
if (row.type === 'session_meta' && typeof payload.model_provider === 'string') return { kind: 'codex_provider', provider: payload.model_provider };
|
|
343
|
+
// A Codex transcript states the model once per turn, in turn_context.
|
|
344
|
+
if (row.type === 'turn_context' && typeof payload.model === 'string' && payload.model) {
|
|
345
|
+
return { kind: 'codex_model', model: payload.model };
|
|
346
|
+
}
|
|
249
347
|
if (payload.type === 'token_count') {
|
|
250
348
|
const last = payload.info?.last_token_usage;
|
|
251
349
|
if (last) {
|
|
@@ -258,7 +356,10 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
|
|
|
258
356
|
const msg = row.message;
|
|
259
357
|
if (!msg || msg.role !== 'assistant' || !msg.usage) return { kind: 'skip' };
|
|
260
358
|
const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
|
|
261
|
-
|
|
359
|
+
// pi omits duration, so derive Elapsed/Speed from the timestamp pair.
|
|
360
|
+
const startMs = typeof msg.timestamp === 'number' ? msg.timestamp : undefined;
|
|
361
|
+
const endMs = typeof msg.completedAt === 'number' ? msg.completedAt : stampMs(row.timestamp);
|
|
362
|
+
const computedDur = startMs !== undefined && Number.isFinite(endMs) && endMs > startMs ? endMs - startMs : undefined;
|
|
262
363
|
const tools: string[] = [];
|
|
263
364
|
for (const part of msg.content ?? []) {
|
|
264
365
|
if (part?.type !== 'toolCall' || typeof part.name !== 'string' || !part.name) continue;
|
|
@@ -271,17 +372,139 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
|
|
|
271
372
|
}
|
|
272
373
|
return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
|
|
273
374
|
}
|
|
375
|
+
// True when at least one token bucket holds a positive finite count.
|
|
376
|
+
export function hasPositiveUsage(usage: Record<string, unknown>): boolean {
|
|
377
|
+
for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
|
|
378
|
+
const v = usage[k];
|
|
379
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return true;
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export interface OpencodeMessage {
|
|
385
|
+
role?: unknown;
|
|
386
|
+
tokens?: { input?: unknown; output?: unknown; reasoning?: unknown; cache?: { read?: unknown; write?: unknown } } | null;
|
|
387
|
+
modelID?: unknown;
|
|
388
|
+
providerID?: unknown;
|
|
389
|
+
time?: { created?: unknown; completed?: unknown } | null;
|
|
390
|
+
cost?: unknown;
|
|
391
|
+
finish?: unknown;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// One OpenCode message file; assistant rows carry the token buckets.
|
|
395
|
+
export function classifyOpencodeMessage(obj: OpencodeMessage | null | undefined): { model: string; usage: Record<string, unknown>; ms: number | undefined; durMs: number | undefined } | null {
|
|
396
|
+
if (!obj || typeof obj !== 'object' || obj.role !== 'assistant') return null;
|
|
397
|
+
const t = obj.tokens;
|
|
398
|
+
if (!t || typeof t !== 'object') return null;
|
|
399
|
+
const usage: Record<string, unknown> = {
|
|
400
|
+
input: t.input,
|
|
401
|
+
output: t.output,
|
|
402
|
+
cacheRead: t.cache?.read,
|
|
403
|
+
cacheWrite: t.cache?.write,
|
|
404
|
+
cost: (obj as Record<string, unknown>).cost,
|
|
405
|
+
};
|
|
406
|
+
if (!hasPositiveUsage(usage)) return null;
|
|
407
|
+
const provider = typeof obj.providerID === 'string' && obj.providerID ? obj.providerID : 'unknown';
|
|
408
|
+
const model = typeof obj.modelID === 'string' && obj.modelID ? obj.modelID : 'unknown';
|
|
409
|
+
const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
|
|
410
|
+
const created = num(obj.time?.created);
|
|
411
|
+
const completed = num(obj.time?.completed);
|
|
412
|
+
return {
|
|
413
|
+
model: `opencode/${provider}/${model}`,
|
|
414
|
+
usage,
|
|
415
|
+
ms: created,
|
|
416
|
+
durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// One OpenCode message file into the shared accum. The msg id is the file name.
|
|
421
|
+
export function processOpencodeFile(accum: SessionAccum, text: string, file?: string): void {
|
|
422
|
+
let obj: unknown;
|
|
423
|
+
try {
|
|
424
|
+
obj = JSON.parse(text);
|
|
425
|
+
} catch {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const parsed = classifyOpencodeMessage(obj as OpencodeMessage);
|
|
429
|
+
if (!parsed) return;
|
|
430
|
+
const id = file ? path.basename(file).replace(/\.[^.]+$/, '') || undefined : undefined;
|
|
431
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, undefined, 'opencode', id);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// One v2 message row into the shared accum. The msg id is the row primary key.
|
|
435
|
+
export function ingestOpencodeDbRow(accum: SessionAccum, row: OpencodeDbRow): void {
|
|
436
|
+
const parsed = classifyOpencodeMessage(row.row);
|
|
437
|
+
if (!parsed) return;
|
|
438
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, undefined, 'opencode', row.id);
|
|
439
|
+
}
|
|
440
|
+
|
|
274
441
|
// Fold `:free`/`-free` suffixes and case variants into one chart key.
|
|
275
442
|
export function canonicalModelId(model: string): string {
|
|
276
443
|
return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
|
|
277
444
|
}
|
|
278
|
-
//
|
|
279
|
-
|
|
445
|
+
// Transcript row id when the host provides one; anything else is not an id.
|
|
446
|
+
export function messageRowId(row: unknown): string | undefined {
|
|
447
|
+
const id = (row as { id?: unknown }).id;
|
|
448
|
+
return typeof id === 'string' && id ? id : undefined;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Vendor names mirror the dashboard map; CLI needs names only. Family first:
|
|
452
|
+
// gateway segments shadow it (minimaxai holds xai, gemma rides nvidia).
|
|
453
|
+
const VENDOR_RES: Array<[RegExp, string]> = [
|
|
454
|
+
[/inclusionai|ling-/i, 'InclusionAI'],
|
|
455
|
+
[/minimax/i, 'MiniMax'],
|
|
456
|
+
[/grok|xai/i, 'xAI'],
|
|
457
|
+
[/stealth|space-bunny/i, 'Stealth'],
|
|
458
|
+
[/openai|codex|gpt-|o1/i, 'OpenAI'],
|
|
459
|
+
[/muse|llama/i, 'Meta'],
|
|
460
|
+
[/deepseek/i, 'DeepSeek'],
|
|
461
|
+
[/qwen|qwq/i, 'Alibaba'],
|
|
462
|
+
[/glm|z-ai|zhipu/i, 'Z.ai'],
|
|
463
|
+
[/mimo/i, 'Xiaomi'],
|
|
464
|
+
[/kimi|moonshot/i, 'Moonshot'],
|
|
465
|
+
[/mistral/i, 'Mistral'],
|
|
466
|
+
[/claude|anthropic/i, 'Anthropic'],
|
|
467
|
+
[/gemini|google|gemma/i, 'Google'],
|
|
468
|
+
[/nemotron|nvidia/i, 'NVIDIA'],
|
|
469
|
+
[/devin|cognition|^swe[-/]/i, 'Cognition'],
|
|
470
|
+
];
|
|
471
|
+
|
|
472
|
+
function modelVendor(model: string): string {
|
|
473
|
+
for (const [re, name] of VENDOR_RES) {
|
|
474
|
+
if (re.test(model)) return name;
|
|
475
|
+
}
|
|
476
|
+
return 'Other';
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Gateway variants of one model share a row under the display label.
|
|
480
|
+
export function foldModelsByLabel(
|
|
481
|
+
byModel: Record<string, TokenBreakdown>,
|
|
482
|
+
byModelMessages: Record<string, number>,
|
|
483
|
+
): { byModel: Record<string, TokenBreakdown>; byModelMessages: Record<string, number> } {
|
|
484
|
+
const folded: Record<string, TokenBreakdown> = {};
|
|
485
|
+
const messages: Record<string, number> = {};
|
|
486
|
+
for (const [model, t] of Object.entries(byModel)) {
|
|
487
|
+
const label = displayModelId(model);
|
|
488
|
+
folded[label] ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
489
|
+
const into = folded[label];
|
|
490
|
+
into.input += t.input;
|
|
491
|
+
into.output += t.output;
|
|
492
|
+
into.cacheRead += t.cacheRead;
|
|
493
|
+
into.cacheWrite += t.cacheWrite;
|
|
494
|
+
messages[label] = (messages[label] ?? 0) + (byModelMessages[model] ?? 0);
|
|
495
|
+
}
|
|
496
|
+
return { byModel: folded, byModelMessages: messages };
|
|
497
|
+
}
|
|
498
|
+
// Vendor-first labels, same fold as the dashboard: "Anthropic - Claude - Haiku-4.5".
|
|
280
499
|
export function displayModelId(model: string): string {
|
|
281
500
|
const bare = model.replace(FREE_SUFFIX, '');
|
|
501
|
+
// The report already folds keys; folding again would lowercase the caps.
|
|
502
|
+
if (bare.includes(' - ')) return bare;
|
|
282
503
|
const cap = (s: string): string => {
|
|
283
504
|
const low = s.toLowerCase();
|
|
284
505
|
if (low === 'openai') return 'OpenAI';
|
|
506
|
+
if (low === 'gpt') return 'GPT';
|
|
507
|
+
if (low === 'swe') return 'SWE';
|
|
285
508
|
if (low === 'ai') return 'AI';
|
|
286
509
|
if (/^\d+[a-z]+$/.test(low)) return low.toUpperCase();
|
|
287
510
|
if (s.length <= 2) return s.toUpperCase();
|
|
@@ -289,21 +512,42 @@ export function displayModelId(model: string): string {
|
|
|
289
512
|
};
|
|
290
513
|
const seg = (s: string): string => s.split('.').map(cap).join('.');
|
|
291
514
|
const words = (s: string): string => s.split(/[-_:]+/).filter(Boolean).map(seg).join('-');
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
515
|
+
const segs = bare.split('/').filter(Boolean);
|
|
516
|
+
const tail = segs[segs.length - 1] ?? bare;
|
|
517
|
+
// One free model under two spellings; fold the alpha variant onto it.
|
|
518
|
+
const pretty = /^space-bunny(-alpha)?$/i.test(tail) ? 'Space-Bunny' : words(tail);
|
|
519
|
+
const vendor = modelVendor(bare);
|
|
520
|
+
if (vendor === 'Other') return segs.length > 1 ? `${words(segs[segs.length - 2])} - ${pretty}` : pretty;
|
|
521
|
+
const claude = pretty.match(/^claude[-_](.+)$/i);
|
|
522
|
+
if (vendor === 'Anthropic' && claude) return `Anthropic - Claude - ${claude[1]}`;
|
|
523
|
+
if (pretty.toLowerCase().startsWith(vendor.toLowerCase())) return pretty;
|
|
524
|
+
return `${vendor} - ${pretty}`;
|
|
295
525
|
}
|
|
296
526
|
|
|
297
527
|
export function importSessionTokens(): SessionTokens {
|
|
298
528
|
const accum = newSessionAccum();
|
|
299
529
|
const files: string[] = [];
|
|
300
|
-
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.
|
|
304
|
-
walkJsonl(
|
|
530
|
+
for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
|
|
531
|
+
// OpenCode message bodies are single JSON documents, not JSONL.
|
|
532
|
+
const ocFiles: string[] = [];
|
|
533
|
+
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
|
|
534
|
+
walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
|
|
535
|
+
}
|
|
536
|
+
for (const file of ocFiles) {
|
|
537
|
+
let text: string;
|
|
538
|
+
try {
|
|
539
|
+
text = fs.readFileSync(file, 'utf8');
|
|
540
|
+
} catch {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
processOpencodeFile(accum, text, file);
|
|
544
|
+
}
|
|
545
|
+
// v2 hosts write sqlite, not the JSON tree above.
|
|
546
|
+
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DB !== undefined) {
|
|
547
|
+
for (const row of readOpencodeDbRows(opencodeDbPath(), 0)) ingestOpencodeDbRow(accum, row);
|
|
305
548
|
}
|
|
306
549
|
let codexProvider: string | null = null;
|
|
550
|
+
let codexModel: string | null = null;
|
|
307
551
|
for (const file of files) {
|
|
308
552
|
let text: string;
|
|
309
553
|
try {
|
|
@@ -311,10 +555,24 @@ export function importSessionTokens(): SessionTokens {
|
|
|
311
555
|
} catch {
|
|
312
556
|
continue;
|
|
313
557
|
}
|
|
314
|
-
processSessionText(accum, {
|
|
558
|
+
processSessionText(accum, {
|
|
559
|
+
get codexProvider() { return codexProvider; },
|
|
560
|
+
set codexProvider(v: string | null) { codexProvider = v; },
|
|
561
|
+
get codexModel() { return codexModel; },
|
|
562
|
+
set codexModel(v: string | null) { codexModel = v; },
|
|
563
|
+
}, text, hostOfSessionFile(file));
|
|
315
564
|
}
|
|
316
565
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
317
|
-
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) };
|
|
566
|
+
return { messages: accum.messages, totals: accum.totals, byModel: accum.byModel, byDay: accum.byDay, byDayModel: accum.byDayModel, byTool: accum.byTool, byModelMessages: accum.byModelMessages, byHost: accum.byHost, costMeasured: accum.costMeasured, recent: accum.recent.slice(0, RECENT_LIMIT) };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Which agent wrote a session file, from the directory it sits in.
|
|
570
|
+
export function hostOfSessionFile(file: string): string {
|
|
571
|
+
const norm = file.replace(/\\/g, '/');
|
|
572
|
+
if (norm.includes('/.omp/')) return 'omp';
|
|
573
|
+
if (norm.includes('/.codex/')) return 'codex';
|
|
574
|
+
if (norm.includes('/opencode/')) return 'opencode';
|
|
575
|
+
return 'pi';
|
|
318
576
|
}
|
|
319
577
|
|
|
320
578
|
const adoptionFileCache = new Map<string, { size: number; mtimeMs: number; counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number } }>();
|
|
@@ -355,10 +613,7 @@ export function clearRtkAdoptionCache(): void {
|
|
|
355
613
|
|
|
356
614
|
export function readRtkAdoption(): RtkAdoption {
|
|
357
615
|
const files: string[] = [];
|
|
358
|
-
|
|
359
|
-
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
360
|
-
walkJsonl(codexSessionsDir(), files, 2000);
|
|
361
|
-
}
|
|
616
|
+
for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
|
|
362
617
|
const live = new Set(files);
|
|
363
618
|
for (const file of adoptionFileCache.keys()) {
|
|
364
619
|
if (!live.has(file)) adoptionFileCache.delete(file);
|
|
@@ -390,23 +645,30 @@ export function readRtkAdoption(): RtkAdoption {
|
|
|
390
645
|
const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
|
|
391
646
|
return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
|
|
392
647
|
}
|
|
393
|
-
export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null }, text: string): void {
|
|
648
|
+
export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null; codexModel?: string | null }, text: string, host?: string): void {
|
|
394
649
|
for (const line of text.split('\n')) {
|
|
395
650
|
if (!line.trim()) continue;
|
|
396
651
|
try {
|
|
397
652
|
const row = JSON.parse(line) as Parameters<typeof classifySessionLine>[0];
|
|
398
653
|
const parsed = classifySessionLine(row);
|
|
654
|
+
const rid = messageRowId(row);
|
|
399
655
|
if (parsed.kind === 'codex_provider') {
|
|
400
656
|
if (parsed.provider) state.codexProvider = parsed.provider;
|
|
401
657
|
continue;
|
|
402
658
|
}
|
|
659
|
+
if (parsed.kind === 'codex_model') {
|
|
660
|
+
if (parsed.model) state.codexModel = parsed.model;
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
403
663
|
if (parsed.kind === 'token_row') {
|
|
404
|
-
const
|
|
405
|
-
|
|
664
|
+
const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
|
|
665
|
+
// The provider alone told you nothing about which model ran.
|
|
666
|
+
const model = state.codexModel ? `${base}/${state.codexModel}` : base;
|
|
667
|
+
ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' as RunStatus }, undefined, host ?? 'codex', rid);
|
|
406
668
|
continue;
|
|
407
669
|
}
|
|
408
670
|
if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage) continue;
|
|
409
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools);
|
|
671
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid);
|
|
410
672
|
} catch { /* skip corrupt lines */ }
|
|
411
673
|
}
|
|
412
674
|
}
|
|
@@ -416,8 +678,8 @@ export function readRtkRecallDiagnostics(binary: string | null = resolveRtkBinar
|
|
|
416
678
|
try {
|
|
417
679
|
const configPath = process.env.RTK_CONFIG
|
|
418
680
|
|| (process.platform === 'darwin'
|
|
419
|
-
? path.join(
|
|
420
|
-
: path.join(process.env.XDG_DATA_HOME || path.join(
|
|
681
|
+
? path.join(homeDir(), 'Library', 'Application Support', 'rtk', 'config.toml')
|
|
682
|
+
: path.join(process.env.XDG_DATA_HOME || path.join(homeDir(), '.local', 'share'), 'rtk', 'config.toml'));
|
|
421
683
|
let config = '';
|
|
422
684
|
try { config = fs.readFileSync(configPath, 'utf8'); } catch { /* use RTK CLI fallback */ }
|
|
423
685
|
const modeMatch = /^\s*mode\s*=\s*"(sqlite|tee|disabled)"/m.exec(config);
|
|
@@ -429,8 +691,7 @@ export function readRtkRecallDiagnostics(binary: string | null = resolveRtkBinar
|
|
|
429
691
|
return { mode: 'unknown', entries: 0, available: false };
|
|
430
692
|
}
|
|
431
693
|
}
|
|
432
|
-
// Lead binary of a shell string: first segment past `cd` chains and VAR=x
|
|
433
|
-
// assignments. Falls back to `bash`.
|
|
694
|
+
// Lead binary of a shell string: first segment past `cd` chains and VAR=x assignments. Falls back to `bash`.
|
|
434
695
|
const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
|
|
435
696
|
export function leadBinary(command: unknown): string {
|
|
436
697
|
if (typeof command !== 'string' || !command.trim()) return 'bash';
|
|
@@ -493,8 +754,7 @@ export function readUsage(): UsageRow[] {
|
|
|
493
754
|
return rows;
|
|
494
755
|
}
|
|
495
756
|
|
|
496
|
-
// Delete the ledger file and return the rows cleared. Tersio-owned data only:
|
|
497
|
-
// transcripts and the RTK database are never touched.
|
|
757
|
+
// Delete the ledger file and return the rows cleared. Tersio-owned data only: transcripts and the RTK database are never touched.
|
|
498
758
|
export function clearUsageLedger(): number {
|
|
499
759
|
const rows = readUsage().length;
|
|
500
760
|
try {
|