memhouse 0.4.4 → 0.7.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 (63) hide show
  1. package/README.md +244 -114
  2. package/bin/memhouse.js +1177 -231
  3. package/editors/base.js +6 -0
  4. package/editors/claude.js +44 -9
  5. package/editors/goose.js +4 -0
  6. package/editors/index.js +0 -0
  7. package/editors/opencode.js +3 -0
  8. package/{mem-house → memhouse}/COMPETITION.md +5 -5
  9. package/{mem-house → memhouse}/DESIGN.md +18 -12
  10. package/memhouse/RESUME-AND-UPDATE.md +185 -0
  11. package/{mem-house → memhouse}/delivery/AGENT-INSTALL-PROMPT.md +1 -1
  12. package/{mem-house → memhouse}/delivery/AGENT-INSTALL.md +9 -7
  13. package/{mem-house → memhouse}/delivery/PROMPT.md +8 -8
  14. package/{mem-house → memhouse}/delivery/kernel-install.md +3 -3
  15. package/{mem-house → memhouse}/delivery/plugin/.claude-plugin/plugin.json +1 -1
  16. package/{mem-house/delivery/skills/memhouse-search → memhouse/delivery/plugin/skills/search}/SKILL.md +60 -18
  17. package/memhouse/delivery/plugin/skills/sessions/SKILL.md +177 -0
  18. package/memhouse/delivery/plugin/skills/sql/SKILL.md +206 -0
  19. package/memhouse/host.js +101 -0
  20. package/{mem-house → memhouse}/per-member/INSTALL.md +59 -14
  21. package/{mem-house → memhouse}/per-member/SCHEMA.md +47 -0
  22. package/{mem-house → memhouse}/per-member/SHARING.md +47 -0
  23. package/memhouse/per-member/provision.js +297 -0
  24. package/memhouse/per-member/rooms.js +252 -0
  25. package/{mem-house → memhouse}/per-member/schema-member.sql.tpl +18 -14
  26. package/{mem-house → memhouse}/per-member/schema-merge.sql.tpl +3 -3
  27. package/memhouse/resume.js +150 -0
  28. package/memhouse/self-update.js +150 -0
  29. package/{mem-house → memhouse}/server/queries.js +80 -34
  30. package/memhouse/server/server.js +451 -0
  31. package/{mem-house → memhouse}/service.js +48 -29
  32. package/memhouse/shipper/ship.js +949 -0
  33. package/package.json +5 -20
  34. package/public/assets/{index-CtH-5i95.js → index-CnWzrjF3.js} +10 -7
  35. package/public/index.html +2 -2
  36. package/ui/index.html +1 -1
  37. package/ui/src/App.jsx +1 -1
  38. package/ui/src/lib/api.js +29 -13
  39. package/ui/src/pages/Dashboard.jsx +8 -5
  40. package/ui/src/pages/Sessions.jsx +3 -1
  41. package/ui/src/pages/SqlViewer.jsx +6 -23
  42. package/cache.js +0 -850
  43. package/ch-ingest.js +0 -329
  44. package/ch-schema.js +0 -152
  45. package/clickhouse.js +0 -87
  46. package/index.js +0 -425
  47. package/mcp-server.js +0 -294
  48. package/mem-house/delivery/install.sh +0 -183
  49. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +0 -125
  50. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +0 -146
  51. package/mem-house/per-member/provision.js +0 -142
  52. package/mem-house/per-member/rooms.js +0 -132
  53. package/mem-house/server/server.js +0 -228
  54. package/mem-house/shipper/ship.js +0 -566
  55. package/relay-client.js +0 -306
  56. package/relay-server.js +0 -577
  57. package/server.js +0 -860
  58. package/share-image.js +0 -367
  59. /package/{mem-house → memhouse}/deploy.js +0 -0
  60. /package/{mem-house → memhouse}/envfile.js +0 -0
  61. /package/{mem-house → memhouse}/per-member/PLAN.md +0 -0
  62. /package/{mem-house → memhouse}/per-member/PROVISIONING.md +0 -0
  63. /package/{mem-house → memhouse}/per-member/README.md +0 -0
package/bin/memhouse.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- // memhouse — the mem-house CLI. One command to install, ship, serve, and query
3
- // agent conversation memory (see mem-house/DESIGN.md).
2
+ // memhouse — the memhouse CLI. One command to install, ship, serve, and query
3
+ // agent conversation memory (see memhouse/DESIGN.md).
4
4
  //
5
5
  // Thin orchestrator: heavy operations run the existing entrypoints
6
- // (mem-house/shipper/ship.js, mem-house/server/server.js) as children with the
6
+ // (memhouse/shipper/ship.js, memhouse/server/server.js) as children with the
7
7
  // resolved MEMHOUSE_* env, so there is a single source of truth for shipping and
8
8
  // serving. The CLI owns: config (~/.memhouse/env), daemons (pidfiles), probes,
9
9
  // and the small read-only queries (status/search) done over ClickHouse HTTP.
@@ -19,12 +19,12 @@ const path = require('path');
19
19
  const { spawn, spawnSync } = require('child_process');
20
20
 
21
21
  const REPO_ROOT = path.join(__dirname, '..');
22
- const SHIP_JS = path.join(REPO_ROOT, 'mem-house', 'shipper', 'ship.js');
23
- const SERVER_JS = path.join(REPO_ROOT, 'mem-house', 'server', 'server.js');
24
- const DELIVERY = path.join(REPO_ROOT, 'mem-house', 'delivery');
22
+ const SHIP_JS = path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js');
23
+ const SERVER_JS = path.join(REPO_ROOT, 'memhouse', 'server', 'server.js');
24
+ const DELIVERY = path.join(REPO_ROOT, 'memhouse', 'delivery');
25
25
  const PKG = require(path.join(REPO_ROOT, 'package.json'));
26
- const { roomNames, ROOM_TYPES, mergeRooms } = require(path.join(REPO_ROOT, 'mem-house', 'per-member', 'rooms'));
27
- const envfile = require(path.join(REPO_ROOT, 'mem-house', 'envfile'));
26
+ const { roomNames, ROOM_TYPES, mergeRooms, MEMBER_PRIVS, MEMBER_PROFILE, MEMBER_PROFILE_SETTINGS, installCommand } = require(path.join(REPO_ROOT, 'memhouse', 'per-member', 'rooms'));
27
+ const envfile = require(path.join(REPO_ROOT, 'memhouse', 'envfile'));
28
28
 
29
29
  const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
30
30
  const ENV_FILE = path.join(HOME_DIR, 'env');
@@ -48,47 +48,7 @@ for (let i = 0; i < rest.length; i++) {
48
48
  }
49
49
  const JSON_OUT = flags.json === true;
50
50
 
51
- // A config written by the solo tier points at a shim this build cannot start. Left
52
- // alone it reads as an ordinary external house that happens to be down, so `start`
53
- // launches a shipper and dashboard against a dead endpoint and `doctor` reports a
54
- // generic connection failure. Refuse, and say the two things that actually work.
55
- //
56
- // Commands that are the way OUT stay allowed — including, emphatically, the ones the
57
- // refusal message itself recommends. A guard that blocks its own advice leaves the user
58
- // editing the env file by hand.
59
- //
60
- // deploy — `--local` rewrites the config, which is what ENDS this state; `--down`
61
- // only removes a container.
62
- // service — `status` is how the stale-unit warning is seen and `uninstall` is how it
63
- // is removed; only `service install` is blocked, since installing a unit
64
- // pointed at the dead shim is the one thing here that makes it worse.
65
- const LEGACY_SOLO_OK = new Set([
66
- 'setup', 'uninstall', 'discover', 'plugins', 'prompt', 'stop', 'deploy', 'help', 'version', null,
67
- ]);
68
-
69
- function legacySoloGuard() {
70
- let file = {};
71
- try { file = envfile.parse(fs.readFileSync(ENV_FILE, 'utf-8')); } catch { return false; }
72
- if (file.MEMHOUSE_SOLO !== '1') return false;
73
- if (LEGACY_SOLO_OK.has(cmd)) return false;
74
- if (cmd === 'service' && positional[0] !== 'install') return false;
75
- console.log(bad(`${ENV_FILE.replace(os.homedir(), '~')} was written by the solo tier, which this version removed.`));
76
- console.log(` It points at ${file.MEMHOUSE_URL || 'an embedded shim'}, and nothing here can start that.`);
77
- console.log('');
78
- console.log(' Your transcripts are not lost — memhouse ships FROM your local session stores,');
79
- console.log(' so a new house rebuilds them. Point at one and re-ship:');
80
- console.log(' memhouse deploy --local (a ClickHouse in docker or podman)');
81
- console.log(' memhouse setup --url … --user … --password … (one you already run)');
82
- console.log(' memhouse ship --full');
83
- console.log('');
84
- console.log(' The old embedded data is chdb-format and only readable by chdb; keep');
85
- console.log(` ${path.join(HOME_DIR, 'solo-data').replace(os.homedir(), '~')} if you want it, or delete it.`);
86
- console.log(' Then, for anything the old version left behind:');
87
- console.log(' memhouse stop (reaps a shim still running)');
88
- console.log(' memhouse service status (shows a stale unit, if there is one)');
89
- console.log(' memhouse service uninstall (removes it)');
90
- return true;
91
- }
51
+ let ONBOARDING = false;
92
52
 
93
53
  // ── config ──────────────────────────────────────────────────────────────────────
