memhouse 0.0.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +72 -3
  3. package/bin/memhouse.js +559 -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/adapter-errors.js +52 -0
  9. package/editors/antigravity.js +1048 -0
  10. package/editors/base.js +321 -0
  11. package/editors/claude.js +386 -0
  12. package/editors/codebuff.js +338 -0
  13. package/editors/codex.js +537 -0
  14. package/editors/commandcode.js +166 -0
  15. package/editors/copilot-jetbrains.js +207 -0
  16. package/editors/copilot.js +260 -0
  17. package/editors/cursor-agent.js +202 -0
  18. package/editors/cursor.js +498 -0
  19. package/editors/gemini.js +231 -0
  20. package/editors/goose.js +386 -0
  21. package/editors/gsd.js +366 -0
  22. package/editors/index.js +274 -0
  23. package/editors/kiro.js +315 -0
  24. package/editors/opencode.js +432 -0
  25. package/editors/vscode.js +413 -0
  26. package/editors/windsurf.js +634 -0
  27. package/editors/zed.js +229 -0
  28. package/index.js +424 -2
  29. package/mcp-server.js +294 -0
  30. package/mem-house/DESIGN.md +119 -0
  31. package/mem-house/delivery/AGENT-INSTALL.md +87 -0
  32. package/mem-house/delivery/PROMPT.md +42 -0
  33. package/mem-house/delivery/install.sh +181 -0
  34. package/mem-house/delivery/kernel-install.md +83 -0
  35. package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
  36. package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
  37. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
  38. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
  39. package/mem-house/rls.sql +17 -0
  40. package/mem-house/schema.sql +145 -0
  41. package/mem-house/server/queries.js +878 -0
  42. package/mem-house/server/server.js +228 -0
  43. package/mem-house/shipper/ship.js +461 -0
  44. package/misc/syntax-check.js +38 -0
  45. package/package.json +59 -7
  46. package/pricing.js +88 -0
  47. package/pricing.json +992 -0
  48. package/public/assets/index-CCPh7a2Z.js +75 -0
  49. package/public/assets/index-DV6ONi_F.css +2 -0
  50. package/public/index.html +16 -0
  51. package/relay-client.js +306 -0
  52. package/relay-server.js +577 -0
  53. package/server.js +860 -0
  54. package/share-image.js +367 -0
  55. package/ui/eslint.config.js +29 -0
  56. package/ui/index.html +15 -0
  57. package/ui/package-lock.json +4229 -0
  58. package/ui/package.json +38 -0
  59. package/ui/src/App.jsx +364 -0
  60. package/ui/src/components/ActivityHeatmap.jsx +206 -0
  61. package/ui/src/components/AiAuditCard.jsx +254 -0
  62. package/ui/src/components/AnimatedLoader.jsx +14 -0
  63. package/ui/src/components/AnimatedLogo.jsx +96 -0
  64. package/ui/src/components/ChatSidebar.jsx +226 -0
  65. package/ui/src/components/DateRangePicker.jsx +104 -0
  66. package/ui/src/components/EditorDot.jsx +11 -0
  67. package/ui/src/components/EditorIcon.jsx +117 -0
  68. package/ui/src/components/KpiCard.jsx +9 -0
  69. package/ui/src/components/LiveFeed.jsx +128 -0
  70. package/ui/src/components/LoginScreen.jsx +81 -0
  71. package/ui/src/components/MessageRenderer.jsx +148 -0
  72. package/ui/src/components/PageHeader.jsx +11 -0
  73. package/ui/src/components/SectionTitle.jsx +3 -0
  74. package/ui/src/components/ShareModal.jsx +305 -0
  75. package/ui/src/components/TokenTimeline.jsx +258 -0
  76. package/ui/src/hooks/useLive.jsx +15 -0
  77. package/ui/src/index.css +83 -0
  78. package/ui/src/lib/api.js +343 -0
  79. package/ui/src/lib/constants.js +88 -0
  80. package/ui/src/lib/theme.jsx +23 -0
  81. package/ui/src/main.jsx +16 -0
  82. package/ui/src/pages/Artifacts.jsx +575 -0
  83. package/ui/src/pages/Compare.jsx +232 -0
  84. package/ui/src/pages/CostAnalysis.jsx +354 -0
  85. package/ui/src/pages/Dashboard.jsx +529 -0
  86. package/ui/src/pages/DeepAnalysis.jsx +495 -0
  87. package/ui/src/pages/GSD.jsx +726 -0
  88. package/ui/src/pages/MCPs.jsx +744 -0
  89. package/ui/src/pages/ProjectDetail.jsx +325 -0
  90. package/ui/src/pages/Projects.jsx +249 -0
  91. package/ui/src/pages/RelayDashboard.jsx +590 -0
  92. package/ui/src/pages/RelayUserDetail.jsx +466 -0
  93. package/ui/src/pages/Sessions.jsx +535 -0
  94. package/ui/src/pages/Settings.jsx +241 -0
  95. package/ui/src/pages/SqlViewer.jsx +370 -0
  96. package/ui/src/pages/Subscriptions.jsx +411 -0
  97. package/ui/vite.config.js +18 -0
