tokentrace 0.19.2 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/TOKENTRACE_AGENT.md +11 -0
- package/app/api/evidence-pack/route.ts +3 -40
- package/app/guide/guide-content.ts +1 -1
- package/app/page.tsx +4 -4
- package/components/overview/summary-cards.tsx +64 -34
- package/components/sidebar.tsx +61 -25
- package/dist/cli/main.mjs +10 -0
- package/dist/runtime/agent.mjs +31 -1
- package/dist/runtime/chatgpt-app.mjs +3901 -0
- package/dist/runtime/mcp.mjs +31 -1
- package/docs/CHATGPT_APP_PROTOTYPE.md +103 -0
- package/docs/CHATGPT_APP_RELEASE.md +101 -0
- package/docs/chatgpt-app/README.md +24 -0
- package/docs/chatgpt-app/assets/icon.svg +10 -0
- package/docs/chatgpt-app/assets/listing-card.svg +34 -0
- package/docs/chatgpt-app/assets/widget-preview.svg +24 -0
- package/docs/chatgpt-app/dashboard-fields.json +39 -0
- package/docs/chatgpt-app/manual-release-steps.md +114 -0
- package/docs/chatgpt-app/privacy-and-data.md +39 -0
- package/docs/chatgpt-app/review-response-template.md +53 -0
- package/docs/chatgpt-app/screenshot-checklist.md +46 -0
- package/docs/chatgpt-app/submission-copy.md +45 -0
- package/docs/chatgpt-app/test-prompts-and-responses.md +61 -0
- package/llms.txt +13 -0
- package/next.config.mjs +8 -2
- package/package.json +11 -4
- package/scripts/build-cli-runtime.mjs +1 -0
- package/scripts/chatgpt-app-release-check.mjs +489 -0
- package/scripts/chatgpt-app.ts +107 -0
- package/scripts/package-inspect.mjs +6 -1
- package/scripts/smoke-packed-install.mjs +5 -0
- package/server.json +3 -3
- package/src/cli/commands.ts +8 -0
- package/src/cli/help.ts +3 -0
- package/src/lib/agent-discovery.ts +32 -1
- package/src/lib/chatgpt-app/prototype.ts +452 -0
- package/src/lib/chatgpt-app/server.ts +162 -0
- package/src/lib/evidence-pack.ts +47 -0
|
@@ -0,0 +1,3901 @@
|
|
|
1
|
+
import { createRequire as __tokentraceCreateRequire } from 'node:module'; const require = __tokentraceCreateRequire(import.meta.url);
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __export = (target, all) => {
|
|
4
|
+
for (var name in all)
|
|
5
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// src/lib/chatgpt-app/server.ts
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
11
|
+
|
|
12
|
+
// src/lib/chatgpt-app/prototype.ts
|
|
13
|
+
import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
|
|
14
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
|
|
17
|
+
// src/lib/format.ts
|
|
18
|
+
function formatTokens(value) {
|
|
19
|
+
const number4 = value ?? 0;
|
|
20
|
+
if (number4 >= 999995e3) return `${(number4 / 1e9).toFixed(2)}B`;
|
|
21
|
+
if (number4 >= 999950) return `${(number4 / 1e6).toFixed(2)}M`;
|
|
22
|
+
if (number4 >= 1e3) return `${(number4 / 1e3).toFixed(1)}K`;
|
|
23
|
+
return number4.toLocaleString();
|
|
24
|
+
}
|
|
25
|
+
function formatCurrency(value, currency = "USD") {
|
|
26
|
+
if (value == null) return "Unknown";
|
|
27
|
+
return new Intl.NumberFormat("en-US", {
|
|
28
|
+
style: "currency",
|
|
29
|
+
currency,
|
|
30
|
+
maximumFractionDigits: value < 1 ? 4 : 2
|
|
31
|
+
}).format(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/db/client.ts
|
|
35
|
+
import fs from "node:fs";
|
|
36
|
+
import path from "node:path";
|
|
37
|
+
import { fileURLToPath } from "node:url";
|
|
38
|
+
import Database from "better-sqlite3";
|
|
39
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
40
|
+
|
|
41
|
+
// src/db/migrate-core.ts
|
|
42
|
+
var ddl = `
|
|
43
|
+
PRAGMA journal_mode = WAL;
|
|
44
|
+
PRAGMA foreign_keys = ON;
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS providers (
|
|
47
|
+
id TEXT PRIMARY KEY,
|
|
48
|
+
name TEXT NOT NULL,
|
|
49
|
+
type TEXT NOT NULL,
|
|
50
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS tools (
|
|
54
|
+
id TEXT PRIMARY KEY,
|
|
55
|
+
provider_id TEXT NOT NULL REFERENCES providers(id) ON DELETE CASCADE,
|
|
56
|
+
name TEXT NOT NULL,
|
|
57
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
|
58
|
+
);
|
|
59
|
+
CREATE UNIQUE INDEX IF NOT EXISTS tools_provider_name_idx ON tools(provider_id, name);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS models (
|
|
62
|
+
id TEXT PRIMARY KEY,
|
|
63
|
+
provider_id TEXT NOT NULL REFERENCES providers(id) ON DELETE CASCADE,
|
|
64
|
+
name TEXT NOT NULL,
|
|
65
|
+
input_token_price REAL,
|
|
66
|
+
output_token_price REAL,
|
|
67
|
+
cached_input_token_price REAL,
|
|
68
|
+
cache_write_token_price REAL,
|
|
69
|
+
currency TEXT NOT NULL DEFAULT 'USD',
|
|
70
|
+
effective_from INTEGER,
|
|
71
|
+
raw_metadata TEXT
|
|
72
|
+
);
|
|
73
|
+
CREATE UNIQUE INDEX IF NOT EXISTS models_provider_name_idx ON models(provider_id, name);
|
|
74
|
+
|
|
75
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
76
|
+
id TEXT PRIMARY KEY,
|
|
77
|
+
name TEXT NOT NULL,
|
|
78
|
+
path TEXT NOT NULL,
|
|
79
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
|
80
|
+
);
|
|
81
|
+
CREATE UNIQUE INDEX IF NOT EXISTS projects_path_idx ON projects(path);
|
|
82
|
+
|
|
83
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
84
|
+
id TEXT PRIMARY KEY,
|
|
85
|
+
source_id TEXT NOT NULL,
|
|
86
|
+
tool_id TEXT NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
|
|
87
|
+
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
|
88
|
+
started_at INTEGER,
|
|
89
|
+
ended_at INTEGER,
|
|
90
|
+
title TEXT,
|
|
91
|
+
source_file TEXT NOT NULL,
|
|
92
|
+
raw_metadata TEXT
|
|
93
|
+
);
|
|
94
|
+
CREATE UNIQUE INDEX IF NOT EXISTS sessions_source_id_idx ON sessions(source_id);
|
|
95
|
+
CREATE INDEX IF NOT EXISTS sessions_tool_idx ON sessions(tool_id);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS sessions_project_idx ON sessions(project_id);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS sessions_started_idx ON sessions(started_at);
|
|
98
|
+
|
|
99
|
+
CREATE TABLE IF NOT EXISTS interactions (
|
|
100
|
+
id TEXT PRIMARY KEY,
|
|
101
|
+
source_id TEXT NOT NULL,
|
|
102
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
103
|
+
timestamp INTEGER,
|
|
104
|
+
role TEXT NOT NULL,
|
|
105
|
+
model_id TEXT REFERENCES models(id) ON DELETE SET NULL,
|
|
106
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
107
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
108
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
109
|
+
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
|
110
|
+
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
|
111
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
112
|
+
estimated_tokens INTEGER NOT NULL DEFAULT 0,
|
|
113
|
+
token_confidence TEXT NOT NULL DEFAULT 'unknown',
|
|
114
|
+
cost REAL,
|
|
115
|
+
cost_estimated INTEGER NOT NULL DEFAULT 0,
|
|
116
|
+
latency_ms INTEGER,
|
|
117
|
+
raw_text_preview TEXT,
|
|
118
|
+
raw_text TEXT,
|
|
119
|
+
raw_metadata TEXT
|
|
120
|
+
);
|
|
121
|
+
CREATE UNIQUE INDEX IF NOT EXISTS interactions_source_id_idx ON interactions(source_id);
|
|
122
|
+
CREATE INDEX IF NOT EXISTS interactions_session_idx ON interactions(session_id);
|
|
123
|
+
CREATE INDEX IF NOT EXISTS interactions_model_idx ON interactions(model_id);
|
|
124
|
+
CREATE INDEX IF NOT EXISTS interactions_timestamp_idx ON interactions(timestamp);
|
|
125
|
+
CREATE INDEX IF NOT EXISTS interactions_analytics_cover_idx
|
|
126
|
+
ON interactions(timestamp, session_id, model_id, total_tokens, input_tokens, output_tokens,
|
|
127
|
+
cache_read_tokens, cache_write_tokens, reasoning_tokens, cost, cost_estimated,
|
|
128
|
+
estimated_tokens, token_confidence);
|
|
129
|
+
CREATE INDEX IF NOT EXISTS interactions_session_analytics_idx
|
|
130
|
+
ON interactions(session_id, timestamp, model_id, total_tokens, input_tokens, output_tokens,
|
|
131
|
+
cache_read_tokens, cache_write_tokens, reasoning_tokens, cost, cost_estimated,
|
|
132
|
+
estimated_tokens, token_confidence);
|
|
133
|
+
CREATE INDEX IF NOT EXISTS interactions_cost_repair_idx
|
|
134
|
+
ON interactions(cost, timestamp, session_id, model_id, total_tokens, input_tokens, output_tokens,
|
|
135
|
+
cache_read_tokens, cache_write_tokens, reasoning_tokens, token_confidence);
|
|
136
|
+
|
|
137
|
+
PRAGMA user_version;
|
|
138
|
+
|
|
139
|
+
CREATE TABLE IF NOT EXISTS tool_calls (
|
|
140
|
+
id TEXT PRIMARY KEY,
|
|
141
|
+
interaction_id TEXT NOT NULL REFERENCES interactions(id) ON DELETE CASCADE,
|
|
142
|
+
name TEXT NOT NULL,
|
|
143
|
+
status TEXT,
|
|
144
|
+
duration_ms INTEGER,
|
|
145
|
+
raw_metadata TEXT
|
|
146
|
+
);
|
|
147
|
+
CREATE INDEX IF NOT EXISTS tool_calls_interaction_idx ON tool_calls(interaction_id);
|
|
148
|
+
|
|
149
|
+
CREATE TABLE IF NOT EXISTS scan_runs (
|
|
150
|
+
id TEXT PRIMARY KEY,
|
|
151
|
+
started_at INTEGER NOT NULL,
|
|
152
|
+
completed_at INTEGER,
|
|
153
|
+
files_scanned INTEGER NOT NULL DEFAULT 0,
|
|
154
|
+
records_imported INTEGER NOT NULL DEFAULT 0,
|
|
155
|
+
warnings TEXT NOT NULL DEFAULT '[]',
|
|
156
|
+
errors TEXT NOT NULL DEFAULT '[]'
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
CREATE TABLE IF NOT EXISTS scan_files (
|
|
160
|
+
id TEXT PRIMARY KEY,
|
|
161
|
+
scan_run_id TEXT NOT NULL REFERENCES scan_runs(id) ON DELETE CASCADE,
|
|
162
|
+
path TEXT NOT NULL,
|
|
163
|
+
modified_time INTEGER,
|
|
164
|
+
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
165
|
+
file_hash TEXT,
|
|
166
|
+
parser TEXT,
|
|
167
|
+
status TEXT NOT NULL,
|
|
168
|
+
records_imported INTEGER NOT NULL DEFAULT 0,
|
|
169
|
+
warnings TEXT NOT NULL DEFAULT '[]',
|
|
170
|
+
errors TEXT NOT NULL DEFAULT '[]',
|
|
171
|
+
raw_metadata TEXT
|
|
172
|
+
);
|
|
173
|
+
CREATE INDEX IF NOT EXISTS scan_files_path_hash_idx ON scan_files(path, file_hash);
|
|
174
|
+
CREATE INDEX IF NOT EXISTS scan_files_run_idx ON scan_files(scan_run_id);
|
|
175
|
+
CREATE INDEX IF NOT EXISTS scan_files_path_latest_idx ON scan_files(path, scan_run_id, status, parser);
|
|
176
|
+
|
|
177
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
178
|
+
key TEXT PRIMARY KEY,
|
|
179
|
+
value TEXT NOT NULL,
|
|
180
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
CREATE TABLE IF NOT EXISTS unknown_cost_reviews (
|
|
184
|
+
key TEXT PRIMARY KEY,
|
|
185
|
+
source_file TEXT NOT NULL DEFAULT '',
|
|
186
|
+
model TEXT NOT NULL DEFAULT '',
|
|
187
|
+
cause TEXT NOT NULL DEFAULT '',
|
|
188
|
+
status TEXT NOT NULL DEFAULT 'unresolved',
|
|
189
|
+
notes TEXT NOT NULL DEFAULT '',
|
|
190
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
|
191
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
CREATE TABLE IF NOT EXISTS saved_views (
|
|
195
|
+
id TEXT PRIMARY KEY,
|
|
196
|
+
name TEXT NOT NULL,
|
|
197
|
+
filters TEXT NOT NULL DEFAULT '{}',
|
|
198
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
|
199
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
|
200
|
+
);
|
|
201
|
+
CREATE INDEX IF NOT EXISTS saved_views_updated_idx ON saved_views(updated_at);
|
|
202
|
+
|
|
203
|
+
CREATE TABLE IF NOT EXISTS file_parser_overrides (
|
|
204
|
+
path TEXT PRIMARY KEY,
|
|
205
|
+
parser_id TEXT,
|
|
206
|
+
excluded INTEGER NOT NULL DEFAULT 0,
|
|
207
|
+
note TEXT,
|
|
208
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
|
209
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
|
210
|
+
CHECK (excluded IN (0, 1)),
|
|
211
|
+
CHECK ((excluded = 1 AND parser_id IS NULL) OR (excluded = 0 AND parser_id IS NOT NULL))
|
|
212
|
+
);
|
|
213
|
+
CREATE INDEX IF NOT EXISTS file_parser_overrides_updated_idx ON file_parser_overrides(updated_at);
|
|
214
|
+
|
|
215
|
+
CREATE TABLE IF NOT EXISTS saved_reports (
|
|
216
|
+
id TEXT PRIMARY KEY,
|
|
217
|
+
name TEXT NOT NULL,
|
|
218
|
+
name_lower TEXT NOT NULL UNIQUE,
|
|
219
|
+
view_type TEXT NOT NULL,
|
|
220
|
+
params TEXT NOT NULL DEFAULT '{}',
|
|
221
|
+
format TEXT NOT NULL DEFAULT 'markdown',
|
|
222
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
|
223
|
+
last_run_at INTEGER
|
|
224
|
+
);
|
|
225
|
+
CREATE INDEX IF NOT EXISTS saved_reports_updated_idx ON saved_reports(created_at);
|
|
226
|
+
|
|
227
|
+
CREATE TABLE IF NOT EXISTS agent_actions (
|
|
228
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
229
|
+
ts INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
|
230
|
+
surface TEXT NOT NULL,
|
|
231
|
+
command TEXT NOT NULL,
|
|
232
|
+
outcome TEXT NOT NULL,
|
|
233
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
234
|
+
payload TEXT NOT NULL DEFAULT '{}'
|
|
235
|
+
);
|
|
236
|
+
CREATE INDEX IF NOT EXISTS agent_actions_ts_idx ON agent_actions(ts);
|
|
237
|
+
|
|
238
|
+
CREATE TABLE IF NOT EXISTS model_aliases (
|
|
239
|
+
id TEXT PRIMARY KEY,
|
|
240
|
+
provider_id TEXT NOT NULL,
|
|
241
|
+
observed_model TEXT NOT NULL,
|
|
242
|
+
priced_model_id TEXT NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
|
243
|
+
confidence REAL NOT NULL,
|
|
244
|
+
rule TEXT NOT NULL,
|
|
245
|
+
notes TEXT,
|
|
246
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
|
247
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
|
248
|
+
);
|
|
249
|
+
CREATE UNIQUE INDEX IF NOT EXISTS model_aliases_provider_observed_idx
|
|
250
|
+
ON model_aliases(provider_id, observed_model);
|
|
251
|
+
CREATE INDEX IF NOT EXISTS model_aliases_priced_model_idx
|
|
252
|
+
ON model_aliases(priced_model_id);
|
|
253
|
+
`;
|
|
254
|
+
function applyMigrations(sqlite2) {
|
|
255
|
+
sqlite2.exec(ddl);
|
|
256
|
+
const interactionColumns = sqlite2.prepare("PRAGMA table_info(interactions)").all();
|
|
257
|
+
if (!interactionColumns.some((column) => column.name === "token_confidence")) {
|
|
258
|
+
try {
|
|
259
|
+
sqlite2.exec("ALTER TABLE interactions ADD COLUMN token_confidence TEXT NOT NULL DEFAULT 'unknown'");
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (!(error instanceof Error) || !error.message.toLowerCase().includes("duplicate column")) {
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const modelColumns = sqlite2.prepare("PRAGMA table_info(models)").all();
|
|
267
|
+
if (!modelColumns.some((column) => column.name === "cache_write_token_price")) {
|
|
268
|
+
try {
|
|
269
|
+
sqlite2.exec("ALTER TABLE models ADD COLUMN cache_write_token_price REAL");
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (!(error instanceof Error) || !error.message.toLowerCase().includes("duplicate column")) {
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const reviewColumns = sqlite2.prepare("PRAGMA table_info(unknown_cost_reviews)").all();
|
|
277
|
+
const reviewColumnNames = new Set(reviewColumns.map((column) => column.name));
|
|
278
|
+
const addReviewColumn = (name, definition) => {
|
|
279
|
+
if (reviewColumnNames.has(name)) return;
|
|
280
|
+
try {
|
|
281
|
+
sqlite2.exec(`ALTER TABLE unknown_cost_reviews ADD COLUMN ${definition}`);
|
|
282
|
+
reviewColumnNames.add(name);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (!(error instanceof Error) || !error.message.toLowerCase().includes("duplicate column")) {
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
addReviewColumn("source_file", "source_file TEXT NOT NULL DEFAULT ''");
|
|
290
|
+
addReviewColumn("model", "model TEXT NOT NULL DEFAULT ''");
|
|
291
|
+
addReviewColumn("cause", "cause TEXT NOT NULL DEFAULT ''");
|
|
292
|
+
addReviewColumn("status", "status TEXT NOT NULL DEFAULT 'unresolved'");
|
|
293
|
+
addReviewColumn("notes", "notes TEXT NOT NULL DEFAULT ''");
|
|
294
|
+
addReviewColumn("created_at", "created_at INTEGER NOT NULL DEFAULT 0");
|
|
295
|
+
addReviewColumn("updated_at", "updated_at INTEGER NOT NULL DEFAULT 0");
|
|
296
|
+
const upgradedReviewColumns = sqlite2.prepare("PRAGMA table_info(unknown_cost_reviews)").all();
|
|
297
|
+
const upgradedReviewColumnNames = new Set(upgradedReviewColumns.map((column) => column.name));
|
|
298
|
+
if (upgradedReviewColumnNames.has("state")) {
|
|
299
|
+
sqlite2.exec("UPDATE unknown_cost_reviews SET status = state WHERE status = 'unresolved' AND state <> ''");
|
|
300
|
+
}
|
|
301
|
+
if (upgradedReviewColumnNames.has("note")) {
|
|
302
|
+
sqlite2.exec("UPDATE unknown_cost_reviews SET notes = note WHERE notes = '' AND note <> ''");
|
|
303
|
+
}
|
|
304
|
+
sqlite2.exec(`
|
|
305
|
+
UPDATE unknown_cost_reviews
|
|
306
|
+
SET cause = substr(key, 1, instr(key, ':') - 1)
|
|
307
|
+
WHERE cause = '' AND instr(key, ':') > 0;
|
|
308
|
+
|
|
309
|
+
UPDATE unknown_cost_reviews
|
|
310
|
+
SET created_at = updated_at
|
|
311
|
+
WHERE created_at = 0 AND updated_at > 0;
|
|
312
|
+
|
|
313
|
+
UPDATE unknown_cost_reviews
|
|
314
|
+
SET created_at = unixepoch() * 1000
|
|
315
|
+
WHERE created_at = 0;
|
|
316
|
+
|
|
317
|
+
UPDATE unknown_cost_reviews
|
|
318
|
+
SET updated_at = created_at
|
|
319
|
+
WHERE updated_at = 0;
|
|
320
|
+
`);
|
|
321
|
+
const repairRows = sqlite2.prepare("SELECT key, model FROM unknown_cost_reviews WHERE model = ''").all();
|
|
322
|
+
const updateModel = sqlite2.prepare("UPDATE unknown_cost_reviews SET model = ? WHERE key = ?");
|
|
323
|
+
for (const row of repairRows) {
|
|
324
|
+
const model = row.key.split(":").at(-1) ?? "";
|
|
325
|
+
updateModel.run(model, row.key);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// src/db/sqlite-functions.ts
|
|
330
|
+
var localDateKeyFunctionName = "local_date_key";
|
|
331
|
+
var invalidDateKey = "1970-01-01";
|
|
332
|
+
function timestampNumber(value) {
|
|
333
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
334
|
+
if (typeof value === "string" && value.trim()) {
|
|
335
|
+
const numeric = Number(value);
|
|
336
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
function formatLocalDateKey(value) {
|
|
341
|
+
const timestamp = timestampNumber(value);
|
|
342
|
+
if (timestamp == null) return invalidDateKey;
|
|
343
|
+
const date = new Date(timestamp);
|
|
344
|
+
if (Number.isNaN(date.getTime())) return invalidDateKey;
|
|
345
|
+
return [
|
|
346
|
+
date.getFullYear(),
|
|
347
|
+
String(date.getMonth() + 1).padStart(2, "0"),
|
|
348
|
+
String(date.getDate()).padStart(2, "0")
|
|
349
|
+
].join("-");
|
|
350
|
+
}
|
|
351
|
+
function registerSqliteFunctions(sqlite2) {
|
|
352
|
+
sqlite2.function(localDateKeyFunctionName, { deterministic: true }, formatLocalDateKey);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// src/db/schema.ts
|
|
356
|
+
var schema_exports = {};
|
|
357
|
+
__export(schema_exports, {
|
|
358
|
+
interactionRelations: () => interactionRelations,
|
|
359
|
+
interactions: () => interactions,
|
|
360
|
+
modelRelations: () => modelRelations,
|
|
361
|
+
models: () => models,
|
|
362
|
+
projectRelations: () => projectRelations,
|
|
363
|
+
projects: () => projects,
|
|
364
|
+
providerRelations: () => providerRelations,
|
|
365
|
+
providers: () => providers,
|
|
366
|
+
savedViews: () => savedViews,
|
|
367
|
+
scanFiles: () => scanFiles,
|
|
368
|
+
scanRuns: () => scanRuns,
|
|
369
|
+
sessionRelations: () => sessionRelations,
|
|
370
|
+
sessions: () => sessions,
|
|
371
|
+
settings: () => settings,
|
|
372
|
+
toolCalls: () => toolCalls,
|
|
373
|
+
toolRelations: () => toolRelations,
|
|
374
|
+
tools: () => tools,
|
|
375
|
+
unknownCostReviews: () => unknownCostReviews
|
|
376
|
+
});
|
|
377
|
+
import { relations, sql } from "drizzle-orm";
|
|
378
|
+
import {
|
|
379
|
+
index,
|
|
380
|
+
integer,
|
|
381
|
+
real,
|
|
382
|
+
sqliteTable,
|
|
383
|
+
text,
|
|
384
|
+
uniqueIndex
|
|
385
|
+
} from "drizzle-orm/sqlite-core";
|
|
386
|
+
var providers = sqliteTable("providers", {
|
|
387
|
+
id: text("id").primaryKey(),
|
|
388
|
+
name: text("name").notNull(),
|
|
389
|
+
type: text("type").notNull(),
|
|
390
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
|
|
391
|
+
});
|
|
392
|
+
var tools = sqliteTable(
|
|
393
|
+
"tools",
|
|
394
|
+
{
|
|
395
|
+
id: text("id").primaryKey(),
|
|
396
|
+
providerId: text("provider_id").notNull().references(() => providers.id, { onDelete: "cascade" }),
|
|
397
|
+
name: text("name").notNull(),
|
|
398
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
|
|
399
|
+
},
|
|
400
|
+
(table) => ({
|
|
401
|
+
providerNameIdx: uniqueIndex("tools_provider_name_idx").on(
|
|
402
|
+
table.providerId,
|
|
403
|
+
table.name
|
|
404
|
+
)
|
|
405
|
+
})
|
|
406
|
+
);
|
|
407
|
+
var models = sqliteTable(
|
|
408
|
+
"models",
|
|
409
|
+
{
|
|
410
|
+
id: text("id").primaryKey(),
|
|
411
|
+
providerId: text("provider_id").notNull().references(() => providers.id, { onDelete: "cascade" }),
|
|
412
|
+
name: text("name").notNull(),
|
|
413
|
+
inputTokenPrice: real("input_token_price"),
|
|
414
|
+
outputTokenPrice: real("output_token_price"),
|
|
415
|
+
cachedInputTokenPrice: real("cached_input_token_price"),
|
|
416
|
+
cacheWriteTokenPrice: real("cache_write_token_price"),
|
|
417
|
+
currency: text("currency").notNull().default("USD"),
|
|
418
|
+
effectiveFrom: integer("effective_from", { mode: "timestamp_ms" }),
|
|
419
|
+
rawMetadata: text("raw_metadata", { mode: "json" }).$type()
|
|
420
|
+
},
|
|
421
|
+
(table) => ({
|
|
422
|
+
providerModelIdx: uniqueIndex("models_provider_name_idx").on(
|
|
423
|
+
table.providerId,
|
|
424
|
+
table.name
|
|
425
|
+
)
|
|
426
|
+
})
|
|
427
|
+
);
|
|
428
|
+
var projects = sqliteTable(
|
|
429
|
+
"projects",
|
|
430
|
+
{
|
|
431
|
+
id: text("id").primaryKey(),
|
|
432
|
+
name: text("name").notNull(),
|
|
433
|
+
path: text("path").notNull(),
|
|
434
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
|
|
435
|
+
},
|
|
436
|
+
(table) => ({
|
|
437
|
+
pathIdx: uniqueIndex("projects_path_idx").on(table.path)
|
|
438
|
+
})
|
|
439
|
+
);
|
|
440
|
+
var sessions = sqliteTable(
|
|
441
|
+
"sessions",
|
|
442
|
+
{
|
|
443
|
+
id: text("id").primaryKey(),
|
|
444
|
+
sourceId: text("source_id").notNull(),
|
|
445
|
+
toolId: text("tool_id").notNull().references(() => tools.id, { onDelete: "cascade" }),
|
|
446
|
+
projectId: text("project_id").references(() => projects.id, {
|
|
447
|
+
onDelete: "set null"
|
|
448
|
+
}),
|
|
449
|
+
startedAt: integer("started_at", { mode: "timestamp_ms" }),
|
|
450
|
+
endedAt: integer("ended_at", { mode: "timestamp_ms" }),
|
|
451
|
+
title: text("title"),
|
|
452
|
+
sourceFile: text("source_file").notNull(),
|
|
453
|
+
rawMetadata: text("raw_metadata", { mode: "json" }).$type()
|
|
454
|
+
},
|
|
455
|
+
(table) => ({
|
|
456
|
+
sourceIdx: uniqueIndex("sessions_source_id_idx").on(table.sourceId),
|
|
457
|
+
toolIdx: index("sessions_tool_idx").on(table.toolId),
|
|
458
|
+
projectIdx: index("sessions_project_idx").on(table.projectId),
|
|
459
|
+
startedIdx: index("sessions_started_idx").on(table.startedAt)
|
|
460
|
+
})
|
|
461
|
+
);
|
|
462
|
+
var interactions = sqliteTable(
|
|
463
|
+
"interactions",
|
|
464
|
+
{
|
|
465
|
+
id: text("id").primaryKey(),
|
|
466
|
+
sourceId: text("source_id").notNull(),
|
|
467
|
+
sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
|
|
468
|
+
timestamp: integer("timestamp", { mode: "timestamp_ms" }),
|
|
469
|
+
role: text("role").notNull(),
|
|
470
|
+
modelId: text("model_id").references(() => models.id, {
|
|
471
|
+
onDelete: "set null"
|
|
472
|
+
}),
|
|
473
|
+
inputTokens: integer("input_tokens").notNull().default(0),
|
|
474
|
+
outputTokens: integer("output_tokens").notNull().default(0),
|
|
475
|
+
cacheReadTokens: integer("cache_read_tokens").notNull().default(0),
|
|
476
|
+
cacheWriteTokens: integer("cache_write_tokens").notNull().default(0),
|
|
477
|
+
reasoningTokens: integer("reasoning_tokens").notNull().default(0),
|
|
478
|
+
totalTokens: integer("total_tokens").notNull().default(0),
|
|
479
|
+
estimatedTokens: integer("estimated_tokens", { mode: "boolean" }).notNull().default(false),
|
|
480
|
+
tokenConfidence: text("token_confidence").notNull().default("unknown"),
|
|
481
|
+
cost: real("cost"),
|
|
482
|
+
costEstimated: integer("cost_estimated", { mode: "boolean" }).notNull().default(false),
|
|
483
|
+
latencyMs: integer("latency_ms"),
|
|
484
|
+
rawTextPreview: text("raw_text_preview"),
|
|
485
|
+
rawText: text("raw_text"),
|
|
486
|
+
rawMetadata: text("raw_metadata", { mode: "json" }).$type()
|
|
487
|
+
},
|
|
488
|
+
(table) => ({
|
|
489
|
+
sourceIdx: uniqueIndex("interactions_source_id_idx").on(table.sourceId),
|
|
490
|
+
sessionIdx: index("interactions_session_idx").on(table.sessionId),
|
|
491
|
+
modelIdx: index("interactions_model_idx").on(table.modelId),
|
|
492
|
+
timestampIdx: index("interactions_timestamp_idx").on(table.timestamp),
|
|
493
|
+
analyticsCoverIdx: index("interactions_analytics_cover_idx").on(
|
|
494
|
+
table.timestamp,
|
|
495
|
+
table.sessionId,
|
|
496
|
+
table.modelId,
|
|
497
|
+
table.totalTokens,
|
|
498
|
+
table.inputTokens,
|
|
499
|
+
table.outputTokens,
|
|
500
|
+
table.cacheReadTokens,
|
|
501
|
+
table.cacheWriteTokens,
|
|
502
|
+
table.reasoningTokens,
|
|
503
|
+
table.cost,
|
|
504
|
+
table.costEstimated,
|
|
505
|
+
table.estimatedTokens,
|
|
506
|
+
table.tokenConfidence
|
|
507
|
+
),
|
|
508
|
+
sessionAnalyticsIdx: index("interactions_session_analytics_idx").on(
|
|
509
|
+
table.sessionId,
|
|
510
|
+
table.timestamp,
|
|
511
|
+
table.modelId,
|
|
512
|
+
table.totalTokens,
|
|
513
|
+
table.inputTokens,
|
|
514
|
+
table.outputTokens,
|
|
515
|
+
table.cacheReadTokens,
|
|
516
|
+
table.cacheWriteTokens,
|
|
517
|
+
table.reasoningTokens,
|
|
518
|
+
table.cost,
|
|
519
|
+
table.costEstimated,
|
|
520
|
+
table.estimatedTokens,
|
|
521
|
+
table.tokenConfidence
|
|
522
|
+
),
|
|
523
|
+
costRepairIdx: index("interactions_cost_repair_idx").on(
|
|
524
|
+
table.cost,
|
|
525
|
+
table.timestamp,
|
|
526
|
+
table.sessionId,
|
|
527
|
+
table.modelId,
|
|
528
|
+
table.totalTokens,
|
|
529
|
+
table.inputTokens,
|
|
530
|
+
table.outputTokens,
|
|
531
|
+
table.cacheReadTokens,
|
|
532
|
+
table.cacheWriteTokens,
|
|
533
|
+
table.reasoningTokens,
|
|
534
|
+
table.tokenConfidence
|
|
535
|
+
)
|
|
536
|
+
})
|
|
537
|
+
);
|
|
538
|
+
var toolCalls = sqliteTable(
|
|
539
|
+
"tool_calls",
|
|
540
|
+
{
|
|
541
|
+
id: text("id").primaryKey(),
|
|
542
|
+
interactionId: text("interaction_id").notNull().references(() => interactions.id, { onDelete: "cascade" }),
|
|
543
|
+
name: text("name").notNull(),
|
|
544
|
+
status: text("status"),
|
|
545
|
+
durationMs: integer("duration_ms"),
|
|
546
|
+
rawMetadata: text("raw_metadata", { mode: "json" }).$type()
|
|
547
|
+
},
|
|
548
|
+
(table) => ({
|
|
549
|
+
interactionIdx: index("tool_calls_interaction_idx").on(table.interactionId)
|
|
550
|
+
})
|
|
551
|
+
);
|
|
552
|
+
var scanRuns = sqliteTable("scan_runs", {
|
|
553
|
+
id: text("id").primaryKey(),
|
|
554
|
+
startedAt: integer("started_at", { mode: "timestamp_ms" }).notNull(),
|
|
555
|
+
completedAt: integer("completed_at", { mode: "timestamp_ms" }),
|
|
556
|
+
filesScanned: integer("files_scanned").notNull().default(0),
|
|
557
|
+
recordsImported: integer("records_imported").notNull().default(0),
|
|
558
|
+
warnings: text("warnings", { mode: "json" }).$type().notNull().default(sql`'[]'`),
|
|
559
|
+
errors: text("errors", { mode: "json" }).$type().notNull().default(sql`'[]'`)
|
|
560
|
+
});
|
|
561
|
+
var scanFiles = sqliteTable(
|
|
562
|
+
"scan_files",
|
|
563
|
+
{
|
|
564
|
+
id: text("id").primaryKey(),
|
|
565
|
+
scanRunId: text("scan_run_id").notNull().references(() => scanRuns.id, { onDelete: "cascade" }),
|
|
566
|
+
path: text("path").notNull(),
|
|
567
|
+
modifiedTime: integer("modified_time", { mode: "timestamp_ms" }),
|
|
568
|
+
sizeBytes: integer("size_bytes").notNull().default(0),
|
|
569
|
+
fileHash: text("file_hash"),
|
|
570
|
+
parser: text("parser"),
|
|
571
|
+
status: text("status").notNull(),
|
|
572
|
+
recordsImported: integer("records_imported").notNull().default(0),
|
|
573
|
+
warnings: text("warnings", { mode: "json" }).$type().notNull().default(sql`'[]'`),
|
|
574
|
+
errors: text("errors", { mode: "json" }).$type().notNull().default(sql`'[]'`),
|
|
575
|
+
rawMetadata: text("raw_metadata", { mode: "json" }).$type()
|
|
576
|
+
},
|
|
577
|
+
(table) => ({
|
|
578
|
+
pathHashIdx: index("scan_files_path_hash_idx").on(table.path, table.fileHash),
|
|
579
|
+
scanRunIdx: index("scan_files_run_idx").on(table.scanRunId),
|
|
580
|
+
pathLatestIdx: index("scan_files_path_latest_idx").on(
|
|
581
|
+
table.path,
|
|
582
|
+
table.scanRunId,
|
|
583
|
+
table.status,
|
|
584
|
+
table.parser
|
|
585
|
+
)
|
|
586
|
+
})
|
|
587
|
+
);
|
|
588
|
+
var settings = sqliteTable("settings", {
|
|
589
|
+
key: text("key").primaryKey(),
|
|
590
|
+
value: text("value", { mode: "json" }).$type().notNull(),
|
|
591
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
|
|
592
|
+
});
|
|
593
|
+
var unknownCostReviews = sqliteTable("unknown_cost_reviews", {
|
|
594
|
+
key: text("key").primaryKey(),
|
|
595
|
+
sourceFile: text("source_file").notNull().default(""),
|
|
596
|
+
model: text("model").notNull().default(""),
|
|
597
|
+
cause: text("cause").notNull().default(""),
|
|
598
|
+
status: text("status").notNull().default("unresolved"),
|
|
599
|
+
notes: text("notes").notNull().default(""),
|
|
600
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
|
|
601
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
|
|
602
|
+
});
|
|
603
|
+
var savedViews = sqliteTable(
|
|
604
|
+
"saved_views",
|
|
605
|
+
{
|
|
606
|
+
id: text("id").primaryKey(),
|
|
607
|
+
name: text("name").notNull(),
|
|
608
|
+
filters: text("filters", { mode: "json" }).$type().notNull().default(sql`'{}'`),
|
|
609
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
|
|
610
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
|
|
611
|
+
},
|
|
612
|
+
(table) => ({
|
|
613
|
+
updatedIdx: index("saved_views_updated_idx").on(table.updatedAt)
|
|
614
|
+
})
|
|
615
|
+
);
|
|
616
|
+
var providerRelations = relations(providers, ({ many }) => ({
|
|
617
|
+
tools: many(tools),
|
|
618
|
+
models: many(models)
|
|
619
|
+
}));
|
|
620
|
+
var toolRelations = relations(tools, ({ one, many }) => ({
|
|
621
|
+
provider: one(providers, {
|
|
622
|
+
fields: [tools.providerId],
|
|
623
|
+
references: [providers.id]
|
|
624
|
+
}),
|
|
625
|
+
sessions: many(sessions)
|
|
626
|
+
}));
|
|
627
|
+
var modelRelations = relations(models, ({ one, many }) => ({
|
|
628
|
+
provider: one(providers, {
|
|
629
|
+
fields: [models.providerId],
|
|
630
|
+
references: [providers.id]
|
|
631
|
+
}),
|
|
632
|
+
interactions: many(interactions)
|
|
633
|
+
}));
|
|
634
|
+
var projectRelations = relations(projects, ({ many }) => ({
|
|
635
|
+
sessions: many(sessions)
|
|
636
|
+
}));
|
|
637
|
+
var sessionRelations = relations(sessions, ({ one, many }) => ({
|
|
638
|
+
tool: one(tools, {
|
|
639
|
+
fields: [sessions.toolId],
|
|
640
|
+
references: [tools.id]
|
|
641
|
+
}),
|
|
642
|
+
project: one(projects, {
|
|
643
|
+
fields: [sessions.projectId],
|
|
644
|
+
references: [projects.id]
|
|
645
|
+
}),
|
|
646
|
+
interactions: many(interactions)
|
|
647
|
+
}));
|
|
648
|
+
var interactionRelations = relations(interactions, ({ one, many }) => ({
|
|
649
|
+
session: one(sessions, {
|
|
650
|
+
fields: [interactions.sessionId],
|
|
651
|
+
references: [sessions.id]
|
|
652
|
+
}),
|
|
653
|
+
model: one(models, {
|
|
654
|
+
fields: [interactions.modelId],
|
|
655
|
+
references: [models.id]
|
|
656
|
+
}),
|
|
657
|
+
toolCalls: many(toolCalls)
|
|
658
|
+
}));
|
|
659
|
+
|
|
660
|
+
// src/db/client.ts
|
|
661
|
+
var defaultDbPath = path.join(process.cwd(), ".tokentrace", "tokentrace.db");
|
|
662
|
+
function databaseUrlPath(value) {
|
|
663
|
+
if (!value?.startsWith("file:")) return null;
|
|
664
|
+
try {
|
|
665
|
+
return fileURLToPath(value);
|
|
666
|
+
} catch {
|
|
667
|
+
return value.slice("file:".length);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
var dbPath = process.env.TOKENTRACE_DB ?? databaseUrlPath(process.env.DATABASE_URL) ?? defaultDbPath;
|
|
671
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
672
|
+
var sqlite = new Database(dbPath);
|
|
673
|
+
sqlite.pragma("journal_mode = WAL");
|
|
674
|
+
sqlite.pragma("synchronous = NORMAL");
|
|
675
|
+
sqlite.pragma("temp_store = MEMORY");
|
|
676
|
+
sqlite.pragma("cache_size = -65536");
|
|
677
|
+
sqlite.pragma("mmap_size = 268435456");
|
|
678
|
+
sqlite.pragma("busy_timeout = 10000");
|
|
679
|
+
sqlite.pragma("foreign_keys = ON");
|
|
680
|
+
registerSqliteFunctions(sqlite);
|
|
681
|
+
applyMigrations(sqlite);
|
|
682
|
+
var db = drizzle(sqlite, { schema: schema_exports });
|
|
683
|
+
|
|
684
|
+
// src/db/prepared.ts
|
|
685
|
+
var cache = /* @__PURE__ */ new Map();
|
|
686
|
+
function prepareCached(sql2) {
|
|
687
|
+
let stmt = cache.get(sql2);
|
|
688
|
+
if (!stmt) {
|
|
689
|
+
stmt = sqlite.prepare(sql2);
|
|
690
|
+
cache.set(sql2, stmt);
|
|
691
|
+
}
|
|
692
|
+
return stmt;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// src/lib/analytics-query-helpers.ts
|
|
696
|
+
function number(value) {
|
|
697
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
698
|
+
}
|
|
699
|
+
function dateStringFromTimestamp(timestamp) {
|
|
700
|
+
const date = new Date(timestamp);
|
|
701
|
+
return [
|
|
702
|
+
date.getFullYear(),
|
|
703
|
+
String(date.getMonth() + 1).padStart(2, "0"),
|
|
704
|
+
String(date.getDate()).padStart(2, "0")
|
|
705
|
+
].join("-");
|
|
706
|
+
}
|
|
707
|
+
function addCalendarDays(value, days) {
|
|
708
|
+
const [year = NaN, month = NaN, day = NaN] = value.split("-").map(Number);
|
|
709
|
+
const date = new Date(year, month - 1, day);
|
|
710
|
+
date.setDate(date.getDate() + days);
|
|
711
|
+
return dateStringFromTimestamp(date.getTime());
|
|
712
|
+
}
|
|
713
|
+
function emptyTrendPoint(date) {
|
|
714
|
+
return {
|
|
715
|
+
date,
|
|
716
|
+
totalTokens: 0,
|
|
717
|
+
inputTokens: 0,
|
|
718
|
+
outputTokens: 0,
|
|
719
|
+
cachedTokens: 0,
|
|
720
|
+
reasoningTokens: 0,
|
|
721
|
+
cost: 0
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
function trendBounds(points, filters) {
|
|
725
|
+
const firstDataDate = points[0]?.date;
|
|
726
|
+
const lastDataDate = points.at(-1)?.date;
|
|
727
|
+
const fromDate = filters.from != null ? dateStringFromTimestamp(filters.from) : firstDataDate;
|
|
728
|
+
const toExclusiveDate = filters.to != null ? addCalendarDays(dateStringFromTimestamp(filters.to - 1), 1) : lastDataDate ? addCalendarDays(lastDataDate, 1) : null;
|
|
729
|
+
if (!fromDate || !toExclusiveDate || fromDate >= toExclusiveDate) return null;
|
|
730
|
+
return { fromDate, toExclusiveDate };
|
|
731
|
+
}
|
|
732
|
+
function fillMissingTrendDays(points, filters) {
|
|
733
|
+
const bounds = trendBounds(points, filters);
|
|
734
|
+
if (!bounds) return [];
|
|
735
|
+
const byDate = new Map(points.map((point) => [point.date, point]));
|
|
736
|
+
const filled = [];
|
|
737
|
+
for (let date = bounds.fromDate; date < bounds.toExclusiveDate; date = addCalendarDays(date, 1)) {
|
|
738
|
+
filled.push(byDate.get(date) ?? emptyTrendPoint(date));
|
|
739
|
+
}
|
|
740
|
+
return filled;
|
|
741
|
+
}
|
|
742
|
+
function parseJson(value, fallback) {
|
|
743
|
+
if (Array.isArray(value) || value && typeof value === "object") return value;
|
|
744
|
+
if (typeof value !== "string") return fallback;
|
|
745
|
+
try {
|
|
746
|
+
return JSON.parse(value);
|
|
747
|
+
} catch {
|
|
748
|
+
return fallback;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
function rows(sql2, ...params) {
|
|
752
|
+
return prepareCached(sql2).all(...params);
|
|
753
|
+
}
|
|
754
|
+
function withQuery(path2, params) {
|
|
755
|
+
const query = new URLSearchParams();
|
|
756
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
757
|
+
if (value) query.set(key, value);
|
|
758
|
+
});
|
|
759
|
+
const serialized = query.toString();
|
|
760
|
+
return serialized ? `${path2}?${serialized}` : path2;
|
|
761
|
+
}
|
|
762
|
+
function stringMetadata(value, key) {
|
|
763
|
+
if (!value || typeof value !== "object") return null;
|
|
764
|
+
const candidate = value[key];
|
|
765
|
+
return typeof candidate === "string" && candidate.trim() ? candidate : null;
|
|
766
|
+
}
|
|
767
|
+
function numberMetadata(value, key) {
|
|
768
|
+
if (!value || typeof value !== "object") return null;
|
|
769
|
+
const candidate = value[key];
|
|
770
|
+
return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : null;
|
|
771
|
+
}
|
|
772
|
+
function timestampWhere(filters = {}, alias = "i", prefix = "WHERE") {
|
|
773
|
+
const clauses = [];
|
|
774
|
+
const params = [];
|
|
775
|
+
if (filters.from != null) {
|
|
776
|
+
clauses.push(`${alias}.timestamp >= ?`);
|
|
777
|
+
params.push(filters.from);
|
|
778
|
+
}
|
|
779
|
+
if (filters.to != null) {
|
|
780
|
+
clauses.push(`${alias}.timestamp < ?`);
|
|
781
|
+
params.push(filters.to);
|
|
782
|
+
}
|
|
783
|
+
return {
|
|
784
|
+
sql: clauses.length ? `${prefix} ${clauses.join(" AND ")}` : "",
|
|
785
|
+
params
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
function timestampJoinCondition(filters = {}, alias = "i") {
|
|
789
|
+
const clauses = [];
|
|
790
|
+
const params = [];
|
|
791
|
+
if (filters.from != null) {
|
|
792
|
+
clauses.push(`${alias}.timestamp >= ?`);
|
|
793
|
+
params.push(filters.from);
|
|
794
|
+
}
|
|
795
|
+
if (filters.to != null) {
|
|
796
|
+
clauses.push(`${alias}.timestamp < ?`);
|
|
797
|
+
params.push(filters.to);
|
|
798
|
+
}
|
|
799
|
+
return {
|
|
800
|
+
sql: clauses.length ? ` AND ${clauses.join(" AND ")}` : "",
|
|
801
|
+
params
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// src/lib/analytics/summary.ts
|
|
806
|
+
function getSummary(filters = {}) {
|
|
807
|
+
const interactionFilter = timestampWhere(filters, "interactions");
|
|
808
|
+
const aggregate = sqlite.prepare(
|
|
809
|
+
`SELECT
|
|
810
|
+
COALESCE(SUM(total_tokens), 0) AS totalTokens,
|
|
811
|
+
COALESCE(SUM(input_tokens + output_tokens + reasoning_tokens), 0) AS nonCachedTokens,
|
|
812
|
+
COALESCE(SUM(input_tokens), 0) AS inputTokens,
|
|
813
|
+
COALESCE(SUM(output_tokens), 0) AS outputTokens,
|
|
814
|
+
COALESCE(SUM(cache_read_tokens), 0) AS cacheReadTokens,
|
|
815
|
+
COALESCE(SUM(cache_write_tokens), 0) AS cacheWriteTokens,
|
|
816
|
+
COALESCE(SUM(cache_read_tokens + cache_write_tokens), 0) AS cachedTokens,
|
|
817
|
+
COALESCE(SUM(reasoning_tokens), 0) AS reasoningTokens,
|
|
818
|
+
COALESCE(SUM(cost), 0) AS totalCost,
|
|
819
|
+
COALESCE(SUM(CASE WHEN cost_estimated = 0 AND cost IS NOT NULL THEN cost ELSE 0 END), 0) AS exactCost,
|
|
820
|
+
COALESCE(SUM(CASE WHEN cost_estimated = 1 AND cost IS NOT NULL THEN cost ELSE 0 END), 0) AS estimatedCost,
|
|
821
|
+
COALESCE(SUM(CASE WHEN cost IS NULL THEN 1 ELSE 0 END), 0) AS unknownCostInteractions,
|
|
822
|
+
COUNT(*) AS interactions,
|
|
823
|
+
COUNT(DISTINCT session_id) AS sessions
|
|
824
|
+
FROM interactions INDEXED BY interactions_analytics_cover_idx
|
|
825
|
+
${interactionFilter.sql}`
|
|
826
|
+
).get(...interactionFilter.params);
|
|
827
|
+
const toolFilter = timestampWhere(filters, "i");
|
|
828
|
+
const tool = sqlite.prepare(
|
|
829
|
+
`SELECT t.name
|
|
830
|
+
FROM interactions i INDEXED BY interactions_analytics_cover_idx
|
|
831
|
+
JOIN sessions s ON s.id = i.session_id
|
|
832
|
+
JOIN tools t ON t.id = s.tool_id
|
|
833
|
+
${toolFilter.sql}
|
|
834
|
+
GROUP BY t.id
|
|
835
|
+
ORDER BY SUM(i.total_tokens) DESC
|
|
836
|
+
LIMIT 1`
|
|
837
|
+
).get(...toolFilter.params);
|
|
838
|
+
const modelFilter = timestampWhere(filters, "i");
|
|
839
|
+
const model = sqlite.prepare(
|
|
840
|
+
`SELECT m.name
|
|
841
|
+
FROM interactions i INDEXED BY interactions_analytics_cover_idx
|
|
842
|
+
LEFT JOIN models m ON m.id = i.model_id
|
|
843
|
+
${modelFilter.sql}
|
|
844
|
+
GROUP BY m.id
|
|
845
|
+
ORDER BY SUM(i.total_tokens) DESC
|
|
846
|
+
LIMIT 1`
|
|
847
|
+
).get(...modelFilter.params);
|
|
848
|
+
return {
|
|
849
|
+
totalTokens: number(aggregate.totalTokens),
|
|
850
|
+
nonCachedTokens: number(aggregate.nonCachedTokens),
|
|
851
|
+
inputTokens: number(aggregate.inputTokens),
|
|
852
|
+
outputTokens: number(aggregate.outputTokens),
|
|
853
|
+
cacheReadTokens: number(aggregate.cacheReadTokens),
|
|
854
|
+
cacheWriteTokens: number(aggregate.cacheWriteTokens),
|
|
855
|
+
cachedTokens: number(aggregate.cachedTokens),
|
|
856
|
+
reasoningTokens: number(aggregate.reasoningTokens),
|
|
857
|
+
totalCost: number(aggregate.totalCost),
|
|
858
|
+
exactCost: number(aggregate.exactCost),
|
|
859
|
+
estimatedCost: number(aggregate.estimatedCost),
|
|
860
|
+
unknownCostInteractions: number(aggregate.unknownCostInteractions),
|
|
861
|
+
sessions: number(aggregate.sessions),
|
|
862
|
+
interactions: number(aggregate.interactions),
|
|
863
|
+
mostUsedTool: tool?.name ?? "No data",
|
|
864
|
+
mostUsedModel: model?.name ?? "No data"
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
var sevenDaysMs = 7 * 24 * 60 * 60 * 1e3;
|
|
868
|
+
var maxUsefulPercentDelta = 999;
|
|
869
|
+
function comparisonSnapshot(summary) {
|
|
870
|
+
return {
|
|
871
|
+
totalTokens: summary.totalTokens,
|
|
872
|
+
totalCost: summary.totalCost,
|
|
873
|
+
sessions: summary.sessions,
|
|
874
|
+
interactions: summary.interactions,
|
|
875
|
+
unknownCostInteractions: summary.unknownCostInteractions
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
function percentDelta(current, previous) {
|
|
879
|
+
if (previous === 0) return current === 0 ? 0 : null;
|
|
880
|
+
const rounded = Math.round((current - previous) / previous * 100);
|
|
881
|
+
return Math.abs(rounded) > maxUsefulPercentDelta ? null : rounded;
|
|
882
|
+
}
|
|
883
|
+
function deltaLabel(percentValue, absolute, previous, noun) {
|
|
884
|
+
if (percentValue == null) {
|
|
885
|
+
if (previous === 0) return absolute > 0 ? `New ${noun} activity` : `No ${noun} activity yet`;
|
|
886
|
+
if (absolute === 0) return `${noun} unchanged`;
|
|
887
|
+
return `${absolute > 0 ? "Higher" : "Lower"} ${noun} activity`;
|
|
888
|
+
}
|
|
889
|
+
if (percentValue === 0) return `${noun} unchanged`;
|
|
890
|
+
return `${Math.abs(percentValue)}% ${percentValue > 0 ? "more" : "less"} ${noun}`;
|
|
891
|
+
}
|
|
892
|
+
function getComparisonWindow(filters = {}) {
|
|
893
|
+
if (filters.from != null && filters.to != null && filters.to > filters.from) {
|
|
894
|
+
const duration = filters.to - filters.from;
|
|
895
|
+
return {
|
|
896
|
+
mode: "selected-period",
|
|
897
|
+
label: "Previous matching period",
|
|
898
|
+
current: { from: filters.from, to: filters.to },
|
|
899
|
+
previous: { from: filters.from - duration, to: filters.from }
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
const latest = sqlite.prepare("SELECT MAX(timestamp) AS latest FROM interactions WHERE timestamp IS NOT NULL").get();
|
|
903
|
+
const latestTimestamp = latest?.latest;
|
|
904
|
+
if (latestTimestamp == null) {
|
|
905
|
+
return {
|
|
906
|
+
mode: "empty",
|
|
907
|
+
label: "Previous period",
|
|
908
|
+
current: {},
|
|
909
|
+
previous: {}
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
const currentTo = latestTimestamp + 1;
|
|
913
|
+
const currentFrom = currentTo - sevenDaysMs;
|
|
914
|
+
return {
|
|
915
|
+
mode: "latest-seven-days",
|
|
916
|
+
label: "Previous 7 days",
|
|
917
|
+
current: { from: currentFrom, to: currentTo },
|
|
918
|
+
previous: { from: currentFrom - sevenDaysMs, to: currentFrom }
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
function getUsageComparison(filters = {}) {
|
|
922
|
+
const window = getComparisonWindow(filters);
|
|
923
|
+
const current = comparisonSnapshot(getSummary(window.current));
|
|
924
|
+
const previous = comparisonSnapshot(getSummary(window.previous));
|
|
925
|
+
const delta = {
|
|
926
|
+
totalTokens: current.totalTokens - previous.totalTokens,
|
|
927
|
+
totalCost: current.totalCost - previous.totalCost,
|
|
928
|
+
sessions: current.sessions - previous.sessions,
|
|
929
|
+
interactions: current.interactions - previous.interactions,
|
|
930
|
+
unknownCostInteractions: current.unknownCostInteractions - previous.unknownCostInteractions,
|
|
931
|
+
totalTokensPercent: percentDelta(current.totalTokens, previous.totalTokens),
|
|
932
|
+
totalCostPercent: percentDelta(current.totalCost, previous.totalCost),
|
|
933
|
+
sessionsPercent: percentDelta(current.sessions, previous.sessions),
|
|
934
|
+
interactionsPercent: percentDelta(current.interactions, previous.interactions),
|
|
935
|
+
unknownCostInteractionsPercent: percentDelta(
|
|
936
|
+
current.unknownCostInteractions,
|
|
937
|
+
previous.unknownCostInteractions
|
|
938
|
+
)
|
|
939
|
+
};
|
|
940
|
+
const headline = window.mode === "empty" ? "No previous usage to compare yet" : deltaLabel(delta.totalTokensPercent, delta.totalTokens, previous.totalTokens, "tokens");
|
|
941
|
+
const detail = window.mode === "selected-period" ? "Compared with the immediately previous matching period." : window.mode === "latest-seven-days" ? "Compared with the seven days before the latest imported interaction." : "Run a scan after CLI sessions to build a local comparison baseline.";
|
|
942
|
+
return {
|
|
943
|
+
mode: window.mode,
|
|
944
|
+
label: window.label,
|
|
945
|
+
current,
|
|
946
|
+
previous,
|
|
947
|
+
delta,
|
|
948
|
+
headline,
|
|
949
|
+
detail
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// src/lib/analytics/trends.ts
|
|
954
|
+
function getTrends(filters = {}) {
|
|
955
|
+
const filter = timestampWhere(filters, "i", "AND");
|
|
956
|
+
const points = rows(
|
|
957
|
+
`SELECT
|
|
958
|
+
local_date_key(i.timestamp) AS date,
|
|
959
|
+
COALESCE(SUM(i.total_tokens), 0) AS totalTokens,
|
|
960
|
+
COALESCE(SUM(i.input_tokens), 0) AS inputTokens,
|
|
961
|
+
COALESCE(SUM(i.output_tokens), 0) AS outputTokens,
|
|
962
|
+
COALESCE(SUM(i.cache_read_tokens + i.cache_write_tokens), 0) AS cachedTokens,
|
|
963
|
+
COALESCE(SUM(i.reasoning_tokens), 0) AS reasoningTokens,
|
|
964
|
+
COALESCE(SUM(i.cost), 0) AS cost
|
|
965
|
+
FROM interactions i INDEXED BY interactions_analytics_cover_idx
|
|
966
|
+
WHERE i.timestamp IS NOT NULL
|
|
967
|
+
${filter.sql}
|
|
968
|
+
GROUP BY date
|
|
969
|
+
ORDER BY date ASC`,
|
|
970
|
+
...filter.params
|
|
971
|
+
).map((row) => ({
|
|
972
|
+
...row,
|
|
973
|
+
totalTokens: number(row.totalTokens),
|
|
974
|
+
inputTokens: number(row.inputTokens),
|
|
975
|
+
outputTokens: number(row.outputTokens),
|
|
976
|
+
cachedTokens: number(row.cachedTokens),
|
|
977
|
+
reasoningTokens: number(row.reasoningTokens),
|
|
978
|
+
cost: number(row.cost)
|
|
979
|
+
}));
|
|
980
|
+
return fillMissingTrendDays(points, filters);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// src/lib/data-confidence.ts
|
|
984
|
+
function ratio(value, total) {
|
|
985
|
+
if (total <= 0) return 0;
|
|
986
|
+
return Math.max(0, Math.min(1, value / total));
|
|
987
|
+
}
|
|
988
|
+
function grade(score, totalInteractions) {
|
|
989
|
+
if (totalInteractions <= 0) return "empty";
|
|
990
|
+
if (score >= 85) return "high";
|
|
991
|
+
if (score >= 60) return "medium";
|
|
992
|
+
return "low";
|
|
993
|
+
}
|
|
994
|
+
function buildDataConfidenceScore(input) {
|
|
995
|
+
const total = input.totalInteractions;
|
|
996
|
+
const exactRatio = ratio(input.exactTokenInteractions, total);
|
|
997
|
+
const tokenizerRatio = ratio(input.tokenizerEstimateInteractions, total);
|
|
998
|
+
const simpleRatio = ratio(input.simpleEstimateInteractions, total);
|
|
999
|
+
const unknownTokenRatio = ratio(input.unknownTokenInteractions, total);
|
|
1000
|
+
const pricedRatio = ratio(input.pricedCostInteractions, total);
|
|
1001
|
+
const unknownCostRatio = ratio(input.unknownCostInteractions, total);
|
|
1002
|
+
const parser = input.parserConfidence == null ? 0.7 : Math.max(0, Math.min(1, input.parserConfidence));
|
|
1003
|
+
const freshness = input.scanFreshness === "fresh" ? 1 : input.scanFreshness === "stale" ? 0.65 : input.scanFreshness === "no-successful-scan" ? 0.3 : 0;
|
|
1004
|
+
const tokenScore = exactRatio * 100 + tokenizerRatio * 82 + simpleRatio * 55 - unknownTokenRatio * 35;
|
|
1005
|
+
const costScore = pricedRatio * 100 - unknownCostRatio * 45;
|
|
1006
|
+
const score = Math.max(
|
|
1007
|
+
0,
|
|
1008
|
+
Math.min(100, Math.round(tokenScore * 0.36 + costScore * 0.34 + parser * 100 * 0.18 + freshness * 100 * 0.12))
|
|
1009
|
+
);
|
|
1010
|
+
const drivers = [
|
|
1011
|
+
`Token coverage: ${Math.round((exactRatio + tokenizerRatio) * 100)}% exact or tokenizer-estimated.`,
|
|
1012
|
+
`Cost coverage: ${Math.round(pricedRatio * 100)}% priced.`
|
|
1013
|
+
];
|
|
1014
|
+
if (input.unknownCostInteractions > 0) {
|
|
1015
|
+
drivers.push(`Repair unknown costs for ${input.unknownCostInteractions.toLocaleString()} interactions.`);
|
|
1016
|
+
}
|
|
1017
|
+
if (input.simpleEstimateInteractions > 0 || input.unknownTokenInteractions > 0) {
|
|
1018
|
+
drivers.push("Improve parser coverage to reduce simple or unknown token estimates.");
|
|
1019
|
+
}
|
|
1020
|
+
if (input.scanFreshness === "stale") {
|
|
1021
|
+
drivers.push("Run a fresh scan.");
|
|
1022
|
+
}
|
|
1023
|
+
if (input.parserConfidence != null && input.parserConfidence < 0.75) {
|
|
1024
|
+
drivers.push("Review parser confidence for this evidence path.");
|
|
1025
|
+
}
|
|
1026
|
+
return {
|
|
1027
|
+
score,
|
|
1028
|
+
grade: grade(score, total),
|
|
1029
|
+
drivers,
|
|
1030
|
+
repairHref: input.unknownCostInteractions > 0 ? "/repair" : null
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// src/lib/analytics/entities.ts
|
|
1035
|
+
function getToolComparison(filters = {}) {
|
|
1036
|
+
const filter = timestampWhere(filters, "i");
|
|
1037
|
+
const subFilter = timestampWhere(filters, "i2", "AND");
|
|
1038
|
+
return rows(
|
|
1039
|
+
`SELECT
|
|
1040
|
+
t.name AS tool,
|
|
1041
|
+
p.name AS provider,
|
|
1042
|
+
COALESCE(SUM(i.total_tokens), 0) AS totalTokens,
|
|
1043
|
+
COALESCE(SUM(i.input_tokens), 0) AS inputTokens,
|
|
1044
|
+
COALESCE(SUM(i.output_tokens), 0) AS outputTokens,
|
|
1045
|
+
COALESCE(SUM(i.cache_read_tokens + i.cache_write_tokens), 0) AS cachedTokens,
|
|
1046
|
+
COALESCE(SUM(i.cost), 0) AS cost,
|
|
1047
|
+
COUNT(DISTINCT s.id) AS sessions,
|
|
1048
|
+
COUNT(*) AS interactions,
|
|
1049
|
+
COALESCE((
|
|
1050
|
+
SELECT m2.name
|
|
1051
|
+
FROM interactions i2 INDEXED BY interactions_session_analytics_idx
|
|
1052
|
+
JOIN sessions s2 ON s2.id = i2.session_id
|
|
1053
|
+
LEFT JOIN models m2 ON m2.id = i2.model_id
|
|
1054
|
+
WHERE s2.tool_id = t.id
|
|
1055
|
+
${subFilter.sql}
|
|
1056
|
+
GROUP BY m2.id
|
|
1057
|
+
ORDER BY SUM(COALESCE(i2.cost, 0)) DESC
|
|
1058
|
+
LIMIT 1
|
|
1059
|
+
), 'Unknown') AS mostExpensiveModel
|
|
1060
|
+
FROM interactions i INDEXED BY interactions_session_analytics_idx
|
|
1061
|
+
JOIN sessions s ON s.id = i.session_id
|
|
1062
|
+
JOIN tools t ON t.id = s.tool_id
|
|
1063
|
+
JOIN providers p ON p.id = t.provider_id
|
|
1064
|
+
${filter.sql}
|
|
1065
|
+
GROUP BY t.id, p.id
|
|
1066
|
+
ORDER BY totalTokens DESC`,
|
|
1067
|
+
...subFilter.params,
|
|
1068
|
+
...filter.params
|
|
1069
|
+
).map((row) => ({
|
|
1070
|
+
tool: row.tool,
|
|
1071
|
+
provider: row.provider,
|
|
1072
|
+
totalTokens: number(row.totalTokens),
|
|
1073
|
+
cost: number(row.cost),
|
|
1074
|
+
sessions: number(row.sessions),
|
|
1075
|
+
interactions: number(row.interactions),
|
|
1076
|
+
averageTokensPerSession: row.sessions ? number(row.totalTokens) / number(row.sessions) : 0,
|
|
1077
|
+
averageTokensPerInteraction: row.interactions ? number(row.totalTokens) / number(row.interactions) : 0,
|
|
1078
|
+
outputInputRatio: row.inputTokens ? number(row.outputTokens) / number(row.inputTokens) : 0,
|
|
1079
|
+
cacheEfficiency: row.inputTokens + row.cachedTokens ? number(row.cachedTokens) / (number(row.inputTokens) + number(row.cachedTokens)) : 0,
|
|
1080
|
+
mostExpensiveModel: row.mostExpensiveModel
|
|
1081
|
+
}));
|
|
1082
|
+
}
|
|
1083
|
+
function getModelRows(filters = {}) {
|
|
1084
|
+
const filter = timestampWhere(filters, "i");
|
|
1085
|
+
const baseRows = rows(
|
|
1086
|
+
`SELECT
|
|
1087
|
+
COALESCE(m.name, 'unknown') AS model,
|
|
1088
|
+
p.name AS provider,
|
|
1089
|
+
p.id AS providerId,
|
|
1090
|
+
m.input_token_price AS inputPrice,
|
|
1091
|
+
m.output_token_price AS outputPrice,
|
|
1092
|
+
COALESCE(SUM(i.total_tokens), 0) AS totalTokens,
|
|
1093
|
+
COALESCE(SUM(i.input_tokens), 0) AS inputTokens,
|
|
1094
|
+
COALESCE(SUM(i.output_tokens), 0) AS outputTokens,
|
|
1095
|
+
COALESCE(SUM(i.cost), 0) AS cost,
|
|
1096
|
+
COUNT(*) AS interactions,
|
|
1097
|
+
COALESCE(AVG(i.output_tokens), 0) AS averageOutputTokens
|
|
1098
|
+
FROM interactions i INDEXED BY interactions_analytics_cover_idx
|
|
1099
|
+
LEFT JOIN models m ON m.id = i.model_id
|
|
1100
|
+
LEFT JOIN providers p ON p.id = m.provider_id
|
|
1101
|
+
${filter.sql}
|
|
1102
|
+
GROUP BY m.id
|
|
1103
|
+
ORDER BY totalTokens DESC`,
|
|
1104
|
+
...filter.params
|
|
1105
|
+
);
|
|
1106
|
+
const configuredPrices = rows(
|
|
1107
|
+
`SELECT provider_id AS providerId, name,
|
|
1108
|
+
COALESCE(input_token_price, 999999) + COALESCE(output_token_price, 999999) AS combinedPrice
|
|
1109
|
+
FROM models
|
|
1110
|
+
WHERE input_token_price IS NOT NULL AND output_token_price IS NOT NULL`
|
|
1111
|
+
);
|
|
1112
|
+
return baseRows.map((row) => {
|
|
1113
|
+
const currentPrice = number(row.inputPrice) + number(row.outputPrice);
|
|
1114
|
+
const cheaper = configuredPrices.filter((candidate) => candidate.providerId === row.providerId).filter((candidate) => candidate.name !== row.model).filter((candidate) => !currentPrice || candidate.combinedPrice < currentPrice).sort((a, b) => a.combinedPrice - b.combinedPrice)[0];
|
|
1115
|
+
const tokenEfficiency = row.inputTokens ? number(row.outputTokens) / number(row.inputTokens) : number(row.outputTokens);
|
|
1116
|
+
return {
|
|
1117
|
+
model: row.model,
|
|
1118
|
+
provider: row.provider ?? "Unknown",
|
|
1119
|
+
totalTokens: number(row.totalTokens),
|
|
1120
|
+
inputTokens: number(row.inputTokens),
|
|
1121
|
+
outputTokens: number(row.outputTokens),
|
|
1122
|
+
cost: number(row.cost),
|
|
1123
|
+
interactions: number(row.interactions),
|
|
1124
|
+
averageOutputTokens: number(row.averageOutputTokens),
|
|
1125
|
+
tokenEfficiency,
|
|
1126
|
+
suggestedAlternative: cheaper?.name ?? null,
|
|
1127
|
+
overuseFlag: number(row.cost) > 0 && cheaper && number(row.totalTokens) > 25e3 ? "Cheaper configured alternative exists" : null
|
|
1128
|
+
};
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
function getProjectRows(filters = {}) {
|
|
1132
|
+
const filter = timestampJoinCondition(filters, "i");
|
|
1133
|
+
return rows(
|
|
1134
|
+
`SELECT
|
|
1135
|
+
pr.id,
|
|
1136
|
+
pr.name AS project,
|
|
1137
|
+
pr.path,
|
|
1138
|
+
COALESCE(SUM(i.total_tokens), 0) AS totalTokens,
|
|
1139
|
+
COALESCE(SUM(i.input_tokens), 0) AS inputTokens,
|
|
1140
|
+
COALESCE(SUM(i.output_tokens), 0) AS outputTokens,
|
|
1141
|
+
COALESCE(SUM(i.cost), 0) AS cost,
|
|
1142
|
+
COUNT(DISTINCT s.id) AS sessions,
|
|
1143
|
+
COUNT(*) AS interactions,
|
|
1144
|
+
COALESCE(SUM(CASE WHEN i.token_confidence = 'exact' THEN 1 ELSE 0 END), 0) AS exactTokenInteractions,
|
|
1145
|
+
COALESCE(SUM(CASE WHEN i.token_confidence = 'tokenizer estimate' THEN 1 ELSE 0 END), 0) AS tokenizerEstimateInteractions,
|
|
1146
|
+
COALESCE(SUM(CASE WHEN i.token_confidence IN ('simple estimate', 'high-confidence estimate', 'low-confidence estimate') THEN 1 ELSE 0 END), 0) AS simpleEstimateInteractions,
|
|
1147
|
+
COALESCE(SUM(CASE WHEN i.token_confidence = 'unknown' THEN 1 ELSE 0 END), 0) AS unknownTokenInteractions,
|
|
1148
|
+
COALESCE(SUM(CASE WHEN i.cost IS NOT NULL THEN 1 ELSE 0 END), 0) AS pricedCostInteractions,
|
|
1149
|
+
COALESCE(SUM(CASE WHEN i.cost IS NULL THEN 1 ELSE 0 END), 0) AS unknownCostInteractions,
|
|
1150
|
+
MAX(i.timestamp) AS lastUsedAt
|
|
1151
|
+
FROM projects pr
|
|
1152
|
+
JOIN sessions s ON s.project_id = pr.id
|
|
1153
|
+
JOIN interactions i INDEXED BY interactions_session_analytics_idx ON i.session_id = s.id ${filter.sql}
|
|
1154
|
+
GROUP BY pr.id
|
|
1155
|
+
ORDER BY totalTokens DESC`,
|
|
1156
|
+
...filter.params
|
|
1157
|
+
).map((row) => {
|
|
1158
|
+
const confidence = buildDataConfidenceScore({
|
|
1159
|
+
totalInteractions: number(row.interactions),
|
|
1160
|
+
exactTokenInteractions: number(row.exactTokenInteractions),
|
|
1161
|
+
tokenizerEstimateInteractions: number(row.tokenizerEstimateInteractions),
|
|
1162
|
+
simpleEstimateInteractions: number(row.simpleEstimateInteractions),
|
|
1163
|
+
unknownTokenInteractions: number(row.unknownTokenInteractions),
|
|
1164
|
+
pricedCostInteractions: number(row.pricedCostInteractions),
|
|
1165
|
+
unknownCostInteractions: number(row.unknownCostInteractions),
|
|
1166
|
+
parserConfidence: null,
|
|
1167
|
+
scanFreshness: "fresh"
|
|
1168
|
+
});
|
|
1169
|
+
return {
|
|
1170
|
+
id: row.id,
|
|
1171
|
+
project: row.project,
|
|
1172
|
+
path: row.path,
|
|
1173
|
+
totalTokens: number(row.totalTokens),
|
|
1174
|
+
cost: number(row.cost),
|
|
1175
|
+
sessions: number(row.sessions),
|
|
1176
|
+
interactions: number(row.interactions),
|
|
1177
|
+
outputInputRatio: row.inputTokens ? number(row.outputTokens) / number(row.inputTokens) : 0,
|
|
1178
|
+
lastUsedAt: row.lastUsedAt,
|
|
1179
|
+
confidenceScore: confidence.score,
|
|
1180
|
+
confidenceGrade: confidence.grade
|
|
1181
|
+
};
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
function getSessions(filters = {}, detail = "full") {
|
|
1185
|
+
const filter = timestampJoinCondition(filters, "i");
|
|
1186
|
+
const pricingModelSql = detail === "full" ? `(
|
|
1187
|
+
SELECT m2.name
|
|
1188
|
+
FROM interactions i2 INDEXED BY interactions_session_analytics_idx
|
|
1189
|
+
LEFT JOIN models m2 ON m2.id = i2.model_id
|
|
1190
|
+
WHERE i2.session_id = s.id
|
|
1191
|
+
GROUP BY m2.id
|
|
1192
|
+
ORDER BY SUM(i2.total_tokens) DESC
|
|
1193
|
+
LIMIT 1
|
|
1194
|
+
)` : "NULL";
|
|
1195
|
+
const parserColumns = detail === "full" ? `sf.parser AS parser,
|
|
1196
|
+
sf.status AS parserStatus,
|
|
1197
|
+
sf.raw_metadata AS parserRawMetadata,` : `NULL AS parser,
|
|
1198
|
+
NULL AS parserStatus,
|
|
1199
|
+
NULL AS parserRawMetadata,`;
|
|
1200
|
+
const parserJoin = detail === "full" ? `LEFT JOIN scan_files sf ON sf.id = (
|
|
1201
|
+
SELECT sf2.id
|
|
1202
|
+
FROM scan_files sf2
|
|
1203
|
+
JOIN scan_runs sr2 ON sr2.id = sf2.scan_run_id
|
|
1204
|
+
WHERE sf2.path = s.source_file
|
|
1205
|
+
ORDER BY sr2.started_at DESC
|
|
1206
|
+
LIMIT 1
|
|
1207
|
+
)` : "";
|
|
1208
|
+
return rows(
|
|
1209
|
+
`SELECT
|
|
1210
|
+
s.id,
|
|
1211
|
+
s.started_at AS startedAt,
|
|
1212
|
+
s.ended_at AS endedAt,
|
|
1213
|
+
s.title,
|
|
1214
|
+
s.source_file AS sourceFile,
|
|
1215
|
+
t.name AS tool,
|
|
1216
|
+
provider.name AS provider,
|
|
1217
|
+
pr.name AS project,
|
|
1218
|
+
pr.path AS projectPath,
|
|
1219
|
+
COALESCE(group_concat(DISTINCT m.name), 'unknown') AS models,
|
|
1220
|
+
${pricingModelSql} AS pricingModel,
|
|
1221
|
+
COALESCE(SUM(i.total_tokens), 0) AS totalTokens,
|
|
1222
|
+
COALESCE(SUM(i.input_tokens), 0) AS inputTokens,
|
|
1223
|
+
COALESCE(SUM(i.output_tokens), 0) AS outputTokens,
|
|
1224
|
+
COALESCE(SUM(i.cache_read_tokens + i.cache_write_tokens), 0) AS cachedTokens,
|
|
1225
|
+
COALESCE(SUM(i.reasoning_tokens), 0) AS reasoningTokens,
|
|
1226
|
+
SUM(i.cost) AS cost,
|
|
1227
|
+
MAX(i.cost_estimated) AS costEstimated,
|
|
1228
|
+
MAX(i.estimated_tokens) AS estimatedTokens,
|
|
1229
|
+
CASE
|
|
1230
|
+
WHEN SUM(CASE WHEN i.token_confidence = 'unknown' THEN 1 ELSE 0 END) > 0 THEN 'unknown'
|
|
1231
|
+
WHEN SUM(CASE WHEN i.token_confidence = 'simple estimate' THEN 1 ELSE 0 END) > 0 THEN 'simple estimate'
|
|
1232
|
+
WHEN SUM(CASE WHEN i.token_confidence = 'low-confidence estimate' THEN 1 ELSE 0 END) > 0 THEN 'low-confidence estimate'
|
|
1233
|
+
WHEN SUM(CASE WHEN i.token_confidence = 'tokenizer estimate' THEN 1 ELSE 0 END) > 0 THEN 'tokenizer estimate'
|
|
1234
|
+
WHEN SUM(CASE WHEN i.token_confidence = 'high-confidence estimate' THEN 1 ELSE 0 END) > 0 THEN 'high-confidence estimate'
|
|
1235
|
+
ELSE 'exact'
|
|
1236
|
+
END AS tokenConfidence,
|
|
1237
|
+
COALESCE(SUM(CASE WHEN i.token_confidence = 'exact' THEN 1 ELSE 0 END), 0) AS exactTokenInteractions,
|
|
1238
|
+
COALESCE(SUM(CASE WHEN i.token_confidence = 'tokenizer estimate' THEN 1 ELSE 0 END), 0) AS tokenizerEstimateInteractions,
|
|
1239
|
+
COALESCE(SUM(CASE WHEN i.token_confidence IN ('simple estimate', 'high-confidence estimate', 'low-confidence estimate') THEN 1 ELSE 0 END), 0) AS simpleEstimateInteractions,
|
|
1240
|
+
COALESCE(SUM(CASE WHEN i.token_confidence = 'unknown' THEN 1 ELSE 0 END), 0) AS unknownTokenInteractions,
|
|
1241
|
+
COALESCE(SUM(CASE WHEN i.cost IS NOT NULL THEN 1 ELSE 0 END), 0) AS pricedCostInteractions,
|
|
1242
|
+
COALESCE(SUM(CASE WHEN i.cost IS NULL THEN 1 ELSE 0 END), 0) AS unknownCostInteractions,
|
|
1243
|
+
${parserColumns}
|
|
1244
|
+
COUNT(*) AS interactionCount,
|
|
1245
|
+
CASE WHEN s.started_at IS NOT NULL AND s.ended_at IS NOT NULL THEN s.ended_at - s.started_at ELSE NULL END AS durationMs
|
|
1246
|
+
FROM sessions s
|
|
1247
|
+
JOIN tools t ON t.id = s.tool_id
|
|
1248
|
+
JOIN providers provider ON provider.id = t.provider_id
|
|
1249
|
+
LEFT JOIN projects pr ON pr.id = s.project_id
|
|
1250
|
+
JOIN interactions i INDEXED BY interactions_session_analytics_idx ON i.session_id = s.id ${filter.sql}
|
|
1251
|
+
LEFT JOIN models m ON m.id = i.model_id
|
|
1252
|
+
${parserJoin}
|
|
1253
|
+
GROUP BY s.id
|
|
1254
|
+
ORDER BY COALESCE(s.started_at, 0) DESC
|
|
1255
|
+
LIMIT 1000`,
|
|
1256
|
+
...filter.params
|
|
1257
|
+
).map((row) => {
|
|
1258
|
+
const parserMetadata = parseJson(row.parserRawMetadata, {});
|
|
1259
|
+
const pricingModel = row.pricingModel && row.pricingModel !== "unknown" ? row.pricingModel : null;
|
|
1260
|
+
const parserConfidence = numberMetadata(parserMetadata, "confidence");
|
|
1261
|
+
const confidence = buildDataConfidenceScore({
|
|
1262
|
+
totalInteractions: number(row.interactionCount),
|
|
1263
|
+
exactTokenInteractions: number(row.exactTokenInteractions),
|
|
1264
|
+
tokenizerEstimateInteractions: number(row.tokenizerEstimateInteractions),
|
|
1265
|
+
simpleEstimateInteractions: number(row.simpleEstimateInteractions),
|
|
1266
|
+
unknownTokenInteractions: number(row.unknownTokenInteractions),
|
|
1267
|
+
pricedCostInteractions: number(row.pricedCostInteractions),
|
|
1268
|
+
unknownCostInteractions: number(row.unknownCostInteractions),
|
|
1269
|
+
parserConfidence,
|
|
1270
|
+
scanFreshness: "fresh"
|
|
1271
|
+
});
|
|
1272
|
+
return {
|
|
1273
|
+
...row,
|
|
1274
|
+
totalTokens: number(row.totalTokens),
|
|
1275
|
+
inputTokens: number(row.inputTokens),
|
|
1276
|
+
outputTokens: number(row.outputTokens),
|
|
1277
|
+
cachedTokens: number(row.cachedTokens),
|
|
1278
|
+
reasoningTokens: number(row.reasoningTokens),
|
|
1279
|
+
cost: row.cost == null ? null : number(row.cost),
|
|
1280
|
+
costEstimated: Boolean(row.costEstimated),
|
|
1281
|
+
estimatedTokens: Boolean(row.estimatedTokens),
|
|
1282
|
+
parserConfidence,
|
|
1283
|
+
parserReason: stringMetadata(parserMetadata, "reason"),
|
|
1284
|
+
sourceHref: withQuery("/sessions", { source: row.sourceFile }),
|
|
1285
|
+
parserHref: withQuery("/parser-debug", { source: row.sourceFile }),
|
|
1286
|
+
pricingHref: pricingModel ? withQuery("/pricing", { model: pricingModel }) : null,
|
|
1287
|
+
interactionCount: number(row.interactionCount),
|
|
1288
|
+
confidenceScore: confidence.score,
|
|
1289
|
+
confidenceGrade: confidence.grade
|
|
1290
|
+
};
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// src/lib/model-aliases.ts
|
|
1295
|
+
function unique(values) {
|
|
1296
|
+
return Array.from(new Set(values.filter(Boolean)));
|
|
1297
|
+
}
|
|
1298
|
+
function addClaudeCandidates(name, candidates) {
|
|
1299
|
+
const lower = name.toLowerCase();
|
|
1300
|
+
const datedFamilyFirst = lower.match(/^(claude)-(opus|sonnet|haiku)-(\d+)-(\d+)-\d{8}$/);
|
|
1301
|
+
if (datedFamilyFirst) {
|
|
1302
|
+
const [, prefix, family, major, minor] = datedFamilyFirst;
|
|
1303
|
+
candidates.push(`${prefix}-${family}-${major}-${minor}`);
|
|
1304
|
+
candidates.push(`${prefix}-${family}-${major}.${minor}`);
|
|
1305
|
+
}
|
|
1306
|
+
const datedVersionFirst = lower.match(/^(claude)-(\d+)-(\d+)-(opus|sonnet|haiku)-\d{8}$/);
|
|
1307
|
+
if (datedVersionFirst) {
|
|
1308
|
+
const [, prefix, major, minor, family] = datedVersionFirst;
|
|
1309
|
+
candidates.push(`${prefix}-${family}-${major}-${minor}`);
|
|
1310
|
+
candidates.push(`${prefix}-${family}-${major}.${minor}`);
|
|
1311
|
+
}
|
|
1312
|
+
const legacyDated = lower.match(/^(claude)-(\d+)-(opus|sonnet|haiku)-\d{8}$/);
|
|
1313
|
+
if (legacyDated) {
|
|
1314
|
+
const [, prefix, major, family] = legacyDated;
|
|
1315
|
+
candidates.push(`${prefix}-${family}-${major}`);
|
|
1316
|
+
}
|
|
1317
|
+
const strippedDate = lower.replace(/-\d{8}$/, "");
|
|
1318
|
+
if (strippedDate !== lower) candidates.push(strippedDate);
|
|
1319
|
+
const familyHyphenVersion = strippedDate.match(/^(claude)-(opus|sonnet|haiku)-(\d+)-(\d+)$/);
|
|
1320
|
+
if (familyHyphenVersion) {
|
|
1321
|
+
const [, prefix, family, major, minor] = familyHyphenVersion;
|
|
1322
|
+
candidates.push(`${prefix}-${family}-${major}.${minor}`);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
function addProviderPrefixCandidates(name, candidates) {
|
|
1326
|
+
const withoutProvider = name.replace(/^[a-z0-9_.-]+\//i, "");
|
|
1327
|
+
if (withoutProvider !== name) candidates.push(withoutProvider);
|
|
1328
|
+
}
|
|
1329
|
+
function addSnapshotDateCandidates(name, candidates) {
|
|
1330
|
+
const withoutProvider = name.replace(/^[a-z0-9_.-]+\//i, "");
|
|
1331
|
+
const lower = withoutProvider.toLowerCase();
|
|
1332
|
+
const stripped = lower.replace(/[-_.]\d{4}[-_.]?\d{2}[-_.]?\d{2}$/, "");
|
|
1333
|
+
if (stripped !== lower) candidates.push(stripped);
|
|
1334
|
+
}
|
|
1335
|
+
function modelNameCandidates(modelName) {
|
|
1336
|
+
const trimmed = modelName?.trim();
|
|
1337
|
+
if (!trimmed) return ["unknown"];
|
|
1338
|
+
const candidates = [trimmed];
|
|
1339
|
+
addProviderPrefixCandidates(trimmed, candidates);
|
|
1340
|
+
addClaudeCandidates(trimmed, candidates);
|
|
1341
|
+
addSnapshotDateCandidates(trimmed, candidates);
|
|
1342
|
+
return unique(candidates);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// src/lib/analytics/repair.ts
|
|
1346
|
+
function getUnknownCostQueue(filters = {}) {
|
|
1347
|
+
const filter = timestampJoinCondition(filters, "i");
|
|
1348
|
+
return rows(
|
|
1349
|
+
`SELECT
|
|
1350
|
+
CASE
|
|
1351
|
+
WHEN lower(COALESCE(m.name, 'unknown')) = 'unknown' THEN 'missing model'
|
|
1352
|
+
WHEN COALESCE(i.total_tokens, 0) <= 0 THEN 'missing token count'
|
|
1353
|
+
WHEN lower(COALESCE(m.name, 'unknown')) <> 'unknown'
|
|
1354
|
+
AND COALESCE(i.total_tokens, 0) > 0
|
|
1355
|
+
AND (m.input_token_price IS NULL OR m.output_token_price IS NULL)
|
|
1356
|
+
THEN 'missing pricing'
|
|
1357
|
+
ELSE 'other'
|
|
1358
|
+
END AS cause,
|
|
1359
|
+
COALESCE(m.name, 'unknown') AS model,
|
|
1360
|
+
COALESCE(p.name, 'Unknown') AS provider,
|
|
1361
|
+
t.name AS tool,
|
|
1362
|
+
s.source_file AS sourceFile,
|
|
1363
|
+
COUNT(*) AS interactions,
|
|
1364
|
+
COUNT(DISTINCT s.id) AS sessions,
|
|
1365
|
+
COALESCE(SUM(i.total_tokens), 0) AS totalTokens,
|
|
1366
|
+
'' AS repairHref,
|
|
1367
|
+
'' AS sourceHref,
|
|
1368
|
+
'' AS parserHref,
|
|
1369
|
+
'' AS pricingHref
|
|
1370
|
+
FROM interactions i INDEXED BY interactions_session_analytics_idx
|
|
1371
|
+
JOIN sessions s ON s.id = i.session_id ${filter.sql}
|
|
1372
|
+
JOIN tools t ON t.id = s.tool_id
|
|
1373
|
+
LEFT JOIN models m ON m.id = i.model_id
|
|
1374
|
+
LEFT JOIN providers p ON p.id = m.provider_id
|
|
1375
|
+
WHERE i.cost IS NULL
|
|
1376
|
+
GROUP BY cause, m.id, t.id, s.source_file
|
|
1377
|
+
ORDER BY
|
|
1378
|
+
CASE cause
|
|
1379
|
+
WHEN 'missing pricing' THEN 0
|
|
1380
|
+
WHEN 'missing model' THEN 1
|
|
1381
|
+
WHEN 'missing token count' THEN 2
|
|
1382
|
+
ELSE 3
|
|
1383
|
+
END,
|
|
1384
|
+
interactions DESC,
|
|
1385
|
+
totalTokens DESC
|
|
1386
|
+
LIMIT 20`,
|
|
1387
|
+
...filter.params
|
|
1388
|
+
).map((row) => {
|
|
1389
|
+
const parserHref = withQuery("/parser-debug", { source: row.sourceFile });
|
|
1390
|
+
const sourceHref = withQuery("/sessions", { source: row.sourceFile });
|
|
1391
|
+
const pricingHref = row.cause === "missing pricing" && row.model !== "unknown" ? withQuery("/pricing", { model: row.model }) : null;
|
|
1392
|
+
return {
|
|
1393
|
+
...row,
|
|
1394
|
+
repairHref: pricingHref ?? parserHref,
|
|
1395
|
+
sourceHref,
|
|
1396
|
+
parserHref,
|
|
1397
|
+
pricingHref,
|
|
1398
|
+
interactions: number(row.interactions),
|
|
1399
|
+
sessions: number(row.sessions),
|
|
1400
|
+
totalTokens: number(row.totalTokens)
|
|
1401
|
+
};
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
function getModelAliasSuggestions(filters = {}) {
|
|
1405
|
+
const filter = timestampJoinCondition(filters, "i");
|
|
1406
|
+
const pricedRows = rows(
|
|
1407
|
+
`SELECT provider_id AS providerId, name AS model
|
|
1408
|
+
FROM models
|
|
1409
|
+
WHERE input_token_price IS NOT NULL AND output_token_price IS NOT NULL`
|
|
1410
|
+
);
|
|
1411
|
+
const pricedByProvider = /* @__PURE__ */ new Map();
|
|
1412
|
+
pricedRows.forEach((row) => {
|
|
1413
|
+
const bucket = pricedByProvider.get(row.providerId) ?? /* @__PURE__ */ new Set();
|
|
1414
|
+
bucket.add(row.model.toLowerCase());
|
|
1415
|
+
pricedByProvider.set(row.providerId, bucket);
|
|
1416
|
+
});
|
|
1417
|
+
const pricedName = /* @__PURE__ */ new Map();
|
|
1418
|
+
pricedRows.forEach((row) => {
|
|
1419
|
+
pricedName.set(`${row.providerId}:${row.model.toLowerCase()}`, row.model);
|
|
1420
|
+
});
|
|
1421
|
+
const suggestions = rows(
|
|
1422
|
+
`SELECT
|
|
1423
|
+
COALESCE(m.name, 'unknown') AS model,
|
|
1424
|
+
COALESCE(p.id, tool_provider.id) AS providerId,
|
|
1425
|
+
COALESCE(p.name, tool_provider.name) AS provider,
|
|
1426
|
+
t.name AS tool,
|
|
1427
|
+
MIN(s.source_file) AS sourceFile,
|
|
1428
|
+
COUNT(*) AS interactions,
|
|
1429
|
+
COALESCE(SUM(i.total_tokens), 0) AS totalTokens
|
|
1430
|
+
FROM interactions i INDEXED BY interactions_session_analytics_idx
|
|
1431
|
+
JOIN sessions s ON s.id = i.session_id ${filter.sql}
|
|
1432
|
+
JOIN tools t ON t.id = s.tool_id
|
|
1433
|
+
JOIN providers tool_provider ON tool_provider.id = t.provider_id
|
|
1434
|
+
LEFT JOIN models m ON m.id = i.model_id
|
|
1435
|
+
LEFT JOIN providers p ON p.id = m.provider_id
|
|
1436
|
+
WHERE i.cost IS NULL
|
|
1437
|
+
GROUP BY model, providerId, t.id
|
|
1438
|
+
ORDER BY interactions DESC, totalTokens DESC
|
|
1439
|
+
LIMIT 40`,
|
|
1440
|
+
...filter.params
|
|
1441
|
+
).map((row) => {
|
|
1442
|
+
const baseRow = {
|
|
1443
|
+
model: row.model,
|
|
1444
|
+
provider: row.provider,
|
|
1445
|
+
tool: row.tool,
|
|
1446
|
+
sourceFile: row.sourceFile
|
|
1447
|
+
};
|
|
1448
|
+
const normalizedModel = row.model.trim().toLowerCase();
|
|
1449
|
+
const parserHref = withQuery("/parser-debug", { source: row.sourceFile });
|
|
1450
|
+
const repairHref = withQuery("/pricing", { model: row.model });
|
|
1451
|
+
const candidates = modelNameCandidates(row.model).slice(1);
|
|
1452
|
+
const pricedSet = pricedByProvider.get(row.providerId) ?? /* @__PURE__ */ new Set();
|
|
1453
|
+
const suggestedModel = candidates.map((candidate) => candidate.toLowerCase()).find((candidate) => pricedSet.has(candidate)) ?? null;
|
|
1454
|
+
const suggestedDisplay = suggestedModel ? pricedName.get(`${row.providerId}:${suggestedModel}`) ?? suggestedModel : null;
|
|
1455
|
+
if (normalizedModel === "unknown") {
|
|
1456
|
+
return {
|
|
1457
|
+
...baseRow,
|
|
1458
|
+
interactions: number(row.interactions),
|
|
1459
|
+
totalTokens: number(row.totalTokens),
|
|
1460
|
+
suggestedModel: null,
|
|
1461
|
+
confidence: "low",
|
|
1462
|
+
reason: "The parser did not extract a model name. Inspect the source metadata before adding pricing.",
|
|
1463
|
+
repairHref: parserHref,
|
|
1464
|
+
parserHref
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
if (normalizedModel === "<synthetic>" || normalizedModel === "synthetic") {
|
|
1468
|
+
return {
|
|
1469
|
+
...baseRow,
|
|
1470
|
+
interactions: number(row.interactions),
|
|
1471
|
+
totalTokens: number(row.totalTokens),
|
|
1472
|
+
suggestedModel: null,
|
|
1473
|
+
confidence: "medium",
|
|
1474
|
+
reason: "Synthetic rows should inherit the real transcript model only after parser review.",
|
|
1475
|
+
repairHref: parserHref,
|
|
1476
|
+
parserHref
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
if (suggestedDisplay) {
|
|
1480
|
+
return {
|
|
1481
|
+
...baseRow,
|
|
1482
|
+
interactions: number(row.interactions),
|
|
1483
|
+
totalTokens: number(row.totalTokens),
|
|
1484
|
+
suggestedModel: suggestedDisplay,
|
|
1485
|
+
confidence: "high",
|
|
1486
|
+
reason: "This observed model name matches a priced base model after removing a dated provider suffix.",
|
|
1487
|
+
repairHref,
|
|
1488
|
+
parserHref
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
return {
|
|
1492
|
+
...baseRow,
|
|
1493
|
+
interactions: number(row.interactions),
|
|
1494
|
+
totalTokens: number(row.totalTokens),
|
|
1495
|
+
suggestedModel: null,
|
|
1496
|
+
confidence: "low",
|
|
1497
|
+
reason: "No priced alias candidate exists yet. Add an explicit price row or verify the model name from parser evidence.",
|
|
1498
|
+
repairHref,
|
|
1499
|
+
parserHref
|
|
1500
|
+
};
|
|
1501
|
+
});
|
|
1502
|
+
const confidenceRank = { high: 0, medium: 1, low: 2 };
|
|
1503
|
+
return suggestions.sort((a, b) => confidenceRank[a.confidence] - confidenceRank[b.confidence] || b.totalTokens - a.totalTokens).slice(0, 8);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// src/lib/scan-health-rules.ts
|
|
1507
|
+
function incrementCount(target, key, amount = 1) {
|
|
1508
|
+
target[key] = (target[key] ?? 0) + amount;
|
|
1509
|
+
}
|
|
1510
|
+
function uniqueMessages(messages) {
|
|
1511
|
+
return Array.from(new Set(messages.map((message) => message.trim()).filter(Boolean))).slice(0, 6);
|
|
1512
|
+
}
|
|
1513
|
+
function groupScanNotes(scanRun, files) {
|
|
1514
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1515
|
+
function add(severity, message, example) {
|
|
1516
|
+
const trimmed = message.trim();
|
|
1517
|
+
if (!trimmed) return;
|
|
1518
|
+
const key = `${severity}:${trimmed}`;
|
|
1519
|
+
const existing = groups.get(key);
|
|
1520
|
+
if (existing) {
|
|
1521
|
+
existing.count += 1;
|
|
1522
|
+
if (example && !existing.examples.includes(example) && existing.examples.length < 3) {
|
|
1523
|
+
existing.examples.push(example);
|
|
1524
|
+
}
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
groups.set(key, {
|
|
1528
|
+
severity,
|
|
1529
|
+
message: trimmed,
|
|
1530
|
+
count: 1,
|
|
1531
|
+
examples: example ? [example] : []
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
for (const error of scanRun?.errors ?? []) add("error", error);
|
|
1535
|
+
for (const warning of scanRun?.warnings ?? []) add("warning", warning);
|
|
1536
|
+
for (const file of files) {
|
|
1537
|
+
const fileErrorSeverity = file.status === "skipped_unknown" ? "warning" : "error";
|
|
1538
|
+
for (const error of file.errors) add(fileErrorSeverity, error, file.path);
|
|
1539
|
+
for (const warning of file.warnings) add("warning", warning, file.path);
|
|
1540
|
+
}
|
|
1541
|
+
return Array.from(groups.values()).sort((a, b) => {
|
|
1542
|
+
if (a.severity !== b.severity) return a.severity === "error" ? -1 : 1;
|
|
1543
|
+
return b.count - a.count;
|
|
1544
|
+
}).slice(0, 6);
|
|
1545
|
+
}
|
|
1546
|
+
var scanHealthStaleAfterMs = 7 * 24 * 60 * 60 * 1e3;
|
|
1547
|
+
function isSuccessfulScanRun(scanRun) {
|
|
1548
|
+
return scanRun.completedAt != null && scanRun.recordsImported > 0 && scanRun.errors.length === 0;
|
|
1549
|
+
}
|
|
1550
|
+
function buildScanFreshness({
|
|
1551
|
+
latestRun,
|
|
1552
|
+
lastSuccessfulRun,
|
|
1553
|
+
now
|
|
1554
|
+
}) {
|
|
1555
|
+
if (!latestRun) {
|
|
1556
|
+
return {
|
|
1557
|
+
state: "no-scan",
|
|
1558
|
+
lastSuccessfulCompletedAt: null,
|
|
1559
|
+
staleAfterMs: scanHealthStaleAfterMs,
|
|
1560
|
+
description: "No local scan has run yet."
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
if (!lastSuccessfulRun?.completedAt) {
|
|
1564
|
+
return {
|
|
1565
|
+
state: "no-successful-scan",
|
|
1566
|
+
lastSuccessfulCompletedAt: null,
|
|
1567
|
+
staleAfterMs: scanHealthStaleAfterMs,
|
|
1568
|
+
description: "No completed scan has imported usage records yet."
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
if (now - lastSuccessfulRun.completedAt > scanHealthStaleAfterMs) {
|
|
1572
|
+
return {
|
|
1573
|
+
state: "stale",
|
|
1574
|
+
lastSuccessfulCompletedAt: lastSuccessfulRun.completedAt,
|
|
1575
|
+
staleAfterMs: scanHealthStaleAfterMs,
|
|
1576
|
+
description: "The last successful import is more than seven days old."
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
return {
|
|
1580
|
+
state: "fresh",
|
|
1581
|
+
lastSuccessfulCompletedAt: lastSuccessfulRun.completedAt,
|
|
1582
|
+
staleAfterMs: scanHealthStaleAfterMs,
|
|
1583
|
+
description: "Recent scan history includes a successful import."
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
function action(label, href, reason, tone = "default") {
|
|
1587
|
+
return { label, href, reason, tone };
|
|
1588
|
+
}
|
|
1589
|
+
function plural(count, singular, pluralValue = `${singular}s`) {
|
|
1590
|
+
return `${count} ${count === 1 ? singular : pluralValue}`;
|
|
1591
|
+
}
|
|
1592
|
+
function joinHealthParts(parts) {
|
|
1593
|
+
if (parts.length <= 1) return parts[0] ?? "";
|
|
1594
|
+
return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
|
|
1595
|
+
}
|
|
1596
|
+
function buildScanHealthStatus({
|
|
1597
|
+
latestRun,
|
|
1598
|
+
stats,
|
|
1599
|
+
confidence
|
|
1600
|
+
}) {
|
|
1601
|
+
if (!latestRun) {
|
|
1602
|
+
return {
|
|
1603
|
+
headline: "No scans yet",
|
|
1604
|
+
description: "Run a local scan to discover Claude Code, Codex CLI, and generic AI CLI artifacts.",
|
|
1605
|
+
tone: "secondary"
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
if (stats.failed > 0 || stats.importedWithErrors > 0 || stats.hardErrorCount > 0) {
|
|
1609
|
+
return {
|
|
1610
|
+
headline: "Scan needs attention",
|
|
1611
|
+
description: `${joinHealthParts([
|
|
1612
|
+
`${plural(stats.failed + stats.importedWithErrors, "file")} failed or imported with errors`,
|
|
1613
|
+
stats.unsupported > 0 ? `${plural(stats.unsupported, "file")} need parser review` : ""
|
|
1614
|
+
].filter(Boolean))} in the latest scan.`,
|
|
1615
|
+
tone: "destructive"
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
if (stats.unsupported > 0 || stats.warningCount > 0 || confidence.unknownCostInteractions > 0 || confidence.unknownTokenInteractions > 0) {
|
|
1619
|
+
return {
|
|
1620
|
+
headline: "Review recommended",
|
|
1621
|
+
description: "The latest scan completed, but some files, prices, or token confidence need review.",
|
|
1622
|
+
tone: "warning"
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
return {
|
|
1626
|
+
headline: "Scan health looks good",
|
|
1627
|
+
description: stats.duplicates > 0 ? "The latest scan completed and previously imported duplicate files were safely skipped." : "The latest scan completed without parser errors, unsupported files, or unknown model rates.",
|
|
1628
|
+
tone: "success"
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
function buildScanHealthActions({
|
|
1632
|
+
latestRun,
|
|
1633
|
+
stats,
|
|
1634
|
+
confidence,
|
|
1635
|
+
freshness,
|
|
1636
|
+
supplyChain
|
|
1637
|
+
}) {
|
|
1638
|
+
const actions = [];
|
|
1639
|
+
const supplyChainStatus = supplyChain ?? {
|
|
1640
|
+
status: "not-run",
|
|
1641
|
+
checkedAt: null,
|
|
1642
|
+
findings: 0,
|
|
1643
|
+
summary: "Supply-chain IOC check has not run in this dashboard process."
|
|
1644
|
+
};
|
|
1645
|
+
if (supplyChainStatus.status === "failed") {
|
|
1646
|
+
actions.push(action("Review supply-chain check", "/diagnostics#supply-chain", supplyChainStatus.summary, "destructive"));
|
|
1647
|
+
}
|
|
1648
|
+
if (!latestRun) {
|
|
1649
|
+
actions.push(action("Run first scan", "/settings#scan-controls", "Discover local AI CLI usage files."));
|
|
1650
|
+
}
|
|
1651
|
+
if (stats.failed > 0 || stats.importedWithErrors > 0 || stats.hardErrorCount > 0) {
|
|
1652
|
+
actions.push(action("Review parser failures", "/parser-debug", "Parser errors can hide usage data.", "destructive"));
|
|
1653
|
+
}
|
|
1654
|
+
if (stats.unsupported > 0) {
|
|
1655
|
+
actions.push(action("Inspect unsupported files", "/discovery", "Unsupported files show where adapters need improvement.", "warning"));
|
|
1656
|
+
}
|
|
1657
|
+
if (confidence.unknownCostInteractions > 0) {
|
|
1658
|
+
actions.push(action("Set model rate", "/pricing", "Unknown costs make cost totals incomplete.", "warning"));
|
|
1659
|
+
}
|
|
1660
|
+
if (confidence.unknownTokenInteractions > 0 || confidence.estimatedTokenInteractions > 0) {
|
|
1661
|
+
actions.push(action("Review token confidence", "/parser-debug", "Estimated or unknown token counts should stay visible.", "warning"));
|
|
1662
|
+
}
|
|
1663
|
+
if (freshness.state === "stale") {
|
|
1664
|
+
actions.push(action("Run a fresh scan", "/settings#scan-controls", freshness.description, "warning"));
|
|
1665
|
+
}
|
|
1666
|
+
if (latestRun && stats.imported === 0 && stats.duplicates === 0 && stats.unsupported === 0 && stats.failed === 0) {
|
|
1667
|
+
actions.push(action("Add custom folders", "/settings#custom-folders", "No files were imported by the latest scan.", "warning"));
|
|
1668
|
+
}
|
|
1669
|
+
if (latestRun) {
|
|
1670
|
+
actions.push(action("Export pack", "/api/export?type=scan-files", "Share parser metadata without raw prompts."));
|
|
1671
|
+
}
|
|
1672
|
+
return {
|
|
1673
|
+
supplyChainStatus,
|
|
1674
|
+
actions
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// src/lib/scan-health.ts
|
|
1679
|
+
function buildScanHealth({
|
|
1680
|
+
scanRuns: scanRuns2,
|
|
1681
|
+
scanFiles: scanFiles2,
|
|
1682
|
+
confidence,
|
|
1683
|
+
supplyChain,
|
|
1684
|
+
now = Date.now()
|
|
1685
|
+
}) {
|
|
1686
|
+
const latestRun = scanRuns2[0] ?? null;
|
|
1687
|
+
const lastSuccessfulRun = scanRuns2.find(isSuccessfulScanRun) ?? null;
|
|
1688
|
+
const freshness = buildScanFreshness({ latestRun, lastSuccessfulRun, now });
|
|
1689
|
+
const latestFiles = latestRun ? scanFiles2.filter((file) => file.scanRunId === latestRun.id) : [];
|
|
1690
|
+
const latestStatusCounts = {};
|
|
1691
|
+
const parserCounts = {};
|
|
1692
|
+
for (const file of latestFiles) {
|
|
1693
|
+
incrementCount(latestStatusCounts, file.status);
|
|
1694
|
+
incrementCount(parserCounts, file.parser ?? "No parser");
|
|
1695
|
+
}
|
|
1696
|
+
const latestWarnings = uniqueMessages([
|
|
1697
|
+
...latestRun?.warnings ?? [],
|
|
1698
|
+
...latestFiles.flatMap((file) => file.warnings.map((warning) => `${file.path}: ${warning}`))
|
|
1699
|
+
]);
|
|
1700
|
+
const latestErrors = uniqueMessages([
|
|
1701
|
+
...latestRun?.errors ?? [],
|
|
1702
|
+
...latestFiles.flatMap((file) => file.errors.map((error) => `${file.path}: ${error}`))
|
|
1703
|
+
]);
|
|
1704
|
+
const latestNoteGroups = groupScanNotes(latestRun, latestFiles);
|
|
1705
|
+
const stats = {
|
|
1706
|
+
unsupported: latestStatusCounts.skipped_unknown ?? 0,
|
|
1707
|
+
failed: latestStatusCounts.failed ?? 0,
|
|
1708
|
+
importedWithErrors: latestStatusCounts.imported_with_errors ?? 0,
|
|
1709
|
+
duplicates: latestStatusCounts.skipped_duplicate ?? 0,
|
|
1710
|
+
imported: latestStatusCounts.imported ?? 0,
|
|
1711
|
+
warningCount: latestWarnings.length,
|
|
1712
|
+
hardErrorCount: (latestRun?.errors.length ?? 0) + latestFiles.filter((file) => file.status === "failed" || file.status === "imported_with_errors").reduce((total, file) => total + file.errors.length, 0)
|
|
1713
|
+
};
|
|
1714
|
+
const tokenCoverage = {
|
|
1715
|
+
exact: confidence.exactTokenInteractions ?? 0,
|
|
1716
|
+
tokenizerEstimate: confidence.tokenizerEstimateInteractions ?? 0,
|
|
1717
|
+
simpleEstimate: confidence.simpleEstimateInteractions ?? 0,
|
|
1718
|
+
highConfidenceEstimate: confidence.highConfidenceTokenInteractions ?? 0,
|
|
1719
|
+
lowConfidenceEstimate: confidence.lowConfidenceTokenInteractions ?? 0,
|
|
1720
|
+
unknown: confidence.unknownTokenInteractions ?? 0,
|
|
1721
|
+
estimated: confidence.estimatedTokenInteractions ?? 0,
|
|
1722
|
+
total: confidence.interactions ?? 0
|
|
1723
|
+
};
|
|
1724
|
+
const costCoverage = {
|
|
1725
|
+
priced: confidence.exactCostInteractions + confidence.estimatedCostInteractions,
|
|
1726
|
+
exact: confidence.exactCostInteractions,
|
|
1727
|
+
estimated: confidence.estimatedCostInteractions,
|
|
1728
|
+
unknown: confidence.unknownCostInteractions,
|
|
1729
|
+
unknownCauses: confidence.unknownCostCauses,
|
|
1730
|
+
total: confidence.interactions
|
|
1731
|
+
};
|
|
1732
|
+
const { headline, description, tone } = buildScanHealthStatus({ latestRun, stats, confidence });
|
|
1733
|
+
const { supplyChainStatus, actions } = buildScanHealthActions({
|
|
1734
|
+
latestRun,
|
|
1735
|
+
stats,
|
|
1736
|
+
confidence,
|
|
1737
|
+
freshness,
|
|
1738
|
+
supplyChain
|
|
1739
|
+
});
|
|
1740
|
+
return {
|
|
1741
|
+
latestRun,
|
|
1742
|
+
lastSuccessfulRun,
|
|
1743
|
+
latestFiles,
|
|
1744
|
+
headline,
|
|
1745
|
+
description,
|
|
1746
|
+
tone,
|
|
1747
|
+
latestStatusCounts,
|
|
1748
|
+
parserCounts,
|
|
1749
|
+
latestWarnings,
|
|
1750
|
+
latestErrors,
|
|
1751
|
+
latestNoteGroups,
|
|
1752
|
+
freshness,
|
|
1753
|
+
tokenCoverage,
|
|
1754
|
+
costCoverage,
|
|
1755
|
+
supplyChain: supplyChainStatus,
|
|
1756
|
+
actions
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// src/lib/analytics-timing.ts
|
|
1761
|
+
var DEFAULT_ANALYTICS_QUERY_WARN_MS = 500;
|
|
1762
|
+
var MAX_SAMPLES = 50;
|
|
1763
|
+
var samples = [];
|
|
1764
|
+
function configuredThresholdMs() {
|
|
1765
|
+
const value = Number(process.env.TOKENTRACE_ANALYTICS_QUERY_WARN_MS);
|
|
1766
|
+
if (Number.isFinite(value) && value > 0) return value;
|
|
1767
|
+
return DEFAULT_ANALYTICS_QUERY_WARN_MS;
|
|
1768
|
+
}
|
|
1769
|
+
function timingEnabled() {
|
|
1770
|
+
return process.env.TOKENTRACE_ANALYTICS_TIMING === "1" || process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
|
|
1771
|
+
}
|
|
1772
|
+
function nowMs() {
|
|
1773
|
+
return globalThis.performance?.now() ?? Date.now();
|
|
1774
|
+
}
|
|
1775
|
+
function recordAnalyticsQueryTiming(label, durationMs, thresholdMs = configuredThresholdMs()) {
|
|
1776
|
+
if (!timingEnabled()) return;
|
|
1777
|
+
if (durationMs < thresholdMs) return;
|
|
1778
|
+
samples.push({
|
|
1779
|
+
label,
|
|
1780
|
+
durationMs: Math.round(durationMs),
|
|
1781
|
+
thresholdMs,
|
|
1782
|
+
recordedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1783
|
+
});
|
|
1784
|
+
if (samples.length > MAX_SAMPLES) {
|
|
1785
|
+
samples.splice(0, samples.length - MAX_SAMPLES);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
function timeAnalyticsQuery(label, callback) {
|
|
1789
|
+
if (!timingEnabled()) return callback();
|
|
1790
|
+
const startedAt = nowMs();
|
|
1791
|
+
try {
|
|
1792
|
+
return callback();
|
|
1793
|
+
} finally {
|
|
1794
|
+
recordAnalyticsQueryTiming(label, nowMs() - startedAt);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// src/lib/analytics/scan-trust.ts
|
|
1799
|
+
function getScanRunRows(limit) {
|
|
1800
|
+
return rows(
|
|
1801
|
+
`SELECT id, started_at AS startedAt, completed_at AS completedAt,
|
|
1802
|
+
files_scanned AS filesScanned, records_imported AS recordsImported, warnings, errors
|
|
1803
|
+
FROM scan_runs
|
|
1804
|
+
ORDER BY started_at DESC, completed_at DESC, id DESC
|
|
1805
|
+
LIMIT ?`,
|
|
1806
|
+
limit
|
|
1807
|
+
).map((row) => ({
|
|
1808
|
+
...row,
|
|
1809
|
+
warnings: parseJson(row.warnings, []),
|
|
1810
|
+
errors: parseJson(row.errors, [])
|
|
1811
|
+
}));
|
|
1812
|
+
}
|
|
1813
|
+
function getScanFileRows(limit) {
|
|
1814
|
+
const limitSql = limit == null ? "" : "LIMIT ?";
|
|
1815
|
+
const params = limit == null ? [] : [limit];
|
|
1816
|
+
return rows(
|
|
1817
|
+
`SELECT sf.id, sf.scan_run_id AS scanRunId, sf.path, sf.modified_time AS modifiedTime,
|
|
1818
|
+
sf.size_bytes AS sizeBytes, sf.file_hash AS fileHash, sf.parser, sf.status,
|
|
1819
|
+
sf.records_imported AS recordsImported, sf.warnings, sf.errors, sf.raw_metadata AS rawMetadata,
|
|
1820
|
+
sr.started_at AS scanStartedAt
|
|
1821
|
+
FROM scan_files sf
|
|
1822
|
+
JOIN scan_runs sr ON sr.id = sf.scan_run_id
|
|
1823
|
+
ORDER BY sr.started_at DESC, sr.completed_at DESC, sr.id DESC, sf.path ASC
|
|
1824
|
+
${limitSql}`,
|
|
1825
|
+
...params
|
|
1826
|
+
).map((row) => ({
|
|
1827
|
+
...row,
|
|
1828
|
+
warnings: parseJson(row.warnings, []),
|
|
1829
|
+
errors: parseJson(row.errors, []),
|
|
1830
|
+
rawMetadata: parseJson(row.rawMetadata, {})
|
|
1831
|
+
}));
|
|
1832
|
+
}
|
|
1833
|
+
function getScanFileRowsForRunIds(scanRunIds) {
|
|
1834
|
+
if (!scanRunIds.length) return [];
|
|
1835
|
+
const placeholders = scanRunIds.map(() => "?").join(", ");
|
|
1836
|
+
return rows(
|
|
1837
|
+
`SELECT sf.id, sf.scan_run_id AS scanRunId, sf.path, sf.modified_time AS modifiedTime,
|
|
1838
|
+
sf.size_bytes AS sizeBytes, sf.file_hash AS fileHash, sf.parser, sf.status,
|
|
1839
|
+
sf.records_imported AS recordsImported, sf.warnings, sf.errors, sf.raw_metadata AS rawMetadata,
|
|
1840
|
+
sr.started_at AS scanStartedAt
|
|
1841
|
+
FROM scan_files sf
|
|
1842
|
+
JOIN scan_runs sr ON sr.id = sf.scan_run_id
|
|
1843
|
+
WHERE sf.scan_run_id IN (${placeholders})
|
|
1844
|
+
ORDER BY sr.started_at DESC, sr.completed_at DESC, sr.id DESC, sf.path ASC`,
|
|
1845
|
+
...scanRunIds
|
|
1846
|
+
).map((row) => ({
|
|
1847
|
+
...row,
|
|
1848
|
+
warnings: parseJson(row.warnings, []),
|
|
1849
|
+
errors: parseJson(row.errors, []),
|
|
1850
|
+
rawMetadata: parseJson(row.rawMetadata, {})
|
|
1851
|
+
}));
|
|
1852
|
+
}
|
|
1853
|
+
function getScanConfidenceSummary(filters = {}) {
|
|
1854
|
+
const filter = timestampWhere(filters, "i");
|
|
1855
|
+
const unknownFilter = timestampJoinCondition(filters, "i");
|
|
1856
|
+
const row = sqlite.prepare(
|
|
1857
|
+
`SELECT
|
|
1858
|
+
COUNT(*) AS interactions,
|
|
1859
|
+
COALESCE(SUM(CASE WHEN token_confidence = 'exact' THEN 1 ELSE 0 END), 0) AS exactTokenInteractions,
|
|
1860
|
+
COALESCE(SUM(CASE WHEN token_confidence = 'tokenizer estimate' THEN 1 ELSE 0 END), 0) AS tokenizerEstimateInteractions,
|
|
1861
|
+
COALESCE(SUM(CASE WHEN token_confidence = 'simple estimate' THEN 1 ELSE 0 END), 0) AS simpleEstimateInteractions,
|
|
1862
|
+
COALESCE(SUM(CASE WHEN token_confidence = 'high-confidence estimate' THEN 1 ELSE 0 END), 0) AS highConfidenceTokenInteractions,
|
|
1863
|
+
COALESCE(SUM(CASE WHEN token_confidence = 'low-confidence estimate' THEN 1 ELSE 0 END), 0) AS lowConfidenceTokenInteractions,
|
|
1864
|
+
COALESCE(SUM(CASE WHEN token_confidence = 'unknown' THEN 1 ELSE 0 END), 0) AS unknownTokenInteractions,
|
|
1865
|
+
COALESCE(SUM(CASE WHEN estimated_tokens = 1 THEN 1 ELSE 0 END), 0) AS estimatedTokenInteractions,
|
|
1866
|
+
COALESCE(SUM(CASE WHEN cost IS NOT NULL AND cost_estimated = 0 THEN 1 ELSE 0 END), 0) AS exactCostInteractions,
|
|
1867
|
+
COALESCE(SUM(CASE WHEN cost IS NOT NULL AND cost_estimated = 1 THEN 1 ELSE 0 END), 0) AS estimatedCostInteractions,
|
|
1868
|
+
COALESCE(SUM(CASE WHEN cost IS NULL THEN 1 ELSE 0 END), 0) AS unknownCostInteractions
|
|
1869
|
+
FROM interactions i INDEXED BY interactions_analytics_cover_idx
|
|
1870
|
+
${filter.sql}`
|
|
1871
|
+
).get(...filter.params);
|
|
1872
|
+
const unknownCostCauses = sqlite.prepare(
|
|
1873
|
+
`WITH unknown_costs AS (
|
|
1874
|
+
SELECT
|
|
1875
|
+
CASE
|
|
1876
|
+
WHEN lower(COALESCE(m.name, 'unknown')) = 'unknown' THEN 'missingModelName'
|
|
1877
|
+
WHEN COALESCE(i.total_tokens, 0) <= 0 THEN 'missingTokenCount'
|
|
1878
|
+
WHEN lower(COALESCE(m.name, 'unknown')) <> 'unknown'
|
|
1879
|
+
AND COALESCE(i.total_tokens, 0) > 0
|
|
1880
|
+
AND (m.input_token_price IS NULL OR m.output_token_price IS NULL)
|
|
1881
|
+
THEN 'missingPricing'
|
|
1882
|
+
ELSE 'other'
|
|
1883
|
+
END AS cause
|
|
1884
|
+
FROM interactions i INDEXED BY interactions_analytics_cover_idx
|
|
1885
|
+
LEFT JOIN models m ON m.id = i.model_id
|
|
1886
|
+
WHERE i.cost IS NULL
|
|
1887
|
+
${unknownFilter.sql}
|
|
1888
|
+
)
|
|
1889
|
+
SELECT
|
|
1890
|
+
COALESCE(SUM(CASE WHEN cause = 'missingModelName' THEN 1 ELSE 0 END), 0) AS missingModelName,
|
|
1891
|
+
COALESCE(SUM(CASE WHEN cause = 'missingTokenCount' THEN 1 ELSE 0 END), 0) AS missingTokenCount,
|
|
1892
|
+
COALESCE(SUM(CASE WHEN cause = 'missingPricing' THEN 1 ELSE 0 END), 0) AS missingPricing,
|
|
1893
|
+
COALESCE(SUM(CASE WHEN cause = 'other' THEN 1 ELSE 0 END), 0) AS other
|
|
1894
|
+
FROM unknown_costs`
|
|
1895
|
+
).get(...unknownFilter.params);
|
|
1896
|
+
return {
|
|
1897
|
+
interactions: number(row.interactions),
|
|
1898
|
+
exactTokenInteractions: number(row.exactTokenInteractions),
|
|
1899
|
+
tokenizerEstimateInteractions: number(row.tokenizerEstimateInteractions),
|
|
1900
|
+
simpleEstimateInteractions: number(row.simpleEstimateInteractions),
|
|
1901
|
+
highConfidenceTokenInteractions: number(row.highConfidenceTokenInteractions),
|
|
1902
|
+
lowConfidenceTokenInteractions: number(row.lowConfidenceTokenInteractions),
|
|
1903
|
+
unknownTokenInteractions: number(row.unknownTokenInteractions),
|
|
1904
|
+
estimatedTokenInteractions: number(row.estimatedTokenInteractions),
|
|
1905
|
+
exactCostInteractions: number(row.exactCostInteractions),
|
|
1906
|
+
estimatedCostInteractions: number(row.estimatedCostInteractions),
|
|
1907
|
+
unknownCostInteractions: number(row.unknownCostInteractions),
|
|
1908
|
+
unknownCostCauses: {
|
|
1909
|
+
missingModelName: number(unknownCostCauses.missingModelName),
|
|
1910
|
+
missingPricing: number(unknownCostCauses.missingPricing),
|
|
1911
|
+
missingTokenCount: number(unknownCostCauses.missingTokenCount),
|
|
1912
|
+
other: number(unknownCostCauses.other)
|
|
1913
|
+
}
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
function getPricedModelCount() {
|
|
1917
|
+
const row = sqlite.prepare(
|
|
1918
|
+
`SELECT COUNT(*) AS count
|
|
1919
|
+
FROM models
|
|
1920
|
+
WHERE input_token_price IS NOT NULL
|
|
1921
|
+
AND output_token_price IS NOT NULL`
|
|
1922
|
+
).get();
|
|
1923
|
+
return number(row.count);
|
|
1924
|
+
}
|
|
1925
|
+
function getScanFileRowsForScope(scanRuns2, scope) {
|
|
1926
|
+
if (scope === "all") return getScanFileRows(null);
|
|
1927
|
+
if (scope === "none") return [];
|
|
1928
|
+
const runIds = scanRuns2.slice(0, scope === "latest" ? 1 : 2).map((scanRun) => scanRun.id);
|
|
1929
|
+
return getScanFileRowsForRunIds(runIds);
|
|
1930
|
+
}
|
|
1931
|
+
function getScanTrustData(filters = {}, options = {}) {
|
|
1932
|
+
return timeAnalyticsQuery("analytics.scanTrust", () => {
|
|
1933
|
+
const scanRuns2 = getScanRunRows(50);
|
|
1934
|
+
const scanFiles2 = getScanFileRowsForScope(scanRuns2, options.scanFileScope ?? "all");
|
|
1935
|
+
const confidence = getScanConfidenceSummary(filters);
|
|
1936
|
+
return {
|
|
1937
|
+
scanRuns: scanRuns2,
|
|
1938
|
+
scanFiles: scanFiles2,
|
|
1939
|
+
confidence,
|
|
1940
|
+
pricedModelCount: getPricedModelCount(),
|
|
1941
|
+
health: buildScanHealth({
|
|
1942
|
+
scanRuns: scanRuns2,
|
|
1943
|
+
scanFiles: scanFiles2,
|
|
1944
|
+
confidence
|
|
1945
|
+
})
|
|
1946
|
+
};
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// src/lib/analytics/insights.ts
|
|
1951
|
+
function getLatestScanRecommendationStats() {
|
|
1952
|
+
const latest = sqlite.prepare(
|
|
1953
|
+
`SELECT id, records_imported AS recordsImported
|
|
1954
|
+
FROM scan_runs
|
|
1955
|
+
ORDER BY started_at DESC, completed_at DESC, id DESC
|
|
1956
|
+
LIMIT 1`
|
|
1957
|
+
).get();
|
|
1958
|
+
if (!latest) {
|
|
1959
|
+
return {
|
|
1960
|
+
latestRecordsImported: 0,
|
|
1961
|
+
duplicateFiles: 0,
|
|
1962
|
+
parserReviewFiles: 0,
|
|
1963
|
+
ignoredFiles: 0
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
const counts = rows(
|
|
1967
|
+
`SELECT status, COUNT(*) AS count
|
|
1968
|
+
FROM scan_files
|
|
1969
|
+
WHERE scan_run_id = ?
|
|
1970
|
+
GROUP BY status`,
|
|
1971
|
+
latest.id
|
|
1972
|
+
).reduce((summary, row) => {
|
|
1973
|
+
summary[row.status] = number(row.count);
|
|
1974
|
+
return summary;
|
|
1975
|
+
}, {});
|
|
1976
|
+
return {
|
|
1977
|
+
latestRecordsImported: number(latest.recordsImported),
|
|
1978
|
+
duplicateFiles: counts.skipped_duplicate ?? 0,
|
|
1979
|
+
parserReviewFiles: (counts.skipped_unknown ?? 0) + (counts.failed ?? 0) + (counts.imported_with_errors ?? 0),
|
|
1980
|
+
ignoredFiles: counts.ignored_non_usage ?? 0
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
function buildInsights(data) {
|
|
1984
|
+
const insights = [];
|
|
1985
|
+
const totalCost = data.summary.totalCost;
|
|
1986
|
+
const topSessions = [...data.sessions].sort((a, b) => b.totalTokens - a.totalTokens);
|
|
1987
|
+
const topTenTokens = topSessions.slice(0, Math.max(1, Math.ceil(topSessions.length * 0.1))).reduce(
|
|
1988
|
+
(sum, session) => sum + session.totalTokens,
|
|
1989
|
+
0
|
|
1990
|
+
);
|
|
1991
|
+
if (data.summary.totalTokens > 0 && topTenTokens / data.summary.totalTokens > 0.5) {
|
|
1992
|
+
insights.push({
|
|
1993
|
+
id: "concentrated-usage",
|
|
1994
|
+
severity: "high",
|
|
1995
|
+
problem: "A small number of sessions account for most token usage.",
|
|
1996
|
+
evidence: `Top sessions represent ${Math.round(topTenTokens / data.summary.totalTokens * 100)}% of all tokens.`,
|
|
1997
|
+
savingOpportunity: totalCost ? `Reviewing these sessions targets about $${(totalCost * 0.5).toFixed(2)} of spend.` : "High token concentration even when cost is unknown.",
|
|
1998
|
+
recommendation: "Split large tasks into smaller prompts and add checkpoints before long coding runs."
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
const highOutputProject = data.projects.find((project) => project.outputInputRatio > 2 && project.totalTokens > 5e3);
|
|
2002
|
+
if (highOutputProject) {
|
|
2003
|
+
insights.push({
|
|
2004
|
+
id: "high-output-project",
|
|
2005
|
+
severity: "medium",
|
|
2006
|
+
problem: "One project uses unusually high output tokens.",
|
|
2007
|
+
evidence: `${highOutputProject.project} has an output/input ratio of ${highOutputProject.outputInputRatio.toFixed(1)}x.`,
|
|
2008
|
+
savingOpportunity: highOutputProject.cost ? `Potential review pool: $${highOutputProject.cost.toFixed(2)}.` : "Savings depend on configured pricing.",
|
|
2009
|
+
recommendation: "Ask for concise diffs, summaries, or file-scoped edits when working in this project."
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
const cacheEfficiency = data.summary.inputTokens + data.summary.cachedTokens ? data.summary.cachedTokens / (data.summary.inputTokens + data.summary.cachedTokens) : 0;
|
|
2013
|
+
if (data.summary.inputTokens > 1e4 && cacheEfficiency < 0.05) {
|
|
2014
|
+
insights.push({
|
|
2015
|
+
id: "low-cache",
|
|
2016
|
+
severity: "medium",
|
|
2017
|
+
problem: "Cache usage is low.",
|
|
2018
|
+
evidence: `Cached tokens are ${Math.round(cacheEfficiency * 100)}% of reusable input volume.`,
|
|
2019
|
+
savingOpportunity: "Better context reuse can reduce repeated input-token spend on supported models.",
|
|
2020
|
+
recommendation: "Keep stable instructions and repo context consistent across related runs where the CLI supports caching."
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
const costlyAlternative = data.models.find((model) => model.overuseFlag && model.suggestedAlternative);
|
|
2024
|
+
if (costlyAlternative) {
|
|
2025
|
+
insights.push({
|
|
2026
|
+
id: "expensive-model-overuse",
|
|
2027
|
+
severity: "medium",
|
|
2028
|
+
problem: "Configured cheaper models may fit some low-complexity work.",
|
|
2029
|
+
evidence: `${costlyAlternative.model} has ${costlyAlternative.totalTokens.toLocaleString()} tokens and ${costlyAlternative.suggestedAlternative} is cheaper in your pricing table.`,
|
|
2030
|
+
savingOpportunity: costlyAlternative.cost ? `Candidate spend: $${costlyAlternative.cost.toFixed(2)}.` : "Savings require complete pricing.",
|
|
2031
|
+
recommendation: "Use cheaper models for refactoring, search-heavy, or mechanical edits, and reserve expensive models for ambiguous architecture work."
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
if (data.trends.length >= 14) {
|
|
2035
|
+
const last = data.trends.slice(-7).reduce((sum, day) => sum + day.totalTokens, 0) / 7;
|
|
2036
|
+
const previous = data.trends.slice(-14, -7).reduce((sum, day) => sum + day.totalTokens, 0) / 7;
|
|
2037
|
+
if (previous > 0 && last / previous > 1.25) {
|
|
2038
|
+
insights.push({
|
|
2039
|
+
id: "session-length-growing",
|
|
2040
|
+
severity: "low",
|
|
2041
|
+
problem: "Average usage is increasing.",
|
|
2042
|
+
evidence: `Last 7-day average is ${Math.round((last / previous - 1) * 100)}% above the prior week.`,
|
|
2043
|
+
savingOpportunity: "Reducing drift can slow recurring spend growth.",
|
|
2044
|
+
recommendation: "Use planning prompts before long coding runs and prune stale context between unrelated tasks."
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
if (!insights.length) {
|
|
2049
|
+
insights.push({
|
|
2050
|
+
id: "baseline",
|
|
2051
|
+
severity: "low",
|
|
2052
|
+
problem: "No strong optimization pattern detected yet.",
|
|
2053
|
+
evidence: "Scan more sessions or configure prices for richer recommendations.",
|
|
2054
|
+
savingOpportunity: "Unknown until more local usage is imported.",
|
|
2055
|
+
recommendation: "Run a scan after several CLI sessions and revisit this page."
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
return insights;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// src/lib/evidence/metrics.ts
|
|
2062
|
+
var evidenceMetrics = [
|
|
2063
|
+
"processed-tokens",
|
|
2064
|
+
"non-cache-tokens",
|
|
2065
|
+
"cached-tokens",
|
|
2066
|
+
"estimated-cost",
|
|
2067
|
+
"sessions",
|
|
2068
|
+
"unknown-cost",
|
|
2069
|
+
"guardrails",
|
|
2070
|
+
"review-queue"
|
|
2071
|
+
];
|
|
2072
|
+
var metricTitles = {
|
|
2073
|
+
"processed-tokens": {
|
|
2074
|
+
title: "Processed tokens",
|
|
2075
|
+
description: "All input, output, cache, and reasoning tokens from imported local CLI records."
|
|
2076
|
+
},
|
|
2077
|
+
"non-cache-tokens": {
|
|
2078
|
+
title: "Non-cache tokens",
|
|
2079
|
+
description: "Fresh input, output, and reasoning tokens, excluding cache read/write tokens."
|
|
2080
|
+
},
|
|
2081
|
+
"cached-tokens": {
|
|
2082
|
+
title: "Cached tokens",
|
|
2083
|
+
description: "Cache read and cache write tokens reported by supported tools."
|
|
2084
|
+
},
|
|
2085
|
+
"estimated-cost": {
|
|
2086
|
+
title: "Estimated cost",
|
|
2087
|
+
description: "Cost calculated from editable provider model rates, including exact, estimated, and unknown rows."
|
|
2088
|
+
},
|
|
2089
|
+
sessions: {
|
|
2090
|
+
title: "Sessions",
|
|
2091
|
+
description: "Imported local CLI sessions and their interaction evidence."
|
|
2092
|
+
},
|
|
2093
|
+
"unknown-cost": {
|
|
2094
|
+
title: "Unknown cost",
|
|
2095
|
+
description: "Interactions whose cost cannot be calculated because model, price, or token counts are missing."
|
|
2096
|
+
},
|
|
2097
|
+
guardrails: {
|
|
2098
|
+
title: "Monthly guardrails",
|
|
2099
|
+
description: "Current-month usage contributing to local guardrail progress."
|
|
2100
|
+
},
|
|
2101
|
+
"review-queue": {
|
|
2102
|
+
title: "Review queue",
|
|
2103
|
+
description: "Evidence behind deterministic local review recommendations."
|
|
2104
|
+
}
|
|
2105
|
+
};
|
|
2106
|
+
function withQuery2(path2, params) {
|
|
2107
|
+
const query = new URLSearchParams();
|
|
2108
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
2109
|
+
if (value) query.set(key, value);
|
|
2110
|
+
});
|
|
2111
|
+
const serialized = query.toString();
|
|
2112
|
+
return serialized ? `${path2}?${serialized}` : path2;
|
|
2113
|
+
}
|
|
2114
|
+
function evidenceHref(metric2, params = {}) {
|
|
2115
|
+
return withQuery2("/evidence", { metric: metric2, ...params });
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// src/lib/evidence/mapping.ts
|
|
2119
|
+
function number2(value) {
|
|
2120
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
2121
|
+
}
|
|
2122
|
+
function mapEvidenceTrail({
|
|
2123
|
+
metric: metric2,
|
|
2124
|
+
rows: rows2
|
|
2125
|
+
}) {
|
|
2126
|
+
const config = metricTitles[metric2] ?? metricTitles["processed-tokens"];
|
|
2127
|
+
const sessions2 = rows2.sessions.map((session) => {
|
|
2128
|
+
const cost = session.cost == null ? null : number2(session.cost);
|
|
2129
|
+
return {
|
|
2130
|
+
id: session.id,
|
|
2131
|
+
title: session.title,
|
|
2132
|
+
tool: session.tool,
|
|
2133
|
+
provider: session.provider,
|
|
2134
|
+
project: session.project,
|
|
2135
|
+
model: session.model,
|
|
2136
|
+
sourceFile: session.sourceFile,
|
|
2137
|
+
parser: session.parser,
|
|
2138
|
+
parserStatus: session.parserStatus,
|
|
2139
|
+
parserConfidence: typeof session.parserConfidence === "number" && Number.isFinite(session.parserConfidence) ? session.parserConfidence : null,
|
|
2140
|
+
tokenConfidence: session.tokenConfidence,
|
|
2141
|
+
totalTokens: number2(session.totalTokens),
|
|
2142
|
+
cost: metric2 === "unknown-cost" && number2(session.unknownCostInteractions) > 0 ? null : cost,
|
|
2143
|
+
unknownCostInteractions: number2(session.unknownCostInteractions),
|
|
2144
|
+
interactions: number2(session.interactions),
|
|
2145
|
+
sessionHref: withQuery2("/sessions", { source: session.sourceFile, evidence: metric2 }),
|
|
2146
|
+
sourceHref: withQuery2("/sessions", { source: session.sourceFile, evidence: metric2 }),
|
|
2147
|
+
parserHref: withQuery2("/parser-debug", { source: session.sourceFile }),
|
|
2148
|
+
pricingHref: session.pricingModel ? withQuery2("/pricing", { model: session.pricingModel }) : null
|
|
2149
|
+
};
|
|
2150
|
+
});
|
|
2151
|
+
return {
|
|
2152
|
+
metric: metric2,
|
|
2153
|
+
title: config.title,
|
|
2154
|
+
description: config.description,
|
|
2155
|
+
totals: {
|
|
2156
|
+
tokens: number2(rows2.totals.tokens),
|
|
2157
|
+
cost: number2(rows2.totals.cost),
|
|
2158
|
+
sessions: number2(rows2.totals.sessions),
|
|
2159
|
+
interactions: number2(rows2.totals.interactions),
|
|
2160
|
+
unknownCostInteractions: number2(rows2.totals.unknownCostInteractions)
|
|
2161
|
+
},
|
|
2162
|
+
confidence: {
|
|
2163
|
+
exact: number2(rows2.confidence.exact),
|
|
2164
|
+
estimated: number2(rows2.confidence.estimated),
|
|
2165
|
+
unknown: number2(rows2.confidence.unknown)
|
|
2166
|
+
},
|
|
2167
|
+
sourceFiles: rows2.sourceFiles.map((source) => ({
|
|
2168
|
+
sourceFile: source.sourceFile,
|
|
2169
|
+
tokens: number2(source.tokens),
|
|
2170
|
+
sessions: number2(source.sessions),
|
|
2171
|
+
interactions: number2(source.interactions),
|
|
2172
|
+
unknownCostInteractions: number2(source.unknownCostInteractions),
|
|
2173
|
+
sourceHref: withQuery2("/sessions", { source: source.sourceFile, evidence: metric2 }),
|
|
2174
|
+
parserHref: withQuery2("/parser-debug", { source: source.sourceFile })
|
|
2175
|
+
})),
|
|
2176
|
+
sessions: sessions2
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
// src/lib/evidence/query.ts
|
|
2181
|
+
function currentMonthWindow(now = /* @__PURE__ */ new Date()) {
|
|
2182
|
+
const from = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
|
|
2183
|
+
const to = new Date(now.getFullYear(), now.getMonth() + 1, 1).getTime();
|
|
2184
|
+
return { from, to };
|
|
2185
|
+
}
|
|
2186
|
+
function metricTokenExpression(metric2, alias = "i") {
|
|
2187
|
+
if (metric2 === "cached-tokens") return `${alias}.cache_read_tokens + ${alias}.cache_write_tokens`;
|
|
2188
|
+
if (metric2 === "non-cache-tokens") return `${alias}.input_tokens + ${alias}.output_tokens + ${alias}.reasoning_tokens`;
|
|
2189
|
+
return `${alias}.total_tokens`;
|
|
2190
|
+
}
|
|
2191
|
+
function metricClauses(metric2, alias = "i") {
|
|
2192
|
+
if (metric2 === "cached-tokens") return [`(${alias}.cache_read_tokens + ${alias}.cache_write_tokens) > 0`];
|
|
2193
|
+
if (metric2 === "unknown-cost") return [`${alias}.cost IS NULL`];
|
|
2194
|
+
if (metric2 === "non-cache-tokens") {
|
|
2195
|
+
return [`(${alias}.input_tokens + ${alias}.output_tokens + ${alias}.reasoning_tokens) > 0`];
|
|
2196
|
+
}
|
|
2197
|
+
if (metric2 === "guardrails") {
|
|
2198
|
+
const window = currentMonthWindow();
|
|
2199
|
+
return [`${alias}.timestamp >= ${window.from}`, `${alias}.timestamp < ${window.to}`];
|
|
2200
|
+
}
|
|
2201
|
+
return [];
|
|
2202
|
+
}
|
|
2203
|
+
function evidenceWhere(metric2, filters = {}, alias = "i", prefix = "WHERE") {
|
|
2204
|
+
const clauses = metricClauses(metric2, alias);
|
|
2205
|
+
const params = [];
|
|
2206
|
+
if (typeof filters.from === "number" && Number.isFinite(filters.from)) {
|
|
2207
|
+
clauses.push(`${alias}.timestamp >= ?`);
|
|
2208
|
+
params.push(filters.from);
|
|
2209
|
+
}
|
|
2210
|
+
if (typeof filters.to === "number" && Number.isFinite(filters.to)) {
|
|
2211
|
+
clauses.push(`${alias}.timestamp < ?`);
|
|
2212
|
+
params.push(filters.to);
|
|
2213
|
+
}
|
|
2214
|
+
return {
|
|
2215
|
+
sql: clauses.length ? `${prefix} ${clauses.join(" AND ")}` : "",
|
|
2216
|
+
params
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
function fetchEvidenceTrailRows({
|
|
2220
|
+
metric: metric2,
|
|
2221
|
+
filters
|
|
2222
|
+
}) {
|
|
2223
|
+
const activeFilters = filters ?? {};
|
|
2224
|
+
const where = evidenceWhere(metric2, activeFilters);
|
|
2225
|
+
const modelWhere = evidenceWhere(metric2, activeFilters, "i3", "AND");
|
|
2226
|
+
const pricingWhere = evidenceWhere(metric2, activeFilters, "i2", "AND");
|
|
2227
|
+
const tokenExpression = metricTokenExpression(metric2);
|
|
2228
|
+
const pricingTokenExpression = metricTokenExpression(metric2, "i2");
|
|
2229
|
+
const totals = sqlite.prepare(
|
|
2230
|
+
`SELECT
|
|
2231
|
+
COALESCE(SUM(${tokenExpression}), 0) AS tokens,
|
|
2232
|
+
COALESCE(SUM(i.cost), 0) AS cost,
|
|
2233
|
+
COUNT(DISTINCT i.session_id) AS sessions,
|
|
2234
|
+
COUNT(*) AS interactions,
|
|
2235
|
+
SUM(CASE WHEN i.cost IS NULL THEN 1 ELSE 0 END) AS unknownCostInteractions
|
|
2236
|
+
FROM interactions i INDEXED BY interactions_analytics_cover_idx
|
|
2237
|
+
${where.sql}`
|
|
2238
|
+
).get(...where.params);
|
|
2239
|
+
const confidence = sqlite.prepare(
|
|
2240
|
+
`SELECT
|
|
2241
|
+
COALESCE(SUM(CASE WHEN i.token_confidence = 'exact' AND i.estimated_tokens = 0 THEN 1 ELSE 0 END), 0) AS exact,
|
|
2242
|
+
COALESCE(SUM(CASE WHEN i.estimated_tokens = 1 OR i.token_confidence LIKE '%estimate%' THEN 1 ELSE 0 END), 0) AS estimated,
|
|
2243
|
+
COALESCE(SUM(CASE WHEN i.token_confidence = 'unknown' THEN 1 ELSE 0 END), 0) AS unknown
|
|
2244
|
+
FROM interactions i INDEXED BY interactions_analytics_cover_idx
|
|
2245
|
+
${where.sql}`
|
|
2246
|
+
).get(...where.params);
|
|
2247
|
+
const sourceFiles = sqlite.prepare(
|
|
2248
|
+
`SELECT
|
|
2249
|
+
s.source_file AS sourceFile,
|
|
2250
|
+
COALESCE(SUM(${tokenExpression}), 0) AS tokens,
|
|
2251
|
+
COUNT(DISTINCT s.id) AS sessions,
|
|
2252
|
+
COUNT(*) AS interactions,
|
|
2253
|
+
SUM(CASE WHEN i.cost IS NULL THEN 1 ELSE 0 END) AS unknownCostInteractions
|
|
2254
|
+
FROM interactions i INDEXED BY interactions_analytics_cover_idx
|
|
2255
|
+
JOIN sessions s ON s.id = i.session_id
|
|
2256
|
+
${where.sql}
|
|
2257
|
+
GROUP BY s.source_file
|
|
2258
|
+
ORDER BY tokens DESC, interactions DESC, sourceFile ASC
|
|
2259
|
+
LIMIT 8`
|
|
2260
|
+
).all(...where.params);
|
|
2261
|
+
const sessions2 = sqlite.prepare(
|
|
2262
|
+
`WITH session_totals AS (
|
|
2263
|
+
SELECT
|
|
2264
|
+
i.session_id AS sessionId,
|
|
2265
|
+
CASE
|
|
2266
|
+
WHEN SUM(CASE WHEN i.token_confidence = 'unknown' THEN 1 ELSE 0 END) > 0 THEN 'unknown'
|
|
2267
|
+
WHEN SUM(CASE WHEN i.token_confidence = 'low-confidence estimate' THEN 1 ELSE 0 END) > 0 THEN 'low-confidence estimate'
|
|
2268
|
+
WHEN SUM(CASE WHEN i.token_confidence = 'high-confidence estimate' THEN 1 ELSE 0 END) > 0 THEN 'high-confidence estimate'
|
|
2269
|
+
WHEN SUM(CASE WHEN i.estimated_tokens = 1 THEN 1 ELSE 0 END) > 0 THEN 'estimated'
|
|
2270
|
+
ELSE 'exact'
|
|
2271
|
+
END AS tokenConfidence,
|
|
2272
|
+
COALESCE(SUM(${tokenExpression}), 0) AS totalTokens,
|
|
2273
|
+
SUM(i.cost) AS cost,
|
|
2274
|
+
SUM(CASE WHEN i.cost IS NULL THEN 1 ELSE 0 END) AS unknownCostInteractions,
|
|
2275
|
+
COUNT(*) AS interactions
|
|
2276
|
+
FROM interactions i INDEXED BY interactions_analytics_cover_idx
|
|
2277
|
+
${where.sql}
|
|
2278
|
+
GROUP BY i.session_id
|
|
2279
|
+
ORDER BY totalTokens DESC, sessionId ASC
|
|
2280
|
+
LIMIT 100
|
|
2281
|
+
)
|
|
2282
|
+
SELECT
|
|
2283
|
+
s.id,
|
|
2284
|
+
COALESCE(s.title, t.name || ' session') AS title,
|
|
2285
|
+
t.name AS tool,
|
|
2286
|
+
p.name AS provider,
|
|
2287
|
+
COALESCE(pr.name, 'Unassigned') AS project,
|
|
2288
|
+
COALESCE((
|
|
2289
|
+
SELECT group_concat(model_name)
|
|
2290
|
+
FROM (
|
|
2291
|
+
SELECT DISTINCT COALESCE(m3.name, 'unknown') AS model_name
|
|
2292
|
+
FROM interactions i3 INDEXED BY interactions_session_analytics_idx
|
|
2293
|
+
LEFT JOIN models m3 ON m3.id = i3.model_id
|
|
2294
|
+
WHERE i3.session_id = s.id
|
|
2295
|
+
${modelWhere.sql}
|
|
2296
|
+
ORDER BY model_name ASC
|
|
2297
|
+
)
|
|
2298
|
+
), 'unknown') AS model,
|
|
2299
|
+
(
|
|
2300
|
+
SELECT m2.name
|
|
2301
|
+
FROM interactions i2 INDEXED BY interactions_session_analytics_idx
|
|
2302
|
+
LEFT JOIN models m2 ON m2.id = i2.model_id
|
|
2303
|
+
WHERE i2.session_id = s.id
|
|
2304
|
+
${pricingWhere.sql}
|
|
2305
|
+
AND m2.name IS NOT NULL
|
|
2306
|
+
GROUP BY m2.id, m2.name
|
|
2307
|
+
ORDER BY SUM(${pricingTokenExpression}) DESC, m2.name ASC
|
|
2308
|
+
LIMIT 1
|
|
2309
|
+
) AS pricingModel,
|
|
2310
|
+
s.source_file AS sourceFile,
|
|
2311
|
+
sf.parser AS parser,
|
|
2312
|
+
sf.status AS parserStatus,
|
|
2313
|
+
json_extract(sf.raw_metadata, '$.confidence') AS parserConfidence,
|
|
2314
|
+
st.tokenConfidence,
|
|
2315
|
+
st.totalTokens,
|
|
2316
|
+
st.cost,
|
|
2317
|
+
st.unknownCostInteractions,
|
|
2318
|
+
st.interactions
|
|
2319
|
+
FROM session_totals st
|
|
2320
|
+
JOIN sessions s ON s.id = st.sessionId
|
|
2321
|
+
JOIN tools t ON t.id = s.tool_id
|
|
2322
|
+
JOIN providers p ON p.id = t.provider_id
|
|
2323
|
+
LEFT JOIN projects pr ON pr.id = s.project_id
|
|
2324
|
+
LEFT JOIN scan_files sf ON sf.id = (
|
|
2325
|
+
SELECT sf2.id
|
|
2326
|
+
FROM scan_files sf2
|
|
2327
|
+
JOIN scan_runs sr2 ON sr2.id = sf2.scan_run_id
|
|
2328
|
+
WHERE sf2.path = s.source_file
|
|
2329
|
+
ORDER BY
|
|
2330
|
+
CASE
|
|
2331
|
+
WHEN sf2.status IN ('imported', 'imported_with_errors')
|
|
2332
|
+
AND sf2.parser IS NOT NULL
|
|
2333
|
+
AND sf2.raw_metadata IS NOT NULL
|
|
2334
|
+
AND sf2.raw_metadata <> '{}'
|
|
2335
|
+
THEN 0
|
|
2336
|
+
WHEN sf2.status IN ('imported', 'imported_with_errors')
|
|
2337
|
+
AND sf2.parser IS NOT NULL
|
|
2338
|
+
THEN 1
|
|
2339
|
+
ELSE 2
|
|
2340
|
+
END,
|
|
2341
|
+
sr2.started_at DESC,
|
|
2342
|
+
sf2.id ASC
|
|
2343
|
+
LIMIT 1
|
|
2344
|
+
)
|
|
2345
|
+
ORDER BY st.totalTokens DESC, s.id ASC`
|
|
2346
|
+
).all(...where.params, ...modelWhere.params, ...pricingWhere.params);
|
|
2347
|
+
return {
|
|
2348
|
+
totals,
|
|
2349
|
+
confidence,
|
|
2350
|
+
sourceFiles,
|
|
2351
|
+
sessions: sessions2
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
// src/lib/evidence-trail.ts
|
|
2356
|
+
function buildEvidenceTrail(input) {
|
|
2357
|
+
return mapEvidenceTrail({
|
|
2358
|
+
metric: input.metric,
|
|
2359
|
+
rows: fetchEvidenceTrailRows({
|
|
2360
|
+
metric: input.metric,
|
|
2361
|
+
filters: input.filters
|
|
2362
|
+
})
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
// src/lib/recommendations.ts
|
|
2367
|
+
function recommendation(id, severity, title, evidence, action2, href) {
|
|
2368
|
+
return { id, severity, title, evidence, action: action2, href };
|
|
2369
|
+
}
|
|
2370
|
+
function guardrailRecommendation(metric2, kind, monthLabel2) {
|
|
2371
|
+
if (!metric2.configured || metric2.status === "ok") return null;
|
|
2372
|
+
const exceeded = metric2.status === "exceeded";
|
|
2373
|
+
const formattedUsed = kind === "cost" ? formatCurrency(metric2.used) : formatTokens(metric2.used);
|
|
2374
|
+
const formattedLimit = kind === "cost" ? formatCurrency(metric2.limit) : formatTokens(metric2.limit);
|
|
2375
|
+
const noun = kind === "cost" ? "cost" : "token";
|
|
2376
|
+
const percent = Math.round(metric2.percent * 100);
|
|
2377
|
+
return recommendation(
|
|
2378
|
+
`monthly-${noun}-limit-${metric2.status}`,
|
|
2379
|
+
exceeded ? "high" : "medium",
|
|
2380
|
+
exceeded ? `Monthly ${noun} guardrail exceeded` : `Monthly ${noun} guardrail is close`,
|
|
2381
|
+
`${monthLabel2} is at ${formattedUsed} of ${formattedLimit} (${percent}%).`,
|
|
2382
|
+
exceeded ? "Review current-month sessions before starting more long CLI runs." : "Watch the next few sessions or adjust the local guardrail in Settings.",
|
|
2383
|
+
"/sessions"
|
|
2384
|
+
);
|
|
2385
|
+
}
|
|
2386
|
+
function buildLocalRecommendations(input) {
|
|
2387
|
+
const recommendations = [];
|
|
2388
|
+
const missingPricing = input.unknownCosts.filter((row) => row.cause === "missing pricing").reduce((sum, row) => sum + row.interactions, 0);
|
|
2389
|
+
if (missingPricing > 0) {
|
|
2390
|
+
const top = input.unknownCosts.find((row) => row.cause === "missing pricing");
|
|
2391
|
+
recommendations.push(recommendation(
|
|
2392
|
+
"unknown-pricing",
|
|
2393
|
+
"high",
|
|
2394
|
+
"Add missing model rates",
|
|
2395
|
+
`${missingPricing.toLocaleString()} interactions have tokens and model names but no usable price row${top ? `; top model: ${top.model}` : ""}.`,
|
|
2396
|
+
"Set the missing provider model rates so cost totals become complete.",
|
|
2397
|
+
top?.repairHref ?? "/pricing"
|
|
2398
|
+
));
|
|
2399
|
+
} else if (input.summary.unknownCostInteractions > 0) {
|
|
2400
|
+
recommendations.push(recommendation(
|
|
2401
|
+
"unknown-cost",
|
|
2402
|
+
"medium",
|
|
2403
|
+
"Repair unknown cost rows",
|
|
2404
|
+
`${input.summary.unknownCostInteractions.toLocaleString()} interactions still have unknown cost.`,
|
|
2405
|
+
"Use the repair queue to decide whether model rates, model names, or token counts are missing.",
|
|
2406
|
+
"/diagnostics"
|
|
2407
|
+
));
|
|
2408
|
+
}
|
|
2409
|
+
if (input.guardrails) {
|
|
2410
|
+
const guardrailItems = [
|
|
2411
|
+
guardrailRecommendation(input.guardrails.cost, "cost", input.guardrails.monthLabel),
|
|
2412
|
+
guardrailRecommendation(input.guardrails.tokens, "tokens", input.guardrails.monthLabel)
|
|
2413
|
+
].filter((item) => Boolean(item));
|
|
2414
|
+
recommendations.push(...guardrailItems);
|
|
2415
|
+
}
|
|
2416
|
+
const topProject = input.projects[0];
|
|
2417
|
+
if (topProject && input.summary.totalTokens > 0 && topProject.totalTokens / input.summary.totalTokens >= 0.5) {
|
|
2418
|
+
recommendations.push(recommendation(
|
|
2419
|
+
"dominant-project",
|
|
2420
|
+
"medium",
|
|
2421
|
+
"One project dominates usage",
|
|
2422
|
+
`${topProject.project} accounts for ${Math.round(topProject.totalTokens / input.summary.totalTokens * 100)}% of imported tokens.`,
|
|
2423
|
+
"Review that project's sessions before optimizing smaller usage elsewhere.",
|
|
2424
|
+
`/sessions?project=${encodeURIComponent(topProject.project)}`
|
|
2425
|
+
));
|
|
2426
|
+
}
|
|
2427
|
+
const estimatedTool = input.tools.find((tool) => tool.interactions > 0 && tool.totalTokens > 0 && tool.tool === "Generic Log");
|
|
2428
|
+
if (estimatedTool) {
|
|
2429
|
+
recommendations.push(recommendation(
|
|
2430
|
+
"generic-log-estimates",
|
|
2431
|
+
"medium",
|
|
2432
|
+
"Generic logs are contributing usage",
|
|
2433
|
+
`${estimatedTool.tool} has ${estimatedTool.totalTokens.toLocaleString()} tokens across ${estimatedTool.interactions.toLocaleString()} interactions.`,
|
|
2434
|
+
"Review parser confidence before treating generic log estimates as exact usage.",
|
|
2435
|
+
"/parser-debug"
|
|
2436
|
+
));
|
|
2437
|
+
}
|
|
2438
|
+
const cacheBase = input.summary.inputTokens + input.summary.cachedTokens;
|
|
2439
|
+
const cacheEfficiency = cacheBase > 0 ? input.summary.cachedTokens / cacheBase : 0;
|
|
2440
|
+
if (input.summary.inputTokens > 1e4 && cacheEfficiency < 0.1) {
|
|
2441
|
+
recommendations.push(recommendation(
|
|
2442
|
+
"low-cache",
|
|
2443
|
+
"low",
|
|
2444
|
+
"Cache usage is low",
|
|
2445
|
+
`Cached tokens are ${Math.round(cacheEfficiency * 100)}% of reusable input volume.`,
|
|
2446
|
+
"Inspect model and session mix to see whether your CLI can reuse more context.",
|
|
2447
|
+
"/models"
|
|
2448
|
+
));
|
|
2449
|
+
}
|
|
2450
|
+
if (input.scan && input.scan.latestRecordsImported === 0 && input.scan.duplicateFiles > 0 && input.scan.parserReviewFiles === 0) {
|
|
2451
|
+
recommendations.push(recommendation(
|
|
2452
|
+
"duplicate-only-scan",
|
|
2453
|
+
"low",
|
|
2454
|
+
"Last scan found only already imported files",
|
|
2455
|
+
`${input.scan.duplicateFiles.toLocaleString()} duplicate files were skipped safely.`,
|
|
2456
|
+
"No action is needed unless you expected new sessions.",
|
|
2457
|
+
"/diagnostics"
|
|
2458
|
+
));
|
|
2459
|
+
}
|
|
2460
|
+
if (input.scan && input.scan.parserReviewFiles > 0) {
|
|
2461
|
+
recommendations.push(recommendation(
|
|
2462
|
+
"parser-review",
|
|
2463
|
+
"medium",
|
|
2464
|
+
"Some files need parser review",
|
|
2465
|
+
`${input.scan.parserReviewFiles.toLocaleString()} files were unsupported in the latest scan.`,
|
|
2466
|
+
"Inspect Discovery to separate real usage files from support files.",
|
|
2467
|
+
"/discovery"
|
|
2468
|
+
));
|
|
2469
|
+
}
|
|
2470
|
+
if (input.scan && input.scan.ignoredFiles > 0) {
|
|
2471
|
+
recommendations.push(recommendation(
|
|
2472
|
+
"ignored-support-files",
|
|
2473
|
+
"low",
|
|
2474
|
+
"Support files are being ignored",
|
|
2475
|
+
`${input.scan.ignoredFiles.toLocaleString()} known non-usage files were ignored instead of imported.`,
|
|
2476
|
+
"This is expected for Claude/Codex cache, plugin, todo, and support files.",
|
|
2477
|
+
"/discovery"
|
|
2478
|
+
));
|
|
2479
|
+
}
|
|
2480
|
+
if (!recommendations.length) {
|
|
2481
|
+
recommendations.push(recommendation(
|
|
2482
|
+
"baseline",
|
|
2483
|
+
"low",
|
|
2484
|
+
"No urgent local recommendation",
|
|
2485
|
+
"Current scans, model rates, and parser confidence do not show a high-priority repair.",
|
|
2486
|
+
"Keep scanning after new CLI sessions.",
|
|
2487
|
+
"/settings#scan-controls"
|
|
2488
|
+
));
|
|
2489
|
+
}
|
|
2490
|
+
const rank = { high: 0, medium: 1, low: 2 };
|
|
2491
|
+
return recommendations.sort((a, b) => rank[a.severity] - rank[b.severity]).slice(0, 8);
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
// src/lib/review-queue.ts
|
|
2495
|
+
function slug(value) {
|
|
2496
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "unknown";
|
|
2497
|
+
}
|
|
2498
|
+
function withQuery3(path2, params) {
|
|
2499
|
+
const query = new URLSearchParams();
|
|
2500
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
2501
|
+
if (value) query.set(key, value);
|
|
2502
|
+
});
|
|
2503
|
+
const serialized = query.toString();
|
|
2504
|
+
return serialized ? `${path2}?${serialized}` : path2;
|
|
2505
|
+
}
|
|
2506
|
+
function guardrailItem(metric2, kind, monthLabel2) {
|
|
2507
|
+
if (!metric2.configured || metric2.status === "ok") return null;
|
|
2508
|
+
const exceeded = metric2.status === "exceeded";
|
|
2509
|
+
const formattedUsed = kind === "cost" ? formatCurrency(metric2.used) : formatTokens(metric2.used);
|
|
2510
|
+
const formattedLimit = kind === "cost" ? formatCurrency(metric2.limit) : formatTokens(metric2.limit);
|
|
2511
|
+
const noun = kind === "cost" ? "cost" : "token";
|
|
2512
|
+
return {
|
|
2513
|
+
id: `guardrail-${kind}-${metric2.status}`,
|
|
2514
|
+
severity: exceeded ? "high" : "medium",
|
|
2515
|
+
category: "guardrail",
|
|
2516
|
+
title: exceeded ? `Monthly ${noun} guardrail exceeded` : `Monthly ${noun} guardrail is close`,
|
|
2517
|
+
evidence: `${monthLabel2} is at ${formattedUsed} of ${formattedLimit} (${Math.round(metric2.percent * 100)}%).`,
|
|
2518
|
+
action: exceeded ? "Review current-month sessions before starting another long CLI run." : "Watch the next sessions or adjust the local guardrail in Settings.",
|
|
2519
|
+
href: "/sessions",
|
|
2520
|
+
impactLabel: `${monthLabel2} ${noun}`,
|
|
2521
|
+
impactValue: formattedUsed
|
|
2522
|
+
};
|
|
2523
|
+
}
|
|
2524
|
+
function unknownCostItem(row) {
|
|
2525
|
+
const severity = row.cause === "missing pricing" ? "high" : "medium";
|
|
2526
|
+
return {
|
|
2527
|
+
id: `repair-unknown-cost-${slug(row.cause)}-${slug(row.model)}`,
|
|
2528
|
+
severity,
|
|
2529
|
+
category: "cost-repair",
|
|
2530
|
+
title: row.cause === "missing pricing" ? "Price a real model name" : "Repair unknown-cost usage",
|
|
2531
|
+
evidence: `${row.interactions.toLocaleString()} interactions from ${row.tool} are ${row.cause}; model: ${row.model}.`,
|
|
2532
|
+
action: row.cause === "missing pricing" ? "Add or confirm the model price so cost totals become complete." : "Inspect parser evidence to recover the missing model or token count.",
|
|
2533
|
+
href: row.repairHref,
|
|
2534
|
+
impactLabel: "unknown cost",
|
|
2535
|
+
impactValue: `${row.interactions.toLocaleString()} interactions`
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
function sessionItem(session, summary) {
|
|
2539
|
+
const tokenShare = summary.totalTokens > 0 ? session.totalTokens / summary.totalTokens : 0;
|
|
2540
|
+
const costShare = summary.totalCost > 0 && session.cost != null ? session.cost / summary.totalCost : 0;
|
|
2541
|
+
const severity = tokenShare >= 0.25 || costShare >= 0.25 ? "high" : "medium";
|
|
2542
|
+
const title = session.title?.trim() || `${session.tool} session`;
|
|
2543
|
+
return {
|
|
2544
|
+
id: `review-session-${session.id}`,
|
|
2545
|
+
severity,
|
|
2546
|
+
category: "session",
|
|
2547
|
+
title: "Review a high-impact session",
|
|
2548
|
+
evidence: `${title} used ${formatTokens(session.totalTokens)} tokens${session.cost != null ? ` and ${formatCurrency(session.cost)}` : ""}.`,
|
|
2549
|
+
action: "Open the evidence trail before optimizing smaller usage elsewhere.",
|
|
2550
|
+
href: session.sourceHref,
|
|
2551
|
+
impactLabel: "session tokens",
|
|
2552
|
+
impactValue: formatTokens(session.totalTokens)
|
|
2553
|
+
};
|
|
2554
|
+
}
|
|
2555
|
+
function projectItem(project, summary) {
|
|
2556
|
+
if (summary.totalTokens <= 0 || project.totalTokens / summary.totalTokens < 0.5) return null;
|
|
2557
|
+
return {
|
|
2558
|
+
id: `review-project-${project.id}`,
|
|
2559
|
+
severity: "medium",
|
|
2560
|
+
category: "project",
|
|
2561
|
+
title: "One project dominates usage",
|
|
2562
|
+
evidence: `${project.project} accounts for ${Math.round(project.totalTokens / summary.totalTokens * 100)}% of imported tokens.`,
|
|
2563
|
+
action: "Review this project's sessions before spreading effort across smaller projects.",
|
|
2564
|
+
href: withQuery3("/sessions", { project: project.project }),
|
|
2565
|
+
impactLabel: "project tokens",
|
|
2566
|
+
impactValue: formatTokens(project.totalTokens)
|
|
2567
|
+
};
|
|
2568
|
+
}
|
|
2569
|
+
function modelItem(model) {
|
|
2570
|
+
if (!model.overuseFlag || !model.suggestedAlternative) return null;
|
|
2571
|
+
return {
|
|
2572
|
+
id: `review-model-${slug(model.provider)}-${slug(model.model)}`,
|
|
2573
|
+
severity: "medium",
|
|
2574
|
+
category: "model",
|
|
2575
|
+
title: "Check expensive-model usage",
|
|
2576
|
+
evidence: `${model.model} has ${formatTokens(model.totalTokens)} tokens and ${model.suggestedAlternative} is cheaper in Model Rates.`,
|
|
2577
|
+
action: "Review sessions for mechanical work that could use a cheaper configured model.",
|
|
2578
|
+
href: withQuery3("/sessions", { model: model.model }),
|
|
2579
|
+
impactLabel: "model cost",
|
|
2580
|
+
impactValue: formatCurrency(model.cost)
|
|
2581
|
+
};
|
|
2582
|
+
}
|
|
2583
|
+
function cacheItem(tool, summary) {
|
|
2584
|
+
if (tool.totalTokens < 1e4 || tool.cacheEfficiency >= 0.05 || summary.inputTokens < 1e4) return null;
|
|
2585
|
+
return {
|
|
2586
|
+
id: `review-cache-${slug(tool.tool)}`,
|
|
2587
|
+
severity: "low",
|
|
2588
|
+
category: "cache",
|
|
2589
|
+
title: "Cache reuse is low",
|
|
2590
|
+
evidence: `${tool.tool} has ${formatTokens(tool.totalTokens)} tokens with ${Math.round(tool.cacheEfficiency * 100)}% cache efficiency.`,
|
|
2591
|
+
action: "Inspect model and session mix to see whether context reuse is possible.",
|
|
2592
|
+
href: withQuery3("/sessions", { tool: tool.tool }),
|
|
2593
|
+
impactLabel: "tool tokens",
|
|
2594
|
+
impactValue: formatTokens(tool.totalTokens)
|
|
2595
|
+
};
|
|
2596
|
+
}
|
|
2597
|
+
function buildReviewQueue(input) {
|
|
2598
|
+
const items = [];
|
|
2599
|
+
const guardrailItems = [
|
|
2600
|
+
guardrailItem(input.guardrails.cost, "cost", input.guardrails.monthLabel),
|
|
2601
|
+
guardrailItem(input.guardrails.tokens, "tokens", input.guardrails.monthLabel)
|
|
2602
|
+
].filter((item) => Boolean(item));
|
|
2603
|
+
items.push(...guardrailItems);
|
|
2604
|
+
const topUnknownCost = input.unknownCosts[0];
|
|
2605
|
+
if (topUnknownCost) {
|
|
2606
|
+
items.push(unknownCostItem(topUnknownCost));
|
|
2607
|
+
}
|
|
2608
|
+
const topSession = [...input.sessions].sort((a, b) => {
|
|
2609
|
+
const costDelta = (b.cost ?? 0) - (a.cost ?? 0);
|
|
2610
|
+
return costDelta || b.totalTokens - a.totalTokens;
|
|
2611
|
+
})[0];
|
|
2612
|
+
if (topSession && topSession.totalTokens > 0) {
|
|
2613
|
+
items.push(sessionItem(topSession, input.summary));
|
|
2614
|
+
}
|
|
2615
|
+
const topProject = input.projects[0] ? projectItem(input.projects[0], input.summary) : null;
|
|
2616
|
+
if (topProject) items.push(topProject);
|
|
2617
|
+
const modelReview = input.models.map(modelItem).find(Boolean);
|
|
2618
|
+
if (modelReview) items.push(modelReview);
|
|
2619
|
+
const cacheReview = input.tools.map((tool) => cacheItem(tool, input.summary)).find(Boolean);
|
|
2620
|
+
if (cacheReview) items.push(cacheReview);
|
|
2621
|
+
if (!items.length) {
|
|
2622
|
+
return [
|
|
2623
|
+
{
|
|
2624
|
+
id: "baseline",
|
|
2625
|
+
severity: "low",
|
|
2626
|
+
category: "baseline",
|
|
2627
|
+
title: "No urgent review item",
|
|
2628
|
+
evidence: "Current local usage does not show a high-priority repair or optimization queue.",
|
|
2629
|
+
action: "Keep scanning after meaningful CLI sessions.",
|
|
2630
|
+
href: "/settings#scan-controls",
|
|
2631
|
+
impactLabel: "review status",
|
|
2632
|
+
impactValue: "clear"
|
|
2633
|
+
}
|
|
2634
|
+
];
|
|
2635
|
+
}
|
|
2636
|
+
const severityRank = { high: 0, medium: 1, low: 2 };
|
|
2637
|
+
const categoryRank = {
|
|
2638
|
+
guardrail: 0,
|
|
2639
|
+
"cost-repair": 1,
|
|
2640
|
+
session: 2,
|
|
2641
|
+
project: 3,
|
|
2642
|
+
model: 4,
|
|
2643
|
+
cache: 5,
|
|
2644
|
+
baseline: 6
|
|
2645
|
+
};
|
|
2646
|
+
return items.sort((a, b) => severityRank[a.severity] - severityRank[b.severity] || categoryRank[a.category] - categoryRank[b.category]).slice(0, 8);
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// src/lib/project-signals.ts
|
|
2650
|
+
function slug2(value) {
|
|
2651
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "unknown";
|
|
2652
|
+
}
|
|
2653
|
+
function sessionsHref(project) {
|
|
2654
|
+
return `/sessions?project=${encodeURIComponent(project)}`;
|
|
2655
|
+
}
|
|
2656
|
+
function sessionsByProject(sessions2) {
|
|
2657
|
+
return sessions2.reduce((groups, session) => {
|
|
2658
|
+
const group = groups[session.project] ??= [];
|
|
2659
|
+
group.push(session);
|
|
2660
|
+
return groups;
|
|
2661
|
+
}, {});
|
|
2662
|
+
}
|
|
2663
|
+
function dominantProjectSignal(project, totalTokens) {
|
|
2664
|
+
if (totalTokens <= 0) return null;
|
|
2665
|
+
const share = project.totalTokens / totalTokens;
|
|
2666
|
+
if (share < 0.5) return null;
|
|
2667
|
+
return {
|
|
2668
|
+
id: `project-dominance-${project.id}`,
|
|
2669
|
+
severity: share >= 0.75 ? "high" : "medium",
|
|
2670
|
+
project: project.project,
|
|
2671
|
+
path: project.path,
|
|
2672
|
+
signal: "dominant usage",
|
|
2673
|
+
evidence: `${project.project} accounts for ${Math.round(share * 100)}% of imported tokens.`,
|
|
2674
|
+
action: "Review this project's sessions before optimizing smaller projects.",
|
|
2675
|
+
href: sessionsHref(project.project),
|
|
2676
|
+
metricLabel: "project share",
|
|
2677
|
+
metricValue: `${Math.round(share * 100)}%`
|
|
2678
|
+
};
|
|
2679
|
+
}
|
|
2680
|
+
function unknownCostSignal(project, sessions2) {
|
|
2681
|
+
const count = sessions2.filter((session) => session.cost == null).length;
|
|
2682
|
+
if (count === 0) return null;
|
|
2683
|
+
return {
|
|
2684
|
+
id: `project-unknown-cost-${slug2(project.project)}`,
|
|
2685
|
+
severity: "high",
|
|
2686
|
+
project: project.project,
|
|
2687
|
+
path: project.path,
|
|
2688
|
+
signal: "unknown cost",
|
|
2689
|
+
evidence: `${count.toLocaleString()} sessions in ${project.project} still have unknown cost.`,
|
|
2690
|
+
action: "Repair model-rate or parser evidence for this project's sessions.",
|
|
2691
|
+
href: `/sessions?project=${encodeURIComponent(project.project)}&cost=unknown`,
|
|
2692
|
+
metricLabel: "unknown sessions",
|
|
2693
|
+
metricValue: count.toLocaleString()
|
|
2694
|
+
};
|
|
2695
|
+
}
|
|
2696
|
+
function estimatedTokensSignal(project, sessions2) {
|
|
2697
|
+
const count = sessions2.filter((session) => session.estimatedTokens || session.tokenConfidence !== "exact").length;
|
|
2698
|
+
if (count === 0) return null;
|
|
2699
|
+
return {
|
|
2700
|
+
id: `project-estimated-tokens-${slug2(project.project)}`,
|
|
2701
|
+
severity: "medium",
|
|
2702
|
+
project: project.project,
|
|
2703
|
+
path: project.path,
|
|
2704
|
+
signal: "estimated tokens",
|
|
2705
|
+
evidence: `${count.toLocaleString()} sessions in ${project.project} use estimated or unknown token confidence.`,
|
|
2706
|
+
action: "Review parser confidence before treating this project as exact.",
|
|
2707
|
+
href: sessionsHref(project.project),
|
|
2708
|
+
metricLabel: "estimated sessions",
|
|
2709
|
+
metricValue: count.toLocaleString()
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
function modelConcentrationSignal(project, sessions2) {
|
|
2713
|
+
if (sessions2.length < 3) return null;
|
|
2714
|
+
const totals = sessions2.reduce((summary, session) => {
|
|
2715
|
+
const model = session.models.split(",")[0]?.trim() || "unknown";
|
|
2716
|
+
summary[model] = (summary[model] ?? 0) + session.totalTokens;
|
|
2717
|
+
return summary;
|
|
2718
|
+
}, {});
|
|
2719
|
+
const top = Object.entries(totals).sort((a, b) => b[1] - a[1])[0];
|
|
2720
|
+
if (!top || project.totalTokens <= 0 || top[1] / project.totalTokens < 0.8) return null;
|
|
2721
|
+
return {
|
|
2722
|
+
id: `project-model-concentration-${slug2(project.project)}-${slug2(top[0])}`,
|
|
2723
|
+
severity: "low",
|
|
2724
|
+
project: project.project,
|
|
2725
|
+
path: project.path,
|
|
2726
|
+
signal: "model concentration",
|
|
2727
|
+
evidence: `${top[0]} accounts for ${Math.round(top[1] / project.totalTokens * 100)}% of ${project.project} tokens.`,
|
|
2728
|
+
action: "Review whether this project needs one default model or more intentional model choices.",
|
|
2729
|
+
href: sessionsHref(project.project),
|
|
2730
|
+
metricLabel: "top model tokens",
|
|
2731
|
+
metricValue: formatTokens(top[1])
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
function buildProjectSignals(input) {
|
|
2735
|
+
const groupedSessions = sessionsByProject(input.sessions);
|
|
2736
|
+
const signals = [];
|
|
2737
|
+
input.projects.forEach((project) => {
|
|
2738
|
+
const projectSessions = groupedSessions[project.project] ?? [];
|
|
2739
|
+
const projectSignals = [
|
|
2740
|
+
dominantProjectSignal(project, input.totalTokens),
|
|
2741
|
+
unknownCostSignal(project, projectSessions),
|
|
2742
|
+
estimatedTokensSignal(project, projectSessions),
|
|
2743
|
+
modelConcentrationSignal(project, projectSessions)
|
|
2744
|
+
].filter((signal) => Boolean(signal));
|
|
2745
|
+
signals.push(...projectSignals);
|
|
2746
|
+
});
|
|
2747
|
+
const severityRank = { high: 0, medium: 1, low: 2 };
|
|
2748
|
+
const signalRank = {
|
|
2749
|
+
"dominant usage": 0,
|
|
2750
|
+
"unknown cost": 1,
|
|
2751
|
+
"estimated tokens": 2,
|
|
2752
|
+
"model concentration": 3
|
|
2753
|
+
};
|
|
2754
|
+
return signals.sort((a, b) => signalRank[a.signal] - signalRank[b.signal] || severityRank[a.severity] - severityRank[b.severity]).slice(0, 12);
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
// src/lib/session-comparison.ts
|
|
2758
|
+
function median(values) {
|
|
2759
|
+
if (!values.length) return 0;
|
|
2760
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
2761
|
+
const middle = Math.floor(sorted.length / 2);
|
|
2762
|
+
const upper = sorted[middle];
|
|
2763
|
+
if (upper === void 0) return 0;
|
|
2764
|
+
const lower = sorted[middle - 1];
|
|
2765
|
+
return sorted.length % 2 === 0 && lower !== void 0 ? (lower + upper) / 2 : upper;
|
|
2766
|
+
}
|
|
2767
|
+
function groupKey(session) {
|
|
2768
|
+
const primaryModel = session.models.split(",")[0]?.trim() || "unknown";
|
|
2769
|
+
return [session.project, session.tool, primaryModel].join("\0");
|
|
2770
|
+
}
|
|
2771
|
+
function titleFor(session) {
|
|
2772
|
+
return session.title?.trim() || `${session.tool} session`;
|
|
2773
|
+
}
|
|
2774
|
+
function buildSessionComparisons(sessions2) {
|
|
2775
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2776
|
+
sessions2.forEach((session) => {
|
|
2777
|
+
const key = groupKey(session);
|
|
2778
|
+
const group = groups.get(key) ?? [];
|
|
2779
|
+
group.push(session);
|
|
2780
|
+
groups.set(key, group);
|
|
2781
|
+
});
|
|
2782
|
+
const rows2 = [];
|
|
2783
|
+
groups.forEach((group) => {
|
|
2784
|
+
if (group.length < 3) return;
|
|
2785
|
+
const medianTokens = median(group.map((session) => session.totalTokens).filter((value) => value > 0));
|
|
2786
|
+
const medianCost = median(group.map((session) => session.cost ?? 0).filter((value) => value > 0));
|
|
2787
|
+
if (medianTokens <= 0) return;
|
|
2788
|
+
group.forEach((session) => {
|
|
2789
|
+
const tokenMultiple = session.totalTokens / medianTokens;
|
|
2790
|
+
const costMultiple = medianCost > 0 && session.cost != null ? session.cost / medianCost : null;
|
|
2791
|
+
const isTokenOutlier = session.totalTokens >= 5e3 && tokenMultiple >= 3;
|
|
2792
|
+
const isCostOutlier = session.cost != null && session.cost >= 1 && costMultiple != null && costMultiple >= 3;
|
|
2793
|
+
if (!isTokenOutlier && !isCostOutlier) return;
|
|
2794
|
+
const flag = isTokenOutlier ? "token outlier" : "cost outlier";
|
|
2795
|
+
const multiple = isTokenOutlier ? tokenMultiple : costMultiple ?? tokenMultiple;
|
|
2796
|
+
rows2.push({
|
|
2797
|
+
sessionId: session.id,
|
|
2798
|
+
title: titleFor(session),
|
|
2799
|
+
project: session.project,
|
|
2800
|
+
tool: session.tool,
|
|
2801
|
+
models: session.models,
|
|
2802
|
+
totalTokens: session.totalTokens,
|
|
2803
|
+
cost: session.cost,
|
|
2804
|
+
peerSessions: group.length,
|
|
2805
|
+
peerMedianTokens: medianTokens,
|
|
2806
|
+
peerMedianCost: medianCost > 0 ? medianCost : null,
|
|
2807
|
+
tokenMultiple,
|
|
2808
|
+
costMultiple,
|
|
2809
|
+
severity: multiple >= 5 ? "high" : "medium",
|
|
2810
|
+
flag,
|
|
2811
|
+
evidence: flag === "token outlier" ? `${titleFor(session)} used ${formatTokens(session.totalTokens)}, ${tokenMultiple.toFixed(1)}x its peer median.` : `${titleFor(session)} cost ${formatCurrency(session.cost)}, ${(costMultiple ?? 0).toFixed(1)}x its peer median.`,
|
|
2812
|
+
action: "Compare this session against similar project, tool, and model sessions.",
|
|
2813
|
+
href: session.sourceHref
|
|
2814
|
+
});
|
|
2815
|
+
});
|
|
2816
|
+
});
|
|
2817
|
+
const severityRank = { high: 0, medium: 1, low: 2 };
|
|
2818
|
+
return rows2.sort((a, b) => severityRank[a.severity] - severityRank[b.severity] || b.tokenMultiple - a.tokenMultiple).slice(0, 12);
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
// src/db/settings.ts
|
|
2822
|
+
import { eq } from "drizzle-orm";
|
|
2823
|
+
|
|
2824
|
+
// src/lib/import-profiles.ts
|
|
2825
|
+
var builtInImportProfiles = [
|
|
2826
|
+
{
|
|
2827
|
+
id: "structured-usage-log",
|
|
2828
|
+
label: "Structured usage logs",
|
|
2829
|
+
kind: "jsonl",
|
|
2830
|
+
description: "Local wrapper or team JSONL records with session, model, usage, and optional cost fields.",
|
|
2831
|
+
matchers: [".jsonl", ".ndjson"],
|
|
2832
|
+
enabled: true,
|
|
2833
|
+
builtIn: true
|
|
2834
|
+
},
|
|
2835
|
+
{
|
|
2836
|
+
id: "cursor-chat-export",
|
|
2837
|
+
label: "Cursor chat exports",
|
|
2838
|
+
kind: "cursor-chat",
|
|
2839
|
+
description: "Cursor-style JSON chat or composer exports with conversations and messages.",
|
|
2840
|
+
matchers: ["cursor", "composer", ".json"],
|
|
2841
|
+
enabled: true,
|
|
2842
|
+
builtIn: true
|
|
2843
|
+
},
|
|
2844
|
+
{
|
|
2845
|
+
id: "generic-jsonl",
|
|
2846
|
+
label: "Generic JSONL usage",
|
|
2847
|
+
kind: "jsonl",
|
|
2848
|
+
description: "Line-delimited JSON records with usage, model, role, or token fields.",
|
|
2849
|
+
matchers: [".jsonl"],
|
|
2850
|
+
enabled: true,
|
|
2851
|
+
builtIn: true
|
|
2852
|
+
},
|
|
2853
|
+
{
|
|
2854
|
+
id: "generic-text-log",
|
|
2855
|
+
label: "Generic text usage log",
|
|
2856
|
+
kind: "text-log",
|
|
2857
|
+
description: "Plain text logs with token, model, session, or cost-like usage lines.",
|
|
2858
|
+
matchers: [".log", ".txt", ".md"],
|
|
2859
|
+
enabled: true,
|
|
2860
|
+
builtIn: true
|
|
2861
|
+
},
|
|
2862
|
+
{
|
|
2863
|
+
id: "sqlite-history",
|
|
2864
|
+
label: "SQLite usage history",
|
|
2865
|
+
kind: "sqlite-history",
|
|
2866
|
+
description: "Local SQLite databases with usage-shaped history tables.",
|
|
2867
|
+
matchers: [".db", ".sqlite", ".sqlite3"],
|
|
2868
|
+
enabled: true,
|
|
2869
|
+
builtIn: true
|
|
2870
|
+
}
|
|
2871
|
+
];
|
|
2872
|
+
function slug3(value) {
|
|
2873
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 64);
|
|
2874
|
+
}
|
|
2875
|
+
function normalizeKind(value) {
|
|
2876
|
+
if (value === "sqlite-history" || value === "text-log" || value === "jsonl" || value === "cursor-chat") return value;
|
|
2877
|
+
return "text-log";
|
|
2878
|
+
}
|
|
2879
|
+
function normalizeMatchers(value) {
|
|
2880
|
+
if (!Array.isArray(value)) return [];
|
|
2881
|
+
return Array.from(
|
|
2882
|
+
new Set(
|
|
2883
|
+
value.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean).slice(0, 12)
|
|
2884
|
+
)
|
|
2885
|
+
);
|
|
2886
|
+
}
|
|
2887
|
+
function normalizeImportProfiles(value) {
|
|
2888
|
+
const custom = Array.isArray(value) ? value : [];
|
|
2889
|
+
const builtIns = builtInImportProfiles.map((profile) => {
|
|
2890
|
+
const override = custom.find((item) => {
|
|
2891
|
+
return item && typeof item === "object" && item.id === profile.id;
|
|
2892
|
+
});
|
|
2893
|
+
return {
|
|
2894
|
+
...profile,
|
|
2895
|
+
enabled: override?.enabled === false ? false : profile.enabled
|
|
2896
|
+
};
|
|
2897
|
+
});
|
|
2898
|
+
const customProfiles = custom.filter((item) => Boolean(item) && typeof item === "object").filter((item) => typeof item.id === "string" && !builtInImportProfiles.some((profile) => profile.id === item.id)).map((item) => {
|
|
2899
|
+
const label = typeof item.label === "string" && item.label.trim() ? item.label.trim().slice(0, 80) : "Custom import profile";
|
|
2900
|
+
const matchers = normalizeMatchers(item.matchers);
|
|
2901
|
+
const id = typeof item.id === "string" && item.id.startsWith("custom-") ? item.id : `custom-${slug3(label) || "profile"}`;
|
|
2902
|
+
return {
|
|
2903
|
+
id,
|
|
2904
|
+
label,
|
|
2905
|
+
kind: normalizeKind(item.kind),
|
|
2906
|
+
description: typeof item.description === "string" && item.description.trim() ? item.description.trim().slice(0, 240) : "Custom local log convention.",
|
|
2907
|
+
matchers,
|
|
2908
|
+
enabled: item.enabled !== false,
|
|
2909
|
+
builtIn: false
|
|
2910
|
+
};
|
|
2911
|
+
}).filter((profile) => profile.matchers.length > 0);
|
|
2912
|
+
return [...builtIns, ...customProfiles];
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
// src/lib/scan-schedule.ts
|
|
2916
|
+
var modes = /* @__PURE__ */ new Set(["manual", "on-open", "hourly", "daily"]);
|
|
2917
|
+
function normalizeScanSchedule(value) {
|
|
2918
|
+
const object = value && typeof value === "object" ? value : {};
|
|
2919
|
+
const mode = modes.has(object.mode) ? object.mode : "manual";
|
|
2920
|
+
const parsedRetention = typeof object.retentionRuns === "number" ? object.retentionRuns : typeof object.retentionRuns === "string" ? Number(object.retentionRuns) : 30;
|
|
2921
|
+
return {
|
|
2922
|
+
mode,
|
|
2923
|
+
retentionRuns: Number.isFinite(parsedRetention) && parsedRetention > 0 ? Math.min(500, Math.round(parsedRetention)) : 30,
|
|
2924
|
+
lastScheduledScanAt: typeof object.lastScheduledScanAt === "string" ? object.lastScheduledScanAt : null,
|
|
2925
|
+
lastScheduledScanStatus: object.lastScheduledScanStatus === "success" || object.lastScheduledScanStatus === "warning" || object.lastScheduledScanStatus === "failed" ? object.lastScheduledScanStatus : null,
|
|
2926
|
+
lastScheduledScanMessage: typeof object.lastScheduledScanMessage === "string" ? object.lastScheduledScanMessage : null
|
|
2927
|
+
};
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
// src/db/settings.ts
|
|
2931
|
+
var defaultSettings = {
|
|
2932
|
+
customFolders: [],
|
|
2933
|
+
storeRawMessageContent: false,
|
|
2934
|
+
usageGuardrails: {
|
|
2935
|
+
monthlyCostLimitUsd: null,
|
|
2936
|
+
monthlyTokenLimit: null,
|
|
2937
|
+
scoped: []
|
|
2938
|
+
},
|
|
2939
|
+
importProfiles: normalizeImportProfiles(null),
|
|
2940
|
+
scanSchedule: normalizeScanSchedule(null)
|
|
2941
|
+
};
|
|
2942
|
+
function positiveLimit(value) {
|
|
2943
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
|
2944
|
+
if (typeof value === "string" && value.trim()) {
|
|
2945
|
+
const parsed = Number(value);
|
|
2946
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
2947
|
+
}
|
|
2948
|
+
return null;
|
|
2949
|
+
}
|
|
2950
|
+
function normalizeUsageGuardrails(value) {
|
|
2951
|
+
if (!value || typeof value !== "object") return defaultSettings.usageGuardrails;
|
|
2952
|
+
const candidate = value;
|
|
2953
|
+
return {
|
|
2954
|
+
monthlyCostLimitUsd: positiveLimit(candidate.monthlyCostLimitUsd),
|
|
2955
|
+
monthlyTokenLimit: positiveLimit(candidate.monthlyTokenLimit),
|
|
2956
|
+
scoped: normalizeScopedUsageGuardrails(candidate.scoped)
|
|
2957
|
+
};
|
|
2958
|
+
}
|
|
2959
|
+
function normalizeScope(value) {
|
|
2960
|
+
if (value === "project" || value === "model" || value === "tool") return value;
|
|
2961
|
+
return null;
|
|
2962
|
+
}
|
|
2963
|
+
function normalizeWarningThreshold(value) {
|
|
2964
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : 0.8;
|
|
2965
|
+
if (!Number.isFinite(parsed)) return 0.8;
|
|
2966
|
+
return Math.min(0.99, Math.max(0.1, parsed));
|
|
2967
|
+
}
|
|
2968
|
+
function normalizeScopedUsageGuardrails(value) {
|
|
2969
|
+
if (!Array.isArray(value)) return [];
|
|
2970
|
+
return value.filter((item) => Boolean(item) && typeof item === "object").map((item, index2) => {
|
|
2971
|
+
const scope = normalizeScope(item.scope);
|
|
2972
|
+
const name = typeof item.name === "string" ? item.name.trim() : "";
|
|
2973
|
+
if (!scope || !name) return null;
|
|
2974
|
+
const id = typeof item.id === "string" && item.id.trim() ? item.id.trim() : `${scope}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || index2}`;
|
|
2975
|
+
return {
|
|
2976
|
+
id,
|
|
2977
|
+
scope,
|
|
2978
|
+
name: name.slice(0, 120),
|
|
2979
|
+
monthlyCostLimitUsd: positiveLimit(item.monthlyCostLimitUsd),
|
|
2980
|
+
monthlyTokenLimit: positiveLimit(item.monthlyTokenLimit),
|
|
2981
|
+
warningThreshold: normalizeWarningThreshold(item.warningThreshold)
|
|
2982
|
+
};
|
|
2983
|
+
}).filter((item) => Boolean(item)).slice(0, 100);
|
|
2984
|
+
}
|
|
2985
|
+
function normalizeSettings(value) {
|
|
2986
|
+
if (!value || typeof value !== "object") return defaultSettings;
|
|
2987
|
+
const candidate = value;
|
|
2988
|
+
return {
|
|
2989
|
+
customFolders: Array.isArray(candidate.customFolders) ? candidate.customFolders.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [],
|
|
2990
|
+
storeRawMessageContent: candidate.storeRawMessageContent === true,
|
|
2991
|
+
usageGuardrails: normalizeUsageGuardrails(candidate.usageGuardrails),
|
|
2992
|
+
importProfiles: normalizeImportProfiles(candidate.importProfiles),
|
|
2993
|
+
scanSchedule: normalizeScanSchedule(candidate.scanSchedule)
|
|
2994
|
+
};
|
|
2995
|
+
}
|
|
2996
|
+
function getAppSettings() {
|
|
2997
|
+
const row = db.select().from(settings).where(eq(settings.key, "app")).get();
|
|
2998
|
+
return normalizeSettings(row?.value);
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
// src/lib/usage-guardrails.ts
|
|
3002
|
+
function number3(value) {
|
|
3003
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
3004
|
+
}
|
|
3005
|
+
function monthWindow(now) {
|
|
3006
|
+
const from = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
3007
|
+
const to = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
3008
|
+
return {
|
|
3009
|
+
from: from.getTime(),
|
|
3010
|
+
to: to.getTime()
|
|
3011
|
+
};
|
|
3012
|
+
}
|
|
3013
|
+
function monthLabel(now) {
|
|
3014
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
3015
|
+
month: "long",
|
|
3016
|
+
year: "numeric"
|
|
3017
|
+
}).format(now);
|
|
3018
|
+
}
|
|
3019
|
+
function metric(used, limit, warningThreshold = 0.8) {
|
|
3020
|
+
if (limit == null || limit <= 0) {
|
|
3021
|
+
return {
|
|
3022
|
+
configured: false,
|
|
3023
|
+
used,
|
|
3024
|
+
limit: null,
|
|
3025
|
+
percent: 0,
|
|
3026
|
+
remaining: null,
|
|
3027
|
+
status: "not-configured"
|
|
3028
|
+
};
|
|
3029
|
+
}
|
|
3030
|
+
const percent = used / limit;
|
|
3031
|
+
return {
|
|
3032
|
+
configured: true,
|
|
3033
|
+
used,
|
|
3034
|
+
limit,
|
|
3035
|
+
percent,
|
|
3036
|
+
remaining: Math.max(0, limit - used),
|
|
3037
|
+
status: percent >= 1 ? "exceeded" : percent >= warningThreshold ? "warning" : "ok"
|
|
3038
|
+
};
|
|
3039
|
+
}
|
|
3040
|
+
function scopedProgress(guardrails, usage2) {
|
|
3041
|
+
const scopedUsage = usage2.scoped ?? [];
|
|
3042
|
+
return (guardrails.scoped ?? []).map((guardrail) => {
|
|
3043
|
+
const current = scopedUsage.find(
|
|
3044
|
+
(item) => item.scope === guardrail.scope && item.name.toLowerCase() === guardrail.name.toLowerCase()
|
|
3045
|
+
) ?? { scope: guardrail.scope, name: guardrail.name, cost: 0, tokens: 0 };
|
|
3046
|
+
return {
|
|
3047
|
+
id: guardrail.id,
|
|
3048
|
+
scope: guardrail.scope,
|
|
3049
|
+
name: guardrail.name,
|
|
3050
|
+
warningThreshold: guardrail.warningThreshold,
|
|
3051
|
+
cost: metric(current.cost, guardrail.monthlyCostLimitUsd, guardrail.warningThreshold),
|
|
3052
|
+
tokens: metric(current.tokens, guardrail.monthlyTokenLimit, guardrail.warningThreshold)
|
|
3053
|
+
};
|
|
3054
|
+
});
|
|
3055
|
+
}
|
|
3056
|
+
function anomalies(scoped) {
|
|
3057
|
+
return scoped.flatMap((item) => {
|
|
3058
|
+
const findings = [];
|
|
3059
|
+
if (item.cost.status === "exceeded" || item.tokens.status === "exceeded") {
|
|
3060
|
+
findings.push({
|
|
3061
|
+
id: `${item.id}-exceeded`,
|
|
3062
|
+
severity: "blocked",
|
|
3063
|
+
message: `${item.scope} ${item.name} exceeded a local monthly guardrail.`
|
|
3064
|
+
});
|
|
3065
|
+
} else if (item.cost.status === "warning" || item.tokens.status === "warning") {
|
|
3066
|
+
findings.push({
|
|
3067
|
+
id: `${item.id}-warning`,
|
|
3068
|
+
severity: "warning",
|
|
3069
|
+
message: `${item.scope} ${item.name} is near a local monthly guardrail.`
|
|
3070
|
+
});
|
|
3071
|
+
}
|
|
3072
|
+
return findings;
|
|
3073
|
+
});
|
|
3074
|
+
}
|
|
3075
|
+
function buildUsageGuardrailProgress({
|
|
3076
|
+
guardrails,
|
|
3077
|
+
usage: usage2,
|
|
3078
|
+
now = /* @__PURE__ */ new Date()
|
|
3079
|
+
}) {
|
|
3080
|
+
const scoped = scopedProgress(guardrails, usage2);
|
|
3081
|
+
return {
|
|
3082
|
+
monthLabel: monthLabel(now),
|
|
3083
|
+
window: monthWindow(now),
|
|
3084
|
+
cost: metric(usage2.cost, guardrails.monthlyCostLimitUsd),
|
|
3085
|
+
tokens: metric(usage2.tokens, guardrails.monthlyTokenLimit),
|
|
3086
|
+
scoped,
|
|
3087
|
+
anomalies: anomalies(scoped)
|
|
3088
|
+
};
|
|
3089
|
+
}
|
|
3090
|
+
function getCurrentMonthUsage(now) {
|
|
3091
|
+
const window = monthWindow(now);
|
|
3092
|
+
const row = sqlite.prepare(
|
|
3093
|
+
`SELECT
|
|
3094
|
+
COALESCE(SUM(total_tokens), 0) AS tokens,
|
|
3095
|
+
COALESCE(SUM(cost), 0) AS cost
|
|
3096
|
+
FROM interactions
|
|
3097
|
+
WHERE timestamp >= ?
|
|
3098
|
+
AND timestamp < ?`
|
|
3099
|
+
).get(window.from, window.to);
|
|
3100
|
+
const scopedRows = sqlite.prepare(
|
|
3101
|
+
`SELECT 'project' AS scope, COALESCE(p.name, 'Unassigned') AS name,
|
|
3102
|
+
COALESCE(SUM(i.total_tokens), 0) AS tokens,
|
|
3103
|
+
COALESCE(SUM(i.cost), 0) AS cost
|
|
3104
|
+
FROM interactions i
|
|
3105
|
+
JOIN sessions s ON s.id = i.session_id
|
|
3106
|
+
LEFT JOIN projects p ON p.id = s.project_id
|
|
3107
|
+
WHERE i.timestamp >= ? AND i.timestamp < ?
|
|
3108
|
+
GROUP BY COALESCE(p.name, 'Unassigned')
|
|
3109
|
+
UNION ALL
|
|
3110
|
+
SELECT 'tool' AS scope, t.name AS name,
|
|
3111
|
+
COALESCE(SUM(i.total_tokens), 0) AS tokens,
|
|
3112
|
+
COALESCE(SUM(i.cost), 0) AS cost
|
|
3113
|
+
FROM interactions i
|
|
3114
|
+
JOIN sessions s ON s.id = i.session_id
|
|
3115
|
+
JOIN tools t ON t.id = s.tool_id
|
|
3116
|
+
WHERE i.timestamp >= ? AND i.timestamp < ?
|
|
3117
|
+
GROUP BY t.name
|
|
3118
|
+
UNION ALL
|
|
3119
|
+
SELECT 'model' AS scope, COALESCE(m.name, 'unknown') AS name,
|
|
3120
|
+
COALESCE(SUM(i.total_tokens), 0) AS tokens,
|
|
3121
|
+
COALESCE(SUM(i.cost), 0) AS cost
|
|
3122
|
+
FROM interactions i
|
|
3123
|
+
LEFT JOIN models m ON m.id = i.model_id
|
|
3124
|
+
WHERE i.timestamp >= ? AND i.timestamp < ?
|
|
3125
|
+
GROUP BY COALESCE(m.name, 'unknown')`
|
|
3126
|
+
).all(window.from, window.to, window.from, window.to, window.from, window.to);
|
|
3127
|
+
return {
|
|
3128
|
+
tokens: number3(row?.tokens),
|
|
3129
|
+
cost: number3(row?.cost),
|
|
3130
|
+
scoped: scopedRows.map((item) => ({
|
|
3131
|
+
scope: item.scope,
|
|
3132
|
+
name: item.name,
|
|
3133
|
+
tokens: number3(item.tokens),
|
|
3134
|
+
cost: number3(item.cost)
|
|
3135
|
+
}))
|
|
3136
|
+
};
|
|
3137
|
+
}
|
|
3138
|
+
function getUsageGuardrailProgress(now = /* @__PURE__ */ new Date()) {
|
|
3139
|
+
return buildUsageGuardrailProgress({
|
|
3140
|
+
guardrails: getAppSettings().usageGuardrails,
|
|
3141
|
+
usage: getCurrentMonthUsage(now),
|
|
3142
|
+
now
|
|
3143
|
+
});
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
// src/lib/analytics.ts
|
|
3147
|
+
function getAnalyticsData(filters = {}, options = {}) {
|
|
3148
|
+
const overviewOnly = options.analyticsProfile === "overview";
|
|
3149
|
+
const summary = timeAnalyticsQuery("analytics.summary", () => getSummary(filters));
|
|
3150
|
+
const scanTrust = getScanTrustData(filters, options);
|
|
3151
|
+
const dataConfidence = buildDataConfidenceScore({
|
|
3152
|
+
totalInteractions: scanTrust.confidence.interactions,
|
|
3153
|
+
exactTokenInteractions: scanTrust.confidence.exactTokenInteractions,
|
|
3154
|
+
tokenizerEstimateInteractions: scanTrust.confidence.tokenizerEstimateInteractions ?? 0,
|
|
3155
|
+
simpleEstimateInteractions: (scanTrust.confidence.simpleEstimateInteractions ?? 0) + scanTrust.confidence.highConfidenceTokenInteractions + scanTrust.confidence.lowConfidenceTokenInteractions,
|
|
3156
|
+
unknownTokenInteractions: scanTrust.confidence.unknownTokenInteractions,
|
|
3157
|
+
pricedCostInteractions: scanTrust.confidence.exactCostInteractions + scanTrust.confidence.estimatedCostInteractions,
|
|
3158
|
+
unknownCostInteractions: scanTrust.confidence.unknownCostInteractions,
|
|
3159
|
+
parserConfidence: null,
|
|
3160
|
+
scanFreshness: scanTrust.health.freshness.state
|
|
3161
|
+
});
|
|
3162
|
+
const evidenceLinks = {
|
|
3163
|
+
"processed-tokens": evidenceHref("processed-tokens"),
|
|
3164
|
+
"non-cache-tokens": evidenceHref("non-cache-tokens"),
|
|
3165
|
+
"cached-tokens": evidenceHref("cached-tokens"),
|
|
3166
|
+
"estimated-cost": evidenceHref("estimated-cost"),
|
|
3167
|
+
sessions: evidenceHref("sessions"),
|
|
3168
|
+
"unknown-cost": evidenceHref("unknown-cost"),
|
|
3169
|
+
guardrails: evidenceHref("guardrails"),
|
|
3170
|
+
"review-queue": evidenceHref("review-queue")
|
|
3171
|
+
};
|
|
3172
|
+
const comparison = timeAnalyticsQuery("analytics.comparison", () => getUsageComparison(filters));
|
|
3173
|
+
const usageGuardrails = timeAnalyticsQuery("analytics.guardrails", () => getUsageGuardrailProgress());
|
|
3174
|
+
const trends = timeAnalyticsQuery("analytics.trends", () => getTrends(filters));
|
|
3175
|
+
const tools2 = timeAnalyticsQuery("analytics.tools", () => getToolComparison(filters));
|
|
3176
|
+
const models2 = overviewOnly ? [] : timeAnalyticsQuery("analytics.models", () => getModelRows(filters));
|
|
3177
|
+
const projects2 = timeAnalyticsQuery("analytics.projects", () => getProjectRows(filters));
|
|
3178
|
+
const sessions2 = timeAnalyticsQuery("analytics.sessions", () => getSessions(filters, options.sessionDetail ?? "full"));
|
|
3179
|
+
const unknownCosts = timeAnalyticsQuery("analytics.unknownCosts", () => getUnknownCostQueue(filters));
|
|
3180
|
+
const modelAliasSuggestions = overviewOnly ? [] : timeAnalyticsQuery("analytics.modelAliases", () => getModelAliasSuggestions(filters));
|
|
3181
|
+
const sessionComparisons = overviewOnly ? [] : timeAnalyticsQuery("analytics.sessionComparisons", () => buildSessionComparisons(sessions2));
|
|
3182
|
+
const projectSignals = overviewOnly ? [] : timeAnalyticsQuery("analytics.projectSignals", () => buildProjectSignals({
|
|
3183
|
+
totalTokens: summary.totalTokens,
|
|
3184
|
+
projects: projects2,
|
|
3185
|
+
sessions: sessions2
|
|
3186
|
+
}));
|
|
3187
|
+
const recommendations = timeAnalyticsQuery("analytics.recommendations", () => buildLocalRecommendations({
|
|
3188
|
+
summary,
|
|
3189
|
+
tools: tools2,
|
|
3190
|
+
projects: projects2,
|
|
3191
|
+
unknownCosts,
|
|
3192
|
+
guardrails: usageGuardrails,
|
|
3193
|
+
scan: getLatestScanRecommendationStats()
|
|
3194
|
+
}));
|
|
3195
|
+
const reviewQueue = overviewOnly ? [] : timeAnalyticsQuery("analytics.reviewQueue", () => buildReviewQueue({
|
|
3196
|
+
summary,
|
|
3197
|
+
guardrails: usageGuardrails,
|
|
3198
|
+
unknownCosts,
|
|
3199
|
+
sessions: sessions2,
|
|
3200
|
+
projects: projects2,
|
|
3201
|
+
models: models2,
|
|
3202
|
+
tools: tools2
|
|
3203
|
+
}));
|
|
3204
|
+
const insights = overviewOnly ? [] : timeAnalyticsQuery("analytics.insights", () => buildInsights({ summary, trends, models: models2, projects: projects2, sessions: sessions2 }));
|
|
3205
|
+
return {
|
|
3206
|
+
summary,
|
|
3207
|
+
scanTrust,
|
|
3208
|
+
dataConfidence,
|
|
3209
|
+
evidenceLinks,
|
|
3210
|
+
comparison,
|
|
3211
|
+
trends,
|
|
3212
|
+
tools: tools2,
|
|
3213
|
+
models: models2,
|
|
3214
|
+
projects: projects2,
|
|
3215
|
+
sessions: sessions2,
|
|
3216
|
+
unknownCosts,
|
|
3217
|
+
modelAliasSuggestions,
|
|
3218
|
+
usageGuardrails,
|
|
3219
|
+
reviewQueue,
|
|
3220
|
+
sessionComparisons,
|
|
3221
|
+
projectSignals,
|
|
3222
|
+
recommendations,
|
|
3223
|
+
insights
|
|
3224
|
+
};
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
// src/lib/evidence-pack.ts
|
|
3228
|
+
function buildEvidencePack(input) {
|
|
3229
|
+
return {
|
|
3230
|
+
schemaVersion: "tokentrace.evidence-pack.v1",
|
|
3231
|
+
generatedAt: input.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
3232
|
+
scope: input.scope,
|
|
3233
|
+
totals: input.totals,
|
|
3234
|
+
confidenceDrivers: [...input.confidenceDrivers].sort(),
|
|
3235
|
+
sourceFiles: [...new Set(input.sourceFiles)].sort(),
|
|
3236
|
+
parserNotes: [...input.parserNotes].sort(),
|
|
3237
|
+
modelRateState: [...input.modelRateState].sort(),
|
|
3238
|
+
repairLinks: [...new Set(input.repairLinks)].sort(),
|
|
3239
|
+
redaction: {
|
|
3240
|
+
rawContentIncluded: false,
|
|
3241
|
+
rawContentPolicy: "excluded by default",
|
|
3242
|
+
excludedFields: ["rawText", "rawTextPreview", "content", "prompt", "completion", "message"]
|
|
3243
|
+
},
|
|
3244
|
+
records: input.records.map((record) => {
|
|
3245
|
+
const sanitized = { ...record };
|
|
3246
|
+
delete sanitized.rawText;
|
|
3247
|
+
delete sanitized.rawTextPreview;
|
|
3248
|
+
return sanitized;
|
|
3249
|
+
})
|
|
3250
|
+
};
|
|
3251
|
+
}
|
|
3252
|
+
function buildMetricEvidencePack(input) {
|
|
3253
|
+
const trail = buildEvidenceTrail({ metric: input.metric });
|
|
3254
|
+
const analytics = getAnalyticsData();
|
|
3255
|
+
return buildEvidencePack({
|
|
3256
|
+
generatedAt: input.generatedAt,
|
|
3257
|
+
scope: {
|
|
3258
|
+
type: "metric",
|
|
3259
|
+
id: input.metric,
|
|
3260
|
+
label: trail.title
|
|
3261
|
+
},
|
|
3262
|
+
totals: trail.totals,
|
|
3263
|
+
confidenceDrivers: [
|
|
3264
|
+
`${trail.confidence.exact.toLocaleString()} exact interactions`,
|
|
3265
|
+
`${trail.confidence.estimated.toLocaleString()} estimated interactions`,
|
|
3266
|
+
`${trail.confidence.unknown.toLocaleString()} unknown interactions`,
|
|
3267
|
+
`Data confidence ${analytics.dataConfidence.score}/100`
|
|
3268
|
+
],
|
|
3269
|
+
sourceFiles: trail.sourceFiles.map((source) => source.sourceFile),
|
|
3270
|
+
parserNotes: trail.sessions.map((session) => `${session.parser ?? "unknown parser"}: ${session.parserStatus ?? "unknown status"}`).slice(0, 20),
|
|
3271
|
+
modelRateState: trail.sessions.map(
|
|
3272
|
+
(session) => session.pricingHref ? `${session.model}: model-rate link available` : `${session.model}: no model-rate link`
|
|
3273
|
+
).slice(0, 20),
|
|
3274
|
+
repairLinks: trail.sessions.filter((session) => session.unknownCostInteractions > 0).map((session) => `/repair?source=${encodeURIComponent(session.sourceFile)}`),
|
|
3275
|
+
records: trail.sessions.map((session) => ({
|
|
3276
|
+
id: session.id,
|
|
3277
|
+
role: "session",
|
|
3278
|
+
model: session.model,
|
|
3279
|
+
sourceFile: session.sourceFile,
|
|
3280
|
+
totalTokens: session.totalTokens,
|
|
3281
|
+
cost: session.cost,
|
|
3282
|
+
interactions: session.interactions
|
|
3283
|
+
}))
|
|
3284
|
+
});
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
// src/lib/mcp/types.ts
|
|
3288
|
+
var registryName = "io.github.abhiyoheswaran1/tokentrace";
|
|
3289
|
+
|
|
3290
|
+
// src/lib/chatgpt-app/prototype.ts
|
|
3291
|
+
var CHATGPT_APP_TOOL_NAME = "get_redacted_evidence_pack";
|
|
3292
|
+
var CHATGPT_APP_WIDGET_URI = "ui://tokentrace/evidence-pack.html";
|
|
3293
|
+
var metricEnumValues = evidenceMetrics;
|
|
3294
|
+
var metricInputSchema = {
|
|
3295
|
+
metric: z.enum(metricEnumValues).optional()
|
|
3296
|
+
};
|
|
3297
|
+
var evidencePackOutputSchema = {
|
|
3298
|
+
summary: z.string(),
|
|
3299
|
+
pack: z.object({
|
|
3300
|
+
schemaVersion: z.literal("tokentrace.evidence-pack.v1")
|
|
3301
|
+
}).passthrough()
|
|
3302
|
+
};
|
|
3303
|
+
var jsonMetricInputSchema = {
|
|
3304
|
+
type: "object",
|
|
3305
|
+
additionalProperties: false,
|
|
3306
|
+
properties: {
|
|
3307
|
+
metric: {
|
|
3308
|
+
type: "string",
|
|
3309
|
+
enum: evidenceMetrics,
|
|
3310
|
+
description: "Evidence metric to export. Defaults to processed-tokens."
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
};
|
|
3314
|
+
var jsonEvidencePackOutputSchema = {
|
|
3315
|
+
type: "object",
|
|
3316
|
+
additionalProperties: false,
|
|
3317
|
+
properties: {
|
|
3318
|
+
summary: { type: "string" },
|
|
3319
|
+
pack: {
|
|
3320
|
+
type: "object",
|
|
3321
|
+
additionalProperties: true,
|
|
3322
|
+
properties: {
|
|
3323
|
+
schemaVersion: { const: "tokentrace.evidence-pack.v1" },
|
|
3324
|
+
redaction: {
|
|
3325
|
+
type: "object",
|
|
3326
|
+
properties: {
|
|
3327
|
+
rawContentIncluded: { const: false },
|
|
3328
|
+
rawContentPolicy: { const: "excluded by default" }
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
},
|
|
3332
|
+
required: ["schemaVersion", "redaction"]
|
|
3333
|
+
}
|
|
3334
|
+
},
|
|
3335
|
+
required: ["summary", "pack"]
|
|
3336
|
+
};
|
|
3337
|
+
function chatGptAppToolDescriptors() {
|
|
3338
|
+
return [
|
|
3339
|
+
{
|
|
3340
|
+
name: CHATGPT_APP_TOOL_NAME,
|
|
3341
|
+
title: "Get redacted evidence pack",
|
|
3342
|
+
description: "Return a selected TokenTrace evidence pack for ChatGPT review. The pack is read-only and excludes raw prompts, completions, and message bodies.",
|
|
3343
|
+
inputSchema: jsonMetricInputSchema,
|
|
3344
|
+
outputSchema: jsonEvidencePackOutputSchema,
|
|
3345
|
+
annotations: {
|
|
3346
|
+
readOnlyHint: true,
|
|
3347
|
+
openWorldHint: false,
|
|
3348
|
+
destructiveHint: false
|
|
3349
|
+
},
|
|
3350
|
+
_meta: {
|
|
3351
|
+
ui: { resourceUri: CHATGPT_APP_WIDGET_URI },
|
|
3352
|
+
"openai/outputTemplate": CHATGPT_APP_WIDGET_URI,
|
|
3353
|
+
"openai/toolInvocation/invoking": "Preparing redacted evidence pack...",
|
|
3354
|
+
"openai/toolInvocation/invoked": "Redacted evidence pack ready."
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
];
|
|
3358
|
+
}
|
|
3359
|
+
function chatGptAppWidgetResource() {
|
|
3360
|
+
return {
|
|
3361
|
+
uri: CHATGPT_APP_WIDGET_URI,
|
|
3362
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
3363
|
+
text: widgetHtml(),
|
|
3364
|
+
_meta: {
|
|
3365
|
+
ui: {
|
|
3366
|
+
csp: {
|
|
3367
|
+
connectDomains: [],
|
|
3368
|
+
resourceDomains: []
|
|
3369
|
+
}
|
|
3370
|
+
},
|
|
3371
|
+
"openai/widgetDescription": "Shows a redacted TokenTrace evidence pack summary with totals and confidence drivers."
|
|
3372
|
+
}
|
|
3373
|
+
};
|
|
3374
|
+
}
|
|
3375
|
+
async function buildChatGptEvidencePackToolResult(args2 = {}) {
|
|
3376
|
+
const metric2 = args2.metric ?? "processed-tokens";
|
|
3377
|
+
const pack = buildMetricEvidencePack({ metric: metric2 });
|
|
3378
|
+
const summary = [
|
|
3379
|
+
`Redacted TokenTrace evidence pack for ${pack.scope.label}.`,
|
|
3380
|
+
`Raw content included: ${pack.redaction.rawContentIncluded ? "yes" : "no"}.`,
|
|
3381
|
+
`Records: ${pack.records.length.toLocaleString()}; source files: ${pack.sourceFiles.length.toLocaleString()}.`
|
|
3382
|
+
].join(" ");
|
|
3383
|
+
return {
|
|
3384
|
+
structuredContent: {
|
|
3385
|
+
summary,
|
|
3386
|
+
pack
|
|
3387
|
+
},
|
|
3388
|
+
content: [
|
|
3389
|
+
{
|
|
3390
|
+
type: "text",
|
|
3391
|
+
text: `Prepared a redacted evidence pack for ${pack.scope.label}. Raw prompts and message bodies are excluded.`
|
|
3392
|
+
}
|
|
3393
|
+
],
|
|
3394
|
+
_meta: {
|
|
3395
|
+
widgetMode: "evidence-pack-summary",
|
|
3396
|
+
rawContentIncluded: false,
|
|
3397
|
+
redactionPolicy: pack.redaction.rawContentPolicy,
|
|
3398
|
+
sourceFileCount: pack.sourceFiles.length,
|
|
3399
|
+
recordCount: pack.records.length
|
|
3400
|
+
}
|
|
3401
|
+
};
|
|
3402
|
+
}
|
|
3403
|
+
function createTokenTraceChatGptAppServer() {
|
|
3404
|
+
const [descriptor] = chatGptAppToolDescriptors();
|
|
3405
|
+
if (!descriptor) throw new Error("TokenTrace ChatGPT app tool descriptor is missing.");
|
|
3406
|
+
const server = new McpServer(
|
|
3407
|
+
{
|
|
3408
|
+
name: "tokentrace-chatgpt-app",
|
|
3409
|
+
version: "0.1.0"
|
|
3410
|
+
},
|
|
3411
|
+
{
|
|
3412
|
+
instructions: "TokenTrace is local-first. Use get_redacted_evidence_pack only for selected, redacted evidence exports; do not request raw prompts or local message bodies."
|
|
3413
|
+
}
|
|
3414
|
+
);
|
|
3415
|
+
registerAppResource(
|
|
3416
|
+
server,
|
|
3417
|
+
"TokenTrace evidence pack widget",
|
|
3418
|
+
CHATGPT_APP_WIDGET_URI,
|
|
3419
|
+
{
|
|
3420
|
+
description: "Compact redacted evidence-pack summary for ChatGPT developer-mode testing.",
|
|
3421
|
+
_meta: chatGptAppWidgetResource()._meta
|
|
3422
|
+
},
|
|
3423
|
+
async () => ({
|
|
3424
|
+
contents: [chatGptAppWidgetResource()]
|
|
3425
|
+
})
|
|
3426
|
+
);
|
|
3427
|
+
registerAppTool(
|
|
3428
|
+
server,
|
|
3429
|
+
CHATGPT_APP_TOOL_NAME,
|
|
3430
|
+
{
|
|
3431
|
+
title: "Get redacted evidence pack",
|
|
3432
|
+
description: "Return a selected TokenTrace evidence pack for ChatGPT review. The pack is read-only and excludes raw prompts, completions, and message bodies.",
|
|
3433
|
+
inputSchema: metricInputSchema,
|
|
3434
|
+
outputSchema: evidencePackOutputSchema,
|
|
3435
|
+
annotations: {
|
|
3436
|
+
readOnlyHint: true,
|
|
3437
|
+
openWorldHint: false,
|
|
3438
|
+
destructiveHint: false
|
|
3439
|
+
},
|
|
3440
|
+
_meta: descriptor._meta
|
|
3441
|
+
},
|
|
3442
|
+
async (args2) => buildChatGptEvidencePackToolResult(args2)
|
|
3443
|
+
);
|
|
3444
|
+
return server;
|
|
3445
|
+
}
|
|
3446
|
+
function check(id, detail, fn) {
|
|
3447
|
+
return Promise.resolve().then(fn).then(() => ({ id, ok: true, detail })).catch((error) => ({
|
|
3448
|
+
id,
|
|
3449
|
+
ok: false,
|
|
3450
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
3451
|
+
}));
|
|
3452
|
+
}
|
|
3453
|
+
async function runChatGptAppSelfTest() {
|
|
3454
|
+
const checks = [];
|
|
3455
|
+
checks.push(
|
|
3456
|
+
await check("tool-descriptor", "Tool descriptor advertises a read-only Apps SDK evidence-pack tool.", () => {
|
|
3457
|
+
const [tool] = chatGptAppToolDescriptors();
|
|
3458
|
+
if (tool?.name !== CHATGPT_APP_TOOL_NAME) throw new Error("missing get_redacted_evidence_pack tool");
|
|
3459
|
+
if (tool._meta.ui.resourceUri !== CHATGPT_APP_WIDGET_URI) throw new Error("tool is missing widget URI");
|
|
3460
|
+
if (tool.annotations.readOnlyHint !== true) throw new Error("tool is not marked read-only");
|
|
3461
|
+
if (tool.annotations.openWorldHint !== false) throw new Error("tool openWorldHint must be false");
|
|
3462
|
+
if (tool.annotations.destructiveHint !== false) throw new Error("tool destructiveHint must be false");
|
|
3463
|
+
})
|
|
3464
|
+
);
|
|
3465
|
+
checks.push(
|
|
3466
|
+
await check("widget-resource", "Widget resource is a text/html;profile=mcp-app document.", () => {
|
|
3467
|
+
const resource = chatGptAppWidgetResource();
|
|
3468
|
+
if (resource.mimeType !== RESOURCE_MIME_TYPE) throw new Error("widget resource has wrong MIME type");
|
|
3469
|
+
if (!resource.text.includes("ui/notifications/tool-result")) {
|
|
3470
|
+
throw new Error("widget does not listen for MCP Apps tool results");
|
|
3471
|
+
}
|
|
3472
|
+
})
|
|
3473
|
+
);
|
|
3474
|
+
checks.push(
|
|
3475
|
+
await check("tool-result", "Tool result returns a redacted evidence-pack payload.", async () => {
|
|
3476
|
+
const result = await buildChatGptEvidencePackToolResult();
|
|
3477
|
+
if (result.structuredContent.pack.schemaVersion !== "tokentrace.evidence-pack.v1") {
|
|
3478
|
+
throw new Error("tool result has the wrong evidence-pack schema");
|
|
3479
|
+
}
|
|
3480
|
+
if (result.structuredContent.pack.redaction.rawContentIncluded !== false) {
|
|
3481
|
+
throw new Error("tool result must not include raw content");
|
|
3482
|
+
}
|
|
3483
|
+
})
|
|
3484
|
+
);
|
|
3485
|
+
checks.push(
|
|
3486
|
+
await check("server-registration", "MCP server can be constructed with Apps SDK tool and resource registrations.", () => {
|
|
3487
|
+
const server = createTokenTraceChatGptAppServer();
|
|
3488
|
+
void server.close();
|
|
3489
|
+
})
|
|
3490
|
+
);
|
|
3491
|
+
return {
|
|
3492
|
+
ok: checks.every((entry) => entry.ok),
|
|
3493
|
+
registryName,
|
|
3494
|
+
app: "TokenTrace ChatGPT app prototype",
|
|
3495
|
+
mcpPath: "/mcp",
|
|
3496
|
+
widgetUri: CHATGPT_APP_WIDGET_URI,
|
|
3497
|
+
tools: chatGptAppToolDescriptors().map((tool) => tool.name),
|
|
3498
|
+
mutatedLocalState: false,
|
|
3499
|
+
checks
|
|
3500
|
+
};
|
|
3501
|
+
}
|
|
3502
|
+
function widgetHtml() {
|
|
3503
|
+
return `<!doctype html>
|
|
3504
|
+
<html lang="en">
|
|
3505
|
+
<head>
|
|
3506
|
+
<meta charset="utf-8" />
|
|
3507
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
3508
|
+
<title>TokenTrace Evidence Pack</title>
|
|
3509
|
+
<style>
|
|
3510
|
+
:root {
|
|
3511
|
+
color: #172026;
|
|
3512
|
+
background: #ffffff;
|
|
3513
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
3514
|
+
}
|
|
3515
|
+
* {
|
|
3516
|
+
box-sizing: border-box;
|
|
3517
|
+
}
|
|
3518
|
+
body {
|
|
3519
|
+
margin: 0;
|
|
3520
|
+
padding: 16px;
|
|
3521
|
+
color: #172026;
|
|
3522
|
+
background: #ffffff;
|
|
3523
|
+
}
|
|
3524
|
+
main {
|
|
3525
|
+
display: grid;
|
|
3526
|
+
gap: 14px;
|
|
3527
|
+
max-width: 680px;
|
|
3528
|
+
}
|
|
3529
|
+
h1, h2, p {
|
|
3530
|
+
margin: 0;
|
|
3531
|
+
}
|
|
3532
|
+
h1 {
|
|
3533
|
+
font-size: 1rem;
|
|
3534
|
+
font-weight: 680;
|
|
3535
|
+
}
|
|
3536
|
+
h2 {
|
|
3537
|
+
font-size: 0.78rem;
|
|
3538
|
+
font-weight: 680;
|
|
3539
|
+
color: #41515c;
|
|
3540
|
+
text-transform: uppercase;
|
|
3541
|
+
}
|
|
3542
|
+
.summary {
|
|
3543
|
+
display: grid;
|
|
3544
|
+
gap: 6px;
|
|
3545
|
+
}
|
|
3546
|
+
.summary p {
|
|
3547
|
+
color: #52616b;
|
|
3548
|
+
font-size: 0.9rem;
|
|
3549
|
+
line-height: 1.45;
|
|
3550
|
+
}
|
|
3551
|
+
.grid {
|
|
3552
|
+
display: grid;
|
|
3553
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
3554
|
+
gap: 8px;
|
|
3555
|
+
}
|
|
3556
|
+
.metric {
|
|
3557
|
+
border: 1px solid #d9e0e6;
|
|
3558
|
+
border-radius: 8px;
|
|
3559
|
+
padding: 10px;
|
|
3560
|
+
min-width: 0;
|
|
3561
|
+
}
|
|
3562
|
+
.metric span {
|
|
3563
|
+
display: block;
|
|
3564
|
+
color: #5d6c76;
|
|
3565
|
+
font-size: 0.74rem;
|
|
3566
|
+
}
|
|
3567
|
+
.metric strong {
|
|
3568
|
+
display: block;
|
|
3569
|
+
margin-top: 4px;
|
|
3570
|
+
font-size: 1rem;
|
|
3571
|
+
font-weight: 700;
|
|
3572
|
+
}
|
|
3573
|
+
ul {
|
|
3574
|
+
margin: 0;
|
|
3575
|
+
padding-left: 18px;
|
|
3576
|
+
color: #36464f;
|
|
3577
|
+
font-size: 0.86rem;
|
|
3578
|
+
line-height: 1.45;
|
|
3579
|
+
}
|
|
3580
|
+
.empty {
|
|
3581
|
+
color: #6d7a83;
|
|
3582
|
+
font-size: 0.86rem;
|
|
3583
|
+
}
|
|
3584
|
+
.badge {
|
|
3585
|
+
width: fit-content;
|
|
3586
|
+
border: 1px solid #bfd8c5;
|
|
3587
|
+
border-radius: 999px;
|
|
3588
|
+
padding: 3px 8px;
|
|
3589
|
+
color: #1f6b3b;
|
|
3590
|
+
background: #f3fbf5;
|
|
3591
|
+
font-size: 0.76rem;
|
|
3592
|
+
font-weight: 620;
|
|
3593
|
+
}
|
|
3594
|
+
@media (max-width: 440px) {
|
|
3595
|
+
body {
|
|
3596
|
+
padding: 12px;
|
|
3597
|
+
}
|
|
3598
|
+
.grid {
|
|
3599
|
+
grid-template-columns: 1fr;
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3602
|
+
</style>
|
|
3603
|
+
</head>
|
|
3604
|
+
<body>
|
|
3605
|
+
<main>
|
|
3606
|
+
<section class="summary">
|
|
3607
|
+
<span class="badge" id="redaction">rawContentIncluded: false</span>
|
|
3608
|
+
<h1 id="title">TokenTrace evidence pack</h1>
|
|
3609
|
+
<p id="summary">Waiting for a redacted evidence pack.</p>
|
|
3610
|
+
</section>
|
|
3611
|
+
<section class="grid" aria-label="Evidence pack totals">
|
|
3612
|
+
<div class="metric"><span>Tokens</span><strong id="tokens">0</strong></div>
|
|
3613
|
+
<div class="metric"><span>Cost</span><strong id="cost">$0.00</strong></div>
|
|
3614
|
+
<div class="metric"><span>Sessions</span><strong id="sessions">0</strong></div>
|
|
3615
|
+
<div class="metric"><span>Interactions</span><strong id="interactions">0</strong></div>
|
|
3616
|
+
</section>
|
|
3617
|
+
<section>
|
|
3618
|
+
<h2>Confidence drivers</h2>
|
|
3619
|
+
<ul id="drivers"><li class="empty">No evidence loaded yet.</li></ul>
|
|
3620
|
+
</section>
|
|
3621
|
+
<section>
|
|
3622
|
+
<h2>Source files</h2>
|
|
3623
|
+
<p class="empty" id="sources">No source-file metadata loaded yet.</p>
|
|
3624
|
+
</section>
|
|
3625
|
+
</main>
|
|
3626
|
+
<script>
|
|
3627
|
+
const state = { pack: window.openai?.toolOutput?.pack ?? null, summary: window.openai?.toolOutput?.summary ?? "" };
|
|
3628
|
+
const nf = new Intl.NumberFormat();
|
|
3629
|
+
const usd = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD" });
|
|
3630
|
+
|
|
3631
|
+
function setText(id, value) {
|
|
3632
|
+
document.getElementById(id).textContent = value;
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
function renderDrivers(items) {
|
|
3636
|
+
const list = document.getElementById("drivers");
|
|
3637
|
+
list.textContent = "";
|
|
3638
|
+
if (!items || items.length === 0) {
|
|
3639
|
+
const item = document.createElement("li");
|
|
3640
|
+
item.className = "empty";
|
|
3641
|
+
item.textContent = "No confidence drivers reported.";
|
|
3642
|
+
list.appendChild(item);
|
|
3643
|
+
return;
|
|
3644
|
+
}
|
|
3645
|
+
for (const value of items.slice(0, 6)) {
|
|
3646
|
+
const item = document.createElement("li");
|
|
3647
|
+
item.textContent = value;
|
|
3648
|
+
list.appendChild(item);
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
function render() {
|
|
3653
|
+
const pack = state.pack;
|
|
3654
|
+
if (!pack) return;
|
|
3655
|
+
setText("title", pack.scope?.label ? "Evidence: " + pack.scope.label : "TokenTrace evidence pack");
|
|
3656
|
+
setText("summary", state.summary || "Redacted evidence pack loaded.");
|
|
3657
|
+
setText("tokens", nf.format(pack.totals?.tokens ?? 0));
|
|
3658
|
+
setText("cost", usd.format(pack.totals?.cost ?? 0));
|
|
3659
|
+
setText("sessions", nf.format(pack.totals?.sessions ?? 0));
|
|
3660
|
+
setText("interactions", nf.format(pack.totals?.interactions ?? 0));
|
|
3661
|
+
setText("redaction", "rawContentIncluded: " + String(pack.redaction?.rawContentIncluded === true));
|
|
3662
|
+
renderDrivers(pack.confidenceDrivers);
|
|
3663
|
+
setText("sources", nf.format(pack.sourceFiles?.length ?? 0) + " source file(s); " + nf.format(pack.records?.length ?? 0) + " record(s).");
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
window.addEventListener("message", (event) => {
|
|
3667
|
+
if (event.source !== window.parent) return;
|
|
3668
|
+
const message = event.data;
|
|
3669
|
+
if (!message || message.jsonrpc !== "2.0") return;
|
|
3670
|
+
if (message.method !== "ui/notifications/tool-result") return;
|
|
3671
|
+
const data = message.params?.structuredContent;
|
|
3672
|
+
state.pack = data?.pack ?? null;
|
|
3673
|
+
state.summary = data?.summary ?? "";
|
|
3674
|
+
render();
|
|
3675
|
+
}, { passive: true });
|
|
3676
|
+
|
|
3677
|
+
render();
|
|
3678
|
+
</script>
|
|
3679
|
+
</body>
|
|
3680
|
+
</html>`;
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
// src/lib/chatgpt-app/server.ts
|
|
3684
|
+
var MCP_PATH = "/mcp";
|
|
3685
|
+
var MCP_METHODS = /* @__PURE__ */ new Set(["POST", "GET", "DELETE"]);
|
|
3686
|
+
function corsHeaders() {
|
|
3687
|
+
return {
|
|
3688
|
+
"Access-Control-Allow-Origin": "*",
|
|
3689
|
+
"Access-Control-Allow-Methods": "POST, GET, DELETE, OPTIONS",
|
|
3690
|
+
"Access-Control-Allow-Headers": "content-type, mcp-session-id, mcp-protocol-version",
|
|
3691
|
+
"Access-Control-Expose-Headers": "Mcp-Session-Id"
|
|
3692
|
+
};
|
|
3693
|
+
}
|
|
3694
|
+
function directMcpVisitHtml(mcpUrl) {
|
|
3695
|
+
return `<!doctype html>
|
|
3696
|
+
<html lang="en">
|
|
3697
|
+
<head>
|
|
3698
|
+
<meta charset="utf-8" />
|
|
3699
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
3700
|
+
<title>TokenTrace ChatGPT app prototype</title>
|
|
3701
|
+
<style>
|
|
3702
|
+
:root { color: #172026; background: #ffffff; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
3703
|
+
body { margin: 0; padding: 24px; }
|
|
3704
|
+
main { max-width: 720px; display: grid; gap: 14px; }
|
|
3705
|
+
h1, p { margin: 0; }
|
|
3706
|
+
h1 { font-size: 1.25rem; }
|
|
3707
|
+
p { color: #52616b; line-height: 1.5; }
|
|
3708
|
+
code { background: #eef2f5; border: 1px solid #d8e0e6; border-radius: 6px; padding: 2px 5px; }
|
|
3709
|
+
pre { white-space: pre-wrap; background: #101820; color: #edf5f7; border-radius: 8px; padding: 12px; overflow: auto; }
|
|
3710
|
+
</style>
|
|
3711
|
+
</head>
|
|
3712
|
+
<body>
|
|
3713
|
+
<main>
|
|
3714
|
+
<h1>TokenTrace ChatGPT app prototype</h1>
|
|
3715
|
+
<p>This is the MCP endpoint for ChatGPT developer mode and MCP clients, not a normal browser page.</p>
|
|
3716
|
+
<p>Real MCP clients must call <code>${mcpUrl}</code> with an <code>Accept</code> header that includes <code>text/event-stream</code>.</p>
|
|
3717
|
+
<p>For ChatGPT, expose this local server through an HTTPS tunnel and use the tunneled <code>/mcp</code> URL as the connector URL.</p>
|
|
3718
|
+
<pre>tokentrace chatgpt-app selftest --json
|
|
3719
|
+
tokentrace chatgpt-app --port 8787 --hostname 127.0.0.1</pre>
|
|
3720
|
+
</main>
|
|
3721
|
+
</body>
|
|
3722
|
+
</html>`;
|
|
3723
|
+
}
|
|
3724
|
+
function acceptsMcpStream(req) {
|
|
3725
|
+
const accept = req.headers.accept ?? "";
|
|
3726
|
+
return accept.includes("text/event-stream");
|
|
3727
|
+
}
|
|
3728
|
+
function createChatGptAppHttpServer() {
|
|
3729
|
+
return createServer((req, res) => {
|
|
3730
|
+
void handleChatGptAppHttpRequest(req, res);
|
|
3731
|
+
});
|
|
3732
|
+
}
|
|
3733
|
+
async function handleChatGptAppHttpRequest(req, res) {
|
|
3734
|
+
if (!req.url) {
|
|
3735
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing URL");
|
|
3736
|
+
return;
|
|
3737
|
+
}
|
|
3738
|
+
const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
|
|
3739
|
+
if (req.method === "OPTIONS" && url.pathname === MCP_PATH) {
|
|
3740
|
+
res.writeHead(204, corsHeaders());
|
|
3741
|
+
res.end();
|
|
3742
|
+
return;
|
|
3743
|
+
}
|
|
3744
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
3745
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("TokenTrace ChatGPT app prototype. Connect ChatGPT developer mode to /mcp.");
|
|
3746
|
+
return;
|
|
3747
|
+
}
|
|
3748
|
+
if (req.method === "GET" && url.pathname === MCP_PATH && !acceptsMcpStream(req)) {
|
|
3749
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(directMcpVisitHtml(`${url.origin}${MCP_PATH}`));
|
|
3750
|
+
return;
|
|
3751
|
+
}
|
|
3752
|
+
if (url.pathname === MCP_PATH && req.method && MCP_METHODS.has(req.method)) {
|
|
3753
|
+
for (const [key, value] of Object.entries(corsHeaders())) {
|
|
3754
|
+
res.setHeader(key, value);
|
|
3755
|
+
}
|
|
3756
|
+
const mcpServer = createTokenTraceChatGptAppServer();
|
|
3757
|
+
const transport = new StreamableHTTPServerTransport({
|
|
3758
|
+
sessionIdGenerator: void 0,
|
|
3759
|
+
enableJsonResponse: true
|
|
3760
|
+
});
|
|
3761
|
+
res.on("close", () => {
|
|
3762
|
+
void transport.close();
|
|
3763
|
+
void mcpServer.close();
|
|
3764
|
+
});
|
|
3765
|
+
try {
|
|
3766
|
+
await mcpServer.connect(transport);
|
|
3767
|
+
await transport.handleRequest(req, res);
|
|
3768
|
+
} catch (error) {
|
|
3769
|
+
console.error("Error handling TokenTrace ChatGPT app MCP request:", error);
|
|
3770
|
+
if (!res.headersSent) {
|
|
3771
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Internal server error");
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
return;
|
|
3775
|
+
}
|
|
3776
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not Found");
|
|
3777
|
+
}
|
|
3778
|
+
async function listenChatGptAppServer(options = {}) {
|
|
3779
|
+
const hostname = options.hostname ?? "127.0.0.1";
|
|
3780
|
+
const port = options.port ?? 8787;
|
|
3781
|
+
const server = createChatGptAppHttpServer();
|
|
3782
|
+
await new Promise((resolve, reject) => {
|
|
3783
|
+
server.once("error", reject);
|
|
3784
|
+
server.listen(port, hostname, () => {
|
|
3785
|
+
server.off("error", reject);
|
|
3786
|
+
resolve();
|
|
3787
|
+
});
|
|
3788
|
+
});
|
|
3789
|
+
const address = server.address();
|
|
3790
|
+
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
3791
|
+
const urlHost = hostname === "0.0.0.0" ? "127.0.0.1" : hostname;
|
|
3792
|
+
const url = `http://${urlHost}:${actualPort}`;
|
|
3793
|
+
return {
|
|
3794
|
+
server,
|
|
3795
|
+
url,
|
|
3796
|
+
mcpUrl: `${url}${MCP_PATH}`,
|
|
3797
|
+
close: () => new Promise((resolve, reject) => {
|
|
3798
|
+
server.close((error) => {
|
|
3799
|
+
if (error) reject(error);
|
|
3800
|
+
else resolve();
|
|
3801
|
+
});
|
|
3802
|
+
})
|
|
3803
|
+
};
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
// scripts/chatgpt-app.ts
|
|
3807
|
+
function usage() {
|
|
3808
|
+
return `TokenTrace ChatGPT app prototype
|
|
3809
|
+
|
|
3810
|
+
Usage:
|
|
3811
|
+
tokentrace chatgpt-app
|
|
3812
|
+
tokentrace chatgpt-app --port 8787 --hostname 127.0.0.1
|
|
3813
|
+
tokentrace chatgpt-app selftest --json
|
|
3814
|
+
|
|
3815
|
+
Options:
|
|
3816
|
+
-p, --port <port> Local HTTP port. Defaults to 8787.
|
|
3817
|
+
-H, --hostname <host> Local bind host. Defaults to 127.0.0.1.
|
|
3818
|
+
-h, --help Print help
|
|
3819
|
+
|
|
3820
|
+
ChatGPT developer mode requires an HTTPS-reachable /mcp URL. For local testing,
|
|
3821
|
+
start this server and expose it with a secure tunnel.`;
|
|
3822
|
+
}
|
|
3823
|
+
function readPort(value) {
|
|
3824
|
+
const port = Number(value);
|
|
3825
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
3826
|
+
throw new Error(`Invalid --port value: ${value ?? "(missing)"}`);
|
|
3827
|
+
}
|
|
3828
|
+
return port;
|
|
3829
|
+
}
|
|
3830
|
+
function parseServeArgs(args2) {
|
|
3831
|
+
const options = {
|
|
3832
|
+
hostname: "127.0.0.1",
|
|
3833
|
+
port: Number(process.env.TOKENTRACE_CHATGPT_APP_PORT ?? process.env.PORT ?? 8787)
|
|
3834
|
+
};
|
|
3835
|
+
for (let index2 = 0; index2 < args2.length; index2 += 1) {
|
|
3836
|
+
const arg = args2[index2];
|
|
3837
|
+
if (arg === "--help" || arg === "-h") {
|
|
3838
|
+
process.stdout.write(`${usage()}
|
|
3839
|
+
`);
|
|
3840
|
+
process.exit(0);
|
|
3841
|
+
}
|
|
3842
|
+
if (arg === "--port" || arg === "-p") {
|
|
3843
|
+
options.port = readPort(args2[index2 + 1]);
|
|
3844
|
+
index2 += 1;
|
|
3845
|
+
continue;
|
|
3846
|
+
}
|
|
3847
|
+
if (arg?.startsWith("--port=")) {
|
|
3848
|
+
options.port = readPort(arg.slice("--port=".length));
|
|
3849
|
+
continue;
|
|
3850
|
+
}
|
|
3851
|
+
if (arg === "--hostname" || arg === "-H") {
|
|
3852
|
+
const hostname = args2[index2 + 1];
|
|
3853
|
+
if (!hostname) throw new Error("Missing --hostname value.");
|
|
3854
|
+
options.hostname = hostname;
|
|
3855
|
+
index2 += 1;
|
|
3856
|
+
continue;
|
|
3857
|
+
}
|
|
3858
|
+
if (arg?.startsWith("--hostname=")) {
|
|
3859
|
+
const hostname = arg.slice("--hostname=".length);
|
|
3860
|
+
if (!hostname) throw new Error("Missing --hostname value.");
|
|
3861
|
+
options.hostname = hostname;
|
|
3862
|
+
continue;
|
|
3863
|
+
}
|
|
3864
|
+
throw new Error(`Unknown chatgpt-app argument: ${arg}`);
|
|
3865
|
+
}
|
|
3866
|
+
return options;
|
|
3867
|
+
}
|
|
3868
|
+
var args = process.argv.slice(2);
|
|
3869
|
+
if (args[0] === "selftest") {
|
|
3870
|
+
if (args.length > 2 || args[1] && args[1] !== "--json") {
|
|
3871
|
+
console.error("Usage: tokentrace chatgpt-app selftest --json");
|
|
3872
|
+
process.exit(1);
|
|
3873
|
+
}
|
|
3874
|
+
const result = await runChatGptAppSelfTest();
|
|
3875
|
+
if (args.includes("--json")) {
|
|
3876
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
3877
|
+
`);
|
|
3878
|
+
} else {
|
|
3879
|
+
process.stdout.write(result.ok ? "TokenTrace ChatGPT app self-test passed.\n" : "TokenTrace ChatGPT app self-test failed.\n");
|
|
3880
|
+
}
|
|
3881
|
+
process.exit(result.ok ? 0 : 1);
|
|
3882
|
+
}
|
|
3883
|
+
try {
|
|
3884
|
+
const options = parseServeArgs(args);
|
|
3885
|
+
const running = await listenChatGptAppServer(options);
|
|
3886
|
+
console.log(`TokenTrace ChatGPT app prototype listening on ${running.mcpUrl}`);
|
|
3887
|
+
console.log("For ChatGPT developer mode, expose this local server through an HTTPS tunnel and use the /mcp URL.");
|
|
3888
|
+
const shutdown = async () => {
|
|
3889
|
+
await running.close();
|
|
3890
|
+
process.exit(0);
|
|
3891
|
+
};
|
|
3892
|
+
process.once("SIGINT", () => {
|
|
3893
|
+
void shutdown();
|
|
3894
|
+
});
|
|
3895
|
+
process.once("SIGTERM", () => {
|
|
3896
|
+
void shutdown();
|
|
3897
|
+
});
|
|
3898
|
+
} catch (error) {
|
|
3899
|
+
console.error(error instanceof Error ? error.message : error);
|
|
3900
|
+
process.exit(1);
|
|
3901
|
+
}
|