@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.js';
|
|
4
|
+
import { homeDir, isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from '../lib/utils.js';
|
|
7
5
|
import { execFileSync } from 'node:child_process';
|
|
8
6
|
export function ledgerPath() {
|
|
9
7
|
const override = process.env.TERSIO_USAGE_FILE;
|
|
@@ -52,8 +50,7 @@ function stampMs(ts) {
|
|
|
52
50
|
export function durOf(v) {
|
|
53
51
|
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
54
52
|
}
|
|
55
|
-
// Measured cost: usage.cost.total, or a bare number in old fixtures. Anything
|
|
56
|
-
// else means unrecorded, which is deliberately not 0.
|
|
53
|
+
// Measured cost: usage.cost.total, or a bare number in old fixtures. Anything else means unrecorded, which is deliberately not 0.
|
|
57
54
|
export function costOf(usage) {
|
|
58
55
|
const c = usage.cost;
|
|
59
56
|
if (typeof c === 'number')
|
|
@@ -65,8 +62,7 @@ export function costOf(usage) {
|
|
|
65
62
|
}
|
|
66
63
|
return undefined;
|
|
67
64
|
}
|
|
68
|
-
// First line only: real messages carry multi-line provider errors, and this
|
|
69
|
-
// ends up in a tooltip.
|
|
65
|
+
// First line only: real messages carry multi-line provider errors, and this ends up in a tooltip.
|
|
70
66
|
export function statusOf(msg) {
|
|
71
67
|
const stop = typeof msg.stopReason === 'string' ? msg.stopReason : '';
|
|
72
68
|
const note = typeof msg.errorMessage === 'string' && msg.errorMessage.trim()
|
|
@@ -91,26 +87,16 @@ export function sessionsDirs() {
|
|
|
91
87
|
const override = process.env.TERSIO_SESSIONS_DIR;
|
|
92
88
|
if (override)
|
|
93
89
|
return [override];
|
|
94
|
-
// Both hosts, not just the running one: a session on pi is missing from the
|
|
95
|
-
// dashboard entirely if we stop at the first directory that exists.
|
|
90
|
+
// 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.
|
|
96
91
|
const dirs = isPiProcess()
|
|
97
|
-
? [path.join(piAgentDir(), 'sessions'), path.join(
|
|
98
|
-
: [path.join(
|
|
92
|
+
? [path.join(piAgentDir(), 'sessions'), path.join(homeDir(), '.omp', 'agent', 'sessions')]
|
|
93
|
+
: [path.join(homeDir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
|
|
99
94
|
const found = dirs.filter((dir) => fs.existsSync(dir));
|
|
100
95
|
return found.length > 0 ? found : [dirs[0]];
|
|
101
96
|
}
|
|
102
97
|
export function sessionsDir() {
|
|
103
98
|
return sessionsDirs()[0];
|
|
104
99
|
}
|
|
105
|
-
export function codexSessionsDir() {
|
|
106
|
-
const override = process.env.TERSIO_CODEX_DIR;
|
|
107
|
-
if (override)
|
|
108
|
-
return override;
|
|
109
|
-
const codexHome = process.env.CODEX_HOME;
|
|
110
|
-
if (codexHome)
|
|
111
|
-
return path.join(codexHome, 'sessions');
|
|
112
|
-
return path.join(os.homedir(), '.codex', 'sessions');
|
|
113
|
-
}
|
|
114
100
|
// One JSON file per message; probe XDG, then local, then macOS default.
|
|
115
101
|
export function opencodeSessionsDir() {
|
|
116
102
|
const override = process.env.TERSIO_OPENCODE_DIR;
|
|
@@ -119,13 +105,88 @@ export function opencodeSessionsDir() {
|
|
|
119
105
|
const xdg = process.env.XDG_DATA_HOME;
|
|
120
106
|
if (xdg && xdg.trim() !== '')
|
|
121
107
|
return path.join(xdg, 'opencode', 'storage', 'message');
|
|
122
|
-
const local = path.join(
|
|
108
|
+
const local = path.join(homeDir(), '.local', 'share', 'opencode', 'storage', 'message');
|
|
109
|
+
try {
|
|
110
|
+
if (fs.existsSync(local))
|
|
111
|
+
return local;
|
|
112
|
+
}
|
|
113
|
+
catch { /* fall through to the platform default */ }
|
|
114
|
+
return path.join(homeDir(), 'Library', 'Application Support', 'opencode', 'storage', 'message');
|
|
115
|
+
}
|
|
116
|
+
// OpenCode v2 keeps messages in sqlite; the JSON tree above is only the pre-v2 layout.
|
|
117
|
+
export function opencodeDbPath() {
|
|
118
|
+
const override = process.env.TERSIO_OPENCODE_DB;
|
|
119
|
+
if (override)
|
|
120
|
+
return override;
|
|
121
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
122
|
+
if (xdg && xdg.trim() !== '')
|
|
123
|
+
return path.join(xdg, 'opencode', 'opencode.db');
|
|
124
|
+
const local = path.join(homeDir(), '.local', 'share', 'opencode', 'opencode.db');
|
|
123
125
|
try {
|
|
124
126
|
if (fs.existsSync(local))
|
|
125
127
|
return local;
|
|
126
128
|
}
|
|
127
129
|
catch { /* fall through to the platform default */ }
|
|
128
|
-
return path.join(
|
|
130
|
+
return path.join(homeDir(), 'Library', 'Application Support', 'opencode', 'opencode.db');
|
|
131
|
+
}
|
|
132
|
+
// Two on-disk shapes: legacy `message` (role/modelID/providerID live inside `data`),
|
|
133
|
+
// current `session_message` (role in the `type` column, model under data.model).
|
|
134
|
+
// Scalars only: the `data` blob holds newlines and tabs, which the sqlite3 CLI would split.
|
|
135
|
+
const OC_DB_ORDER = ' ORDER BY time_created;';
|
|
136
|
+
const OC_DB_COLUMNS = 'json_extract(data,\'$.time.created\'), json_extract(data,\'$.time.completed\'),' +
|
|
137
|
+
' json_extract(data,\'$.tokens.input\'), json_extract(data,\'$.tokens.output\'),' +
|
|
138
|
+
' json_extract(data,\'$.tokens.cache.read\'), json_extract(data,\'$.tokens.cache.write\'), json_extract(data,\'$.cost\')';
|
|
139
|
+
const ocDbLegacy = (since) => `SELECT id, time_created, json_extract(data,'$.role'), json_extract(data,'$.providerID'), json_extract(data,'$.modelID'), ${OC_DB_COLUMNS}` +
|
|
140
|
+
` FROM message WHERE json_extract(data,'$.role')='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
|
|
141
|
+
const ocDbCurrent = (since) => `SELECT id, time_created, 'assistant', json_extract(data,'$.model.providerID'), json_extract(data,'$.model.id'), ${OC_DB_COLUMNS}` +
|
|
142
|
+
` FROM session_message WHERE type='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
|
|
143
|
+
// A re-scan window covers tokens that land after the row was created, so a late fill is not lost.
|
|
144
|
+
export const OPENCODE_DB_OVERLAP_MS = 3_600_000;
|
|
145
|
+
// execFileSync caps output at 1MB; a busy table clears it in one SELECT.
|
|
146
|
+
export const SQLITE_READ_BUFFER = 50 * 1024 * 1024;
|
|
147
|
+
// Empty when sqlite3 is absent or the db is unreadable; that is a sync skip, not a failure.
|
|
148
|
+
export function readOpencodeDbRows(db, sinceMs) {
|
|
149
|
+
if (!Number.isFinite(sinceMs))
|
|
150
|
+
return [];
|
|
151
|
+
try {
|
|
152
|
+
if (!fs.existsSync(db))
|
|
153
|
+
return [];
|
|
154
|
+
const since = Math.max(0, Math.floor(sinceMs));
|
|
155
|
+
// Separate calls: a db holding only one table must not cancel the other.
|
|
156
|
+
const run = (sql) => execFileSync('sqlite3', ['-separator', '\t', '-readonly', db, sql], { encoding: 'utf8', timeout: 30000, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: SQLITE_READ_BUFFER });
|
|
157
|
+
let out = '';
|
|
158
|
+
try {
|
|
159
|
+
out += run(ocDbLegacy(since));
|
|
160
|
+
}
|
|
161
|
+
catch { /* no legacy table or unreadable */ }
|
|
162
|
+
try {
|
|
163
|
+
out += run(ocDbCurrent(since));
|
|
164
|
+
}
|
|
165
|
+
catch { /* no session_message table or unreadable */ }
|
|
166
|
+
const rows = [];
|
|
167
|
+
for (const line of out.split('\n')) {
|
|
168
|
+
if (!line.trim())
|
|
169
|
+
continue;
|
|
170
|
+
const [id, created, role, providerID, modelID, tCreated, tCompleted, input, output, cacheRead, cacheWrite, cost] = line.split('\t');
|
|
171
|
+
const n = (v) => (v !== undefined && v !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined);
|
|
172
|
+
rows.push({
|
|
173
|
+
id,
|
|
174
|
+
created: Number(created),
|
|
175
|
+
row: {
|
|
176
|
+
role,
|
|
177
|
+
modelID,
|
|
178
|
+
providerID,
|
|
179
|
+
tokens: { input: n(input), output: n(output), cache: { read: n(cacheRead), write: n(cacheWrite) } },
|
|
180
|
+
time: { created: n(tCreated), completed: n(tCompleted) },
|
|
181
|
+
cost: n(cost),
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return rows;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
129
190
|
}
|
|
130
191
|
export function dayKey(ts) {
|
|
131
192
|
const ms = typeof ts === 'number' ? ts : Date.parse(ts);
|
|
@@ -153,9 +214,8 @@ export function walkJsonl(dir, out, cap, ext = '.jsonl') {
|
|
|
153
214
|
out.push(full);
|
|
154
215
|
}
|
|
155
216
|
}
|
|
156
|
-
// Recent
|
|
157
|
-
|
|
158
|
-
export const RECENT_LIMIT = 200;
|
|
217
|
+
// Recent rows are a newest-first window over lifetime history; aggregates keep everything.
|
|
218
|
+
export const RECENT_LIMIT = 2000;
|
|
159
219
|
const FREE_SUFFIX = /(?::free|-free)$/i;
|
|
160
220
|
const RTK_ELIGIBLE_HEADS = new Set([
|
|
161
221
|
'rtk', 'git', 'grep', 'rg', 'find', 'cat', 'ls', 'tree', 'diff', 'log',
|
|
@@ -163,9 +223,9 @@ const RTK_ELIGIBLE_HEADS = new Set([
|
|
|
163
223
|
'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
|
|
164
224
|
]);
|
|
165
225
|
export function newSessionAccum() {
|
|
166
|
-
return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
|
|
226
|
+
return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
|
|
167
227
|
}
|
|
168
|
-
export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host) {
|
|
228
|
+
export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host, id) {
|
|
169
229
|
const watermark = readResetWatermark();
|
|
170
230
|
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
171
231
|
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
|
|
@@ -175,10 +235,15 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames,
|
|
|
175
235
|
accum.byModel[key] ??= zeroBreakdown();
|
|
176
236
|
addInto(accum.byModel[key], usage);
|
|
177
237
|
accum.byModelMessages[key] = (accum.byModelMessages[key] ?? 0) + 1;
|
|
238
|
+
if (host) {
|
|
239
|
+
const hs = (accum.byHost[key] ??= {});
|
|
240
|
+
hs[host] ??= zeroBreakdown();
|
|
241
|
+
addInto(hs[host], usage);
|
|
242
|
+
}
|
|
178
243
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
179
244
|
if (Number.isFinite(ms)) {
|
|
180
245
|
const outcome = run ?? { st: 'completed' };
|
|
181
|
-
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 });
|
|
246
|
+
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 });
|
|
182
247
|
}
|
|
183
248
|
const day = ts !== undefined ? dayKey(ts) : null;
|
|
184
249
|
if (day) {
|
|
@@ -274,8 +339,8 @@ export function classifyOpencodeMessage(obj) {
|
|
|
274
339
|
durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
|
|
275
340
|
};
|
|
276
341
|
}
|
|
277
|
-
// One OpenCode message file into the shared accum.
|
|
278
|
-
export function processOpencodeFile(accum, text) {
|
|
342
|
+
// One OpenCode message file into the shared accum. The msg id is the file name.
|
|
343
|
+
export function processOpencodeFile(accum, text, file) {
|
|
279
344
|
let obj;
|
|
280
345
|
try {
|
|
281
346
|
obj = JSON.parse(text);
|
|
@@ -286,20 +351,82 @@ export function processOpencodeFile(accum, text) {
|
|
|
286
351
|
const parsed = classifyOpencodeMessage(obj);
|
|
287
352
|
if (!parsed)
|
|
288
353
|
return;
|
|
289
|
-
|
|
354
|
+
const id = file ? path.basename(file).replace(/\.[^.]+$/, '') || undefined : undefined;
|
|
355
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode', id);
|
|
356
|
+
}
|
|
357
|
+
// One v2 message row into the shared accum. The msg id is the row primary key.
|
|
358
|
+
export function ingestOpencodeDbRow(accum, row) {
|
|
359
|
+
const parsed = classifyOpencodeMessage(row.row);
|
|
360
|
+
if (!parsed)
|
|
361
|
+
return;
|
|
362
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode', row.id);
|
|
290
363
|
}
|
|
291
364
|
// Fold `:free`/`-free` suffixes and case variants into one chart key.
|
|
292
365
|
export function canonicalModelId(model) {
|
|
293
366
|
return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
|
|
294
367
|
}
|
|
295
|
-
//
|
|
296
|
-
|
|
368
|
+
// Transcript row id when the host provides one; anything else is not an id.
|
|
369
|
+
export function messageRowId(row) {
|
|
370
|
+
const id = row.id;
|
|
371
|
+
return typeof id === 'string' && id ? id : undefined;
|
|
372
|
+
}
|
|
373
|
+
// Vendor names mirror the dashboard map; CLI needs names only. Family first:
|
|
374
|
+
// gateway segments shadow it (minimaxai holds xai, gemma rides nvidia).
|
|
375
|
+
const VENDOR_RES = [
|
|
376
|
+
[/inclusionai|ling-/i, 'InclusionAI'],
|
|
377
|
+
[/minimax/i, 'MiniMax'],
|
|
378
|
+
[/grok|xai/i, 'xAI'],
|
|
379
|
+
[/stealth|space-bunny/i, 'Stealth'],
|
|
380
|
+
[/openai|codex|gpt-|o1/i, 'OpenAI'],
|
|
381
|
+
[/muse|llama/i, 'Meta'],
|
|
382
|
+
[/deepseek/i, 'DeepSeek'],
|
|
383
|
+
[/qwen|qwq/i, 'Alibaba'],
|
|
384
|
+
[/glm|z-ai|zhipu/i, 'Z.ai'],
|
|
385
|
+
[/mimo/i, 'Xiaomi'],
|
|
386
|
+
[/kimi|moonshot/i, 'Moonshot'],
|
|
387
|
+
[/mistral/i, 'Mistral'],
|
|
388
|
+
[/claude|anthropic/i, 'Anthropic'],
|
|
389
|
+
[/gemini|google|gemma/i, 'Google'],
|
|
390
|
+
[/nemotron|nvidia/i, 'NVIDIA'],
|
|
391
|
+
[/devin|cognition|^swe[-/]/i, 'Cognition'],
|
|
392
|
+
];
|
|
393
|
+
function modelVendor(model) {
|
|
394
|
+
for (const [re, name] of VENDOR_RES) {
|
|
395
|
+
if (re.test(model))
|
|
396
|
+
return name;
|
|
397
|
+
}
|
|
398
|
+
return 'Other';
|
|
399
|
+
}
|
|
400
|
+
// Gateway variants of one model share a row under the display label.
|
|
401
|
+
export function foldModelsByLabel(byModel, byModelMessages) {
|
|
402
|
+
const folded = {};
|
|
403
|
+
const messages = {};
|
|
404
|
+
for (const [model, t] of Object.entries(byModel)) {
|
|
405
|
+
const label = displayModelId(model);
|
|
406
|
+
folded[label] ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
407
|
+
const into = folded[label];
|
|
408
|
+
into.input += t.input;
|
|
409
|
+
into.output += t.output;
|
|
410
|
+
into.cacheRead += t.cacheRead;
|
|
411
|
+
into.cacheWrite += t.cacheWrite;
|
|
412
|
+
messages[label] = (messages[label] ?? 0) + (byModelMessages[model] ?? 0);
|
|
413
|
+
}
|
|
414
|
+
return { byModel: folded, byModelMessages: messages };
|
|
415
|
+
}
|
|
416
|
+
// Vendor-first labels, same fold as the dashboard: "Anthropic - Claude - Haiku-4.5".
|
|
297
417
|
export function displayModelId(model) {
|
|
298
418
|
const bare = model.replace(FREE_SUFFIX, '');
|
|
419
|
+
// The report already folds keys; folding again would lowercase the caps.
|
|
420
|
+
if (bare.includes(' - '))
|
|
421
|
+
return bare;
|
|
299
422
|
const cap = (s) => {
|
|
300
423
|
const low = s.toLowerCase();
|
|
301
424
|
if (low === 'openai')
|
|
302
425
|
return 'OpenAI';
|
|
426
|
+
if (low === 'gpt')
|
|
427
|
+
return 'GPT';
|
|
428
|
+
if (low === 'swe')
|
|
429
|
+
return 'SWE';
|
|
303
430
|
if (low === 'ai')
|
|
304
431
|
return 'AI';
|
|
305
432
|
if (/^\d+[a-z]+$/.test(low))
|
|
@@ -310,21 +437,25 @@ export function displayModelId(model) {
|
|
|
310
437
|
};
|
|
311
438
|
const seg = (s) => s.split('.').map(cap).join('.');
|
|
312
439
|
const words = (s) => s.split(/[-_:]+/).filter(Boolean).map(seg).join('-');
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
440
|
+
const segs = bare.split('/').filter(Boolean);
|
|
441
|
+
const tail = segs[segs.length - 1] ?? bare;
|
|
442
|
+
// One free model under two spellings; fold the alpha variant onto it.
|
|
443
|
+
const pretty = /^space-bunny(-alpha)?$/i.test(tail) ? 'Space-Bunny' : words(tail);
|
|
444
|
+
const vendor = modelVendor(bare);
|
|
445
|
+
if (vendor === 'Other')
|
|
446
|
+
return segs.length > 1 ? `${words(segs[segs.length - 2])} - ${pretty}` : pretty;
|
|
447
|
+
const claude = pretty.match(/^claude[-_](.+)$/i);
|
|
448
|
+
if (vendor === 'Anthropic' && claude)
|
|
449
|
+
return `Anthropic - Claude - ${claude[1]}`;
|
|
450
|
+
if (pretty.toLowerCase().startsWith(vendor.toLowerCase()))
|
|
451
|
+
return pretty;
|
|
452
|
+
return `${vendor} - ${pretty}`;
|
|
317
453
|
}
|
|
318
454
|
export function importSessionTokens() {
|
|
319
455
|
const accum = newSessionAccum();
|
|
320
456
|
const files = [];
|
|
321
457
|
for (const dir of sessionsDirs())
|
|
322
458
|
walkJsonl(dir, files, 2000);
|
|
323
|
-
// An override means an isolated environment (tests, fixtures): only walk the
|
|
324
|
-
// real codex dir when one is explicitly set.
|
|
325
|
-
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
326
|
-
walkJsonl(codexSessionsDir(), files, 2000);
|
|
327
|
-
}
|
|
328
459
|
// OpenCode message bodies are single JSON documents, not JSONL.
|
|
329
460
|
const ocFiles = [];
|
|
330
461
|
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
|
|
@@ -338,7 +469,12 @@ export function importSessionTokens() {
|
|
|
338
469
|
catch {
|
|
339
470
|
continue;
|
|
340
471
|
}
|
|
341
|
-
processOpencodeFile(accum, text);
|
|
472
|
+
processOpencodeFile(accum, text, file);
|
|
473
|
+
}
|
|
474
|
+
// v2 hosts write sqlite, not the JSON tree above.
|
|
475
|
+
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DB !== undefined) {
|
|
476
|
+
for (const row of readOpencodeDbRows(opencodeDbPath(), 0))
|
|
477
|
+
ingestOpencodeDbRow(accum, row);
|
|
342
478
|
}
|
|
343
479
|
let codexProvider = null;
|
|
344
480
|
let codexModel = null;
|
|
@@ -358,7 +494,7 @@ export function importSessionTokens() {
|
|
|
358
494
|
}, text, hostOfSessionFile(file));
|
|
359
495
|
}
|
|
360
496
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
361
|
-
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) };
|
|
497
|
+
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) };
|
|
362
498
|
}
|
|
363
499
|
// Which agent wrote a session file, from the directory it sits in.
|
|
364
500
|
export function hostOfSessionFile(file) {
|
|
@@ -367,6 +503,8 @@ export function hostOfSessionFile(file) {
|
|
|
367
503
|
return 'omp';
|
|
368
504
|
if (norm.includes('/.codex/'))
|
|
369
505
|
return 'codex';
|
|
506
|
+
if (norm.includes('/opencode/'))
|
|
507
|
+
return 'opencode';
|
|
370
508
|
return 'pi';
|
|
371
509
|
}
|
|
372
510
|
const adoptionFileCache = new Map();
|
|
@@ -409,9 +547,6 @@ export function readRtkAdoption() {
|
|
|
409
547
|
const files = [];
|
|
410
548
|
for (const dir of sessionsDirs())
|
|
411
549
|
walkJsonl(dir, files, 2000);
|
|
412
|
-
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
413
|
-
walkJsonl(codexSessionsDir(), files, 2000);
|
|
414
|
-
}
|
|
415
550
|
const live = new Set(files);
|
|
416
551
|
for (const file of adoptionFileCache.keys()) {
|
|
417
552
|
if (!live.has(file))
|
|
@@ -453,6 +588,7 @@ export function processSessionText(accum, state, text, host) {
|
|
|
453
588
|
try {
|
|
454
589
|
const row = JSON.parse(line);
|
|
455
590
|
const parsed = classifySessionLine(row);
|
|
591
|
+
const rid = messageRowId(row);
|
|
456
592
|
if (parsed.kind === 'codex_provider') {
|
|
457
593
|
if (parsed.provider)
|
|
458
594
|
state.codexProvider = parsed.provider;
|
|
@@ -467,12 +603,12 @@ export function processSessionText(accum, state, text, host) {
|
|
|
467
603
|
const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
|
|
468
604
|
// The provider alone told you nothing about which model ran.
|
|
469
605
|
const model = state.codexModel ? `${base}/${state.codexModel}` : base;
|
|
470
|
-
ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' }, undefined, host ?? 'codex');
|
|
606
|
+
ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' }, undefined, host ?? 'codex', rid);
|
|
471
607
|
continue;
|
|
472
608
|
}
|
|
473
609
|
if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
|
|
474
610
|
continue;
|
|
475
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host);
|
|
611
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid);
|
|
476
612
|
}
|
|
477
613
|
catch { /* skip corrupt lines */ }
|
|
478
614
|
}
|
|
@@ -483,8 +619,8 @@ export function readRtkRecallDiagnostics(binary = resolveRtkBinary()) {
|
|
|
483
619
|
try {
|
|
484
620
|
const configPath = process.env.RTK_CONFIG
|
|
485
621
|
|| (process.platform === 'darwin'
|
|
486
|
-
? path.join(
|
|
487
|
-
: path.join(process.env.XDG_DATA_HOME || path.join(
|
|
622
|
+
? path.join(homeDir(), 'Library', 'Application Support', 'rtk', 'config.toml')
|
|
623
|
+
: path.join(process.env.XDG_DATA_HOME || path.join(homeDir(), '.local', 'share'), 'rtk', 'config.toml'));
|
|
488
624
|
let config = '';
|
|
489
625
|
try {
|
|
490
626
|
config = fs.readFileSync(configPath, 'utf8');
|
|
@@ -500,8 +636,7 @@ export function readRtkRecallDiagnostics(binary = resolveRtkBinary()) {
|
|
|
500
636
|
return { mode: 'unknown', entries: 0, available: false };
|
|
501
637
|
}
|
|
502
638
|
}
|
|
503
|
-
// Lead binary of a shell string: first segment past `cd` chains and VAR=x
|
|
504
|
-
// assignments. Falls back to `bash`.
|
|
639
|
+
// Lead binary of a shell string: first segment past `cd` chains and VAR=x assignments. Falls back to `bash`.
|
|
505
640
|
const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
|
|
506
641
|
export function leadBinary(command) {
|
|
507
642
|
if (typeof command !== 'string' || !command.trim())
|
|
@@ -562,8 +697,7 @@ export function readUsage() {
|
|
|
562
697
|
}
|
|
563
698
|
return rows;
|
|
564
699
|
}
|
|
565
|
-
// Delete the ledger file and return the rows cleared. Tersio-owned data only:
|
|
566
|
-
// transcripts and the RTK database are never touched.
|
|
700
|
+
// Delete the ledger file and return the rows cleared. Tersio-owned data only: transcripts and the RTK database are never touched.
|
|
567
701
|
export function clearUsageLedger() {
|
|
568
702
|
const rows = readUsage().length;
|
|
569
703
|
try {
|