memhouse 0.0.1 → 0.3.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 (96) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +59 -3
  3. package/bin/memhouse.js +487 -0
  4. package/cache.js +850 -0
  5. package/ch-ingest.js +329 -0
  6. package/ch-schema.js +152 -0
  7. package/clickhouse.js +87 -0
  8. package/editors/antigravity.js +1019 -0
  9. package/editors/base.js +321 -0
  10. package/editors/claude.js +386 -0
  11. package/editors/codebuff.js +338 -0
  12. package/editors/codex.js +537 -0
  13. package/editors/commandcode.js +166 -0
  14. package/editors/copilot-jetbrains.js +207 -0
  15. package/editors/copilot.js +260 -0
  16. package/editors/cursor-agent.js +202 -0
  17. package/editors/cursor.js +438 -0
  18. package/editors/gemini.js +231 -0
  19. package/editors/goose.js +358 -0
  20. package/editors/gsd.js +366 -0
  21. package/editors/index.js +179 -0
  22. package/editors/kiro.js +315 -0
  23. package/editors/opencode.js +374 -0
  24. package/editors/vscode.js +413 -0
  25. package/editors/windsurf.js +634 -0
  26. package/editors/zed.js +213 -0
  27. package/index.js +424 -2
  28. package/mcp-server.js +294 -0
  29. package/mem-house/DESIGN.md +119 -0
  30. package/mem-house/delivery/AGENT-INSTALL.md +75 -0
  31. package/mem-house/delivery/PROMPT.md +42 -0
  32. package/mem-house/delivery/install.sh +181 -0
  33. package/mem-house/delivery/kernel-install.md +76 -0
  34. package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
  35. package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
  36. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
  37. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
  38. package/mem-house/rls.sql +17 -0
  39. package/mem-house/schema.sql +145 -0
  40. package/mem-house/server/queries.js +878 -0
  41. package/mem-house/server/server.js +228 -0
  42. package/mem-house/shipper/ship.js +402 -0
  43. package/misc/syntax-check.js +38 -0
  44. package/package.json +56 -7
  45. package/pricing.js +88 -0
  46. package/pricing.json +992 -0
  47. package/public/assets/index-BsgjOSTv.js +75 -0
  48. package/public/assets/index-DV6ONi_F.css +2 -0
  49. package/public/index.html +16 -0
  50. package/relay-client.js +306 -0
  51. package/relay-server.js +577 -0
  52. package/server.js +860 -0
  53. package/share-image.js +367 -0
  54. package/ui/eslint.config.js +29 -0
  55. package/ui/index.html +15 -0
  56. package/ui/package-lock.json +4229 -0
  57. package/ui/package.json +38 -0
  58. package/ui/src/App.jsx +364 -0
  59. package/ui/src/components/ActivityHeatmap.jsx +206 -0
  60. package/ui/src/components/AiAuditCard.jsx +254 -0
  61. package/ui/src/components/AnimatedLoader.jsx +14 -0
  62. package/ui/src/components/AnimatedLogo.jsx +96 -0
  63. package/ui/src/components/ChatSidebar.jsx +226 -0
  64. package/ui/src/components/DateRangePicker.jsx +104 -0
  65. package/ui/src/components/EditorDot.jsx +11 -0
  66. package/ui/src/components/EditorIcon.jsx +117 -0
  67. package/ui/src/components/KpiCard.jsx +9 -0
  68. package/ui/src/components/LiveFeed.jsx +128 -0
  69. package/ui/src/components/LoginScreen.jsx +81 -0
  70. package/ui/src/components/MessageRenderer.jsx +148 -0
  71. package/ui/src/components/PageHeader.jsx +11 -0
  72. package/ui/src/components/SectionTitle.jsx +3 -0
  73. package/ui/src/components/ShareModal.jsx +305 -0
  74. package/ui/src/components/TokenTimeline.jsx +258 -0
  75. package/ui/src/hooks/useLive.jsx +15 -0
  76. package/ui/src/index.css +83 -0
  77. package/ui/src/lib/api.js +343 -0
  78. package/ui/src/lib/constants.js +88 -0
  79. package/ui/src/lib/theme.jsx +23 -0
  80. package/ui/src/main.jsx +16 -0
  81. package/ui/src/pages/Artifacts.jsx +575 -0
  82. package/ui/src/pages/Compare.jsx +232 -0
  83. package/ui/src/pages/CostAnalysis.jsx +354 -0
  84. package/ui/src/pages/Dashboard.jsx +529 -0
  85. package/ui/src/pages/DeepAnalysis.jsx +495 -0
  86. package/ui/src/pages/GSD.jsx +726 -0
  87. package/ui/src/pages/MCPs.jsx +744 -0
  88. package/ui/src/pages/ProjectDetail.jsx +325 -0
  89. package/ui/src/pages/Projects.jsx +249 -0
  90. package/ui/src/pages/RelayDashboard.jsx +590 -0
  91. package/ui/src/pages/RelayUserDetail.jsx +466 -0
  92. package/ui/src/pages/Sessions.jsx +535 -0
  93. package/ui/src/pages/Settings.jsx +241 -0
  94. package/ui/src/pages/SqlViewer.jsx +344 -0
  95. package/ui/src/pages/Subscriptions.jsx +411 -0
  96. package/ui/vite.config.js +18 -0
