memhouse 0.18.1 → 0.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/memhouse.js CHANGED
@@ -273,14 +273,24 @@ function portOf(u) {
273
273
  // "not running" for every correctly service-managed install, and `doctor` fails on a
274
274
  // healthy machine. Returns { running, via }.
275
275
  function shipperHealth() {
276
+ // "running" is the pid; whether it is DOING anything is the log. Both, always.
277
+ const lastPass = (() => {
278
+ try {
279
+ const passlog = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog'));
280
+ const f = path.join(LOG_DIR, 'shipper.log'); const st = fs.statSync(f);
281
+ const fd = fs.openSync(f, 'r'); const len = Math.min(st.size, 65536); const buf = Buffer.alloc(len);
282
+ fs.readSync(fd, buf, 0, len, st.size - len); fs.closeSync(fd);
283
+ return passlog.lastPass(buf.toString('utf-8'));
284
+ } catch { return { outcome: null }; }
285
+ })();
276
286
  const pid = pidOf('shipper');
277
- if (pid) return { running: true, via: `daemon (pid ${pid})` };
287
+ if (pid) return { running: true, via: `daemon (pid ${pid})`, lastPass };
278
288
  try {
279
289
  const svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
280
290
  const st = svc.status();
281
- if (st.installed) return { running: st.running, via: `service (${st.kind})` };
291
+ if (st.installed) return { running: st.running, via: `service (${st.kind})`, lastPass };
282
292
  } catch { /* no service integration on this platform */ }
283
- return { running: false, via: null };
293
+ return { running: false, via: null, lastPass };
284
294
  }
285
295
 
286
296
  // Room routing for the CLI's own queries. The shipper and dashboard resolve rooms through
@@ -405,7 +415,9 @@ Setup onboard interactive wizard: discover → configure →
405
415
  --print-sql print the SQL, run it yourself
406
416
  with admin: --admin-user --admin-password [--member NAME]
407
417
  builds house + user + rooms + grants, then verifies as
408
- the member. The admin credential is never stored.
418
+ the member. The admin credential IS written to the env
419
+ file (mode 600) so invite/members need no flags; delete
420
+ those two lines if you would rather pass it each time.
409
421
  --env FILE installs from an invite file (see: invite)
410
422
  invite <name> mint a member and write the env file their install needs
411
423
  (--url [--db NAME] [--out FILE]; --admin-user and
@@ -969,6 +981,16 @@ function startShipperBackground(cfg) {
969
981
  console.log(' dashboard (browse / search / analyze): memhouse start');
970
982
  }
971
983
 
984
+ // The store is node:sqlite, stable from Node 24. On 22 everything green-lights and the
985
+ // SQLite-backed adapters silently ship nothing — 36 messages went out before anyone noticed.
986
+ // doctor said so; install and onboard did not. Now they refuse, with the same sentence.
987
+ function engineOk() {
988
+ const major = Number(process.versions.node.split('.')[0]);
989
+ if (major >= 24) return true;
990
+ console.log(bad(`node ${process.versions.node} — need >= 24 — node:sqlite is stable there; the SQLite-backed adapters need it`));
991
+ process.exitCode = 1; return false;
992
+ }
993
+
972
994
  async function cmdInstall({ interactive }) {
973
995
  // --env <file>: an INVITE intake. The file carries MEMHOUSE_URL/USER/PASSWORD/DB from
974
996
  // `memhouse invite` on the admin's machine; loading it into the environment BEFORE
@@ -985,6 +1007,10 @@ async function cmdInstall({ interactive }) {
985
1007
  // this machine with a file that cannot authenticate on the invitee's.
986
1008
  const missing = wanted.filter((k) => !parsed[k]);
987
1009
  if (missing.length) { console.log(bad(`--env ${flags.env} is missing ${missing.join(', ')} — not a complete invite file?`)); return 1; }
1010
+ try {
1011
+ const h = new URL(String(parsed.MEMHOUSE_URL || '')).hostname;
1012
+ if (/^(localhost|127\.0\.0\.1|\[?::1\]?)$/.test(h)) console.log(warn(`${parsed.MEMHOUSE_URL} is loopback — it points at THIS machine; only right if a tunnel or port-forward puts the house here`));
1013
+ } catch { /* not a URL; the missing-fields check above already spoke */ }
988
1014
  // Clear ambient MEMHOUSE_* so ONLY the file speaks (exported vars normally win over the
989
1015
  // file; an invite intake is the one place they must not).
990
1016
  for (const k of ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB', 'MEMHOUSE_PORT']) delete process.env[k];
@@ -1285,6 +1311,18 @@ async function finishInvite(cfg) {
1285
1311
  }
1286
1312
  if (go) {
1287
1313
  const code = await cmdPasswd({ quiet: true });
1314
+ if (code === 0) {
1315
+ // The rotation rewrote the env FILE. But --env hoisted the invite's values into
1316
+ // process.env, and resolveConfig prefers process.env over the file — so the
1317
+ // shipper spawned a few lines later got the INVITE password, which the rotation
1318
+ // had just killed, and failed every pass with "Authentication failed" while every
1319
+ // fresh CLI call (reading the file) worked. A drill member watched an empty house
1320
+ // for twelve minutes. Adopt what the file now says before anything else is spawned.
1321
+ const drift = envfile.credentialDrift(fs.readFileSync(ENV_FILE, 'utf-8'), process.env);
1322
+ for (const k of drift.changed) process.env[k] = drift.values[k];
1323
+ if (drift.values.MEMHOUSE_PASSWORD !== undefined && flags.password !== undefined) flags.password = drift.values.MEMHOUSE_PASSWORD;
1324
+ if (cfg) cfg.password = process.env.MEMHOUSE_PASSWORD;
1325
+ }
1288
1326
  if (code !== 0) {
1289
1327
  // Rotation failed and its own state may be uncertain — KEEP the invite file (it
1290
1328
  // still carries the password the config was just written from) so nothing is
@@ -1323,7 +1361,8 @@ function printGettingStarted(cfg) {
1323
1361
  console.log(' /mem:sql, /mem:access, /mem:admin');
1324
1362
  console.log(` ${mh} search <terms> find a past conversation right now`);
1325
1363
  console.log(` ${mh} doctor every line a check mark = healthy`);
1326
- console.log(' The house keeps shipping as you work; nothing else to do.');
1364
+ if (flags['no-ship'] === true) console.log(' Shipping is OFF (--no-ship). Start it when ready: memhouse start');
1365
+ else console.log(' The house keeps shipping as you work; nothing else to do.');
1327
1366
  }
1328
1367
 
1329
1368
  async function cmdOnboard() {
@@ -1605,7 +1644,12 @@ async function cmdStatus() {
1605
1644
  console.log(` ${f.writer.padEnd(28)} ${String(f.version || '?').padEnd(8)} last ship ${fleetAge(f.ageMs)}${mark}`);
1606
1645
  }
1607
1646
  }
1608
- console.log(out.shipper.running ? ok(`shipper: running — ${out.shipper.via}`) : warn('shipper: not running'));
1647
+ {
1648
+ const fail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(out.shipper.lastPass);
1649
+ console.log(!out.shipper.running ? warn('shipper: not running')
1650
+ : fail ? warn(`shipper: running — ${out.shipper.via} — but ${fail}`)
1651
+ : ok(`shipper: running — ${out.shipper.via}`));
1652
+ }
1609
1653
  console.log(out.daemons.dashboard ? ok(`dashboard: running (pid ${out.daemons.dashboard})`) : warn('dashboard: not running'));
1610
1654
  if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${runningPort(cfg)}`);
1611
1655
  // Exit non-zero when the house is unreachable. `status` is what a health check, a
@@ -1952,7 +1996,9 @@ async function cmdDoctor() {
1952
1996
  'that adapter does not extract usage; the messages are stored, the numbers are not');
1953
1997
  } catch { /* a house that cannot be read is already reported above */ }
1954
1998
  const sh = shipperHealth();
1955
- add(sh.running, `shipper${sh.via ? ` — ${sh.via}` : ''}`, 'memhouse start (or: memhouse service install)');
1999
+ const shFail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(sh.lastPass);
2000
+ add(sh.running && !shFail, `shipper${sh.via ? ` — ${sh.via}` : ''}${shFail ? ` — ${shFail}` : ''}`,
2001
+ shFail ? (sh.lastPass.auth ? 'memhouse stop && memhouse start' : 'read: ~/.memhouse/logs/shipper.log') : 'memhouse start (or: memhouse service install)');
1956
2002
  add(!!pidOf('dashboard'), 'dashboard daemon', 'memhouse start');
1957
2003
  add(fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html')), 'dashboard UI built', 'built automatically by memhouse start');
1958
2004
 
@@ -3891,8 +3937,8 @@ async function cmdUninstall() {
3891
3937
  // fall through to HELP: forty lines of help and exit 0 is not what --version means.
3892
3938
  case 'version': console.log(PKG.version); break;
3893
3939
  case 'discover': await cmdDiscover(); break;
3894
- case 'onboard': process.exitCode = await cmdOnboard(); break;
3895
- case 'install': process.exitCode = await cmdInstall({ interactive: false }); break;
3940
+ case 'onboard': if (!engineOk()) break; process.exitCode = await cmdOnboard(); break;
3941
+ case 'install': if (!engineOk()) break; process.exitCode = await cmdInstall({ interactive: false }); break;
3896
3942
  case 'setup': {
3897
3943
  // Dual-mode like install: --yes (with any --url/--user/--password/--db/
3898
3944
  // --port overrides, already resolved into cfg) skips every prompt so
package/editors/claude.js CHANGED
@@ -195,11 +195,19 @@ function cleanPrompt(prompt) {
195
195
  // Parse one Claude session .jsonl into the adapter's message shape. When
196
196
  // `isSubagent` is set, each message is tagged `[subagent]` so folded subagent turns
197
197
  // are clearly attributed in the transcript.
198
- function parseSessionFile(filePath, isSubagent) {
198
+ function parseSessionFile(filePath, isSubagent, agent = null) {
199
199
  const messages = [];
200
200
  let lines;
201
201
  try { lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean); } catch { return messages; }
202
202
  const tag = isSubagent ? '[subagent] ' : '';
203
+ // A folded subagent turn used to carry only the `[subagent]` tag: the house held the
204
+ // turns but not WHICH subagent said them, so one fork's transcript could not be isolated,
205
+ // cited, or told apart from a sibling's. An agent looking for the fork as its own session
206
+ // concluded it was never shipped. Every turn now carries the agent's id, the description
207
+ // the parent gave it, and its position within that subagent — in `extra`, which is not
208
+ // part of the line hash, so nothing already shipped is re-shipped for this.
209
+ let turn = 0;
210
+ const stamp = (m) => { if (agent) { m._agent = { ...agent, turn: turn++ }; } return m; };
203
211
 
204
212
  for (const line of lines) {
205
213
  let obj;
@@ -223,19 +231,19 @@ function parseSessionFile(filePath, isSubagent) {
223
231
 
224
232
  if (obj.type === 'user' && obj.message) {
225
233
  const content = extractContent(obj.message.content);
226
- if (content) messages.push({ role: 'user', content: tag + content, _ts: at });
234
+ if (content) messages.push(stamp({ role: 'user', content: tag + content, _ts: at }));
227
235
  } else if (obj.type === 'assistant' && obj.message) {
228
236
  const { text, toolCalls } = extractAssistantContent(obj.message.content);
229
237
  const usage = obj.message.usage;
230
- if (text) messages.push({
238
+ if (text) messages.push(stamp({
231
239
  role: 'assistant', content: tag + text, _model: obj.message.model,
232
240
  _inputTokens: usage?.input_tokens, _outputTokens: usage?.output_tokens,
233
241
  _cacheRead: usage?.cache_read_input_tokens, _cacheWrite: usage?.cache_creation_input_tokens,
234
242
  _toolCalls: toolCalls, _ts: at,
235
- });
243
+ }));
236
244
  } else if (obj.type === 'system') {
237
245
  const text = typeof obj.message?.content === 'string' ? obj.message.content : '';
238
- if (text) messages.push({ role: 'system', content: tag + text, _ts: at });
246
+ if (text) messages.push(stamp({ role: 'system', content: tag + text, _ts: at }));
239
247
  }
240
248
  }
241
249
  return messages;
@@ -252,14 +260,86 @@ function getMessages(chat) {
252
260
  // after the parent's turns, so nothing is lost and subagents are never
253
261
  // double-counted as standalone sessions.
254
262
  const subagentsDir = path.join(filePath.replace(/\.jsonl$/, ''), 'subagents');
255
- try {
256
- const files = fs.readdirSync(subagentsDir).filter(f => f.endsWith('.jsonl')).sort();
257
- for (const f of files) messages.push(...parseSessionFile(path.join(subagentsDir, f), true));
258
- } catch { /* no subagents for this session */ }
263
+ const subFiles = subagentFiles(subagentsDir);
264
+ const named = subFiles.length ? subagentNames(filePath) : new Map();
265
+ for (const { file, workflow } of subFiles) {
266
+ const id = path.basename(file).replace(/^agent-/, '').replace(/\.jsonl$/, '');
267
+ const meta = named.get(id) || {};
268
+ const agent = { id, description: meta.description || '', type: meta.type || (workflow ? 'workflow-subagent' : ''), file: path.basename(file) };
269
+ if (workflow) agent.workflow = workflow;
270
+ const turns = parseSessionFile(file, true, agent);
271
+ // A fork the parent never named (a Workflow-run agent, or a parent from before the
272
+ // ids were reported) is described by its own first prompt — what it was told to do.
273
+ if (!agent.description) {
274
+ const first = turns.find((m) => m.role === 'user');
275
+ if (first) { const d = String(first.content).replace(/^\[subagent\] /, '').replace(/\s+/g, ' ').trim().slice(0, 160); for (const m of turns) m._agent.description = d; }
276
+ }
277
+ messages.push(...turns);
278
+ }
259
279
 
260
280
  return messages;
261
281
  }
262
282
 
283
+ /**
284
+ * Every subagent transcript under a session, wherever Claude Code put it:
285
+ * <session>/subagents/agent-<id>.jsonl — Agent tool forks
286
+ * <session>/subagents/workflows/wf_<id>/agent-<id>.jsonl — Workflow-run agents
287
+ * journal.jsonl and *.meta.json are bookkeeping, not transcripts. Sorted for a stable
288
+ * fold order. On real data the workflow layer held 100 of 165 forks and shipped none.
289
+ */
290
+ function subagentFiles(subagentsDir) {
291
+ const out = [];
292
+ let names = [];
293
+ try { names = fs.readdirSync(subagentsDir); } catch { return out; }
294
+ for (const f of names.sort()) if (f.startsWith('agent-') && f.endsWith('.jsonl')) out.push({ file: path.join(subagentsDir, f), workflow: null });
295
+ const wfDir = path.join(subagentsDir, 'workflows');
296
+ let wfs = [];
297
+ try { wfs = fs.readdirSync(wfDir).filter((d) => d.startsWith('wf_')).sort(); } catch { return out; }
298
+ for (const wf of wfs) {
299
+ let files = [];
300
+ try { files = fs.readdirSync(path.join(wfDir, wf)); } catch { continue; }
301
+ for (const f of files.sort()) if (f.startsWith('agent-') && f.endsWith('.jsonl')) out.push({ file: path.join(wfDir, wf, f), workflow: wf });
302
+ }
303
+ return out;
304
+ }
305
+
306
+ /**
307
+ * What the parent called each subagent. The parent's `Agent` tool_use carries the
308
+ * description and subagent_type; the tool_result that answers it names the agentId
309
+ * ("agentId: <id>"). Join the two through tool_use_id → a map agentId → {description, type}.
310
+ * Best effort: a parent that predates the ids, or a truncated line, just yields no name.
311
+ */
312
+ function subagentNames(parentFile) {
313
+ const byToolUse = new Map(); const out = new Map();
314
+ let lines = [];
315
+ try { lines = fs.readFileSync(parentFile, 'utf-8').split('\n').filter(Boolean); } catch { return out; }
316
+ for (const line of lines) {
317
+ let obj; try { obj = JSON.parse(line); } catch { continue; }
318
+ const content = obj.message && Array.isArray(obj.message.content) ? obj.message.content : [];
319
+ for (const c of content) {
320
+ if (c && c.type === 'tool_use' && c.name === 'Agent' && c.id) {
321
+ byToolUse.set(c.id, { description: String((c.input && c.input.description) || ''), type: String((c.input && c.input.subagent_type) || '') });
322
+ } else if (c && c.type === 'tool_result' && c.tool_use_id && byToolUse.has(c.tool_use_id)) {
323
+ const text = typeof c.content === 'string' ? c.content : (Array.isArray(c.content) ? c.content.map((x) => (x && x.text) || '').join('\n') : '');
324
+ const m = /agentId:\s*([0-9a-f]{6,})/i.exec(text);
325
+ if (m) out.set(m[1], byToolUse.get(c.tool_use_id));
326
+ }
327
+ }
328
+ // A background subagent reports through a task notification instead — a user-role
329
+ // line whose text carries <task-id>agentId</task-id> and <tool-use-id>toolu_…</tool-use-id>.
330
+ // On real data 30 of 65 forks were named only this way.
331
+ const flat = typeof (obj.message && obj.message.content) === 'string' ? obj.message.content
332
+ : content.map((x) => (x && typeof x.text === 'string') ? x.text : '').join('\n');
333
+ if (flat.includes('<task-notification>')) {
334
+ for (const block of flat.split('<task-notification>').slice(1)) {
335
+ const t = /<task-id>([0-9a-f]{6,})<\/task-id>/i.exec(block); const u = /<tool-use-id>(toolu_[A-Za-z0-9]+)<\/tool-use-id>/.exec(block);
336
+ if (t && u && byToolUse.has(u[1]) && !out.has(t[1])) out.set(t[1], byToolUse.get(u[1]));
337
+ }
338
+ }
339
+ }
340
+ return out;
341
+ }
342
+
263
343
  function extractContent(content) {
264
344
  if (typeof content === 'string') return content;
265
345
  if (!Array.isArray(content)) return '';
@@ -420,4 +500,4 @@ function getMCPServers() {
420
500
  // answer to a different question: where this pilot's Claude Code instances live. Two
421
501
  // implementations of "find every CLAUDE_CONFIG_DIR" would drift the first time a playbook
422
502
  // layout changes, and this is the copy exercised on every single ship.
423
- module.exports = { name, labels, getChats, getMessages, getUsage, getArtifacts, getMCPServers, discoverClaudeRoots };
503
+ module.exports = { name, labels, getChats, getMessages, getUsage, getArtifacts, getMCPServers, discoverClaudeRoots, subagentNames, subagentFiles };
@@ -38,6 +38,10 @@ going on. Do not print its contents — the last line of it is a password.
38
38
  {{INSTALL}}
39
39
 
40
40
  On most Linux distros the global install needs `sudo`; if `npm` says `EACCES`, that is why.
41
+ If you handed this page to an agent and it reports that a permission classifier blocked the
42
+ install (a global `npm install` is exactly what such classifiers stop), run this one line
43
+ yourself in a terminal and tell the agent to continue from step 2 — that is the intended
44
+ split: the person installs software, the agent does the rest.
41
45
  `memhouse --version` should print `{{VERSION}}`.
42
46
 
43
47
  ## 2. Join the house
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mem",
3
3
  "description": "Conversation memory for AI agents over the memhouse typed ClickHouse store: recall past sessions and answer from them, run analytics, check the house, bring people in, and administer the server. Skills install as /mem:recall, /mem:sql, /mem:house, /mem:access, and /mem:admin.",
4
- "version": "0.18.1",
4
+ "version": "0.18.3",
5
5
  "author": {
6
6
  "name": "Ramazan Polat"
7
7
  },
@@ -92,6 +92,21 @@ wrong rooms. Say whose memory an answer came from.
92
92
 
93
93
  ## Schema
94
94
 
95
+ **Subagents (forks) live inside their parent session.** Claude Code writes them to
96
+ `<session>/subagents/agent-<id>.jsonl` (Agent tool) and
97
+ `<session>/subagents/workflows/wf_<id>/agent-<id>.jsonl` (Workflow runs); the shipper folds them into the parent with
98
+ `is_subagent = 1` and stamps *which* one in `extra.agent`. Looking for a fork as its own
99
+ session finds nothing — look inside the parent:
100
+
101
+ ```sql
102
+ -- the subagents a session spawned
103
+ SELECT toString(extra.agent.id) AS agent, any(toString(extra.agent.description)) AS asked_to, count() AS turns
104
+ FROM messages WHERE session_id = '<sid>' AND is_subagent GROUP BY agent -- toString: a JSON path cannot be a GROUP BY key
105
+ -- one subagent's transcript, in order
106
+ SELECT seq, role, substring(text, 1, 2000) FROM messages
107
+ WHERE session_id = '<sid>' AND extra.agent.id = '<agent>' ORDER BY seq
108
+ ```
109
+
95
110
  **`messages`** — one row per turn.
96
111
 
97
112
  | column | meaning |
@@ -102,6 +117,8 @@ wrong rooms. Say whose memory an answer came from.
102
117
  | `host` | the machine, `<hostname>-<8 hex>` — random per install, not derived |
103
118
  | `ts` | when the turn happened (`DateTime64(3,'UTC')`) |
104
119
  | `role` | `user`, `assistant`, `system` |
120
+ | `is_subagent` | 1 for a turn that a Claude Code **subagent** (a fork) produced; folded into the parent session after the parent's own turns. Subagents are never separate sessions. |
121
+ | `extra` | JSON. For a subagent turn: `extra.agent.id`, `extra.agent.description` (what the parent asked it to do, else the fork's own first prompt), `extra.agent.type` (`general-purpose`, `workflow-subagent`, …), `extra.agent.turn` (position within that subagent), and `extra.agent.workflow` (`wf_…`) when a Workflow run spawned it. Empty `{}` otherwise, and on rows shipped before 0.18.3. |
105
122
  | `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens` | usage; **empty for several adapters** — see below |
106
123
  | `text` | the turn, **truncated at 50,000 chars** |
107
124
  | `text_ngram`, `text_word` | `MATERIALIZED lower(text)`, carrying the text indexes |
@@ -112,6 +112,11 @@ Beyond the three in the reference:
112
112
 
113
113
  - **Ranking is yours.** There is no relevance score in the store. `hits` plus recency is
114
114
  the whole of it — say so rather than implying the top row is "the best match".
115
+ - **A subagent (fork) is not a session.** Its turns are inside the parent session with
116
+ `is_subagent = 1` and `extra.agent.id` / `extra.agent.description` saying which one; the
117
+ reference has the two queries. "Find the fork that did X" → search `messages` as usual,
118
+ then group the hits by `toString(extra.agent.id)` — never conclude it was not shipped because no
119
+ session carries its name.
115
120
  - **Tool *results* are not stored.** "What did that command print" is unanswerable from
116
121
  the house; only the invocation and its arguments survive.
117
122
  - **`memhouse search`** exists as a CLI, but it joins your terms into ONE literal phrase
@@ -46,4 +46,19 @@ function assertSingleLine(env) {
46
46
  }
47
47
  }
48
48
 
49
- module.exports = { parse, quoteShell, quoteSystemd, assertSingleLine };
49
+ /**
50
+ * Which connection values in an env file's text differ from what a process currently holds.
51
+ * A running shipper was spawned with one credential; `install --env` rotates the password
52
+ * and rewrites the file, and a member can `memhouse passwd` under a running daemon. In
53
+ * both cases the FILE is the truth and the process copy is stale. Pure; used by the
54
+ * shipper (adopt on auth failure) and by install (adopt before spawning).
55
+ */
56
+ function credentialDrift(text, current = {}) {
57
+ const file = parse(String(text || ''));
58
+ const keys = ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB'];
59
+ const changed = keys.filter((k) => file[k] !== undefined && file[k] !== current[k]);
60
+ const values = Object.fromEntries(changed.map((k) => [k, file[k]]));
61
+ return { changed, values };
62
+ }
63
+
64
+ module.exports = { credentialDrift, parse, quoteShell, quoteSystemd, assertSingleLine };
@@ -306,6 +306,117 @@ function sessionsRollup({ sessions, messages }) {
306
306
  )`;
307
307
  }
308
308
 
309
+ // The precomputed rollups' table names. One place, because the shipper that fills them,
310
+ // the runtime probe and the read layer must all agree.
311
+ const SESSION_STATS = 'session_stats';
312
+ const MODEL_STATS = 'session_model_stats';
313
+ const TOOL_STATS = 'session_tool_stats';
314
+ // One-layout: like every room, a stat table is named for its member — `<member>_session_stats`
315
+ // — so it sits under the same `<db>.<member>_*` grant the member already holds. No
316
+ // database-level privilege exists in this layout, and none is needed.
317
+ function statTableNames(member) {
318
+ return {
319
+ session_stats: physicalRoom(SESSION_STATS, member),
320
+ model_stats: physicalRoom(MODEL_STATS, member),
321
+ tool_stats: physicalRoom(TOOL_STATS, member),
322
+ };
323
+ }
324
+
325
+ /**
326
+ * The precomputed rollups — the tables the dashboard reads instead of rebuilding the
327
+ * session rollup on every query. Measured on a 714k-message house: 1,413,624 rows read
328
+ * per query became ~1,250, and a page load went from 4–10 seconds to ~250 ms.
329
+ *
330
+ * REFRESHED BY THE SHIPPER, NOT BY A MATERIALIZED VIEW — and that is forced, not chosen.
331
+ * The obvious tool is a refreshable materialized view (`REFRESH EVERY 5 MINUTE TO t`),
332
+ * which is exactly what the 0.17 line uses. It cannot exist in this layout: a refreshable
333
+ * view swaps its target atomically through a temporary table with a generated name, so
334
+ * ClickHouse demands `SELECT, INSERT, CREATE TABLE, DROP TABLE ON <db>.*` — the whole
335
+ * database — and a one-layout member holds `<db>.<member>_*` and nothing else, on
336
+ * purpose. Measured on 25.8: the CREATE is refused with exactly that message, and the
337
+ * grant that would satisfy it is the one this layout exists to withhold.
338
+ *
339
+ * (An ordinary insert-trigger MV is wrong for a different reason: it counts every
340
+ * ReplacingMergeTree version the merge has not collapsed yet, cannot see the epoch
341
+ * filter, and cannot join.)
342
+ *
343
+ * So the shipper runs the rollup itself, once at the end of every pass — the same
344
+ * cadence a view would have had — and INSERTs the result stamped with one
345
+ * `refreshed_at`. Every insert is a complete snapshot, a GENERATION; the read layer
346
+ * takes only the newest generation. Nothing is deleted, nothing is swapped, nothing
347
+ * needs a privilege beyond INSERT and SELECT on the member's own rooms. A TTL of one day
348
+ * keeps old generations from accumulating (288 a day at the default cadence), and the
349
+ * sorting key leads with `refreshed_at` so reading the newest one is one index range,
350
+ * not a scan.
351
+ *
352
+ * Built from the SAME rollup text the read layer would otherwise run, so the two
353
+ * cannot drift; column types are inferred with EMPTY AS for the same reason.
354
+ */
355
+ const STAT_TTL = 'TTL toDateTime(refreshed_at) + INTERVAL 1 DAY';
356
+
357
+ function statBodies(rooms) {
358
+ // sessionsRollup wraps itself in parentheses for the FROM position; strip them.
359
+ const rollup = sessionsRollup(rooms).replace(/^\s*\(/, '').replace(/\)\s*$/, '');
360
+ return {
361
+ session_stats: {
362
+ key: '(refreshed_at, session_id, user_id)',
363
+ body: rollup,
364
+ },
365
+ // One row per (session, user, model) with the message count and the four token sums.
366
+ // Every model/cost aggregate the dashboard runs is a sum or argMax over these rows —
367
+ // a few thousand — instead of a scan of the full messages room. The read layer's
368
+ // any-token predicates survive unchanged: a zero-token row contributes zero to every
369
+ // sum, so filtering before summing and summing over everything agree.
370
+ model_stats: {
371
+ key: '(refreshed_at, session_id, user_id, model)',
372
+ body: `SELECT session_id, user_id, any(source) AS source, any(folder) AS folder, model,
373
+ count() AS msgs,
374
+ sum(input_tokens) AS input_tokens, sum(output_tokens) AS output_tokens,
375
+ sum(cache_read_tokens) AS cache_read_tokens, sum(cache_write_tokens) AS cache_write_tokens
376
+ FROM ${rooms.messages} AS m
377
+ GROUP BY session_id, user_id, model`,
378
+ },
379
+ // One row per (session, user, tool_name) with the call count. NOTE the epoch source:
380
+ // rooms.tool_calls is currentParse(tool_calls, 'messages') — the tool room takes its
381
+ // epoch from MESSAGES, because a parse that keeps its messages but emits no tool calls
382
+ // writes nothing at the new epoch, and this room's own max(epoch) would resurrect the
383
+ // superseded parse's calls.
384
+ tool_stats: {
385
+ key: '(refreshed_at, session_id, user_id, tool_name)',
386
+ body: `SELECT session_id, user_id, any(source) AS source, any(folder) AS folder, tool_name,
387
+ count() AS calls
388
+ FROM ${rooms.tool_calls} AS tc
389
+ GROUP BY session_id, user_id, tool_name`,
390
+ },
391
+ };
392
+ }
393
+
394
+ /** The CREATE statements — additive, idempotent, run by ensureSchema. */
395
+ function statCreateStatements(rooms) {
396
+ const names = statTableNames(rooms.member);
397
+ const bodies = statBodies(rooms);
398
+ return Object.keys(bodies).map((k) =>
399
+ `CREATE TABLE IF NOT EXISTS ${names[k]} ENGINE = MergeTree ORDER BY ${bodies[k].key} ${STAT_TTL} `
400
+ + `EMPTY AS SELECT now64(3) AS refreshed_at, * FROM (${bodies[k].body})`);
401
+ }
402
+
403
+ /**
404
+ * The refresh — one INSERT per table, one generation per call. All three carry the same
405
+ * `refreshed_at` so a reader never mixes a session_stats generation with a model_stats
406
+ * one. Run by the shipper after every pass and by --ensure-schema.
407
+ */
408
+ function statRefreshStatements(rooms, at = 'now64(3)') {
409
+ const names = statTableNames(rooms.member);
410
+ const bodies = statBodies(rooms);
411
+ return Object.keys(bodies).map((k) =>
412
+ `INSERT INTO ${names[k]} SELECT ${at} AS refreshed_at, * FROM (${bodies[k].body})`);
413
+ }
414
+
415
+ /** The read-side source for one stat table: its newest generation, and nothing else. */
416
+ function latestGeneration(table) {
417
+ return `(SELECT * FROM ${table} WHERE refreshed_at = (SELECT max(refreshed_at) FROM ${table}))`;
418
+ }
419
+
309
420
  /**
310
421
  * The settings every read needs. `final` collapses ReplacingMergeTree versions;
311
422
  * `join_use_nulls` is what the rollup's coalesce depends on.
@@ -381,8 +492,67 @@ async function currentUser(client) {
381
492
  }
382
493
 
383
494
  /** Resolve the rooms for whoever this client is connected as. */
495
+ /**
496
+ * The precomputed rollup, when the house has one.
497
+ *
498
+ * `sessions_v` is a SAVED QUERY that rebuilds the whole rollup — sessions FINAL joined
499
+ * to the current parse of messages, grouped over the entire house — on every call. A
500
+ * house that has run the session_stats migration carries the same rows as a flat table,
501
+ * refreshed on a timer, and the read layer can name that instead. Measured on a 714k
502
+ * message house: 1,413,624 rows read per query becomes 1,230, and p50 query time 68ms
503
+ * becomes 4ms.
504
+ *
505
+ * Resolved at RUNTIME rather than by version, and that is the point: one binary has to
506
+ * serve a migrated house, a house whose migration has not run, and a friend's house
507
+ * shared read-only that was never migrated at all. Asking the server what exists is the
508
+ * only answer that is correct in all three.
509
+ */
510
+ async function statTables(client, member) {
511
+ const names = statTableNames(member);
512
+ const wanted = Object.values(names);
513
+ try {
514
+ const rs = await client.query({
515
+ query: `SELECT name FROM system.tables
516
+ WHERE database = currentDatabase()
517
+ AND name IN (${wanted.map((n) => `'${n}'`).join(', ')})`,
518
+ format: 'JSONEachRow',
519
+ });
520
+ const present = (await rs.json()).map((r) => r.name);
521
+ // Present is not enough: a table exists from the moment ensureSchema creates it and
522
+ // holds nothing until the first refresh lands. A dashboard reading it then would show
523
+ // a house with no sessions at all (caught by house-test the first time). An empty
524
+ // stat table reads as absent, so the inline rollup answers until the table can.
525
+ const usable = new Set();
526
+ for (const name of present) {
527
+ try {
528
+ const c = await client.query({ query: `SELECT count() AS n FROM ${name}`, format: 'JSONEachRow' });
529
+ const rows = await c.json();
530
+ if (Number(rows[0] && rows[0].n) > 0) usable.add(name);
531
+ } catch { /* unreadable: treat as absent */ }
532
+ }
533
+ return usable;
534
+ } catch {
535
+ // No grant on system.tables is not an error — it means "assume none", and the
536
+ // subquery path is always correct.
537
+ return new Set();
538
+ }
539
+ }
540
+
541
+ async function hasSessionStats(client, member) {
542
+ return (await statTables(client, member)).has(statTableNames(member).session_stats);
543
+ }
544
+
384
545
  async function resolveRooms(client) {
385
- return roomNames(await currentUser(client));
546
+ const rooms = roomNames(await currentUser(client));
547
+ const names = statTableNames(rooms.member);
548
+ const have = await statTables(client, rooms.member);
549
+ if (have.has(names.session_stats)) rooms.sessions_v = latestGeneration(names.session_stats);
550
+ // Null when absent: the read layer branches to the legacy scan-the-room SQL. Never a
551
+ // token substitution, because the legacy SQL has a different shape, not just a
552
+ // different table name.
553
+ rooms.model_stats = have.has(names.model_stats) ? latestGeneration(names.model_stats) : null;
554
+ rooms.tool_stats = have.has(names.tool_stats) ? latestGeneration(names.tool_stats) : null;
555
+ return rooms;
386
556
  }
387
557
 
388
558
  /**
@@ -426,4 +596,6 @@ module.exports = {
426
596
  READ_SETTINGS, MEMBER_PIN,
427
597
  installCommand, assertUsableName,
428
598
  sessionsRollup, currentParse, createStatement, roomNames, currentUser, resolveRooms,
599
+ SESSION_STATS, MODEL_STATS, TOOL_STATS, statTableNames, hasSessionStats, statTables,
600
+ statCreateStatements, statRefreshStatements, latestGeneration,
429
601
  };
@@ -61,7 +61,10 @@ function plan({ db, member, password = null }) {
61
61
  { sql: `CREATE DATABASE IF NOT EXISTS ${db}`, why: 'the house', optional: false },
62
62
  ];
63
63
  if (password !== null) {
64
- steps.push({ sql: `CREATE USER IF NOT EXISTS ${member} IDENTIFIED BY ${sqlString(password)}`, why: 'the member', optional: false });
64
+ // No IF NOT EXISTS. The live path checks first and refuses an existing user; the printed
65
+ // plan used to no-op here, apply the grants, and hand out a password the server never saw —
66
+ // found on a realm that mints its own users. Now it fails loudly on line one instead.
67
+ steps.push({ sql: `CREATE USER ${member} IDENTIFIED BY ${sqlString(password)}`, why: 'the member — fails if it already exists; then this password is NOT theirs', optional: false });
65
68
  }
66
69
  steps.push(
67
70
  { sql: roomGrant(db, member), why: `their rooms — ${db}.${roomPattern(member)} — and nothing else in the house`, optional: false },