@@ -0,0 +1,878 @@
1
+ // queries.js — mem-house read/query layer.
2
+ //
3
+ // Serves the SAME response shapes as the root cache.js (the agentlytics API
4
+ // contract) but computed over the mem-house typed schema (sessions, messages,
5
+ // tool_calls + sessions_v view; see ../schema.sql). The dashboard SPA consumes
6
+ // these shapes unchanged.
7
+ //
8
+ // Schema mapping notes (root cache schema → mem-house):
9
+ // chats → sessions / sessions_v (id → session_id, bubble_count → total_msgs)
10
+ // chat_stats → sessions_v aggregates (token/char sums, models array)
11
+ // messages.content → messages.text (cache_read → cache_read_tokens, …)
12
+ // tool_calls → tool_calls (args_json → args, chat_id → session_id)
13
+ // epoch-ms columns → DateTime64(3,'UTC') (converted with toUnixTimestamp64Milli)
14
+ //
15
+ // All reads run with the `final: 1` setting so ReplacingMergeTree collapses
16
+ // duplicate versions at read time (see DESIGN.md data-plane rules).
17
+
18
+ const { createClient } = require('@clickhouse/client');
19
+ const { calculateCost, normalizeModelName } = require('../../pricing');
20
+
21
+ const config = {
22
+ url: process.env.MEMHOUSE_URL || 'http://localhost:8123',
23
+ username: process.env.MEMHOUSE_USER || 'memhouse_root',
24
+ password: process.env.MEMHOUSE_PASSWORD || '',
25
+ database: process.env.MEMHOUSE_DB || 'memhouse',
26
+ };
27
+
28
+ let _client = null;
29
+ function getClient() {
30
+ if (_client) return _client;
31
+ _client = createClient({
32
+ url: config.url,
33
+ username: config.username,
34
+ password: config.password,
35
+ database: config.database,
36
+ request_timeout: 60000,
37
+ clickhouse_settings: {
38
+ final: 1,
39
+ output_format_json_quote_64bit_integers: 0,
40
+ },
41
+ });
42
+ return _client;
43
+ }
44
+
45
+ const TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
46
+
47
+ async function q(query, params = {}) {
48
+ const rs = await getClient().query({ query, query_params: params, format: 'JSONEachRow' });
49
+ return rs.json();
50
+ }
51
+ async function q1(query, params = {}) { return (await q(query, params))[0]; }
52
+ function safeParseJson(s) { try { return JSON.parse(s); } catch { return {}; } }
53
+
54
+ // Session timestamp: prefer adapter's last_updated_at, then created_at, then the
55
+ // message-derived start (always present in sessions_v). All UTC DateTime64(3).
56
+ const TS = "COALESCE(c.last_updated_at, c.created_at, c.started)";
57
+ const MS = `toUnixTimestamp64Milli(${TS})`;
58
+ const EXCLUDED_MODELS = "('', '<synthetic>')";
59
+
60
+ // ── shared filter builder (named params) — mirrors root cache.js filters() ──────
61
+ // Applies over sessions_v aliased `c`. editorLike/folderLike toggle LIKE-substring
62
+ // vs exact equality, matching each root getter's behavior.
63
+ function filters(opts, { editorLike = false, folderLike = false } = {}) {
64
+ const parts = [];
65
+ const params = {};
66
+ if (opts.hiddenFolders && opts.hiddenFolders.length) {
67
+ parts.push('c.folder NOT IN {hidden:Array(String)}');
68
+ params.hidden = opts.hiddenFolders;
69
+ }
70
+ if (opts.editor) {
71
+ if (editorLike) { parts.push('c.source LIKE {editor:String}'); params.editor = `%${opts.editor}%`; }
72
+ else { parts.push('c.source = {editor:String}'); params.editor = opts.editor; }
73
+ }
74
+ if (opts.folder) {
75
+ if (folderLike) { parts.push('c.folder LIKE {folder:String}'); params.folder = `%${opts.folder}%`; }
76
+ else { parts.push('c.folder = {folder:String}'); params.folder = opts.folder; }
77
+ }
78
+ if (opts.chatId) {
79
+ // Accept the composite 'session_id::user_id' id form getChats emits.
80
+ // Last-separator split: codebuff session ids legitimately contain '::'.
81
+ const cut = String(opts.chatId).lastIndexOf('::');
82
+ const sid = cut === -1 ? String(opts.chatId) : String(opts.chatId).slice(0, cut);
83
+ const uid = cut === -1 ? undefined : String(opts.chatId).slice(cut + 2);
84
+ parts.push('c.session_id = {chatId:String}'); params.chatId = sid;
85
+ if (uid) { parts.push('c.user_id = {chatUid:String}'); params.chatUid = uid; }
86
+ }
87
+ if (opts.dateFrom != null) { parts.push(`${MS} >= {dateFrom:Int64}`); params.dateFrom = opts.dateFrom; }
88
+ if (opts.dateTo != null) { parts.push(`${MS} <= {dateTo:Int64}`); params.dateTo = opts.dateTo; }
89
+ return {
90
+ and: parts.length ? ' AND ' + parts.join(' AND ') : '',
91
+ where: parts.length ? ' WHERE ' + parts.join(' AND ') : '',
92
+ params,
93
+ };
94
+ }
95
+
96
+ // Restrict a messages/tool_calls query to the filtered session set. Empty filter →
97
+ // no restriction (avoids a pointless subquery). Matches the FULL (session_id,
98
+ // user_id) key: with session_id alone, an owner/no-RLS reader filtering by
99
+ // folder/date/editor would pull EVERY member's rows for a colliding adapter-local
100
+ // session_id into the filtered aggregate.
101
+ function inSessions(f, col) {
102
+ if (!f.and) return '';
103
+ const alias = col.split('.')[0];
104
+ return ` AND (${alias}.session_id, ${alias}.user_id) IN (SELECT session_id, user_id FROM sessions_v AS c WHERE 1=1${f.and})`;
105
+ }
106
+
107
+ // Fold a [{name|model, cnt}] list through normalizeModelName into a freq map.
108
+ function normalizedModelFreq(rows) {
109
+ const freq = {};
110
+ for (const r of rows) {
111
+ const k = normalizeModelName(r.model) || r.model;
112
+ freq[k] = (freq[k] || 0) + Number(r.cnt);
113
+ }
114
+ return freq;
115
+ }
116
+ const topN = (freq, n) => Object.entries(freq).sort((a, b) => b[1] - a[1]).slice(0, n)
117
+ .map(([name, count]) => ({ name, count }));
118
+
119
+ // ── overview ────────────────────────────────────────────────────────────────────
120
+ async function getOverview(opts = {}) {
121
+ const f = filters(opts);
122
+ const totalChats = Number((await q1(`SELECT count() AS cnt FROM sessions_v AS c WHERE 1=1${f.and}`, f.params)).cnt);
123
+
124
+ // Root parity: without a folder filter the editor breakdown is global.
125
+ const editors = opts.folder
126
+ ? await q(`SELECT source, count() AS count FROM sessions_v AS c WHERE 1=1${f.and} GROUP BY source ORDER BY count DESC`, f.params)
127
+ : await q('SELECT source, count() AS count FROM sessions_v GROUP BY source ORDER BY count DESC');
128
+
129
+ const modes = await q(`SELECT mode, count() AS count FROM sessions_v AS c WHERE mode != ''${f.and} GROUP BY mode`, f.params);
130
+ const byMode = {};
131
+ for (const m of modes) byMode[m.mode] = Number(m.count);
132
+
133
+ const rows = await q(`
134
+ SELECT formatDateTime(${TS}, '%Y-%m', 'UTC') AS month, source, count() AS count
135
+ FROM sessions_v AS c WHERE 1=1${f.and}
136
+ GROUP BY month, source ORDER BY month`, f.params);
137
+ const monthMap = {};
138
+ for (const r of rows) {
139
+ if (!monthMap[r.month]) monthMap[r.month] = { count: 0, editors: {} };
140
+ monthMap[r.month].count += Number(r.count);
141
+ monthMap[r.month].editors[r.source] = Number(r.count);
142
+ }
143
+ const byMonth = Object.keys(monthMap).sort().map(m => ({ month: m, ...monthMap[m] }));
144
+
145
+ const projects = await q(`
146
+ SELECT folder, count() AS count FROM sessions_v AS c
147
+ WHERE folder != ''${f.and} GROUP BY folder ORDER BY count DESC LIMIT 20`, f.params);
148
+ const topProjects = projects.map(p => ({
149
+ name: p.folder.split(/[/\\]/).slice(-2).join('/'),
150
+ fullPath: p.folder,
151
+ count: Number(p.count),
152
+ }));
153
+
154
+ const range = await q1(`SELECT min(${MS}) AS oldest, max(${MS}) AS newest FROM sessions_v AS c WHERE 1=1${f.and}`, f.params);
155
+
156
+ return {
157
+ totalChats,
158
+ editors: editors.map(e => ({ id: e.source, count: Number(e.count) })),
159
+ byMode, byMonth, topProjects,
160
+ oldestChat: range ? range.oldest : null,
161
+ newestChat: range ? range.newest : null,
162
+ };
163
+ }
164
+
165
+ // ── daily activity ──────────────────────────────────────────────────────────────
166
+ async function getDailyActivity(opts = {}) {
167
+ const f = filters(opts);
168
+ const params = { ...f.params, tz: TZ };
169
+ const rows = await q(`
170
+ SELECT formatDateTime(${TS}, '%Y-%m-%d', {tz:String}) AS day,
171
+ source,
172
+ toHour(${TS}, {tz:String}) AS hour,
173
+ count() AS count
174
+ FROM sessions_v AS c WHERE 1=1${f.and}
175
+ GROUP BY day, source, hour ORDER BY day`, params);
176
+
177
+ const daily = {};
178
+ for (const r of rows) {
179
+ if (!daily[r.day]) daily[r.day] = { total: 0, editors: {}, hours: {} };
180
+ const cnt = Number(r.count);
181
+ daily[r.day].total += cnt;
182
+ daily[r.day].editors[r.source] = (daily[r.day].editors[r.source] || 0) + cnt;
183
+ if (!daily[r.day].hours[r.source]) daily[r.day].hours[r.source] = new Array(24).fill(0);
184
+ daily[r.day].hours[r.source][Number(r.hour)] += cnt;
185
+ }
186
+ return Object.keys(daily).sort().map(day => ({ day, ...daily[day] }));
187
+ }
188
+
189
+ // ── dashboard stats ─────────────────────────────────────────────────────────────
190
+ async function getDashboardStats(opts = {}) {
191
+ const f = filters(opts);
192
+ const p = { ...f.params, tz: TZ };
193
+
194
+ const hourlyRows = await q(`
195
+ SELECT toHour(${TS}, {tz:String}) AS hour, count() AS count
196
+ FROM sessions_v AS c WHERE 1=1${f.and} GROUP BY hour ORDER BY hour`, p);
197
+ const hourly = new Array(24).fill(0);
198
+ for (const r of hourlyRows) hourly[Number(r.hour)] = Number(r.count);
199
+
200
+ const weekdayRows = await q(`
201
+ SELECT toDayOfWeek(${TS}, 0, {tz:String}) % 7 AS dow, count() AS count
202
+ FROM sessions_v AS c WHERE 1=1${f.and} GROUP BY dow ORDER BY dow`, p);
203
+ const weekdays = new Array(7).fill(0);
204
+ for (const r of weekdayRows) weekdays[Number(r.dow)] = Number(r.count);
205
+
206
+ const depthRows = await q(`
207
+ SELECT total_msgs AS msgs FROM sessions_v AS c WHERE total_msgs > 0${f.and}`, f.params);
208
+ const depthBuckets = { '1': 0, '2-5': 0, '6-10': 0, '11-20': 0, '21-50': 0, '51-100': 0, '100+': 0 };
209
+ for (const r of depthRows) {
210
+ const m = Number(r.msgs);
211
+ if (m <= 1) depthBuckets['1']++;
212
+ else if (m <= 5) depthBuckets['2-5']++;
213
+ else if (m <= 10) depthBuckets['6-10']++;
214
+ else if (m <= 20) depthBuckets['11-20']++;
215
+ else if (m <= 50) depthBuckets['21-50']++;
216
+ else if (m <= 100) depthBuckets['51-100']++;
217
+ else depthBuckets['100+']++;
218
+ }
219
+
220
+ const tokenRow = await q1(`
221
+ SELECT COALESCE(sum(input_tokens), 0) AS input, COALESCE(sum(output_tokens), 0) AS output,
222
+ COALESCE(sum(cache_read_tokens), 0) AS cacheRead, COALESCE(sum(cache_write_tokens), 0) AS cacheWrite,
223
+ COALESCE(sum(user_chars), 0) AS userChars, COALESCE(sum(assistant_chars), 0) AS assistantChars,
224
+ count() AS sessions
225
+ FROM sessions_v AS c WHERE 1=1${f.and}`, f.params);
226
+
227
+ const streakRows = await q(`
228
+ SELECT DISTINCT formatDateTime(${TS}, '%Y-%m-%d', {tz:String}) AS day
229
+ FROM sessions_v AS c WHERE 1=1${f.and} ORDER BY day`, p);
230
+ let currentStreak = 0, longestStreak = 0, tempStreak = 1;
231
+ const today = new Date().toISOString().split('T')[0];
232
+ for (let i = 1; i < streakRows.length; i++) {
233
+ const diff = (new Date(streakRows[i].day) - new Date(streakRows[i - 1].day)) / 86400000;
234
+ if (diff === 1) tempStreak++;
235
+ else { if (tempStreak > longestStreak) longestStreak = tempStreak; tempStreak = 1; }
236
+ }
237
+ if (tempStreak > longestStreak) longestStreak = tempStreak;
238
+ if (streakRows.length > 0) {
239
+ const daysDiff = (new Date(today) - new Date(streakRows[streakRows.length - 1].day)) / 86400000;
240
+ if (daysDiff <= 1) {
241
+ currentStreak = 1;
242
+ for (let i = streakRows.length - 2; i >= 0; i--) {
243
+ if ((new Date(streakRows[i + 1].day) - new Date(streakRows[i].day)) / 86400000 === 1) currentStreak++;
244
+ else break;
245
+ }
246
+ }
247
+ }
248
+ if (streakRows.length === 0) longestStreak = 0;
249
+
250
+ const monthEditorRows = await q(`
251
+ SELECT formatDateTime(${TS}, '%Y-%m', 'UTC') AS month, source, count() AS count
252
+ FROM sessions_v AS c WHERE 1=1${f.and} GROUP BY month, source ORDER BY month`, f.params);
253
+ const monthEditors = {};
254
+ const allSources = new Set();
255
+ for (const r of monthEditorRows) {
256
+ if (!monthEditors[r.month]) monthEditors[r.month] = {};
257
+ monthEditors[r.month][r.source] = Number(r.count);
258
+ allSources.add(r.source);
259
+ }
260
+
261
+ const velocityRows = await q(`
262
+ SELECT formatDateTime(c.last_updated_at, '%Y-%m', 'UTC') AS month,
263
+ avg(total_msgs) AS avgMsgs, avg(input_tokens + output_tokens) AS avgTokens
264
+ FROM sessions_v AS c WHERE c.last_updated_at IS NOT NULL${f.and}
265
+ GROUP BY month ORDER BY month`, f.params);
266
+
267
+ const modelRows = await q(`
268
+ SELECT m.model AS model, count() AS cnt FROM messages AS m
269
+ WHERE m.model NOT IN ${EXCLUDED_MODELS}${inSessions(f, 'm.session_id')}
270
+ GROUP BY model`, f.params);
271
+ const topModels = topN(normalizedModelFreq(modelRows), 10);
272
+
273
+ const toolRows = await q(`
274
+ SELECT tool_name, count() AS cnt FROM tool_calls AS tc
275
+ WHERE 1=1${inSessions(f, 'tc.session_id')} GROUP BY tool_name`, f.params);
276
+ let totalToolCalls = 0;
277
+ const toolFreq = {};
278
+ for (const r of toolRows) { toolFreq[r.tool_name] = Number(r.cnt); totalToolCalls += Number(r.cnt); }
279
+ const topTools = topN(toolFreq, 8);
280
+
281
+ let inputTokens = Number(tokenRow.input), outputTokens = Number(tokenRow.output);
282
+ let tokensEstimated = false;
283
+ if (inputTokens === 0 && outputTokens === 0 && (tokenRow.userChars > 0 || tokenRow.assistantChars > 0)) {
284
+ inputTokens = Math.round(tokenRow.userChars / 4);
285
+ outputTokens = Math.round(tokenRow.assistantChars / 4);
286
+ tokensEstimated = true;
287
+ }
288
+
289
+ return {
290
+ hourly, weekdays, depthBuckets,
291
+ tokens: {
292
+ input: inputTokens, output: outputTokens, cacheRead: Number(tokenRow.cacheRead), cacheWrite: Number(tokenRow.cacheWrite),
293
+ userChars: Number(tokenRow.userChars), assistantChars: Number(tokenRow.assistantChars),
294
+ sessions: Number(tokenRow.sessions), estimated: tokensEstimated,
295
+ },
296
+ streaks: { current: currentStreak, longest: longestStreak, totalDays: streakRows.length },
297
+ monthlyTrend: { months: Object.keys(monthEditors).sort(), sources: [...allSources], data: monthEditors },
298
+ velocity: velocityRows.map(r => ({ month: r.month, avgMsgs: Math.round(r.avgMsgs * 10) / 10, avgTokens: Math.round(r.avgTokens) })),
299
+ topModels, topTools, totalToolCalls,
300
+ };
301
+ }
302
+
303
+ // ── chats list ──────────────────────────────────────────────────────────────────
304
+ async function countChats(opts = {}) {
305
+ const f = filters(opts, { editorLike: true, folderLike: true });
306
+ let sql = `SELECT count() AS cnt FROM sessions_v AS c WHERE 1=1${f.and}`;
307
+ if (opts.named !== false) sql += " AND (c.name != '' OR c.total_msgs > 0)";
308
+ return Number((await q1(sql, f.params)).cnt);
309
+ }
310
+
311
+ async function getChats(opts = {}) {
312
+ const f = filters(opts, { editorLike: true, folderLike: true });
313
+ let sql = `
314
+ SELECT c.session_id AS id, c.user_id AS user_id, c.source AS source, c.name AS name, c.mode AS mode, c.folder AS folder,
315
+ toUnixTimestamp64Milli(c.created_at) AS created_at,
316
+ toUnixTimestamp64Milli(c.last_updated_at) AS last_updated_at,
317
+ c.total_msgs AS bubble_count,
318
+ c.input_tokens AS _inTok, c.output_tokens AS _outTok,
319
+ c.cache_read_tokens AS _cacheR, c.cache_write_tokens AS _cacheW,
320
+ c.user_chars AS _uChars, c.assistant_chars AS _aChars
321
+ FROM sessions_v AS c WHERE 1=1${f.and}`;
322
+ const params = { ...f.params };
323
+ if (opts.named !== false) sql += " AND (c.name != '' OR c.total_msgs > 0)";
324
+ sql += ` ORDER BY ${MS} DESC`;
325
+ if (opts.limit) { sql += ' LIMIT {limit:UInt64}'; params.limit = opts.limit; }
326
+ if (opts.offset) { sql += ' OFFSET {offset:UInt64}'; params.offset = opts.offset; }
327
+ const rows = await q(sql, params);
328
+ if (rows.length === 0) return [];
329
+
330
+ // Top model per rollup = most frequent model across THAT WRITER's messages
331
+ // (session_id alone would blend colliding sessions across members).
332
+ const ids = rows.map(r => r.id);
333
+ const tmRows = await q(`
334
+ SELECT session_id, user_id, argMax(model, cnt) AS top_model
335
+ FROM (SELECT session_id, user_id, model, count() AS cnt FROM messages
336
+ WHERE session_id IN {ids:Array(String)} AND model NOT IN ${EXCLUDED_MODELS}
337
+ GROUP BY session_id, user_id, model)
338
+ GROUP BY session_id, user_id`, { ids });
339
+ const topModelBySession = {};
340
+ for (const r of tmRows) topModelBySession[`${r.session_id}::${r.user_id}`] = r.top_model;
341
+
342
+ return rows.map(r => {
343
+ const topModel = topModelBySession[`${r.id}::${r.user_id}`] || null;
344
+ let inTok = Number(r._inTok) || 0, outTok = Number(r._outTok) || 0;
345
+ if (inTok === 0 && outTok === 0 && ((r._uChars || 0) > 0 || (r._aChars || 0) > 0)) {
346
+ inTok = Math.round((r._uChars || 0) / 4);
347
+ outTok = Math.round((r._aChars || 0) / 4);
348
+ }
349
+ const cost = topModel ? (calculateCost(topModel, inTok, outTok, Number(r._cacheR) || 0, Number(r._cacheW) || 0) || 0) : 0;
350
+ return {
351
+ // Composite API id: rollups are keyed (session_id, user_id), so the id a
352
+ // client clicks must pin BOTH — otherwise opening one of two colliding
353
+ // rows shows an arbitrary member's transcript. '::' never occurs in
354
+ // session ids; getChat() splits it and plain ids still work.
355
+ id: `${r.id}::${r.user_id}`, user: r.user_id,
356
+ source: r.source, name: r.name, mode: r.mode, folder: r.folder,
357
+ createdAt: r.created_at, lastUpdatedAt: r.last_updated_at,
358
+ encrypted: false, bubbleCount: Number(r.bubble_count), topModel, cost,
359
+ };
360
+ });
361
+ }
362
+
363
+ // ── single chat ─────────────────────────────────────────────────────────────────
364
+ async function getChat(id) {
365
+ // Accept both id forms: composite 'session_id::user_id' (what getChats emits —
366
+ // deterministic when members share a session_id) and a plain full session_id
367
+ // (manual/legacy use). Split on the LAST '::' — some adapters (codebuff) build
368
+ // session ids as '<project>::<chatId>', so the first separator can be inside
369
+ // the sid. CH user names here are colon-free identifiers.
370
+ // EQUALITY, not prefix LIKE: the UI passes complete ids, and a prefix match is
371
+ // ambiguous when one id prefixes another ('1' vs '10'). Prefix search, if ever
372
+ // needed, must be a separate deterministic operation.
373
+ const cut = String(id).lastIndexOf('::');
374
+ const sidPart = cut === -1 ? String(id) : String(id).slice(0, cut);
375
+ const uidPart = cut === -1 ? undefined : String(id).slice(cut + 2);
376
+ let sql = `
377
+ SELECT c.session_id AS id, c.user_id AS user_id, c.source AS source, c.name AS name, c.mode AS mode, c.folder AS folder,
378
+ toUnixTimestamp64Milli(c.created_at) AS created_at,
379
+ toUnixTimestamp64Milli(c.last_updated_at) AS last_updated_at,
380
+ c.total_msgs AS total_msgs, c.user_msgs AS user_msgs, c.assistant_msgs AS assistant_msgs,
381
+ c.user_chars AS user_chars, c.assistant_chars AS assistant_chars,
382
+ c.input_tokens AS input_tokens, c.output_tokens AS output_tokens,
383
+ c.cache_read_tokens AS cache_read_tokens, c.cache_write_tokens AS cache_write_tokens
384
+ FROM sessions_v AS c WHERE session_id = {sid:String}`;
385
+ const params = { sid: sidPart };
386
+ if (uidPart) { sql += ' AND user_id = {uid:String}'; params.uid = uidPart; }
387
+ sql += ' LIMIT 1';
388
+ let chat = await q1(sql, params);
389
+ if (!chat && uidPart) {
390
+ // A plain codebuff-style id ('<source>:<project>::<chatId>', no user suffix)
391
+ // parses as sid::uid above. Retry treating the whole string as the sid.
392
+ chat = await q1(sql.replace(' AND user_id = {uid:String}', ''), { sid: String(id) });
393
+ }
394
+ if (!chat) return null;
395
+
396
+ // Sessions are keyed (session_id, user_id): constrain the row reloads to the
397
+ // selected rollup's writer, or an owner/no-RLS reader viewing a house where two
398
+ // members share an adapter-local session_id would see their rows merged.
399
+ const messages = await q(`
400
+ SELECT role, text AS content, model, input_tokens, output_tokens
401
+ FROM messages WHERE session_id = {cid:String} AND user_id = {uid:String} ORDER BY seq`,
402
+ { cid: chat.id, uid: chat.user_id });
403
+
404
+ const toolCalls = await q(`
405
+ SELECT tool_name, args FROM tool_calls WHERE session_id = {cid:String} AND user_id = {uid:String} ORDER BY idx`,
406
+ { cid: chat.id, uid: chat.user_id });
407
+ const toolCallDetails = toolCalls.map(tc => ({ name: tc.tool_name, args: safeParseJson(tc.args) }));
408
+
409
+ let toolMessages = 0, systemMessages = 0;
410
+ const models = [];
411
+ for (const m of messages) {
412
+ if (m.role === 'tool') toolMessages++;
413
+ else if (m.role === 'system') systemMessages++;
414
+ if (m.model && m.model !== '<synthetic>') models.push(m.model);
415
+ }
416
+
417
+ const stats = {
418
+ totalMessages: Number(chat.total_msgs), userMessages: Number(chat.user_msgs),
419
+ assistantMessages: Number(chat.assistant_msgs), toolMessages, systemMessages,
420
+ toolCalls: toolCalls.map(tc => tc.tool_name), models,
421
+ totalUserChars: Number(chat.user_chars), totalAssistantChars: Number(chat.assistant_chars),
422
+ totalInputTokens: Number(chat.input_tokens), totalOutputTokens: Number(chat.output_tokens),
423
+ totalCacheRead: Number(chat.cache_read_tokens), totalCacheWrite: Number(chat.cache_write_tokens),
424
+ };
425
+
426
+ return {
427
+ // Echo the id form the caller used: composite stays composite so follow-up
428
+ // URLs built from it (markdown download) stay pinned to the same writer.
429
+ id: uidPart ? `${chat.id}::${chat.user_id}` : chat.id, user: chat.user_id,
430
+ source: chat.source, name: chat.name, mode: chat.mode, folder: chat.folder,
431
+ createdAt: chat.created_at, lastUpdatedAt: chat.last_updated_at, encrypted: false,
432
+ messages: messages.map(m => ({
433
+ role: m.role, content: m.content, model: m.model || null,
434
+ inputTokens: Number(m.input_tokens) || 0, outputTokens: Number(m.output_tokens) || 0,
435
+ })),
436
+ stats, toolCallDetails,
437
+ };
438
+ }
439
+
440
+ // ── projects ────────────────────────────────────────────────────────────────────
441
+ async function getProjects(opts = {}) {
442
+ const df = filters({
443
+ hiddenFolders: opts.includeHidden ? [] : opts.hiddenFolders,
444
+ dateFrom: opts.dateFrom, dateTo: opts.dateTo,
445
+ });
446
+
447
+ const perSource = await q(`
448
+ SELECT folder, source, count() AS count, min(${MS}) AS first_seen, max(${MS}) AS last_seen
449
+ FROM sessions_v AS c WHERE folder != ''${df.and}
450
+ GROUP BY folder, source ORDER BY folder, count DESC`, df.params);
451
+
452
+ const map = {};
453
+ for (const r of perSource) {
454
+ if (!map[r.folder]) map[r.folder] = { folder: r.folder, totalSessions: 0, editors: {}, firstSeen: r.first_seen, lastSeen: r.last_seen };
455
+ map[r.folder].totalSessions += Number(r.count);
456
+ map[r.folder].editors[r.source] = Number(r.count);
457
+ if (r.first_seen != null && r.first_seen < map[r.folder].firstSeen) map[r.folder].firstSeen = r.first_seen;
458
+ if (r.last_seen != null && r.last_seen > map[r.folder].lastSeen) map[r.folder].lastSeen = r.last_seen;
459
+ }
460
+
461
+ const sums = await q(`
462
+ SELECT folder, sum(total_msgs) AS totalMessages,
463
+ sum(input_tokens) AS totalInputTokens, sum(output_tokens) AS totalOutputTokens,
464
+ sum(user_chars) AS totalUserChars, sum(assistant_chars) AS totalAssistantChars,
465
+ sum(cache_read_tokens) AS totalCacheRead, sum(cache_write_tokens) AS totalCacheWrite
466
+ FROM sessions_v AS c WHERE folder != ''${df.and} GROUP BY folder`, df.params);
467
+ const sumByFolder = {};
468
+ for (const r of sums) sumByFolder[r.folder] = r;
469
+
470
+ const modelRows = await q(`
471
+ SELECT m.folder AS folder, m.model AS model, count() AS cnt FROM messages AS m
472
+ WHERE m.folder != '' AND m.model NOT IN ${EXCLUDED_MODELS}${inSessions(df, 'm.session_id')}
473
+ GROUP BY folder, model`, df.params);
474
+ const modelsByFolder = {};
475
+ for (const r of modelRows) {
476
+ const k = normalizeModelName(r.model) || r.model;
477
+ if (!modelsByFolder[r.folder]) modelsByFolder[r.folder] = {};
478
+ modelsByFolder[r.folder][k] = (modelsByFolder[r.folder][k] || 0) + Number(r.cnt);
479
+ }
480
+
481
+ const toolRows = await q(`
482
+ SELECT tc.folder AS folder, tc.tool_name AS tool_name, count() AS cnt FROM tool_calls AS tc
483
+ WHERE tc.folder != ''${inSessions(df, 'tc.session_id')} GROUP BY folder, tool_name`, df.params);
484
+ const toolsByFolder = {};
485
+ for (const r of toolRows) {
486
+ if (!toolsByFolder[r.folder]) toolsByFolder[r.folder] = {};
487
+ toolsByFolder[r.folder][r.tool_name] = (toolsByFolder[r.folder][r.tool_name] || 0) + Number(r.cnt);
488
+ }
489
+
490
+ const result = [];
491
+ for (const [folder, proj] of Object.entries(map)) {
492
+ const s = sumByFolder[folder] || {};
493
+ let totalInputTokens = Number(s.totalInputTokens) || 0;
494
+ let totalOutputTokens = Number(s.totalOutputTokens) || 0;
495
+ const totalUserChars = Number(s.totalUserChars) || 0;
496
+ const totalAssistantChars = Number(s.totalAssistantChars) || 0;
497
+ let tokensEstimated = false;
498
+ if (totalInputTokens === 0 && totalOutputTokens === 0 && (totalUserChars > 0 || totalAssistantChars > 0)) {
499
+ totalInputTokens = Math.round(totalUserChars / 4);
500
+ totalOutputTokens = Math.round(totalAssistantChars / 4);
501
+ tokensEstimated = true;
502
+ }
503
+ const toolFreq = toolsByFolder[folder] || {};
504
+ const totalToolCalls = Object.values(toolFreq).reduce((a, b) => a + b, 0);
505
+ result.push({
506
+ folder, name: folder.split(/[/\\]/).pop(),
507
+ totalSessions: proj.totalSessions, editors: proj.editors,
508
+ firstSeen: proj.firstSeen, lastSeen: proj.lastSeen,
509
+ totalMessages: Number(s.totalMessages) || 0,
510
+ totalInputTokens, totalOutputTokens, tokensEstimated,
511
+ totalUserChars, totalAssistantChars, totalToolCalls,
512
+ totalCacheRead: Number(s.totalCacheRead) || 0, totalCacheWrite: Number(s.totalCacheWrite) || 0,
513
+ topModels: topN(modelsByFolder[folder] || {}, 10),
514
+ topTools: topN(toolFreq, 10),
515
+ });
516
+ }
517
+ return result.sort((a, b) => b.totalSessions - a.totalSessions);
518
+ }
519
+
520
+ // ── deep analytics ──────────────────────────────────────────────────────────────
521
+ async function getDeepAnalytics(opts = {}) {
522
+ const f = filters(opts, { editorLike: true });
523
+ let sql = `
524
+ SELECT c.session_id AS id, c.user_id AS user_id, c.total_msgs AS msgs, c.user_chars AS uc, c.assistant_chars AS ac,
525
+ c.input_tokens AS ti, c.output_tokens AS to_, c.cache_read_tokens AS cr, c.cache_write_tokens AS cw
526
+ FROM sessions_v AS c WHERE 1=1${f.and} ORDER BY ${MS} DESC`;
527
+ const params = { ...f.params };
528
+ if (opts.limit) { sql += ' LIMIT {limit:UInt64}'; params.limit = opts.limit; }
529
+ const rows = await q(sql, params);
530
+
531
+ let totalMessages = 0, totalUserChars = 0, totalAssistantChars = 0;
532
+ let totalInputTokens = 0, totalOutputTokens = 0, totalCacheRead = 0, totalCacheWrite = 0;
533
+ for (const r of rows) {
534
+ totalMessages += Number(r.msgs);
535
+ totalUserChars += Number(r.uc);
536
+ totalAssistantChars += Number(r.ac);
537
+ totalInputTokens += Number(r.ti);
538
+ totalOutputTokens += Number(r.to_);
539
+ totalCacheRead += Number(r.cr);
540
+ totalCacheWrite += Number(r.cw);
541
+ }
542
+
543
+ // Constrain by the FULL (session_id, user_id) pairs of the selected rollups —
544
+ // an id-only list would readmit other members' rows for colliding session_ids.
545
+ const ids = rows.map(r => r.id);
546
+ const users = rows.map(r => r.user_id);
547
+ let topTools = [], topModels = [], totalToolCalls = 0;
548
+ if (ids.length > 0) {
549
+ const pairs = { ids, users };
550
+ const toolRows = await q(`
551
+ SELECT tool_name, count() AS cnt FROM tool_calls
552
+ WHERE (session_id, user_id) IN arrayZip({ids:Array(String)}, {users:Array(String)})
553
+ GROUP BY tool_name`, pairs);
554
+ const toolFreq = {};
555
+ for (const r of toolRows) { toolFreq[r.tool_name] = Number(r.cnt); totalToolCalls += Number(r.cnt); }
556
+ topTools = topN(toolFreq, 30);
557
+
558
+ const modelRows = await q(`
559
+ SELECT model, count() AS cnt FROM messages
560
+ WHERE (session_id, user_id) IN arrayZip({ids:Array(String)}, {users:Array(String)})
561
+ AND model NOT IN ${EXCLUDED_MODELS} GROUP BY model`, pairs);
562
+ topModels = topN(normalizedModelFreq(modelRows), 20);
563
+ }
564
+
565
+ let tokensEstimated = false;
566
+ if (totalInputTokens === 0 && totalOutputTokens === 0 && (totalUserChars > 0 || totalAssistantChars > 0)) {
567
+ totalInputTokens = Math.round(totalUserChars / 4);
568
+ totalOutputTokens = Math.round(totalAssistantChars / 4);
569
+ tokensEstimated = true;
570
+ }
571
+
572
+ return {
573
+ analyzedChats: rows.length,
574
+ totalMessages, totalToolCalls, totalUserChars, totalAssistantChars,
575
+ totalInputTokens, totalOutputTokens, tokensEstimated, totalCacheRead, totalCacheWrite,
576
+ topTools, topModels,
577
+ };
578
+ }
579
+
580
+ // ── tool calls ──────────────────────────────────────────────────────────────────
581
+ async function getToolCalls(toolName, opts = {}) {
582
+ const limit = opts.limit || 200;
583
+ // Join on the FULL (session_id, user_id) key — id alone fans each call out to
584
+ // every member's colliding session row (duplicates + wrong chat names). The
585
+ // drill-down also honors the same hidden/date/editor filters as the charts it
586
+ // is opened from, so out-of-scope call args never appear in it.
587
+ let sql = `
588
+ SELECT tc.tool_name AS tool_name, tc.args AS args, tc.source AS source, tc.folder AS folder,
589
+ toUnixTimestamp64Milli(tc.ts) AS timestamp, s.name AS chat_name,
590
+ concat(tc.session_id, '::', tc.user_id) AS chat_id
591
+ FROM tool_calls AS tc INNER JOIN sessions AS s
592
+ ON s.session_id = tc.session_id AND s.user_id = tc.user_id
593
+ WHERE tc.tool_name = {name:String}`;
594
+ const params = { name: toolName, limit };
595
+ if (opts.folder) { sql += ' AND tc.folder = {folder:String}'; params.folder = opts.folder; }
596
+ if (opts.hiddenFolders && opts.hiddenFolders.length) {
597
+ sql += ' AND tc.folder NOT IN {hidden:Array(String)}';
598
+ params.hidden = opts.hiddenFolders;
599
+ }
600
+ if (opts.editor) { sql += ' AND tc.source LIKE {editor:String}'; params.editor = `%${opts.editor}%`; }
601
+ if (opts.dateFrom != null) { sql += ' AND toUnixTimestamp64Milli(tc.ts) >= {dateFrom:Int64}'; params.dateFrom = opts.dateFrom; }
602
+ if (opts.dateTo != null) { sql += ' AND toUnixTimestamp64Milli(tc.ts) <= {dateTo:Int64}'; params.dateTo = opts.dateTo; }
603
+ sql += ' ORDER BY tc.ts DESC LIMIT {limit:UInt64}';
604
+ const rows = await q(sql, params);
605
+ return rows.map(r => ({
606
+ toolName: r.tool_name, args: safeParseJson(r.args), source: r.source, folder: r.folder,
607
+ timestamp: r.timestamp, chatName: r.chat_name, chatId: r.chat_id,
608
+ }));
609
+ }
610
+
611
+ // ── cost estimation ─────────────────────────────────────────────────────────────
612
+ // Messages with model '' or '<synthetic>' are "orphans": their tokens are
613
+ // attributed to the session's dominant model (root cache.js treats model IS NULL
614
+ // the same way; '' is the mem-house null and synthetic models have no pricing).
615
+ const ORPHAN_TOKENS = '(m.input_tokens > 0 OR m.output_tokens > 0 OR m.cache_read_tokens > 0 OR m.cache_write_tokens > 0)';
616
+
617
+ // session_id → dominant model (most frequent across the session's messages).
618
+ async function sessionDominantMap(f) {
619
+ const rows = await q(`
620
+ SELECT session_id, user_id, argMax(model, cnt) AS dominant
621
+ FROM (SELECT m.session_id AS session_id, m.user_id AS user_id, m.model AS model, count() AS cnt
622
+ FROM messages AS m WHERE m.model NOT IN ${EXCLUDED_MODELS}${inSessions(f, 'm.session_id')}
623
+ GROUP BY session_id, user_id, model)
624
+ GROUP BY session_id, user_id`, f.params);
625
+ const map = {};
626
+ for (const r of rows) map[`${r.session_id}::${r.user_id}`] = r.dominant;
627
+ return map;
628
+ }
629
+
630
+ // source → dominant model + global dominant (for sessions with tokens but no model).
631
+ async function sourceDominantMap(f) {
632
+ const rows = await q(`
633
+ SELECT m.source AS source, m.model AS model, count() AS cnt
634
+ FROM messages AS m WHERE m.model NOT IN ${EXCLUDED_MODELS}${inSessions(f, 'm.session_id')}
635
+ GROUP BY source, model`, f.params);
636
+ const bySource = {}, globalFreq = {};
637
+ for (const r of rows) {
638
+ if (!bySource[r.source]) bySource[r.source] = {};
639
+ bySource[r.source][r.model] = (bySource[r.source][r.model] || 0) + Number(r.cnt);
640
+ globalFreq[r.model] = (globalFreq[r.model] || 0) + Number(r.cnt);
641
+ }
642
+ const sourceDominant = {};
643
+ for (const [src, fr] of Object.entries(bySource)) sourceDominant[src] = Object.entries(fr).sort((a, b) => b[1] - a[1])[0]?.[0];
644
+ const globalDominant = Object.entries(globalFreq).sort((a, b) => b[1] - a[1])[0]?.[0];
645
+ return { sourceDominant, globalDominant };
646
+ }
647
+
648
+ const CHARS_PER_TOKEN = 4;
649
+
650
+ async function estimateCosts(opts = {}) {
651
+ const f = filters(opts, { editorLike: true });
652
+
653
+ const modelTokens = await q(`
654
+ SELECT m.model AS model, sum(m.input_tokens) AS input, sum(m.output_tokens) AS output,
655
+ sum(m.cache_read_tokens) AS cacheRead, sum(m.cache_write_tokens) AS cacheWrite
656
+ FROM messages AS m
657
+ WHERE m.model NOT IN ${EXCLUDED_MODELS} AND ${ORPHAN_TOKENS}${inSessions(f, 'm.session_id')}
658
+ GROUP BY model`, f.params);
659
+
660
+ const orphanRows = await q(`
661
+ SELECT m.session_id AS session_id, m.user_id AS user_id, sum(m.input_tokens) AS input, sum(m.output_tokens) AS output,
662
+ sum(m.cache_read_tokens) AS cacheRead, sum(m.cache_write_tokens) AS cacheWrite
663
+ FROM messages AS m
664
+ WHERE m.model IN ${EXCLUDED_MODELS} AND ${ORPHAN_TOKENS}${inSessions(f, 'm.session_id')}
665
+ GROUP BY session_id, user_id`, f.params);
666
+
667
+ const dominantMap = await sessionDominantMap(f);
668
+
669
+ const orphanByModel = {};
670
+ const addOrphan = (dominant, input, output, cacheRead, cacheWrite) => {
671
+ if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
672
+ orphanByModel[dominant].input += Number(input) || 0;
673
+ orphanByModel[dominant].output += Number(output) || 0;
674
+ orphanByModel[dominant].cacheRead += Number(cacheRead) || 0;
675
+ orphanByModel[dominant].cacheWrite += Number(cacheWrite) || 0;
676
+ };
677
+ for (const r of orphanRows) {
678
+ const dominant = dominantMap[`${r.session_id}::${r.user_id}`];
679
+ if (dominant) addOrphan(dominant, r.input, r.output, r.cacheRead, r.cacheWrite);
680
+ }
681
+
682
+ // Sessions that name models but report zero tokens → estimate from chars.
683
+ const charRows = await q(`
684
+ SELECT c.session_id AS session_id, c.user_id AS user_id, c.user_chars AS userChars, c.assistant_chars AS asstChars
685
+ FROM sessions_v AS c
686
+ WHERE notEmpty(c.models) AND c.input_tokens = 0 AND c.output_tokens = 0
687
+ AND (c.user_chars > 0 OR c.assistant_chars > 0)${f.and}`, f.params);
688
+ for (const r of charRows) {
689
+ const dominant = dominantMap[`${r.session_id}::${r.user_id}`];
690
+ if (dominant) addOrphan(dominant, Math.round((Number(r.userChars) || 0) / CHARS_PER_TOKEN), Math.round((Number(r.asstChars) || 0) / CHARS_PER_TOKEN), 0, 0);
691
+ }
692
+
693
+ // Sessions with tokens but NO model at all → the source's dominant model.
694
+ const unmodeledRows = await q(`
695
+ SELECT c.source AS source, c.input_tokens AS input, c.output_tokens AS output,
696
+ c.cache_read_tokens AS cacheRead, c.cache_write_tokens AS cacheWrite
697
+ FROM sessions_v AS c
698
+ WHERE empty(c.models) AND (c.input_tokens > 0 OR c.output_tokens > 0)${f.and}`, f.params);
699
+ if (unmodeledRows.length > 0) {
700
+ const { sourceDominant, globalDominant } = await sourceDominantMap(f);
701
+ for (const r of unmodeledRows) {
702
+ const dominant = sourceDominant[r.source] || globalDominant;
703
+ if (dominant) addOrphan(dominant, r.input, r.output, r.cacheRead, r.cacheWrite);
704
+ }
705
+ }
706
+
707
+ const tokenMap = {};
708
+ const addTokens = (rawModel, input, output, cacheRead, cacheWrite) => {
709
+ const key = normalizeModelName(rawModel) || rawModel;
710
+ if (!tokenMap[key]) tokenMap[key] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
711
+ tokenMap[key].input += Number(input) || 0;
712
+ tokenMap[key].output += Number(output) || 0;
713
+ tokenMap[key].cacheRead += Number(cacheRead) || 0;
714
+ tokenMap[key].cacheWrite += Number(cacheWrite) || 0;
715
+ };
716
+ for (const row of modelTokens) addTokens(row.model, row.input, row.output, row.cacheRead, row.cacheWrite);
717
+ for (const [model, tok] of Object.entries(orphanByModel)) addTokens(model, tok.input, tok.output, tok.cacheRead, tok.cacheWrite);
718
+
719
+ let totalCost = 0;
720
+ let unknownModels = [];
721
+ const byModel = [];
722
+ for (const [model, tok] of Object.entries(tokenMap)) {
723
+ const cost = calculateCost(model, tok.input, tok.output, tok.cacheRead, tok.cacheWrite);
724
+ if (cost !== null) {
725
+ totalCost += cost;
726
+ byModel.push({ model, inputTokens: tok.input, outputTokens: tok.output, cacheRead: tok.cacheRead, cacheWrite: tok.cacheWrite, cost });
727
+ } else unknownModels.push(model);
728
+ }
729
+ byModel.sort((a, b) => b.cost - a.cost);
730
+ unknownModels = [...new Set(unknownModels)];
731
+ return { totalCost, byModel, unknownModels };
732
+ }
733
+
734
+ // Per-session cost attribution in a FIXED number of bulk queries (no per-session
735
+ // round-trips). Mirrors root computePerChatCosts.
736
+ async function computePerChatCosts(f) {
737
+ const aRows = await q(`
738
+ SELECT m.session_id AS session_id, m.user_id AS user_id, m.model AS model, sum(m.input_tokens) AS i, sum(m.output_tokens) AS o,
739
+ sum(m.cache_read_tokens) AS cr, sum(m.cache_write_tokens) AS cw
740
+ FROM messages AS m
741
+ WHERE m.model NOT IN ${EXCLUDED_MODELS} AND ${ORPHAN_TOKENS}${inSessions(f, 'm.session_id')}
742
+ GROUP BY session_id, user_id, model`, f.params);
743
+ const byChatModel = {};
744
+ for (const r of aRows) { const k = `${r.session_id}::${r.user_id}`; (byChatModel[k] = byChatModel[k] || []).push(r); }
745
+
746
+ const bRows = await q(`
747
+ SELECT m.session_id AS session_id, m.user_id AS user_id, sum(m.input_tokens) AS i, sum(m.output_tokens) AS o,
748
+ sum(m.cache_read_tokens) AS cr, sum(m.cache_write_tokens) AS cw
749
+ FROM messages AS m
750
+ WHERE m.model IN ${EXCLUDED_MODELS} AND ${ORPHAN_TOKENS}${inSessions(f, 'm.session_id')}
751
+ GROUP BY session_id, user_id`, f.params);
752
+ const orphanByChat = {};
753
+ for (const r of bRows) orphanByChat[`${r.session_id}::${r.user_id}`] = r;
754
+
755
+ const cRows = await q(`
756
+ SELECT c.session_id AS id, c.user_id AS user_id, c.source AS source, c.name AS name, c.folder AS folder,
757
+ toUnixTimestamp64Milli(c.last_updated_at) AS last_updated_at,
758
+ toUnixTimestamp64Milli(c.created_at) AS created_at,
759
+ c.total_msgs AS msgs, c.models AS models,
760
+ c.user_chars AS uc, c.assistant_chars AS ac,
761
+ c.input_tokens AS ti, c.output_tokens AS to_,
762
+ c.cache_read_tokens AS cr, c.cache_write_tokens AS cw,
763
+ formatDateTime(${TS}, '%Y-%m', 'UTC') AS month
764
+ FROM sessions_v AS c WHERE 1=1${f.and}`, f.params);
765
+
766
+ const dominantMap = await sessionDominantMap(f);
767
+ const { sourceDominant, globalDominant } = await sourceDominantMap(f);
768
+
769
+ const out = [];
770
+ for (const c of cRows) {
771
+ const key = `${c.id}::${c.user_id}`; // rollups are per (session_id, user_id)
772
+ const hasModels = (c.models || []).length > 0;
773
+ const dominant = dominantMap[key] || null;
774
+ const tokenMap = {};
775
+ const add = (rawModel, i, o, cr, cw) => {
776
+ if (!rawModel) return;
777
+ const key = normalizeModelName(rawModel) || rawModel;
778
+ if (!tokenMap[key]) tokenMap[key] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
779
+ tokenMap[key].input += Number(i) || 0; tokenMap[key].output += Number(o) || 0;
780
+ tokenMap[key].cacheRead += Number(cr) || 0; tokenMap[key].cacheWrite += Number(cw) || 0;
781
+ };
782
+ for (const r of (byChatModel[key] || [])) add(r.model, r.i, r.o, r.cr, r.cw);
783
+ const orphan = orphanByChat[key];
784
+ if (orphan && dominant) add(dominant, orphan.i, orphan.o, orphan.cr, orphan.cw);
785
+ if (hasModels && Number(c.ti) === 0 && Number(c.to_) === 0 && (c.uc > 0 || c.ac > 0) && dominant)
786
+ add(dominant, Math.round((Number(c.uc) || 0) / CHARS_PER_TOKEN), Math.round((Number(c.ac) || 0) / CHARS_PER_TOKEN), 0, 0);
787
+ if (!hasModels && (Number(c.ti) > 0 || Number(c.to_) > 0)) {
788
+ const srcDom = sourceDominant[c.source] || globalDominant;
789
+ if (srcDom) add(srcDom, c.ti, c.to_, c.cr, c.cw);
790
+ }
791
+ let totalCost = 0; const byModel = [];
792
+ for (const [model, tok] of Object.entries(tokenMap)) {
793
+ const cost = calculateCost(model, tok.input, tok.output, tok.cacheRead, tok.cacheWrite);
794
+ if (cost !== null) { totalCost += cost; byModel.push({ model, cost }); }
795
+ }
796
+ byModel.sort((a, b) => b.cost - a.cost);
797
+ out.push({
798
+ id: key, user: c.user_id, source: c.source, name: c.name, folder: c.folder,
799
+ last_updated_at: c.last_updated_at, created_at: c.created_at,
800
+ msgs: Number(c.msgs), month: c.month, totalCost, byModel,
801
+ });
802
+ }
803
+ return out;
804
+ }
805
+
806
+ async function getCostAnalytics(opts = {}) {
807
+ const f = filters(opts, { editorLike: true });
808
+ const overall = await estimateCosts(opts);
809
+ const perChat = await computePerChatCosts(f);
810
+
811
+ const editorAgg = {}, projectAgg = {}, monthCosts = {};
812
+ const sessionCosts = [];
813
+ for (const r of perChat) {
814
+ if (r.month) {
815
+ if (!monthCosts[r.month]) monthCosts[r.month] = { cost: 0, sessions: 0 };
816
+ monthCosts[r.month].cost += r.totalCost;
817
+ monthCosts[r.month].sessions++;
818
+ }
819
+ if (r.totalCost <= 0) continue;
820
+ if (!editorAgg[r.source]) editorAgg[r.source] = { cost: 0, models: new Set() };
821
+ editorAgg[r.source].cost += r.totalCost;
822
+ for (const m of r.byModel) editorAgg[r.source].models.add(m.model);
823
+ if (r.folder) projectAgg[r.folder] = (projectAgg[r.folder] || 0) + r.totalCost;
824
+ sessionCosts.push({
825
+ id: r.id, source: r.source, name: r.name, folder: r.folder,
826
+ model: r.byModel[0]?.model || null,
827
+ cost: r.totalCost, messages: r.msgs || 0, lastUpdatedAt: r.last_updated_at || r.created_at,
828
+ });
829
+ }
830
+ const byEditor = Object.entries(editorAgg).map(([editor, d]) => ({ editor, cost: d.cost, models: d.models.size })).sort((a, b) => b.cost - a.cost);
831
+ const byProject = Object.entries(projectAgg).map(([folder, cost]) => ({ folder, name: folder.split('/').pop(), cost })).sort((a, b) => b.cost - a.cost).slice(0, 20);
832
+ 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 }));
833
+ sessionCosts.sort((a, b) => b.cost - a.cost);
834
+
835
+ const totalSessions = sessionCosts.length;
836
+ const avgPerSession = totalSessions > 0 ? overall.totalCost / totalSessions : 0;
837
+ const totalDays = monthly.length > 0 ? (() => {
838
+ const first = new Date(monthly[0].month + '-01');
839
+ const last = new Date(monthly[monthly.length - 1].month + '-01');
840
+ return Math.max(1, Math.ceil((last - first) / 86400000) + 30);
841
+ })() : 1;
842
+ const avgPerDay = overall.totalCost / totalDays;
843
+
844
+ return {
845
+ totalCost: overall.totalCost, byModel: overall.byModel, unknownModels: overall.unknownModels,
846
+ byEditor, byProject, monthly, topSessions: sessionCosts.slice(0, 50),
847
+ summary: { totalSessions, avgPerSession: Math.round(avgPerSession * 100) / 100, avgPerDay: Math.round(avgPerDay * 100) / 100, totalDays },
848
+ };
849
+ }
850
+
851
+ // ── raw SQL / schema (SqlViewer) ────────────────────────────────────────────────
852
+ async function rawQuery(sql) {
853
+ const rows = await q(sql);
854
+ const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
855
+ return { columns, rows, count: rows.length };
856
+ }
857
+
858
+ async function schema() {
859
+ const tbls = await q(`
860
+ SELECT name FROM system.tables
861
+ WHERE database = currentDatabase() AND NOT startsWith(name, '.') ORDER BY name`);
862
+ const out = {};
863
+ for (const { name } of tbls) {
864
+ out[name] = await q(`
865
+ SELECT name, type FROM system.columns
866
+ WHERE database = currentDatabase() AND table = {tbl:String} ORDER BY position`, { tbl: name });
867
+ }
868
+ return { tables: tbls.map(t => t.name), schema: out };
869
+ }
870
+
871
+ module.exports = {
872
+ getClient, config,
873
+ getOverview, getDailyActivity, getDashboardStats,
874
+ getChats, countChats, getChat,
875
+ getProjects, getDeepAnalytics, getToolCalls,
876
+ estimateCosts, getCostAnalytics,
877
+ rawQuery, schema,
878
+ };