@krtclcdy/tersio 2.24.1 → 2.25.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 +27 -21
- package/cli/banner.js +7 -4
- package/cli/common.js +14 -13
- package/cli/currency.js +1 -2
- package/cli/dashboard.js +57 -14
- package/cli/doctor-fix.js +88 -21
- package/cli/doctor.js +32 -24
- package/cli/hosts.js +19 -18
- package/cli/install.js +123 -52
- package/cli/interactive.js +29 -19
- package/cli/manifest.js +7 -0
- package/cli/profile.js +42 -11
- package/cli/rtk-wiring.js +28 -26
- package/cli/settings.js +37 -8
- package/cli/uninstall.js +40 -7
- package/cli/update.js +22 -14
- package/cli/usage.js +39 -14
- package/dashboard/dist/index.html +16 -16
- package/extensions/ai-addons-updater/index.js +69 -15
- package/extensions/ai-addons-updater/index.ts +60 -17
- package/extensions/caveman-session/index.js +28 -55
- package/extensions/caveman-session/index.ts +28 -55
- package/extensions/combo-toggle/index.js +40 -58
- package/extensions/combo-toggle/index.ts +38 -67
- package/extensions/lib/utils.js +60 -15
- package/extensions/lib/utils.ts +59 -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 +20 -35
- package/extensions/rtk-session/index.ts +19 -34
- package/extensions/shared/carbon.js +1 -2
- package/extensions/shared/carbon.ts +1 -2
- package/extensions/shared/host.js +95 -46
- package/extensions/shared/host.ts +106 -50
- package/extensions/shared/plugin-settings.js +4 -6
- package/extensions/shared/plugin-settings.ts +4 -6
- package/extensions/shared/pricing.js +79 -25
- package/extensions/shared/pricing.ts +80 -26
- package/extensions/shared/rtk-gain.js +3 -5
- package/extensions/shared/rtk-gain.ts +3 -5
- package/extensions/shared/session-state.js +10 -33
- package/extensions/shared/session-state.ts +12 -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 +190 -56
- package/extensions/shared/usage-ledger.ts +194 -58
- package/extensions/shared/usage-store.js +228 -64
- package/extensions/shared/usage-store.ts +235 -67
- package/extensions/tersio-commands/index.js +4 -9
- package/extensions/tersio-commands/index.ts +4 -9
- package/package.json +8 -2
- package/tersio.js +3 -3
- package/cli/agent-hosts.js +0 -42
- package/cli/agents.js +0 -266
- package/cli/layer.js +0 -22
- 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;
|
|
@@ -79,6 +76,8 @@ export interface RecentRequest {
|
|
|
79
76
|
st: RunStatus;
|
|
80
77
|
code?: number;
|
|
81
78
|
note?: string;
|
|
79
|
+
/** Message id when the host provides one (transcript row id, opencode msg file). */
|
|
80
|
+
id?: string;
|
|
82
81
|
}
|
|
83
82
|
|
|
84
83
|
export interface SessionTokens {
|
|
@@ -89,6 +88,7 @@ export interface SessionTokens {
|
|
|
89
88
|
byDayModel: Record<string, Record<string, number>>;
|
|
90
89
|
byTool: Record<string, number>;
|
|
91
90
|
byModelMessages: Record<string, number>;
|
|
91
|
+
byHost: Record<string, Record<string, TokenBreakdown>>;
|
|
92
92
|
costMeasured: number;
|
|
93
93
|
recent: RecentRequest[];
|
|
94
94
|
}
|
|
@@ -119,8 +119,7 @@ export function durOf(v: unknown): number | undefined {
|
|
|
119
119
|
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// Measured cost: usage.cost.total, or a bare number in old fixtures. Anything
|
|
123
|
-
// 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.
|
|
124
123
|
export function costOf(usage: Record<string, unknown>): number | undefined {
|
|
125
124
|
const c = usage.cost;
|
|
126
125
|
if (typeof c === 'number') return Number.isFinite(c) ? c : undefined;
|
|
@@ -131,8 +130,7 @@ export function costOf(usage: Record<string, unknown>): number | undefined {
|
|
|
131
130
|
return undefined;
|
|
132
131
|
}
|
|
133
132
|
|
|
134
|
-
// First line only: real messages carry multi-line provider errors, and this
|
|
135
|
-
// ends up in a tooltip.
|
|
133
|
+
// First line only: real messages carry multi-line provider errors, and this ends up in a tooltip.
|
|
136
134
|
export function statusOf(msg: { stopReason?: unknown; errorStatus?: unknown; errorMessage?: unknown; isError?: unknown }): { st: RunStatus; code?: number; note?: string } {
|
|
137
135
|
const stop = typeof msg.stopReason === 'string' ? msg.stopReason : '';
|
|
138
136
|
const note = typeof msg.errorMessage === 'string' && msg.errorMessage.trim()
|
|
@@ -156,11 +154,10 @@ function addInto(into: TokenBreakdown, u: { input?: unknown; output?: unknown; c
|
|
|
156
154
|
export function sessionsDirs(): string[] {
|
|
157
155
|
const override = process.env.TERSIO_SESSIONS_DIR;
|
|
158
156
|
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.
|
|
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.
|
|
161
158
|
const dirs = isPiProcess()
|
|
162
|
-
? [path.join(piAgentDir(), 'sessions'), path.join(
|
|
163
|
-
: [path.join(
|
|
159
|
+
? [path.join(piAgentDir(), 'sessions'), path.join(homeDir(), '.omp', 'agent', 'sessions')]
|
|
160
|
+
: [path.join(homeDir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
|
|
164
161
|
const found = dirs.filter((dir) => fs.existsSync(dir));
|
|
165
162
|
return found.length > 0 ? found : [dirs[0]];
|
|
166
163
|
}
|
|
@@ -169,25 +166,93 @@ export function sessionsDir(): string {
|
|
|
169
166
|
return sessionsDirs()[0];
|
|
170
167
|
}
|
|
171
168
|
|
|
172
|
-
export function codexSessionsDir(): string {
|
|
173
|
-
const override = process.env.TERSIO_CODEX_DIR;
|
|
174
|
-
if (override) return override;
|
|
175
|
-
const codexHome = process.env.CODEX_HOME;
|
|
176
|
-
if (codexHome) return path.join(codexHome, 'sessions');
|
|
177
|
-
return path.join(os.homedir(), '.codex', 'sessions');
|
|
178
|
-
}
|
|
179
|
-
|
|
180
169
|
// One JSON file per message; probe XDG, then local, then macOS default.
|
|
181
170
|
export function opencodeSessionsDir(): string {
|
|
182
171
|
const override = process.env.TERSIO_OPENCODE_DIR;
|
|
183
172
|
if (override) return override;
|
|
184
173
|
const xdg = process.env.XDG_DATA_HOME;
|
|
185
174
|
if (xdg && xdg.trim() !== '') return path.join(xdg, 'opencode', 'storage', 'message');
|
|
186
|
-
const local = path.join(
|
|
175
|
+
const local = path.join(homeDir(), '.local', 'share', 'opencode', 'storage', 'message');
|
|
187
176
|
try {
|
|
188
177
|
if (fs.existsSync(local)) return local;
|
|
189
178
|
} catch { /* fall through to the platform default */ }
|
|
190
|
-
return path.join(
|
|
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
|
+
}
|
|
191
256
|
}
|
|
192
257
|
|
|
193
258
|
export function dayKey(ts: string | number): string | null {
|
|
@@ -211,32 +276,31 @@ export function walkJsonl(dir: string, out: string[], cap: number, ext = '.jsonl
|
|
|
211
276
|
else if (e.isFile() && e.name.endsWith(ext)) out.push(full);
|
|
212
277
|
}
|
|
213
278
|
}
|
|
214
|
-
// Recent
|
|
215
|
-
|
|
216
|
-
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;
|
|
217
281
|
const FREE_SUFFIX = /(?::free|-free)$/i;
|
|
218
282
|
const RTK_ELIGIBLE_HEADS = new Set([
|
|
219
283
|
'rtk', 'git', 'grep', 'rg', 'find', 'cat', 'ls', 'tree', 'diff', 'log',
|
|
220
284
|
'npm', 'npx', 'pnpm', 'bun', 'bunx', 'cargo', 'go', 'python', 'pytest',
|
|
221
285
|
'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
|
|
222
286
|
]);
|
|
223
|
-
// Parse buffer shared by live reads and usage.db syncs, so the store is a
|
|
224
|
-
// 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.
|
|
225
288
|
export interface SessionAccum {
|
|
226
289
|
byModel: Record<string, TokenBreakdown>;
|
|
227
290
|
byDay: Record<string, TokenBreakdown>;
|
|
228
291
|
byDayModel: Record<string, Record<string, number>>;
|
|
229
292
|
byTool: Record<string, number>;
|
|
230
293
|
byModelMessages: Record<string, number>;
|
|
294
|
+
byHost: Record<string, Record<string, TokenBreakdown>>;
|
|
231
295
|
totals: TokenBreakdown;
|
|
232
296
|
messages: number;
|
|
233
297
|
costMeasured: number;
|
|
234
298
|
recent: RecentRequest[];
|
|
235
299
|
}
|
|
236
300
|
export function newSessionAccum(): SessionAccum {
|
|
237
|
-
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: [] };
|
|
238
302
|
}
|
|
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 {
|
|
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 {
|
|
240
304
|
const watermark = readResetWatermark();
|
|
241
305
|
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
242
306
|
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark)) return false;
|
|
@@ -245,10 +309,15 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
|
|
|
245
309
|
accum.byModel[key] ??= zeroBreakdown();
|
|
246
310
|
addInto(accum.byModel[key], usage);
|
|
247
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
|
+
}
|
|
248
317
|
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
249
318
|
if (Number.isFinite(ms)) {
|
|
250
319
|
const outcome = run ?? { st: 'completed' as RunStatus };
|
|
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 });
|
|
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 });
|
|
252
321
|
}
|
|
253
322
|
const day = ts !== undefined ? dayKey(ts) : null;
|
|
254
323
|
if (day) {
|
|
@@ -348,8 +417,8 @@ export function classifyOpencodeMessage(obj: OpencodeMessage | null | undefined)
|
|
|
348
417
|
};
|
|
349
418
|
}
|
|
350
419
|
|
|
351
|
-
// One OpenCode message file into the shared accum.
|
|
352
|
-
export function processOpencodeFile(accum: SessionAccum, text: string): void {
|
|
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 {
|
|
353
422
|
let obj: unknown;
|
|
354
423
|
try {
|
|
355
424
|
obj = JSON.parse(text);
|
|
@@ -358,20 +427,84 @@ export function processOpencodeFile(accum: SessionAccum, text: string): void {
|
|
|
358
427
|
}
|
|
359
428
|
const parsed = classifyOpencodeMessage(obj as OpencodeMessage);
|
|
360
429
|
if (!parsed) return;
|
|
361
|
-
|
|
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);
|
|
362
439
|
}
|
|
363
440
|
|
|
364
441
|
// Fold `:free`/`-free` suffixes and case variants into one chart key.
|
|
365
442
|
export function canonicalModelId(model: string): string {
|
|
366
443
|
return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
|
|
367
444
|
}
|
|
368
|
-
//
|
|
369
|
-
|
|
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".
|
|
370
499
|
export function displayModelId(model: string): string {
|
|
371
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;
|
|
372
503
|
const cap = (s: string): string => {
|
|
373
504
|
const low = s.toLowerCase();
|
|
374
505
|
if (low === 'openai') return 'OpenAI';
|
|
506
|
+
if (low === 'gpt') return 'GPT';
|
|
507
|
+
if (low === 'swe') return 'SWE';
|
|
375
508
|
if (low === 'ai') return 'AI';
|
|
376
509
|
if (/^\d+[a-z]+$/.test(low)) return low.toUpperCase();
|
|
377
510
|
if (s.length <= 2) return s.toUpperCase();
|
|
@@ -379,20 +512,22 @@ export function displayModelId(model: string): string {
|
|
|
379
512
|
};
|
|
380
513
|
const seg = (s: string): string => s.split('.').map(cap).join('.');
|
|
381
514
|
const words = (s: string): string => s.split(/[-_:]+/).filter(Boolean).map(seg).join('-');
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
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}`;
|
|
385
525
|
}
|
|
386
526
|
|
|
387
527
|
export function importSessionTokens(): SessionTokens {
|
|
388
528
|
const accum = newSessionAccum();
|
|
389
529
|
const files: string[] = [];
|
|
390
530
|
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.
|
|
393
|
-
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
394
|
-
walkJsonl(codexSessionsDir(), files, 2000);
|
|
395
|
-
}
|
|
396
531
|
// OpenCode message bodies are single JSON documents, not JSONL.
|
|
397
532
|
const ocFiles: string[] = [];
|
|
398
533
|
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
|
|
@@ -405,7 +540,11 @@ export function importSessionTokens(): SessionTokens {
|
|
|
405
540
|
} catch {
|
|
406
541
|
continue;
|
|
407
542
|
}
|
|
408
|
-
processOpencodeFile(accum, text);
|
|
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);
|
|
409
548
|
}
|
|
410
549
|
let codexProvider: string | null = null;
|
|
411
550
|
let codexModel: string | null = null;
|
|
@@ -424,7 +563,7 @@ export function importSessionTokens(): SessionTokens {
|
|
|
424
563
|
}, text, hostOfSessionFile(file));
|
|
425
564
|
}
|
|
426
565
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
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) };
|
|
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) };
|
|
428
567
|
}
|
|
429
568
|
|
|
430
569
|
// Which agent wrote a session file, from the directory it sits in.
|
|
@@ -432,6 +571,7 @@ export function hostOfSessionFile(file: string): string {
|
|
|
432
571
|
const norm = file.replace(/\\/g, '/');
|
|
433
572
|
if (norm.includes('/.omp/')) return 'omp';
|
|
434
573
|
if (norm.includes('/.codex/')) return 'codex';
|
|
574
|
+
if (norm.includes('/opencode/')) return 'opencode';
|
|
435
575
|
return 'pi';
|
|
436
576
|
}
|
|
437
577
|
|
|
@@ -474,9 +614,6 @@ export function clearRtkAdoptionCache(): void {
|
|
|
474
614
|
export function readRtkAdoption(): RtkAdoption {
|
|
475
615
|
const files: string[] = [];
|
|
476
616
|
for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
|
|
477
|
-
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
478
|
-
walkJsonl(codexSessionsDir(), files, 2000);
|
|
479
|
-
}
|
|
480
617
|
const live = new Set(files);
|
|
481
618
|
for (const file of adoptionFileCache.keys()) {
|
|
482
619
|
if (!live.has(file)) adoptionFileCache.delete(file);
|
|
@@ -514,6 +651,7 @@ export function processSessionText(accum: SessionAccum, state: { codexProvider:
|
|
|
514
651
|
try {
|
|
515
652
|
const row = JSON.parse(line) as Parameters<typeof classifySessionLine>[0];
|
|
516
653
|
const parsed = classifySessionLine(row);
|
|
654
|
+
const rid = messageRowId(row);
|
|
517
655
|
if (parsed.kind === 'codex_provider') {
|
|
518
656
|
if (parsed.provider) state.codexProvider = parsed.provider;
|
|
519
657
|
continue;
|
|
@@ -526,11 +664,11 @@ export function processSessionText(accum: SessionAccum, state: { codexProvider:
|
|
|
526
664
|
const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
|
|
527
665
|
// The provider alone told you nothing about which model ran.
|
|
528
666
|
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');
|
|
667
|
+
ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' as RunStatus }, undefined, host ?? 'codex', rid);
|
|
530
668
|
continue;
|
|
531
669
|
}
|
|
532
670
|
if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage) continue;
|
|
533
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host);
|
|
671
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid);
|
|
534
672
|
} catch { /* skip corrupt lines */ }
|
|
535
673
|
}
|
|
536
674
|
}
|
|
@@ -540,8 +678,8 @@ export function readRtkRecallDiagnostics(binary: string | null = resolveRtkBinar
|
|
|
540
678
|
try {
|
|
541
679
|
const configPath = process.env.RTK_CONFIG
|
|
542
680
|
|| (process.platform === 'darwin'
|
|
543
|
-
? path.join(
|
|
544
|
-
: 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'));
|
|
545
683
|
let config = '';
|
|
546
684
|
try { config = fs.readFileSync(configPath, 'utf8'); } catch { /* use RTK CLI fallback */ }
|
|
547
685
|
const modeMatch = /^\s*mode\s*=\s*"(sqlite|tee|disabled)"/m.exec(config);
|
|
@@ -553,8 +691,7 @@ export function readRtkRecallDiagnostics(binary: string | null = resolveRtkBinar
|
|
|
553
691
|
return { mode: 'unknown', entries: 0, available: false };
|
|
554
692
|
}
|
|
555
693
|
}
|
|
556
|
-
// Lead binary of a shell string: first segment past `cd` chains and VAR=x
|
|
557
|
-
// 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`.
|
|
558
695
|
const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
|
|
559
696
|
export function leadBinary(command: unknown): string {
|
|
560
697
|
if (typeof command !== 'string' || !command.trim()) return 'bash';
|
|
@@ -617,8 +754,7 @@ export function readUsage(): UsageRow[] {
|
|
|
617
754
|
return rows;
|
|
618
755
|
}
|
|
619
756
|
|
|
620
|
-
// Delete the ledger file and return the rows cleared. Tersio-owned data only:
|
|
621
|
-
// 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.
|
|
622
758
|
export function clearUsageLedger(): number {
|
|
623
759
|
const rows = readUsage().length;
|
|
624
760
|
try {
|