@krtclcdy/tersio 2.24.0 → 2.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -3
- package/cli/banner.js +2 -3
- package/cli/currency.js +3 -7
- package/cli/dashboard.js +167 -3
- package/cli/doctor-fix.js +15 -12
- package/cli/doctor.js +1 -12
- package/cli/hosts.js +7 -11
- package/cli/install.js +113 -71
- package/cli/interactive.js +36 -2
- package/cli/layer.js +1 -3
- package/cli/manifest.js +1 -4
- package/cli/profile.js +9 -7
- package/cli/reset.js +3 -7
- package/cli/rtk-wiring.js +1 -6
- package/cli/settings.js +3 -8
- package/cli/uninstall.js +43 -24
- package/cli/update.js +7 -8
- package/cli/usage.js +15 -5
- package/dashboard/dist/index.html +18 -18
- package/extensions/ai-addons-updater/index.js +6 -5
- package/extensions/ai-addons-updater/index.ts +7 -6
- package/extensions/caveman-session/index.js +14 -29
- package/extensions/caveman-session/index.ts +15 -30
- package/extensions/combo-toggle/index.js +10 -14
- package/extensions/combo-toggle/index.ts +10 -15
- package/extensions/lib/utils.js +4 -0
- package/extensions/lib/utils.ts +3 -0
- package/extensions/rtk-session/index.js +27 -31
- package/extensions/rtk-session/index.ts +25 -29
- package/extensions/shared/carbon.js +3 -8
- package/extensions/shared/carbon.ts +4 -8
- package/extensions/shared/host.js +1 -11
- package/extensions/shared/host.ts +1 -11
- package/extensions/shared/plugin-settings.js +4 -14
- package/extensions/shared/plugin-settings.ts +4 -14
- package/extensions/shared/pricing.js +4 -6
- package/extensions/shared/pricing.ts +4 -6
- package/extensions/shared/rtk-gain.js +2 -7
- package/extensions/shared/rtk-gain.ts +2 -7
- package/extensions/shared/session-state.js +30 -18
- package/extensions/shared/session-state.ts +33 -16
- package/extensions/shared/usage-ledger.js +138 -17
- package/extensions/shared/usage-ledger.ts +143 -19
- package/extensions/shared/usage-store.js +175 -23
- package/extensions/shared/usage-store.ts +171 -22
- package/extensions/tersio-commands/index.js +1 -5
- package/extensions/tersio-commands/index.ts +1 -5
- package/package.json +6 -7
- package/tersio.js +1 -3
|
@@ -46,6 +46,9 @@ export function appendUsage(kind, detail) {
|
|
|
46
46
|
function zeroBreakdown() {
|
|
47
47
|
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
48
48
|
}
|
|
49
|
+
function stampMs(ts) {
|
|
50
|
+
return ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
51
|
+
}
|
|
49
52
|
export function durOf(v) {
|
|
50
53
|
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
51
54
|
}
|
|
@@ -84,15 +87,20 @@ function addInto(into, u) {
|
|
|
84
87
|
into[k] += Math.floor(v);
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
|
-
export function
|
|
90
|
+
export function sessionsDirs() {
|
|
88
91
|
const override = process.env.TERSIO_SESSIONS_DIR;
|
|
89
92
|
if (override)
|
|
90
|
-
return override;
|
|
91
|
-
//
|
|
93
|
+
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.
|
|
92
96
|
const dirs = isPiProcess()
|
|
93
97
|
? [path.join(piAgentDir(), 'sessions'), path.join(os.homedir(), '.omp', 'agent', 'sessions')]
|
|
94
98
|
: [path.join(os.homedir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
|
|
95
|
-
|
|
99
|
+
const found = dirs.filter((dir) => fs.existsSync(dir));
|
|
100
|
+
return found.length > 0 ? found : [dirs[0]];
|
|
101
|
+
}
|
|
102
|
+
export function sessionsDir() {
|
|
103
|
+
return sessionsDirs()[0];
|
|
96
104
|
}
|
|
97
105
|
export function codexSessionsDir() {
|
|
98
106
|
const override = process.env.TERSIO_CODEX_DIR;
|
|
@@ -103,6 +111,22 @@ export function codexSessionsDir() {
|
|
|
103
111
|
return path.join(codexHome, 'sessions');
|
|
104
112
|
return path.join(os.homedir(), '.codex', 'sessions');
|
|
105
113
|
}
|
|
114
|
+
// One JSON file per message; probe XDG, then local, then macOS default.
|
|
115
|
+
export function opencodeSessionsDir() {
|
|
116
|
+
const override = process.env.TERSIO_OPENCODE_DIR;
|
|
117
|
+
if (override)
|
|
118
|
+
return override;
|
|
119
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
120
|
+
if (xdg && xdg.trim() !== '')
|
|
121
|
+
return path.join(xdg, 'opencode', 'storage', 'message');
|
|
122
|
+
const local = path.join(os.homedir(), '.local', 'share', 'opencode', 'storage', 'message');
|
|
123
|
+
try {
|
|
124
|
+
if (fs.existsSync(local))
|
|
125
|
+
return local;
|
|
126
|
+
}
|
|
127
|
+
catch { /* fall through to the platform default */ }
|
|
128
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'opencode', 'storage', 'message');
|
|
129
|
+
}
|
|
106
130
|
export function dayKey(ts) {
|
|
107
131
|
const ms = typeof ts === 'number' ? ts : Date.parse(ts);
|
|
108
132
|
if (!Number.isFinite(ms))
|
|
@@ -111,7 +135,7 @@ export function dayKey(ts) {
|
|
|
111
135
|
const p = (n) => String(n).padStart(2, '0');
|
|
112
136
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
113
137
|
}
|
|
114
|
-
export function walkJsonl(dir, out, cap) {
|
|
138
|
+
export function walkJsonl(dir, out, cap, ext = '.jsonl') {
|
|
115
139
|
let entries;
|
|
116
140
|
try {
|
|
117
141
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -124,8 +148,8 @@ export function walkJsonl(dir, out, cap) {
|
|
|
124
148
|
return;
|
|
125
149
|
const full = path.join(dir, e.name);
|
|
126
150
|
if (e.isDirectory())
|
|
127
|
-
walkJsonl(full, out, cap);
|
|
128
|
-
else if (e.isFile() && e.name.endsWith(
|
|
151
|
+
walkJsonl(full, out, cap, ext);
|
|
152
|
+
else if (e.isFile() && e.name.endsWith(ext))
|
|
129
153
|
out.push(full);
|
|
130
154
|
}
|
|
131
155
|
}
|
|
@@ -141,7 +165,7 @@ const RTK_ELIGIBLE_HEADS = new Set([
|
|
|
141
165
|
export function newSessionAccum() {
|
|
142
166
|
return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
|
|
143
167
|
}
|
|
144
|
-
export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames) {
|
|
168
|
+
export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host) {
|
|
145
169
|
const watermark = readResetWatermark();
|
|
146
170
|
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
147
171
|
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
|
|
@@ -154,7 +178,7 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames)
|
|
|
154
178
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
155
179
|
if (Number.isFinite(ms)) {
|
|
156
180
|
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 });
|
|
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 });
|
|
158
182
|
}
|
|
159
183
|
const day = ts !== undefined ? dayKey(ts) : null;
|
|
160
184
|
if (day) {
|
|
@@ -179,6 +203,10 @@ export function classifySessionLine(row) {
|
|
|
179
203
|
if (payload && typeof payload === 'object') {
|
|
180
204
|
if (row.type === 'session_meta' && typeof payload.model_provider === 'string')
|
|
181
205
|
return { kind: 'codex_provider', provider: payload.model_provider };
|
|
206
|
+
// A Codex transcript states the model once per turn, in turn_context.
|
|
207
|
+
if (row.type === 'turn_context' && typeof payload.model === 'string' && payload.model) {
|
|
208
|
+
return { kind: 'codex_model', model: payload.model };
|
|
209
|
+
}
|
|
182
210
|
if (payload.type === 'token_count') {
|
|
183
211
|
const last = payload.info?.last_token_usage;
|
|
184
212
|
if (last) {
|
|
@@ -192,7 +220,10 @@ export function classifySessionLine(row) {
|
|
|
192
220
|
if (!msg || msg.role !== 'assistant' || !msg.usage)
|
|
193
221
|
return { kind: 'skip' };
|
|
194
222
|
const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
|
|
195
|
-
|
|
223
|
+
// pi omits duration, so derive Elapsed/Speed from the timestamp pair.
|
|
224
|
+
const startMs = typeof msg.timestamp === 'number' ? msg.timestamp : undefined;
|
|
225
|
+
const endMs = typeof msg.completedAt === 'number' ? msg.completedAt : stampMs(row.timestamp);
|
|
226
|
+
const computedDur = startMs !== undefined && Number.isFinite(endMs) && endMs > startMs ? endMs - startMs : undefined;
|
|
196
227
|
const tools = [];
|
|
197
228
|
for (const part of msg.content ?? []) {
|
|
198
229
|
if (part?.type !== 'toolCall' || typeof part.name !== 'string' || !part.name)
|
|
@@ -206,6 +237,57 @@ export function classifySessionLine(row) {
|
|
|
206
237
|
}
|
|
207
238
|
return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
|
|
208
239
|
}
|
|
240
|
+
// True when at least one token bucket holds a positive finite count.
|
|
241
|
+
export function hasPositiveUsage(usage) {
|
|
242
|
+
for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
|
|
243
|
+
const v = usage[k];
|
|
244
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0)
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
// One OpenCode message file; assistant rows carry the token buckets.
|
|
250
|
+
export function classifyOpencodeMessage(obj) {
|
|
251
|
+
if (!obj || typeof obj !== 'object' || obj.role !== 'assistant')
|
|
252
|
+
return null;
|
|
253
|
+
const t = obj.tokens;
|
|
254
|
+
if (!t || typeof t !== 'object')
|
|
255
|
+
return null;
|
|
256
|
+
const usage = {
|
|
257
|
+
input: t.input,
|
|
258
|
+
output: t.output,
|
|
259
|
+
cacheRead: t.cache?.read,
|
|
260
|
+
cacheWrite: t.cache?.write,
|
|
261
|
+
cost: obj.cost,
|
|
262
|
+
};
|
|
263
|
+
if (!hasPositiveUsage(usage))
|
|
264
|
+
return null;
|
|
265
|
+
const provider = typeof obj.providerID === 'string' && obj.providerID ? obj.providerID : 'unknown';
|
|
266
|
+
const model = typeof obj.modelID === 'string' && obj.modelID ? obj.modelID : 'unknown';
|
|
267
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
|
|
268
|
+
const created = num(obj.time?.created);
|
|
269
|
+
const completed = num(obj.time?.completed);
|
|
270
|
+
return {
|
|
271
|
+
model: `opencode/${provider}/${model}`,
|
|
272
|
+
usage,
|
|
273
|
+
ms: created,
|
|
274
|
+
durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// One OpenCode message file into the shared accum.
|
|
278
|
+
export function processOpencodeFile(accum, text) {
|
|
279
|
+
let obj;
|
|
280
|
+
try {
|
|
281
|
+
obj = JSON.parse(text);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const parsed = classifyOpencodeMessage(obj);
|
|
287
|
+
if (!parsed)
|
|
288
|
+
return;
|
|
289
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode');
|
|
290
|
+
}
|
|
209
291
|
// Fold `:free`/`-free` suffixes and case variants into one chart key.
|
|
210
292
|
export function canonicalModelId(model) {
|
|
211
293
|
return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
|
|
@@ -236,13 +318,30 @@ export function displayModelId(model) {
|
|
|
236
318
|
export function importSessionTokens() {
|
|
237
319
|
const accum = newSessionAccum();
|
|
238
320
|
const files = [];
|
|
239
|
-
|
|
321
|
+
for (const dir of sessionsDirs())
|
|
322
|
+
walkJsonl(dir, files, 2000);
|
|
240
323
|
// An override means an isolated environment (tests, fixtures): only walk the
|
|
241
324
|
// real codex dir when one is explicitly set.
|
|
242
325
|
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
243
326
|
walkJsonl(codexSessionsDir(), files, 2000);
|
|
244
327
|
}
|
|
328
|
+
// OpenCode message bodies are single JSON documents, not JSONL.
|
|
329
|
+
const ocFiles = [];
|
|
330
|
+
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
|
|
331
|
+
walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
|
|
332
|
+
}
|
|
333
|
+
for (const file of ocFiles) {
|
|
334
|
+
let text;
|
|
335
|
+
try {
|
|
336
|
+
text = fs.readFileSync(file, 'utf8');
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
processOpencodeFile(accum, text);
|
|
342
|
+
}
|
|
245
343
|
let codexProvider = null;
|
|
344
|
+
let codexModel = null;
|
|
246
345
|
for (const file of files) {
|
|
247
346
|
let text;
|
|
248
347
|
try {
|
|
@@ -251,11 +350,25 @@ export function importSessionTokens() {
|
|
|
251
350
|
catch {
|
|
252
351
|
continue;
|
|
253
352
|
}
|
|
254
|
-
processSessionText(accum, {
|
|
353
|
+
processSessionText(accum, {
|
|
354
|
+
get codexProvider() { return codexProvider; },
|
|
355
|
+
set codexProvider(v) { codexProvider = v; },
|
|
356
|
+
get codexModel() { return codexModel; },
|
|
357
|
+
set codexModel(v) { codexModel = v; },
|
|
358
|
+
}, text, hostOfSessionFile(file));
|
|
255
359
|
}
|
|
256
360
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
257
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) };
|
|
258
362
|
}
|
|
363
|
+
// Which agent wrote a session file, from the directory it sits in.
|
|
364
|
+
export function hostOfSessionFile(file) {
|
|
365
|
+
const norm = file.replace(/\\/g, '/');
|
|
366
|
+
if (norm.includes('/.omp/'))
|
|
367
|
+
return 'omp';
|
|
368
|
+
if (norm.includes('/.codex/'))
|
|
369
|
+
return 'codex';
|
|
370
|
+
return 'pi';
|
|
371
|
+
}
|
|
259
372
|
const adoptionFileCache = new Map();
|
|
260
373
|
function parseAdoptionFile(file) {
|
|
261
374
|
const counts = { bashCalls: 0, eligibleCalls: 0, rtkCalls: 0 };
|
|
@@ -294,7 +407,8 @@ export function clearRtkAdoptionCache() {
|
|
|
294
407
|
}
|
|
295
408
|
export function readRtkAdoption() {
|
|
296
409
|
const files = [];
|
|
297
|
-
|
|
410
|
+
for (const dir of sessionsDirs())
|
|
411
|
+
walkJsonl(dir, files, 2000);
|
|
298
412
|
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
299
413
|
walkJsonl(codexSessionsDir(), files, 2000);
|
|
300
414
|
}
|
|
@@ -332,7 +446,7 @@ export function readRtkAdoption() {
|
|
|
332
446
|
const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
|
|
333
447
|
return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
|
|
334
448
|
}
|
|
335
|
-
export function processSessionText(accum, state, text) {
|
|
449
|
+
export function processSessionText(accum, state, text, host) {
|
|
336
450
|
for (const line of text.split('\n')) {
|
|
337
451
|
if (!line.trim())
|
|
338
452
|
continue;
|
|
@@ -344,14 +458,21 @@ export function processSessionText(accum, state, text) {
|
|
|
344
458
|
state.codexProvider = parsed.provider;
|
|
345
459
|
continue;
|
|
346
460
|
}
|
|
461
|
+
if (parsed.kind === 'codex_model') {
|
|
462
|
+
if (parsed.model)
|
|
463
|
+
state.codexModel = parsed.model;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
347
466
|
if (parsed.kind === 'token_row') {
|
|
348
|
-
const
|
|
349
|
-
|
|
467
|
+
const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
|
|
468
|
+
// The provider alone told you nothing about which model ran.
|
|
469
|
+
const model = state.codexModel ? `${base}/${state.codexModel}` : base;
|
|
470
|
+
ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' }, undefined, host ?? 'codex');
|
|
350
471
|
continue;
|
|
351
472
|
}
|
|
352
473
|
if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
|
|
353
474
|
continue;
|
|
354
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools);
|
|
475
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host);
|
|
355
476
|
}
|
|
356
477
|
catch { /* skip corrupt lines */ }
|
|
357
478
|
}
|
|
@@ -70,6 +70,8 @@ export interface RecentRequest {
|
|
|
70
70
|
o: number;
|
|
71
71
|
t: number;
|
|
72
72
|
d?: number;
|
|
73
|
+
/** Which agent wrote the session: pi, omp, or codex. */
|
|
74
|
+
h?: string;
|
|
73
75
|
cr?: number;
|
|
74
76
|
cw?: number;
|
|
75
77
|
// Undefined when the host recorded no cost; never backfilled.
|
|
@@ -109,6 +111,10 @@ 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
|
}
|
|
@@ -147,14 +153,20 @@ function addInto(into: TokenBreakdown, u: { input?: unknown; output?: unknown; c
|
|
|
147
153
|
}
|
|
148
154
|
}
|
|
149
155
|
|
|
150
|
-
export function
|
|
156
|
+
export function sessionsDirs(): string[] {
|
|
151
157
|
const override = process.env.TERSIO_SESSIONS_DIR;
|
|
152
|
-
if (override) return override;
|
|
153
|
-
//
|
|
158
|
+
if (override) return [override];
|
|
159
|
+
// Both hosts, not just the running one: a session on pi is missing from the
|
|
160
|
+
// dashboard entirely if we stop at the first directory that exists.
|
|
154
161
|
const dirs = isPiProcess()
|
|
155
162
|
? [path.join(piAgentDir(), 'sessions'), path.join(os.homedir(), '.omp', 'agent', 'sessions')]
|
|
156
163
|
: [path.join(os.homedir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
|
|
157
|
-
|
|
164
|
+
const found = dirs.filter((dir) => fs.existsSync(dir));
|
|
165
|
+
return found.length > 0 ? found : [dirs[0]];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function sessionsDir(): string {
|
|
169
|
+
return sessionsDirs()[0];
|
|
158
170
|
}
|
|
159
171
|
|
|
160
172
|
export function codexSessionsDir(): string {
|
|
@@ -165,6 +177,19 @@ export function codexSessionsDir(): string {
|
|
|
165
177
|
return path.join(os.homedir(), '.codex', 'sessions');
|
|
166
178
|
}
|
|
167
179
|
|
|
180
|
+
// One JSON file per message; probe XDG, then local, then macOS default.
|
|
181
|
+
export function opencodeSessionsDir(): string {
|
|
182
|
+
const override = process.env.TERSIO_OPENCODE_DIR;
|
|
183
|
+
if (override) return override;
|
|
184
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
185
|
+
if (xdg && xdg.trim() !== '') return path.join(xdg, 'opencode', 'storage', 'message');
|
|
186
|
+
const local = path.join(os.homedir(), '.local', 'share', 'opencode', 'storage', 'message');
|
|
187
|
+
try {
|
|
188
|
+
if (fs.existsSync(local)) return local;
|
|
189
|
+
} catch { /* fall through to the platform default */ }
|
|
190
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'opencode', 'storage', 'message');
|
|
191
|
+
}
|
|
192
|
+
|
|
168
193
|
export function dayKey(ts: string | number): string | null {
|
|
169
194
|
const ms = typeof ts === 'number' ? ts : Date.parse(ts);
|
|
170
195
|
if (!Number.isFinite(ms)) return null;
|
|
@@ -172,7 +197,7 @@ export function dayKey(ts: string | number): string | null {
|
|
|
172
197
|
const p = (n: number): string => String(n).padStart(2, '0');
|
|
173
198
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
174
199
|
}
|
|
175
|
-
export function walkJsonl(dir: string, out: string[], cap: number): void {
|
|
200
|
+
export function walkJsonl(dir: string, out: string[], cap: number, ext = '.jsonl'): void {
|
|
176
201
|
let entries: fs.Dirent[];
|
|
177
202
|
try {
|
|
178
203
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -182,8 +207,8 @@ export function walkJsonl(dir: string, out: string[], cap: number): void {
|
|
|
182
207
|
for (const e of entries) {
|
|
183
208
|
if (out.length >= cap) return;
|
|
184
209
|
const full = path.join(dir, e.name);
|
|
185
|
-
if (e.isDirectory()) walkJsonl(full, out, cap);
|
|
186
|
-
else if (e.isFile() && e.name.endsWith(
|
|
210
|
+
if (e.isDirectory()) walkJsonl(full, out, cap, ext);
|
|
211
|
+
else if (e.isFile() && e.name.endsWith(ext)) out.push(full);
|
|
187
212
|
}
|
|
188
213
|
}
|
|
189
214
|
// Recent requests get their own full-width table, so this bounds payload
|
|
@@ -211,7 +236,7 @@ export interface SessionAccum {
|
|
|
211
236
|
export function newSessionAccum(): SessionAccum {
|
|
212
237
|
return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
|
|
213
238
|
}
|
|
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 {
|
|
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 {
|
|
215
240
|
const watermark = readResetWatermark();
|
|
216
241
|
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
217
242
|
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark)) return false;
|
|
@@ -223,7 +248,7 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
|
|
|
223
248
|
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
224
249
|
if (Number.isFinite(ms)) {
|
|
225
250
|
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 });
|
|
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 });
|
|
227
252
|
}
|
|
228
253
|
const day = ts !== undefined ? dayKey(ts) : null;
|
|
229
254
|
if (day) {
|
|
@@ -241,11 +266,15 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
|
|
|
241
266
|
for (const name of toolNames ?? []) accum.byTool[name] = (accum.byTool[name] ?? 0) + 1;
|
|
242
267
|
return true;
|
|
243
268
|
}
|
|
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 } {
|
|
269
|
+
export type SessionLineKind = 'codex_provider' | 'codex_model' | 'token_row' | 'assistant_row' | 'skip';
|
|
270
|
+
export function classifySessionLine(row: { timestamp?: string | number; type?: unknown; message?: { role?: unknown; model?: unknown; usage?: Record<string, unknown>; duration?: unknown; completedAt?: unknown; timestamp?: unknown; stopReason?: unknown; errorStatus?: unknown; errorMessage?: unknown; isError?: unknown; content?: Array<{ type?: unknown; name?: unknown; arguments?: unknown }> }; payload?: { type?: unknown; model?: unknown; model_provider?: unknown; info?: { last_token_usage?: Record<string, unknown> } } }): { kind: SessionLineKind; provider?: string; model?: string; usage?: Record<string, unknown>; durMs?: number; run?: { st: RunStatus; code?: number; note?: string }; tools?: string[]; ms?: number } {
|
|
246
271
|
const payload = row.payload;
|
|
247
272
|
if (payload && typeof payload === 'object') {
|
|
248
273
|
if (row.type === 'session_meta' && typeof payload.model_provider === 'string') return { kind: 'codex_provider', provider: payload.model_provider };
|
|
274
|
+
// A Codex transcript states the model once per turn, in turn_context.
|
|
275
|
+
if (row.type === 'turn_context' && typeof payload.model === 'string' && payload.model) {
|
|
276
|
+
return { kind: 'codex_model', model: payload.model };
|
|
277
|
+
}
|
|
249
278
|
if (payload.type === 'token_count') {
|
|
250
279
|
const last = payload.info?.last_token_usage;
|
|
251
280
|
if (last) {
|
|
@@ -258,7 +287,10 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
|
|
|
258
287
|
const msg = row.message;
|
|
259
288
|
if (!msg || msg.role !== 'assistant' || !msg.usage) return { kind: 'skip' };
|
|
260
289
|
const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
|
|
261
|
-
|
|
290
|
+
// pi omits duration, so derive Elapsed/Speed from the timestamp pair.
|
|
291
|
+
const startMs = typeof msg.timestamp === 'number' ? msg.timestamp : undefined;
|
|
292
|
+
const endMs = typeof msg.completedAt === 'number' ? msg.completedAt : stampMs(row.timestamp);
|
|
293
|
+
const computedDur = startMs !== undefined && Number.isFinite(endMs) && endMs > startMs ? endMs - startMs : undefined;
|
|
262
294
|
const tools: string[] = [];
|
|
263
295
|
for (const part of msg.content ?? []) {
|
|
264
296
|
if (part?.type !== 'toolCall' || typeof part.name !== 'string' || !part.name) continue;
|
|
@@ -271,6 +303,64 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
|
|
|
271
303
|
}
|
|
272
304
|
return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
|
|
273
305
|
}
|
|
306
|
+
// True when at least one token bucket holds a positive finite count.
|
|
307
|
+
export function hasPositiveUsage(usage: Record<string, unknown>): boolean {
|
|
308
|
+
for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
|
|
309
|
+
const v = usage[k];
|
|
310
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return true;
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface OpencodeMessage {
|
|
316
|
+
role?: unknown;
|
|
317
|
+
tokens?: { input?: unknown; output?: unknown; reasoning?: unknown; cache?: { read?: unknown; write?: unknown } } | null;
|
|
318
|
+
modelID?: unknown;
|
|
319
|
+
providerID?: unknown;
|
|
320
|
+
time?: { created?: unknown; completed?: unknown } | null;
|
|
321
|
+
cost?: unknown;
|
|
322
|
+
finish?: unknown;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// One OpenCode message file; assistant rows carry the token buckets.
|
|
326
|
+
export function classifyOpencodeMessage(obj: OpencodeMessage | null | undefined): { model: string; usage: Record<string, unknown>; ms: number | undefined; durMs: number | undefined } | null {
|
|
327
|
+
if (!obj || typeof obj !== 'object' || obj.role !== 'assistant') return null;
|
|
328
|
+
const t = obj.tokens;
|
|
329
|
+
if (!t || typeof t !== 'object') return null;
|
|
330
|
+
const usage: Record<string, unknown> = {
|
|
331
|
+
input: t.input,
|
|
332
|
+
output: t.output,
|
|
333
|
+
cacheRead: t.cache?.read,
|
|
334
|
+
cacheWrite: t.cache?.write,
|
|
335
|
+
cost: (obj as Record<string, unknown>).cost,
|
|
336
|
+
};
|
|
337
|
+
if (!hasPositiveUsage(usage)) return null;
|
|
338
|
+
const provider = typeof obj.providerID === 'string' && obj.providerID ? obj.providerID : 'unknown';
|
|
339
|
+
const model = typeof obj.modelID === 'string' && obj.modelID ? obj.modelID : 'unknown';
|
|
340
|
+
const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
|
|
341
|
+
const created = num(obj.time?.created);
|
|
342
|
+
const completed = num(obj.time?.completed);
|
|
343
|
+
return {
|
|
344
|
+
model: `opencode/${provider}/${model}`,
|
|
345
|
+
usage,
|
|
346
|
+
ms: created,
|
|
347
|
+
durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// One OpenCode message file into the shared accum.
|
|
352
|
+
export function processOpencodeFile(accum: SessionAccum, text: string): void {
|
|
353
|
+
let obj: unknown;
|
|
354
|
+
try {
|
|
355
|
+
obj = JSON.parse(text);
|
|
356
|
+
} catch {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const parsed = classifyOpencodeMessage(obj as OpencodeMessage);
|
|
360
|
+
if (!parsed) return;
|
|
361
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, undefined, 'opencode');
|
|
362
|
+
}
|
|
363
|
+
|
|
274
364
|
// Fold `:free`/`-free` suffixes and case variants into one chart key.
|
|
275
365
|
export function canonicalModelId(model: string): string {
|
|
276
366
|
return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
|
|
@@ -297,13 +387,28 @@ export function displayModelId(model: string): string {
|
|
|
297
387
|
export function importSessionTokens(): SessionTokens {
|
|
298
388
|
const accum = newSessionAccum();
|
|
299
389
|
const files: string[] = [];
|
|
300
|
-
|
|
390
|
+
for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
|
|
301
391
|
// An override means an isolated environment (tests, fixtures): only walk the
|
|
302
392
|
// real codex dir when one is explicitly set.
|
|
303
393
|
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
304
394
|
walkJsonl(codexSessionsDir(), files, 2000);
|
|
305
395
|
}
|
|
396
|
+
// OpenCode message bodies are single JSON documents, not JSONL.
|
|
397
|
+
const ocFiles: string[] = [];
|
|
398
|
+
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
|
|
399
|
+
walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
|
|
400
|
+
}
|
|
401
|
+
for (const file of ocFiles) {
|
|
402
|
+
let text: string;
|
|
403
|
+
try {
|
|
404
|
+
text = fs.readFileSync(file, 'utf8');
|
|
405
|
+
} catch {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
processOpencodeFile(accum, text);
|
|
409
|
+
}
|
|
306
410
|
let codexProvider: string | null = null;
|
|
411
|
+
let codexModel: string | null = null;
|
|
307
412
|
for (const file of files) {
|
|
308
413
|
let text: string;
|
|
309
414
|
try {
|
|
@@ -311,12 +416,25 @@ export function importSessionTokens(): SessionTokens {
|
|
|
311
416
|
} catch {
|
|
312
417
|
continue;
|
|
313
418
|
}
|
|
314
|
-
processSessionText(accum, {
|
|
419
|
+
processSessionText(accum, {
|
|
420
|
+
get codexProvider() { return codexProvider; },
|
|
421
|
+
set codexProvider(v: string | null) { codexProvider = v; },
|
|
422
|
+
get codexModel() { return codexModel; },
|
|
423
|
+
set codexModel(v: string | null) { codexModel = v; },
|
|
424
|
+
}, text, hostOfSessionFile(file));
|
|
315
425
|
}
|
|
316
426
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
317
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) };
|
|
318
428
|
}
|
|
319
429
|
|
|
430
|
+
// Which agent wrote a session file, from the directory it sits in.
|
|
431
|
+
export function hostOfSessionFile(file: string): string {
|
|
432
|
+
const norm = file.replace(/\\/g, '/');
|
|
433
|
+
if (norm.includes('/.omp/')) return 'omp';
|
|
434
|
+
if (norm.includes('/.codex/')) return 'codex';
|
|
435
|
+
return 'pi';
|
|
436
|
+
}
|
|
437
|
+
|
|
320
438
|
const adoptionFileCache = new Map<string, { size: number; mtimeMs: number; counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number } }>();
|
|
321
439
|
|
|
322
440
|
function parseAdoptionFile(file: string): { counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number }; sessions: number } {
|
|
@@ -355,7 +473,7 @@ export function clearRtkAdoptionCache(): void {
|
|
|
355
473
|
|
|
356
474
|
export function readRtkAdoption(): RtkAdoption {
|
|
357
475
|
const files: string[] = [];
|
|
358
|
-
|
|
476
|
+
for (const dir of sessionsDirs()) walkJsonl(dir, files, 2000);
|
|
359
477
|
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
360
478
|
walkJsonl(codexSessionsDir(), files, 2000);
|
|
361
479
|
}
|
|
@@ -390,7 +508,7 @@ export function readRtkAdoption(): RtkAdoption {
|
|
|
390
508
|
const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
|
|
391
509
|
return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
|
|
392
510
|
}
|
|
393
|
-
export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null }, text: string): void {
|
|
511
|
+
export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null; codexModel?: string | null }, text: string, host?: string): void {
|
|
394
512
|
for (const line of text.split('\n')) {
|
|
395
513
|
if (!line.trim()) continue;
|
|
396
514
|
try {
|
|
@@ -400,13 +518,19 @@ export function processSessionText(accum: SessionAccum, state: { codexProvider:
|
|
|
400
518
|
if (parsed.provider) state.codexProvider = parsed.provider;
|
|
401
519
|
continue;
|
|
402
520
|
}
|
|
521
|
+
if (parsed.kind === 'codex_model') {
|
|
522
|
+
if (parsed.model) state.codexModel = parsed.model;
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
403
525
|
if (parsed.kind === 'token_row') {
|
|
404
|
-
const
|
|
405
|
-
|
|
526
|
+
const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
|
|
527
|
+
// The provider alone told you nothing about which model ran.
|
|
528
|
+
const model = state.codexModel ? `${base}/${state.codexModel}` : base;
|
|
529
|
+
ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' as RunStatus }, undefined, host ?? 'codex');
|
|
406
530
|
continue;
|
|
407
531
|
}
|
|
408
532
|
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);
|
|
533
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host);
|
|
410
534
|
} catch { /* skip corrupt lines */ }
|
|
411
535
|
}
|
|
412
536
|
}
|