memhouse 0.3.2 → 0.4.2

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 (36) hide show
  1. package/README.md +64 -6
  2. package/bin/memhouse.js +882 -40
  3. package/index.js +2 -2
  4. package/mem-house/COMPETITION.md +217 -0
  5. package/mem-house/DESIGN.md +61 -32
  6. package/mem-house/delivery/AGENT-INSTALL.md +5 -3
  7. package/mem-house/delivery/PROMPT.md +23 -10
  8. package/mem-house/delivery/install.sh +9 -7
  9. package/mem-house/delivery/kernel-install.md +56 -25
  10. package/mem-house/delivery/skills/memhouse-search/SKILL.md +46 -16
  11. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +38 -7
  12. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +48 -17
  13. package/mem-house/deploy.js +333 -0
  14. package/mem-house/envfile.js +49 -0
  15. package/mem-house/per-member/INSTALL.md +183 -0
  16. package/mem-house/per-member/PLAN.md +174 -0
  17. package/mem-house/per-member/PROVISIONING.md +177 -0
  18. package/mem-house/per-member/README.md +48 -0
  19. package/mem-house/per-member/SCHEMA.md +73 -0
  20. package/mem-house/per-member/SHARING.md +102 -0
  21. package/mem-house/per-member/provision.js +142 -0
  22. package/mem-house/per-member/rooms.js +132 -0
  23. package/mem-house/per-member/schema-member.sql.tpl +85 -0
  24. package/mem-house/per-member/schema-merge.sql.tpl +24 -0
  25. package/mem-house/server/queries.js +103 -53
  26. package/mem-house/service.js +400 -0
  27. package/mem-house/shipper/ship.js +61 -18
  28. package/package.json +3 -3
  29. package/pricing.js +20 -2
  30. package/pricing.json +216 -24
  31. package/public/assets/{index-CCPh7a2Z.js → index-CtH-5i95.js} +5 -5
  32. package/public/index.html +1 -1
  33. package/ui/src/App.jsx +3 -2
  34. package/ui/src/pages/SqlViewer.jsx +39 -21
  35. package/mem-house/rls.sql +0 -17
  36. package/mem-house/schema.sql +0 -145
@@ -1,28 +1,30 @@
1
1
  // queries.js — mem-house read/query layer.
2
2
  //
3
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.
4
+ // contract) but computed over the mem-house typed schema — one set of rooms per member,
5
+ // `sessions_<m>` / `messages_<m>` / `tool_calls_<m>` (../per-member/schema-member.sql.tpl),
6
+ // plus the session rollup, which is a SAVED QUERY and not an object
7
+ // (../per-member/rooms.js). The dashboard SPA consumes these shapes unchanged.
7
8
  //
8
9
  // 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)
10
+ // chats → sessions_<m> / the rollup (id → session_id, bubble_count → total_msgs)
11
+ // chat_stats → rollup aggregates (token/char sums, models array)
12
+ // messages.content → messages_<m>.text (cache_read → cache_read_tokens, …)
13
+ // tool_calls → tool_calls_<m> (args_json → args, chat_id → session_id)
14
+ // epoch-ms columns → DateTime64(3,'UTC') (converted with toUnixTimestamp64Milli)
14
15
  //
15
16
  // All reads run with the `final: 1` setting so ReplacingMergeTree collapses
16
17
  // duplicate versions at read time (see DESIGN.md data-plane rules).
17
18
 
18
19
  const { createClient } = require('@clickhouse/client');
19
20
  const { calculateCost, normalizeModelName } = require('../../pricing');
21
+ const { resolveRooms, READ_SETTINGS } = require('../per-member/rooms');
20
22
 
21
23
  const config = {
22
24
  url: process.env.MEMHOUSE_URL || 'http://localhost:8123',
23
25
  username: process.env.MEMHOUSE_USER || 'memhouse_root',
24
26
  password: process.env.MEMHOUSE_PASSWORD || '',
25
- database: process.env.MEMHOUSE_DB || 'memhouse',
27
+ database: process.env.MEMHOUSE_DB || 'mem',
26
28
  };
