@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.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;
|
|
@@ -46,11 +44,13 @@ export function appendUsage(kind, detail) {
|
|
|
46
44
|
function zeroBreakdown() {
|
|
47
45
|
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
48
46
|
}
|
|
47
|
+
function stampMs(ts) {
|
|
48
|
+
return ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
49
|
+
}
|
|
49
50
|
export function durOf(v) {
|
|
50
51
|
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
51
52
|
}
|
|
52
|
-
// Measured cost: usage.cost.total, or a bare number in old fixtures. Anything
|
|
53
|
-
// 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.
|
|
54
54
|
export function costOf(usage) {
|
|
55
55
|
const c = usage.cost;
|
|
56
56
|
if (typeof c === 'number')
|
|
@@ -62,8 +62,7 @@ export function costOf(usage) {
|
|
|
62
62
|
}
|
|
63
63
|
return undefined;
|
|
64
64
|
}
|
|
65
|
-
// First line only: real messages carry multi-line provider errors, and this
|
|
66
|
-
// ends up in a tooltip.
|
|
65
|
+
// First line only: real messages carry multi-line provider errors, and this ends up in a tooltip.
|
|
67
66
|
export function statusOf(msg) {
|
|
68
67
|
const stop = typeof msg.stopReason === 'string' ? msg.stopReason : '';
|
|
69
68
|
const note = typeof msg.errorMessage === 'string' && msg.errorMessage.trim()
|
|
@@ -84,24 +83,110 @@ function addInto(into, u) {
|
|
|
84
83
|
into[k] += Math.floor(v);
|
|
85
84
|
}
|
|
86
85
|
}
|
|
87
|
-
export function
|
|
86
|
+
export function sessionsDirs() {
|
|
88
87
|
const override = process.env.TERSIO_SESSIONS_DIR;
|
|
89
88
|
if (override)
|
|
90
|
-
return override;
|
|
91
|
-
//
|
|
89
|
+
return [override];
|
|
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.
|
|
92
91
|
const dirs = isPiProcess()
|
|
93
|
-
? [path.join(piAgentDir(), 'sessions'), path.join(
|
|
94
|
-
: [path.join(
|
|
95
|
-
|
|
92
|
+
? [path.join(piAgentDir(), 'sessions'), path.join(homeDir(), '.omp', 'agent', 'sessions')]
|
|
93
|
+
: [path.join(homeDir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
|
|
94
|
+
const found = dirs.filter((dir) => fs.existsSync(dir));
|
|
95
|
+
return found.length > 0 ? found : [dirs[0]];
|
|
96
|
+
}
|
|
97
|
+
export function sessionsDir() {
|
|
98
|
+
return sessionsDirs()[0];
|
|
96
99
|
}
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
// One JSON file per message; probe XDG, then local, then macOS default.
|
|
101
|
+
export function opencodeSessionsDir() {
|
|
102
|
+
const override = process.env.TERSIO_OPENCODE_DIR;
|
|
99
103
|
if (override)
|
|
100
104
|
return override;
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
return path.join(
|
|
104
|
-
|
|
105
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
106
|
+
if (xdg && xdg.trim() !== '')
|
|
107
|
+
return path.join(xdg, 'opencode', 'storage', 'message');
|
|
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');
|
|
125
|
+
try {
|
|
126
|
+
if (fs.existsSync(local))
|
|
127
|
+
return local;
|
|
128
|
+
}
|
|
129
|
+
catch { /* fall through to the platform default */ }
|
|
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
|
+
}
|
|
105
190
|
}
|
|
106
191
|
export function dayKey(ts) {
|
|
107
192
|
const ms = typeof ts === 'number' ? ts : Date.parse(ts);
|
|
@@ -111,7 +196,7 @@ export function dayKey(ts) {
|
|
|
111
196
|
const p = (n) => String(n).padStart(2, '0');
|
|
112
197
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
113
198
|
}
|
|
114
|
-
export function walkJsonl(dir, out, cap) {
|
|
199
|
+
export function walkJsonl(dir, out, cap, ext = '.jsonl') {
|
|
115
200
|
let entries;
|
|
116
201
|
try {
|
|
117
202
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -124,14 +209,13 @@ export function walkJsonl(dir, out, cap) {
|
|
|
124
209
|
return;
|
|
125
210
|
const full = path.join(dir, e.name);
|
|
126
211
|
if (e.isDirectory())
|
|
127
|
-
walkJsonl(full, out, cap);
|
|
128
|
-
else if (e.isFile() && e.name.endsWith(
|
|
212
|
+
walkJsonl(full, out, cap, ext);
|
|
213
|
+
else if (e.isFile() && e.name.endsWith(ext))
|
|
129
214
|
out.push(full);
|
|
130
215
|
}
|
|
131
216
|
}
|
|
132
|
-
// Recent
|
|
133
|
-
|
|
134
|
-
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;
|
|
135
219
|
const FREE_SUFFIX = /(?::free|-free)$/i;
|
|
136
220
|
const RTK_ELIGIBLE_HEADS = new Set([
|
|
137
221
|
'rtk', 'git', 'grep', 'rg', 'find', 'cat', 'ls', 'tree', 'diff', 'log',
|
|
@@ -139,9 +223,9 @@ const RTK_ELIGIBLE_HEADS = new Set([
|
|
|
139
223
|
'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
|
|
140
224
|
]);
|
|
141
225
|
export function newSessionAccum() {
|
|
142
|
-
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: [] };
|
|
143
227
|
}
|
|
144
|
-
export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames) {
|
|
228
|
+
export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host, id) {
|
|
145
229
|
const watermark = readResetWatermark();
|
|
146
230
|
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
147
231
|
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
|
|
@@ -151,10 +235,15 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames)
|
|
|
151
235
|
accum.byModel[key] ??= zeroBreakdown();
|
|
152
236
|
addInto(accum.byModel[key], usage);
|
|
153
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
|
+
}
|
|
154
243
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
155
244
|
if (Number.isFinite(ms)) {
|
|
156
245
|
const outcome = run ?? { st: 'completed' };
|
|
157
|
-
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 });
|
|
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 });
|
|
158
247
|
}
|
|
159
248
|
const day = ts !== undefined ? dayKey(ts) : null;
|
|
160
249
|
if (day) {
|
|
@@ -179,6 +268,10 @@ export function classifySessionLine(row) {
|
|
|
179
268
|
if (payload && typeof payload === 'object') {
|
|
180
269
|
if (row.type === 'session_meta' && typeof payload.model_provider === 'string')
|
|
181
270
|
return { kind: 'codex_provider', provider: payload.model_provider };
|
|
271
|
+
// A Codex transcript states the model once per turn, in turn_context.
|
|
272
|
+
if (row.type === 'turn_context' && typeof payload.model === 'string' && payload.model) {
|
|
273
|
+
return { kind: 'codex_model', model: payload.model };
|
|
274
|
+
}
|
|
182
275
|
if (payload.type === 'token_count') {
|
|
183
276
|
const last = payload.info?.last_token_usage;
|
|
184
277
|
if (last) {
|
|
@@ -192,7 +285,10 @@ export function classifySessionLine(row) {
|
|
|
192
285
|
if (!msg || msg.role !== 'assistant' || !msg.usage)
|
|
193
286
|
return { kind: 'skip' };
|
|
194
287
|
const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
|
|
195
|
-
|
|
288
|
+
// pi omits duration, so derive Elapsed/Speed from the timestamp pair.
|
|
289
|
+
const startMs = typeof msg.timestamp === 'number' ? msg.timestamp : undefined;
|
|
290
|
+
const endMs = typeof msg.completedAt === 'number' ? msg.completedAt : stampMs(row.timestamp);
|
|
291
|
+
const computedDur = startMs !== undefined && Number.isFinite(endMs) && endMs > startMs ? endMs - startMs : undefined;
|
|
196
292
|
const tools = [];
|
|
197
293
|
for (const part of msg.content ?? []) {
|
|
198
294
|
if (part?.type !== 'toolCall' || typeof part.name !== 'string' || !part.name)
|
|
@@ -206,18 +302,131 @@ export function classifySessionLine(row) {
|
|
|
206
302
|
}
|
|
207
303
|
return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
|
|
208
304
|
}
|
|
305
|
+
// True when at least one token bucket holds a positive finite count.
|
|
306
|
+
export function hasPositiveUsage(usage) {
|
|
307
|
+
for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
|
|
308
|
+
const v = usage[k];
|
|
309
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0)
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
// One OpenCode message file; assistant rows carry the token buckets.
|
|
315
|
+
export function classifyOpencodeMessage(obj) {
|
|
316
|
+
if (!obj || typeof obj !== 'object' || obj.role !== 'assistant')
|
|
317
|
+
return null;
|
|
318
|
+
const t = obj.tokens;
|
|
319
|
+
if (!t || typeof t !== 'object')
|
|
320
|
+
return null;
|
|
321
|
+
const usage = {
|
|
322
|
+
input: t.input,
|
|
323
|
+
output: t.output,
|
|
324
|
+
cacheRead: t.cache?.read,
|
|
325
|
+
cacheWrite: t.cache?.write,
|
|
326
|
+
cost: obj.cost,
|
|
327
|
+
};
|
|
328
|
+
if (!hasPositiveUsage(usage))
|
|
329
|
+
return null;
|
|
330
|
+
const provider = typeof obj.providerID === 'string' && obj.providerID ? obj.providerID : 'unknown';
|
|
331
|
+
const model = typeof obj.modelID === 'string' && obj.modelID ? obj.modelID : 'unknown';
|
|
332
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
|
|
333
|
+
const created = num(obj.time?.created);
|
|
334
|
+
const completed = num(obj.time?.completed);
|
|
335
|
+
return {
|
|
336
|
+
model: `opencode/${provider}/${model}`,
|
|
337
|
+
usage,
|
|
338
|
+
ms: created,
|
|
339
|
+
durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
// One OpenCode message file into the shared accum. The msg id is the file name.
|
|
343
|
+
export function processOpencodeFile(accum, text, file) {
|
|
344
|
+
let obj;
|
|
345
|
+
try {
|
|
346
|
+
obj = JSON.parse(text);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const parsed = classifyOpencodeMessage(obj);
|
|
352
|
+
if (!parsed)
|
|
353
|
+
return;
|
|
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);
|
|
363
|
+
}
|
|
209
364
|
// Fold `:free`/`-free` suffixes and case variants into one chart key.
|
|
210
365
|
export function canonicalModelId(model) {
|
|
211
366
|
return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
|
|
212
367
|
}
|
|
213
|
-
//
|
|
214
|
-
|
|
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".
|
|
215
417
|
export function displayModelId(model) {
|
|
216
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;
|
|
217
422
|
const cap = (s) => {
|
|
218
423
|
const low = s.toLowerCase();
|
|
219
424
|
if (low === 'openai')
|
|
220
425
|
return 'OpenAI';
|
|
426
|
+
if (low === 'gpt')
|
|
427
|
+
return 'GPT';
|
|
428
|
+
if (low === 'swe')
|
|
429
|
+
return 'SWE';
|
|
221
430
|
if (low === 'ai')
|
|
222
431
|
return 'AI';
|
|
223
432
|
if (/^\d+[a-z]+$/.test(low))
|
|
@@ -228,21 +437,47 @@ export function displayModelId(model) {
|
|
|
228
437
|
};
|
|
229
438
|
const seg = (s) => s.split('.').map(cap).join('.');
|
|
230
439
|
const words = (s) => s.split(/[-_:]+/).filter(Boolean).map(seg).join('-');
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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}`;
|
|
235
453
|
}
|
|
236
454
|
export function importSessionTokens() {
|
|
237
455
|
const accum = newSessionAccum();
|
|
238
456
|
const files = [];
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
457
|
+
for (const dir of sessionsDirs())
|
|
458
|
+
walkJsonl(dir, files, 2000);
|
|
459
|
+
// OpenCode message bodies are single JSON documents, not JSONL.
|
|
460
|
+
const ocFiles = [];
|
|
461
|
+
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
|
|
462
|
+
walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
|
|
463
|
+
}
|
|
464
|
+
for (const file of ocFiles) {
|
|
465
|
+
let text;
|
|
466
|
+
try {
|
|
467
|
+
text = fs.readFileSync(file, 'utf8');
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
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);
|
|
244
478
|
}
|
|
245
479
|
let codexProvider = null;
|
|
480
|
+
let codexModel = null;
|
|
246
481
|
for (const file of files) {
|
|
247
482
|
let text;
|
|
248
483
|
try {
|
|
@@ -251,10 +486,26 @@ export function importSessionTokens() {
|
|
|
251
486
|
catch {
|
|
252
487
|
continue;
|
|
253
488
|
}
|
|
254
|
-
processSessionText(accum, {
|
|
489
|
+
processSessionText(accum, {
|
|
490
|
+
get codexProvider() { return codexProvider; },
|
|
491
|
+
set codexProvider(v) { codexProvider = v; },
|
|
492
|
+
get codexModel() { return codexModel; },
|
|
493
|
+
set codexModel(v) { codexModel = v; },
|
|
494
|
+
}, text, hostOfSessionFile(file));
|
|
255
495
|
}
|
|
256
496
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
257
|
-
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) };
|
|
498
|
+
}
|
|
499
|
+
// Which agent wrote a session file, from the directory it sits in.
|
|
500
|
+
export function hostOfSessionFile(file) {
|
|
501
|
+
const norm = file.replace(/\\/g, '/');
|
|
502
|
+
if (norm.includes('/.omp/'))
|
|
503
|
+
return 'omp';
|
|
504
|
+
if (norm.includes('/.codex/'))
|
|
505
|
+
return 'codex';
|
|
506
|
+
if (norm.includes('/opencode/'))
|
|
507
|
+
return 'opencode';
|
|
508
|
+
return 'pi';
|
|
258
509
|
}
|
|
259
510
|
const adoptionFileCache = new Map();
|
|
260
511
|
function parseAdoptionFile(file) {
|
|
@@ -294,10 +545,8 @@ export function clearRtkAdoptionCache() {
|
|
|
294
545
|
}
|
|
295
546
|
export function readRtkAdoption() {
|
|
296
547
|
const files = [];
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
walkJsonl(codexSessionsDir(), files, 2000);
|
|
300
|
-
}
|
|
548
|
+
for (const dir of sessionsDirs())
|
|
549
|
+
walkJsonl(dir, files, 2000);
|
|
301
550
|
const live = new Set(files);
|
|
302
551
|
for (const file of adoptionFileCache.keys()) {
|
|
303
552
|
if (!live.has(file))
|
|
@@ -332,26 +581,34 @@ export function readRtkAdoption() {
|
|
|
332
581
|
const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
|
|
333
582
|
return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
|
|
334
583
|
}
|
|
335
|
-
export function processSessionText(accum, state, text) {
|
|
584
|
+
export function processSessionText(accum, state, text, host) {
|
|
336
585
|
for (const line of text.split('\n')) {
|
|
337
586
|
if (!line.trim())
|
|
338
587
|
continue;
|
|
339
588
|
try {
|
|
340
589
|
const row = JSON.parse(line);
|
|
341
590
|
const parsed = classifySessionLine(row);
|
|
591
|
+
const rid = messageRowId(row);
|
|
342
592
|
if (parsed.kind === 'codex_provider') {
|
|
343
593
|
if (parsed.provider)
|
|
344
594
|
state.codexProvider = parsed.provider;
|
|
345
595
|
continue;
|
|
346
596
|
}
|
|
597
|
+
if (parsed.kind === 'codex_model') {
|
|
598
|
+
if (parsed.model)
|
|
599
|
+
state.codexModel = parsed.model;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
347
602
|
if (parsed.kind === 'token_row') {
|
|
348
|
-
const
|
|
349
|
-
|
|
603
|
+
const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
|
|
604
|
+
// The provider alone told you nothing about which model ran.
|
|
605
|
+
const model = state.codexModel ? `${base}/${state.codexModel}` : base;
|
|
606
|
+
ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' }, undefined, host ?? 'codex', rid);
|
|
350
607
|
continue;
|
|
351
608
|
}
|
|
352
609
|
if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
|
|
353
610
|
continue;
|
|
354
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools);
|
|
611
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid);
|
|
355
612
|
}
|
|
356
613
|
catch { /* skip corrupt lines */ }
|
|
357
614
|
}
|
|
@@ -362,8 +619,8 @@ export function readRtkRecallDiagnostics(binary = resolveRtkBinary()) {
|
|
|
362
619
|
try {
|
|
363
620
|
const configPath = process.env.RTK_CONFIG
|
|
364
621
|
|| (process.platform === 'darwin'
|
|
365
|
-
? path.join(
|
|
366
|
-
: 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'));
|
|
367
624
|
let config = '';
|
|
368
625
|
try {
|
|
369
626
|
config = fs.readFileSync(configPath, 'utf8');
|
|
@@ -379,8 +636,7 @@ export function readRtkRecallDiagnostics(binary = resolveRtkBinary()) {
|
|
|
379
636
|
return { mode: 'unknown', entries: 0, available: false };
|
|
380
637
|
}
|
|
381
638
|
}
|
|
382
|
-
// Lead binary of a shell string: first segment past `cd` chains and VAR=x
|
|
383
|
-
// 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`.
|
|
384
640
|
const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
|
|
385
641
|
export function leadBinary(command) {
|
|
386
642
|
if (typeof command !== 'string' || !command.trim())
|
|
@@ -441,8 +697,7 @@ export function readUsage() {
|
|
|
441
697
|
}
|
|
442
698
|
return rows;
|
|
443
699
|
}
|
|
444
|
-
// Delete the ledger file and return the rows cleared. Tersio-owned data only:
|
|
445
|
-
// 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.
|
|
446
701
|
export function clearUsageLedger() {
|
|
447
702
|
const rows = readUsage().length;
|
|
448
703
|
try {
|