94
54
  function readEnvFile() {
@@ -99,18 +59,56 @@ function resolveConfig() {
99
59
  const file = readEnvFile();
100
60
  const pick = (flag, env, dflt) => flags[flag] !== undefined && flags[flag] !== true
101
61
  ? flags[flag] : (process.env[env] ?? file[env] ?? dflt);
62
+ const stated = (flag, env) => (flags[flag] !== undefined && flags[flag] !== true)
63
+ || process.env[env] !== undefined || file[env] !== undefined;
102
64
  return {
103
65
  url: pick('url', 'MEMHOUSE_URL', 'http://localhost:8123'),
104
66
  user: pick('user', 'MEMHOUSE_USER', 'memhouse_root'),
105
67
  password: pick('password', 'MEMHOUSE_PASSWORD', ''),
106
68
  db: pick('db', 'MEMHOUSE_DB', 'mem'),
107
69
  port: pick('port', 'MEMHOUSE_PORT', '4640'),
70
+ // Did anything actually SAY which house this is, or are the values above just the
71
+ // defaults? The defaults are not neutral — localhost:8123 as memhouse_root is a real
72
+ // house on a lot of machines, usually the pilot's own. An agent whose config went
73
+ // missing (a temp HOME, a deleted env file, a service running as another user) would
74
+ // otherwise connect there and read or write someone else's memory, silently and with
75
+ // a plausible-looking result. Commands that expect YOUR house call requireConfig().
76
+ stated: stated('url', 'MEMHOUSE_URL') || stated('user', 'MEMHOUSE_USER'),
108
77
  };
109
78
  }
110
79
 
80
+ // Refuse to fall back to the built-in defaults. `install`, `deploy`, `discover`,
81
+ // `doctor` and `prompt` are exempt by design — they exist precisely for the state where
82
+ // there is no config yet, and doctor probes localhost on purpose.
83
+ function requireConfig(cfg, what) {
84
+ if (cfg.stated) return cfg;
85
+ if (JSON_OUT) {
86
+ // A --json caller gets JSON even when refused. Prose on stderr and an empty stdout is
87
+ // an unparseable answer to a machine-readable request.
88
+ console.log(JSON.stringify({
89
+ error: 'no_config', command: what, env_file: ENV_FILE, home: HOME_DIR,
90
+ message: `no house configured, so ${what} has nothing to talk to`,
91
+ fix: ['memhouse install --url … --user … --password …', 'memhouse onboard'],
92
+ }, null, 2));
93
+ process.exit(2);
94
+ }
95
+ console.error(`memhouse: no house configured, so ${what} has nothing to talk to.`);
96
+ console.error(` Nothing was read from ${ENV_FILE.replace(os.homedir(), '~')} and no MEMHOUSE_URL/MEMHOUSE_USER is set.`);
97
+ console.error(' Rather than guess http://localhost:8123 as memhouse_root — which on many');
98
+ console.error(' machines is a real house belonging to someone else — this stops here.');
99
+ console.error(' Fix it with one of:');
100
+ console.error(' memhouse install --url … --user … --password … point at a house you have');
101
+ console.error(' memhouse onboard set one up from scratch');
102
+ console.error(' MEMHOUSE_URL=… MEMHOUSE_USER=… memhouse … state it for this run');
103
+ console.error(` If your config lives elsewhere, set MEMHOUSE_HOME (currently ${HOME_DIR.replace(os.homedir(), '~')}).`);
104
+ process.exit(2);
105
+ }
106
+
111
107
  function childEnv(cfg) {
112
108
  return {
113
109
  ...process.env,
110
+ // Set only by the install path, which prints its own refusal for this case.
111
+ ...(cfg._quietDenied ? { MEMHOUSE_QUIET_DENIED: '1' } : {}),
114
112
  MEMHOUSE_URL: cfg.url, MEMHOUSE_USER: cfg.user, MEMHOUSE_PASSWORD: cfg.password,
115
113
  MEMHOUSE_DB: cfg.db, MEMHOUSE_PORT: String(cfg.port), MEMHOUSE_HOME: HOME_DIR,
116
114
  };
@@ -122,7 +120,7 @@ function writeEnvFile(cfg) {
122
120
  // `. ~/.memhouse/env`), so metacharacters in a password must never be bare.
123
121
  const sq = envfile.quoteShell;
124
122
  const body = [
125
- '# mem-house connection — written by `memhouse install/setup`',
123
+ '# memhouse connection — written by `memhouse install/setup`',
126
124
  `MEMHOUSE_URL=${sq(cfg.url)}`,
127
125
  `MEMHOUSE_USER=${sq(cfg.user)}`,
128
126
  `MEMHOUSE_PASSWORD=${sq(cfg.password)}`,
@@ -155,47 +153,6 @@ async function chRows(cfg, sql, opts) {
155
153
  return text ? text.split('\n').map((l) => JSON.parse(l)) : [];
156
154
  }
157
155
 
158
- /**
159
- * Is this house a pre-0.4 one?
160
- *
161
- * 0.3.x kept three rooms named `sessions`/`messages`/`tool_calls` plus a stored
162
- * `sessions_v` view, shared by everyone and separated by row policies. 0.4.0 deleted that
163
- * layout. The database NAME survives an upgrade — explicit MEMHOUSE_DB outranks the new
164
- * `mem` default — but the TABLES do not, so every read and write fails with UNKNOWN_TABLE
165
- * naming a room the user has never heard of.
166
- *
167
- * Saying "run memhouse install" to someone in that state is useless: they did install, and
168
- * their memory is sitting right there in the house. Detect it and say so.
169
- */
170
- async function looksLikePre040(cfg) {
171
- try {
172
- const rows = await chRows(cfg,
173
- `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND name IN ('sessions','messages','tool_calls','sessions_v')`,
174
- { database: '' });
175
- return rows.map((r) => r.name);
176
- } catch { return []; }
177
- }
178
-
179
- function reportPre040(cfg, member, found) {
180
- console.log(bad(`'${cfg.db}' holds a pre-0.4 house — ${found.join(', ')} — and 0.4.0 cannot read it.`));
181
- console.log(' 0.4.0 replaced the shared rooms with one set per member. The database name');
182
- console.log(' survived your upgrade; the table names did not.');
183
- console.log('');
184
- console.log(' Your transcripts are NOT lost. memhouse ships FROM your local session stores,');
185
- console.log(' so the new rooms rebuild from disk. Build them in a NEW house and leave this');
186
- console.log(' one untouched:');
187
- console.log(` memhouse install --force --db mem --admin-user <user> --admin-password <pw> --member ${member}`);
188
- console.log(' memhouse ship --full');
189
- console.log('');
190
- console.log(` A NEW house, not this one, and --force because the config still points here.`);
191
- console.log(` Rebuilding into '${cfg.db}' would put the member rooms beside the old`);
192
- console.log(" `sessions_v`, which the `^sessions_` team-room selector matches — every");
193
- console.log(' session would then be counted twice.');
194
- console.log('');
195
- console.log(' Sessions whose transcripts you have since deleted locally live only in the old');
196
- console.log(` tables. Read them there before dropping anything: SELECT * FROM ${cfg.db}.sessions`);
197
- }
198
-
199
156
  /** Is anything listening on this loopback port? */
200
157
  function portInUse(port) {
201
158
  return new Promise((resolve) => {
@@ -236,7 +193,7 @@ function shipperHealth() {
236
193
  const pid = pidOf('shipper');
237
194
  if (pid) return { running: true, via: `daemon (pid ${pid})` };
238
195
  try {
239
- const svc = require(path.join(REPO_ROOT, 'mem-house', 'service.js'));
196
+ const svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
240
197
  const st = svc.status();
241
198
  if (st.installed) return { running: st.running, via: `service (${st.kind})` };
242
199
  } catch { /* no service integration on this platform */ }
@@ -275,6 +232,46 @@ async function ask(question, dflt) {
275
232
  return a || dflt || '';
276
233
  }
277
234
 
235
+ // undici reports every transport failure as the string 'fetch failed' and hides the real
236
+ // reason in .cause. `✗ not connected: fetch failed` names neither the host nor the problem,
237
+ // while `ship` prints ECONNREFUSED for the identical condition — the three commands
238
+ // disagreed about the same event.
239
+ function netReason(e) {
240
+ const cause = e && e.cause;
241
+ const inner = cause && (cause.message || cause.code);
242
+ const codes = cause && Array.isArray(cause.errors)
243
+ ? cause.errors.map((x) => x && (x.code || x.message)).filter(Boolean) : [];
244
+ const detail = inner || codes[0];
245
+ if (!detail) return e && e.message ? e.message : String(e);
246
+ return e.message && e.message !== detail ? `${e.message} (${detail})` : detail;
247
+ }
248
+
249
+ // Read without echoing. `onboard` and `setup` both prompt for the house password and
250
+ // printed it back on the terminal — in a wizard whose whole audience is someone typing a
251
+ // credential in front of whoever is in the room.
252
+ function askSecret(label, dflt = '') {
253
+ if (!process.stdin.isTTY) return ask(label, dflt);
254
+ return new Promise((resolve) => {
255
+ process.stdout.write(`${label}${dflt ? ` [${'*'.repeat(Math.min(dflt.length, 8))}]` : ''}: `);
256
+ const stdin = process.stdin;
257
+ const wasRaw = stdin.isRaw;
258
+ stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf-8');
259
+ let buf = '';
260
+ const onData = (ch) => {
261
+ if (ch === '\r' || ch === '\n' || ch === '\u0004') {
262
+ stdin.removeListener('data', onData);
263
+ stdin.setRawMode(!!wasRaw); stdin.pause();
264
+ process.stdout.write('\n');
265
+ return resolve(buf || dflt);
266
+ }
267
+ if (ch === '\u0003') { process.stdout.write('\n'); process.exit(130); }
268
+ if (ch === '\u007f' || ch === '\b') { buf = buf.slice(0, -1); return; }
269
+ buf += ch;
270
+ };
271
+ stdin.on('data', onData);
272
+ });
273
+ }
274
+
278
275
  function pidOf(name) {
279
276
  try {
280
277
  const pid = parseInt(fs.readFileSync(path.join(RUN_DIR, name + '.pid'), 'utf-8'), 10);
@@ -283,6 +280,17 @@ function pidOf(name) {
283
280
  } catch { return null; }
284
281
  }
285
282
 
283
+ // Signal 0 tests for existence without delivering anything.
284
+ // The port the RUNNING dashboard was started with, not the one currently configured.
285
+ function runningPort(cfg) {
286
+ try { return fs.readFileSync(path.join(RUN_DIR, 'dashboard.port'), 'utf-8').trim() || cfg.port; }
287
+ catch { return cfg.port; }
288
+ }
289
+
290
+ function alive(pid) {
291
+ try { process.kill(pid, 0); return true; } catch { return false; }
292
+ }
293
+
286
294
  function ensureUiBuilt() {
287
295
  if (fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html'))) return true;
288
296
  const uiDir = path.join(REPO_ROOT, 'ui');
@@ -307,18 +315,29 @@ Setup onboard interactive wizard: discover → configure →
307
315
  the member. The admin credential is never stored.
308
316
  setup (re)write the connection config only (--yes = no prompts)
309
317
  discover read-only preflight: editors, sessions, reachable ClickHouses
310
- uninstall stop daemons + remove ${HOME_DIR.replace(os.homedir(), '~')} (house data untouched)
311
- reset truncate the house tables and re-ship everything (--yes to skip confirm)
318
+ uninstall stop daemons + service, clear runtime state.
319
+ KEEPS the config and this machine's host identity
320
+ --credentials also forget the house and its password
321
+ --full-removal all of ${HOME_DIR.replace(os.homedir(), '~')}, identity included
322
+ no tier touches the house data
323
+ update upgrade, restart the daemons, and check the house schema
324
+ (--check to compare versions and change nothing)
325
+ reset clear the shipper's rows and re-ship everything (--yes to skip confirm)
326
+ imported rows are kept; --all-origins removes those too
312
327
 
313
328
  Data ship one incremental pass (--full | --loop [sec])
314
329
  stats per-source session/message/token counts
315
330
  search <terms…> full-text search across all sessions
331
+ resume <session-id> print the command that reopens that session in its editor
316
332
  sessions-query print the session rollup SQL for this credential
317
333
  start | stop | shipper loop + dashboard as background daemons
318
334
  status daemons, connection, counts, freshness (--json)
319
335
  doctor diagnose the whole pipeline
320
336
 
321
337
  Agents plugins list | install claude [--target DIR] | remove claude
338
+ acts on EVERY Claude config dir found (~/.claude,
339
+ CLAUDE_CONFIG_DIR, ~/.claude-playbooks/*), all
340
+ selected by default; --yes takes them all unasked
322
341
  prompt print the memory system-prompt snippet
323
342
  prompt --install print an install prompt for an agent, with this
324
343
  machine's state and the one route that applies
@@ -342,7 +361,7 @@ function printAdapterErrors(errors) {
342
361
  if (blocked.length) {
343
362
  console.log(warn(`${blocked.length} adapter${blocked.length > 1 ? 's' : ''} skipped — better-sqlite3 has no native binding: ${blocked.map((e) => e.source).join(', ')}`));
344
363
  console.log(' their sessions are NOT being shipped. npm >= 12 blocks install scripts by default; rebuild with:');
345
- console.log(' npm install -g memhouse --allow-scripts=better-sqlite3');
364
+ console.log(' ${installCommand()}');
346
365
  console.log(' (from a checkout: npm install --no-audit --no-fund, which package.json already allows)');
347
366
  }
348
367
  for (const e of other) console.log(warn(`${e.source.padEnd(16)} skipped: ${e.message}`));
@@ -382,7 +401,7 @@ async function cmdDiscover() {
382
401
  if (JSON_OUT) return console.log(JSON.stringify(out, null, 2));
383
402
  console.log('\nEditors with sessions on this machine:');
384
403
  if (out.editors.length === 0) console.log(warn('none found' + (out.editorsError ? ` (${out.editorsError})` : '')));
385
- for (const e of out.editors) console.log(ok(`${e.source.padEnd(16)} ${e.sessions} sessions`));
404
+ for (const e of out.editors) console.log(ok(`${e.source.padEnd(16)} ${e.sessions} session${e.sessions === 1 ? '' : 's'}`));
386
405
  printAdapterErrors(out.adapterErrors);
387
406
  console.log('\nClickHouse endpoints:');
388
407
  for (const p of out.clickhouse) {
@@ -391,7 +410,9 @@ async function cmdDiscover() {
391
410
  else console.log(ok(`${p.url} — v${p.version}${p.hasHouse ? ', house present' : ''}${p.kernel ? ', KERNEL detected' : ''}`));
392
411
  }
393
412
  console.log('');
394
- console.log(out.config ? ok(`config: ${out.config}`) : warn('no config yet — run: memhouse install (or onboard)'));
413
+ // This banner is shared with `onboard`, which was therefore telling the user, mid-wizard,
414
+ // to run the wizard they are already inside.
415
+ console.log(out.config ? ok(`config: ${out.config}`) : warn(ONBOARDING ? 'no config yet — setting one up now' : 'no config yet — run: memhouse install (or onboard)'));
395
416
  if (out.memoryHouse) console.log(warn('memory-house detected on this machine (they coexist fine)'));
396
417
  return out;
397
418
  }
@@ -459,9 +480,10 @@ endpoint does not answer. Do NOT reconfigure before finding out why.
459
480
  } else if (houseUp) {
460
481
  plan = `A ClickHouse is already reachable at \`${reachable[0].url}\`. Use it.
461
482
 
462
- 1. \`npm install -g memhouse --allow-scripts=better-sqlite3\`
463
- The flag is not optional — six adapters (cursor, zed, opencode, goose, windsurf,
464
- antigravity) read SQLite stores and silently drop out without the native build.
483
+ 1. \`${installCommand()}\`
484
+ The flag is not optional — five adapters (cursor, zed, opencode, goose, antigravity)
485
+ read SQLite stores and silently drop out without the native build. (windsurf needs it
486
+ too but is excluded upstream, so discover names five; the two numbers agree.)
465
487
  2. Ask the user for the ClickHouse username and password for that endpoint.
466
488
  3. \`memhouse install --url ${reachable[0].url} --user <user> --password <pw>\`
467
489
  If they hold admin on it and want memhouse to create the user, database, rooms and
@@ -474,8 +496,8 @@ endpoint does not answer. Do NOT reconfigure before finding out why.
474
496
  plan = `There is no ClickHouse to point at, but **${engines.join(' and ')}** is available,
475
497
  so memhouse can run one:
476
498
 
477
- 1. \`npm install -g memhouse --allow-scripts=better-sqlite3\`
478
- The flag is not optional — six adapters read SQLite stores and silently drop out
499
+ 1. \`${installCommand()}\`
500
+ The flag is not optional — five adapters read SQLite stores and silently drop out
479
501
  without the native build.
480
502
  2. \`memhouse deploy --local\`
481
503
  This starts a loopback-only ClickHouse, generates a credential, installs, creates the
@@ -483,7 +505,7 @@ so memhouse can run one:
483
505
  3. \`memhouse start\`.
484
506
 
485
507
  ${process.platform === 'linux' && engines.includes('podman')
486
- ? 'On Linux with rootless podman the container stops at logout unless lingering is on.\nIf memhouse warns about this, run what it prints (`loginctl enable-linger <user>`)\nand tell the user why it matters.' : ''}`;
508
+ ? 'On Linux with rootless podman the container MAY stop at logout — it depends on the\ndistro (Ubuntu 24.04 keeps it). If memhouse warns about this, run what it prints\n(`loginctl enable-linger <user>`) and tell the user why it matters.' : ''}`;
487
509
  } else {
488
510
  plan = `There is no ClickHouse reachable and no container engine to run one with. You
489
511
  cannot finish this install alone — say so rather than improvising.
@@ -492,12 +514,16 @@ Tell the user they need one of:
492
514
  - a ClickHouse they already run (Cloud, a server, a kernel house) plus its credentials;
493
515
  - docker or podman installed, after which \`memhouse deploy --local\` does everything.
494
516
 
495
- You can still do the harmless half now: \`npm install -g memhouse --allow-scripts=better-sqlite3\`,
517
+ You can still do the harmless half now: \`${installCommand()}\`,
496
518
  then \`memhouse discover\` to show them what would be shipped once a house exists.`;
497
519
  }
498
520
 
499
521
  const out = body
500
522
  .replaceAll('{{VERSION}}', PKG.version)
523
+ // The install prompt is otherwise entirely per-machine — db, url, editors, engines —
524
+ // and then told the agent the config lands at a hardcoded ~/.memhouse/env. `prompt`
525
+ // without --install already got this right; the fix had landed in one of the two.
526
+ .replaceAll('{{ENV_FILE}}', ENV_FILE)
501
527
  .replaceAll('{{PLATFORM}}', `${process.platform}/${process.arch}`)
502
528
  .replaceAll('{{NODE}}', process.versions.node)
503
529
  .replaceAll('{{STATE}}', state)
@@ -528,7 +554,7 @@ async function withSuppressedOutput(fn) {
528
554
  * "fix the connection" by the one command whose whole job is to get them connected.
529
555
  */
530
556
  function deployableEngines() {
531
- try { return require(path.join(REPO_ROOT, 'mem-house', 'deploy.js')).availableEngines() || []; }
557
+ try { return require(path.join(REPO_ROOT, 'memhouse', 'deploy.js')).availableEngines() || []; }
532
558
  catch { return []; }
533
559
  }
534
560
 
@@ -574,7 +600,7 @@ function generatePassword() {
574
600
  * same templates provision.js applies, so the two cannot drift into different houses.
575
601
  */
576
602
  function memberSql(db, member, password) {
577
- const here = path.join(REPO_ROOT, 'mem-house', 'per-member');
603
+ const here = path.join(REPO_ROOT, 'memhouse', 'per-member');
578
604
  // The templates are written unqualified because provision.js applies them with the
579
605
  // house already selected. A human pastes this somewhere unknown — clickhouse-client,
580
606
  // the play UI, curl — so every name is qualified here and there is no `USE`.
@@ -591,8 +617,14 @@ function memberSql(db, member, password) {
591
617
  .replaceAll('{{MEMBER}}', member));
592
618
  const merge = qualify(fs.readFileSync(path.join(here, 'schema-merge.sql.tpl'), 'utf-8')
593
619
  .replaceAll('{{TEMPLATE_MEMBER}}', member));
620
+ // The SAME grant set provision.js issues. These two paths build the same house, and when
621
+ // only one of them was narrowed, `install --print-sql` — the path INSTALL.md calls the
622
+ // common case — still emitted GRANT ALL, which includes CREATE TABLE on the member's own
623
+ // room name. A member provisioned that way could drop their room, recreate it as
624
+ // Merge('<db>','^messages_'), and double every other member's rows in the team room:
625
+ // exactly the attack the narrowing was for, fully reachable, one release later.
594
626
  const grants = ROOM_TYPES.flatMap((t) => [
595
- `GRANT ALL ON ${db}.${t}_${member} TO ${member};`,
627
+ `GRANT ${MEMBER_PRIVS} ON ${db}.${t}_${member} TO ${member};`,
596
628
  `GRANT SELECT ON ${db}.${t}_${member} TO ${member} WITH GRANT OPTION;`,
597
629
  ]).join('\n');
598
630
  const mergeGrants = Object.values(mergeRooms())
@@ -619,12 +651,14 @@ function memberSql(db, member, password) {
619
651
  SET allow_experimental_full_text_index = 1;
620
652
 
621
653
  CREATE DATABASE IF NOT EXISTS ${db};
622
- CREATE USER ${member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}';
654
+ CREATE USER IF NOT EXISTS ${member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}';
623
655
 
624
656
  ${rooms.trim()}
625
657
 
626
- -- ALL is not re-grantable; SELECT is. The member owns their rooms outright — DROP
627
- -- included, it is their memory — but a share can only ever be read-only.
658
+ -- Exactly what the shipper uses, and nothing that defines an object: no CREATE TABLE, no
659
+ -- DROP, no TRUNCATE. A member who owned the room NAME rather than its data could replace
660
+ -- it with a Merge over every member's rooms and double their rows in the team room.
661
+ -- Only SELECT is re-grantable, so a share can only ever be read-only.
628
662
  ${grants}
629
663
 
630
664
  -- Team-wide reads. A Merge room reduces to the rooms the CALLER holds grants for, so
@@ -632,6 +666,20 @@ ${grants}
632
666
  ${merge.trim()}
633
667
 
634
668
  ${mergeGrants}
669
+
670
+ -- Resource ceilings. Confidentiality isolation is a grant that is absent; this is the
671
+ -- availability half, and it did not exist until 0.4.7 — a member could raise
672
+ -- max_memory_usage, zero max_execution_time and take a shared server down for everyone.
673
+ -- CONST, not MAX: a plain default is advisory, and MAX is not enough either because 0 means
674
+ -- UNLIMITED in ClickHouse and 0 satisfies any MAX. Measured — with a MAX ceiling in force,
675
+ -- SETTINGS max_memory_usage = 0 was accepted and the ceiling was gone.
676
+ -- CREATE ... OR REPLACE, which this used, DETACHES every user already on the profile, so
677
+ -- provisioning a second member silently stripped the first member's ceilings. IF NOT
678
+ -- EXISTS + ALTER edits the same object in place and keeps every assignment.
679
+ CREATE SETTINGS PROFILE IF NOT EXISTS ${MEMBER_PROFILE};
680
+ ALTER SETTINGS PROFILE ${MEMBER_PROFILE} SETTINGS NONE;
681
+ ALTER SETTINGS PROFILE ${MEMBER_PROFILE} SETTINGS ${MEMBER_PROFILE_SETTINGS};
682
+ ALTER USER ${member} SETTINGS PROFILE '${MEMBER_PROFILE}';
635
683
  `;
636
684
  }
637
685
 
@@ -644,6 +692,8 @@ ${mergeGrants}
644
692
  * log. A shipper running as the house owner makes per-member rooms decoration.
645
693
  */
646
694
  async function adminBootstrap(cfg, admin) {
695
+ // What this run has already changed on the server, for an honest failure message.
696
+ let createdUser = null;
647
697
  const adminCfg = { ...cfg, user: admin.user, password: admin.password };
648
698
  const q = (sql, opts) => ch(adminCfg, sql, opts);
649
699
 
@@ -660,7 +710,24 @@ async function adminBootstrap(cfg, admin) {
660
710
  // 2. the user. An EXISTING user is refused: adopting one silently hands the second
661
711
  // human the first's identity and rooms, and user_id MATERIALIZED currentUser() would
662
712
  // stamp them identically, so nothing downstream would ever notice.
663
- const exists = (await chRows(adminCfg, `SELECT name FROM system.users WHERE name = '${admin.member}'`, { database: '' })).length > 0;
713
+ // A member who passes --admin-user gets ACCESS_DENIED here, because no member holds
714
+ // SELECT ON system.users. That is the isolation working — but the raw 497 names neither
715
+ // side, while INSTALL.md promises install "refuses, names both sides, and creates
716
+ // nothing". Say who you are not, and what the owner has to run instead.
717
+ let exists;
718
+ try {
719
+ exists = (await chRows(adminCfg, `SELECT name FROM system.users WHERE name = '${admin.member}'`, { database: '' })).length > 0;
720
+ } catch (e) {
721
+ if (/Not enough privileges|ACCESS_DENIED|system\.users/i.test(e.message || '')) {
722
+ console.log(bad(`'${admin.user}' is not an admin of this house — it cannot read system.users, so it cannot provision a member`));
723
+ console.log(' Adding a member is the house owner\'s job. Ask them for:');
724
+ console.log(` node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member ${admin.member} --merge`);
725
+ console.log(' If you already HAVE a member credential on this house, you do not need admin:');
726
+ console.log(` memhouse install --url ${adminCfg.url} --user ${admin.member} --password …`);
727
+ return null;
728
+ }
729
+ throw e;
730
+ }
664
731
  let password = flags['member-password'] || null;
665
732
  if (exists) {
666
733
  if (flags['adopt-user'] !== true) {
@@ -681,23 +748,49 @@ async function adminBootstrap(cfg, admin) {
681
748
  password = generatePassword();
682
749
  console.log('');
683
750
  console.log(` password for '${admin.member}': ${password}`);
684
- console.log(' Shown once. It goes into ~/.memhouse/env; to change it later:');
751
+ console.log(` Shown once. It goes into ${ENV_FILE}; to change it later:`);
685
752
  console.log(` ALTER USER ${admin.member} IDENTIFIED BY '…' then: memhouse setup --password …`);
686
753
  console.log('');
687
754
  }
688
755
  try { await q(`CREATE USER ${admin.member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}'`, { database: '' }); }
689
756
  catch (e) { console.log(bad(`could not create user '${admin.member}': ${e.message}`)); return null; }
690
757
  console.log(ok(`created ClickHouse user '${admin.member}'`));
758
+ createdUser = admin.member;
691
759
  }
692
760
 
693
761
  // 3. rooms, 4. grants, 5. Merge rooms + their grant — provision.js owns all of it, so
694
762
  // there is one implementation of the grant set rather than two that drift.
695
- const provision = path.join(REPO_ROOT, 'mem-house', 'per-member', 'provision.js');
696
- const rc = spawnSync(process.execPath, [provision, '--member', admin.member, '--merge'], {
763
+ const provision = path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js');
764
+ // Tell it who made the user. Without this the child finds a user that exists — because
765
+ // THIS command created it two lines ago — and says so, producing two consecutive lines
766
+ // of one command's output that contradict each other:
767
+ // ✓ created ClickHouse user 'henry'
768
+ // [mem] ClickHouse user 'henry' already exists — provisioning rooms only
769
+ // and "already exists" is the exact phrase memhouse uses for the identity-takeover
770
+ // REFUSAL, so a first-time installer reads a success as a warning about someone else.
771
+ const provisionArgs = [provision, '--member', admin.member, '--merge'];
772
+ if (createdUser) provisionArgs.push('--user-just-created');
773
+ const rc = spawnSync(process.execPath, provisionArgs, {
697
774
  stdio: 'inherit',
698
775
  env: { ...process.env, MEM_URL: cfg.url, MEM_USER: admin.user, MEM_PASSWORD: admin.password, MEM_DB: cfg.db },
699
776
  });
700
- if (rc.status !== 0) { console.log(bad('provisioning failed — nothing was written')); return null; }
777
+ if (rc.status !== 0) {
778
+ // "nothing was written" was a lie whenever the CREATE USER above had already run —
779
+ // and it runs before this. Observed: provision.js died on a missing dependency, this
780
+ // printed the all-clear, and the next install refused with the identity-takeover
781
+ // message because the user it said it had not created was there. Say what exists, and
782
+ // name the two ways forward.
783
+ console.log(bad('provisioning failed'));
784
+ if (createdUser) {
785
+ console.log(` ClickHouse user '${createdUser}' WAS created before this failed; its rooms were not.`);
786
+ console.log(' Nothing was written to disk. To continue once the cause is fixed:');
787
+ console.log(` memhouse install --adopt-user --member ${createdUser} --member-password '<the password above>' …`);
788
+ console.log(` Or undo it as the admin: DROP USER ${createdUser}`);
789
+ } else {
790
+ console.log(' nothing was written');
791
+ }
792
+ return null;
793
+ }
701
794
 
702
795
  // The step that makes this trustworthy: stop being admin, and prove the credential we
703
796
  // are about to persist actually reaches the rooms.
@@ -719,10 +812,45 @@ function resolveMemberHandle() {
719
812
  console.log(bad(`'${member}' cannot name a room — expected [A-Za-z][A-Za-z0-9_]*`));
720
813
  return null;
721
814
  }
815
+ // Reserved names are refused however they arrive. The check used to live only in
816
+ // suggestMember(), i.e. only on the handle DERIVED from $HOME — so `HOME=/root` was
817
+ // refused while an explicit `--member root` sailed through and created a ClickHouse
818
+ // user named root with ALL on its rooms. A guard that a flag walks around is not a
819
+ // guard; the reason it exists does not change with how the name was typed.
820
+ //
821
+ // `default` is deliberately NOT on this list. It is ClickHouse's own catch-all user and
822
+ // a poor choice of member, but it is a real one that existing houses are built on —
823
+ // refusing it would lock those pilots out of their own install.
824
+ if (member.toLowerCase() === 'root') {
825
+ console.log(bad(`'${member}' cannot be a member — it is a container artefact, not a person.`));
826
+ console.log(' Pick a name that identifies a person or a machine: memhouse install --member <name>');
827
+ return null;
828
+ }
722
829
  if (!flags.member && s.changed) console.log(warn(`member handle '${s.changed}' sanitized to '${member}' — pass --member to choose another`));
723
830
  return member;
724
831
  }
725
832
 
833
+ // Do the rooms' sorting keys match what the shipper will accept? Returns a description of
834
+ // what is wrong, or null. `origin` belongs in the transcript rooms' keys (so an imported
835
+ // row and a shipped one at the same seq stay two rows) and must NOT be in the sessions key
836
+ // (so a session has exactly one metadata row).
837
+ async function sortingKeyProblem(cfg) {
838
+ try {
839
+ const rooms = await roomsFor(cfg);
840
+ const wrong = [];
841
+ for (const t of ROOM_TYPES) {
842
+ const r = await chRows(cfg, `SELECT sorting_key AS k FROM system.tables WHERE database = '${cfg.db}' AND name = '${rooms[t]}'`, { database: '' });
843
+ const key = r[0]?.k;
844
+ if (!key) continue;
845
+ const want = t !== 'sessions';
846
+ if (/\borigin\b/.test(key) !== want) {
847
+ wrong.push(`${rooms[t]} (${key}) — origin ${want ? 'missing from' : 'must not be in'} the key`);
848
+ }
849
+ }
850
+ return wrong.length ? wrong.join('; ') : null;
851
+ } catch { return null; } // unreachable house is a different check's problem
852
+ }
853
+
726
854
  async function cmdInstall({ interactive }) {
727
855
  let cfg = resolveConfig();
728
856
  const adminUser = flags['admin-user'];
@@ -736,7 +864,14 @@ async function cmdInstall({ interactive }) {
736
864
  const password = flags['member-password'] || generatePassword();
737
865
  console.log(memberSql(cfg.db, member, password));
738
866
  console.log(`-- Then, once that has run:`);
739
- console.log(`-- memhouse install --url ${cfg.url} --db ${cfg.db} --user ${member} --password '${password}'`);
867
+ // Only echo a URL that was actually STATED. --print-sql is the path where the house is
868
+ // typically someone else's, run by someone else, and cfg.url falls back to
869
+ // http://localhost:8123 — the one address the rest of the product refuses to guess.
870
+ // Handing it back as a copy-paste command carrying a live credential is worse than
871
+ // leaving a placeholder.
872
+ console.log(cfg.stated
873
+ ? `-- memhouse install --url ${cfg.url} --db ${cfg.db} --user ${member} --password '${password}'`
874
+ : `-- memhouse install --url <the house this SQL was run on> --db ${cfg.db} --user ${member} --password '${password}'`);
740
875
  return 0;
741
876
  }
742
877
 
@@ -749,21 +884,76 @@ async function cmdInstall({ interactive }) {
749
884
  });
750
885
  if (!built) return 1;
751
886
  cfg = built;
887
+ // BEFORE the env file and BEFORE the ship. INSTALL.md's contract is that the config is
888
+ // written last, "after everything above has proved out", because a config left by a
889
+ // failed install is read as truth by the next command. Placed after the write, a
890
+ // wrong-key house got four green ticks and a persisted config, and `status` then added
891
+ // five more on a house that can never be shipped to.
892
+ const keyProblem = await sortingKeyProblem(cfg);
893
+ if (keyProblem) {
894
+ console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
895
+ console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
896
+ console.log(` rebuild them from ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl')}, then: memhouse ship --full`);
897
+ console.log(' no config was written — nothing here reads as installed.');
898
+ return 1;
899
+ }
752
900
  writeEnvFile(cfg);
753
901
  console.log(ok(`config written: ${ENV_FILE}`));
902
+ // Same as the member path below: the identity is minted where the machine joins.
903
+ {
904
+ const me = require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity(HOME_DIR);
905
+ console.log(ok(`host identity: ${me.id}`));
906
+ console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
907
+ }
754
908
  if (flags['no-ship'] !== true && run(SHIP_JS, [], cfg) !== 0) return 1;
755
909
  console.log(ok('installed — next: memhouse start (dashboard + shipper loop)'));
756
910
  console.log(' admin is needed again only for: a second member, or an ADD COLUMN rollout.');
757
- console.log(' node mem-house/per-member/provision.js --member <name> --merge');
911
+ console.log(` node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member <name> --merge`);
758
912
  return 0;
759
913
  }
760
914
 
915
+ // Flags that only mean something on the admin branch. Silently ignoring them is how a
916
+ // run with --adopt-user --member bob --member-password … and no --admin-user ended up
917
+ // authenticating as memhouse_root instead: the three flags did nothing, --yes satisfied
918
+ // haveAll so no prompt asked who you were, and resolveConfig's default was used AS A
919
+ // CREDENTIAL. Refuse rather than do something else silently.
920
+ const adminOnly = ['adopt-user', 'member', 'member-password'].filter((k) => flags[k] !== undefined);
921
+ if (!adminUser && adminOnly.length) {
922
+ console.log(bad(`--${adminOnly.join(', --')} ${adminOnly.length > 1 ? 'are' : 'is'} only read with --admin-user`));
923
+ console.log(' Those flags provision a member, which needs house admin. Without them this');
924
+ console.log(' command connects as an EXISTING member instead:');
925
+ console.log(' memhouse install --url … --user <member> --password …');
926
+ console.log(' That is also the "same person on a new machine" path — no admin needed.');
927
+ return 1;
928
+ }
929
+
761
930
  const haveAll = flags.yes === true || (flags.url && flags.user !== undefined);
931
+ // --yes must not turn "unanswered" into "the default". Everywhere else in the product a
932
+ // missing house is refused; here it was authenticated with.
933
+ if (haveAll && !cfg.stated) {
934
+ console.log(bad('no house given: --url and --user are required with --yes'));
935
+ console.log(' memhouse will not fall back to http://localhost:8123 as memhouse_root —');
936
+ console.log(' on many machines that is a real house belonging to someone else.');
937
+ console.log(' No ClickHouse yet? memhouse deploy --local --house-port <port>');
938
+ return 2; // same code as every other no-config refusal
939
+ }
762
940
  if (interactive || !haveAll) {
763
- console.log('mem-house connection (Enter keeps the default):');
764
- cfg.url = await ask(' ClickHouse URL', cfg.url);
765
- cfg.user = await ask(' user', cfg.user);
766
- cfg.password = await ask(' password', cfg.password);
941
+ // Offer a default only where one was actually STATED. Otherwise this prompt said
942
+ // "Enter keeps the default" over `http://localhost:8123` / `memhouse_root` — the pair
943
+ // the no-config refusal three commands away declines to guess, while naming this very
944
+ // command as the fix. Pressing Enter is not a decision the user made about which house
945
+ // to use; requiring the answer is.
946
+ console.log(cfg.stated
947
+ ? 'memhouse connection (Enter keeps the default):'
948
+ : 'memhouse connection (no house is configured yet — these have no defaults):');
949
+ cfg.url = await ask(' ClickHouse URL', cfg.stated ? cfg.url : '');
950
+ cfg.user = await ask(' user', cfg.stated ? cfg.user : '');
951
+ if (!cfg.url || !cfg.user) {
952
+ console.log(bad('a URL and a user are required — memhouse will not pick a house for you'));
953
+ console.log(' no ClickHouse yet? memhouse deploy --local --house-port <port>');
954
+ return 1;
955
+ }
956
+ cfg.password = await askSecret(' password', cfg.password);
767
957
  cfg.db = await ask(' database (the house)', cfg.db);
768
958
  cfg.port = await ask(' dashboard port', cfg.port);
769
959
  }
@@ -816,13 +1006,10 @@ async function cmdInstall({ interactive }) {
816
1006
  let have = [];
817
1007
  try { have = await present(); } catch (e) { console.log(bad(`could not list rooms: ${e.message}`)); return 1; }
818
1008
  if (have.length !== want.length) {
819
- // Check for a pre-0.4 house BEFORE trying to mint anything. `--ensure-schema` on one
820
- // of those dies with an UNKNOWN_TABLE stack trace, and a stack trace printed above a
821
- // clean explanation is how a clear message gets missed.
822
- const legacy = await looksLikePre040(cfg);
823
- if (legacy.length) { reportPre040(cfg, r.member, legacy); return 1; }
824
- // Try to mint them as ourselves before asking anyone for anything.
825
- run(SHIP_JS, ['--ensure-schema'], cfg);
1009
+ // Try to mint them as ourselves before asking anyone for anything. This is the ONE
1010
+ // call site where a permission refusal is expected and already explained below, so
1011
+ // the child is told to stay quiet about it rather than print above our message.
1012
+ run(SHIP_JS, ['--ensure-schema'], { ...cfg, _quietDenied: true });
826
1013
  try { have = await present(); } catch { /* reported below */ }
827
1014
  }
828
1015
  const missing = want.filter((n) => !have.includes(n));
@@ -834,10 +1021,32 @@ async function cmdInstall({ interactive }) {
834
1021
  return 1;
835
1022
  }
836
1023
  console.log(ok(`rooms for '${r.member}': ${want.join(', ')}`));
1024
+ // BEFORE the env file and BEFORE the ship. INSTALL.md's contract is that the config is
1025
+ // written last, "after everything above has proved out", because a config left by a
1026
+ // failed install is read as truth by the next command. Placed after the write, a
1027
+ // wrong-key house got four green ticks and a persisted config, and `status` then added
1028
+ // five more on a house that can never be shipped to.
1029
+ const keyProblem = await sortingKeyProblem(cfg);
1030
+ if (keyProblem) {
1031
+ console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
1032
+ console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
1033
+ console.log(` rebuild them from ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl')}, then: memhouse ship --full`);
1034
+ console.log(' no config was written — nothing here reads as installed.');
1035
+ return 1;
1036
+ }
837
1037
  // Written LAST, and only once everything above proved out. Writing it first leaves a
838
1038
  // config file behind every failed attempt, and the next command reads it as truth.
839
1039
  writeEnvFile(cfg);
840
1040
  console.log(ok(`config written: ${ENV_FILE}`));
1041
+ // Mint this machine's identity here rather than leaving it to whatever runs first.
1042
+ // Installing is the moment a machine joins the member's rooms, and the id is what every
1043
+ // later `WHERE host = …` depends on — so it is worth naming once, out loud, at the point
1044
+ // the pilot can still see it.
1045
+ {
1046
+ const me = require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity(HOME_DIR);
1047
+ console.log(ok(`host identity: ${me.id}`));
1048
+ console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
1049
+ }
841
1050
  if (flags['no-ship'] !== true) {
842
1051
  if (run(SHIP_JS, [], cfg) !== 0) return 1;
843
1052
  }
@@ -846,6 +1055,8 @@ async function cmdInstall({ interactive }) {
846
1055
  }
847
1056
 
848
1057
  async function cmdOnboard() {
1058
+ ONBOARDING = true;
1059
+ let deployed = false;
849
1060
  console.log(`memhouse ${PKG.version} — onboarding\n`);
850
1061
  const found = await cmdDiscover();
851
1062
  console.log('');
@@ -865,7 +1076,14 @@ async function cmdOnboard() {
865
1076
  // process reading its own flags. Reaching into it with a synthesised flag object
866
1077
  // is how one of them silently stops applying.
867
1078
  const rc = spawnSync(process.execPath, [__filename, 'deploy', '--local'], { stdio: 'inherit' });
868
- return rc.status === 0 ? 0 : (rc.status || 1);
1079
+ if (rc.status !== 0) return rc.status || 1;
1080
+ // Fall through to the daemon prompt below rather than returning. This branch —
1081
+ // "no ClickHouse yet", the README's headline scenario — used to return here, so
1082
+ // `onboard` skipped its own last step: HELP says onboard is
1083
+ // discover → configure → ship → START, the README says it "ships, and starts the
1084
+ // dashboard", and doctor then exited 1 on a fresh, entirely successful install
1085
+ // because neither daemon was running. Measured on a clean machine.
1086
+ deployed = true;
869
1087
  }
870
1088
  } else {
871
1089
  console.log(warn('No reachable ClickHouse found, and neither docker nor podman is on PATH.'));
@@ -873,16 +1091,39 @@ async function cmdOnboard() {
873
1091
  }
874
1092
  console.log('');
875
1093
  }
876
- const code = await cmdInstall({ interactive: true });
877
- if (code !== 0) return code;
1094
+ // `deploy --local` already installed and shipped, so asking the install questions again
1095
+ // would prompt for a house it just built.
1096
+ if (!deployed) {
1097
+ const code = await cmdInstall({ interactive: true });
1098
+ if (code !== 0) return code;
1099
+ }
878
1100
  const yn = (await ask('Start the daemons now? (Y/n)', 'Y')).toLowerCase();
879
1101
  if (yn !== 'n' && yn !== 'no') await cmdStart();
1102
+
1103
+ // The skills were the one delivered thing onboarding never mentioned. They shipped,
1104
+ // they were packaged as a plugin, and the only way to find them was to already know
1105
+ // `memhouse plugins install claude` — so a pilot completed the whole wizard and ended up
1106
+ // with a house their agent could not query. Offer them here, across every Claude instance
1107
+ // found, all selected.
1108
+ const targets = claudeTargets();
1109
+ if (targets.length) {
1110
+ console.log('');
1111
+ console.log(`Claude Code skills: ${fs.readdirSync(path.join(DELIVERY, 'plugin', 'skills')).map((n) => `/memhouse:${n}`).join(', ')}`);
1112
+ const chosen = await chooseTargets(targets, 'Install into');
1113
+ for (const t of chosen) console.log(ok(`installed skills into ${short(installPluginInto(t.dir))}`));
1114
+ if (chosen.length) console.log(' they load next time that Claude Code starts');
1115
+ else console.log(warn('skipped — install later with: memhouse plugins install claude'));
1116
+ }
880
1117
  return 0;
881
1118
  }
882
1119
 
883
1120
  async function cmdStart() {
884
- const cfg = resolveConfig();
1121
+ const cfg = requireConfig(resolveConfig(), 'start');
885
1122
  fs.mkdirSync(RUN_DIR, { recursive: true });
1123
+ // Record the port this run actually used. `start --port 4673` did not persist it, so
1124
+ // `status` fell back to the env file and printed `dashboard → http://localhost:4640` —
1125
+ // a live process on 4673 named alongside a link to someone else's dashboard on 4640.
1126
+ try { fs.writeFileSync(path.join(RUN_DIR, 'dashboard.port'), String(cfg.port)); } catch { /* best effort */ }
886
1127
  fs.mkdirSync(LOG_DIR, { recursive: true });
887
1128
  ensureUiBuilt();
888
1129
  const daemons = [
@@ -899,7 +1140,7 @@ async function cmdStart() {
899
1140
  // Reachable in the obvious way: after a reboot the user wants the dashboard back, which
900
1141
  // is not service-managed, and types `memhouse start`.
901
1142
  let svcStatus = { installed: false, running: false };
902
- try { svcStatus = require(path.join(REPO_ROOT, 'mem-house', 'service.js')).status(); } catch { /* unsupported platform */ }
1143
+ try { svcStatus = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { /* unsupported platform */ }
903
1144
  const owned = [
904
1145
  ...(svcStatus.installed ? [['shipper', svcStatus.running, 'memhouse-shipper']] : []),
905
1146
  ];
@@ -913,6 +1154,7 @@ async function cmdStart() {
913
1154
  : ` start it with: launchctl kickstart gui/$(id -u)/com.${unit.replace('memhouse-', 'memhouse.')}`);
914
1155
  }
915
1156
  }
1157
+ const started = [];
916
1158
  for (const d of daemons) {
917
1159
  if (pidOf(d.name)) { console.log(warn(`${d.name} already running (pid ${pidOf(d.name)})`)); continue; }
918
1160
  const log = fs.openSync(path.join(LOG_DIR, d.name + '.log'), 'a');
@@ -921,20 +1163,50 @@ async function cmdStart() {
921
1163
  });
922
1164
  fs.writeFileSync(path.join(RUN_DIR, d.name + '.pid'), String(child.pid));
923
1165
  child.unref();
924
- console.log(ok(`${d.name} started (pid ${child.pid})`));
1166
+ started.push({ ...d, pid: child.pid });
925
1167
  }
926
- console.log(` dashboard → http://localhost:${cfg.port}`);
927
- }
928
1168
 
929
- // `solo` is here and NOT in the start list on purpose. The tier is gone, but a machine
930
- // that ran it before this upgrade can still have a detached shim alive with its pid in
931
- // run/solo.pid — and `uninstall` deletes MEMHOUSE_HOME, which is where its data directory
932
- // lives. Reaping has to outlive the feature; starting must not.
933
- const LEGACY_DAEMONS = ['solo'];
1169
+ // spawn() succeeding means the PROCESS started, not that it is still alive or that it
1170
+ // got the port. `start` used to print "✓ dashboard started (pid …)" and a URL for a
1171
+ // process that had already died of EADDRINUSE — and the URL then pointed at whatever
1172
+ // else owned the port, which on this machine was a different house's dashboard. Give
1173
+ // each a moment, then confirm, and dig the reason out of the log rather than making the
1174
+ // user find it.
1175
+ if (started.length) await new Promise((r) => setTimeout(r, 900));
1176
+ let dashboardAlive = false;
1177
+ for (const d of started) {
1178
+ if (alive(d.pid)) {
1179
+ console.log(ok(`${d.name} started (pid ${d.pid})`));
1180
+ if (d.name === 'dashboard') dashboardAlive = true;
1181
+ continue;
1182
+ }
1183
+ try { fs.unlinkSync(path.join(RUN_DIR, d.name + '.pid')); } catch { /* already gone */ }
1184
+ const logPath = path.join(LOG_DIR, d.name + '.log');
1185
+ let why = '';
1186
+ try {
1187
+ const tail = fs.readFileSync(logPath, 'utf-8').split('\n').filter(Boolean).slice(-40);
1188
+ const err = tail.reverse().find((l) => /EADDRINUSE|Error|error:/i.test(l));
1189
+ if (/EADDRINUSE/.test(err || '')) why = `port ${cfg.port} is already in use — free it, or set MEMHOUSE_PORT`;
1190
+ else if (err) why = err.trim().slice(0, 200);
1191
+ } catch { /* no log to read */ }
1192
+ console.log(bad(`${d.name} exited immediately${why ? ` — ${why}` : ''}`));
1193
+ if (!why) console.log(` see ${logPath}`);
1194
+ process.exitCode = 1;
1195
+ // Partial success is the confusing case: exit 1 reads as "nothing started", and the
1196
+ // daemon that DID start keeps running unmentioned.
1197
+ const others = started.filter((o) => o.name !== d.name && alive(o.pid));
1198
+ if (others.length) {
1199
+ console.log(` ${others.map((o) => o.name).join(' and ')} ${others.length > 1 ? 'are' : 'is'} still running — memhouse stop, if you did not want that`);
1200
+ }
1201
+ }
1202
+ if (dashboardAlive || (pidOf('dashboard') && !started.some((d) => d.name === 'dashboard'))) {
1203
+ console.log(` dashboard → http://localhost:${cfg.port}`);
1204
+ }
1205
+ }
934
1206
 
935
1207
  function cmdStop() {
936
1208
  let stopped = 0;
937
- for (const name of ['shipper', 'dashboard', ...LEGACY_DAEMONS]) {
1209
+ for (const name of ['shipper', 'dashboard']) {
938
1210
  const pid = pidOf(name);
939
1211
  if (pid) { try { process.kill(pid, 'SIGTERM'); console.log(ok(`${name} stopped (pid ${pid})`)); stopped++; } catch { /* raced */ } }
940
1212
  try { fs.unlinkSync(path.join(RUN_DIR, name + '.pid')); } catch { /* absent */ }
@@ -943,12 +1215,21 @@ function cmdStop() {
943
1215
  }
944
1216
 
945
1217
  async function cmdStatus() {
946
- const cfg = resolveConfig();
1218
+ // requireConfig, not resolveConfig. `status` reads a house and reports its counts as
1219
+ // YOURS, which is exactly the answer that must not come from a guessed URL: with no
1220
+ // config it connected to localhost:8123 as memhouse_root and, wherever that credential
1221
+ // works, printed a stranger's session and message counts as the pilot's own.
1222
+ const cfg = requireConfig(resolveConfig(), 'status');
947
1223
  const out = {
948
1224
  config: fs.existsSync(ENV_FILE) ? ENV_FILE : null,
949
1225
  url: cfg.url, db: cfg.db, user: cfg.user,
950
1226
  daemons: { shipper: pidOf('shipper'), dashboard: pidOf('dashboard') },
1227
+ dashboard_url: pidOf('dashboard') ? `http://localhost:${runningPort(cfg)}` : null,
951
1228
  shipper: shipperHealth(),
1229
+ // Which machine this is, in the rooms' own terms. All of a member's machines write
1230
+ // into one set of rooms, so `WHERE host = ...` is how the pilot separates this laptop
1231
+ // from the other one — and they cannot type it if nothing ever prints it.
1232
+ host: require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity(HOME_DIR),
952
1233
  connected: false,
953
1234
  };
954
1235
  try {
@@ -960,16 +1241,26 @@ async function cmdStatus() {
960
1241
  out.sessions = Number(s[0]?.sessions || 0);
961
1242
  out.messages = Number(m[0]?.msgs || 0);
962
1243
  out.freshest = m[0]?.freshest || null;
963
- } catch (e) { out.connected = false; out.error = e.message; }
1244
+ } catch (e) { out.connected = false; out.error = netReason(e); }
964
1245
 
965
- if (JSON_OUT) return console.log(JSON.stringify(out, null, 2));
1246
+ if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); return out.connected ? 0 : 1; }
966
1247
  console.log(out.config ? ok(`config: ${out.config}`) : warn('no config (memhouse install)'));
967
- console.log(out.connected ? ok(`connected: ${cfg.url} / ${cfg.db} as ${cfg.user}`) : bad(`not connected: ${out.error || cfg.url}`));
1248
+ console.log(out.connected
1249
+ ? ok(`connected: ${cfg.url} / ${cfg.db} as ${cfg.user}`)
1250
+ : bad(`not connected: ${cfg.url} — ${out.error || 'no reason given'}`));
1251
+ if (!out.connected) console.log(' diagnose it with: memhouse doctor');
968
1252
  if (out.member) console.log(ok(`rooms for '${out.member}'`));
969
- if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${out.freshest} UTC)`));
1253
+ // The member owns the rooms; the host says which of their machines wrote a row.
1254
+ console.log(ok(`host: ${out.host.id}${out.host.renamed ? ` (this machine now answers to '${out.host.current_hostname}' — the id is kept so its history stays one machine)` : ''}`));
1255
+ if (out.connected && !out.messages) console.log(ok(`house: empty — ${out.sessions} sessions, 0 messages (nothing shipped yet)`));
1256
+ else if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${out.freshest} UTC)`));
970
1257
  console.log(out.shipper.running ? ok(`shipper: running — ${out.shipper.via}`) : warn('shipper: not running'));
971
1258
  console.log(out.daemons.dashboard ? ok(`dashboard: running (pid ${out.daemons.dashboard})`) : warn('dashboard: not running'));
972
- if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${cfg.port}`);
1259
+ if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${runningPort(cfg)}`);
1260
+ // Exit non-zero when the house is unreachable. `status` is what a health check, a
1261
+ // cron, or an agent gates on; printing '✗ not connected' and exiting 0 tells every
1262
+ // one of them the house is fine.
1263
+ return out.connected ? 0 : 1;
973
1264
  }
974
1265
 
975
1266
  async function cmdDoctor() {
@@ -980,28 +1271,172 @@ async function cmdDoctor() {
980
1271
  const [major, minor] = process.versions.node.split('.').map(Number);
981
1272
  add(major > 20 || (major === 20 && minor >= 19), `node ${process.versions.node}`, 'need >= 20.19');
982
1273
  add(fs.existsSync(ENV_FILE), `config ${ENV_FILE}`, 'run: memhouse install');
1274
+ // doctor is exempt from requireConfig — diagnosing an unconfigured machine is its job —
1275
+ // but "exempt from refusing" is not "licensed to log in somewhere". With no config it
1276
+ // used to print `✗ config … run: memhouse install` and then attempt an AUTHENTICATED
1277
+ // connection to http://localhost:8123 as memhouse_root, the address every other command
1278
+ // explicitly declines to guess. The server answers AUTHENTICATION_FAILED, which on a
1279
+ // hardened house is a failed-login entry per run — and doctor is the first thing a
1280
+ // confused user types. `discover` still probes 8123, and should: it reports what is
1281
+ // reachable as a DISCOVERY. A check that reports someone else's server as a failure of
1282
+ // your config is a different thing.
1283
+ if (!cfg.stated) {
1284
+ add(false, 'house', 'no config, so there is nothing to connect to — run: memhouse install');
1285
+ for (const label of ['rooms', 'schema', 'sorting keys', 'identity stamping']) {
1286
+ add(false, label, 'skipped — no house configured');
1287
+ }
1288
+ return renderDoctor(checks);
1289
+ }
983
1290
  try { await ch(cfg, 'SELECT 1', { database: '' }); add(true, `clickhouse reachable (${cfg.url})`); }
984
- catch (e) { add(false, `clickhouse reachable (${cfg.url})`, e.message); }
1291
+ catch (e) { add(false, `clickhouse reachable (${cfg.url})`, netReason(e)); }
985
1292
  let rooms = null;
986
- try { rooms = await roomsFor(cfg); } catch (e) { add(false, 'room resolution', e.message); }
987
- if (rooms) add(true, `rooms for '${rooms.member}'`);
988
- try {
989
- // Three rooms. The session rollup every read path goes through is a saved query over
990
- // exactly these, so if they are here it is too — there is no fourth object to lose.
991
- const objects = ROOM_TYPES.map((t) => rooms[t]);
992
- const want = objects.map((n) => `'${n}'`).join(',');
993
- const t = (await chRows(cfg, `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND name IN (${want})`, { database: '' })).length;
994
- const legacy = t === objects.length ? [] : await looksLikePre040(cfg);
995
- add(t === objects.length, `schema: ${t}/${objects.length} rooms in '${cfg.db}' (${objects.join(', ')})`,
996
- legacy.length
997
- ? `pre-0.4 house (${legacy.join(', ')}) — 0.4 cannot read it; see: memhouse install --help, then ship --full`
998
- : `run: memhouse install, or as the owner: node mem-house/per-member/provision.js --member ${rooms.member}`);
999
- } catch (e) { add(false, 'schema check', e.message); }
1000
- try {
1001
- const u = await chRows(cfg, `SELECT any(user_id) AS u FROM ${rooms.sessions}`);
1002
- add((u[0]?.u ?? '') !== '' || (await chRows(cfg, `SELECT count() AS c FROM ${rooms.sessions}`))[0].c === 0,
1003
- `identity stamping (user_id='${u[0]?.u ?? ''}')`, 'writers must use async_insert=0');
1004
- } catch { add(false, 'identity stamping', 'schema missing?'); }
1293
+ // Room resolution asks the server who you are (SELECT currentUser()). It fails when
1294
+ // the house is unreachable or the credential is rejected — both already reported by
1295
+ // the check above, so name that as the fix rather than repeating the driver's error.
1296
+ try { rooms = await roomsFor(cfg); } catch (e) {
1297
+ add(false, 'room resolution', `${e.message} — fix the connection above first (memhouse install --url … --user … --password …)`);
1298
+ }
1299
+ // "rooms for X" means the NAMES resolved, not that the rooms exist — and it printed a
1300
+ // green tick immediately above `✗ schema: 0/3 rooms`, contradicting the next line.
1301
+ if (rooms) add(true, `member is '${rooms.member}' — rooms would be ${ROOM_TYPES.map((ty) => rooms[ty]).join(', ')}`);
1302
+ // Every check below reads a room NAME, so none of them can run without `rooms`.
1303
+ // Reaching into a null here is how doctor used to print a raw
1304
+ // "Cannot read properties of null (reading 'sessions')" as its hint — a stack-trace
1305
+ // fragment where the fix should be. Skip them, and say why they were skipped.
1306
+ if (!rooms) {
1307
+ add(false, 'schema', 'skipped — room names are unknown until the house answers');
1308
+ add(false, 'identity stamping', 'skipped — same reason');
1309
+ } else {
1310
+ try {
1311
+ // Three rooms. The session rollup every read path goes through is a saved query over
1312
+ // exactly these, so if they are here it is too — there is no fourth object to lose.
1313
+ const objects = ROOM_TYPES.map((t) => rooms[t]);
1314
+ const want = objects.map((n) => `'${n}'`).join(',');
1315
+ const t = (await chRows(cfg, `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND name IN (${want})`, { database: '' })).length;
1316
+ // Name what is MISSING. The parenthesised list used to be what should exist, so
1317
+ // "2/3" was the only signal and you could not tell which room to worry about.
1318
+ const present = new Set((await chRows(cfg, `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND name IN (${want})`, { database: '' })).map((r) => r.name));
1319
+ const absent = objects.filter((n) => !present.has(n));
1320
+ add(t === objects.length, t === objects.length
1321
+ ? `schema: 3/3 rooms in '${cfg.db}' (${objects.join(', ')})`
1322
+ : `schema: ${t}/${objects.length} rooms in '${cfg.db}' — missing ${absent.join(', ')}`,
1323
+ `run: memhouse install, or as the owner: node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member ${rooms.member}`);
1324
+ } catch (e) { add(false, 'schema check', `${e.message} — run: memhouse install`); }
1325
+ // Columns, not just rooms. A room with a column missing accepts every insert and
1326
+ // discards that field — measured, 85 rows shipped with the value thrown away while
1327
+ // ship, install, --ensure-schema and doctor all reported success, because this check
1328
+ // counted rooms.
1329
+ try {
1330
+ const { templateColumns } = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js'));
1331
+ const tpl = fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl'), 'utf-8');
1332
+ const want = templateColumns(tpl);
1333
+ // Names AND types AND the MATERIALIZED kind. Comparing names alone left every other
1334
+ // kind of drift invisible, with measured consequences: a `UInt64` column narrowed to
1335
+ // `Int8` stored 200 as **-56**, silently, so token counts and every cost derived
1336
+ // from them were wrong; and `text_ngram` without its MATERIALIZED clause was empty
1337
+ // on all 33 rows, so the FTS column the search skill queries had simply stopped
1338
+ // being populated. Both read `✓ columns: every room matches the schema template`.
1339
+ const missing = [];
1340
+ const wrong = [];
1341
+ let roomsSeen = 0;
1342
+ for (const ty of ROOM_TYPES) {
1343
+ const cols = await chRows(cfg, `SELECT name, type, default_kind FROM system.columns WHERE database = '${cfg.db}' AND table = '${rooms[ty]}'`, { database: '' });
1344
+ if (!cols.length) continue;
1345
+ roomsSeen++;
1346
+ const byName = new Map(cols.map((r) => [r.name, r]));
1347
+ for (const c of (want[ty] || [])) {
1348
+ const got = byName.get(c.name);
1349
+ if (!got) { missing.push(`${rooms[ty]}.${c.name}`); continue; }
1350
+ // The template's declaration is `<type> [DEFAULT x | MATERIALIZED x]`; compare
1351
+ // the type word and, when the template says MATERIALIZED, that the column still is.
1352
+ const wantType = c.type.replace(/\s+(DEFAULT|MATERIALIZED|ALIAS|EPHEMERAL)\b[\s\S]*$/i, '').trim();
1353
+ const wantKind = /\bMATERIALIZED\b/i.test(c.type) ? 'MATERIALIZED' : null;
1354
+ if (wantType && got.type !== wantType) wrong.push(`${rooms[ty]}.${c.name} is ${got.type}, template says ${wantType}`);
1355
+ else if (wantKind && got.default_kind !== 'MATERIALIZED') wrong.push(`${rooms[ty]}.${c.name} lost its MATERIALIZED clause`);
1356
+ }
1357
+ }
1358
+ const bad2 = missing.length + wrong.length;
1359
+ add(bad2 === 0 && roomsSeen === ROOM_TYPES.length,
1360
+ roomsSeen !== ROOM_TYPES.length ? `columns: only ${roomsSeen}/${ROOM_TYPES.length} rooms exist, so the template comparison is incomplete`
1361
+ : bad2 === 0 ? 'columns: every room matches the schema template (name, type and kind)'
1362
+ : `columns: ${missing.length} missing, ${wrong.length} wrong — ${[...missing, ...wrong].slice(0, 4).join('; ')}${bad2 > 4 ? ' …' : ''}`,
1363
+ missing.length && !wrong.length
1364
+ ? 'those fields are being discarded on every ship; heal with: memhouse ship --ensure-schema'
1365
+ : 'a changed type silently corrupts values and a lost MATERIALIZED clause stops a column being computed; the room has to be rebuilt by its owner');
1366
+ } catch (e) { add(false, 'columns', `could not compare against the template: ${e.message}`); }
1367
+
1368
+ // The sorting keys the shipper refuses to write into. doctor is where a house should
1369
+ // learn it needs rebuilding, not the middle of a ship pass.
1370
+ try {
1371
+ const wrongKeys = [];
1372
+ let checked = 0;
1373
+ for (const t of ROOM_TYPES) {
1374
+ const r = await chRows(cfg, `SELECT sorting_key AS k FROM system.tables WHERE database = '${cfg.db}' AND name = '${rooms[t]}'`, { database: '' });
1375
+ const key = r[0]?.k;
1376
+ if (!key) continue;
1377
+ checked++;
1378
+ const want = t !== 'sessions';
1379
+ if (/\borigin\b/.test(key) !== want) wrongKeys.push(`${rooms[t]} (${key})`);
1380
+ }
1381
+ // `checked` matters: every room name that resolved to nothing was skipped by the
1382
+ // `continue` above, so on an empty house this printed a green "sorting keys carry
1383
+ // origin correctly" having examined zero tables — a reassuring tick on the exact
1384
+ // check that a broken house needs to fail.
1385
+ // A room whose sorting_key is empty was SKIPPED by the loop — that is a Merge, a
1386
+ // View, a Log engine, anything that is not the MergeTree this expects. Skipping is
1387
+ // right; calling the result a pass is not. It printed
1388
+ // "✓ sorting keys carry origin correctly (2/3 rooms)" on a house where `ship` then
1389
+ // failed with "DELETE query is not supported for table …". The number was right
1390
+ // there in the green line.
1391
+ if (!checked) add(false, 'sorting keys: no rooms to check', 'create them first: memhouse install');
1392
+ else if (checked < ROOM_TYPES.length) {
1393
+ add(false, `sorting keys: only ${checked}/${ROOM_TYPES.length} rooms are MergeTree — the rest have no sorting key at all`,
1394
+ 'a room was replaced by a Merge/View/Log engine; rebuild it from the schema template as the house owner');
1395
+ } else add(wrongKeys.length === 0, `sorting keys${wrongKeys.length ? `: wrong on ${wrongKeys.join(', ')}` : ` carry origin correctly (${checked}/${ROOM_TYPES.length} rooms)`}`,
1396
+ `rebuild those rooms from the schema template, then: memhouse ship --full\n template: ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl')}`);
1397
+ } catch (e) { add(false, 'sorting keys', e.message); }
1398
+ try {
1399
+ // countIf, not any(). `any()` returns an arbitrary row's value, so on a house with
1400
+ // four correctly-stamped rows and one blank it reported a pass five times out of
1401
+ // six — the one check whose whole purpose is to catch unattributed rows. Count them
1402
+ // instead, and say how many: a row with an empty user_id is invisible to every
1403
+ // identity-bound path at once (loadExisting's WHERE, the shipper's clear, reset's
1404
+ // DELETE), so its owner cannot even remove it.
1405
+ // All three rooms. This read only `sessions`, and the two it skipped are where the
1406
+ // shipper writes almost everything — measured, a messages row with an empty user_id
1407
+ // sat there while this printed "all attributed". An unattributed row is invisible to
1408
+ // every identity-bound path at once, including its owner's own reset.
1409
+ let total = 0; let blank = 0;
1410
+ for (const ty of ROOM_TYPES) {
1411
+ const r = (await chRows(cfg, `SELECT count() AS c, countIf(user_id = '') AS blank FROM ${rooms[ty]} FINAL`))[0] || {};
1412
+ total += Number(r.c || 0); blank += Number(r.blank || 0);
1413
+ }
1414
+ add(blank === 0,
1415
+ total === 0 ? 'identity stamping (no rows yet)'
1416
+ : blank === 0 ? `identity stamping (${total} rows across all three rooms, all attributed)`
1417
+ : `identity stamping: ${blank} of ${total} rows have an empty user_id`,
1418
+ 'a writer used async_insert=1 — the MATERIALIZED currentUser() stamp does not run during an async flush');
1419
+ } catch { add(false, 'identity stamping', 'schema missing? run: memhouse install'); }
1420
+
1421
+ // Which machines this member's rooms already hold, and whether THIS one is among
1422
+ // them. All of a member's machines write into one set of rooms, so a host id that
1423
+ // has quietly changed shows up here as a second machine that never existed — and
1424
+ // nothing else in the pipeline would ever mention it.
1425
+ try {
1426
+ const me = require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity(HOME_DIR);
1427
+ const hosts = await chRows(cfg, `SELECT host, count() AS c FROM ${rooms.sessions} FINAL GROUP BY host ORDER BY c DESC`);
1428
+ const mine = hosts.find((h) => h.host === me.id);
1429
+ const others = hosts.filter((h) => h.host !== me.id);
1430
+ add(true,
1431
+ hosts.length === 0
1432
+ ? `host identity ${me.id} (nothing shipped from here yet)`
1433
+ : `host identity ${me.id} — ${mine ? `${mine.c} sessions from this machine` : 'no sessions from this machine yet'}`
1434
+ + (others.length ? `, ${others.length} other host${others.length > 1 ? 's' : ''} in these rooms: ${others.map((h) => `${h.host} (${h.c})`).join(', ')}` : ''));
1435
+ if (me.renamed) {
1436
+ add(true, `host renamed since install — id stays ${me.id}, machine now answers to '${me.current_hostname}'`);
1437
+ }
1438
+ } catch { /* the room read above already reported anything that would break this */ }
1439
+ }
1005
1440
  let adapterErrors = [];
1006
1441
  try {
1007
1442
  const { getAllChats, getAdapterErrors, getMessages } = require(path.join(REPO_ROOT, 'editors'));
@@ -1056,8 +1491,13 @@ async function cmdDoctor() {
1056
1491
  const blocked = adapterErrors.filter((e) => e.missingBinding).map((e) => e.source);
1057
1492
  const failed = adapterErrors.map((e) => e.source);
1058
1493
  add(adapterErrors.length === 0,
1059
- `adapters: ${seen} sessions visible locally${failed.length ? ` (${failed.length} skipped: ${failed.join(', ')})` : ''}`,
1060
- blocked.length ? 'npm install -g memhouse --allow-scripts=better-sqlite3'
1494
+ // "visible locally" is what the ADAPTERS see; `stats` reports what the house HOLDS,
1495
+ // and the two differ legitimately — a session that parses to zero messages is
1496
+ // visible and not worth a row. Saying "visible locally" and leaving the reader to
1497
+ // find the other number elsewhere (161 here, 156 there, on a real machine) gives
1498
+ // them no way to tell "correctly skipped" from "silently dropped".
1499
+ `adapters: ${seen} sessions visible locally${failed.length ? ` (${failed.length} skipped: ${failed.join(', ')})` : ''} — compare with what the house holds: memhouse stats`,
1500
+ blocked.length ? installCommand()
1061
1501
  : adapterErrors.length ? adapterErrors.map((e) => `${e.source}: ${e.message}`).join('; ')
1062
1502
  : undefined);
1063
1503
  } catch (e) { add(false, 'adapters', e.message); }
@@ -1082,19 +1522,43 @@ async function cmdDoctor() {
1082
1522
  ? `pricing: every model in this house has a price (${rows.length} models)`
1083
1523
  : `pricing: ${unpriced.length} model${unpriced.length > 1 ? 's' : ''} unpriced — ${share} cost nothing in the dashboard (${unpriced.map((r) => r.model).join(', ')})`,
1084
1524
  'node sync-pricing.js --write (then re-run doctor)');
1525
+
1526
+ // A source whose rows carry NO tokens costs nothing in the dashboard, which reads as
1527
+ // "this editor is free" rather than "this editor's usage was never parsed". That is
1528
+ // the same shape as the unpriced-model gap above, which once hid $6,919 — except no
1529
+ // price list can fix it, because the numbers were never extracted. zed is the current
1530
+ // case: editors/zed.js contains no usage handling at all.
1531
+ // origin='ship' only. Imported history came from another product that may never have
1532
+ // recorded usage — on this machine four of the five zero-token sources are imports
1533
+ // from memory-house, which is a fact about the past, not an adapter that needs fixing.
1534
+ // Naming them alongside a live adapter gap makes the real one easy to dismiss.
1535
+ const zero = await chRows(cfg,
1536
+ `SELECT source, count() AS n FROM ${rooms.messages} FINAL WHERE role = 'assistant' AND origin = 'ship' GROUP BY source HAVING sum(input_tokens) + sum(output_tokens) = 0 ORDER BY source`);
1537
+ const zeroImported = await chRows(cfg,
1538
+ `SELECT count() AS n FROM ${rooms.messages} FINAL WHERE role = 'assistant' AND origin != 'ship' AND input_tokens = 0 AND output_tokens = 0`);
1539
+ const impN = Number(zeroImported[0]?.n || 0);
1540
+ add(zero.length === 0,
1541
+ zero.length === 0
1542
+ ? `token capture: every editor shipping into this house reports usage${impN ? ` (${impN} imported messages carry none — historical, not fixable here)` : ''}`
1543
+ : `token capture: ${zero.map((r) => `${r.source} (${r.n} messages)`).join(', ')} ship ZERO tokens — their cost shows as $0, not as unknown`,
1544
+ 'that adapter does not extract usage; the messages are stored, the numbers are not');
1085
1545
  } catch { /* a house that cannot be read is already reported above */ }
1086
1546
  const sh = shipperHealth();
1087
1547
  add(sh.running, `shipper${sh.via ? ` — ${sh.via}` : ''}`, 'memhouse start (or: memhouse service install)');
1088
1548
  add(!!pidOf('dashboard'), 'dashboard daemon', 'memhouse start');
1089
1549
  add(fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html')), 'dashboard UI built', 'built automatically by memhouse start');
1090
1550
 
1551
+ return renderDoctor(checks);
1552
+ }
1553
+
1554
+ function renderDoctor(checks) {
1091
1555
  for (const c of checks) console.log(c.ok ? ok(c.label) : bad(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`));
1092
1556
  return checks.every((c) => c.ok) ? 0 : 1;
1093
1557
  }
1094
1558
 
1095
1559
  async function cmdSearch() {
1096
1560
  if (!positional.length) { console.log('usage: memhouse search <terms…>'); return 2; }
1097
- const cfg = resolveConfig();
1561
+ const cfg = requireConfig(resolveConfig(), 'search');
1098
1562
  const needle = positional.join(' ').toLowerCase().replace(/[%_\\]/g, '\\$&').replace(/'/g, "\\'");
1099
1563
  const r = await roomsFor(cfg);
1100
1564
  const rows = await chRows(cfg, `
@@ -1107,47 +1571,376 @@ async function cmdSearch() {
1107
1571
  if (JSON_OUT) return console.log(JSON.stringify(rows, null, 2));
1108
1572
  if (!rows.length) return console.log(warn('no matches'));
1109
1573
  for (const r of rows) {
1110
- console.log(`\x1b[1m${r.session_id.slice(0, 8)}\x1b[0m ${r.source} ${r.project || '-'} ${r.at} (${r.hits} hits)`);
1574
+ // The full id. It was sliced to 8 characters, which for claude-code renders every hit
1575
+ // as the literal string "claude-c" — while the shipped prompt tells agents to cite the
1576
+ // session_id and to replay with `WHERE session_id = '<id>'`. Neither is possible from
1577
+ // a prefix, and the prefix is not even distinguishing.
1578
+ console.log(`\x1b[1m${r.session_id}\x1b[0m ${r.source} ${r.project || '-'} ${r.at} (${r.hits} hit${r.hits === 1 ? '' : 's'})`);
1111
1579
  console.log(` ${r.snippet.replace(/\s+/g, ' ')}`);
1112
1580
  }
1113
1581
  }
1114
1582
 
1115
- function cmdPlugins() {
1116
- const sub = positional[0] || 'list';
1117
- const target = flags.target || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
1118
- const skillsSrc = path.join(DELIVERY, 'skills');
1119
- const names = fs.readdirSync(skillsSrc);
1120
- const dstSkills = path.join(target, 'skills');
1121
- if (sub === 'list') {
1122
- console.log(`skills available: ${names.join(', ')}`);
1123
- for (const n of names) {
1124
- console.log(fs.existsSync(path.join(dstSkills, n)) ? ok(`${n} installed in ${dstSkills}`) : warn(`${n} not installed (memhouse plugins install claude)`));
1583
+ // `search` finds the session; this hands the pilot back into it. memhouse has been a
1584
+ // read-only archive — you could find the conversation that solved this before and not
1585
+ // return to it.
1586
+ //
1587
+ // It PRINTS the command and does not run it, and that is the whole design. Running it would
1588
+ // have to guess a terminal, inherit this process's cwd, and hope the id is still live; when
1589
+ // any of that is wrong the CLI does not fail, it opens a NEW session — the pilot loses the
1590
+ // transcript they asked for while the tool reports success. Printed, the pilot reads it,
1591
+ // pastes it, and owns the result.
1592
+ async function cmdResume() {
1593
+ if (!positional.length) {
1594
+ console.log('usage: memhouse resume <session-id> (the id search and the dashboard print)');
1595
+ return 2;
1596
+ }
1597
+ const cfg = requireConfig(resolveConfig(), 'resume');
1598
+ const { resumeFor } = require(path.join(REPO_ROOT, 'memhouse', 'resume'));
1599
+ // Same escaping as search: this value is interpolated into SQL, and a session id is not
1600
+ // guaranteed to be tame — it comes from whatever the editor wrote on disk.
1601
+ const want = positional[0].replace(/[\\']/g, '\\$&');
1602
+ const r = await roomsFor(cfg);
1603
+ // Two ways to name a session, because there are two ways it gets in front of the pilot:
1604
+ // the canonical `<source>:<id>` that search and the prompt print, and the bare native id
1605
+ // that the editor's own UI shows. FINAL because sessions is a ReplacingMergeTree and a
1606
+ // re-shipped session has an older row underneath — without it a moved project resolves to
1607
+ // the folder it used to live in.
1608
+ const rows = await chRows(cfg, `
1609
+ SELECT session_id, source, folder, name, origin,
1610
+ formatDateTime(last_updated_at, '%Y-%m-%d %H:%i') AS at
1611
+ FROM ${r.sessions} FINAL
1612
+ WHERE session_id = '${want}' OR session_id LIKE '%:${want}'
1613
+ ORDER BY last_updated_at DESC LIMIT 5`);
1614
+
1615
+ if (!rows.length) {
1616
+ if (JSON_OUT) return console.log(JSON.stringify({ error: 'no such session', session_id: positional[0] }, null, 2));
1617
+ console.log(bad(`no session '${positional[0]}' in ${r.sessions}`));
1618
+ console.log(' find one with: memhouse search <terms>');
1619
+ return 1;
1620
+ }
1621
+ // A bare native id can match more than one source. Say so instead of picking — resuming
1622
+ // the wrong editor's session of the same name is exactly the silent-wrong-answer this
1623
+ // command exists to avoid.
1624
+ if (rows.length > 1) {
1625
+ if (JSON_OUT) return console.log(JSON.stringify({ error: 'ambiguous', matches: rows }, null, 2));
1626
+ console.log(warn(`'${positional[0]}' matches ${rows.length} sessions — name one exactly:`));
1627
+ for (const s of rows) console.log(` ${s.session_id} ${s.at} ${s.name || '-'}`);
1628
+ return 1;
1629
+ }
1630
+
1631
+ const row = rows[0];
1632
+ const res = resumeFor(row);
1633
+ if (JSON_OUT) return console.log(JSON.stringify({ ...row, ...res }, null, 2));
1634
+ // `last_updated_at` is Nullable and some imported rows carry no timestamp, which
1635
+ // formatDateTime returns as JSON null — printed raw it reads as the literal word 'null'
1636
+ // sitting where a date belongs.
1637
+ console.log(`\x1b[1m${row.session_id}\x1b[0m ${row.at || '-'} ${row.name || '-'}`);
1638
+ if (!res.ok) {
1639
+ console.log(bad(res.reason));
1640
+ // The folder is still worth printing: for a GUI editor it is the one actionable thing
1641
+ // memhouse knows, and opening it is what the pilot would do next anyway.
1642
+ if (res.folder) console.log(` the session ran in: ${res.folder}`);
1643
+ return 1;
1644
+ }
1645
+ console.log('');
1646
+ console.log(` ${res.command}`);
1647
+ console.log('');
1648
+ if (!res.folder) console.log(warn('no folder was recorded for this session — run it from the right directory yourself'));
1649
+ return 0;
1650
+ }
1651
+
1652
+ // Which kind of installation is this process running out of? Every upgrade path below
1653
+ // depends on the answer, and getting it wrong is not a no-op: running `git pull` in a
1654
+ // global install does nothing visible, and `npm i -g` from a checkout installs the
1655
+ // PUBLISHED version over the branch the pilot was testing.
1656
+ function installKind() {
1657
+ const root = fs.realpathSync(REPO_ROOT);
1658
+ // npx unpacks into a cache directory keyed by a hash. Nothing there is upgradable — the
1659
+ // next `npx memhouse` resolves the registry again — and re-execing daemons out of a cache
1660
+ // entry that npm may evict is how a daemon ends up running from a directory that no
1661
+ // longer exists.
1662
+ if (/[/\\]_npx[/\\]/.test(root)) return { kind: 'npx', root };
1663
+ if (fs.existsSync(path.join(root, '.git'))) return { kind: 'checkout', root };
1664
+ if (/[/\\]node_modules[/\\]memhouse$/.test(root)) {
1665
+ // Global vs a project dependency. `npm prefix -g` is the only thing that tells them
1666
+ // apart, and it is worth the spawn: `npm i -g` from inside someone's project
1667
+ // dependency upgrades a different copy than the one they just ran.
1668
+ try {
1669
+ const p = spawnSync('npm', ['prefix', '-g'], { encoding: 'utf-8' });
1670
+ const prefix = (p.stdout || '').trim();
1671
+ if (prefix && root.startsWith(fs.realpathSync(prefix))) return { kind: 'global', root };
1672
+ } catch { /* fall through */ }
1673
+ return { kind: 'local-dep', root };
1674
+ }
1675
+ return { kind: 'unknown', root };
1676
+ }
1677
+
1678
+ // `npm i -g memhouse@latest` upgrades the files and nothing else: the flag that keeps five
1679
+ // adapters alive has to be repeated, the daemons keep executing the version they booted
1680
+ // with, and a house provisioned by an older shipper can be missing a room this one needs.
1681
+ // Each of those has cost a real machine real sessions. This command owns all three.
1682
+ async function cmdUpdate() {
1683
+ const { kind, root } = installKind();
1684
+ const latest = await (async () => {
1685
+ try {
1686
+ const res = await fetch('https://registry.npmjs.org/memhouse/latest', { signal: AbortSignal.timeout(8000) });
1687
+ return res.ok ? (await res.json()).version : null;
1688
+ } catch { return null; }
1689
+ })();
1690
+
1691
+ if (JSON_OUT && flags.check) return console.log(JSON.stringify({ kind, root, current: PKG.version, latest }, null, 2));
1692
+ console.log(` installed ${PKG.version} (${kind}: ${root})`);
1693
+ console.log(` latest ${latest || 'unknown — the registry did not answer'}`);
1694
+ if (latest && latest === PKG.version && kind !== 'checkout') console.log(ok('already current'));
1695
+ if (flags.check) return 0;
1696
+
1697
+ if (kind === 'npx') {
1698
+ console.log(warn('nothing to update — npx resolves the registry on every run'));
1699
+ console.log(' to keep a version around: npm install -g memhouse --allow-scripts=better-sqlite3');
1700
+ return 1;
1701
+ }
1702
+ if (kind === 'local-dep') {
1703
+ console.log(warn('this is a project dependency, not a global install'));
1704
+ console.log(` upgrade it where it lives: npm install memhouse@latest --allow-scripts=better-sqlite3`);
1705
+ return 1;
1706
+ }
1707
+
1708
+ // Whether the daemons were OURS matters after the upgrade, not before: a service-managed
1709
+ // shipper must be restarted through its supervisor, and pidfile daemons only come back if
1710
+ // something restarts them. Read it first — `stop` erases the evidence.
1711
+ let svc = { installed: false, running: false };
1712
+ try { svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { /* unsupported platform */ }
1713
+ const wasRunning = { shipper: !!pidOf('shipper'), dashboard: !!pidOf('dashboard') };
1714
+
1715
+ if (kind === 'checkout') {
1716
+ // The stale-UI case, and it is checkout-only: a published tarball ships public/ built by
1717
+ // prepack, but `git pull` updates ui/src and leaves the old bundle in public/ — so the
1718
+ // dashboard serves the previous release however many times it is restarted.
1719
+ const steps = [
1720
+ ['git', ['pull', '--ff-only']],
1721
+ ['npm', ['install', '--no-audit', '--no-fund']],
1722
+ ['npm', ['run', 'build']],
1723
+ ];
1724
+ for (const [bin, args] of steps) {
1725
+ const r = spawnSync(bin, args, { cwd: root, stdio: 'inherit' });
1726
+ if (r.status !== 0) {
1727
+ console.log(bad(`${bin} ${args.join(' ')} failed — stopping here, nothing was restarted`));
1728
+ return 1;
1729
+ }
1125
1730
  }
1126
- } else if (sub === 'install' && positional[1] === 'claude') {
1127
- fs.mkdirSync(dstSkills, { recursive: true });
1128
- for (const n of names) fs.cpSync(path.join(skillsSrc, n), path.join(dstSkills, n), { recursive: true });
1129
- console.log(ok(`installed ${names.length} skills into ${dstSkills} (${names.join(', ')})`));
1130
- } else if (sub === 'remove' && positional[1] === 'claude') {
1131
- let removed = 0;
1132
- for (const n of names) { const p = path.join(dstSkills, n); if (fs.existsSync(p)) { fs.rmSync(p, { recursive: true }); removed++; } }
1133
- console.log(ok(`removed ${removed} skills from ${dstSkills}`));
1134
1731
  } else {
1135
- console.log('usage: memhouse plugins [list | install claude | remove claude] [--target DIR]');
1136
- return 2;
1732
+ // --allow-scripts is not optional and not remembered: npm >= 12 blocks install scripts
1733
+ // by default, so an upgrade without it leaves better-sqlite3 with no binding and the
1734
+ // five SQLite-backed adapters read zero sessions on every pass afterwards.
1735
+ const r = spawnSync('npm', ['install', '-g', 'memhouse@latest', '--allow-scripts=better-sqlite3'], { stdio: 'inherit' });
1736
+ if (r.status !== 0) {
1737
+ console.log(bad('npm install failed — nothing was restarted, the running version is unchanged'));
1738
+ console.log(' if it was EACCES: ls -ld "$(npm prefix -g)" — root-owned needs sudo, yours needs a chown');
1739
+ return 1;
1740
+ }
1741
+ }
1742
+ console.log(ok('files updated'));
1743
+
1744
+ // Restarting is the half a bare `npm i -g` leaves undone. The daemons notice on their own
1745
+ // within a loop interval (memhouse/self-update.js), but a pilot who typed `update` should
1746
+ // not have to wait for it, and the dashboard's stale bundle is visible immediately.
1747
+ if (svc.installed) {
1748
+ console.log(warn(`the shipper is service-managed (${svc.kind}) — restart it to pick this up:`));
1749
+ console.log(svc.kind === 'systemd'
1750
+ ? ' systemctl --user restart memhouse-shipper'
1751
+ : ' launchctl kickstart -k gui/$(id -u)/com.memhouse.shipper');
1752
+ }
1753
+ if (wasRunning.shipper || wasRunning.dashboard) {
1754
+ cmdStop();
1755
+ await cmdStart();
1756
+ } else if (!svc.installed) {
1757
+ console.log(warn('no daemons were running — start them with: memhouse start'));
1758
+ }
1759
+
1760
+ // A shipper from a newer release can need a column an older house does not have. The
1761
+ // shipper reports the drift and prints the rebuild; running it here means the pilot
1762
+ // learns at upgrade time rather than from a warning in a log nobody reads.
1763
+ const cfg = resolveConfig();
1764
+ if (cfg.url && cfg.user) {
1765
+ const code = run(SHIP_JS, ['--ensure-schema'], cfg);
1766
+ if (code !== 0) { console.log(warn('the house schema needs attention — memhouse doctor')); return code; }
1137
1767
  }
1138
1768
  return 0;
1139
1769
  }
1140
1770
 
1771
+ // Every Claude Code instance on this machine, not just the default one.
1772
+ //
1773
+ // A pilot rarely has one. `~/.claude` is the stock install, `CLAUDE_CONFIG_DIR` points at
1774
+ // whichever they are running right now, and Kommander-style playbooks live under
1775
+ // `~/.claude-playbooks/<name>[/playbook]`, each a complete config directory with its own
1776
+ // skills/. Installing into one and calling it done leaves /memhouse:search missing from
1777
+ // every other instance the pilot uses — silently, because a missing skill does not announce
1778
+ // itself, it just never appears.
1779
+ //
1780
+ // The discovery is the ADAPTER's (editors/claude.js discoverClaudeRoots), reused rather
1781
+ // than reimplemented: it already ports memory-house's discover_roots and runs on every
1782
+ // ship. A second copy here would drift the first time a playbook layout changes — which is
1783
+ // the same defect this repo just retired in install.sh.
1784
+ function claudeTargets() {
1785
+ const seen = new Map(); // realpath -> { dir, why }
1786
+ const add = (dir, why) => {
1787
+ if (!dir) return;
1788
+ let isDir = false;
1789
+ try { isDir = fs.statSync(dir).isDirectory(); } catch { /* missing */ }
1790
+ if (!isDir) return;
1791
+ let rp; try { rp = fs.realpathSync(dir); } catch { rp = dir; }
1792
+ if (!seen.has(rp)) seen.set(rp, { dir, why });
1793
+ };
1794
+ // The instance the caller is running under comes first, and is NOT subject to the
1795
+ // adapter's "has this been used?" test — a fresh config dir has no projects/ yet, and it
1796
+ // is exactly where the pilot wants the skills.
1797
+ add(process.env.CLAUDE_CONFIG_DIR, 'CLAUDE_CONFIG_DIR');
1798
+ add(path.join(os.homedir(), '.claude'), 'default');
1799
+ try {
1800
+ const { discoverClaudeRoots } = require(path.join(REPO_ROOT, 'editors', 'claude'));
1801
+ for (const r of discoverClaudeRoots()) add(r, 'playbook');
1802
+ } catch { /* adapter unavailable — the two above still stand */ }
1803
+ return [...seen.values()];
1804
+ }
1805
+
1806
+ const short = (p) => p.replace(os.homedir(), '~');
1807
+ const PLUGIN_MARK = path.join('skills', 'memhouse', '.claude-plugin', 'plugin.json');
1808
+ const isPluginInstalled = (dir) => fs.existsSync(path.join(dir, PLUGIN_MARK));
1809
+
1810
+ function installPluginInto(dir) {
1811
+ const dst = path.join(dir, 'skills', 'memhouse');
1812
+ fs.mkdirSync(dst, { recursive: true });
1813
+ fs.cpSync(path.join(DELIVERY, 'plugin'), dst, { recursive: true });
1814
+ return dst;
1815
+ }
1816
+
1817
+ // Which instances to act on. EVERYTHING IS SELECTED BY DEFAULT: with several Claude
1818
+ // installs the answer is almost always "all of them", and a pilot who wanted one would have
1819
+ // passed --target. Numbers narrow it, 'n' skips.
1820
+ //
1821
+ // No prompt at all when there is nothing to choose (one target), when an agent is driving
1822
+ // (--yes / --json), or when there is no terminal to answer with — a blocked prompt in a
1823
+ // script is a hang, not a question.
1824
+ async function chooseTargets(targets, verb) {
1825
+ if (targets.length === 1 || flags.yes === true || JSON_OUT || !process.stdin.isTTY) return targets;
1826
+ console.log('');
1827
+ targets.forEach((t, i) => {
1828
+ const state = isPluginInstalled(t.dir) ? 'already installed — will be refreshed' : t.why;
1829
+ console.log(` ${i + 1}) ${short(t.dir)} (${state})`);
1830
+ });
1831
+ const a = (await ask(`${verb} all ${targets.length}? (Y/n, or numbers like "1 3")`, 'Y')).trim().toLowerCase();
1832
+ if (a === 'n' || a === 'no') return [];
1833
+ if (a === 'y' || a === 'yes' || a === '') return targets;
1834
+ const picked = [...new Set(a.split(/[\s,]+/).map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= targets.length))];
1835
+ // An unparseable answer must not quietly mean "all" — that would install into places the
1836
+ // pilot was in the middle of narrowing down. Say so and take none.
1837
+ if (!picked.length) { console.log(warn(`did not understand '${a}' — nothing selected`)); return []; }
1838
+ return picked.map((n) => targets[n - 1]);
1839
+ }
1840
+
1841
+ // Install the skills as a PLUGIN, not as three loose skill directories.
1842
+ //
1843
+ // Claude Code gives a skill a namespace only when it arrives inside a plugin: a directory
1844
+ // under <config>/skills/ containing .claude-plugin/plugin.json loads as
1845
+ // `memhouse@skills-dir` and its skills become /memhouse:search, /memhouse:sessions,
1846
+ // /memhouse:sql. Copied in flat, the same three files register as unrelated top-level
1847
+ // skills named after their folders — which is what this used to do, while plugin.json sat
1848
+ // unread one directory away claiming the colon form. Driving a real Claude Code is what
1849
+ // caught it: `/memhouse:search` answered `Unknown command. Did you mean /memhouse-search?`
1850
+ async function cmdPlugins() {
1851
+ const sub = positional[0] || 'list';
1852
+ const pluginSrc = path.join(DELIVERY, 'plugin');
1853
+ const names = fs.readdirSync(path.join(pluginSrc, 'skills'));
1854
+ const invocations = names.map((n) => `/memhouse:${n}`).join(', ');
1855
+ // --target overrides the discovery rather than joining it: given one, that is the only
1856
+ // directory touched.
1857
+ const targets = flags.target ? [{ dir: flags.target, why: '--target' }] : claudeTargets();
1858
+ // Whatever --target was given has to reappear in the advice, or pasting it installs
1859
+ // somewhere else than the directory just inspected.
1860
+ const self = `memhouse plugins install claude${flags.target ? ` --target ${flags.target}` : ''}`;
1861
+
1862
+ if (sub === 'list') {
1863
+ console.log(`skills available: ${invocations}`);
1864
+ if (!targets.length) { console.log(warn('no Claude Code config directory found')); return 0; }
1865
+ for (const t of targets) {
1866
+ console.log(isPluginInstalled(t.dir)
1867
+ ? ok(`installed in ${short(t.dir)} (${t.why})`)
1868
+ : warn(`not installed in ${short(t.dir)} (${t.why})`));
1869
+ }
1870
+ if (!targets.some((t) => isPluginInstalled(t.dir))) console.log(` install with: ${self}`);
1871
+ return 0;
1872
+ }
1873
+
1874
+ if (sub === 'install' && positional[1] === 'claude') {
1875
+ if (!targets.length) {
1876
+ console.log(warn('no Claude Code config directory found — nothing to install into'));
1877
+ console.log(' name one explicitly with --target DIR');
1878
+ return 1;
1879
+ }
1880
+ const chosen = await chooseTargets(targets, 'Install');
1881
+ if (!chosen.length) { console.log(warn('nothing installed')); return 0; }
1882
+ for (const t of chosen) console.log(ok(`installed ${names.length} skills into ${short(installPluginInto(t.dir))}`));
1883
+ console.log(` loads as memhouse@skills-dir next session — invoke ${invocations}`);
1884
+ return 0;
1885
+ }
1886
+
1887
+ if (sub === 'remove' && positional[1] === 'claude') {
1888
+ const installed = targets.filter((t) => isPluginInstalled(t.dir));
1889
+ if (!installed.length) { console.log(warn('nothing installed in any Claude config directory')); return 0; }
1890
+ const chosen = await chooseTargets(installed, 'Remove from');
1891
+ if (!chosen.length) { console.log(warn('nothing removed')); return 0; }
1892
+ for (const t of chosen) {
1893
+ fs.rmSync(path.join(t.dir, 'skills', 'memhouse'), { recursive: true });
1894
+ // Remove the now-empty skills/ we created, but never a skills/ holding someone
1895
+ // else's work.
1896
+ try { fs.rmdirSync(path.join(t.dir, 'skills')); } catch { /* not empty: leave it */ }
1897
+ console.log(ok(`removed ${short(path.join(t.dir, 'skills', 'memhouse'))}`));
1898
+ }
1899
+ return 0;
1900
+ }
1901
+
1902
+ console.log('usage: memhouse plugins [list | install claude | remove claude] [--target DIR] [--yes]');
1903
+ return 2;
1904
+ }
1905
+
1141
1906
  async function cmdReset() {
1142
- const cfg = resolveConfig();
1907
+ const cfg = requireConfig(resolveConfig(), 'reset');
1143
1908
  const r = await roomsFor(cfg);
1144
1909
  const targets = ROOM_TYPES.map((t) => r[t]);
1910
+
1911
+ // Imported rows are NOT the shipper's to remove, and reset is a re-ship: whatever it
1912
+ // deletes has to be something re-shipping puts back. An import cannot be — it came from
1913
+ // an older house, another product, or a machine that no longer exists. Scoped to
1914
+ // origin='ship' by default, therefore, exactly like the shipper's per-session clear.
1915
+ //
1916
+ // This path was missed when that clear was fixed, and it is the one place the 0.4.4 data
1917
+ // loss survived: `reset --yes` took a house from 2 imported rows to 0 while the prompt
1918
+ // said only "truncates … and re-ships". --all-origins still allows it, but nobody
1919
+ // reaches it by accident, and the count is named before it happens.
1920
+ const allOrigins = flags['all-origins'] === true;
1921
+ let imported = 0;
1922
+ try {
1923
+ // Same predicate the DELETE uses. Counted without an owner filter, this warned
1924
+ // "removing 2 imported row(s)" and then removed none of them — rows imported by an
1925
+ // ADMIN during a migration carry the admin's user_id, so a member's DELETE cannot
1926
+ // reach them. The prompt was wrong in the alarming direction.
1927
+ const esc0 = (await chRows(cfg, 'SELECT currentUser() AS u'))[0]?.u?.replace(/\\/g, '\\\\').replace(/'/g, "\\'") || '';
1928
+ const counts = await Promise.all(targets.map(async (t) =>
1929
+ Number((await chRows(cfg, `SELECT count() AS c FROM ${t} FINAL WHERE origin != 'ship' AND (user_id = '${esc0}' OR user_id = '')`))[0]?.c || 0)));
1930
+ imported = counts.reduce((a, b) => a + b, 0);
1931
+ } catch { /* pre-origin house: nothing to protect, ensureSchema will add the column */ }
1932
+
1145
1933
  if (flags.yes !== true) {
1146
1934
  // Name the rooms. Under the per-member layout this only ever empties the caller's own,
1147
1935
  // and "ALL rows in 'mem'" would misdescribe that in the alarming direction.
1148
- const a = (await ask(`This truncates ${targets.join(', ')} in '${cfg.db}' and re-ships. Continue? (yes/no)`, 'no')).toLowerCase();
1936
+ const scope = allOrigins
1937
+ ? `EVERY row, including ${imported} imported one(s) that re-shipping CANNOT restore,`
1938
+ : 'the rows the shipper wrote';
1939
+ const a = (await ask(`This clears ${scope} from ${targets.join(', ')} in '${cfg.db}' and re-ships. Continue? (yes/no)`, 'no')).toLowerCase();
1149
1940
  if (a !== 'yes' && a !== 'y') return console.log('aborted'), 1;
1150
1941
  }
1942
+ if (allOrigins && imported) console.log(warn(`removing ${imported} imported row(s) — re-shipping will not bring them back`));
1943
+ else if (imported) console.log(` keeping ${imported} imported row(s) (--all-origins removes them too)`);
1151
1944
  // DELETE, not TRUNCATE. Historically for two reasons:
1152
1945
  // * TRUNCATE is its own privilege, and a member provisioned under the pre-0.4 grant
1153
1946
  // set (SELECT, INSERT and the two ALTER grants) did not hold it — so `reset` failed
@@ -1163,21 +1956,28 @@ async function cmdReset() {
1163
1956
  const uid = (await chRows(cfg, 'SELECT currentUser() AS u'))[0]?.u;
1164
1957
  if (!uid) return console.log(bad('could not determine currentUser() — refusing to reset')), 1;
1165
1958
  const esc = uid.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
1166
- for (const t of targets) await ch(cfg, `DELETE FROM ${t} WHERE user_id = '${esc}'`);
1167
- console.log(ok(`cleared ${uid}'s rows from ${targets.join(', ')}`));
1959
+ const originScope = allOrigins ? '' : " AND origin = 'ship'";
1960
+ // `OR user_id = ''` because rows written under async_insert=1 never got the
1961
+ // MATERIALIZED currentUser() stamp, and a DELETE bound to the caller's name cannot
1962
+ // reach them — so `reset --all-origins`, whose prompt says "EVERY row", left them
1963
+ // behind and only an admin could clear them. Safe to include here and nowhere else:
1964
+ // these rooms are named for one member, so an unattributed row IN THIS ROOM is theirs
1965
+ // by construction. The shipper's per-session clear deliberately does NOT do this — it
1966
+ // only ever removes what it wrote, and it always writes with async_insert=0.
1967
+ const owner = `(user_id = '${esc}' OR user_id = '')`;
1968
+ for (const t of targets) await ch(cfg, `DELETE FROM ${t} WHERE ${owner}${originScope}`);
1969
+ console.log(ok(`cleared ${uid}'s ${allOrigins ? '' : 'shipped '}rows from ${targets.join(', ')}`));
1168
1970
  return run(SHIP_JS, ['--full'], cfg);
1169
1971
  }
1170
1972
 
1171
- function cmdUninstall() {
1973
+ async function cmdUninstall() {
1172
1974
  // The OS service first, and this is not tidiness: it outlives the pidfile daemons by
1173
1975
  // design, it holds the credential inlined in its unit file, and it restarts itself. An
1174
1976
  // uninstall that stopped only the daemons would report success while a service kept
1175
1977
  // shipping transcripts — with a credential in a file the user now believes is gone.
1176
- const svc = require(path.join(REPO_ROOT, 'mem-house', 'service.js'));
1978
+ const svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
1177
1979
  const st = svc.status();
1178
- // `legacy` too: a machine that ran the solo tier can have a stale unit and no current
1179
- // one, and skipping the service step there leaves it enabled over a deleted home.
1180
- if (st.kind && (st.installed || (st.legacy || []).length)) {
1980
+ if (st.kind && st.installed) {
1181
1981
  const r = svc.uninstall();
1182
1982
  if (!r.ok) {
1183
1983
  // Removing the home now would delete the env file while a service keeps shipping
@@ -1190,17 +1990,90 @@ function cmdUninstall() {
1190
1990
  console.log(ok(`service removed (${r.kind})`));
1191
1991
  }
1192
1992
  cmdStop();
1193
- if (fs.existsSync(HOME_DIR)) fs.rmSync(HOME_DIR, { recursive: true });
1194
- console.log(ok(`removed ${HOME_DIR} (the house data in ClickHouse is untouched)`));
1993
+
1994
+ // Three tiers, and the default is the conservative one.
1995
+ //
1996
+ // This used to delete MEMHOUSE_HOME outright, which made "stop the daemons" and "forget
1997
+ // the house I connect to, and who this machine is" the same command. They are not the
1998
+ // same intent. Stopping the shipper is routine — before an upgrade, while debugging,
1999
+ // when a laptop should go quiet for a week. Discarding the credential is a decision, and
2000
+ // dropping the host fingerprint silently re-labels every row this machine ships after.
2001
+ //
2002
+ // (default) daemons, service, runtime state. Config and identity KEPT.
2003
+ // --credentials also forget the house: the env file, with its password.
2004
+ // --full-removal all of MEMHOUSE_HOME, fingerprint included. The next install is a
2005
+ // NEW host whose rows do not join this machine's history.
2006
+ //
2007
+ // No tier touches the house: transcripts live in ClickHouse and re-ship from the local
2008
+ // session stores regardless.
2009
+ const hostjs = require(path.join(REPO_ROOT, 'memhouse', 'host.js'));
2010
+ const full = flags['full-removal'] === true;
2011
+ const creds = full || flags.credentials === true;
2012
+ const hostFile = hostjs.filePath(HOME_DIR);
2013
+
2014
+ if (!fs.existsSync(HOME_DIR)) {
2015
+ console.log(ok('daemons stopped — no state directory to remove'));
2016
+ return;
2017
+ }
2018
+
2019
+ if (full) {
2020
+ // The only tier that discards the machine's identity, so the only one that asks.
2021
+ // Re-installing afterwards starts a second host inside the same rooms, and nothing
2022
+ // later can stitch the two halves together.
2023
+ if (flags.yes !== true && process.stdin.isTTY) {
2024
+ const id = hostjs.read(HOME_DIR);
2025
+ console.log(warn(`--full-removal also discards this machine's host identity${id ? ` (${id.id})` : ''}.`));
2026
+ console.log(' Rows already shipped keep that name, a future install gets a new one, and');
2027
+ console.log(" this machine then reads as two. Your transcripts themselves are safe.");
2028
+ const a = (await ask('Remove everything, including the host identity? (yes/no)', 'no')).toLowerCase();
2029
+ if (a !== 'yes' && a !== 'y') { console.log('aborted — daemons are stopped, nothing was removed'); return; }
2030
+ }
2031
+ fs.rmSync(HOME_DIR, { recursive: true });
2032
+ console.log(ok(`removed ${short(HOME_DIR)} entirely — config, credential and host identity`));
2033
+ console.log(' the house data in ClickHouse is untouched');
2034
+ return;
2035
+ }
2036
+
2037
+ // Runtime state only: pidfiles and logs. The next `start` rebuilds both, and neither is
2038
+ // worth keeping once nothing is running.
2039
+ for (const d of ['run', 'logs']) {
2040
+ const p = path.join(HOME_DIR, d);
2041
+ if (fs.existsSync(p)) fs.rmSync(p, { recursive: true });
2042
+ }
2043
+ console.log(ok('removed runtime state (pidfiles, logs)'));
2044
+
2045
+ if (creds) {
2046
+ if (fs.existsSync(ENV_FILE)) {
2047
+ fs.rmSync(ENV_FILE);
2048
+ console.log(ok(`removed ${short(ENV_FILE)} — the house connection and its credential`));
2049
+ } else {
2050
+ console.log(warn('no config file to remove'));
2051
+ }
2052
+ } else if (fs.existsSync(ENV_FILE)) {
2053
+ console.log(ok(`kept ${short(ENV_FILE)} — the house connection and its credential`));
2054
+ console.log(' forget it too with: memhouse uninstall --credentials');
2055
+ }
2056
+
2057
+ if (fs.existsSync(hostFile)) {
2058
+ console.log(ok(`kept ${short(hostFile)} — this machine's identity, so a reinstall continues its history`));
2059
+ console.log(' remove everything with: memhouse uninstall --full-removal');
2060
+ }
2061
+ console.log(' the house data in ClickHouse is untouched');
1195
2062
  }
1196
2063
 
1197
2064
  // ── dispatch ────────────────────────────────────────────────────────────────────
1198
2065
  (async () => {
2066
+ // Single-dash flags are not parsed (only `--x`), so `-v` arrives as a positional. Both
2067
+ // spellings are what people type for a version.
2068
+ if (flags.version === true || process.argv.slice(2).some((a) => a === '-v' || a === '-V')) {
2069
+ console.log(PKG.version); return;
2070
+ }
1199
2071
  const cfg = resolveConfig();
1200
- if (legacySoloGuard()) { process.exitCode = 2; return; }
1201
2072
 
1202
2073
  switch (cmd) {
1203
2074
  case null: case 'help': console.log(HELP); break;
2075
+ // `--version` and `-v` land here as flags, not as the `version` verb, so they used to
2076
+ // fall through to HELP: forty lines of help and exit 0 is not what --version means.
1204
2077
  case 'version': console.log(PKG.version); break;
1205
2078
  case 'discover': await cmdDiscover(); break;
1206
2079
  case 'onboard': process.exitCode = await cmdOnboard(); break;
@@ -1210,10 +2083,27 @@ function cmdUninstall() {
1210
2083
  // --port overrides, already resolved into cfg) skips every prompt so
1211
2084
  // agents/CI can rewrite the config non-interactively.
1212
2085
  const c = { ...cfg };
2086
+ // `install` refuses to guess http://localhost:8123 as memhouse_root, with a comment
2087
+ // explaining why; `setup --yes` wrote precisely that config in one command, exit 0,
2088
+ // and five Enters through the interactive form did the same. A guard at one command
2089
+ // is not a guard.
2090
+ if (!cfg.stated && flags.yes === true) {
2091
+ console.log(bad('no house given: --url and --user are required with --yes'));
2092
+ console.log(' setup will not write http://localhost:8123 as memhouse_root into your config.');
2093
+ // 2, like every other no-config refusal. A script gating on the exit code got a
2094
+ // different answer from these two commands than from the other eight.
2095
+ process.exitCode = 2;
2096
+ break;
2097
+ }
1213
2098
  if (flags.yes !== true) {
1214
- c.url = await ask(' ClickHouse URL', c.url);
1215
- c.user = await ask(' user', c.user);
1216
- c.password = await ask(' password', c.password);
2099
+ c.url = await ask(' ClickHouse URL', cfg.stated ? c.url : '');
2100
+ c.user = await ask(' user', cfg.stated ? c.user : '');
2101
+ if (!c.url || !c.user) {
2102
+ console.log(bad('a URL and a user are required — memhouse will not pick a house for you'));
2103
+ process.exitCode = 1;
2104
+ break;
2105
+ }
2106
+ c.password = await askSecret(' password', c.password);
1217
2107
  c.db = await ask(' database (the house)', c.db);
1218
2108
  c.port = await ask(' dashboard port', c.port);
1219
2109
  }
@@ -1227,41 +2117,57 @@ function cmdUninstall() {
1227
2117
  break;
1228
2118
  }
1229
2119
  case 'ship': {
2120
+ requireConfig(cfg, 'ship');
1230
2121
  const args = [];
2122
+ // --ensure-schema was accepted by the parser and never forwarded, so asking for a
2123
+ // schema-only rollout shipped 398 sessions instead and exited 0.
2124
+ if (flags['ensure-schema'] === true) args.push('--ensure-schema');
1231
2125
  if (flags.full === true) args.push('--full');
1232
2126
  if (flags.loop !== undefined) { args.push('--loop'); if (flags.loop !== true) args.push(String(flags.loop)); }
1233
2127
  process.exitCode = run(SHIP_JS, args, cfg);
1234
2128
  break;
1235
2129
  }
1236
- case 'stats': process.exitCode = run(SHIP_JS, ['--stats'], cfg); break;
2130
+ case 'stats': requireConfig(cfg, 'stats'); process.exitCode = run(SHIP_JS, ['--stats'], cfg); break;
1237
2131
  case 'start': await cmdStart(); break;
1238
2132
  case 'stop': cmdStop(); break;
1239
- case 'status': await cmdStatus(); break;
2133
+ case 'status': process.exitCode = await cmdStatus(); break;
1240
2134
  case 'doctor': process.exitCode = await cmdDoctor(); break;
1241
2135
  case 'search': process.exitCode = await cmdSearch(); break;
2136
+ case 'resume': process.exitCode = await cmdResume(); break;
2137
+ case 'update': process.exitCode = await cmdUpdate(); break;
1242
2138
  // The session rollup is a saved query, not an object, so there is no name an agent
1243
2139
  // or a skill can put in a FROM clause. This prints it, resolved for whoever the
1244
2140
  // configured credential is — the substitute for that name.
1245
2141
  case 'sessions-query': {
1246
- const cfg = resolveConfig();
2142
+ const cfg = requireConfig(resolveConfig(), 'sessions-query');
1247
2143
  const r = await roomsFor(cfg);
2144
+ // On STDOUT and inside the SQL. This used to go to stderr, so `sessions-query >
2145
+ // q.sql` kept the query and dropped the instruction it depended on. The rollup now
2146
+ // carries FINAL and join_use_nulls itself, so the note is provenance, not a warning.
2147
+ if (!JSON_OUT) console.log(`-- memhouse rollup for '${r.member}' — self-contained (FINAL + join_use_nulls).`);
1248
2148
  console.log(r.sessions_v);
1249
- if (!JSON_OUT) {
1250
- console.error(`-- rollup for '${r.member}'. Read with final=1 and join_use_nulls=1.`);
1251
- }
1252
2149
  break;
1253
2150
  }
1254
- case 'plugins': process.exitCode = cmdPlugins(); break;
2151
+ case 'plugins': process.exitCode = await cmdPlugins(); break;
1255
2152
  case 'prompt':
1256
2153
  // Two audiences, two prompts. Bare `prompt` is the memory-USAGE snippet that goes
1257
2154
  // into a running agent's system prompt; `--install` is the one you hand an agent
1258
2155
  // that has not installed memhouse yet.
1259
2156
  if (flags.install === true) process.exitCode = await renderInstallPrompt();
1260
- else process.stdout.write(fs.readFileSync(path.join(DELIVERY, 'PROMPT.md'), 'utf-8'));
2157
+ else {
2158
+ // Render THIS machine's state. The snippet used to be a straight file dump that
2159
+ // told the agent the database was 'mem' and the config was at ~/.memhouse/env —
2160
+ // wrong for anyone whose house or MEMHOUSE_HOME differs, which is the whole point
2161
+ // of having those knobs. `--install` already renders; bare `prompt` did not.
2162
+ const pcfg = resolveConfig();
2163
+ process.stdout.write(fs.readFileSync(path.join(DELIVERY, 'PROMPT.md'), 'utf-8')
2164
+ .replaceAll('{{DB}}', pcfg.db)
2165
+ .replaceAll('{{ENV_FILE}}', ENV_FILE));
2166
+ }
1261
2167
  break;
1262
2168
  case 'reset': process.exitCode = await cmdReset(); break;
1263
2169
  case 'deploy': {
1264
- const dep = require(path.join(REPO_ROOT, 'mem-house', 'deploy.js'));
2170
+ const dep = require(path.join(REPO_ROOT, 'memhouse', 'deploy.js'));
1265
2171
  if (flags.down) {
1266
2172
  // A shipper service outlives the house it points at. Tearing down the container
1267
2173
  // and volume beneath it leaves the service retrying an endpoint that is gone —
@@ -1273,7 +2179,7 @@ function cmdUninstall() {
1273
2179
  // container. Compare the unit's own inlined URL against the port this container
1274
2180
  // publishes; if that cannot be determined, fail closed.
1275
2181
  let svcCfg = { installed: false, url: null };
1276
- try { svcCfg = require(path.join(REPO_ROOT, 'mem-house', 'service.js')).installedConfig(); } catch { /* unsupported */ }
2182
+ try { svcCfg = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).installedConfig(); } catch { /* unsupported */ }
1277
2183
  if (svcCfg.installed) {
1278
2184
  const pub = dep.publishedPort();
1279
2185
  // Host AND port. Port alone classified a service pointing at
@@ -1287,6 +2193,23 @@ function cmdUninstall() {
1287
2193
  }
1288
2194
  console.log(warn(`a shipper service is installed but points at ${svcCfg.url} — leaving it alone`));
1289
2195
  }
2196
+ // Ask. This removes the data VOLUME as well as the container — measured, 810 MB of
2197
+ // a real house — and it was the only destructive command in the product that did
2198
+ // not confirm, while `reset`, which merely re-ships, does. `prompt --install` had
2199
+ // to tell agents in prose not to run it.
2200
+ if (flags.yes !== true) {
2201
+ const held = await (async () => {
2202
+ try {
2203
+ const c = resolveConfig();
2204
+ const n = await chRows(c, `SELECT count() AS n FROM ${(await roomsFor(c)).messages} FINAL`);
2205
+ return Number(n[0]?.n || 0);
2206
+ } catch { return null; }
2207
+ })();
2208
+ console.log(warn('this removes the container AND its data volume — the house and everything in it.'));
2209
+ if (held) console.log(` ${held.toLocaleString()} messages are stored there. Re-shipping recovers only what the adapters can still see.`);
2210
+ const a = (await ask('Remove it? (yes/no)', 'no')).toLowerCase();
2211
+ if (a !== 'yes' && a !== 'y') { console.log('aborted'); process.exitCode = 1; break; }
2212
+ }
1290
2213
  const r = dep.down();
1291
2214
  console.log(r.ok ? ok(`local ClickHouse removed (${r.engine}${r.volumeRemoved ? ', volume included' : ''})`) : bad(r.msg));
1292
2215
  process.exitCode = r.ok ? 0 : 1;
@@ -1387,7 +2310,7 @@ function cmdUninstall() {
1387
2310
  // was skipped, and `status` would then report a running shipper beside counts
1388
2311
  // from a different house.
1389
2312
  let svcCfg = { installed: false, url: null };
1390
- try { svcCfg = require(path.join(REPO_ROOT, 'mem-house', 'service.js')).installedConfig(); } catch { /* unsupported */ }
2313
+ try { svcCfg = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).installedConfig(); } catch { /* unsupported */ }
1391
2314
  // Endpoint AND database. Same URL with a different database is still somewhere
1392
2315
  // else: `deploy --local --db memories` over a service holding `mem` would pass a
1393
2316
  // URL-only check and split reads from service writes.
@@ -1457,12 +2380,18 @@ function cmdUninstall() {
1457
2380
  process.exitCode = 1; break;
1458
2381
  }
1459
2382
  console.log(ok('ClickHouse ready'));
1460
- // A ROOTLESS container lives in the user's systemd slice, and that slice is torn
1461
- // down at logout unless lingering is enabled — the container takes a SIGTERM and
1462
- // the house goes down with it. Measured on a testbed VM: `deploy --local` over ssh
1463
- // shipped 211 sessions, the ssh session ended, and the container was
1464
- // `Exited (143)` twenty seconds later with `Linger=no`. The volume survives, so
1465
- // nothing is lost, but the house is gone with no explanation anywhere.
2383
+ // A ROOTLESS container lives in the user's systemd slice, which MAY be torn down at
2384
+ // logout — if so the container takes a SIGTERM and the house goes with it. The
2385
+ // volume survives, so nothing is lost, but the house is gone with no explanation.
2386
+ //
2387
+ // Whether it happens depends on logind's KillUserProcesses. Measured once on a
2388
+ // testbed VM as `Exited (143)` twenty seconds after the ssh session ended; measured
2389
+ // again later on Ubuntu 24.04, which ships KillUserProcesses=no, and the container
2390
+ // and the user manager both survived 44 seconds with zero sessions and Linger=no.
2391
+ // So the warning is conditional, and it is worded as a possibility rather than a
2392
+ // certainty: enabling lingering is harmless and makes it moot either way, but
2393
+ // telling a user to fix a problem their distro does not have costs credibility on
2394
+ // every other thing this command says.
1466
2395
  //
1467
2396
  // `service install` already detects exactly this for its own unit. The container
1468
2397
  // needs the same check, and podman is the case that matters: it is rootless by
@@ -1470,13 +2399,27 @@ function cmdUninstall() {
1470
2399
  // (rootless docker has the same exposure, but is the deliberate minority).
1471
2400
  if (process.platform === 'linux' && r.engine === 'podman') {
1472
2401
  let lingering = true;
1473
- try { lingering = require(path.join(REPO_ROOT, 'mem-house', 'service.js')).lingerEnabled(); } catch { /* assume fine */ }
2402
+ try { lingering = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).lingerEnabled(); } catch { /* assume fine */ }
1474
2403
  if (!lingering) {
1475
- console.log(warn('rootless podman: this container stops when you log out (lingering is off).'));
1476
- console.log(` loginctl enable-linger ${os.userInfo().username}`);
1477
- console.log(' Until then, after a logout: memhouse deploy --local (the data volume persists)');
2404
+ console.log(warn('rootless podman: this container MAY stop when you log out (lingering is off).'));
2405
+ console.log(' Whether it does depends on your distro — Ubuntu 24.04 keeps it, others kill it.');
2406
+ console.log(` To make it moot: loginctl enable-linger ${os.userInfo().username}`);
2407
+ // Repeat the port that was actually used. cmdPlugins already knows to echo back
2408
+ // the --target it was given; this line dropped --house-port, so following it
2409
+ // would stand the house up somewhere else.
2410
+ console.log(` If it does stop: memhouse deploy --local${flags['house-port'] ? ` --house-port ${flags['house-port']}` : ''} (the data volume persists)`);
1478
2411
  }
1479
2412
  }
2413
+ // Say what this credential is. `deploy --local` stands up a house you own, so the
2414
+ // member IS the superuser — SHOW GRANTS for it includes CREATE USER, FILE, URL,
2415
+ // REMOTE and S3, all WITH GRANT OPTION, and `service install` inlines it into a
2416
+ // systemd unit. That is a defensible position for a single-owner house (DESIGN.md
2417
+ // anticipates it) and it is NOT the narrow set INSTALL.md's grant-set argument
2418
+ // describes. A user who later adds a second member should know which house they
2419
+ // have.
2420
+ console.log(warn(`this house is yours alone: '${'memhouse_root'}' is its superuser, and that is the credential being saved`));
2421
+ console.log(' Adding a second person later? Give them their own member instead:');
2422
+ console.log(` node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member <name> --merge`);
1480
2423
  flags.url = r.url; flags.user = 'memhouse_root'; flags.password = pw;
1481
2424
  flags.db = targetDb;
1482
2425
  flags.yes = true;
@@ -1484,7 +2427,7 @@ function cmdUninstall() {
1484
2427
  break;
1485
2428
  }
1486
2429
  case 'service': {
1487
- const svc = require(path.join(REPO_ROOT, 'mem-house', 'service.js'));
2430
+ const svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
1488
2431
  const sub = positional[0] || 'status';
1489
2432
  if (sub === 'install') {
1490
2433
  // Ask what can be asked before killing the daemon this is taking over from. A
@@ -1507,6 +2450,10 @@ function cmdUninstall() {
1507
2450
  // Where this install lives. The env file records the connection, not the home
1508
2451
  // that contains it, and an adapter override may hang off the home.
1509
2452
  home: HOME_DIR,
2453
+ // Replacing a service that belongs to a DIFFERENT MEMHOUSE_HOME is refused
2454
+ // unless asked for; there is one service name per user, so the replacement is
2455
+ // otherwise silent and the other house is left with no shipper.
2456
+ force: flags.force === true,
1510
2457
  });
1511
2458
  if (!r.ok) { console.log(bad(r.msg)); process.exitCode = 1; break; }
1512
2459
  console.log(ok(`service installed (${r.kind}): ${r.path}`));
@@ -1524,11 +2471,10 @@ function cmdUninstall() {
1524
2471
  if (!st.kind) { console.log(warn(`no service integration for '${process.platform}'`)); break; }
1525
2472
  console.log(st.installed ? ok(`service installed (${st.kind}): ${st.path}`) : warn('service not installed'));
1526
2473
  console.log(st.running ? ok('service running') : warn('service not running'));
1527
- for (const f of st.legacy || []) console.log(warn(`stale unit from an older version: ${f} — remove with: memhouse service uninstall`));
1528
2474
  }
1529
2475
  break;
1530
2476
  }
1531
- case 'uninstall': cmdUninstall(); break;
2477
+ case 'uninstall': await cmdUninstall(); break;
1532
2478
  default:
1533
2479
  console.error(`unknown command: ${cmd}\n${HELP}`);
1534
2480
  process.exitCode = 2;