27
29
 
28
30
  let _client = null;
@@ -35,7 +37,7 @@ function getClient() {
35
37
  database: config.database,
36
38
  request_timeout: 60000,
37
39
  clickhouse_settings: {
38
- final: 1,
40
+ ...READ_SETTINGS,
39
41
  output_format_json_quote_64bit_integers: 0,
40
42
  },
41
43
  });
@@ -44,8 +46,38 @@ function getClient() {
44
46
 
45
47
  const TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
46
48
 
49
+ // Room routing. Rooms are named for the member
50
+ // (`messages_alice`), so a read layer hardcoding `messages` either hits a permission
51
+ // error or, against a house provisioned for someone else, silently returns the wrong
52
+ // shared-layout data. Every query below names its rooms as `{{sessions_v}}` and friends,
53
+ // and this is the single place they are resolved.
54
+ //
55
+ // `{{name}}` cannot collide with ClickHouse's own `{name:Type}` parameter syntax: double
56
+ // braces, no type.
57
+ const ROOM_TOKEN = /\{\{([a-z_]+)\}\}/g;
58
+ let roomsPromise = null;
59
+
60
+ function rooms() {
61
+ // Resolved once per process. resolveRooms asks the server for currentUser(), so it is a
62
+ // round trip, and every dashboard request would otherwise pay for it.
63
+ if (!roomsPromise) {
64
+ roomsPromise = resolveRooms(getClient()).catch((e) => { roomsPromise = null; throw e; });
65
+ }
66
+ return roomsPromise;
67
+ }
68
+
69
+ function applyRooms(sql, r) {
70
+ return sql.replace(ROOM_TOKEN, (_, name) => {
71
+ const t = r[name];
72
+ if (!t) throw new Error(`query names unknown room '${name}'`);
73
+ return t;
74
+ });
75
+ }
76
+
47
77
  async function q(query, params = {}) {
48
- const rs = await getClient().query({ query, query_params: params, format: 'JSONEachRow' });
78
+ const rs = await getClient().query({
79
+ query: applyRooms(query, await rooms()), query_params: params, format: 'JSONEachRow',
80
+ });
49
81
  return rs.json();
50
82
  }
51
83
  async function q1(query, params = {}) { return (await q(query, params))[0]; }
@@ -101,7 +133,7 @@ function filters(opts, { editorLike = false, folderLike = false } = {}) {
101
133
  function inSessions(f, col) {
102
134
  if (!f.and) return '';
103
135
  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})`;
136
+ 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
137
  }
106
138
 
107
139
  // Fold a [{name|model, cnt}] list through normalizeModelName into a freq map.
@@ -119,20 +151,20 @@ const topN = (freq, n) => Object.entries(freq).sort((a, b) => b[1] - a[1]).slice
119
151
  // ── overview ────────────────────────────────────────────────────────────────────
120
152
  async function getOverview(opts = {}) {
121
153
  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);
154
+ const totalChats = Number((await q1(`SELECT count() AS cnt FROM {{sessions_v}} AS c WHERE 1=1${f.and}`, f.params)).cnt);
123
155
 
124
156
  // Root parity: without a folder filter the editor breakdown is global.
125
157
  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');
158
+ ? 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)
159
+ : await q('SELECT source, count() AS count FROM {{sessions_v}} GROUP BY source ORDER BY count DESC');
128
160
 
129
- const modes = await q(`SELECT mode, count() AS count FROM sessions_v AS c WHERE mode != ''${f.and} GROUP BY mode`, f.params);
161
+ const modes = await q(`SELECT mode, count() AS count FROM {{sessions_v}} AS c WHERE mode != ''${f.and} GROUP BY mode`, f.params);
130
162
  const byMode = {};
131
163
  for (const m of modes) byMode[m.mode] = Number(m.count);
132
164
 
133
165
  const rows = await q(`
134
166
  SELECT formatDateTime(${TS}, '%Y-%m', 'UTC') AS month, source, count() AS count
135
- FROM sessions_v AS c WHERE 1=1${f.and}
167
+ FROM {{sessions_v}} AS c WHERE 1=1${f.and}
136
168
  GROUP BY month, source ORDER BY month`, f.params);
137
169
  const monthMap = {};
138
170
  for (const r of rows) {
@@ -143,7 +175,7 @@ async function getOverview(opts = {}) {
143
175
  const byMonth = Object.keys(monthMap).sort().map(m => ({ month: m, ...monthMap[m] }));
144
176
 
145
177
  const projects = await q(`
146
- SELECT folder, count() AS count FROM sessions_v AS c
178
+ SELECT folder, count() AS count FROM {{sessions_v}} AS c
147
179
  WHERE folder != ''${f.and} GROUP BY folder ORDER BY count DESC LIMIT 20`, f.params);
148
180
  const topProjects = projects.map(p => ({
149
181
  name: p.folder.split(/[/\\]/).slice(-2).join('/'),
@@ -151,7 +183,7 @@ async function getOverview(opts = {}) {
151
183
  count: Number(p.count),
152
184
  }));
153
185
 
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);
186
+ 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
187
 
156
188
  return {
157
189
  totalChats,
@@ -171,7 +203,7 @@ async function getDailyActivity(opts = {}) {
171
203
  source,
172
204
  toHour(${TS}, {tz:String}) AS hour,
173
205
  count() AS count
174
- FROM sessions_v AS c WHERE 1=1${f.and}
206
+ FROM {{sessions_v}} AS c WHERE 1=1${f.and}
175
207
  GROUP BY day, source, hour ORDER BY day`, params);
