@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
|
+
|
|
18
|
+
/** A read this large used to vanish behind a 1MB cap, which cleared on one busy session_message table. */
|
|
19
|
+
const SQLITE_READ_BUFFER = 50 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
const TIMEOUT = 30_000;
|
|
22
|
+
const requireOptional = createRequire(import.meta.url);
|
|
23
|
+
|
|
24
|
+
interface Engine {
|
|
25
|
+
kind: 'bun' | 'node';
|
|
26
|
+
read(db: string, sql: string): unknown[][];
|
|
27
|
+
exec(db: string, sql: string): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface BunDatabase {
|
|
31
|
+
query(sql: string): { values(): unknown[][] };
|
|
32
|
+
exec(sql: string): void;
|
|
33
|
+
close(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface NodeStatement {
|
|
37
|
+
all(): unknown[][];
|
|
38
|
+
setReturnArrays?(on: boolean): unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface NodeDatabase {
|
|
42
|
+
prepare(sql: string): NodeStatement;
|
|
43
|
+
exec(sql: string): void;
|
|
44
|
+
close(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let cached: Engine | null | undefined;
|
|
48
|
+
let cachedFor: string | undefined;
|
|
49
|
+
|
|
50
|
+
/** Bun's SQLite, or Node's when this build carries one. Null when neither runtime has an engine. */
|
|
51
|
+
function engine(): Engine | null {
|
|
52
|
+
const asked = process.env.TERSIO_SQLITE ?? '';
|
|
53
|
+
if (cached !== undefined && cachedFor === asked) return cached;
|
|
54
|
+
cachedFor = asked;
|
|
55
|
+
cached = null;
|
|
56
|
+
if (asked === 'cli') return cached;
|
|
57
|
+
try {
|
|
58
|
+
const { Database } = requireOptional('bun:sqlite') as { Database: new (db: string, opts?: { readonly?: boolean }) => BunDatabase };
|
|
59
|
+
cached = {
|
|
60
|
+
kind: 'bun',
|
|
61
|
+
read: (db, sql) => { const d = new Database(db, { readonly: true }); try { return d.query(sql).values(); } finally { d.close(); } },
|
|
62
|
+
exec: (db, sql) => { const d = new Database(db); try { d.exec(sql); } finally { d.close(); } },
|
|
63
|
+
};
|
|
64
|
+
return cached;
|
|
65
|
+
} catch { /* not Bun, or no SQLite in this build */ }
|
|
66
|
+
try {
|
|
67
|
+
const { DatabaseSync } = requireOptional('node:sqlite') as { DatabaseSync: new (db: string, opts?: { readOnly?: boolean }) => NodeDatabase };
|
|
68
|
+
// Rows arrive keyed by column name unless the statement returns arrays. A duplicate name collapses
|
|
69
|
+
// its key and shifts every later cell, so a Node that cannot return arrays is no engine at all.
|
|
70
|
+
const probe = new DatabaseSync(':memory:');
|
|
71
|
+
try {
|
|
72
|
+
if (typeof probe.prepare('SELECT 1').setReturnArrays !== 'function') throw new Error('no positional rows');
|
|
73
|
+
} finally {
|
|
74
|
+
probe.close();
|
|
75
|
+
}
|
|
76
|
+
cached = {
|
|
77
|
+
kind: 'node',
|
|
78
|
+
read: (db, sql) => {
|
|
79
|
+
const d = new DatabaseSync(db, { readOnly: true });
|
|
80
|
+
try {
|
|
81
|
+
const st = d.prepare(sql);
|
|
82
|
+
st.setReturnArrays?.(true);
|
|
83
|
+
return st.all();
|
|
84
|
+
} finally { d.close(); }
|
|
85
|
+
},
|
|
86
|
+
exec: (db, sql) => { const d = new DatabaseSync(db); try { d.exec(sql); } finally { d.close(); } },
|
|
87
|
+
};
|
|
88
|
+
return cached;
|
|
89
|
+
} catch { /* Node older than 22.5, or one built without SQLite or positional rows */ }
|
|
90
|
+
return cached;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The `sqlite3` binary, resolved once from PATH so a missing one costs one scan, not one spawn per call. */
|
|
94
|
+
let binary: string | null | undefined;
|
|
95
|
+
function findBinary(): string | null {
|
|
96
|
+
if (binary !== undefined) return binary;
|
|
97
|
+
binary = null;
|
|
98
|
+
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
|
99
|
+
if (!dir) continue;
|
|
100
|
+
for (const name of process.platform === 'win32' ? ['sqlite3.exe', 'sqlite3'] : ['sqlite3']) {
|
|
101
|
+
const at = path.join(dir, name);
|
|
102
|
+
try {
|
|
103
|
+
fs.accessSync(at, fs.constants.X_OK);
|
|
104
|
+
binary = at;
|
|
105
|
+
return binary;
|
|
106
|
+
} catch { /* not here */ }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return binary;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `bun`, `node`, `cli`, or null when this host has no way to read SQLite at all. */
|
|
113
|
+
export function sqliteEngine(): 'bun' | 'node' | 'cli' | null {
|
|
114
|
+
return engine()?.kind ?? (findBinary() === null ? null : 'cli');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Whether any path can run SQL: what the tests guard their skips on. */
|
|
118
|
+
export function sqliteAvailable(): boolean {
|
|
119
|
+
return sqliteEngine() !== null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Rows as the CLI prints them, so a caller cannot tell which engine answered. */
|
|
123
|
+
function toTsv(rows: unknown[][]): string {
|
|
124
|
+
if (rows.length === 0) return '';
|
|
125
|
+
return `${rows.map((row) => row.map((cell) => (cell === null || cell === undefined ? '' : String(cell))).join('\t')).join('\n')}\n`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function cliRead(db: string, sql: string): string {
|
|
129
|
+
const opts = { encoding: 'utf8' as const, timeout: TIMEOUT, stdio: ['pipe', 'pipe', 'ignore'] as ['pipe', 'pipe', 'ignore'], maxBuffer: SQLITE_READ_BUFFER, input: sql };
|
|
130
|
+
try {
|
|
131
|
+
return execFileSync('sqlite3', ['-separator', '\t', '-readonly', db], opts);
|
|
132
|
+
} catch { /* the side files may be gone; try the next rung */ }
|
|
133
|
+
if (!fs.existsSync(`${db}-wal`)) {
|
|
134
|
+
try {
|
|
135
|
+
return execFileSync('sqlite3', ['-separator', '\t', `file:${db}?immutable=1`], opts);
|
|
136
|
+
} catch { /* fall through to the copy */ }
|
|
137
|
+
}
|
|
138
|
+
// A live database cannot be read without its write-ahead log, so copy the set and read the copy.
|
|
139
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tersio-sqlite-'));
|
|
140
|
+
const target = path.join(dir, path.basename(db));
|
|
141
|
+
try {
|
|
142
|
+
fs.copyFileSync(db, target);
|
|
143
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
144
|
+
if (fs.existsSync(`${db}${suffix}`)) fs.copyFileSync(`${db}${suffix}`, `${target}${suffix}`);
|
|
145
|
+
}
|
|
146
|
+
return execFileSync('sqlite3', ['-separator', '\t', '-readonly', target], opts);
|
|
147
|
+
} finally {
|
|
148
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Run a read and return the tab-separated rows. Throws when the file is missing, as the callers expect. */
|
|
153
|
+
export function sqliteRead(db: string, sql: string): string {
|
|
154
|
+
if (!fs.existsSync(db)) throw new Error(`missing database: ${db}`);
|
|
155
|
+
const e = engine();
|
|
156
|
+
if (e !== null) return toTsv(e.read(db, sql));
|
|
157
|
+
return cliRead(db, sql);
|
|
158
|
+
}
|
|
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: string, sql: string): void {
|
|
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,4 +1,4 @@
|
|
|
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.ts';
|
|
3
3
|
import type { ExtensionCtx, UiApi } from './types.ts';
|
|
4
4
|
|
|
@@ -7,19 +7,20 @@ export interface StatusState {
|
|
|
7
7
|
caveman: string;
|
|
8
8
|
rtk: string;
|
|
9
9
|
ponytail: string;
|
|
10
|
+
pstack: string;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
/** OFF collapses
|
|
13
|
+
/** OFF collapses every mode into one word; a preset or a mix names itself. */
|
|
13
14
|
export function formatStatus(state: StatusState): string {
|
|
14
|
-
const { level, caveman, rtk, ponytail } = state;
|
|
15
|
-
if (level === 'off' && caveman === 'off' && rtk === 'off' && ponytail === 'off') return '🧩 combo OFF';
|
|
15
|
+
const { level, caveman, rtk, ponytail, pstack } = state;
|
|
16
|
+
if (level === 'off' && caveman === 'off' && rtk === 'off' && ponytail === 'off' && pstack === 'off') return '🧩 combo OFF';
|
|
16
17
|
const label = level === 'custom' ? 'CUSTOM' : level.toUpperCase();
|
|
17
|
-
return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()}`;
|
|
18
|
+
return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()} 🥔pstack=${pstack.toUpperCase()}`;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
// The string upstream's skill tells the model to relay for `/caveman status
|
|
21
|
+
// The string upstream's skill tells the model to relay for `/caveman status`; `off` is a mode, so it reads as one.
|
|
21
22
|
export function formatCavemanStatus(mode: string): string {
|
|
22
|
-
return `Caveman mode: ${mode
|
|
23
|
+
return `Caveman mode: ${mode || 'unknown'}`;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
// Stale ctx access throws, so never let it abort a mode restore.
|
|
@@ -38,6 +39,7 @@ let lastText: string | undefined;
|
|
|
38
39
|
let lastUi: UiApi | undefined;
|
|
39
40
|
|
|
40
41
|
// Paint the host footer bar; same text on the same UI is skipped, a new ctx repaints it.
|
|
42
|
+
// One key only: the combo line already names the caveman mode, and a second key renders as a second footer line.
|
|
41
43
|
export function announceStatus(ctx: ExtensionCtx | undefined): void {
|
|
42
44
|
const state = getSharedComboState();
|
|
43
45
|
const text = formatStatus(state);
|
|
@@ -48,5 +50,4 @@ export function announceStatus(ctx: ExtensionCtx | undefined): void {
|
|
|
48
50
|
lastText = text;
|
|
49
51
|
lastUi = ui;
|
|
50
52
|
ui.setStatus?.('tersio', text);
|
|
51
|
-
ui.setStatus?.('caveman', formatCavemanStatus(state.caveman));
|
|
52
53
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
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
|
|
|
3
3
|
export interface ComboState {
|
|
4
4
|
level: string;
|
|
5
5
|
caveman: string;
|
|
6
6
|
rtk: string;
|
|
7
7
|
ponytail: string;
|
|
8
|
+
pstack: string;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export type ComboLevel = 'off' | 'medium' | 'balanced' | 'max' | 'custom';
|
|
@@ -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.ts';
|
|
5
4
|
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { homeDir, isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from './utils.ts';
|
|
6
|
+
import { sqliteRead } from './sqlite.ts';
|
|
6
7
|
|
|
7
8
|
export type UsageKind = 'command' | 'toggle' | 'install' | 'update' | 'rtk-audit';
|
|
8
9
|
|
|
@@ -50,7 +51,7 @@ export function appendUsage(kind: UsageKind, detail: string): void {
|
|
|
50
51
|
} catch { /* ledger is best-effort; never break the caller */ }
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
//
|
|
54
|
+
// Session tokens, tokscale-style: assistant messages in the host transcripts carry usage, model, and a timestamp.
|
|
54
55
|
export interface TokenBreakdown {
|
|
55
56
|
input: number;
|
|
56
57
|
output: number;
|
|
@@ -67,6 +68,8 @@ export interface RecentRequest {
|
|
|
67
68
|
o: number;
|
|
68
69
|
t: number;
|
|
69
70
|
d?: number;
|
|
71
|
+
/** First-token latency, when the host recorded one. */
|
|
72
|
+
tf?: number;
|
|
70
73
|
/** Which agent wrote the session: pi, omp, or codex. */
|
|
71
74
|
h?: string;
|
|
72
75
|
cr?: number;
|
|
@@ -89,6 +92,14 @@ export interface SessionTokens {
|
|
|
89
92
|
byTool: Record<string, number>;
|
|
90
93
|
byModelMessages: Record<string, number>;
|
|
91
94
|
byHost: Record<string, Record<string, TokenBreakdown>>;
|
|
95
|
+
/** Model -> agent kind (`main`, `subagent`), so a subagent's work reads apart from its root's. */
|
|
96
|
+
byAgent: Record<string, Record<string, TokenBreakdown>>;
|
|
97
|
+
/** Model -> agent kind -> message count, so a share can read requests beside the tokens. */
|
|
98
|
+
byAgentMessages: Record<string, Record<string, number>>;
|
|
99
|
+
/** Model -> cost the host reported, over the messages that carried one. */
|
|
100
|
+
byModelMeasured: Record<string, number>;
|
|
101
|
+
/** Model -> messages that carried a cost. Compare to `byModelMessages` before trusting the sum. */
|
|
102
|
+
byModelCostMessages: Record<string, number>;
|
|
92
103
|
costMeasured: number;
|
|
93
104
|
recent: RecentRequest[];
|
|
94
105
|
}
|
|
@@ -119,7 +130,7 @@ export function durOf(v: unknown): number | undefined {
|
|
|
119
130
|
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
120
131
|
}
|
|
121
132
|
|
|
122
|
-
// Measured cost: usage.cost.total, or a bare number in old fixtures. Anything else means unrecorded,
|
|
133
|
+
// Measured cost: usage.cost.total, or a bare number in old fixtures. Anything else means unrecorded, not 0.
|
|
123
134
|
export function costOf(usage: Record<string, unknown>): number | undefined {
|
|
124
135
|
const c = usage.cost;
|
|
125
136
|
if (typeof c === 'number') return Number.isFinite(c) ? c : undefined;
|
|
@@ -154,7 +165,7 @@ function addInto(into: TokenBreakdown, u: { input?: unknown; output?: unknown; c
|
|
|
154
165
|
export function sessionsDirs(): string[] {
|
|
155
166
|
const override = process.env.TERSIO_SESSIONS_DIR;
|
|
156
167
|
if (override) return [override];
|
|
157
|
-
// Both hosts, not just the running one: a session
|
|
168
|
+
// Both hosts, not just the running one: a pi session is missing if we stop at the first existing directory.
|
|
158
169
|
const dirs = isPiProcess()
|
|
159
170
|
? [path.join(piAgentDir(), 'sessions'), path.join(homeDir(), '.omp', 'agent', 'sessions')]
|
|
160
171
|
: [path.join(homeDir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
|
|
@@ -196,55 +207,55 @@ export interface OpencodeDbRow {
|
|
|
196
207
|
id: string;
|
|
197
208
|
created: number;
|
|
198
209
|
row: OpencodeMessage;
|
|
210
|
+
/** Tool names this message called, comma-joined. Empty when it called none. */
|
|
211
|
+
tools?: string;
|
|
199
212
|
}
|
|
200
213
|
|
|
201
|
-
//
|
|
202
|
-
// current `session_message` (role in the `type` column, model under data.model).
|
|
203
|
-
// Scalars only: the `data` blob holds newlines and tabs, which the sqlite3 CLI would split.
|
|
214
|
+
// `message` vs `session_message`: read scalars only, since the blob's newlines break the row-per-line output.
|
|
204
215
|
const OC_DB_ORDER = ' ORDER BY time_created;';
|
|
205
216
|
const OC_DB_COLUMNS =
|
|
206
|
-
'json_extract(data,\'$.time.created\'), json_extract(data,\'$.time.completed\'),' +
|
|
217
|
+
'json_extract(data,\'$.time.created\'), json_extract(data,\'$.time.streamed\'), json_extract(data,\'$.time.completed\'),' +
|
|
207
218
|
' json_extract(data,\'$.tokens.input\'), json_extract(data,\'$.tokens.output\'),' +
|
|
208
219
|
' json_extract(data,\'$.tokens.cache.read\'), json_extract(data,\'$.tokens.cache.write\'), json_extract(data,\'$.cost\')';
|
|
220
|
+
const OC_DB_TOOLS =
|
|
221
|
+
'(SELECT group_concat(json_extract(j.value,\'$.name\')) FROM json_each(data,\'$.content\') j' +
|
|
222
|
+
' WHERE json_extract(j.value,\'$.type\')=\'tool\')';
|
|
209
223
|
const ocDbLegacy = (since: number): string =>
|
|
210
|
-
`SELECT id, time_created, json_extract(data,'$.role'), json_extract(data,'$.providerID'), json_extract(data,'$.modelID'), ${OC_DB_COLUMNS}` +
|
|
224
|
+
`SELECT id, time_created, json_extract(data,'$.role'), json_extract(data,'$.providerID'), json_extract(data,'$.modelID'), ${OC_DB_COLUMNS}, ${OC_DB_TOOLS}` +
|
|
211
225
|
` FROM message WHERE json_extract(data,'$.role')='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
|
|
212
226
|
const ocDbCurrent = (since: number): string =>
|
|
213
|
-
`SELECT id, time_created, 'assistant', json_extract(data,'$.model.providerID'), json_extract(data,'$.model.id'), ${OC_DB_COLUMNS}` +
|
|
227
|
+
`SELECT id, time_created, 'assistant', json_extract(data,'$.model.providerID'), json_extract(data,'$.model.id'), ${OC_DB_COLUMNS}, ${OC_DB_TOOLS}` +
|
|
214
228
|
` FROM session_message WHERE type='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
|
|
215
229
|
|
|
216
230
|
// A re-scan window covers tokens that land after the row was created, so a late fill is not lost.
|
|
217
231
|
export const OPENCODE_DB_OVERLAP_MS = 3_600_000;
|
|
218
232
|
|
|
219
|
-
//
|
|
220
|
-
export const SQLITE_READ_BUFFER = 50 * 1024 * 1024;
|
|
221
|
-
|
|
222
|
-
// Empty when sqlite3 is absent or the db is unreadable; that is a sync skip, not a failure.
|
|
233
|
+
// Empty when no SQLite is available or the db is unreadable; that is a sync skip, not a failure.
|
|
223
234
|
export function readOpencodeDbRows(db: string, sinceMs: number): OpencodeDbRow[] {
|
|
224
235
|
if (!Number.isFinite(sinceMs)) return [];
|
|
225
236
|
try {
|
|
226
237
|
if (!fs.existsSync(db)) return [];
|
|
227
238
|
const since = Math.max(0, Math.floor(sinceMs));
|
|
228
239
|
// Separate calls: a db holding only one table must not cancel the other.
|
|
229
|
-
const run = (sql: string): string =>
|
|
230
|
-
execFileSync('sqlite3', ['-separator', '\t', '-readonly', db, sql], { encoding: 'utf8', timeout: 30000, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: SQLITE_READ_BUFFER });
|
|
240
|
+
const run = (sql: string): string => sqliteRead(db, sql);
|
|
231
241
|
let out = '';
|
|
232
242
|
try { out += run(ocDbLegacy(since)); } catch { /* no legacy table or unreadable */ }
|
|
233
243
|
try { out += run(ocDbCurrent(since)); } catch { /* no session_message table or unreadable */ }
|
|
234
244
|
const rows: OpencodeDbRow[] = [];
|
|
235
245
|
for (const line of out.split('\n')) {
|
|
236
246
|
if (!line.trim()) continue;
|
|
237
|
-
const [id, created, role, providerID, modelID, tCreated, tCompleted, input, output, cacheRead, cacheWrite, cost] = line.split('\t');
|
|
247
|
+
const [id, created, role, providerID, modelID, tCreated, tStreamed, tCompleted, input, output, cacheRead, cacheWrite, cost, tools] = line.split('\t');
|
|
238
248
|
const n = (v: string | undefined): unknown => (v !== undefined && v !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined);
|
|
239
249
|
rows.push({
|
|
240
250
|
id,
|
|
241
251
|
created: Number(created),
|
|
252
|
+
tools: tools ?? '',
|
|
242
253
|
row: {
|
|
243
254
|
role,
|
|
244
255
|
modelID,
|
|
245
256
|
providerID,
|
|
246
257
|
tokens: { input: n(input), output: n(output), cache: { read: n(cacheRead), write: n(cacheWrite) } },
|
|
247
|
-
time: { created: n(tCreated), completed: n(tCompleted) },
|
|
258
|
+
time: { created: n(tCreated), streamed: n(tStreamed), completed: n(tCompleted) },
|
|
248
259
|
cost: n(cost),
|
|
249
260
|
},
|
|
250
261
|
});
|
|
@@ -292,15 +303,25 @@ export interface SessionAccum {
|
|
|
292
303
|
byTool: Record<string, number>;
|
|
293
304
|
byModelMessages: Record<string, number>;
|
|
294
305
|
byHost: Record<string, Record<string, TokenBreakdown>>;
|
|
306
|
+
/** Model -> agent kind (`main`, `subagent`), so a subagent's work reads apart from its root's. */
|
|
307
|
+
byAgent: Record<string, Record<string, TokenBreakdown>>;
|
|
308
|
+
/** Model -> agent kind -> message count. */
|
|
309
|
+
byAgentMessages: Record<string, Record<string, number>>;
|
|
310
|
+
/** Model -> cost the host itself reported, summed over the messages that carried one. */
|
|
311
|
+
byModelMeasured: Record<string, number>;
|
|
312
|
+
/** Model -> messages that carried a cost. Compare to `byModelMessages` before trusting the sum. */
|
|
313
|
+
byModelCostMessages: Record<string, number>;
|
|
295
314
|
totals: TokenBreakdown;
|
|
296
315
|
messages: number;
|
|
297
316
|
costMeasured: number;
|
|
298
317
|
recent: RecentRequest[];
|
|
318
|
+
/** Oldest timestamp held in `recent`, so the list stays bounded instead of growing with the store. */
|
|
319
|
+
recentFloor: number;
|
|
299
320
|
}
|
|
300
321
|
export function newSessionAccum(): SessionAccum {
|
|
301
|
-
return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
|
|
322
|
+
return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, byAgent: {}, byAgentMessages: {}, byModelMeasured: {}, byModelCostMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [], recentFloor: Number.POSITIVE_INFINITY };
|
|
302
323
|
}
|
|
303
|
-
export function ingestSessionRow(accum: SessionAccum, model: string, usage: Record<string, unknown>, ts: string | number | undefined, durMs?: unknown, run?: { st: RunStatus; code?: number; note?: string }, toolNames?: string[], host?: string, id?: string): boolean {
|
|
324
|
+
export function ingestSessionRow(accum: SessionAccum, model: string, usage: Record<string, unknown>, ts: string | number | undefined, durMs?: unknown, run?: { st: RunStatus; code?: number; note?: string }, toolNames?: string[], host?: string, id?: string, agent?: string, ttftMs?: unknown): boolean {
|
|
304
325
|
const watermark = readResetWatermark();
|
|
305
326
|
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
306
327
|
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark)) return false;
|
|
@@ -314,10 +335,28 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
|
|
|
314
335
|
hs[host] ??= zeroBreakdown();
|
|
315
336
|
addInto(hs[host], usage);
|
|
316
337
|
}
|
|
338
|
+
if (agent) {
|
|
339
|
+
const as = (accum.byAgent[key] ??= {});
|
|
340
|
+
as[agent] ??= zeroBreakdown();
|
|
341
|
+
addInto(as[agent], usage);
|
|
342
|
+
const am = (accum.byAgentMessages[key] ??= {});
|
|
343
|
+
am[agent] = (am[agent] ?? 0) + 1;
|
|
344
|
+
}
|
|
317
345
|
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
318
346
|
if (Number.isFinite(ms)) {
|
|
319
347
|
const outcome = run ?? { st: 'completed' as RunStatus };
|
|
320
|
-
|
|
348
|
+
// Only the newest rows are read: skip below the floor, and a 50k-row store costs 2000 rows of memory.
|
|
349
|
+
if (accum.recent.length < RECENT_LIMIT || ms >= accum.recentFloor) {
|
|
350
|
+
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 });
|
|
351
|
+
if (accum.recent.length > RECENT_LIMIT) {
|
|
352
|
+
let drop = 0;
|
|
353
|
+
for (let k = 1; k < accum.recent.length; k += 1) if (accum.recent[k].t < accum.recent[drop].t) drop = k;
|
|
354
|
+
accum.recent.splice(drop, 1);
|
|
355
|
+
accum.recentFloor = accum.recent.reduce((lo, r) => Math.min(lo, r.t), Number.POSITIVE_INFINITY);
|
|
356
|
+
} else if (ms < accum.recentFloor) {
|
|
357
|
+
accum.recentFloor = ms;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
321
360
|
}
|
|
322
361
|
const day = ts !== undefined ? dayKey(ts) : null;
|
|
323
362
|
if (day) {
|
|
@@ -331,12 +370,16 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
|
|
|
331
370
|
}
|
|
332
371
|
accum.messages += 1;
|
|
333
372
|
const measured = costOf(usage);
|
|
334
|
-
if (measured !== undefined)
|
|
373
|
+
if (measured !== undefined) {
|
|
374
|
+
accum.costMeasured += measured;
|
|
375
|
+
accum.byModelMeasured[key] = (accum.byModelMeasured[key] ?? 0) + measured;
|
|
376
|
+
accum.byModelCostMessages[key] = (accum.byModelCostMessages[key] ?? 0) + 1;
|
|
377
|
+
}
|
|
335
378
|
for (const name of toolNames ?? []) accum.byTool[name] = (accum.byTool[name] ?? 0) + 1;
|
|
336
379
|
return true;
|
|
337
380
|
}
|
|
338
381
|
export type SessionLineKind = 'codex_provider' | 'codex_model' | 'token_row' | 'assistant_row' | 'skip';
|
|
339
|
-
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 } {
|
|
382
|
+
export function classifySessionLine(row: { timestamp?: string | number; type?: unknown; message?: { role?: unknown; model?: unknown; usage?: Record<string, unknown>; duration?: unknown; ttft?: 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; ttftMs?: number; run?: { st: RunStatus; code?: number; note?: string }; tools?: string[]; ms?: number } {
|
|
340
383
|
const payload = row.payload;
|
|
341
384
|
if (payload && typeof payload === 'object') {
|
|
342
385
|
if (row.type === 'session_meta' && typeof payload.model_provider === 'string') return { kind: 'codex_provider', provider: payload.model_provider };
|
|
@@ -370,7 +413,7 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
|
|
|
370
413
|
const command = (part.arguments as { command?: unknown } | null)?.command;
|
|
371
414
|
tools.push(`bash:${leadBinary(command)}`);
|
|
372
415
|
}
|
|
373
|
-
return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
|
|
416
|
+
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 };
|
|
374
417
|
}
|
|
375
418
|
// True when at least one token bucket holds a positive finite count.
|
|
376
419
|
export function hasPositiveUsage(usage: Record<string, unknown>): boolean {
|
|
@@ -386,13 +429,30 @@ export interface OpencodeMessage {
|
|
|
386
429
|
tokens?: { input?: unknown; output?: unknown; reasoning?: unknown; cache?: { read?: unknown; write?: unknown } } | null;
|
|
387
430
|
modelID?: unknown;
|
|
388
431
|
providerID?: unknown;
|
|
389
|
-
time?: { created?: unknown; completed?: unknown } | null;
|
|
432
|
+
time?: { created?: unknown; streamed?: unknown; completed?: unknown } | null;
|
|
390
433
|
cost?: unknown;
|
|
391
434
|
finish?: unknown;
|
|
435
|
+
/** The message's parts. Tool calls sit here, named under a part of type `tool`. */
|
|
436
|
+
content?: unknown;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Tool names an OpenCode message called; v2 passes SQL's comma-joined string as the blob holds newlines. */
|
|
440
|
+
export function opencodeToolNames(message: OpencodeMessage, joined?: string): string[] {
|
|
441
|
+
if (joined !== undefined) return joined === '' ? [] : joined.split(',');
|
|
442
|
+
const parts = message?.content;
|
|
443
|
+
if (!Array.isArray(parts)) return [];
|
|
444
|
+
const names: string[] = [];
|
|
445
|
+
for (const part of parts) {
|
|
446
|
+
if (!part || typeof part !== 'object') continue;
|
|
447
|
+
if ((part as Record<string, unknown>).type !== 'tool') continue;
|
|
448
|
+
const name = (part as Record<string, unknown>).name;
|
|
449
|
+
if (typeof name === 'string' && name) names.push(name);
|
|
450
|
+
}
|
|
451
|
+
return names;
|
|
392
452
|
}
|
|
393
453
|
|
|
394
454
|
// One OpenCode message file; assistant rows carry the token buckets.
|
|
395
|
-
export function classifyOpencodeMessage(obj: OpencodeMessage | null | undefined): { model: string; usage: Record<string, unknown>; ms: number | undefined; durMs: number | undefined } | null {
|
|
455
|
+
export function classifyOpencodeMessage(obj: OpencodeMessage | null | undefined): { model: string; usage: Record<string, unknown>; ms: number | undefined; durMs: number | undefined; ttftMs: number | undefined } | null {
|
|
396
456
|
if (!obj || typeof obj !== 'object' || obj.role !== 'assistant') return null;
|
|
397
457
|
const t = obj.tokens;
|
|
398
458
|
if (!t || typeof t !== 'object') return null;
|
|
@@ -409,15 +469,17 @@ export function classifyOpencodeMessage(obj: OpencodeMessage | null | undefined)
|
|
|
409
469
|
const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
|
|
410
470
|
const created = num(obj.time?.created);
|
|
411
471
|
const completed = num(obj.time?.completed);
|
|
472
|
+
// `streamed` is when the first token arrived, so it is the host's own first-token latency.
|
|
473
|
+
const streamed = num(obj.time?.streamed);
|
|
412
474
|
return {
|
|
413
475
|
model: `opencode/${provider}/${model}`,
|
|
414
476
|
usage,
|
|
415
477
|
ms: created,
|
|
416
478
|
durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
|
|
479
|
+
ttftMs: created !== undefined && streamed !== undefined && streamed > created ? streamed - created : undefined,
|
|
417
480
|
};
|
|
418
481
|
}
|
|
419
482
|
|
|
420
|
-
// One OpenCode message file into the shared accum. The msg id is the file name.
|
|
421
483
|
export function processOpencodeFile(accum: SessionAccum, text: string, file?: string): void {
|
|
422
484
|
let obj: unknown;
|
|
423
485
|
try {
|
|
@@ -425,17 +487,18 @@ export function processOpencodeFile(accum: SessionAccum, text: string, file?: st
|
|
|
425
487
|
} catch {
|
|
426
488
|
return;
|
|
427
489
|
}
|
|
428
|
-
const
|
|
490
|
+
const message = obj as OpencodeMessage;
|
|
491
|
+
const parsed = classifyOpencodeMessage(message);
|
|
429
492
|
if (!parsed) return;
|
|
430
493
|
const id = file ? path.basename(file).replace(/\.[^.]+$/, '') || undefined : undefined;
|
|
431
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus },
|
|
494
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, opencodeToolNames(message), 'opencode', id, 'main', parsed.ttftMs);
|
|
432
495
|
}
|
|
433
496
|
|
|
434
497
|
// One v2 message row into the shared accum. The msg id is the row primary key.
|
|
435
498
|
export function ingestOpencodeDbRow(accum: SessionAccum, row: OpencodeDbRow): void {
|
|
436
499
|
const parsed = classifyOpencodeMessage(row.row);
|
|
437
500
|
if (!parsed) return;
|
|
438
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus },
|
|
501
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, opencodeToolNames(row.row, row.tools), 'opencode', row.id, 'main', parsed.ttftMs);
|
|
439
502
|
}
|
|
440
503
|
|
|
441
504
|
// Fold `:free`/`-free` suffixes and case variants into one chart key.
|
|
@@ -448,8 +511,7 @@ export function messageRowId(row: unknown): string | undefined {
|
|
|
448
511
|
return typeof id === 'string' && id ? id : undefined;
|
|
449
512
|
}
|
|
450
513
|
|
|
451
|
-
// Vendor names mirror the dashboard map;
|
|
452
|
-
// gateway segments shadow it (minimaxai holds xai, gemma rides nvidia).
|
|
514
|
+
// Vendor names mirror the dashboard map; family first, so gateway segments shadow it (minimaxai holds xai).
|
|
453
515
|
const VENDOR_RES: Array<[RegExp, string]> = [
|
|
454
516
|
[/inclusionai|ling-/i, 'InclusionAI'],
|
|
455
517
|
[/minimax/i, 'MiniMax'],
|
|
@@ -560,10 +622,10 @@ export function importSessionTokens(): SessionTokens {
|
|
|
560
622
|
set codexProvider(v: string | null) { codexProvider = v; },
|
|
561
623
|
get codexModel() { return codexModel; },
|
|
562
624
|
set codexModel(v: string | null) { codexModel = v; },
|
|
563
|
-
}, text, hostOfSessionFile(file));
|
|
625
|
+
}, text, hostOfSessionFile(file), agentOfSessionText(text));
|
|
564
626
|
}
|
|
565
627
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
566
|
-
return { messages: accum.messages, totals: accum.totals, byModel: accum.byModel, byDay: accum.byDay, byDayModel: accum.byDayModel, byTool: accum.byTool, byModelMessages: accum.byModelMessages, byHost: accum.byHost, costMeasured: accum.costMeasured, recent: accum.recent.slice(0, RECENT_LIMIT) };
|
|
628
|
+
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) };
|
|
567
629
|
}
|
|
568
630
|
|
|
569
631
|
// Which agent wrote a session file, from the directory it sits in.
|
|
@@ -575,6 +637,19 @@ export function hostOfSessionFile(file: string): string {
|
|
|
575
637
|
return 'pi';
|
|
576
638
|
}
|
|
577
639
|
|
|
640
|
+
// A transcript with a parent session ran as a subagent; the host's own row, so a mirror answers the same.
|
|
641
|
+
export function agentOfSessionText(text: string): string {
|
|
642
|
+
if (!text.includes('parentSession')) return 'main';
|
|
643
|
+
for (const line of text.split('\n')) {
|
|
644
|
+
if (!line.includes('parentSession')) continue;
|
|
645
|
+
try {
|
|
646
|
+
const row = JSON.parse(line) as { type?: unknown; parentSession?: unknown };
|
|
647
|
+
if (row.type === 'session' && typeof row.parentSession === 'string' && row.parentSession !== '') return 'subagent';
|
|
648
|
+
} catch { /* skip a corrupt line */ }
|
|
649
|
+
}
|
|
650
|
+
return 'main';
|
|
651
|
+
}
|
|
652
|
+
|
|
578
653
|
const adoptionFileCache = new Map<string, { size: number; mtimeMs: number; counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number } }>();
|
|
579
654
|
|
|
580
655
|
function parseAdoptionFile(file: string): { counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number }; sessions: number } {
|
|
@@ -645,7 +720,7 @@ export function readRtkAdoption(): RtkAdoption {
|
|
|
645
720
|
const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
|
|
646
721
|
return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
|
|
647
722
|
}
|
|
648
|
-
export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null; codexModel?: string | null }, text: string, host?: string): void {
|
|
723
|
+
export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null; codexModel?: string | null }, text: string, host?: string, agent?: string): void {
|
|
649
724
|
for (const line of text.split('\n')) {
|
|
650
725
|
if (!line.trim()) continue;
|
|
651
726
|
try {
|
|
@@ -664,11 +739,11 @@ export function processSessionText(accum: SessionAccum, state: { codexProvider:
|
|
|
664
739
|
const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
|
|
665
740
|
// The provider alone told you nothing about which model ran.
|
|
666
741
|
const model = state.codexModel ? `${base}/${state.codexModel}` : base;
|
|
667
|
-
ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' as RunStatus }, undefined, host ?? 'codex', rid);
|
|
742
|
+
ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' as RunStatus }, undefined, host ?? 'codex', rid, agent);
|
|
668
743
|
continue;
|
|
669
744
|
}
|
|
670
745
|
if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage) continue;
|
|
671
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid);
|
|
746
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid, agent, parsed.ttftMs);
|
|
672
747
|
} catch { /* skip corrupt lines */ }
|
|
673
748
|
}
|
|
674
749
|
}
|
|
@@ -705,18 +780,29 @@ export function leadBinary(command: unknown): string {
|
|
|
705
780
|
return 'bash';
|
|
706
781
|
}
|
|
707
782
|
|
|
708
|
-
//
|
|
709
|
-
import { DEFAULT_PRICE, canonicalPriceId, priceFor, refreshPricesIfStale } from './pricing.ts';
|
|
783
|
+
// Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table).
|
|
784
|
+
import { DEFAULT_PRICE, PRICE_TIERS_FIRM, canonicalPriceId, priceFor, pricesOverridePath, refreshPricesIfStale, type PriceHit, type PriceTier } from './pricing.ts';
|
|
710
785
|
import { co2GramsFor, energyWhFor } from './carbon.ts';
|
|
711
786
|
import type { ModelPrice } from './pricing.ts';
|
|
712
787
|
|
|
713
|
-
export { DEFAULT_PRICE, priceFor, refreshPricesIfStale };
|
|
788
|
+
export { DEFAULT_PRICE, PRICE_TIERS_FIRM, priceFor, pricesOverridePath, refreshPricesIfStale };
|
|
714
789
|
export { co2GramsFor, energyWhFor };
|
|
715
|
-
export type { ModelPrice };
|
|
790
|
+
export type { PriceHit, PriceTier, ModelPrice };
|
|
716
791
|
|
|
717
|
-
|
|
718
|
-
|
|
792
|
+
/** Price one model's tokens; `measured` outranks every rate card, so pass undefined when unreported. */
|
|
793
|
+
export function usdCost(t: TokenBreakdown, model?: string, measured?: number): { usd: number; priced: boolean; tier: PriceTier; buckets: { input: number; output: number; cacheRead: number; cacheWrite: number } } {
|
|
719
794
|
const m = 1 / 1_000_000;
|
|
795
|
+
if (measured !== undefined) {
|
|
796
|
+
// Only a bare total arrives, so it attributes to input, the largest bucket for every model here.
|
|
797
|
+
return {
|
|
798
|
+
usd: measured,
|
|
799
|
+
priced: true,
|
|
800
|
+
tier: 'measured',
|
|
801
|
+
buckets: { input: measured, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
const hit: PriceHit = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false, live: false, tier: 'default' };
|
|
805
|
+
const price = hit.price;
|
|
720
806
|
const buckets = {
|
|
721
807
|
input: t.input * price.input * m,
|
|
722
808
|
output: t.output * price.output * m,
|
|
@@ -725,7 +811,9 @@ export function usdCost(t: TokenBreakdown, model?: string): { usd: number; price
|
|
|
725
811
|
};
|
|
726
812
|
return {
|
|
727
813
|
usd: buckets.input + buckets.output + buckets.cacheRead + buckets.cacheWrite,
|
|
728
|
-
|
|
814
|
+
// `tail`/`default` are estimates; summing them beside measured cost reads as a bill it is not.
|
|
815
|
+
priced: PRICE_TIERS_FIRM.includes(hit.tier),
|
|
816
|
+
tier: hit.tier,
|
|
729
817
|
buckets,
|
|
730
818
|
};
|
|
731
819
|
}
|
|
@@ -754,7 +842,7 @@ export function readUsage(): UsageRow[] {
|
|
|
754
842
|
return rows;
|
|
755
843
|
}
|
|
756
844
|
|
|
757
|
-
// Delete the ledger file and return the rows cleared. Tersio-owned data only: transcripts and the RTK database
|
|
845
|
+
// Delete the ledger file and return the rows cleared. Tersio-owned data only: transcripts and the RTK database stay.
|
|
758
846
|
export function clearUsageLedger(): number {
|
|
759
847
|
const rows = readUsage().length;
|
|
760
848
|
try {
|