opencode-usage-insights 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +323 -2
- package/dist/core.d.ts +259 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +1227 -0
- package/dist/index.js.map +16 -0
- package/dist/line.d.ts +71 -0
- package/dist/panel-build.d.ts +46 -0
- package/dist/panel-data.d.ts +216 -0
- package/dist/panel.d.ts +39 -0
- package/dist/recorder.d.ts +69 -0
- package/dist/sidebar.d.ts +25 -0
- package/dist/stats/aggregates.d.ts +283 -0
- package/dist/stats/db.d.ts +99 -0
- package/dist/stats/maintenance.d.ts +49 -0
- package/dist/stats/paths.d.ts +35 -0
- package/dist/stats/queries.d.ts +239 -0
- package/dist/text.d.ts +25 -0
- package/dist/tui.d.ts +53 -0
- package/dist/tui.js +3515 -0
- package/dist/tui.js.map +22 -0
- package/package.json +84 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1227 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/index.ts
|
|
3
|
+
import { Plugin } from "@opencode/plugin";
|
|
4
|
+
|
|
5
|
+
// src/stats/db.ts
|
|
6
|
+
import { Database } from "bun:sqlite";
|
|
7
|
+
import { chmodSync, mkdirSync } from "fs";
|
|
8
|
+
import { dirname } from "path";
|
|
9
|
+
|
|
10
|
+
// src/stats/paths.ts
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { statSync } from "fs";
|
|
14
|
+
function defaultDbPath(env = process.env, platform = process.platform) {
|
|
15
|
+
const name = join("opencode", "opencode-usage-insights", "usage.db");
|
|
16
|
+
if (platform === "win32") {
|
|
17
|
+
return join(env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), name);
|
|
18
|
+
}
|
|
19
|
+
if (platform === "darwin") {
|
|
20
|
+
return join(homedir(), "Library", "Application Support", name);
|
|
21
|
+
}
|
|
22
|
+
return join(env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), name);
|
|
23
|
+
}
|
|
24
|
+
function expandHome(path, home = homedir()) {
|
|
25
|
+
if (path === "~")
|
|
26
|
+
return home;
|
|
27
|
+
if (path.startsWith("~/") || path.startsWith("~\\"))
|
|
28
|
+
return join(home, path.slice(2));
|
|
29
|
+
return path;
|
|
30
|
+
}
|
|
31
|
+
function resolveDbPath(configured, env = process.env, platform = process.platform) {
|
|
32
|
+
const home = env.HOME ?? env.USERPROFILE ?? homedir();
|
|
33
|
+
if (configured)
|
|
34
|
+
return expandHome(configured, home);
|
|
35
|
+
if (env.OPENCODE_USAGE_INSIGHTS_DB)
|
|
36
|
+
return expandHome(env.OPENCODE_USAGE_INSIGHTS_DB, home);
|
|
37
|
+
return defaultDbPath(env, platform);
|
|
38
|
+
}
|
|
39
|
+
function ensureRegularFile(path) {
|
|
40
|
+
let stats;
|
|
41
|
+
try {
|
|
42
|
+
stats = statSync(path);
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!stats.isFile())
|
|
47
|
+
throw new Error(`not a regular file: ${path}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/text.ts
|
|
51
|
+
var TEXT_LIMIT = 512;
|
|
52
|
+
function sanitizeText(value, limit = TEXT_LIMIT) {
|
|
53
|
+
if (limit <= 0)
|
|
54
|
+
return "";
|
|
55
|
+
let cleaned = "";
|
|
56
|
+
for (const character of value) {
|
|
57
|
+
const code = character.codePointAt(0) ?? 0;
|
|
58
|
+
if (code <= 31 || code === 127 || code >= 128 && code <= 159)
|
|
59
|
+
continue;
|
|
60
|
+
cleaned += character;
|
|
61
|
+
if (cleaned.length >= limit)
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
return cleaned;
|
|
65
|
+
}
|
|
66
|
+
function sanitizeLabel(value, limit = TEXT_LIMIT) {
|
|
67
|
+
return sanitizeText(value, limit).trim();
|
|
68
|
+
}
|
|
69
|
+
var TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
|
|
70
|
+
function isToken(value, limit = 64) {
|
|
71
|
+
return value.length > 0 && value.length <= limit && TOKEN.test(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/stats/db.ts
|
|
75
|
+
var SCHEMA_VERSION = 9;
|
|
76
|
+
var LABEL_LIMIT = 4 * TEXT_LIMIT;
|
|
77
|
+
var SCHEMA = `
|
|
78
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
79
|
+
key TEXT PRIMARY KEY,
|
|
80
|
+
value TEXT NOT NULL
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
84
|
+
session_id TEXT PRIMARY KEY,
|
|
85
|
+
parent_id TEXT,
|
|
86
|
+
directory TEXT,
|
|
87
|
+
title TEXT,
|
|
88
|
+
started_at INTEGER,
|
|
89
|
+
updated_at INTEGER
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
CREATE TABLE IF NOT EXISTS prompts (
|
|
93
|
+
session_id TEXT NOT NULL,
|
|
94
|
+
message_id TEXT NOT NULL,
|
|
95
|
+
created_at INTEGER,
|
|
96
|
+
PRIMARY KEY (session_id, message_id)
|
|
97
|
+
);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS prompts_created ON prompts(created_at);
|
|
99
|
+
|
|
100
|
+
CREATE TABLE IF NOT EXISTS steps (
|
|
101
|
+
message_id TEXT PRIMARY KEY,
|
|
102
|
+
session_id TEXT NOT NULL,
|
|
103
|
+
parent_id TEXT,
|
|
104
|
+
provider_id TEXT,
|
|
105
|
+
model_id TEXT,
|
|
106
|
+
variant TEXT,
|
|
107
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
108
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
109
|
+
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
|
110
|
+
cache_read INTEGER NOT NULL DEFAULT 0,
|
|
111
|
+
cache_write INTEGER NOT NULL DEFAULT 0,
|
|
112
|
+
cost REAL NOT NULL DEFAULT 0,
|
|
113
|
+
created_at INTEGER,
|
|
114
|
+
streamed_at INTEGER,
|
|
115
|
+
completed_at INTEGER,
|
|
116
|
+
finish TEXT,
|
|
117
|
+
recorded_at INTEGER NOT NULL
|
|
118
|
+
);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS steps_session ON steps(session_id, created_at);
|
|
120
|
+
CREATE INDEX IF NOT EXISTS steps_model ON steps(provider_id, model_id, variant);
|
|
121
|
+
CREATE INDEX IF NOT EXISTS steps_completed ON steps(completed_at);
|
|
122
|
+
-- Every range filter and every ordering compares the step's own clock, which
|
|
123
|
+
-- falls back to the recording time for a step that was already in flight when
|
|
124
|
+
-- the plugin loaded. The index has to cover that expression, or every ranged
|
|
125
|
+
-- view is a full table scan.
|
|
126
|
+
CREATE INDEX IF NOT EXISTS steps_at ON steps(COALESCE(created_at, recorded_at));
|
|
127
|
+
CREATE INDEX IF NOT EXISTS steps_session_at ON steps(session_id, COALESCE(created_at, recorded_at));
|
|
128
|
+
|
|
129
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
130
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
131
|
+
session_id TEXT NOT NULL,
|
|
132
|
+
kind TEXT NOT NULL,
|
|
133
|
+
message_id TEXT,
|
|
134
|
+
at INTEGER,
|
|
135
|
+
data TEXT
|
|
136
|
+
);
|
|
137
|
+
CREATE INDEX IF NOT EXISTS events_session ON events(session_id, at);
|
|
138
|
+
CREATE INDEX IF NOT EXISTS events_kind ON events(kind, at);
|
|
139
|
+
`;
|
|
140
|
+
var TABLES = ["sessions", "prompts", "events", "steps"];
|
|
141
|
+
function jsonText(value) {
|
|
142
|
+
if (value === undefined || value === null)
|
|
143
|
+
return null;
|
|
144
|
+
if (typeof value === "string")
|
|
145
|
+
return value;
|
|
146
|
+
try {
|
|
147
|
+
return JSON.stringify(value);
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
var REMOVED_COLUMNS = ["provider_state", "upstream", "upstream_source", "upstream_model", "tps", "router"];
|
|
153
|
+
var REMOVED_EVENT_KINDS = ["execution", "step-failed", "model-selected", "usage-recorded"];
|
|
154
|
+
function dropUnused(db) {
|
|
155
|
+
const objects = db.query("SELECT name, type FROM sqlite_master WHERE type IN ('table', 'index')").all();
|
|
156
|
+
const tables = new Set(objects.filter((row) => row.type === "table").map((row) => row.name));
|
|
157
|
+
const indexes = new Set(objects.filter((row) => row.type === "index").map((row) => row.name));
|
|
158
|
+
if (tables.has("http_responses"))
|
|
159
|
+
db.exec("DROP TABLE http_responses");
|
|
160
|
+
if (indexes.has("steps_upstream"))
|
|
161
|
+
db.exec("DROP INDEX steps_upstream");
|
|
162
|
+
if (indexes.has("steps_router"))
|
|
163
|
+
db.exec("DROP INDEX steps_router");
|
|
164
|
+
const columns = new Set(db.query("PRAGMA table_info(steps)").all().map((column) => column.name));
|
|
165
|
+
for (const name of REMOVED_COLUMNS) {
|
|
166
|
+
if (columns.has(name))
|
|
167
|
+
db.exec(`ALTER TABLE steps DROP COLUMN ${name}`);
|
|
168
|
+
}
|
|
169
|
+
if (tables.has("events")) {
|
|
170
|
+
const kinds = REMOVED_EVENT_KINDS.map((kind) => `'${kind}'`).join(", ");
|
|
171
|
+
db.exec(`DELETE FROM events WHERE kind IN (${kinds})`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function migrate(db) {
|
|
175
|
+
dropUnused(db);
|
|
176
|
+
}
|
|
177
|
+
var POSIX = process.platform !== "win32";
|
|
178
|
+
function hardenDirectory(path) {
|
|
179
|
+
if (!POSIX)
|
|
180
|
+
return;
|
|
181
|
+
try {
|
|
182
|
+
chmodSync(path, 448);
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
function hardenFiles(path) {
|
|
186
|
+
if (!POSIX || path === ":memory:")
|
|
187
|
+
return;
|
|
188
|
+
for (const file of [path, `${path}-wal`, `${path}-shm`]) {
|
|
189
|
+
try {
|
|
190
|
+
chmodSync(file, 384);
|
|
191
|
+
} catch {}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function prepareDirectory(path) {
|
|
195
|
+
if (path === ":memory:")
|
|
196
|
+
return;
|
|
197
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
198
|
+
hardenDirectory(dirname(path));
|
|
199
|
+
}
|
|
200
|
+
function openStatsDb(path) {
|
|
201
|
+
if (path !== ":memory:")
|
|
202
|
+
ensureRegularFile(path);
|
|
203
|
+
prepareDirectory(path);
|
|
204
|
+
const db = new Database(path, { create: true });
|
|
205
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
206
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
207
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
208
|
+
db.exec(SCHEMA);
|
|
209
|
+
migrate(db);
|
|
210
|
+
db.query("INSERT INTO meta (key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(String(SCHEMA_VERSION));
|
|
211
|
+
hardenFiles(path);
|
|
212
|
+
return db;
|
|
213
|
+
}
|
|
214
|
+
function openStatsDbReadOnly(path) {
|
|
215
|
+
ensureRegularFile(path);
|
|
216
|
+
const db = new Database(path, { readonly: true });
|
|
217
|
+
try {
|
|
218
|
+
db.query("SELECT 1 FROM steps LIMIT 1").get();
|
|
219
|
+
} catch (error) {
|
|
220
|
+
db.close();
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
return db;
|
|
224
|
+
}
|
|
225
|
+
function openStatsDbWrite(path) {
|
|
226
|
+
if (path !== ":memory:")
|
|
227
|
+
ensureRegularFile(path);
|
|
228
|
+
prepareDirectory(path);
|
|
229
|
+
const db = new Database(path, { create: true });
|
|
230
|
+
db.exec("PRAGMA busy_timeout = 250");
|
|
231
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
232
|
+
hardenFiles(path);
|
|
233
|
+
return db;
|
|
234
|
+
}
|
|
235
|
+
function recordStep(db, record) {
|
|
236
|
+
db.query(`INSERT INTO steps (
|
|
237
|
+
message_id, session_id, parent_id, provider_id, model_id, variant,
|
|
238
|
+
input_tokens, output_tokens, reasoning_tokens, cache_read, cache_write, cost,
|
|
239
|
+
created_at, streamed_at, completed_at, finish, recorded_at
|
|
240
|
+
) VALUES (
|
|
241
|
+
$messageID, $sessionID, $parentID, $providerID, $modelID, $variant,
|
|
242
|
+
$input, $output, $reasoning, $cacheRead, $cacheWrite, $cost,
|
|
243
|
+
$createdAt, $streamedAt, $completedAt, $finish, $recordedAt
|
|
244
|
+
)
|
|
245
|
+
ON CONFLICT(message_id) DO UPDATE SET
|
|
246
|
+
session_id = excluded.session_id,
|
|
247
|
+
parent_id = COALESCE(excluded.parent_id, steps.parent_id),
|
|
248
|
+
provider_id = COALESCE(excluded.provider_id, steps.provider_id),
|
|
249
|
+
model_id = COALESCE(excluded.model_id, steps.model_id),
|
|
250
|
+
variant = COALESCE(excluded.variant, steps.variant),
|
|
251
|
+
input_tokens = excluded.input_tokens,
|
|
252
|
+
output_tokens = excluded.output_tokens,
|
|
253
|
+
reasoning_tokens = excluded.reasoning_tokens,
|
|
254
|
+
cache_read = excluded.cache_read,
|
|
255
|
+
cache_write = excluded.cache_write,
|
|
256
|
+
cost = excluded.cost,
|
|
257
|
+
created_at = COALESCE(excluded.created_at, steps.created_at),
|
|
258
|
+
streamed_at = COALESCE(excluded.streamed_at, steps.streamed_at),
|
|
259
|
+
completed_at = COALESCE(excluded.completed_at, steps.completed_at),
|
|
260
|
+
finish = COALESCE(excluded.finish, steps.finish),
|
|
261
|
+
recorded_at = excluded.recorded_at`).run({
|
|
262
|
+
$messageID: record.messageID,
|
|
263
|
+
$sessionID: record.sessionID,
|
|
264
|
+
$parentID: record.parentID ?? null,
|
|
265
|
+
$providerID: record.providerID !== undefined && isToken(record.providerID) ? record.providerID : null,
|
|
266
|
+
$modelID: record.modelID ?? null,
|
|
267
|
+
$variant: record.variant ?? null,
|
|
268
|
+
$input: record.input ?? 0,
|
|
269
|
+
$output: record.output ?? 0,
|
|
270
|
+
$reasoning: record.reasoning ?? 0,
|
|
271
|
+
$cacheRead: record.cacheRead ?? 0,
|
|
272
|
+
$cacheWrite: record.cacheWrite ?? 0,
|
|
273
|
+
$cost: record.cost ?? 0,
|
|
274
|
+
$createdAt: record.createdAt ?? null,
|
|
275
|
+
$streamedAt: record.streamedAt ?? null,
|
|
276
|
+
$completedAt: record.completedAt ?? null,
|
|
277
|
+
$finish: record.finish === undefined ? null : sanitizeLabel(record.finish, 64),
|
|
278
|
+
$recordedAt: record.recordedAt ?? Date.now()
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
function recordPrompt(db, record) {
|
|
282
|
+
db.query(`INSERT INTO prompts (session_id, message_id, created_at)
|
|
283
|
+
VALUES ($sessionID, $messageID, $createdAt)
|
|
284
|
+
ON CONFLICT(session_id, message_id) DO UPDATE SET created_at = COALESCE(excluded.created_at, prompts.created_at)`).run({
|
|
285
|
+
$sessionID: record.sessionID,
|
|
286
|
+
$messageID: record.messageID,
|
|
287
|
+
$createdAt: record.createdAt ?? null
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
function recordEvent(db, record) {
|
|
291
|
+
db.query(`INSERT INTO events (session_id, kind, message_id, at, data)
|
|
292
|
+
VALUES ($sessionID, $kind, $messageID, $at, $data)`).run({
|
|
293
|
+
$sessionID: record.sessionID,
|
|
294
|
+
$kind: sanitizeLabel(record.kind, 64),
|
|
295
|
+
$messageID: record.messageID ?? null,
|
|
296
|
+
$at: record.at ?? Date.now(),
|
|
297
|
+
$data: jsonText(record.data)
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
function upsertSession(db, record) {
|
|
301
|
+
db.query(`INSERT INTO sessions (session_id, parent_id, directory, title, started_at, updated_at)
|
|
302
|
+
VALUES ($sessionID, $parentID, $directory, $title, $startedAt, $updatedAt)
|
|
303
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
304
|
+
parent_id = COALESCE(excluded.parent_id, sessions.parent_id),
|
|
305
|
+
directory = COALESCE(excluded.directory, sessions.directory),
|
|
306
|
+
title = COALESCE(excluded.title, sessions.title),
|
|
307
|
+
started_at = COALESCE(sessions.started_at, excluded.started_at),
|
|
308
|
+
updated_at = COALESCE(excluded.updated_at, sessions.updated_at)`).run({
|
|
309
|
+
$sessionID: record.sessionID,
|
|
310
|
+
$parentID: record.parentID ?? null,
|
|
311
|
+
$directory: record.directory === undefined ? null : sanitizeLabel(record.directory, LABEL_LIMIT),
|
|
312
|
+
$title: record.title === undefined ? null : sanitizeLabel(record.title, LABEL_LIMIT),
|
|
313
|
+
$startedAt: record.startedAt ?? null,
|
|
314
|
+
$updatedAt: record.updatedAt ?? null
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// src/core.ts
|
|
319
|
+
var PROMPT_BOUNDARIES = new Set(["user", "synthetic", "system"]);
|
|
320
|
+
function transcriptTail(messages, count) {
|
|
321
|
+
const segments = [];
|
|
322
|
+
let current = [];
|
|
323
|
+
for (let index = messages.length - 1;index >= 0; index -= 1) {
|
|
324
|
+
const message = messages[index];
|
|
325
|
+
if (message.type === "assistant") {
|
|
326
|
+
current.unshift(message);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (!PROMPT_BOUNDARIES.has(message.type))
|
|
330
|
+
continue;
|
|
331
|
+
if (current.length === 0)
|
|
332
|
+
continue;
|
|
333
|
+
segments.unshift({ assistants: current, prompt: message });
|
|
334
|
+
if (segments.length >= count)
|
|
335
|
+
return segments;
|
|
336
|
+
current = [];
|
|
337
|
+
}
|
|
338
|
+
if (current.length > 0 && segments.length < count)
|
|
339
|
+
segments.unshift({ assistants: current, prompt: undefined });
|
|
340
|
+
return segments;
|
|
341
|
+
}
|
|
342
|
+
function tokenCounts(message) {
|
|
343
|
+
const tokens = message.tokens;
|
|
344
|
+
return {
|
|
345
|
+
input: tokens?.input ?? 0,
|
|
346
|
+
output: tokens?.output ?? 0,
|
|
347
|
+
reasoning: tokens?.reasoning ?? 0,
|
|
348
|
+
read: tokens?.cache?.read ?? 0,
|
|
349
|
+
write: tokens?.cache?.write ?? 0
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function totalize(steps) {
|
|
353
|
+
const totals = { input: 0, cached: 0, output: 0, reasoning: 0, cacheWrite: 0, cost: 0, steps: steps.length };
|
|
354
|
+
for (const step of steps) {
|
|
355
|
+
const counts = tokenCounts(step);
|
|
356
|
+
totals.input += counts.input;
|
|
357
|
+
totals.cached += counts.read;
|
|
358
|
+
totals.output += counts.output;
|
|
359
|
+
totals.reasoning += counts.reasoning;
|
|
360
|
+
totals.cacheWrite += counts.write;
|
|
361
|
+
totals.cost += step.cost ?? 0;
|
|
362
|
+
}
|
|
363
|
+
return totals;
|
|
364
|
+
}
|
|
365
|
+
function identityKey(model) {
|
|
366
|
+
if (!model)
|
|
367
|
+
return;
|
|
368
|
+
return `${model.providerID}/${model.id}${model.variant ? `#${model.variant}` : ""}`;
|
|
369
|
+
}
|
|
370
|
+
function cacheRead(message) {
|
|
371
|
+
return message?.tokens?.cache?.read ?? 0;
|
|
372
|
+
}
|
|
373
|
+
function stepCreated(message) {
|
|
374
|
+
return message?.time?.created;
|
|
375
|
+
}
|
|
376
|
+
function stepEnd(message) {
|
|
377
|
+
const time = message?.time;
|
|
378
|
+
if (!time)
|
|
379
|
+
return;
|
|
380
|
+
return time.completed ?? time.streamed ?? time.created;
|
|
381
|
+
}
|
|
382
|
+
function cacheDrop(reference, current, scope = "provider") {
|
|
383
|
+
if (!reference)
|
|
384
|
+
return;
|
|
385
|
+
const before = reference.model;
|
|
386
|
+
const after = current.model;
|
|
387
|
+
if (!before || !after)
|
|
388
|
+
return;
|
|
389
|
+
if (scope === "provider" && before.providerID !== after.providerID)
|
|
390
|
+
return;
|
|
391
|
+
if (before.id !== after.id || (before.variant ?? "") !== (after.variant ?? "")) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const drop = cacheRead(reference) - cacheRead(current);
|
|
395
|
+
if (isNoiseDrop(after.providerID, drop))
|
|
396
|
+
return;
|
|
397
|
+
return drop > 0 ? drop : undefined;
|
|
398
|
+
}
|
|
399
|
+
function stepGap(reference, current) {
|
|
400
|
+
const ended = stepEnd(reference);
|
|
401
|
+
const started = stepCreated(current);
|
|
402
|
+
if (ended === undefined || started === undefined)
|
|
403
|
+
return;
|
|
404
|
+
return Math.max(0, started - ended);
|
|
405
|
+
}
|
|
406
|
+
function tokensPerSecond(produced, from, to) {
|
|
407
|
+
if (from === undefined || to === undefined || to <= from)
|
|
408
|
+
return;
|
|
409
|
+
if (produced <= 0)
|
|
410
|
+
return;
|
|
411
|
+
return produced / (to - from) * 1000;
|
|
412
|
+
}
|
|
413
|
+
var DROP_EXEMPT_PROVIDERS = new Set(["openai"]);
|
|
414
|
+
function isSettledStep(message) {
|
|
415
|
+
if (message.time?.completed !== undefined)
|
|
416
|
+
return true;
|
|
417
|
+
return message.finish !== undefined && message.finish !== null;
|
|
418
|
+
}
|
|
419
|
+
function computeTurn(messages, session) {
|
|
420
|
+
const segments = transcriptTail(messages, 2);
|
|
421
|
+
const last = segments[segments.length - 1];
|
|
422
|
+
if (!last)
|
|
423
|
+
return;
|
|
424
|
+
const previous = segments[segments.length - 2];
|
|
425
|
+
const lastStep = last.assistants[last.assistants.length - 1];
|
|
426
|
+
if (!lastStep)
|
|
427
|
+
return;
|
|
428
|
+
if (!isSettledStep(lastStep))
|
|
429
|
+
return;
|
|
430
|
+
const previousStep = previous?.assistants[previous.assistants.length - 1];
|
|
431
|
+
const settled = last.assistants.filter(isSettledStep);
|
|
432
|
+
const drops = [];
|
|
433
|
+
let reference = previousStep !== undefined && isSettledStep(previousStep) ? previousStep : undefined;
|
|
434
|
+
for (const step of settled) {
|
|
435
|
+
const drop = cacheDrop(reference, step);
|
|
436
|
+
if (drop !== undefined) {
|
|
437
|
+
drops.push({
|
|
438
|
+
messageID: step.id,
|
|
439
|
+
tokens: drop,
|
|
440
|
+
prevCached: cacheRead(reference),
|
|
441
|
+
gapMs: stepGap(reference, step)
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
reference = step;
|
|
445
|
+
}
|
|
446
|
+
const provider = lastStep.model?.providerID;
|
|
447
|
+
const previousProvider = previousStep?.model?.providerID;
|
|
448
|
+
const key = identityKey(lastStep.model);
|
|
449
|
+
const previousKey = identityKey(previousStep?.model);
|
|
450
|
+
return {
|
|
451
|
+
endAt: stepEnd(lastStep),
|
|
452
|
+
startAt: stepCreated(settled[0] ?? lastStep),
|
|
453
|
+
promptAt: last.prompt?.time?.created,
|
|
454
|
+
promptGapMs: promptGap(last.prompt, previous?.prompt),
|
|
455
|
+
providerID: provider,
|
|
456
|
+
modelID: lastStep.model?.id,
|
|
457
|
+
variant: lastStep.model?.variant,
|
|
458
|
+
providerChanged: previousProvider !== undefined && provider !== undefined && previousProvider !== provider,
|
|
459
|
+
modelChanged: previousKey !== undefined && key !== undefined && previousKey !== key && previousProvider === provider,
|
|
460
|
+
totals: totalize(settled),
|
|
461
|
+
latest: totalize([lastStep]),
|
|
462
|
+
session,
|
|
463
|
+
drops,
|
|
464
|
+
cacheDropTokens: drops.reduce((sum, drop) => sum + drop.tokens, 0),
|
|
465
|
+
cacheExpired: drops.length > 0,
|
|
466
|
+
steps: settled.length
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
function promptGap(current, previous) {
|
|
470
|
+
const now = current?.time?.created;
|
|
471
|
+
const before = previous?.time?.created;
|
|
472
|
+
if (now === undefined || before === undefined)
|
|
473
|
+
return;
|
|
474
|
+
return Math.max(0, now - before);
|
|
475
|
+
}
|
|
476
|
+
function isNoiseDrop(providerID, drop) {
|
|
477
|
+
if (providerID === undefined)
|
|
478
|
+
return false;
|
|
479
|
+
return DROP_EXEMPT_PROVIDERS.has(providerID) && drop >= 1024 && drop <= 2048;
|
|
480
|
+
}
|
|
481
|
+
var DEFAULT_LABELS = {
|
|
482
|
+
delta: "\u0394",
|
|
483
|
+
turn: "",
|
|
484
|
+
session: "sess",
|
|
485
|
+
step: "step",
|
|
486
|
+
steps: "steps",
|
|
487
|
+
input: "in",
|
|
488
|
+
cached: "cached",
|
|
489
|
+
output: "out",
|
|
490
|
+
reasoning: "reason",
|
|
491
|
+
cost: "cost",
|
|
492
|
+
cacheExpired: "cache expired",
|
|
493
|
+
providerChanged: "changed",
|
|
494
|
+
modelChanged: "model changed"
|
|
495
|
+
};
|
|
496
|
+
var DEFAULT_FIELDS = {
|
|
497
|
+
clock: true,
|
|
498
|
+
delta: true,
|
|
499
|
+
model: true,
|
|
500
|
+
variant: true,
|
|
501
|
+
turn: true,
|
|
502
|
+
session: true,
|
|
503
|
+
reasoning: false,
|
|
504
|
+
cost: true,
|
|
505
|
+
cacheExpiry: true
|
|
506
|
+
};
|
|
507
|
+
function formatClock(ms) {
|
|
508
|
+
const date = new Date(ms);
|
|
509
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
510
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
511
|
+
}
|
|
512
|
+
function formatDuration(ms) {
|
|
513
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
514
|
+
const seconds = totalSeconds % 60;
|
|
515
|
+
const minutes = Math.floor(totalSeconds / 60) % 60;
|
|
516
|
+
const hours = Math.floor(totalSeconds / 3600) % 24;
|
|
517
|
+
const days = Math.floor(totalSeconds / 86400);
|
|
518
|
+
if (days > 0)
|
|
519
|
+
return `${days}d${hours}h`;
|
|
520
|
+
if (hours > 0)
|
|
521
|
+
return `${hours}h${String(minutes).padStart(2, "0")}m`;
|
|
522
|
+
if (minutes > 0)
|
|
523
|
+
return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
524
|
+
return `${seconds}s`;
|
|
525
|
+
}
|
|
526
|
+
function formatTokens(value) {
|
|
527
|
+
const n = Math.max(0, Math.round(value));
|
|
528
|
+
if (n < 1000)
|
|
529
|
+
return String(n);
|
|
530
|
+
if (n < 999500) {
|
|
531
|
+
const k = n / 1000;
|
|
532
|
+
return `${trimZeros(k.toFixed(k < 10 ? 2 : 1))}k`;
|
|
533
|
+
}
|
|
534
|
+
if (n < 999500000) {
|
|
535
|
+
const m = n / 1e6;
|
|
536
|
+
return `${trimZeros(m.toFixed(m < 10 ? 2 : 1))}M`;
|
|
537
|
+
}
|
|
538
|
+
return `${Math.round(n / 1e6)}M`;
|
|
539
|
+
}
|
|
540
|
+
function trimZeros(value) {
|
|
541
|
+
return value.includes(".") ? value.replace(/0+$/, "").replace(/\.$/, "") : value;
|
|
542
|
+
}
|
|
543
|
+
function formatCost(value) {
|
|
544
|
+
if (value <= 0)
|
|
545
|
+
return "$0";
|
|
546
|
+
if (value < 1)
|
|
547
|
+
return `$${value.toFixed(4)}`;
|
|
548
|
+
return `$${value.toFixed(2)}`;
|
|
549
|
+
}
|
|
550
|
+
function displayTotals(totals) {
|
|
551
|
+
const prompt = totals.input + totals.cached + totals.cacheWrite;
|
|
552
|
+
return {
|
|
553
|
+
input: prompt - totals.cached,
|
|
554
|
+
cached: totals.cached,
|
|
555
|
+
cacheWrite: totals.cacheWrite,
|
|
556
|
+
output: totals.output + totals.reasoning,
|
|
557
|
+
reasoning: totals.reasoning,
|
|
558
|
+
cost: totals.cost,
|
|
559
|
+
steps: totals.steps,
|
|
560
|
+
prompt
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function totalsText(prefix, totals, labels, fields, stepCount = 0, cost = totals.cost) {
|
|
564
|
+
const display = displayTotals(totals);
|
|
565
|
+
const input = display.prompt === display.input ? `${labels.input} ${formatTokens(display.input)}` : `${labels.input} ${formatTokens(display.input)} (${formatTokens(display.prompt)})`;
|
|
566
|
+
const parts = [
|
|
567
|
+
input,
|
|
568
|
+
`${labels.cached} ${formatTokens(display.cached)}`,
|
|
569
|
+
`${labels.output} ${formatTokens(display.output)}`
|
|
570
|
+
];
|
|
571
|
+
if (fields.reasoning)
|
|
572
|
+
parts.push(`${labels.reasoning} ${formatTokens(display.reasoning)}`);
|
|
573
|
+
if (fields.cost && cost > 0)
|
|
574
|
+
parts.push(`${labels.cost} ${formatCost(cost)}`);
|
|
575
|
+
const steps = stepCount > 1 ? `${stepCount} ${stepCount === 1 ? labels.step : labels.steps}` : "";
|
|
576
|
+
const head = [prefix, steps].filter((piece) => piece !== "").join(" ");
|
|
577
|
+
const numbers = parts.join(", ");
|
|
578
|
+
if (head === "")
|
|
579
|
+
return numbers;
|
|
580
|
+
return stepCount > 1 ? `${head} \xB7 ${numbers}` : `${head} ${numbers}`;
|
|
581
|
+
}
|
|
582
|
+
function formatLineSegments(stats, options) {
|
|
583
|
+
const labels = { ...DEFAULT_LABELS, ...options?.labels };
|
|
584
|
+
const fields = { ...DEFAULT_FIELDS, ...options?.fields };
|
|
585
|
+
const turnTotals = options?.turnTotals ?? "latest";
|
|
586
|
+
const segments = [];
|
|
587
|
+
if (fields.clock && stats.endAt !== undefined) {
|
|
588
|
+
segments.push({ text: formatClock(stats.endAt), tone: "muted" });
|
|
589
|
+
}
|
|
590
|
+
if (fields.delta && stats.promptGapMs !== undefined) {
|
|
591
|
+
segments.push({ text: `${labels.delta} ${formatDuration(stats.promptGapMs)}`, tone: "muted" });
|
|
592
|
+
}
|
|
593
|
+
if (stats.providerID !== undefined) {
|
|
594
|
+
let text = stats.providerID;
|
|
595
|
+
if (fields.model && stats.modelID !== undefined)
|
|
596
|
+
text += `/${stats.modelID}`;
|
|
597
|
+
if (fields.variant && stats.variant)
|
|
598
|
+
text += ` ${stats.variant}`;
|
|
599
|
+
segments.push({ text, tone: stats.providerChanged ? "warning" : "base" });
|
|
600
|
+
if (stats.providerChanged)
|
|
601
|
+
segments.push({ text: labels.providerChanged, tone: "warning" });
|
|
602
|
+
else if (stats.modelChanged)
|
|
603
|
+
segments.push({ text: labels.modelChanged, tone: "muted" });
|
|
604
|
+
}
|
|
605
|
+
if (fields.turn && stats.totals.steps > 0) {
|
|
606
|
+
const totals = turnTotals === "sum" ? stats.totals : stats.latest;
|
|
607
|
+
segments.push({
|
|
608
|
+
text: totalsText(labels.turn, totals, labels, fields, turnTotals === "sum" ? stats.totals.steps : stats.steps, stats.totals.cost),
|
|
609
|
+
tone: "base"
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
if (fields.session && stats.session) {
|
|
613
|
+
segments.push({ text: totalsText(labels.session, stats.session, labels, fields, 0, stats.session.cost), tone: "muted" });
|
|
614
|
+
}
|
|
615
|
+
if (fields.cacheExpiry && stats.cacheExpired) {
|
|
616
|
+
const count = stats.drops.length > 1 ? `\xD7${stats.drops.length}` : "";
|
|
617
|
+
segments.push({ text: `\u26A0 ${labels.cacheExpired} (\u2212${formatTokens(stats.cacheDropTokens)}${count})`, tone: "warning" });
|
|
618
|
+
}
|
|
619
|
+
return segments;
|
|
620
|
+
}
|
|
621
|
+
function formatLine(stats, options) {
|
|
622
|
+
return formatLineSegments(stats, options).map((segment) => segment.text).join(" \xB7 ");
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// src/stats/queries.ts
|
|
626
|
+
var COMPACTION_KIND = "compaction";
|
|
627
|
+
var DEFAULT_TTL_HINT_MS = 10 * 60 * 1000;
|
|
628
|
+
var STEP_TIME = "COALESCE(created_at, recorded_at)";
|
|
629
|
+
var MODEL_KEY_SQL = "COALESCE(model_id, '(unknown)') || '#' || COALESCE(variant, '')";
|
|
630
|
+
function modelKey(modelID, variant) {
|
|
631
|
+
return `${modelID}#${variant ?? ""}`;
|
|
632
|
+
}
|
|
633
|
+
function modelLabel(modelID, variant) {
|
|
634
|
+
return variant === undefined ? modelID : `${modelID} ${variant}`;
|
|
635
|
+
}
|
|
636
|
+
var USAGE_SELECT = `COUNT(*) AS steps,
|
|
637
|
+
COALESCE(SUM(input_tokens), 0) AS input,
|
|
638
|
+
COALESCE(SUM(cache_read), 0) AS cached,
|
|
639
|
+
COALESCE(SUM(cache_write), 0) AS cacheWrite,
|
|
640
|
+
COALESCE(SUM(output_tokens), 0) AS output,
|
|
641
|
+
COALESCE(SUM(reasoning_tokens), 0) AS reasoning,
|
|
642
|
+
COALESCE(SUM(cost), 0) AS cost`;
|
|
643
|
+
function usageFrom(row) {
|
|
644
|
+
return {
|
|
645
|
+
steps: row.steps ?? 0,
|
|
646
|
+
input: row.input ?? 0,
|
|
647
|
+
cached: row.cached ?? 0,
|
|
648
|
+
cacheWrite: row.cacheWrite ?? 0,
|
|
649
|
+
output: row.output ?? 0,
|
|
650
|
+
reasoning: row.reasoning ?? 0,
|
|
651
|
+
cost: row.cost ?? 0
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
function rangeWhere(range, column = STEP_TIME) {
|
|
655
|
+
const clauses = [];
|
|
656
|
+
const params = [];
|
|
657
|
+
if (range?.from !== undefined) {
|
|
658
|
+
clauses.push(`${column} >= ?`);
|
|
659
|
+
params.push(range.from);
|
|
660
|
+
}
|
|
661
|
+
if (range?.to !== undefined) {
|
|
662
|
+
clauses.push(`${column} < ?`);
|
|
663
|
+
params.push(range.to);
|
|
664
|
+
}
|
|
665
|
+
return { where: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "", params };
|
|
666
|
+
}
|
|
667
|
+
function buildWhere(range, filters, column = STEP_TIME) {
|
|
668
|
+
const rangeClause = rangeWhere(range, column);
|
|
669
|
+
const clauses = rangeClause.where === "" ? [] : [rangeClause.where.slice("WHERE ".length)];
|
|
670
|
+
const params = [...rangeClause.params];
|
|
671
|
+
if (filters?.modelID !== undefined && filters.modelID !== "") {
|
|
672
|
+
clauses.push("model_id = ?");
|
|
673
|
+
params.push(filters.modelID);
|
|
674
|
+
}
|
|
675
|
+
if (filters?.modelKey !== undefined && filters.modelKey !== "") {
|
|
676
|
+
clauses.push(`${MODEL_KEY_SQL} = ?`);
|
|
677
|
+
params.push(filters.modelKey);
|
|
678
|
+
}
|
|
679
|
+
if (filters?.provider !== undefined && filters.provider !== "") {
|
|
680
|
+
clauses.push("provider_id = ?");
|
|
681
|
+
params.push(filters.provider);
|
|
682
|
+
}
|
|
683
|
+
if (filters?.sessionID !== undefined && filters.sessionID !== "") {
|
|
684
|
+
clauses.push("session_id = ?");
|
|
685
|
+
params.push(filters.sessionID);
|
|
686
|
+
}
|
|
687
|
+
return { where: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "", params };
|
|
688
|
+
}
|
|
689
|
+
function cacheHitRatio(usage) {
|
|
690
|
+
const total = usage.cached + usage.input + usage.cacheWrite;
|
|
691
|
+
return total > 0 ? usage.cached / total : undefined;
|
|
692
|
+
}
|
|
693
|
+
function overview(db, range, filters) {
|
|
694
|
+
const steps = buildWhere(range, filters);
|
|
695
|
+
const row = db.query(`SELECT ${USAGE_SELECT},
|
|
696
|
+
COUNT(DISTINCT session_id) AS sessions,
|
|
697
|
+
MIN(${STEP_TIME}) AS firstAt,
|
|
698
|
+
MAX(COALESCE(completed_at, ${STEP_TIME})) AS lastAt,
|
|
699
|
+
COALESCE(SUM(CASE WHEN streamed_at > created_at THEN output_tokens + reasoning_tokens ELSE 0 END), 0) AS produced,
|
|
700
|
+
COALESCE(SUM(CASE WHEN streamed_at > created_at THEN streamed_at - created_at ELSE 0 END), 0) AS window
|
|
701
|
+
FROM steps ${steps.where}`).get(...steps.params);
|
|
702
|
+
const prompts = rangeWhere(range, "created_at");
|
|
703
|
+
const promptRow = db.query(`SELECT COUNT(*) AS prompts FROM prompts ${prompts.where}`).get(...prompts.params);
|
|
704
|
+
const usage = usageFrom(row ?? { steps: 0, input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0, cost: 0 });
|
|
705
|
+
return {
|
|
706
|
+
...usage,
|
|
707
|
+
sessions: row?.sessions ?? 0,
|
|
708
|
+
prompts: promptRow?.prompts ?? 0,
|
|
709
|
+
firstAt: row?.firstAt ?? undefined,
|
|
710
|
+
lastAt: row?.lastAt ?? undefined,
|
|
711
|
+
cacheHitRatio: cacheHitRatio(usage),
|
|
712
|
+
tps: row === null ? undefined : tokensPerSecond(row.produced ?? 0, 0, row.window ?? 0)
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
function models(db, range, filters) {
|
|
716
|
+
const filter = buildWhere(range, filters);
|
|
717
|
+
const rows = db.query(`SELECT provider_id, model_id, variant,
|
|
718
|
+
${USAGE_SELECT},
|
|
719
|
+
MIN(${STEP_TIME}) AS first_at,
|
|
720
|
+
MAX(COALESCE(completed_at, recorded_at)) AS last_at
|
|
721
|
+
FROM steps ${filter.where}
|
|
722
|
+
GROUP BY provider_id, model_id, variant
|
|
723
|
+
ORDER BY steps DESC, cached DESC`).all(...filter.params);
|
|
724
|
+
const models = new Map;
|
|
725
|
+
for (const row of rows) {
|
|
726
|
+
const modelID = row.model_id ?? "(unknown)";
|
|
727
|
+
const variant = row.variant ?? undefined;
|
|
728
|
+
const key = `${modelID}#${variant ?? ""}`;
|
|
729
|
+
let model = models.get(key);
|
|
730
|
+
if (!model) {
|
|
731
|
+
model = { modelID, variant, providers: [], totals: emptyUsage() };
|
|
732
|
+
models.set(key, model);
|
|
733
|
+
}
|
|
734
|
+
const usage = usageFrom(row);
|
|
735
|
+
model.providers.push({
|
|
736
|
+
...usage,
|
|
737
|
+
providerID: row.provider_id ?? "(unknown)",
|
|
738
|
+
firstAt: row.first_at ?? undefined,
|
|
739
|
+
lastAt: row.last_at ?? undefined
|
|
740
|
+
});
|
|
741
|
+
addUsage(model.totals, usage);
|
|
742
|
+
}
|
|
743
|
+
return [...models.values()].sort((a, b) => billedTokens(b.totals) - billedTokens(a.totals));
|
|
744
|
+
}
|
|
745
|
+
function emptyUsage() {
|
|
746
|
+
return { steps: 0, input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0, cost: 0 };
|
|
747
|
+
}
|
|
748
|
+
function addUsage(target, source) {
|
|
749
|
+
target.steps += source.steps;
|
|
750
|
+
target.input += source.input;
|
|
751
|
+
target.cached += source.cached;
|
|
752
|
+
target.cacheWrite += source.cacheWrite;
|
|
753
|
+
target.output += source.output;
|
|
754
|
+
target.reasoning += source.reasoning;
|
|
755
|
+
target.cost += source.cost;
|
|
756
|
+
}
|
|
757
|
+
function billedTokens(usage) {
|
|
758
|
+
return usage.input + usage.cached + usage.output;
|
|
759
|
+
}
|
|
760
|
+
function percentile(values, p) {
|
|
761
|
+
if (values.length === 0)
|
|
762
|
+
return;
|
|
763
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
764
|
+
const rank = Math.ceil(p / 100 * sorted.length);
|
|
765
|
+
const index = Math.min(sorted.length - 1, Math.max(0, rank - 1));
|
|
766
|
+
return sorted[index];
|
|
767
|
+
}
|
|
768
|
+
function latencyStats(values) {
|
|
769
|
+
return {
|
|
770
|
+
count: values.length,
|
|
771
|
+
p50: percentile(values, 50),
|
|
772
|
+
p90: percentile(values, 90),
|
|
773
|
+
p99: percentile(values, 99)
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function sessions(db, options) {
|
|
777
|
+
if (options?.sessionIDs !== undefined && options.sessionIDs.length === 0)
|
|
778
|
+
return [];
|
|
779
|
+
const range = options?.range;
|
|
780
|
+
const filter = buildWhere(range, options?.filters, "COALESCE(st.created_at, st.recorded_at)");
|
|
781
|
+
const limit = options?.limit ?? 20;
|
|
782
|
+
const clauses = filter.where === "" ? [] : [filter.where.slice("WHERE ".length)];
|
|
783
|
+
const params = [...filter.params];
|
|
784
|
+
if (options?.sessionIDs !== undefined) {
|
|
785
|
+
const ids = options.sessionIDs.slice(0, 500);
|
|
786
|
+
clauses.push(`st.session_id IN (${ids.map(() => "?").join(", ")})`);
|
|
787
|
+
params.push(...ids);
|
|
788
|
+
}
|
|
789
|
+
const rows = db.query(`SELECT st.session_id, s.title, s.directory, s.parent_id,
|
|
790
|
+
GROUP_CONCAT(DISTINCT st.provider_id) AS providers,
|
|
791
|
+
GROUP_CONCAT(DISTINCT st.model_id) AS models,
|
|
792
|
+
(SELECT COUNT(*) FROM prompts p WHERE p.session_id = st.session_id) AS prompts,
|
|
793
|
+
COUNT(*) AS steps,
|
|
794
|
+
COALESCE(SUM(st.input_tokens), 0) AS input,
|
|
795
|
+
COALESCE(SUM(st.cache_read), 0) AS cached,
|
|
796
|
+
COALESCE(SUM(st.cache_write), 0) AS cacheWrite,
|
|
797
|
+
COALESCE(SUM(st.output_tokens), 0) AS output,
|
|
798
|
+
COALESCE(SUM(st.reasoning_tokens), 0) AS reasoning,
|
|
799
|
+
COALESCE(SUM(st.cost), 0) AS cost,
|
|
800
|
+
MIN(COALESCE(st.created_at, st.recorded_at)) AS first_at,
|
|
801
|
+
MAX(COALESCE(st.completed_at, st.created_at, st.recorded_at)) AS last_at
|
|
802
|
+
FROM steps st
|
|
803
|
+
LEFT JOIN sessions s ON s.session_id = st.session_id
|
|
804
|
+
${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
|
|
805
|
+
GROUP BY st.session_id
|
|
806
|
+
ORDER BY last_at DESC
|
|
807
|
+
LIMIT ?`).all(...params, limit);
|
|
808
|
+
return rows.map((row) => {
|
|
809
|
+
const usage = usageFrom(row);
|
|
810
|
+
return {
|
|
811
|
+
...usage,
|
|
812
|
+
sessionID: row.session_id,
|
|
813
|
+
parentID: row.parent_id ?? undefined,
|
|
814
|
+
title: row.title ?? undefined,
|
|
815
|
+
directory: row.directory ?? undefined,
|
|
816
|
+
providers: row.providers ? row.providers.split(",").filter(Boolean) : [],
|
|
817
|
+
models: row.models ? row.models.split(",").filter(Boolean) : [],
|
|
818
|
+
prompts: row.prompts ?? 0,
|
|
819
|
+
firstAt: row.first_at ?? undefined,
|
|
820
|
+
lastAt: row.last_at ?? undefined,
|
|
821
|
+
cacheHitRatio: cacheHitRatio(usage)
|
|
822
|
+
};
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
function childSessionIDs(db, parentID) {
|
|
826
|
+
return db.query("SELECT session_id FROM sessions WHERE parent_id = ? ORDER BY session_id").all(parentID).map((row) => row.session_id);
|
|
827
|
+
}
|
|
828
|
+
function sessionUsageOf(db, sessionIDs) {
|
|
829
|
+
return sessions(db, { sessionIDs, limit: sessionIDs.length });
|
|
830
|
+
}
|
|
831
|
+
function turnFinished(finish) {
|
|
832
|
+
return finish !== null && finish !== "" && finish !== "tool-calls";
|
|
833
|
+
}
|
|
834
|
+
function stepUsage(row) {
|
|
835
|
+
return {
|
|
836
|
+
steps: 1,
|
|
837
|
+
input: row.input_tokens ?? 0,
|
|
838
|
+
cached: row.cache_read ?? 0,
|
|
839
|
+
cacheWrite: row.cache_write ?? 0,
|
|
840
|
+
output: row.output_tokens ?? 0,
|
|
841
|
+
reasoning: row.reasoning_tokens ?? 0,
|
|
842
|
+
cost: row.cost ?? 0
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
function stepMessage(row) {
|
|
846
|
+
return {
|
|
847
|
+
type: "assistant",
|
|
848
|
+
id: row.message_id,
|
|
849
|
+
time: { created: row.created_at ?? row.recorded_at, completed: row.completed_at ?? undefined },
|
|
850
|
+
model: row.provider_id !== null && row.model_id !== null ? { providerID: row.provider_id, id: row.model_id, variant: row.variant ?? undefined } : undefined,
|
|
851
|
+
tokens: {
|
|
852
|
+
input: row.input_tokens ?? 0,
|
|
853
|
+
output: row.output_tokens ?? 0,
|
|
854
|
+
reasoning: row.reasoning_tokens ?? 0,
|
|
855
|
+
cache: { read: row.cache_read ?? 0, write: row.cache_write ?? 0 }
|
|
856
|
+
},
|
|
857
|
+
finish: row.finish ?? undefined
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
function turns(db, options) {
|
|
861
|
+
const limit = options?.limit ?? 50;
|
|
862
|
+
const filter = buildWhere(options?.range, options?.filters);
|
|
863
|
+
const clauses = filter.where === "" ? [] : [filter.where.slice("WHERE ".length)];
|
|
864
|
+
const params = [...filter.params];
|
|
865
|
+
if (options?.sessionID !== undefined) {
|
|
866
|
+
clauses.push("session_id = ?");
|
|
867
|
+
params.push(options.sessionID);
|
|
868
|
+
}
|
|
869
|
+
const floor = options?.sessionID === undefined ? undefined : promptFloor(db, options.sessionID, limit + 1);
|
|
870
|
+
if (floor !== undefined) {
|
|
871
|
+
clauses.push(`${STEP_TIME} >= ?`);
|
|
872
|
+
params.push(floor);
|
|
873
|
+
}
|
|
874
|
+
const steps = db.query(`SELECT message_id, session_id, provider_id, model_id, variant,
|
|
875
|
+
input_tokens, cache_read, cache_write, output_tokens, reasoning_tokens, cost,
|
|
876
|
+
created_at, streamed_at, recorded_at, completed_at, finish
|
|
877
|
+
FROM steps ${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
|
|
878
|
+
ORDER BY session_id, ${STEP_TIME}`).all(...params);
|
|
879
|
+
const promptClause = options?.sessionID === undefined ? "" : `WHERE session_id = ?${floor === undefined ? "" : " AND created_at >= ?"}`;
|
|
880
|
+
const promptParams = options?.sessionID === undefined ? [] : floor === undefined ? [options.sessionID] : [options.sessionID, floor];
|
|
881
|
+
const promptRows = db.query(`SELECT session_id, created_at FROM prompts ${promptClause} ORDER BY session_id, created_at`).all(...promptParams);
|
|
882
|
+
const promptsBySession = new Map;
|
|
883
|
+
for (const row of promptRows) {
|
|
884
|
+
if (row.created_at === null)
|
|
885
|
+
continue;
|
|
886
|
+
const list = promptsBySession.get(row.session_id) ?? [];
|
|
887
|
+
list.push(row.created_at);
|
|
888
|
+
promptsBySession.set(row.session_id, list);
|
|
889
|
+
}
|
|
890
|
+
const result = [];
|
|
891
|
+
let sessionID;
|
|
892
|
+
let bucket = [];
|
|
893
|
+
let promptIndex = -2;
|
|
894
|
+
let promptAt;
|
|
895
|
+
let previousPromptAt;
|
|
896
|
+
function close() {
|
|
897
|
+
const last = bucket[bucket.length - 1];
|
|
898
|
+
if (last === undefined || !turnFinished(last.finish)) {
|
|
899
|
+
bucket = [];
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const totals = emptyUsage();
|
|
903
|
+
for (const row of bucket)
|
|
904
|
+
addUsage(totals, stepUsage(row));
|
|
905
|
+
const drops = [];
|
|
906
|
+
let reference;
|
|
907
|
+
for (const row of bucket) {
|
|
908
|
+
const message = stepMessage(row);
|
|
909
|
+
const drop = cacheDrop(reference, message);
|
|
910
|
+
if (drop !== undefined) {
|
|
911
|
+
drops.push({ messageID: row.message_id, tokens: drop, gapMs: stepGap(reference, message) });
|
|
912
|
+
}
|
|
913
|
+
reference = message;
|
|
914
|
+
}
|
|
915
|
+
const providerID = last.provider_id ?? undefined;
|
|
916
|
+
const modelID = last.model_id ?? undefined;
|
|
917
|
+
const variant = last.variant ?? undefined;
|
|
918
|
+
let produced = 0;
|
|
919
|
+
let window = 0;
|
|
920
|
+
for (const row of bucket) {
|
|
921
|
+
if (row.created_at === null || row.streamed_at === null || row.streamed_at <= row.created_at)
|
|
922
|
+
continue;
|
|
923
|
+
produced += (row.output_tokens ?? 0) + (row.reasoning_tokens ?? 0);
|
|
924
|
+
window += row.streamed_at - row.created_at;
|
|
925
|
+
}
|
|
926
|
+
const previous = result.length > 0 && result[result.length - 1].sessionID === last.session_id ? result[result.length - 1] : undefined;
|
|
927
|
+
result.push({
|
|
928
|
+
sessionID: last.session_id,
|
|
929
|
+
promptAt,
|
|
930
|
+
endAt: last.completed_at ?? last.created_at ?? last.recorded_at,
|
|
931
|
+
promptGapMs: promptAt !== undefined && previousPromptAt !== undefined ? Math.max(0, promptAt - previousPromptAt) : undefined,
|
|
932
|
+
providerID,
|
|
933
|
+
modelID,
|
|
934
|
+
variant,
|
|
935
|
+
providerChanged: previous?.providerID !== undefined && providerID !== undefined && previous.providerID !== providerID,
|
|
936
|
+
modelChanged: previous !== undefined && previous.providerID === providerID && `${previous.modelID ?? ""}#${previous.variant ?? ""}` !== `${modelID ?? ""}#${variant ?? ""}`,
|
|
937
|
+
steps: bucket.length,
|
|
938
|
+
latest: stepUsage(last),
|
|
939
|
+
totals,
|
|
940
|
+
drops,
|
|
941
|
+
finish: last.finish ?? undefined,
|
|
942
|
+
tps: tokensPerSecond(produced, 0, window)
|
|
943
|
+
});
|
|
944
|
+
bucket = [];
|
|
945
|
+
}
|
|
946
|
+
for (const row of steps) {
|
|
947
|
+
if (row.session_id !== sessionID) {
|
|
948
|
+
close();
|
|
949
|
+
sessionID = row.session_id;
|
|
950
|
+
promptIndex = -2;
|
|
951
|
+
promptAt = undefined;
|
|
952
|
+
previousPromptAt = undefined;
|
|
953
|
+
}
|
|
954
|
+
const time = row.created_at ?? row.recorded_at;
|
|
955
|
+
const prompts = promptsBySession.get(row.session_id) ?? [];
|
|
956
|
+
let index = -1;
|
|
957
|
+
for (let candidate = prompts.length - 1;candidate >= 0; candidate -= 1) {
|
|
958
|
+
if ((prompts[candidate] ?? Number.POSITIVE_INFINITY) <= time) {
|
|
959
|
+
index = candidate;
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
if (index !== promptIndex) {
|
|
964
|
+
close();
|
|
965
|
+
promptIndex = index;
|
|
966
|
+
promptAt = index >= 0 ? prompts[index] : undefined;
|
|
967
|
+
previousPromptAt = index >= 1 ? prompts[index - 1] : undefined;
|
|
968
|
+
}
|
|
969
|
+
bucket.push(row);
|
|
970
|
+
}
|
|
971
|
+
close();
|
|
972
|
+
return result.reverse().slice(0, limit);
|
|
973
|
+
}
|
|
974
|
+
function promptFloor(db, sessionID, count) {
|
|
975
|
+
const rows = db.query("SELECT created_at FROM prompts WHERE session_id = ? AND created_at IS NOT NULL ORDER BY created_at DESC LIMIT ?").all(sessionID, count);
|
|
976
|
+
return rows.length === count ? rows[count - 1].created_at : undefined;
|
|
977
|
+
}
|
|
978
|
+
function filterOptions(db) {
|
|
979
|
+
const pick = (column) => db.query(`SELECT ${column} AS value, COUNT(*) AS steps FROM steps WHERE ${column} IS NOT NULL GROUP BY ${column} ORDER BY steps DESC`).all().map((row) => ({ value: row.value, steps: row.steps }));
|
|
980
|
+
return { models: pick("model_id"), providers: pick("provider_id") };
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// src/recorder.ts
|
|
984
|
+
function createRecorder(db, options) {
|
|
985
|
+
const MAX_TRACKED_SESSIONS = 512;
|
|
986
|
+
const MAX_PENDING = 256;
|
|
987
|
+
const pending = new Map;
|
|
988
|
+
const lastModel = new Map;
|
|
989
|
+
const now = options?.now ?? (() => Date.now());
|
|
990
|
+
const MAX_DEFERRED = 256;
|
|
991
|
+
const deferred = [];
|
|
992
|
+
function isBusy(error) {
|
|
993
|
+
const code = error?.code;
|
|
994
|
+
return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED";
|
|
995
|
+
}
|
|
996
|
+
function enqueue(run) {
|
|
997
|
+
if (deferred.length >= MAX_DEFERRED)
|
|
998
|
+
deferred.shift();
|
|
999
|
+
deferred.push(run);
|
|
1000
|
+
}
|
|
1001
|
+
function flushDeferred() {
|
|
1002
|
+
while (deferred.length > 0) {
|
|
1003
|
+
const next = deferred[0];
|
|
1004
|
+
try {
|
|
1005
|
+
next();
|
|
1006
|
+
} catch {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
deferred.shift();
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
function write(run) {
|
|
1013
|
+
if (deferred.length > 0) {
|
|
1014
|
+
flushDeferred();
|
|
1015
|
+
if (deferred.length > 0) {
|
|
1016
|
+
enqueue(run);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
try {
|
|
1021
|
+
run();
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
if (isBusy(error))
|
|
1024
|
+
enqueue(run);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
function remember(map, key, value, limit) {
|
|
1028
|
+
map.delete(key);
|
|
1029
|
+
map.set(key, value);
|
|
1030
|
+
while (map.size > limit) {
|
|
1031
|
+
const oldest = map.keys().next();
|
|
1032
|
+
if (oldest.done === true)
|
|
1033
|
+
break;
|
|
1034
|
+
map.delete(oldest.value);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
function forget(sessionID) {
|
|
1038
|
+
lastModel.delete(sessionID);
|
|
1039
|
+
for (const [messageID, step] of pending) {
|
|
1040
|
+
if (step.sessionID === sessionID)
|
|
1041
|
+
pending.delete(messageID);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
function recordPendingStep(messageID, data, finish, at) {
|
|
1045
|
+
const sessionID = data.sessionID;
|
|
1046
|
+
if (typeof sessionID !== "string")
|
|
1047
|
+
return;
|
|
1048
|
+
const info = pending.get(messageID);
|
|
1049
|
+
pending.delete(messageID);
|
|
1050
|
+
const model = info?.model ?? lastModel.get(sessionID);
|
|
1051
|
+
write(() => recordStep(db, {
|
|
1052
|
+
messageID,
|
|
1053
|
+
sessionID,
|
|
1054
|
+
providerID: model?.providerID,
|
|
1055
|
+
modelID: model?.id,
|
|
1056
|
+
variant: model?.variant,
|
|
1057
|
+
input: data.tokens?.input,
|
|
1058
|
+
output: data.tokens?.output,
|
|
1059
|
+
reasoning: data.tokens?.reasoning,
|
|
1060
|
+
cacheRead: data.tokens?.cache?.read,
|
|
1061
|
+
cacheWrite: data.tokens?.cache?.write,
|
|
1062
|
+
cost: data.cost,
|
|
1063
|
+
createdAt: info?.started,
|
|
1064
|
+
streamedAt: info?.streamed,
|
|
1065
|
+
completedAt: at,
|
|
1066
|
+
finish,
|
|
1067
|
+
recordedAt: now()
|
|
1068
|
+
}));
|
|
1069
|
+
}
|
|
1070
|
+
function handleEvent(event) {
|
|
1071
|
+
if (deferred.length > 0)
|
|
1072
|
+
flushDeferred();
|
|
1073
|
+
const data = event.data ?? {};
|
|
1074
|
+
const at = typeof event.created === "number" ? event.created : now();
|
|
1075
|
+
const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined;
|
|
1076
|
+
switch (event.type) {
|
|
1077
|
+
case "session.created": {
|
|
1078
|
+
if (sessionID === undefined)
|
|
1079
|
+
return;
|
|
1080
|
+
write(() => upsertSession(db, {
|
|
1081
|
+
sessionID,
|
|
1082
|
+
parentID: data.parentID,
|
|
1083
|
+
directory: data.location?.directory,
|
|
1084
|
+
title: data.title,
|
|
1085
|
+
startedAt: at,
|
|
1086
|
+
updatedAt: at
|
|
1087
|
+
}));
|
|
1088
|
+
if (data.model !== undefined)
|
|
1089
|
+
remember(lastModel, sessionID, data.model, MAX_TRACKED_SESSIONS);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
case "session.deleted": {
|
|
1093
|
+
if (sessionID !== undefined)
|
|
1094
|
+
forget(sessionID);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
case "session.renamed": {
|
|
1098
|
+
if (sessionID === undefined)
|
|
1099
|
+
return;
|
|
1100
|
+
write(() => upsertSession(db, { sessionID, title: data.title, updatedAt: at }));
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
case "session.moved": {
|
|
1104
|
+
if (sessionID === undefined)
|
|
1105
|
+
return;
|
|
1106
|
+
write(() => upsertSession(db, { sessionID, directory: data.location?.directory, updatedAt: at }));
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
case "session.inbox.enqueued": {
|
|
1110
|
+
if (sessionID === undefined)
|
|
1111
|
+
return;
|
|
1112
|
+
if (data.item?.type !== "user" && data.item?.type !== "synthetic")
|
|
1113
|
+
return;
|
|
1114
|
+
const inboxID = data.inboxID;
|
|
1115
|
+
if (typeof inboxID !== "string")
|
|
1116
|
+
return;
|
|
1117
|
+
write(() => recordPrompt(db, { sessionID, messageID: inboxID, createdAt: at }));
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
case "session.model.selected": {
|
|
1121
|
+
if (sessionID !== undefined && data.model !== undefined)
|
|
1122
|
+
remember(lastModel, sessionID, data.model, MAX_TRACKED_SESSIONS);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
case "session.step.started": {
|
|
1126
|
+
if (sessionID === undefined || typeof data.assistantMessageID !== "string")
|
|
1127
|
+
return;
|
|
1128
|
+
remember(pending, data.assistantMessageID, {
|
|
1129
|
+
messageID: data.assistantMessageID,
|
|
1130
|
+
sessionID,
|
|
1131
|
+
started: typeof data.started === "number" ? data.started : undefined,
|
|
1132
|
+
streamed: undefined,
|
|
1133
|
+
model: data.model
|
|
1134
|
+
}, MAX_PENDING);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
case "session.step.streamed": {
|
|
1138
|
+
if (typeof data.assistantMessageID !== "string")
|
|
1139
|
+
return;
|
|
1140
|
+
const info = pending.get(data.assistantMessageID);
|
|
1141
|
+
if (info !== undefined)
|
|
1142
|
+
info.streamed = at;
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
case "session.step.ended": {
|
|
1146
|
+
if (typeof data.assistantMessageID !== "string")
|
|
1147
|
+
return;
|
|
1148
|
+
recordPendingStep(data.assistantMessageID, data, data.finish ?? "unknown", at);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
case "session.step.failed": {
|
|
1152
|
+
if (typeof data.assistantMessageID !== "string")
|
|
1153
|
+
return;
|
|
1154
|
+
recordPendingStep(data.assistantMessageID, data, data.finish ?? "error", at);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
case "session.compaction.ended": {
|
|
1158
|
+
if (sessionID === undefined)
|
|
1159
|
+
return;
|
|
1160
|
+
write(() => recordEvent(db, {
|
|
1161
|
+
sessionID,
|
|
1162
|
+
kind: COMPACTION_KIND,
|
|
1163
|
+
at,
|
|
1164
|
+
data: { reason: data.reason, model: data.model, tokens: data.tokens, cost: data.cost }
|
|
1165
|
+
}));
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
default:
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
function pendingFor(sessionID) {
|
|
1173
|
+
let found;
|
|
1174
|
+
for (const item of pending.values()) {
|
|
1175
|
+
if (item.sessionID !== sessionID)
|
|
1176
|
+
continue;
|
|
1177
|
+
if (found === undefined || (item.started ?? 0) >= (found.started ?? 0))
|
|
1178
|
+
found = item;
|
|
1179
|
+
}
|
|
1180
|
+
return found;
|
|
1181
|
+
}
|
|
1182
|
+
return { handleEvent, pendingFor };
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// src/index.ts
|
|
1186
|
+
function tryOpen(path) {
|
|
1187
|
+
try {
|
|
1188
|
+
return openStatsDb(path);
|
|
1189
|
+
} catch {
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
var src_default = Plugin.define({
|
|
1194
|
+
id: "usage-insights",
|
|
1195
|
+
async setup(ctx) {
|
|
1196
|
+
const options = ctx.options ?? {};
|
|
1197
|
+
if (options.enabled === false)
|
|
1198
|
+
return;
|
|
1199
|
+
const db = tryOpen(resolveDbPath(options.db));
|
|
1200
|
+
if (db === undefined)
|
|
1201
|
+
return;
|
|
1202
|
+
try {
|
|
1203
|
+
db.exec("PRAGMA busy_timeout = 1000");
|
|
1204
|
+
} catch {}
|
|
1205
|
+
const recorder = createRecorder(db);
|
|
1206
|
+
const abort = new AbortController;
|
|
1207
|
+
const stream = (async () => {
|
|
1208
|
+
try {
|
|
1209
|
+
for await (const event of ctx.event.subscribe({ signal: abort.signal })) {
|
|
1210
|
+
try {
|
|
1211
|
+
recorder.handleEvent(event);
|
|
1212
|
+
} catch {}
|
|
1213
|
+
}
|
|
1214
|
+
} catch {}
|
|
1215
|
+
})();
|
|
1216
|
+
return async () => {
|
|
1217
|
+
abort.abort();
|
|
1218
|
+
await stream;
|
|
1219
|
+
db.close();
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
});
|
|
1223
|
+
export {
|
|
1224
|
+
src_default as default
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1227
|
+
//# debugId=CB2A8AAFDC0E182864756E2164756E21
|