176
208
 
177
209
  const daily = {};
@@ -193,18 +225,18 @@ async function getDashboardStats(opts = {}) {
193
225
 
194
226
  const hourlyRows = await q(`
195
227
  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);
228
+ FROM {{sessions_v}} AS c WHERE 1=1${f.and} GROUP BY hour ORDER BY hour`, p);
197
229
  const hourly = new Array(24).fill(0);
198
230
  for (const r of hourlyRows) hourly[Number(r.hour)] = Number(r.count);
199
231
 
200
232
  const weekdayRows = await q(`
201
233
  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);
234
+ FROM {{sessions_v}} AS c WHERE 1=1${f.and} GROUP BY dow ORDER BY dow`, p);
203
235
  const weekdays = new Array(7).fill(0);
204
236
  for (const r of weekdayRows) weekdays[Number(r.dow)] = Number(r.count);
205
237
 
206
238
  const depthRows = await q(`
207
- SELECT total_msgs AS msgs FROM sessions_v AS c WHERE total_msgs > 0${f.and}`, f.params);
239
+ SELECT total_msgs AS msgs FROM {{sessions_v}} AS c WHERE total_msgs > 0${f.and}`, f.params);
208
240
  const depthBuckets = { '1': 0, '2-5': 0, '6-10': 0, '11-20': 0, '21-50': 0, '51-100': 0, '100+': 0 };
209
241
  for (const r of depthRows) {
210
242
  const m = Number(r.msgs);
@@ -222,11 +254,11 @@ async function getDashboardStats(opts = {}) {
222
254
  COALESCE(sum(cache_read_tokens), 0) AS cacheRead, COALESCE(sum(cache_write_tokens), 0) AS cacheWrite,
223
255
  COALESCE(sum(user_chars), 0) AS userChars, COALESCE(sum(assistant_chars), 0) AS assistantChars,
224
256
  count() AS sessions
225
- FROM sessions_v AS c WHERE 1=1${f.and}`, f.params);
257
+ FROM {{sessions_v}} AS c WHERE 1=1${f.and}`, f.params);
226
258
 
227
259
  const streakRows = await q(`
228
260
  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);
261
+ FROM {{sessions_v}} AS c WHERE 1=1${f.and} ORDER BY day`, p);
230
262
  let currentStreak = 0, longestStreak = 0, tempStreak = 1;
231
263
  const today = new Date().toISOString().split('T')[0];
232
264
  for (let i = 1; i < streakRows.length; i++) {
@@ -249,7 +281,7 @@ async function getDashboardStats(opts = {}) {
249
281
 
250
282
  const monthEditorRows = await q(`