package/cache.js ADDED
@@ -0,0 +1,850 @@
1
+ // cache.js — ClickHouse-backed analytics cache.
2
+ //
3
+ // HARD REPLACEMENT for the former better-sqlite3 implementation. Same public API
4
+ // (same exported function names + return shapes) so server.js is unchanged except
5
+ // that every getter is now async (ClickHouse's client is Promise-based) and must be
6
+ // awaited. Ingest/scan lives in ch-ingest.js; schema in ch-schema.js; this file is
7
+ // the read/query layer that server.js calls.
8
+ //
9
+ // SQL dialect notes (SQLite → ClickHouse):
10
+ // - `?` positional params → named `{name:Type}` params via query_params.
11
+ // - `date(ts/1000,'unixepoch','localtime')` → formatDateTime(toDateTime(intDiv(ts,1000)), fmt, tz).
12
+ // Local-time buckets pass the machine tz; UTC month buckets pass 'UTC' (matches
13
+ // SQLite's plain 'unixepoch').
14
+ // - `strftime('%H'…)` → toHour(…, tz); `strftime('%w'…)` (Sun=0..Sat=6) → toDayOfWeek(…,0,tz) % 7.
15
+ // - JSON columns (models, tool_calls, args_json) stay strings, parsed in JS exactly
16
+ // as before. The client returns 64-bit ints as numbers (see clickhouse.js).
17
+
18
+ const { getClient } = require('./clickhouse');
19
+ const chIngest = require('./ch-ingest');
20
+ const { calculateCost, normalizeModelName } = require('./pricing');
21
+
22
+ const TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
23
+ const DATA_TABLES = ['chats', 'chat_stats', 'messages', 'tool_calls', 'meta', 'gsd_projects', 'gsd_phases'];
24
+
25
+ async function q(query, params = {}) {
26
+ const rs = await getClient().query({ query, query_params: params, format: 'JSONEachRow' });
27
+ return rs.json();
28
+ }
29
+ async function q1(query, params = {}) { return (await q(query, params))[0]; }
30
+ function safeParseJson(s) { try { return JSON.parse(s); } catch { return {}; } }
31
+
32
+ // Most-frequent model in a chat_stats.models JSON array (null if none).
33
+ function dominantOfModels(modelsJson) {
34
+ let models; try { models = JSON.parse(modelsJson || '[]'); } catch { return null; }
35
+ if (!models.length) return null;
36
+ const freq = {};
37
+ for (const m of models) freq[m] = (freq[m] || 0) + 1;
38
+ return Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
39
+ }
40
+
41
+ // ── init / scan (delegate to the ingest layer) ──────────────────────────────────
42
+ const initDb = chIngest.initDb;
43
+ const scanAllAsync = chIngest.scanAllAsync;
44
+ const cacheGSDProjects = chIngest.cacheGSDProjects;
45
+ async function scanAll(onProgress, opts) { return chIngest.scanAllAsync(onProgress, opts); }
46
+
47
+ async function resetAndRescanAsync(onProgress) {
48
+ const client = getClient();
49
+ for (const t of DATA_TABLES) await client.command({ query: `TRUNCATE TABLE IF EXISTS ${t}` });
50
+ await initDb();
51
+ return scanAllAsync(onProgress);
52
+ }
53
+
54
+ // Empty the data tables (keeps the schema + meta.schema_version). Replaces the
55
+ // SQLite `--no-cache` file wipe. Callers should await initDb() first.
56
+ async function clearAll() {
57
+ const client = getClient();
58
+ for (const t of ['chats', 'chat_stats', 'messages', 'tool_calls', 'gsd_projects', 'gsd_phases']) {
59
+ await client.command({ query: `TRUNCATE TABLE IF EXISTS ${t}` });
60
+ }
61
+ }
62
+
63
+ // ── shared filter builder (named params) ────────────────────────────────────────
64
+ // col: table alias holding source/folder/timestamps (always 'c').
65
+ // editorLike/folderLike toggle LIKE-substring vs exact-equality, matching each
66
+ // getter's original SQLite behavior.
67
+ function filters(opts, col, { editorLike = false, folderLike = false } = {}) {
68
+ const parts = [];
69
+ const params = {};
70
+ if (opts.hiddenFolders && opts.hiddenFolders.length) {
71
+ parts.push(`(${col}.folder IS NULL OR ${col}.folder NOT IN {hidden:Array(String)})`);
72
+ params.hidden = opts.hiddenFolders;
73
+ }
74
+ if (opts.editor) {
75
+ if (editorLike) { parts.push(`${col}.source LIKE {editor:String}`); params.editor = `%${opts.editor}%`; }
76
+ else { parts.push(`${col}.source = {editor:String}`); params.editor = opts.editor; }
77
+ }
78
+ if (opts.folder) {
79
+ if (folderLike) { parts.push(`${col}.folder LIKE {folder:String}`); params.folder = `%${opts.folder}%`; }
80
+ else { parts.push(`${col}.folder = {folder:String}`); params.folder = opts.folder; }
81
+ }
82
+ if (opts.dateFrom != null) { parts.push(`COALESCE(${col}.last_updated_at, ${col}.created_at) >= {dateFrom:Int64}`); params.dateFrom = opts.dateFrom; }
83
+ if (opts.dateTo != null) { parts.push(`COALESCE(${col}.last_updated_at, ${col}.created_at) <= {dateTo:Int64}`); params.dateTo = opts.dateTo; }
84
+ return {
85
+ and: parts.length ? ' AND ' + parts.join(' AND ') : '',
86
+ where: parts.length ? ' WHERE ' + parts.join(' AND ') : '',
87
+ params,
88
+ };
89
+ }
90
+
91
+ // SQL fragment: epoch-ms column → seconds DateTime
92
+ const dt = (ms) => `toDateTime(intDiv(${ms}, 1000))`;
93
+
94
+ // ── chats list ──────────────────────────────────────────────────────────────────
95
+ async function getCachedChats(opts = {}) {
96
+ const f = filters(opts, 'c', { editorLike: true, folderLike: true });
97
+ // Filters live INSIDE the subquery; the LEFT JOIN happens outside with no
98
+ // outer WHERE. LEFT JOIN + WHERE under the client's global final=1 fails on
99
+ // ClickHouse >= 26.5 with NOT_FOUND_COLUMN_IN_BLOCK (see the
100
+ // ch-267-not-found-column repro) — filter-then-join sidesteps the broken plan.
101
+ const params = { ...f.params };
102
+ let sub = `SELECT id, source, name, mode, folder, created_at, last_updated_at,
103
+ encrypted, bubble_count FROM chats AS c WHERE 1=1${f.and}`;
104
+ if (opts.named !== false) sub += ' AND (c.name IS NOT NULL OR c.bubble_count > 0)';
105
+ let sql = `SELECT c.id, c.source, c.name, c.mode, c.folder,
106
+ c.created_at, c.last_updated_at, c.encrypted, c.bubble_count,
107
+ cs.models AS _models,
108
+ cs.total_input_tokens AS _inTok, cs.total_output_tokens AS _outTok,
109
+ cs.total_cache_read AS _cacheR, cs.total_cache_write AS _cacheW,
110
+ cs.total_user_chars AS _uChars, cs.total_assistant_chars AS _aChars
111
+ FROM (${sub}) AS c LEFT JOIN chat_stats AS cs ON cs.chat_id = c.id
112
+ ORDER BY c.last_updated_at DESC`;
113
+ if (opts.limit) { sql += ' LIMIT {limit:UInt64}'; params.limit = opts.limit; }
114
+ if (opts.offset) { sql += ' OFFSET {offset:UInt64}'; params.offset = opts.offset; }
115
+
116
+ const rows = await q(sql, params);
117
+ for (const r of rows) {
118
+ r.top_model = null;
119
+ try {
120
+ const models = JSON.parse(r._models || '[]');
121
+ if (models.length > 0) {
122
+ const freq = {};
123
+ for (const m of models) freq[m] = (freq[m] || 0) + 1;
124
+ r.top_model = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
125
+ }
126
+ } catch { /* no models */ }
127
+ let inTok = r._inTok || 0, outTok = r._outTok || 0;
128
+ if (inTok === 0 && outTok === 0 && ((r._uChars || 0) > 0 || (r._aChars || 0) > 0)) {
129
+ inTok = Math.round((r._uChars || 0) / 4);
130
+ outTok = Math.round((r._aChars || 0) / 4);
131
+ }
132
+ r.cost = r.top_model ? (calculateCost(r.top_model, inTok, outTok, r._cacheR || 0, r._cacheW || 0) || 0) : 0;
133
+ delete r._models; delete r._inTok; delete r._outTok; delete r._cacheR; delete r._cacheW; delete r._uChars; delete r._aChars;
134
+ }
135
+ return rows;
136
+ }
137
+
138
+ async function countCachedChats(opts = {}) {
139
+ const f = filters(opts, 'c', { editorLike: true, folderLike: true });
140
+ let sql = `SELECT count() AS cnt FROM chats AS c WHERE 1=1${f.and}`;
141
+ if (opts.named !== false) sql += ' AND (c.name IS NOT NULL OR c.bubble_count > 0)';
142
+ return Number((await q1(sql, f.params)).cnt);
143
+ }
144
+
145
+ // ── overview ──────────────────────────────────────────────────────────────────
146
+ async function getCachedOverview(opts = {}) {
147
+ const f = filters(opts, 'c');
148
+ const totalChats = Number((await q1(`SELECT count() AS cnt FROM chats AS c${f.where}`, f.params)).cnt);
149
+
150
+ const editors = opts.folder
151
+ ? await q(`SELECT source, count() AS count FROM chats AS c${f.where} GROUP BY source ORDER BY count DESC`, f.params)
152
+ : await q('SELECT source, count() AS count FROM chats GROUP BY source ORDER BY count DESC');
153
+
154
+ const modes = await q(`SELECT mode, count() AS count FROM chats AS c WHERE mode IS NOT NULL${f.and} GROUP BY mode`, f.params);
155
+ const byMode = {};
156
+ for (const m of modes) byMode[m.mode] = Number(m.count);
157
+
158
+ const rows = await q(`
159
+ SELECT formatDateTime(${dt('last_updated_at')}, '%Y-%m', 'UTC') AS month, source, count() AS count
160
+ FROM chats AS c WHERE last_updated_at IS NOT NULL${f.and}
161
+ GROUP BY month, source ORDER BY month`, f.params);
162
+ const monthMap = {};
163
+ for (const r of rows) {
164
+ if (!monthMap[r.month]) monthMap[r.month] = { count: 0, editors: {} };
165
+ monthMap[r.month].count += Number(r.count);
166
+ monthMap[r.month].editors[r.source] = Number(r.count);
167
+ }
168
+ const byMonth = Object.keys(monthMap).sort().map(m => ({ month: m, ...monthMap[m] }));
169
+
170
+ const projects = await q(`
171
+ SELECT folder, count() AS count FROM chats AS c
172
+ WHERE folder IS NOT NULL${f.and} GROUP BY folder ORDER BY count DESC LIMIT 20`, f.params);
173
+ const topProjects = projects.map(p => ({
174
+ name: p.folder.split(/[/\\]/).slice(-2).join('/'),
175
+ fullPath: p.folder,
176
+ count: Number(p.count),
177
+ }));
178
+
179
+ const oldest = (await q1(`SELECT min(COALESCE(c.last_updated_at, c.created_at)) AS ts FROM chats AS c${f.where}`, f.params)).ts;
180
+ const newest = (await q1(`SELECT max(COALESCE(c.last_updated_at, c.created_at)) AS ts FROM chats AS c${f.where}`, f.params)).ts;
181
+
182
+ return {
183
+ totalChats,
184
+ editors: editors.map(e => ({ id: e.source, count: Number(e.count) })),
185
+ byMode, byMonth, topProjects,
186
+ oldestChat: oldest, newestChat: newest,
187
+ };
188
+ }
189
+
190
+ // ── daily activity ──────────────────────────────────────────────────────────────
191
+ async function getCachedDailyActivity(opts = {}) {
192
+ const f = filters(opts, 'c');
193
+ const params = { ...f.params, tz: TZ };
194
+ const rows = await q(`
195
+ SELECT formatDateTime(${dt('COALESCE(last_updated_at, created_at)')}, '%Y-%m-%d', {tz:String}) AS day,
196
+ source,
197
+ toHour(${dt('COALESCE(last_updated_at, created_at)')}, {tz:String}) AS hour,
198
+ count() AS count
199
+ FROM chats AS c
200
+ WHERE (last_updated_at IS NOT NULL OR created_at IS NOT NULL)${f.and}
201
+ GROUP BY day, source, hour ORDER BY day`, params);
202
+
203
+ const daily = {};
204
+ for (const r of rows) {
205
+ if (!daily[r.day]) daily[r.day] = { total: 0, editors: {}, hours: {} };
206
+ const cnt = Number(r.count);
207
+ daily[r.day].total += cnt;
208
+ daily[r.day].editors[r.source] = (daily[r.day].editors[r.source] || 0) + cnt;
209
+ if (!daily[r.day].hours[r.source]) daily[r.day].hours[r.source] = new Array(24).fill(0);
210
+ daily[r.day].hours[r.source][Number(r.hour)] += cnt;
211
+ }
212
+ return Object.keys(daily).sort().map(day => ({ day, ...daily[day] }));
213
+ }
214
+
215
+ // ── deep analytics ──────────────────────────────────────────────────────────────
216
+ async function getCachedDeepAnalytics(opts = {}) {
217
+ const f = filters(opts, 'c', { editorLike: true });
218
+ let sql = `SELECT cs.* FROM chat_stats AS cs JOIN chats AS c ON cs.chat_id = c.id WHERE 1=1${f.and} ORDER BY cs.analyzed_at DESC`;
219
+ const params = { ...f.params };
220
+ if (opts.limit) { sql += ' LIMIT {limit:UInt64}'; params.limit = opts.limit; }
221
+ const rows = await q(sql, params);
222
+
223
+ const toolFreq = {}, modelFreq = {};
224
+ let totalMessages = 0, totalUserChars = 0, totalAssistantChars = 0;
225
+ let totalToolCalls = 0, totalInputTokens = 0, totalOutputTokens = 0, totalCacheRead = 0, totalCacheWrite = 0;
226
+
227
+ for (const r of rows) {
228
+ totalMessages += r.total_messages;
229
+ totalUserChars += r.total_user_chars;
230
+ totalAssistantChars += r.total_assistant_chars;
231
+ totalInputTokens += r.total_input_tokens;
232
+ totalOutputTokens += r.total_output_tokens;
233
+ totalCacheRead += r.total_cache_read;
234
+ totalCacheWrite += r.total_cache_write;
235
+ try { for (const t of JSON.parse(r.tool_calls)) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; } } catch { /* skip */ }
236
+ try { for (const m of JSON.parse(r.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch { /* skip */ }
237
+ }
238
+
239
+ let tokensEstimated = false;
240
+ if (totalInputTokens === 0 && totalOutputTokens === 0 && (totalUserChars > 0 || totalAssistantChars > 0)) {
241
+ totalInputTokens = Math.round(totalUserChars / 4);
242
+ totalOutputTokens = Math.round(totalAssistantChars / 4);
243
+ tokensEstimated = true;
244
+ }
245
+
246
+ return {
247
+ analyzedChats: rows.length,
248
+ totalMessages, totalToolCalls, totalUserChars, totalAssistantChars,
249
+ totalInputTokens, totalOutputTokens, tokensEstimated, totalCacheRead, totalCacheWrite,
250
+ topTools: Object.entries(toolFreq).sort((a, b) => b[1] - a[1]).slice(0, 30).map(([name, count]) => ({ name, count })),
251
+ topModels: Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([name, count]) => ({ name, count })),
252
+ };
253
+ }
254
+
255
+ // ── single chat ─────────────────────────────────────────────────────────────────
256
+ async function getCachedChat(id) {
257
+ // Equality, not prefix LIKE: the UI/API always passes complete ids, and a
258
+ // prefix match is ambiguous when one id is a prefix of another ('1' vs '10').
259
+ const chat = await q1('SELECT * FROM chats AS c WHERE id = {id:String} LIMIT 1', { id });
260
+ if (!chat) return null;
261
+
262
+ const stats = await q1('SELECT * FROM chat_stats WHERE chat_id = {cid:String} LIMIT 1', { cid: chat.id });
263
+ const msgSql = 'SELECT role, content, model, input_tokens, output_tokens, cache_read, cache_write FROM messages WHERE chat_id = {cid:String} ORDER BY seq';
264
+ let messages = await q(msgSql, { cid: chat.id });
265
+
266
+ // No cached messages → try a live fetch from the editor adapter (display only).
267
+ if (messages.length === 0 && !chat.encrypted) {
268
+ try {
269
+ const meta = safeParseJson(chat._meta || '{}');
270
+ const { getMessages } = require('./editors');
271
+ const live = getMessages({
272
+ composerId: chat.id, source: chat.source, name: chat.name, mode: chat.mode,
273
+ folder: chat.folder, createdAt: chat.created_at, lastUpdatedAt: chat.last_updated_at,
274
+ encrypted: !!chat.encrypted, bubbleCount: chat.bubble_count, ...meta,
275
+ });
276
+ if (live && live.length) {
277
+ messages = live.map(m => ({
278
+ role: m.role,
279
+ content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
280
+ model: m._model || null, input_tokens: m._inputTokens || null, output_tokens: m._outputTokens || null,
281
+ }));
282
+ }
283
+ } catch { /* live fetch failed */ }
284
+ }
285
+
286
+ let parsedStats = null;
287
+ if (stats) {
288
+ parsedStats = {
289
+ totalMessages: stats.total_messages, userMessages: stats.user_messages,
290
+ assistantMessages: stats.assistant_messages, toolMessages: stats.tool_messages,
291
+ systemMessages: stats.system_messages,
292
+ toolCalls: JSON.parse(stats.tool_calls || '[]'), models: JSON.parse(stats.models || '[]'),
293
+ totalUserChars: stats.total_user_chars, totalAssistantChars: stats.total_assistant_chars,
294
+ totalInputTokens: stats.total_input_tokens, totalOutputTokens: stats.total_output_tokens,
295
+ totalCacheRead: stats.total_cache_read, totalCacheWrite: stats.total_cache_write,
296
+ };
297
+ }
298
+
299
+ const toolCalls = await q('SELECT tool_name, args_json FROM tool_calls WHERE chat_id = {cid:String} ORDER BY idx', { cid: chat.id });
300
+ const toolCallDetails = toolCalls.map(tc => ({ name: tc.tool_name, args: safeParseJson(tc.args_json) }));
301
+
302
+ return {
303
+ id: chat.id, source: chat.source, name: chat.name, mode: chat.mode, folder: chat.folder,
304
+ createdAt: chat.created_at, lastUpdatedAt: chat.last_updated_at, encrypted: !!chat.encrypted,
305
+ messages: messages.map(m => ({ role: m.role, content: m.content, model: m.model, inputTokens: m.input_tokens || 0, outputTokens: m.output_tokens || 0 })),
306
+ stats: parsedStats, toolCallDetails,
307
+ };
308
+ }
309
+
310
+ // ── projects ──────────────────────────────────────────────────────────────────
311
+ async function getCachedProjects(opts = {}) {
312
+ const df = filters({
313
+ hiddenFolders: opts.includeHidden ? [] : opts.hiddenFolders,
314
+ dateFrom: opts.dateFrom, dateTo: opts.dateTo,
315
+ }, 'c');
316
+
317
+ const projects = await q(`
318
+ SELECT folder, source, count() AS count,
319
+ min(COALESCE(c.last_updated_at, c.created_at)) AS first_seen,
320
+ max(COALESCE(c.last_updated_at, c.created_at)) AS last_seen
321
+ FROM chats AS c WHERE folder IS NOT NULL${df.and}
322
+ GROUP BY folder, source ORDER BY folder, count DESC`, df.params);
323
+
324
+ const map = {};
325
+ for (const r of projects) {
326
+ if (!map[r.folder]) map[r.folder] = { folder: r.folder, totalSessions: 0, editors: {}, firstSeen: r.first_seen, lastSeen: r.last_seen };
327
+ map[r.folder].totalSessions += Number(r.count);
328
+ map[r.folder].editors[r.source] = Number(r.count);
329
+ if (r.first_seen && r.first_seen < map[r.folder].firstSeen) map[r.folder].firstSeen = r.first_seen;
330
+ if (r.last_seen && r.last_seen > map[r.folder].lastSeen) map[r.folder].lastSeen = r.last_seen;
331
+ }
332
+
333
+ const result = [];
334
+ for (const [folder, proj] of Object.entries(map)) {
335
+ const stats = await q(`
336
+ SELECT cs.models, cs.tool_calls, cs.total_messages, cs.total_input_tokens, cs.total_output_tokens,
337
+ cs.total_user_chars, cs.total_assistant_chars, cs.total_cache_read, cs.total_cache_write
338
+ FROM chat_stats AS cs JOIN chats AS c ON cs.chat_id = c.id
339
+ WHERE c.folder = {folder:String}${df.and}`, { ...df.params, folder });
340
+
341
+ const modelFreq = {}, toolFreq = {};
342
+ let totalMessages = 0, totalInputTokens = 0, totalOutputTokens = 0;
343
+ let totalUserChars = 0, totalAssistantChars = 0, totalToolCalls = 0, totalCacheRead = 0, totalCacheWrite = 0;
344
+ for (const s of stats) {
345
+ totalMessages += s.total_messages;
346
+ totalInputTokens += s.total_input_tokens;
347
+ totalOutputTokens += s.total_output_tokens;
348
+ totalUserChars += s.total_user_chars;
349
+ totalAssistantChars += s.total_assistant_chars;
350
+ totalCacheRead += s.total_cache_read;
351
+ totalCacheWrite += s.total_cache_write;
352
+ try { for (const m of JSON.parse(s.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch { /* skip */ }
353
+ try { for (const t of JSON.parse(s.tool_calls)) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; } } catch { /* skip */ }
354
+ }
355
+
356
+ let tokensEstimated = false;
357
+ if (totalInputTokens === 0 && totalOutputTokens === 0 && (totalUserChars > 0 || totalAssistantChars > 0)) {
358
+ totalInputTokens = Math.round(totalUserChars / 4);
359
+ totalOutputTokens = Math.round(totalAssistantChars / 4);
360
+ tokensEstimated = true;
361
+ }
362
+
363
+ result.push({
364
+ folder: proj.folder, name: proj.folder.split(/[/\\]/).pop(),
365
+ totalSessions: proj.totalSessions, editors: proj.editors,
366
+ firstSeen: proj.firstSeen, lastSeen: proj.lastSeen,
367
+ totalMessages, totalInputTokens, totalOutputTokens, tokensEstimated,
368
+ totalUserChars, totalAssistantChars, totalToolCalls, totalCacheRead, totalCacheWrite,
369
+ topModels: Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([name, count]) => ({ name, count })),
370
+ topTools: Object.entries(toolFreq).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([name, count]) => ({ name, count })),
371
+ });
372
+ }
373
+ return result.sort((a, b) => b.totalSessions - a.totalSessions);
374
+ }
375
+
376
+ // ── tool calls ──────────────────────────────────────────────────────────────────
377
+ async function getCachedToolCalls(toolName, opts = {}) {
378
+ const limit = opts.limit || 200;
379
+ let sql = `SELECT tc.tool_name, tc.args_json, tc.source, tc.folder, tc.timestamp,
380
+ c.name AS chat_name, tc.chat_id
381
+ FROM tool_calls AS tc JOIN chats AS c ON tc.chat_id = c.id
382
+ WHERE tc.tool_name = {name:String}`;
383
+ const params = { name: toolName, limit };
384
+ if (opts.folder) { sql += ' AND tc.folder = {folder:String}'; params.folder = opts.folder; }
385
+ // Drill-downs honor the same filters as the analytics they are opened from.
386
+ if (opts.hiddenFolders && opts.hiddenFolders.length) {
387
+ sql += ' AND (tc.folder IS NULL OR tc.folder NOT IN {hidden:Array(String)})';
388
+ params.hidden = opts.hiddenFolders;
389
+ }
390
+ if (opts.editor) { sql += ' AND tc.source LIKE {editor:String}'; params.editor = `%${opts.editor}%`; }
391
+ if (opts.dateFrom != null) { sql += ' AND tc.timestamp >= {dateFrom:Int64}'; params.dateFrom = opts.dateFrom; }
392
+ if (opts.dateTo != null) { sql += ' AND tc.timestamp <= {dateTo:Int64}'; params.dateTo = opts.dateTo; }
393
+ sql += ' ORDER BY tc.timestamp DESC LIMIT {limit:UInt64}';
394
+ const rows = await q(sql, params);
395
+ return rows.map(r => ({
396
+ toolName: r.tool_name, args: safeParseJson(r.args_json), source: r.source, folder: r.folder,
397
+ timestamp: r.timestamp, chatName: r.chat_name, chatId: r.chat_id,
398
+ }));
399
+ }
400
+
401
+ // ── dashboard stats ─────────────────────────────────────────────────────────────
402
+ async function getCachedDashboardStats(opts = {}) {
403
+ const f = filters(opts, 'c');
404
+ const p = { ...f.params, tz: TZ };
405
+
406
+ const hourlyRows = await q(`
407
+ SELECT toHour(${dt('COALESCE(last_updated_at, created_at)')}, {tz:String}) AS hour, count() AS count
408
+ FROM chats AS c WHERE (last_updated_at IS NOT NULL OR created_at IS NOT NULL)${f.and}
409
+ GROUP BY hour ORDER BY hour`, p);
410
+ const hourly = new Array(24).fill(0);
411
+ for (const r of hourlyRows) hourly[Number(r.hour)] = Number(r.count);
412
+
413
+ const weekdayRows = await q(`
414
+ SELECT toDayOfWeek(${dt('COALESCE(last_updated_at, created_at)')}, 0, {tz:String}) % 7 AS dow, count() AS count
415
+ FROM chats AS c WHERE (last_updated_at IS NOT NULL OR created_at IS NOT NULL)${f.and}
416
+ GROUP BY dow ORDER BY dow`, p);
417
+ const weekdays = new Array(7).fill(0);
418
+ for (const r of weekdayRows) weekdays[Number(r.dow)] = Number(r.count);
419
+
420
+ const depthRows = await q(`
421
+ SELECT cs.total_messages AS msgs FROM chat_stats AS cs JOIN chats AS c ON cs.chat_id = c.id
422
+ WHERE cs.total_messages > 0${f.and}`, f.params);
423
+ const depthBuckets = { '1': 0, '2-5': 0, '6-10': 0, '11-20': 0, '21-50': 0, '51-100': 0, '100+': 0 };
424
+ for (const r of depthRows) {
425
+ const m = r.msgs;
426
+ if (m <= 1) depthBuckets['1']++;
427
+ else if (m <= 5) depthBuckets['2-5']++;
428
+ else if (m <= 10) depthBuckets['6-10']++;
429
+ else if (m <= 20) depthBuckets['11-20']++;
430
+ else if (m <= 50) depthBuckets['21-50']++;
431
+ else if (m <= 100) depthBuckets['51-100']++;
432
+ else depthBuckets['100+']++;
433
+ }
434
+
435
+ const tokenRow = await q1(`
436
+ SELECT COALESCE(sum(cs.total_input_tokens), 0) AS input, COALESCE(sum(cs.total_output_tokens), 0) AS output,
437
+ COALESCE(sum(cs.total_cache_read), 0) AS cacheRead, COALESCE(sum(cs.total_cache_write), 0) AS cacheWrite,
438
+ COALESCE(sum(cs.total_user_chars), 0) AS userChars, COALESCE(sum(cs.total_assistant_chars), 0) AS assistantChars,
439
+ count() AS sessions
440
+ FROM chat_stats AS cs JOIN chats AS c ON cs.chat_id = c.id WHERE 1=1${f.and}`, f.params);
441
+
442
+ const streakRows = await q(`
443
+ SELECT DISTINCT formatDateTime(${dt('COALESCE(last_updated_at, created_at)')}, '%Y-%m-%d', {tz:String}) AS day
444
+ FROM chats AS c WHERE (last_updated_at IS NOT NULL OR created_at IS NOT NULL)${f.and}
445
+ ORDER BY day`, p);
446
+ let currentStreak = 0, longestStreak = 0, tempStreak = 1;
447
+ const today = new Date().toISOString().split('T')[0];
448
+ for (let i = 1; i < streakRows.length; i++) {
449
+ const diff = (new Date(streakRows[i].day) - new Date(streakRows[i - 1].day)) / 86400000;
450
+ if (diff === 1) tempStreak++;
451
+ else { if (tempStreak > longestStreak) longestStreak = tempStreak; tempStreak = 1; }
452
+ }
453
+ if (tempStreak > longestStreak) longestStreak = tempStreak;
454
+ if (streakRows.length > 0) {
455
+ const daysDiff = (new Date(today) - new Date(streakRows[streakRows.length - 1].day)) / 86400000;
456
+ if (daysDiff <= 1) {
457
+ currentStreak = 1;
458
+ for (let i = streakRows.length - 2; i >= 0; i--) {
459
+ if ((new Date(streakRows[i + 1].day) - new Date(streakRows[i].day)) / 86400000 === 1) currentStreak++;
460
+ else break;
461
+ }
462
+ }
463
+ }
464
+
465
+ const monthEditorRows = await q(`
466
+ SELECT formatDateTime(${dt('COALESCE(last_updated_at, created_at)')}, '%Y-%m', 'UTC') AS month, source, count() AS count
467
+ FROM chats AS c WHERE (last_updated_at IS NOT NULL OR created_at IS NOT NULL)${f.and}
468
+ GROUP BY month, source ORDER BY month`, f.params);
469
+ const monthEditors = {};
470
+ const allSources = new Set();
471
+ for (const r of monthEditorRows) {
472
+ if (!monthEditors[r.month]) monthEditors[r.month] = {};
473
+ monthEditors[r.month][r.source] = Number(r.count);
474
+ allSources.add(r.source);
475
+ }
476
+
477
+ const velocityRows = await q(`
478
+ SELECT formatDateTime(${dt('c.last_updated_at')}, '%Y-%m', 'UTC') AS month,
479
+ avg(cs.total_messages) AS avgMsgs, avg(cs.total_input_tokens + cs.total_output_tokens) AS avgTokens
480
+ FROM chat_stats AS cs JOIN chats AS c ON cs.chat_id = c.id
481
+ WHERE c.last_updated_at IS NOT NULL${f.and} GROUP BY month ORDER BY month`, f.params);
482
+
483
+ const modelRows = await q(`SELECT cs.models FROM chat_stats AS cs JOIN chats AS c ON cs.chat_id = c.id WHERE 1=1${f.and}`, f.params);
484
+ const modelFreq = {};
485
+ for (const r of modelRows) { try { for (const m of JSON.parse(r.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch { /* skip */ } }
486
+ const topModels = Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 10);
487
+
488
+ const toolRows = await q(`SELECT cs.tool_calls FROM chat_stats AS cs JOIN chats AS c ON cs.chat_id = c.id WHERE 1=1${f.and}`, f.params);
489
+ const toolFreq = {};
490
+ let totalToolCalls = 0;
491
+ for (const r of toolRows) { try { for (const t of JSON.parse(r.tool_calls)) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; } } catch { /* skip */ } }
492
+ const topTools = Object.entries(toolFreq).sort((a, b) => b[1] - a[1]).slice(0, 8);
493
+
494
+ let inputTokens = Number(tokenRow.input), outputTokens = Number(tokenRow.output);
495
+ let tokensEstimated = false;
496
+ if (inputTokens === 0 && outputTokens === 0 && (tokenRow.userChars > 0 || tokenRow.assistantChars > 0)) {
497
+ inputTokens = Math.round(tokenRow.userChars / 4);
498
+ outputTokens = Math.round(tokenRow.assistantChars / 4);
499
+ tokensEstimated = true;
500
+ }
501
+
502
+ return {
503
+ hourly, weekdays, depthBuckets,
504
+ tokens: {
505
+ input: inputTokens, output: outputTokens, cacheRead: Number(tokenRow.cacheRead), cacheWrite: Number(tokenRow.cacheWrite),
506
+ userChars: Number(tokenRow.userChars), assistantChars: Number(tokenRow.assistantChars),
507
+ sessions: Number(tokenRow.sessions), estimated: tokensEstimated,
508
+ },
509
+ streaks: { current: currentStreak, longest: longestStreak, totalDays: streakRows.length },
510
+ monthlyTrend: { months: Object.keys(monthEditors).sort(), sources: [...allSources], data: monthEditors },
511
+ velocity: velocityRows.map(r => ({ month: r.month, avgMsgs: Math.round(r.avgMsgs * 10) / 10, avgTokens: Math.round(r.avgTokens) })),
512
+ topModels: topModels.map(([name, count]) => ({ name, count })),
513
+ topTools: topTools.map(([name, count]) => ({ name, count })),
514
+ totalToolCalls,
515
+ };
516
+ }
517
+
518
+ // ── cost estimation ─────────────────────────────────────────────────────────────
519
+ async function estimateCosts(filterSql = '', params = {}) {
520
+ const modelTokens = await q(`
521
+ SELECT m.model AS model, sum(m.input_tokens) AS input, sum(m.output_tokens) AS output,
522
+ sum(m.cache_read) AS cacheRead, sum(m.cache_write) AS cacheWrite
523
+ FROM messages AS m JOIN chats AS c ON m.chat_id = c.id
524
+ WHERE m.model IS NOT NULL AND (m.input_tokens > 0 OR m.output_tokens > 0 OR m.cache_read > 0 OR m.cache_write > 0)${filterSql}
525
+ GROUP BY m.model`, params);
526
+
527
+ const orphanRows = await q(`
528
+ SELECT m.chat_id AS chat_id, sum(m.input_tokens) AS input, sum(m.output_tokens) AS output,
529
+ sum(m.cache_read) AS cacheRead, sum(m.cache_write) AS cacheWrite
530
+ FROM messages AS m JOIN chats AS c ON m.chat_id = c.id
531
+ WHERE m.model IS NULL AND (m.input_tokens > 0 OR m.output_tokens > 0 OR m.cache_read > 0 OR m.cache_write > 0)${filterSql}
532
+ GROUP BY m.chat_id`, params);
533
+
534
+ // Preload chat_id → models once (replaces per-chat lookups).
535
+ const statsModels = {};
536
+ for (const r of await q('SELECT chat_id, models FROM chat_stats')) statsModels[r.chat_id] = r.models;
537
+
538
+ const orphanByModel = {};
539
+ const addOrphan = (dominant, input, output, cacheRead, cacheWrite) => {
540
+ if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
541
+ orphanByModel[dominant].input += input || 0;
542
+ orphanByModel[dominant].output += output || 0;
543
+ orphanByModel[dominant].cacheRead += cacheRead || 0;
544
+ orphanByModel[dominant].cacheWrite += cacheWrite || 0;
545
+ };
546
+ const dominantOf = (modelsJson) => {
547
+ let models; try { models = JSON.parse(modelsJson || '[]'); } catch { return null; }
548
+ if (!models.length) return null;
549
+ const freq = {};
550
+ for (const m of models) freq[m] = (freq[m] || 0) + 1;
551
+ return Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
552
+ };
553
+
554
+ for (const r of orphanRows) {
555
+ const dominant = dominantOf(statsModels[r.chat_id]);
556
+ if (dominant) addOrphan(dominant, r.input, r.output, r.cacheRead, r.cacheWrite);
557
+ }
558
+
559
+ const CHARS_PER_TOKEN = 4;
560
+ const charRows = await q(`
561
+ SELECT cs.models AS models, cs.total_user_chars AS userChars, cs.total_assistant_chars AS asstChars
562
+ FROM chat_stats AS cs JOIN chats AS c ON cs.chat_id = c.id
563
+ WHERE cs.models != '[]' AND cs.total_input_tokens = 0 AND cs.total_output_tokens = 0
564
+ AND (cs.total_user_chars > 0 OR cs.total_assistant_chars > 0)${filterSql}`, params);
565
+ for (const r of charRows) {
566
+ const dominant = dominantOf(r.models);
567
+ if (dominant) addOrphan(dominant, Math.round((r.userChars || 0) / CHARS_PER_TOKEN), Math.round((r.asstChars || 0) / CHARS_PER_TOKEN), 0, 0);
568
+ }
569
+
570
+ const unmodeledRows = await q(`
571
+ SELECT c.source AS source, cs.total_input_tokens AS input, cs.total_output_tokens AS output,
572
+ cs.total_cache_read AS cacheRead, cs.total_cache_write AS cacheWrite
573
+ FROM chat_stats AS cs JOIN chats AS c ON cs.chat_id = c.id
574
+ WHERE cs.models = '[]' AND (cs.total_input_tokens > 0 OR cs.total_output_tokens > 0)${filterSql}`, params);
575
+ if (unmodeledRows.length > 0) {
576
+ const allSessions = await q(`
577
+ SELECT c.source AS source, cs.models AS models FROM chat_stats AS cs JOIN chats AS c ON cs.chat_id = c.id
578
+ WHERE cs.models != '[]'${filterSql}`, params);
579
+ const sourceModelFreq = {};
580
+ for (const s of allSessions) {
581
+ let models; try { models = JSON.parse(s.models || '[]'); } catch { continue; }
582
+ if (!sourceModelFreq[s.source]) sourceModelFreq[s.source] = {};
583
+ for (const m of models) sourceModelFreq[s.source][m] = (sourceModelFreq[s.source][m] || 0) + 1;
584
+ }
585
+ const globalFreq = {};
586
+ for (const sf of Object.values(sourceModelFreq)) for (const [m, c] of Object.entries(sf)) globalFreq[m] = (globalFreq[m] || 0) + c;
587
+ const globalDominant = Object.entries(globalFreq).sort((a, b) => b[1] - a[1])[0]?.[0];
588
+ for (const r of unmodeledRows) {
589
+ const sf = sourceModelFreq[r.source];
590
+ const dominant = sf ? Object.entries(sf).sort((a, b) => b[1] - a[1])[0]?.[0] : globalDominant;
591
+ if (dominant) addOrphan(dominant, r.input, r.output, r.cacheRead, r.cacheWrite);
592
+ }
593
+ }
594
+
595
+ const tokenMap = {};
596
+ const addTokens = (rawModel, input, output, cacheRead, cacheWrite) => {
597
+ const key = normalizeModelName(rawModel) || rawModel;
598
+ if (!tokenMap[key]) tokenMap[key] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
599
+ tokenMap[key].input += input || 0;
600
+ tokenMap[key].output += output || 0;
601
+ tokenMap[key].cacheRead += cacheRead || 0;
602
+ tokenMap[key].cacheWrite += cacheWrite || 0;
603
+ };
604
+ for (const row of modelTokens) addTokens(row.model, row.input, row.output, row.cacheRead, row.cacheWrite);
605
+ for (const [model, tok] of Object.entries(orphanByModel)) addTokens(model, tok.input, tok.output, tok.cacheRead, tok.cacheWrite);
606
+
607
+ let totalCost = 0;
608
+ let unknownModels = [];
609
+ const byModel = [];
610
+ for (const [model, tok] of Object.entries(tokenMap)) {
611
+ const cost = calculateCost(model, tok.input, tok.output, tok.cacheRead, tok.cacheWrite);
612
+ if (cost !== null) {
613
+ totalCost += cost;
614
+ byModel.push({ model, inputTokens: tok.input, outputTokens: tok.output, cacheRead: tok.cacheRead, cacheWrite: tok.cacheWrite, cost });
615
+ } else unknownModels.push(model);
616
+ }
617
+ byModel.sort((a, b) => b.cost - a.cost);
618
+ unknownModels = [...new Set(unknownModels)];
619
+ return { totalCost, byModel, unknownModels };
620
+ }
621
+
622
+ async function getCostBreakdown(opts = {}) {
623
+ const f = filters(opts, 'c', { editorLike: true });
624
+ const params = { ...f.params };
625
+ let sql = f.and;
626
+ if (opts.chatId) { sql += ' AND c.id = {chatId:String}'; params.chatId = opts.chatId; }
627
+ return estimateCosts(sql, params);
628
+ }
629
+
630
+ // Per-chat cost attribution for a filter, in a FIXED number of bulk queries
631
+ // (3) instead of the former N+1 (~5 per session). Mirrors estimateCosts' logic
632
+ // but keyed per chat; because calculateCost is linear in tokens, the per-chat
633
+ // costs sum to the same overall total as estimateCosts.
634
+ async function computePerChatCosts(filterSql, params) {
635
+ const aRows = await q(`
636
+ SELECT m.chat_id AS chat_id, m.model AS model, sum(m.input_tokens) AS i, sum(m.output_tokens) AS o,
637
+ sum(m.cache_read) AS cr, sum(m.cache_write) AS cw
638
+ FROM messages AS m JOIN chats AS c ON m.chat_id = c.id
639
+ WHERE m.model IS NOT NULL AND (m.input_tokens > 0 OR m.output_tokens > 0 OR m.cache_read > 0 OR m.cache_write > 0)${filterSql}
640
+ GROUP BY chat_id, model`, params);
641
+ const byChatModel = {};
642
+ for (const r of aRows) (byChatModel[r.chat_id] = byChatModel[r.chat_id] || []).push(r);
643
+
644
+ const bRows = await q(`
645
+ SELECT m.chat_id AS chat_id, sum(m.input_tokens) AS i, sum(m.output_tokens) AS o,
646
+ sum(m.cache_read) AS cr, sum(m.cache_write) AS cw
647
+ FROM messages AS m JOIN chats AS c ON m.chat_id = c.id
648
+ WHERE m.model IS NULL AND (m.input_tokens > 0 OR m.output_tokens > 0 OR m.cache_read > 0 OR m.cache_write > 0)${filterSql}
649
+ GROUP BY chat_id`, params);
650
+ const orphanByChat = {};
651
+ for (const r of bRows) orphanByChat[r.chat_id] = r;
652
+
653
+ const cRows = await q(`
654
+ SELECT c.id AS id, c.source AS source, c.name AS name, c.folder AS folder,
655
+ c.last_updated_at AS last_updated_at, c.created_at AS created_at,
656
+ cs.total_messages AS msgs, cs.models AS models,
657
+ cs.total_user_chars AS uc, cs.total_assistant_chars AS ac,
658
+ cs.total_input_tokens AS ti, cs.total_output_tokens AS to_,
659
+ cs.total_cache_read AS cr, cs.total_cache_write AS cw,
660
+ formatDateTime(${dt('COALESCE(c.last_updated_at, c.created_at)')}, '%Y-%m', 'UTC') AS month
661
+ FROM chat_stats AS cs JOIN chats AS c ON cs.chat_id = c.id WHERE 1=1${filterSql}`, params);
662
+
663
+ // source → dominant model (over chats that name a model) + global fallback.
664
+ const sourceModelFreq = {};
665
+ for (const r of cRows) {
666
+ if (r.models === '[]') continue;
667
+ let models; try { models = JSON.parse(r.models || '[]'); } catch { continue; }
668
+ if (!sourceModelFreq[r.source]) sourceModelFreq[r.source] = {};
669
+ for (const m of models) sourceModelFreq[r.source][m] = (sourceModelFreq[r.source][m] || 0) + 1;
670
+ }
671
+ const globalFreq = {};
672
+ for (const sf of Object.values(sourceModelFreq)) for (const [m, c] of Object.entries(sf)) globalFreq[m] = (globalFreq[m] || 0) + c;
673
+ const globalDominant = Object.entries(globalFreq).sort((a, b) => b[1] - a[1])[0]?.[0];
674
+ const sourceDominant = {};
675
+ for (const [src, fr] of Object.entries(sourceModelFreq)) sourceDominant[src] = Object.entries(fr).sort((a, b) => b[1] - a[1])[0]?.[0];
676
+
677
+ const CHARS_PER_TOKEN = 4;
678
+ const out = [];
679
+ for (const c of cRows) {
680
+ const hasModels = c.models !== '[]';
681
+ const dominant = dominantOfModels(c.models);
682
+ const tokenMap = {};
683
+ const add = (rawModel, i, o, cr, cw) => {
684
+ if (!rawModel) return;
685
+ const key = normalizeModelName(rawModel) || rawModel;
686
+ if (!tokenMap[key]) tokenMap[key] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
687
+ tokenMap[key].input += i || 0; tokenMap[key].output += o || 0; tokenMap[key].cacheRead += cr || 0; tokenMap[key].cacheWrite += cw || 0;
688
+ };
689
+ for (const r of (byChatModel[c.id] || [])) add(r.model, r.i, r.o, r.cr, r.cw);
690
+ const orphan = orphanByChat[c.id];
691
+ if (orphan && dominant) add(dominant, orphan.i, orphan.o, orphan.cr, orphan.cw);
692
+ if (hasModels && c.ti === 0 && c.to_ === 0 && (c.uc > 0 || c.ac > 0) && dominant)
693
+ add(dominant, Math.round((c.uc || 0) / CHARS_PER_TOKEN), Math.round((c.ac || 0) / CHARS_PER_TOKEN), 0, 0);
694
+ if (!hasModels && (c.ti > 0 || c.to_ > 0)) {
695
+ const srcDom = sourceDominant[c.source] || globalDominant;
696
+ if (srcDom) add(srcDom, c.ti, c.to_, c.cr, c.cw);
697
+ }
698
+ let totalCost = 0; const byModel = [];
699
+ for (const [model, tok] of Object.entries(tokenMap)) {
700
+ const cost = calculateCost(model, tok.input, tok.output, tok.cacheRead, tok.cacheWrite);
701
+ if (cost !== null) { totalCost += cost; byModel.push({ model, cost }); }
702
+ }
703
+ byModel.sort((a, b) => b.cost - a.cost);
704
+ out.push({ id: c.id, source: c.source, name: c.name, folder: c.folder, last_updated_at: c.last_updated_at, created_at: c.created_at, msgs: c.msgs, month: c.month, totalCost, byModel });
705
+ }
706
+ return out;
707
+ }
708
+
709
+ async function getCostAnalytics(opts = {}) {
710
+ const f = filters(opts, 'c', { editorLike: true });
711
+ const overall = await getCostBreakdown(opts);
712
+ const perChat = await computePerChatCosts(f.and, f.params);
713
+
714
+ const editorAgg = {}, projectAgg = {}, monthCosts = {};
715
+ const sessionCosts = [];
716
+ for (const r of perChat) {
717
+ // monthly counts every measured session (cost may be 0)
718
+ if (r.month) {
719
+ if (!monthCosts[r.month]) monthCosts[r.month] = { cost: 0, sessions: 0 };
720
+ monthCosts[r.month].cost += r.totalCost;
721
+ monthCosts[r.month].sessions++;
722
+ }
723
+ if (r.totalCost <= 0) continue;
724
+ if (!editorAgg[r.source]) editorAgg[r.source] = { cost: 0, models: new Set() };
725
+ editorAgg[r.source].cost += r.totalCost;
726
+ for (const m of r.byModel) editorAgg[r.source].models.add(m.model);
727
+ if (r.folder) projectAgg[r.folder] = (projectAgg[r.folder] || 0) + r.totalCost;
728
+ sessionCosts.push({
729
+ id: r.id, source: r.source, name: r.name, folder: r.folder,
730
+ model: r.byModel[0]?.model || null,
731
+ cost: r.totalCost, messages: r.msgs || 0, lastUpdatedAt: r.last_updated_at || r.created_at,
732
+ });
733
+ }
734
+ const byEditor = Object.entries(editorAgg).map(([editor, d]) => ({ editor, cost: d.cost, models: d.models.size })).sort((a, b) => b.cost - a.cost);
735
+ const byProject = Object.entries(projectAgg).map(([folder, cost]) => ({ folder, name: folder.split('/').pop(), cost })).sort((a, b) => b.cost - a.cost).slice(0, 20);
736
+ const monthly = Object.entries(monthCosts).sort((a, b) => a[0].localeCompare(b[0])).map(([month, d]) => ({ month, cost: Math.round(d.cost * 100) / 100, sessions: d.sessions }));
737
+ sessionCosts.sort((a, b) => b.cost - a.cost);
738
+
739
+ const totalSessions = sessionCosts.length;
740
+ const avgPerSession = totalSessions > 0 ? overall.totalCost / totalSessions : 0;
741
+ const totalDays = monthly.length > 0 ? (() => {
742
+ const first = new Date(monthly[0].month + '-01');
743
+ const last = new Date(monthly[monthly.length - 1].month + '-01');
744
+ return Math.max(1, Math.ceil((last - first) / 86400000) + 30);
745
+ })() : 1;
746
+ const avgPerDay = overall.totalCost / totalDays;
747
+
748
+ return {
749
+ totalCost: overall.totalCost, byModel: overall.byModel, unknownModels: overall.unknownModels,
750
+ byEditor, byProject, monthly, topSessions: sessionCosts.slice(0, 50),
751
+ summary: { totalSessions, avgPerSession: Math.round(avgPerSession * 100) / 100, avgPerDay: Math.round(avgPerDay * 100) / 100, totalDays },
752
+ };
753
+ }
754
+
755
+ // ── raw SQL / schema (for the SqlViewer + /api/query, /api/schema) ───────────────
756
+ async function rawQuery(sql) {
757
+ const rows = await q(sql);
758
+ const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
759
+ return { columns, rows, count: rows.length };
760
+ }
761
+
762
+ async function schema() {
763
+ const tbls = await q(`SELECT name FROM system.tables WHERE database = currentDatabase() AND name IN {t:Array(String)} ORDER BY name`, { t: DATA_TABLES });
764
+ const out = {};
765
+ for (const { name } of tbls) {
766
+ out[name] = await q(`SELECT name, type FROM system.columns WHERE database = currentDatabase() AND table = {tbl:String} ORDER BY position`, { tbl: name });
767
+ }
768
+ return { tables: tbls.map(t => t.name), schema: out };
769
+ }
770
+
771
+ // Tool-call rows joined to chat name (for /api/mcps).
772
+ async function getAllToolCallRows() {
773
+ return q(`
774
+ SELECT tc.tool_name AS tool_name, tc.source AS source, tc.chat_id AS chat_id, tc.folder AS folder,
775
+ tc.timestamp AS timestamp, c.name AS chat_name
776
+ FROM tool_calls AS tc JOIN chats AS c ON tc.chat_id = c.id
777
+ ORDER BY tc.timestamp DESC`);
778
+ }
779
+
780
+ // ── GSD ─────────────────────────────────────────────────────────────────────────
781
+ async function getCachedGSDProjects() {
782
+ const projects = await q('SELECT * FROM gsd_projects ORDER BY last_modified DESC');
783
+ for (const p of projects) {
784
+ try { p.total_cost = (await getGSDPhaseTokens(p.folder)).reduce((s, r) => s + (r.cost || 0), 0); }
785
+ catch { p.total_cost = 0; }
786
+ }
787
+ return projects;
788
+ }
789
+
790
+ async function getCachedGSDPhases(folder) {
791
+ return q('SELECT * FROM gsd_phases WHERE folder = {folder:String} ORDER BY phase_number ASC', { folder });
792
+ }
793
+
794
+ async function getGSDPhaseTokens(folder) {
795
+ const phases = await q('SELECT id, phase_number, phase_name, status, last_modified FROM gsd_phases WHERE folder = {folder:String} ORDER BY phase_number ASC', { folder });
796
+ if (phases.length === 0) return [];
797
+
798
+ const byTime = [...phases].filter(p => p.last_modified).sort((a, b) => a.last_modified - b.last_modified);
799
+ const windowMap = new Map();
800
+ for (let i = 0; i < byTime.length; i++) {
801
+ const start = i === 0 ? 0 : byTime[i - 1].last_modified;
802
+ const end = i === byTime.length - 1 ? Date.now() : byTime[i].last_modified;
803
+ windowMap.set(byTime[i].id, { start, end });
804
+ }
805
+
806
+ const out = [];
807
+ for (const ph of phases) {
808
+ const win = windowMap.get(ph.id);
809
+ let totalInput = 0, totalOutput = 0, totalCacheRead = 0, totalCacheWrite = 0, sessionCount = 0;
810
+ const modelFreq = {};
811
+ if (win) {
812
+ const rows = await q(`
813
+ SELECT cs.total_input_tokens AS ti, cs.total_output_tokens AS to_, cs.total_cache_read AS cr, cs.total_cache_write AS cw, cs.models AS models
814
+ FROM chats AS c JOIN chat_stats AS cs ON cs.chat_id = c.id
815
+ WHERE c.folder = {folder:String} AND COALESCE(c.last_updated_at, c.created_at) BETWEEN {start:Int64} AND {end:Int64}`,
816
+ { folder, start: win.start, end: win.end });
817
+ for (const row of rows) {
818
+ totalInput += row.ti || 0; totalOutput += row.to_ || 0; totalCacheRead += row.cr || 0; totalCacheWrite += row.cw || 0; sessionCount++;
819
+ try { for (const m of JSON.parse(row.models || '[]')) { const key = typeof m === 'string' ? m : (m && m.model); if (key) modelFreq[key] = (modelFreq[key] || 0) + 1; } } catch { /* skip */ }
820
+ }
821
+ }
822
+ const dominantModel = Object.entries(modelFreq).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
823
+ const cost = dominantModel ? (calculateCost(dominantModel, totalInput, totalOutput, totalCacheRead, totalCacheWrite) || 0) : 0;
824
+ out.push({
825
+ id: ph.id, phase_number: ph.phase_number, phase_name: ph.phase_name, status: ph.status,
826
+ total_tokens: totalInput + totalOutput, cost, session_count: sessionCount,
827
+ });
828
+ }
829
+ return out;
830
+ }
831
+
832
+ async function getCachedGSDOverview() {
833
+ const projects = await getCachedGSDProjects();
834
+ const totalProjects = projects.length;
835
+ const totalPhases = projects.reduce((s, p) => s + p.total_phases, 0);
836
+ const completedPhases = projects.reduce((s, p) => s + p.completed_phases, 0);
837
+ const activePhases = projects.filter(p => p.active_phase).map(p => ({ folder: p.folder, name: p.name, activePhase: p.active_phase }));
838
+ const executingPhases = Number((await q1("SELECT count() AS c FROM gsd_phases WHERE status = {s:String}", { s: 'executing' })).c);
839
+ const plannedPhases = Number((await q1("SELECT count() AS c FROM gsd_phases WHERE status = {s:String}", { s: 'planned' })).c);
840
+ return { totalProjects, totalPhases, completedPhases, activePhases, executingPhases, plannedPhases };
841
+ }
842
+
843
+ module.exports = {
844
+ initDb, scanAll, scanAllAsync, resetAndRescanAsync, clearAll, cacheGSDProjects,
845
+ getCachedChats, countCachedChats, getCachedOverview, getCachedDailyActivity,
846
+ getCachedDeepAnalytics, getCachedChat, getCachedProjects, getCachedToolCalls,
847
+ getCachedDashboardStats, getCostBreakdown, getCostAnalytics,
848
+ rawQuery, schema, getAllToolCallRows,
849
+ getCachedGSDProjects, getCachedGSDPhases, getCachedGSDOverview, getGSDPhaseTokens,
850
+ };