@krtclcdy/tersio 2.25.4 → 2.25.6
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 -63
- package/dashboard/dist/index.html +18 -16
- package/dist/cli/backups.js +187 -0
- package/dist/cli/banner.js +4 -3
- package/dist/cli/common.js +29 -13
- package/dist/cli/currency.js +1 -1
- package/dist/cli/dashboard.js +168 -110
- package/dist/cli/doctor-fix.js +232 -50
- package/dist/cli/doctor.js +281 -32
- package/dist/cli/hosts.js +29 -9
- package/dist/cli/install.js +204 -69
- package/dist/cli/interactive.js +3 -3
- package/dist/cli/manifest.js +125 -17
- package/dist/cli/profile.js +14 -9
- package/dist/cli/rtk-wiring.js +1 -1
- package/dist/cli/settings.js +20 -5
- package/dist/cli/uninstall.js +24 -8
- package/dist/cli/update.js +9 -10
- package/dist/cli/usage.js +75 -7
- package/dist/extensions/{caveman-session → caveman}/index.js +4 -2
- package/{extensions/caveman-session → dist/extensions/caveman}/rule-megacave.md +5 -3
- package/{extensions/caveman-session → dist/extensions/caveman}/rule-ultra.md +4 -4
- package/dist/extensions/caveman/rule.md +89 -0
- package/dist/extensions/{combo-toggle → combo}/index.js +62 -21
- package/dist/extensions/{tersio-commands → commands}/index.js +2 -2
- package/dist/extensions/pstack/index.js +119 -0
- package/dist/extensions/pstack/rule.md +65 -0
- package/dist/extensions/{rtk-filter/index.js → rtk/filter.js} +1 -2
- package/dist/extensions/{rtk-session/index.js → rtk/session.js} +5 -5
- package/dist/extensions/{opencode/server.js → server.js} +98 -14
- package/dist/extensions/shared/agent-stats.js +1198 -0
- package/dist/extensions/shared/brand-marks.js +153 -0
- package/dist/extensions/shared/carbon.js +1 -1
- package/dist/extensions/shared/plugin-settings.js +12 -7
- package/dist/extensions/shared/pricing.js +86 -30
- package/dist/extensions/shared/rtk-gain.js +4 -9
- package/dist/extensions/shared/session-state.js +15 -10
- package/dist/extensions/shared/sqlite.js +168 -0
- package/dist/extensions/shared/status.js +8 -8
- package/dist/extensions/shared/types.js +1 -1
- package/dist/extensions/shared/usage-ledger.js +113 -39
- package/dist/extensions/shared/usage-store.js +191 -48
- package/dist/extensions/{lib → shared}/utils.js +6 -6
- package/dist/extensions/timing/index.js +31 -0
- package/dist/extensions/{ai-addons-updater → updater}/index.js +13 -6
- package/dist/probe-catalog.js +48 -0
- package/dist/probe-catalog188.js +44 -0
- package/dist/tersio.js +9 -4
- package/extensions/{caveman-session → caveman}/index.ts +4 -2
- package/{dist/extensions/caveman-session → extensions/caveman}/rule-megacave.md +5 -3
- package/{dist/extensions/caveman-session → extensions/caveman}/rule-ultra.md +4 -4
- package/extensions/caveman/rule.md +89 -0
- package/extensions/{combo-toggle → combo}/index.ts +62 -19
- package/extensions/{tersio-commands → commands}/index.ts +2 -2
- package/extensions/pstack/index.ts +128 -0
- package/extensions/pstack/rule.md +65 -0
- package/extensions/{rtk-filter/index.ts → rtk/filter.ts} +1 -2
- package/extensions/{rtk-session/index.ts → rtk/session.ts} +5 -5
- package/extensions/{opencode/server.ts → server.ts} +81 -16
- package/extensions/shared/agent-stats.ts +1605 -0
- package/extensions/shared/brand-marks.ts +163 -0
- package/extensions/shared/carbon.ts +1 -1
- package/extensions/shared/plugin-settings.ts +13 -7
- package/extensions/shared/pricing.ts +95 -32
- package/extensions/shared/rtk-gain.ts +4 -9
- package/extensions/shared/session-state.ts +18 -13
- package/extensions/shared/sqlite.ts +168 -0
- package/extensions/shared/status.ts +9 -8
- package/extensions/shared/types.ts +2 -1
- package/extensions/shared/usage-ledger.ts +134 -46
- package/extensions/shared/usage-store.ts +202 -50
- package/extensions/{lib → shared}/utils.ts +6 -6
- package/extensions/timing/index.ts +45 -0
- package/extensions/{ai-addons-updater → updater}/index.ts +15 -9
- package/package.json +27 -18
- package/dist/extensions/caveman-session/rule.md +0 -79
- package/extensions/caveman-session/rule.md +0 -79
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// One place that runs SQL against a database file.
|
|
2
|
+
//
|
|
3
|
+
// Bun and Node 22.5+ carry SQLite in process, so the `sqlite3` binary is a fallback rather than a
|
|
4
|
+
// requirement: hosts without it kept silently reading nothing. Reads come back exactly as the sqlite3
|
|
5
|
+
// CLI printed them -- rows on their own lines, cells joined by tabs, NULL an empty cell, one trailing
|
|
6
|
+
// newline -- because every caller already parses that shape. The engine rungs take one statement per
|
|
7
|
+
// read, so a caller with several queries calls once per query.
|
|
8
|
+
//
|
|
9
|
+
// The write path leaves the journal mode alone. The store's own schema sets WAL so a reader (the
|
|
10
|
+
// dashboard) never blocks a writer, and changing the mode needs exclusive access: with a reader holding
|
|
11
|
+
// the file the pragma fails, which is a failure reported after the caller's rows have already committed.
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
/** A read this large used to vanish behind a 1MB cap, which cleared on one busy session_message table. */
|
|
18
|
+
const SQLITE_READ_BUFFER = 50 * 1024 * 1024;
|
|
19
|
+
const TIMEOUT = 30_000;
|
|
20
|
+
const requireOptional = createRequire(import.meta.url);
|
|
21
|
+
let cached;
|
|
22
|
+
let cachedFor;
|
|
23
|
+
/** Bun's SQLite, or Node's when this build carries one. Null when neither runtime has an engine. */
|
|
24
|
+
function engine() {
|
|
25
|
+
const asked = process.env.TERSIO_SQLITE ?? '';
|
|
26
|
+
if (cached !== undefined && cachedFor === asked)
|
|
27
|
+
return cached;
|
|
28
|
+
cachedFor = asked;
|
|
29
|
+
cached = null;
|
|
30
|
+
if (asked === 'cli')
|
|
31
|
+
return cached;
|
|
32
|
+
try {
|
|
33
|
+
const { Database } = requireOptional('bun:sqlite');
|
|
34
|
+
cached = {
|
|
35
|
+
kind: 'bun',
|
|
36
|
+
read: (db, sql) => { const d = new Database(db, { readonly: true }); try {
|
|
37
|
+
return d.query(sql).values();
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
d.close();
|
|
41
|
+
} },
|
|
42
|
+
exec: (db, sql) => { const d = new Database(db); try {
|
|
43
|
+
d.exec(sql);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
d.close();
|
|
47
|
+
} },
|
|
48
|
+
};
|
|
49
|
+
return cached;
|
|
50
|
+
}
|
|
51
|
+
catch { /* not Bun, or no SQLite in this build */ }
|
|
52
|
+
try {
|
|
53
|
+
const { DatabaseSync } = requireOptional('node:sqlite');
|
|
54
|
+
// Rows arrive keyed by column name unless the statement returns arrays. A duplicate name collapses
|
|
55
|
+
// its key and shifts every later cell, so a Node that cannot return arrays is no engine at all.
|
|
56
|
+
const probe = new DatabaseSync(':memory:');
|
|
57
|
+
try {
|
|
58
|
+
if (typeof probe.prepare('SELECT 1').setReturnArrays !== 'function')
|
|
59
|
+
throw new Error('no positional rows');
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
probe.close();
|
|
63
|
+
}
|
|
64
|
+
cached = {
|
|
65
|
+
kind: 'node',
|
|
66
|
+
read: (db, sql) => {
|
|
67
|
+
const d = new DatabaseSync(db, { readOnly: true });
|
|
68
|
+
try {
|
|
69
|
+
const st = d.prepare(sql);
|
|
70
|
+
st.setReturnArrays?.(true);
|
|
71
|
+
return st.all();
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
d.close();
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
exec: (db, sql) => { const d = new DatabaseSync(db); try {
|
|
78
|
+
d.exec(sql);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
d.close();
|
|
82
|
+
} },
|
|
83
|
+
};
|
|
84
|
+
return cached;
|
|
85
|
+
}
|
|
86
|
+
catch { /* Node older than 22.5, or one built without SQLite or positional rows */ }
|
|
87
|
+
return cached;
|
|
88
|
+
}
|
|
89
|
+
/** The `sqlite3` binary, resolved once from PATH so a missing one costs one scan, not one spawn per call. */
|
|
90
|
+
let binary;
|
|
91
|
+
function findBinary() {
|
|
92
|
+
if (binary !== undefined)
|
|
93
|
+
return binary;
|
|
94
|
+
binary = null;
|
|
95
|
+
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
|
96
|
+
if (!dir)
|
|
97
|
+
continue;
|
|
98
|
+
for (const name of process.platform === 'win32' ? ['sqlite3.exe', 'sqlite3'] : ['sqlite3']) {
|
|
99
|
+
const at = path.join(dir, name);
|
|
100
|
+
try {
|
|
101
|
+
fs.accessSync(at, fs.constants.X_OK);
|
|
102
|
+
binary = at;
|
|
103
|
+
return binary;
|
|
104
|
+
}
|
|
105
|
+
catch { /* not here */ }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return binary;
|
|
109
|
+
}
|
|
110
|
+
/** `bun`, `node`, `cli`, or null when this host has no way to read SQLite at all. */
|
|
111
|
+
export function sqliteEngine() {
|
|
112
|
+
return engine()?.kind ?? (findBinary() === null ? null : 'cli');
|
|
113
|
+
}
|
|
114
|
+
/** Whether any path can run SQL: what the tests guard their skips on. */
|
|
115
|
+
export function sqliteAvailable() {
|
|
116
|
+
return sqliteEngine() !== null;
|
|
117
|
+
}
|
|
118
|
+
/** Rows as the CLI prints them, so a caller cannot tell which engine answered. */
|
|
119
|
+
function toTsv(rows) {
|
|
120
|
+
if (rows.length === 0)
|
|
121
|
+
return '';
|
|
122
|
+
return `${rows.map((row) => row.map((cell) => (cell === null || cell === undefined ? '' : String(cell))).join('\t')).join('\n')}\n`;
|
|
123
|
+
}
|
|
124
|
+
function cliRead(db, sql) {
|
|
125
|
+
const opts = { encoding: 'utf8', timeout: TIMEOUT, stdio: ['pipe', 'pipe', 'ignore'], maxBuffer: SQLITE_READ_BUFFER, input: sql };
|
|
126
|
+
try {
|
|
127
|
+
return execFileSync('sqlite3', ['-separator', '\t', '-readonly', db], opts);
|
|
128
|
+
}
|
|
129
|
+
catch { /* the side files may be gone; try the next rung */ }
|
|
130
|
+
if (!fs.existsSync(`${db}-wal`)) {
|
|
131
|
+
try {
|
|
132
|
+
return execFileSync('sqlite3', ['-separator', '\t', `file:${db}?immutable=1`], opts);
|
|
133
|
+
}
|
|
134
|
+
catch { /* fall through to the copy */ }
|
|
135
|
+
}
|
|
136
|
+
// A live database cannot be read without its write-ahead log, so copy the set and read the copy.
|
|
137
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tersio-sqlite-'));
|
|
138
|
+
const target = path.join(dir, path.basename(db));
|
|
139
|
+
try {
|
|
140
|
+
fs.copyFileSync(db, target);
|
|
141
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
142
|
+
if (fs.existsSync(`${db}${suffix}`))
|
|
143
|
+
fs.copyFileSync(`${db}${suffix}`, `${target}${suffix}`);
|
|
144
|
+
}
|
|
145
|
+
return execFileSync('sqlite3', ['-separator', '\t', '-readonly', target], opts);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Run a read and return the tab-separated rows. Throws when the file is missing, as the callers expect. */
|
|
152
|
+
export function sqliteRead(db, sql) {
|
|
153
|
+
if (!fs.existsSync(db))
|
|
154
|
+
throw new Error(`missing database: ${db}`);
|
|
155
|
+
const e = engine();
|
|
156
|
+
if (e !== null)
|
|
157
|
+
return toTsv(e.read(db, sql));
|
|
158
|
+
return cliRead(db, sql);
|
|
159
|
+
}
|
|
160
|
+
/** Run a script, creating the file if needed. Stops at the first error, so a failed INSERT leaves no DELETEs. */
|
|
161
|
+
export function sqliteExec(db, sql) {
|
|
162
|
+
const e = engine();
|
|
163
|
+
if (e !== null) {
|
|
164
|
+
e.exec(db, sql);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
execFileSync('sqlite3', ['-bail', db, sql], { stdio: ['ignore', 'ignore', 'ignore'], timeout: TIMEOUT });
|
|
168
|
+
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
// One status string every surface renders: host footer
|
|
1
|
+
// One status string every surface renders: host footer, CLI title, dashboard banner. Nothing else formats mode state.
|
|
2
2
|
import { getSharedComboState } from './session-state.js';
|
|
3
|
-
/** OFF collapses
|
|
3
|
+
/** OFF collapses every mode into one word; a preset or a mix names itself. */
|
|
4
4
|
export function formatStatus(state) {
|
|
5
|
-
const { level, caveman, rtk, ponytail } = state;
|
|
6
|
-
if (level === 'off' && caveman === 'off' && rtk === 'off' && ponytail === 'off')
|
|
5
|
+
const { level, caveman, rtk, ponytail, pstack } = state;
|
|
6
|
+
if (level === 'off' && caveman === 'off' && rtk === 'off' && ponytail === 'off' && pstack === 'off')
|
|
7
7
|
return '🧩 combo OFF';
|
|
8
8
|
const label = level === 'custom' ? 'CUSTOM' : level.toUpperCase();
|
|
9
|
-
return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()}`;
|
|
9
|
+
return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()} 🥔pstack=${pstack.toUpperCase()}`;
|
|
10
10
|
}
|
|
11
|
-
// The string upstream's skill tells the model to relay for `/caveman status
|
|
11
|
+
// The string upstream's skill tells the model to relay for `/caveman status`; `off` is a mode, so it reads as one.
|
|
12
12
|
export function formatCavemanStatus(mode) {
|
|
13
|
-
return `Caveman mode: ${mode
|
|
13
|
+
return `Caveman mode: ${mode || 'unknown'}`;
|
|
14
14
|
}
|
|
15
15
|
// Stale ctx access throws, so never let it abort a mode restore.
|
|
16
16
|
function statusUi(ctx) {
|
|
@@ -27,6 +27,7 @@ let remembered;
|
|
|
27
27
|
let lastText;
|
|
28
28
|
let lastUi;
|
|
29
29
|
// Paint the host footer bar; same text on the same UI is skipped, a new ctx repaints it.
|
|
30
|
+
// One key only: the combo line already names the caveman mode, and a second key renders as a second footer line.
|
|
30
31
|
export function announceStatus(ctx) {
|
|
31
32
|
const state = getSharedComboState();
|
|
32
33
|
const text = formatStatus(state);
|
|
@@ -40,5 +41,4 @@ export function announceStatus(ctx) {
|
|
|
40
41
|
lastText = text;
|
|
41
42
|
lastUi = ui;
|
|
42
43
|
ui.setStatus?.('tersio', text);
|
|
43
|
-
ui.setStatus?.('caveman', formatCavemanStatus(state.caveman));
|
|
44
44
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
// Shared OMP extension host types. Structural — the real `pi` object comes from the OMP runtime
|
|
1
|
+
// Shared OMP extension host types. Structural — the real `pi` object comes from the OMP runtime.
|
|
2
2
|
export {};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Usage ledger: append-only JSON lines, read by the CLI, `/tersio`, and the Dashboard. Corrupt lines are skipped.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { homeDir, isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from '../lib/utils.js';
|
|
5
4
|
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { homeDir, isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from './utils.js';
|
|
6
|
+
import { sqliteRead } from './sqlite.js';
|
|
6
7
|
export function ledgerPath() {
|
|
7
8
|
const override = process.env.TERSIO_USAGE_FILE;
|
|
8
9
|
if (override)
|
|
@@ -50,7 +51,7 @@ function stampMs(ts) {
|
|
|
50
51
|
export function durOf(v) {
|
|
51
52
|
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
52
53
|
}
|
|
53
|
-
// Measured cost: usage.cost.total, or a bare number in old fixtures. Anything else means unrecorded,
|
|
54
|
+
// Measured cost: usage.cost.total, or a bare number in old fixtures. Anything else means unrecorded, not 0.
|
|
54
55
|
export function costOf(usage) {
|
|
55
56
|
const c = usage.cost;
|
|
56
57
|
if (typeof c === 'number')
|
|
@@ -87,7 +88,7 @@ export function sessionsDirs() {
|
|
|
87
88
|
const override = process.env.TERSIO_SESSIONS_DIR;
|
|
88
89
|
if (override)
|
|
89
90
|
return [override];
|
|
90
|
-
// Both hosts, not just the running one: a session
|
|
91
|
+
// Both hosts, not just the running one: a pi session is missing if we stop at the first existing directory.
|
|
91
92
|
const dirs = isPiProcess()
|
|
92
93
|
? [path.join(piAgentDir(), 'sessions'), path.join(homeDir(), '.omp', 'agent', 'sessions')]
|
|
93
94
|
: [path.join(homeDir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
|
|
@@ -129,22 +130,20 @@ export function opencodeDbPath() {
|
|
|
129
130
|
catch { /* fall through to the platform default */ }
|
|
130
131
|
return path.join(homeDir(), 'Library', 'Application Support', 'opencode', 'opencode.db');
|
|
131
132
|
}
|
|
132
|
-
//
|
|
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.
|
|
133
|
+
// `message` vs `session_message`: read scalars only, since the blob's newlines break the row-per-line output.
|
|
135
134
|
const OC_DB_ORDER = ' ORDER BY time_created;';
|
|
136
|
-
const OC_DB_COLUMNS = 'json_extract(data,\'$.time.created\'), json_extract(data,\'$.time.completed\'),' +
|
|
135
|
+
const OC_DB_COLUMNS = 'json_extract(data,\'$.time.created\'), json_extract(data,\'$.time.streamed\'), json_extract(data,\'$.time.completed\'),' +
|
|
137
136
|
' json_extract(data,\'$.tokens.input\'), json_extract(data,\'$.tokens.output\'),' +
|
|
138
137
|
' json_extract(data,\'$.tokens.cache.read\'), json_extract(data,\'$.tokens.cache.write\'), json_extract(data,\'$.cost\')';
|
|
139
|
-
const
|
|
138
|
+
const OC_DB_TOOLS = '(SELECT group_concat(json_extract(j.value,\'$.name\')) FROM json_each(data,\'$.content\') j' +
|
|
139
|
+
' WHERE json_extract(j.value,\'$.type\')=\'tool\')';
|
|
140
|
+
const ocDbLegacy = (since) => `SELECT id, time_created, json_extract(data,'$.role'), json_extract(data,'$.providerID'), json_extract(data,'$.modelID'), ${OC_DB_COLUMNS}, ${OC_DB_TOOLS}` +
|
|
140
141
|
` 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
|
+
const ocDbCurrent = (since) => `SELECT id, time_created, 'assistant', json_extract(data,'$.model.providerID'), json_extract(data,'$.model.id'), ${OC_DB_COLUMNS}, ${OC_DB_TOOLS}` +
|
|
142
143
|
` FROM session_message WHERE type='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
|
|
143
144
|
// A re-scan window covers tokens that land after the row was created, so a late fill is not lost.
|
|
144
145
|
export const OPENCODE_DB_OVERLAP_MS = 3_600_000;
|
|
145
|
-
//
|
|
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.
|
|
146
|
+
// Empty when no SQLite is available or the db is unreadable; that is a sync skip, not a failure.
|
|
148
147
|
export function readOpencodeDbRows(db, sinceMs) {
|
|
149
148
|
if (!Number.isFinite(sinceMs))
|
|
150
149
|
return [];
|
|
@@ -153,7 +152,7 @@ export function readOpencodeDbRows(db, sinceMs) {
|
|
|
153
152
|
return [];
|
|
154
153
|
const since = Math.max(0, Math.floor(sinceMs));
|
|
155
154
|
// Separate calls: a db holding only one table must not cancel the other.
|
|
156
|
-
const run = (sql) =>
|
|
155
|
+
const run = (sql) => sqliteRead(db, sql);
|
|
157
156
|
let out = '';
|
|
158
157
|
try {
|
|
159
158
|
out += run(ocDbLegacy(since));
|
|
@@ -167,17 +166,18 @@ export function readOpencodeDbRows(db, sinceMs) {
|
|
|
167
166
|
for (const line of out.split('\n')) {
|
|
168
167
|
if (!line.trim())
|
|
169
168
|
continue;
|
|
170
|
-
const [id, created, role, providerID, modelID, tCreated, tCompleted, input, output, cacheRead, cacheWrite, cost] = line.split('\t');
|
|
169
|
+
const [id, created, role, providerID, modelID, tCreated, tStreamed, tCompleted, input, output, cacheRead, cacheWrite, cost, tools] = line.split('\t');
|
|
171
170
|
const n = (v) => (v !== undefined && v !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined);
|
|
172
171
|
rows.push({
|
|
173
172
|
id,
|
|
174
173
|
created: Number(created),
|
|
174
|
+
tools: tools ?? '',
|
|
175
175
|
row: {
|
|
176
176
|
role,
|
|
177
177
|
modelID,
|
|
178
178
|
providerID,
|
|
179
179
|
tokens: { input: n(input), output: n(output), cache: { read: n(cacheRead), write: n(cacheWrite) } },
|
|
180
|
-
time: { created: n(tCreated), completed: n(tCompleted) },
|
|
180
|
+
time: { created: n(tCreated), streamed: n(tStreamed), completed: n(tCompleted) },
|
|
181
181
|
cost: n(cost),
|
|
182
182
|
},
|
|
183
183
|
});
|
|
@@ -223,9 +223,9 @@ const RTK_ELIGIBLE_HEADS = new Set([
|
|
|
223
223
|
'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
|
|
224
224
|
]);
|
|
225
225
|
export function newSessionAccum() {
|
|
226
|
-
return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
|
|
226
|
+
return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, byAgent: {}, byAgentMessages: {}, byModelMeasured: {}, byModelCostMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [], recentFloor: Number.POSITIVE_INFINITY };
|
|
227
227
|
}
|
|
228
|
-
export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host, id) {
|
|
228
|
+
export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host, id, agent, ttftMs) {
|
|
229
229
|
const watermark = readResetWatermark();
|
|
230
230
|
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
231
231
|
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
|
|
@@ -240,10 +240,31 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames,
|
|
|
240
240
|
hs[host] ??= zeroBreakdown();
|
|
241
241
|
addInto(hs[host], usage);
|
|
242
242
|
}
|
|
243
|
+
if (agent) {
|
|
244
|
+
const as = (accum.byAgent[key] ??= {});
|
|
245
|
+
as[agent] ??= zeroBreakdown();
|
|
246
|
+
addInto(as[agent], usage);
|
|
247
|
+
const am = (accum.byAgentMessages[key] ??= {});
|
|
248
|
+
am[agent] = (am[agent] ?? 0) + 1;
|
|
249
|
+
}
|
|
243
250
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
244
251
|
if (Number.isFinite(ms)) {
|
|
245
252
|
const outcome = run ?? { st: 'completed' };
|
|
246
|
-
|
|
253
|
+
// Only the newest rows are read: skip below the floor, and a 50k-row store costs 2000 rows of memory.
|
|
254
|
+
if (accum.recent.length < RECENT_LIMIT || ms >= accum.recentFloor) {
|
|
255
|
+
accum.recent.push({ m: key, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), tf: durOf(ttftMs), h: host, cr: num(usage.cacheRead), cw: num(usage.cacheWrite), usd: costOf(usage), st: outcome.st, code: outcome.code, note: outcome.note, id });
|
|
256
|
+
if (accum.recent.length > RECENT_LIMIT) {
|
|
257
|
+
let drop = 0;
|
|
258
|
+
for (let k = 1; k < accum.recent.length; k += 1)
|
|
259
|
+
if (accum.recent[k].t < accum.recent[drop].t)
|
|
260
|
+
drop = k;
|
|
261
|
+
accum.recent.splice(drop, 1);
|
|
262
|
+
accum.recentFloor = accum.recent.reduce((lo, r) => Math.min(lo, r.t), Number.POSITIVE_INFINITY);
|
|
263
|
+
}
|
|
264
|
+
else if (ms < accum.recentFloor) {
|
|
265
|
+
accum.recentFloor = ms;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
247
268
|
}
|
|
248
269
|
const day = ts !== undefined ? dayKey(ts) : null;
|
|
249
270
|
if (day) {
|
|
@@ -257,8 +278,11 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames,
|
|
|
257
278
|
}
|
|
258
279
|
accum.messages += 1;
|
|
259
280
|
const measured = costOf(usage);
|
|
260
|
-
if (measured !== undefined)
|
|
281
|
+
if (measured !== undefined) {
|
|
261
282
|
accum.costMeasured += measured;
|
|
283
|
+
accum.byModelMeasured[key] = (accum.byModelMeasured[key] ?? 0) + measured;
|
|
284
|
+
accum.byModelCostMessages[key] = (accum.byModelCostMessages[key] ?? 0) + 1;
|
|
285
|
+
}
|
|
262
286
|
for (const name of toolNames ?? [])
|
|
263
287
|
accum.byTool[name] = (accum.byTool[name] ?? 0) + 1;
|
|
264
288
|
return true;
|
|
@@ -300,7 +324,7 @@ export function classifySessionLine(row) {
|
|
|
300
324
|
const command = part.arguments?.command;
|
|
301
325
|
tools.push(`bash:${leadBinary(command)}`);
|
|
302
326
|
}
|
|
303
|
-
return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
|
|
327
|
+
return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, ttftMs: typeof msg.ttft === 'number' ? msg.ttft : undefined, run: statusOf(msg), tools };
|
|
304
328
|
}
|
|
305
329
|
// True when at least one token bucket holds a positive finite count.
|
|
306
330
|
export function hasPositiveUsage(usage) {
|
|
@@ -311,6 +335,25 @@ export function hasPositiveUsage(usage) {
|
|
|
311
335
|
}
|
|
312
336
|
return false;
|
|
313
337
|
}
|
|
338
|
+
/** Tool names an OpenCode message called; v2 passes SQL's comma-joined string as the blob holds newlines. */
|
|
339
|
+
export function opencodeToolNames(message, joined) {
|
|
340
|
+
if (joined !== undefined)
|
|
341
|
+
return joined === '' ? [] : joined.split(',');
|
|
342
|
+
const parts = message?.content;
|
|
343
|
+
if (!Array.isArray(parts))
|
|
344
|
+
return [];
|
|
345
|
+
const names = [];
|
|
346
|
+
for (const part of parts) {
|
|
347
|
+
if (!part || typeof part !== 'object')
|
|
348
|
+
continue;
|
|
349
|
+
if (part.type !== 'tool')
|
|
350
|
+
continue;
|
|
351
|
+
const name = part.name;
|
|
352
|
+
if (typeof name === 'string' && name)
|
|
353
|
+
names.push(name);
|
|
354
|
+
}
|
|
355
|
+
return names;
|
|
356
|
+
}
|
|
314
357
|
// One OpenCode message file; assistant rows carry the token buckets.
|
|
315
358
|
export function classifyOpencodeMessage(obj) {
|
|
316
359
|
if (!obj || typeof obj !== 'object' || obj.role !== 'assistant')
|
|
@@ -332,14 +375,16 @@ export function classifyOpencodeMessage(obj) {
|
|
|
332
375
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
|
|
333
376
|
const created = num(obj.time?.created);
|
|
334
377
|
const completed = num(obj.time?.completed);
|
|
378
|
+
// `streamed` is when the first token arrived, so it is the host's own first-token latency.
|
|
379
|
+
const streamed = num(obj.time?.streamed);
|
|
335
380
|
return {
|
|
336
381
|
model: `opencode/${provider}/${model}`,
|
|
337
382
|
usage,
|
|
338
383
|
ms: created,
|
|
339
384
|
durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
|
|
385
|
+
ttftMs: created !== undefined && streamed !== undefined && streamed > created ? streamed - created : undefined,
|
|
340
386
|
};
|
|
341
387
|
}
|
|
342
|
-
// One OpenCode message file into the shared accum. The msg id is the file name.
|
|
343
388
|
export function processOpencodeFile(accum, text, file) {
|
|
344
389
|
let obj;
|
|
345
390
|
try {
|
|
@@ -348,18 +393,19 @@ export function processOpencodeFile(accum, text, file) {
|
|
|
348
393
|
catch {
|
|
349
394
|
return;
|
|
350
395
|
}
|
|
351
|
-
const
|
|
396
|
+
const message = obj;
|
|
397
|
+
const parsed = classifyOpencodeMessage(message);
|
|
352
398
|
if (!parsed)
|
|
353
399
|
return;
|
|
354
400
|
const id = file ? path.basename(file).replace(/\.[^.]+$/, '') || undefined : undefined;
|
|
355
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' },
|
|
401
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, opencodeToolNames(message), 'opencode', id, 'main', parsed.ttftMs);
|
|
356
402
|
}
|
|
357
403
|
// One v2 message row into the shared accum. The msg id is the row primary key.
|
|
358
404
|
export function ingestOpencodeDbRow(accum, row) {
|
|
359
405
|
const parsed = classifyOpencodeMessage(row.row);
|
|
360
406
|
if (!parsed)
|
|
361
407
|
return;
|
|
362
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' },
|
|
408
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, opencodeToolNames(row.row, row.tools), 'opencode', row.id, 'main', parsed.ttftMs);
|
|
363
409
|
}
|
|
364
410
|
// Fold `:free`/`-free` suffixes and case variants into one chart key.
|
|
365
411
|
export function canonicalModelId(model) {
|
|
@@ -370,8 +416,7 @@ export function messageRowId(row) {
|
|
|
370
416
|
const id = row.id;
|
|
371
417
|
return typeof id === 'string' && id ? id : undefined;
|
|
372
418
|
}
|
|
373
|
-
// Vendor names mirror the dashboard map;
|
|
374
|
-
// gateway segments shadow it (minimaxai holds xai, gemma rides nvidia).
|
|
419
|
+
// Vendor names mirror the dashboard map; family first, so gateway segments shadow it (minimaxai holds xai).
|
|
375
420
|
const VENDOR_RES = [
|
|
376
421
|
[/inclusionai|ling-/i, 'InclusionAI'],
|
|
377
422
|
[/minimax/i, 'MiniMax'],
|
|
@@ -491,10 +536,10 @@ export function importSessionTokens() {
|
|
|
491
536
|
set codexProvider(v) { codexProvider = v; },
|
|
492
537
|
get codexModel() { return codexModel; },
|
|
493
538
|
set codexModel(v) { codexModel = v; },
|
|
494
|
-
}, text, hostOfSessionFile(file));
|
|
539
|
+
}, text, hostOfSessionFile(file), agentOfSessionText(text));
|
|
495
540
|
}
|
|
496
541
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
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) };
|
|
542
|
+
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, byAgent: accum.byAgent, byAgentMessages: accum.byAgentMessages, byModelMeasured: accum.byModelMeasured, byModelCostMessages: accum.byModelCostMessages, costMeasured: accum.costMeasured, recent: accum.recent.slice(0, RECENT_LIMIT) };
|
|
498
543
|
}
|
|
499
544
|
// Which agent wrote a session file, from the directory it sits in.
|
|
500
545
|
export function hostOfSessionFile(file) {
|
|
@@ -507,6 +552,22 @@ export function hostOfSessionFile(file) {
|
|
|
507
552
|
return 'opencode';
|
|
508
553
|
return 'pi';
|
|
509
554
|
}
|
|
555
|
+
// A transcript with a parent session ran as a subagent; the host's own row, so a mirror answers the same.
|
|
556
|
+
export function agentOfSessionText(text) {
|
|
557
|
+
if (!text.includes('parentSession'))
|
|
558
|
+
return 'main';
|
|
559
|
+
for (const line of text.split('\n')) {
|
|
560
|
+
if (!line.includes('parentSession'))
|
|
561
|
+
continue;
|
|
562
|
+
try {
|
|
563
|
+
const row = JSON.parse(line);
|
|
564
|
+
if (row.type === 'session' && typeof row.parentSession === 'string' && row.parentSession !== '')
|
|
565
|
+
return 'subagent';
|
|
566
|
+
}
|
|
567
|
+
catch { /* skip a corrupt line */ }
|
|
568
|
+
}
|
|
569
|
+
return 'main';
|
|
570
|
+
}
|
|
510
571
|
const adoptionFileCache = new Map();
|
|
511
572
|
function parseAdoptionFile(file) {
|
|
512
573
|
const counts = { bashCalls: 0, eligibleCalls: 0, rtkCalls: 0 };
|
|
@@ -581,7 +642,7 @@ export function readRtkAdoption() {
|
|
|
581
642
|
const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
|
|
582
643
|
return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
|
|
583
644
|
}
|
|
584
|
-
export function processSessionText(accum, state, text, host) {
|
|
645
|
+
export function processSessionText(accum, state, text, host, agent) {
|
|
585
646
|
for (const line of text.split('\n')) {
|
|
586
647
|
if (!line.trim())
|
|
587
648
|
continue;
|
|
@@ -603,12 +664,12 @@ export function processSessionText(accum, state, text, host) {
|
|
|
603
664
|
const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
|
|
604
665
|
// The provider alone told you nothing about which model ran.
|
|
605
666
|
const model = state.codexModel ? `${base}/${state.codexModel}` : base;
|
|
606
|
-
ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' }, undefined, host ?? 'codex', rid);
|
|
667
|
+
ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' }, undefined, host ?? 'codex', rid, agent);
|
|
607
668
|
continue;
|
|
608
669
|
}
|
|
609
670
|
if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
|
|
610
671
|
continue;
|
|
611
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid);
|
|
672
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid, agent, parsed.ttftMs);
|
|
612
673
|
}
|
|
613
674
|
catch { /* skip corrupt lines */ }
|
|
614
675
|
}
|
|
@@ -652,14 +713,25 @@ export function leadBinary(command) {
|
|
|
652
713
|
}
|
|
653
714
|
return 'bash';
|
|
654
715
|
}
|
|
655
|
-
//
|
|
656
|
-
import { DEFAULT_PRICE, canonicalPriceId, priceFor, refreshPricesIfStale } from './pricing.js';
|
|
716
|
+
// Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table).
|
|
717
|
+
import { DEFAULT_PRICE, PRICE_TIERS_FIRM, canonicalPriceId, priceFor, pricesOverridePath, refreshPricesIfStale } from './pricing.js';
|
|
657
718
|
import { co2GramsFor, energyWhFor } from './carbon.js';
|
|
658
|
-
export { DEFAULT_PRICE, priceFor, refreshPricesIfStale };
|
|
719
|
+
export { DEFAULT_PRICE, PRICE_TIERS_FIRM, priceFor, pricesOverridePath, refreshPricesIfStale };
|
|
659
720
|
export { co2GramsFor, energyWhFor };
|
|
660
|
-
|
|
661
|
-
|
|
721
|
+
/** Price one model's tokens; `measured` outranks every rate card, so pass undefined when unreported. */
|
|
722
|
+
export function usdCost(t, model, measured) {
|
|
662
723
|
const m = 1 / 1_000_000;
|
|
724
|
+
if (measured !== undefined) {
|
|
725
|
+
// Only a bare total arrives, so it attributes to input, the largest bucket for every model here.
|
|
726
|
+
return {
|
|
727
|
+
usd: measured,
|
|
728
|
+
priced: true,
|
|
729
|
+
tier: 'measured',
|
|
730
|
+
buckets: { input: measured, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
const hit = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false, live: false, tier: 'default' };
|
|
734
|
+
const price = hit.price;
|
|
663
735
|
const buckets = {
|
|
664
736
|
input: t.input * price.input * m,
|
|
665
737
|
output: t.output * price.output * m,
|
|
@@ -668,7 +740,9 @@ export function usdCost(t, model) {
|
|
|
668
740
|
};
|
|
669
741
|
return {
|
|
670
742
|
usd: buckets.input + buckets.output + buckets.cacheRead + buckets.cacheWrite,
|
|
671
|
-
|
|
743
|
+
// `tail`/`default` are estimates; summing them beside measured cost reads as a bill it is not.
|
|
744
|
+
priced: PRICE_TIERS_FIRM.includes(hit.tier),
|
|
745
|
+
tier: hit.tier,
|
|
672
746
|
buckets,
|
|
673
747
|
};
|
|
674
748
|
}
|
|
@@ -697,7 +771,7 @@ export function readUsage() {
|
|
|
697
771
|
}
|
|
698
772
|
return rows;
|
|
699
773
|
}
|
|
700
|
-
// Delete the ledger file and return the rows cleared. Tersio-owned data only: transcripts and the RTK database
|
|
774
|
+
// Delete the ledger file and return the rows cleared. Tersio-owned data only: transcripts and the RTK database stay.
|
|
701
775
|
export function clearUsageLedger() {
|
|
702
776
|
const rows = readUsage().length;
|
|
703
777
|
try {
|