251
283
  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);
284
+ FROM {{sessions_v}} AS c WHERE 1=1${f.and} GROUP BY month, source ORDER BY month`, f.params);
253
285
  const monthEditors = {};
254
286
  const allSources = new Set();
255
287
  for (const r of monthEditorRows) {
@@ -261,17 +293,17 @@ async function getDashboardStats(opts = {}) {
261
293
  const velocityRows = await q(`
262
294
  SELECT formatDateTime(c.last_updated_at, '%Y-%m', 'UTC') AS month,
263
295
  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}
296
+ FROM {{sessions_v}} AS c WHERE c.last_updated_at IS NOT NULL${f.and}
265
297
  GROUP BY month ORDER BY month`, f.params);
266
298
 
267
299
  const modelRows = await q(`
268
- SELECT m.model AS model, count() AS cnt FROM messages AS m
300
+ SELECT m.model AS model, count() AS cnt FROM {{messages}} AS m
269
301
  WHERE m.model NOT IN ${EXCLUDED_MODELS}${inSessions(f, 'm.session_id')}
270
302
  GROUP BY model`, f.params);
271
303
  const topModels = topN(normalizedModelFreq(modelRows), 10);
272
304
 
273
305
  const toolRows = await q(`
274
- SELECT tool_name, count() AS cnt FROM tool_calls AS tc
306
+ SELECT tool_name, count() AS cnt FROM {{tool_calls}} AS tc
275
307
  WHERE 1=1${inSessions(f, 'tc.session_id')} GROUP BY tool_name`, f.params);
276
308
  let totalToolCalls = 0;
277
309
  const toolFreq = {};
@@ -303,7 +335,7 @@ async function getDashboardStats(opts = {}) {
303
335
  // ── chats list ──────────────────────────────────────────────────────────────────
304
336
  async function countChats(opts = {}) {
305
337
  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}`;
338
+ let sql = `SELECT count() AS cnt FROM {{sessions_v}} AS c WHERE 1=1${f.and}`;
307
339
  if (opts.named !== false) sql += " AND (c.name != '' OR c.total_msgs > 0)";
308
340
  return Number((await q1(sql, f.params)).cnt);
309
341
  }
