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.
Files changed (40) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/TOKENTRACE_AGENT.md +11 -0
  4. package/app/api/evidence-pack/route.ts +3 -40
  5. package/app/guide/guide-content.ts +1 -1
  6. package/app/page.tsx +4 -4
  7. package/components/overview/summary-cards.tsx +64 -34
  8. package/components/sidebar.tsx +61 -25
  9. package/dist/cli/main.mjs +10 -0
  10. package/dist/runtime/agent.mjs +31 -1
  11. package/dist/runtime/chatgpt-app.mjs +3901 -0
  12. package/dist/runtime/mcp.mjs +31 -1
  13. package/docs/CHATGPT_APP_PROTOTYPE.md +103 -0
  14. package/docs/CHATGPT_APP_RELEASE.md +101 -0
  15. package/docs/chatgpt-app/README.md +24 -0
  16. package/docs/chatgpt-app/assets/icon.svg +10 -0
  17. package/docs/chatgpt-app/assets/listing-card.svg +34 -0
  18. package/docs/chatgpt-app/assets/widget-preview.svg +24 -0
  19. package/docs/chatgpt-app/dashboard-fields.json +39 -0
  20. package/docs/chatgpt-app/manual-release-steps.md +114 -0
  21. package/docs/chatgpt-app/privacy-and-data.md +39 -0
  22. package/docs/chatgpt-app/review-response-template.md +53 -0
  23. package/docs/chatgpt-app/screenshot-checklist.md +46 -0
  24. package/docs/chatgpt-app/submission-copy.md +45 -0
  25. package/docs/chatgpt-app/test-prompts-and-responses.md +61 -0
  26. package/llms.txt +13 -0
  27. package/next.config.mjs +8 -2
  28. package/package.json +11 -4
  29. package/scripts/build-cli-runtime.mjs +1 -0
  30. package/scripts/chatgpt-app-release-check.mjs +489 -0
  31. package/scripts/chatgpt-app.ts +107 -0
  32. package/scripts/package-inspect.mjs +6 -1
  33. package/scripts/smoke-packed-install.mjs +5 -0
  34. package/server.json +3 -3
  35. package/src/cli/commands.ts +8 -0
  36. package/src/cli/help.ts +3 -0
  37. package/src/lib/agent-discovery.ts +32 -1
  38. package/src/lib/chatgpt-app/prototype.ts +452 -0
  39. package/src/lib/chatgpt-app/server.ts +162 -0
  40. 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
+ }