@@ -318,7 +350,7 @@ async function getChats(opts = {}) {
318
350
  c.input_tokens AS _inTok, c.output_tokens AS _outTok,
319
351
  c.cache_read_tokens AS _cacheR, c.cache_write_tokens AS _cacheW,
320
352
  c.user_chars AS _uChars, c.assistant_chars AS _aChars
321
- FROM sessions_v AS c WHERE 1=1${f.and}`;
353
+ FROM {{sessions_v}} AS c WHERE 1=1${f.and}`;
322
354
  const params = { ...f.params };
323
355
  if (opts.named !== false) sql += " AND (c.name != '' OR c.total_msgs > 0)";
324
356
  sql += ` ORDER BY ${MS} DESC`;
@@ -332,7 +364,7 @@ async function getChats(opts = {}) {
332
364
  const ids = rows.map(r => r.id);
333
365
  const tmRows = await q(`
334
366
  SELECT session_id, user_id, argMax(model, cnt) AS top_model
335
- FROM (SELECT session_id, user_id, model, count() AS cnt FROM messages
367
+ FROM (SELECT session_id, user_id, model, count() AS cnt FROM {{messages}}
336
368
  WHERE session_id IN {ids:Array(String)} AND model NOT IN ${EXCLUDED_MODELS}
337
369
  GROUP BY session_id, user_id, model)
338
370
  GROUP BY session_id, user_id`, { ids });
@@ -381,7 +413,7 @@ async function getChat(id) {
381
413
  c.user_chars AS user_chars, c.assistant_chars AS assistant_chars,
382
414
  c.input_tokens AS input_tokens, c.output_tokens AS output_tokens,
383
415
  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}`;
416
+ FROM {{sessions_v}} AS c WHERE session_id = {sid:String}`;
385
417
  const params = { sid: sidPart };
386
418
  if (uidPart) { sql += ' AND user_id = {uid:String}'; params.uid = uidPart; }
387
419
  sql += ' LIMIT 1';
@@ -398,11 +430,11 @@ async function getChat(id) {
398
430
  // members share an adapter-local session_id would see their rows merged.
399
431
  const messages = await q(`
400
432
  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`,
433
+ FROM {{messages}} WHERE session_id = {cid:String} AND user_id = {uid:String} ORDER BY seq`,
402
434
  { cid: chat.id, uid: chat.user_id });
403
435
 
404
436
  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`,
437
+ SELECT tool_name, args FROM {{tool_calls}} WHERE session_id = {cid:String} AND user_id = {uid:String} ORDER BY idx`,
406
438
  { cid: chat.id, uid: chat.user_id });
407
439
  const toolCallDetails = toolCalls.map(tc => ({ name: tc.tool_name, args: safeParseJson(tc.args) }));
408
440
 
@@ -446,7 +478,7 @@ async function getProjects(opts = {}) {
446
478
 
447
479
  const perSource = await q(`
448
480
  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}
481
+ FROM {{sessions_v}} AS c WHERE folder != ''${df.and}
450
482
  GROUP BY folder, source ORDER BY folder, count DESC`, df.params);
451
483
 
452
484
  const map = {};
@@ -463,12 +495,12 @@ async function getProjects(opts = {}) {
463
495
  sum(input_tokens) AS totalInputTokens, sum(output_tokens) AS totalOutputTokens,
464
496
  sum(user_chars) AS totalUserChars, sum(assistant_chars) AS totalAssistantChars,
465
497
  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);
498
+ FROM {{sessions_v}} AS c WHERE folder != ''${df.and} GROUP BY folder`, df.params);
467
499
  const sumByFolder = {};
468
500
  for (const r of sums) sumByFolder[r.folder] = r;
469
501
 
470
502
  const modelRows = await q(`
471
- SELECT m.folder AS folder, m.model AS model, count() AS cnt FROM messages AS m
503
+ SELECT m.folder AS folder, m.model AS model, count() AS cnt FROM {{messages}} AS m
472
504
  WHERE m.folder != '' AND m.model NOT IN ${EXCLUDED_MODELS}${inSessions(df, 'm.session_id')}
473
505
  GROUP BY folder, model`, df.params);
474
506
  const modelsByFolder = {};
@@ -479,7 +511,7 @@ async function getProjects(opts = {}) {
479
511
  }
480
512
 
481
513
  const toolRows = await q(`
482
- SELECT tc.folder AS folder, tc.tool_name AS tool_name, count() AS cnt FROM tool_calls AS tc
514
+ SELECT tc.folder AS folder, tc.tool_name AS tool_name, count() AS cnt FROM {{tool_calls}} AS tc
483
515
  WHERE tc.folder != ''${inSessions(df, 'tc.session_id')} GROUP BY folder, tool_name`, df.params);
484
516
  const toolsByFolder = {};
485
517
  for (const r of toolRows) {
@@ -523,7 +555,7 @@ async function getDeepAnalytics(opts = {}) {
523
555
  let sql = `
524
556
  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
557
  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`;
558
+ FROM {{sessions_v}} AS c WHERE 1=1${f.and} ORDER BY ${MS} DESC`;
527
559
  const params = { ...f.params };
528
560
  if (opts.limit) { sql += ' LIMIT {limit:UInt64}'; params.limit = opts.limit; }
529
561
  const rows = await q(sql, params);
@@ -548,7 +580,7 @@ async function getDeepAnalytics(opts = {}) {
548
580
  if (ids.length > 0) {
549
581
  const pairs = { ids, users };
550
582
  const toolRows = await q(`
551
- SELECT tool_name, count() AS cnt FROM tool_calls
583
+ SELECT tool_name, count() AS cnt FROM {{tool_calls}}
552
584
  WHERE (session_id, user_id) IN arrayZip({ids:Array(String)}, {users:Array(String)})
553
585
  GROUP BY tool_name`, pairs);
554
586
  const toolFreq = {};
@@ -556,7 +588,7 @@ async function getDeepAnalytics(opts = {}) {
556
588
  topTools = topN(toolFreq, 30);
557
589
 
558
590
  const modelRows = await q(`
559
- SELECT model, count() AS cnt FROM messages
591
+ SELECT model, count() AS cnt FROM {{messages}}
560
592
  WHERE (session_id, user_id) IN arrayZip({ids:Array(String)}, {users:Array(String)})
561
593
  AND model NOT IN ${EXCLUDED_MODELS} GROUP BY model`, pairs);
562
594
  topModels = topN(normalizedModelFreq(modelRows), 20);
@@ -588,7 +620,7 @@ async function getToolCalls(toolName, opts = {}) {
588
620
  SELECT tc.tool_name AS tool_name, tc.args AS args, tc.source AS source, tc.folder AS folder,
589
621
  toUnixTimestamp64Milli(tc.ts) AS timestamp, s.name AS chat_name,
590
622
  concat(tc.session_id, '::', tc.user_id) AS chat_id
591
- FROM tool_calls AS tc INNER JOIN sessions AS s
623
+ FROM {{tool_calls}} AS tc INNER JOIN {{sessions}} AS s
592
624
  ON s.session_id = tc.session_id AND s.user_id = tc.user_id
593
625
  WHERE tc.tool_name = {name:String}`;
594
626
  const params = { name: toolName, limit };
@@ -619,7 +651,7 @@ async function sessionDominantMap(f) {
619
651
  const rows = await q(`
620
652
  SELECT session_id, user_id, argMax(model, cnt) AS dominant
621
653
  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')}
654
+ FROM {{messages}} AS m WHERE m.model NOT IN ${EXCLUDED_MODELS}${inSessions(f, 'm.session_id')}
623
655
  GROUP BY session_id, user_id, model)
624
656
  GROUP BY session_id, user_id`, f.params);
625
657
  const map = {};
@@ -631,7 +663,7 @@ async function sessionDominantMap(f) {
631
663
  async function sourceDominantMap(f) {
632
664
  const rows = await q(`
633
665
  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')}
666
+ FROM {{messages}} AS m WHERE m.model NOT IN ${EXCLUDED_MODELS}${inSessions(f, 'm.session_id')}
635
667
  GROUP BY source, model`, f.params);
636
668
  const bySource = {}, globalFreq = {};
637
669
  for (const r of rows) {
@@ -653,14 +685,14 @@ async function estimateCosts(opts = {}) {
653
685
  const modelTokens = await q(`
654
686
  SELECT m.model AS model, sum(m.input_tokens) AS input, sum(m.output_tokens) AS output,
655
687
  sum(m.cache_read_tokens) AS cacheRead, sum(m.cache_write_tokens) AS cacheWrite
656
- FROM messages AS m
688
+ FROM {{messages}} AS m
657
689
  WHERE m.model NOT IN ${EXCLUDED_MODELS} AND ${ORPHAN_TOKENS}${inSessions(f, 'm.session_id')}
658
690
  GROUP BY model`, f.params);
659
691
 
660
692
  const orphanRows = await q(`
661
693
  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
694
  sum(m.cache_read_tokens) AS cacheRead, sum(m.cache_write_tokens) AS cacheWrite
663
- FROM messages AS m
695
+ FROM {{messages}} AS m
664
696
  WHERE m.model IN ${EXCLUDED_MODELS} AND ${ORPHAN_TOKENS}${inSessions(f, 'm.session_id')}
665
697
  GROUP BY session_id, user_id`, f.params);
666
698
 
@@ -682,7 +714,7 @@ async function estimateCosts(opts = {}) {
682
714
  // Sessions that name models but report zero tokens → estimate from chars.
683
715
  const charRows = await q(`
684
716
  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
717
+ FROM {{sessions_v}} AS c
686
718
  WHERE notEmpty(c.models) AND c.input_tokens = 0 AND c.output_tokens = 0
687
719
  AND (c.user_chars > 0 OR c.assistant_chars > 0)${f.and}`, f.params);
688
720
  for (const r of charRows) {
@@ -694,7 +726,7 @@ async function estimateCosts(opts = {}) {
694
726
  const unmodeledRows = await q(`
695
727
  SELECT c.source AS source, c.input_tokens AS input, c.output_tokens AS output,
696
728
  c.cache_read_tokens AS cacheRead, c.cache_write_tokens AS cacheWrite
697
- FROM sessions_v AS c
729
+ FROM {{sessions_v}} AS c
698
730
  WHERE empty(c.models) AND (c.input_tokens > 0 OR c.output_tokens > 0)${f.and}`, f.params);
699
731
  if (unmodeledRows.length > 0) {
700
732
  const { sourceDominant, globalDominant } = await sourceDominantMap(f);
@@ -737,7 +769,7 @@ async function computePerChatCosts(f) {
737
769
  const aRows = await q(`
738
770
  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
771
  sum(m.cache_read_tokens) AS cr, sum(m.cache_write_tokens) AS cw
740
- FROM messages AS m
772
+ FROM {{messages}} AS m
741
773
  WHERE m.model NOT IN ${EXCLUDED_MODELS} AND ${ORPHAN_TOKENS}${inSessions(f, 'm.session_id')}
742
774
  GROUP BY session_id, user_id, model`, f.params);
743
775
  const byChatModel = {};
@@ -746,7 +778,7 @@ async function computePerChatCosts(f) {
746
778
  const bRows = await q(`
747
779
  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
780
  sum(m.cache_read_tokens) AS cr, sum(m.cache_write_tokens) AS cw
749
- FROM messages AS m
781
+ FROM {{messages}} AS m
750
782
  WHERE m.model IN ${EXCLUDED_MODELS} AND ${ORPHAN_TOKENS}${inSessions(f, 'm.session_id')}
751
783
  GROUP BY session_id, user_id`, f.params);
752
784
  const orphanByChat = {};
@@ -761,7 +793,7 @@ async function computePerChatCosts(f) {
761
793
  c.input_tokens AS ti, c.output_tokens AS to_,
762
794
  c.cache_read_tokens AS cr, c.cache_write_tokens AS cw,
763
795
  formatDateTime(${TS}, '%Y-%m', 'UTC') AS month
764
- FROM sessions_v AS c WHERE 1=1${f.and}`, f.params);
796
+ FROM {{sessions_v}} AS c WHERE 1=1${f.and}`, f.params);
765
797
 
766
798
  const dominantMap = await sessionDominantMap(f);
767
799
  const { sourceDominant, globalDominant } = await sourceDominantMap(f);
@@ -865,7 +897,25 @@ async function schema() {
865
897
  SELECT name, type FROM system.columns
866
898
  WHERE database = currentDatabase() AND table = {tbl:String} ORDER BY position`, { tbl: name });
867
899
  }
868
- return { tables: tbls.map(t => t.name), schema: out };
900
+ // The SPA used to infer the house's flavor from `tables.includes('sessions_v')`. That
901
+ // stopped working the moment the rollup became a saved query: a per-member house has
902
+ // three rooms named for the member and no `sessions_v` at all, so the page reported an
903
+ // indeterminate schema and every typed example it might have offered would have failed
904
+ // with UNKNOWN_TABLE. Hand it the resolved names instead of making it guess.
905
+ const r = await rooms();
906
+ return {
907
+ tables: tbls.map((t) => t.name),
908
+ schema: out,
909
+ rooms: {
910
+ member: r.member,
911
+ sessions: r.sessions,
912
+ messages: r.messages,
913
+ tool_calls: r.tool_calls,
914
+ // A parenthesised SELECT, not a name. It drops
915
+ // into a `FROM … AS c` unchanged, which is what the examples do with it.
916
+ sessions_v: r.sessions_v,
917
+ },
918
+ };
869
919
  }
870
920
 
871
921
  module.exports = {