memhouse 0.3.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +83 -6
  2. package/bin/memhouse.js +882 -40
  3. package/editors/zed.js +55 -6
  4. package/index.js +2 -2
  5. package/mem-house/COMPETITION.md +217 -0
  6. package/mem-house/DESIGN.md +61 -32
  7. package/mem-house/delivery/AGENT-INSTALL.md +23 -3
  8. package/mem-house/delivery/PROMPT.md +23 -10
  9. package/mem-house/delivery/install.sh +9 -7
  10. package/mem-house/delivery/kernel-install.md +56 -25
  11. package/mem-house/delivery/skills/memhouse-search/SKILL.md +46 -16
  12. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +38 -7
  13. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +48 -17
  14. package/mem-house/deploy.js +333 -0
  15. package/mem-house/envfile.js +49 -0
  16. package/mem-house/per-member/INSTALL.md +183 -0
  17. package/mem-house/per-member/PLAN.md +174 -0
  18. package/mem-house/per-member/PROVISIONING.md +177 -0
  19. package/mem-house/per-member/README.md +48 -0
  20. package/mem-house/per-member/SCHEMA.md +73 -0
  21. package/mem-house/per-member/SHARING.md +102 -0
  22. package/mem-house/per-member/provision.js +142 -0
  23. package/mem-house/per-member/rooms.js +132 -0
  24. package/mem-house/per-member/schema-member.sql.tpl +85 -0
  25. package/mem-house/per-member/schema-merge.sql.tpl +24 -0
  26. package/mem-house/server/queries.js +103 -53
  27. package/mem-house/service.js +400 -0
  28. package/mem-house/shipper/ship.js +61 -18
  29. package/package.json +3 -3
  30. package/pricing.js +20 -2
  31. package/pricing.json +216 -24
  32. package/public/assets/{index-CCPh7a2Z.js → index-CtH-5i95.js} +5 -5
  33. package/public/index.html +1 -1
  34. package/ui/src/App.jsx +3 -2
  35. package/ui/src/pages/SqlViewer.jsx +39 -21
  36. package/mem-house/rls.sql +0 -17
  37. package/mem-house/schema.sql +0 -145
package/bin/memhouse.js CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  const fs = require('fs');
16
16
  const os = require('os');
17
+ const crypto = require('crypto');
17
18
  const path = require('path');
18
19
  const { spawn, spawnSync } = require('child_process');
19
20
 
@@ -22,6 +23,8 @@ const SHIP_JS = path.join(REPO_ROOT, 'mem-house', 'shipper', 'ship.js');
22
23
  const SERVER_JS = path.join(REPO_ROOT, 'mem-house', 'server', 'server.js');
23
24
  const DELIVERY = path.join(REPO_ROOT, 'mem-house', 'delivery');
24
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'));
25
28
 
26
29
  const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
27
30
  const ENV_FILE = path.join(HOME_DIR, 'env');
@@ -45,25 +48,51 @@ for (let i = 0; i < rest.length; i++) {
45
48
  }
46
49
  const JSON_OUT = flags.json === true;
47
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
+ }
92
+
48
93
  // ── config ──────────────────────────────────────────────────────────────────────
49
94
  function readEnvFile() {
50
- const out = {};
51
- try {
52
- for (const line of fs.readFileSync(ENV_FILE, 'utf-8').split('\n')) {
53
- const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
54
- if (m && !line.trim().startsWith('#')) {
55
- let v = m[2].trim();
56
- // Unwrap shell quoting (we write single-quoted; tolerate double too).
57
- if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) {
58
- v = v.slice(1, -1).replace(/'\\''/g, "'");
59
- } else if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) {
60
- v = v.slice(1, -1);
61
- }
62
- out[m[1]] = v;
63
- }
64
- }
65
- } catch { /* no env file yet */ }
66
- return out;
95
+ try { return envfile.parse(fs.readFileSync(ENV_FILE, 'utf-8')); } catch { return {}; }
67
96
  }
68
97
 
69
98
  function resolveConfig() {
@@ -74,7 +103,7 @@ function resolveConfig() {
74
103
  url: pick('url', 'MEMHOUSE_URL', 'http://localhost:8123'),
75
104
  user: pick('user', 'MEMHOUSE_USER', 'memhouse_root'),
76
105
  password: pick('password', 'MEMHOUSE_PASSWORD', ''),
77
- db: pick('db', 'MEMHOUSE_DB', 'memhouse'),
106
+ db: pick('db', 'MEMHOUSE_DB', 'mem'),
78
107
  port: pick('port', 'MEMHOUSE_PORT', '4640'),
79
108
  };
80
109
  }
@@ -91,7 +120,7 @@ function writeEnvFile(cfg) {
91
120
  fs.mkdirSync(HOME_DIR, { recursive: true });
92
121
  // Single-quoted values: this file is also sourced by shells (skills/docs use
93
122
  // `. ~/.memhouse/env`), so metacharacters in a password must never be bare.
94
- const sq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
123
+ const sq = envfile.quoteShell;
95
124
  const body = [
96
125
  '# mem-house connection — written by `memhouse install/setup`',
97
126
  `MEMHOUSE_URL=${sq(cfg.url)}`,
@@ -106,7 +135,10 @@ function writeEnvFile(cfg) {
106
135
 
107
136
  // ── ClickHouse over HTTP (small read-only queries; heavy ops go via ship.js) ───
108
137
  async function ch(cfg, sql, { database = cfg.db } = {}) {
109
- const params = new URLSearchParams({ final: '1' });
138
+ // Both settings, always: `final` collapses ReplacingMergeTree versions, and
139
+ // `join_use_nulls` is what the session rollup's coalesce depends on now that it is a
140
+ // saved query rather than a view carrying its own SETTINGS clause.
141
+ const params = new URLSearchParams({ final: '1', join_use_nulls: '1' });
110
142
  if (database) params.set('database', database);
111
143
  const res = await fetch(`${cfg.url.replace(/\/$/, '')}/?${params}`, {
112
144
  method: 'POST',
@@ -123,6 +155,107 @@ async function chRows(cfg, sql, opts) {
123
155
  return text ? text.split('\n').map((l) => JSON.parse(l)) : [];
124
156
  }
125
157
 
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
+ /** Is anything listening on this loopback port? */
200
+ function portInUse(port) {
201
+ return new Promise((resolve) => {
202
+ const s = require('net').createServer();
203
+ s.once('error', () => resolve(true));
204
+ s.once('listening', () => s.close(() => resolve(false)));
205
+ s.listen(Number(port), '127.0.0.1');
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Same house? Host AND port, normalised. Comparing ports alone called a service pointing
211
+ * at `http://remote-house:8123` a match for a local container publishing 8123.
212
+ * localhost/127.0.0.1/::1 are the same machine and must compare equal.
213
+ */
214
+ function sameEndpoint(a, b) {
215
+ const norm = (u) => {
216
+ try {
217
+ const x = new URL(u);
218
+ const host = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(x.hostname) ? 'local' : x.hostname;
219
+ return `${host}:${x.port || (x.protocol === 'https:' ? '443' : '80')}`;
220
+ } catch { return null; }
221
+ };
222
+ const na = norm(a); const nb = norm(b);
223
+ return na !== null && nb !== null && na === nb;
224
+ }
225
+
226
+ /** Port from a configured URL, '' when it has none or the URL is unparseable. */
227
+ function portOf(u) {
228
+ try { return new URL(u).port || ''; } catch { return ''; }
229
+ }
230
+
231
+ // Is the shipper alive, by whichever mechanism owns it? After `service install` the
232
+ // pidfile is deliberately gone — the service took over — so a pidfile-only check reports
233
+ // "not running" for every correctly service-managed install, and `doctor` fails on a
234
+ // healthy machine. Returns { running, via }.
235
+ function shipperHealth() {
236
+ const pid = pidOf('shipper');
237
+ if (pid) return { running: true, via: `daemon (pid ${pid})` };
238
+ try {
239
+ const svc = require(path.join(REPO_ROOT, 'mem-house', 'service.js'));
240
+ const st = svc.status();
241
+ if (st.installed) return { running: st.running, via: `service (${st.kind})` };
242
+ } catch { /* no service integration on this platform */ }
243
+ return { running: false, via: null };
244
+ }
245
+
246
+ // Room routing for the CLI's own queries. The shipper and dashboard resolve rooms through
247
+ // @clickhouse/client; the CLI speaks raw HTTP, so it asks the same question over its own
248
+ // transport and builds the names with the same shared function.
249
+ let _rooms = null;
250
+ async function roomsFor(cfg) {
251
+ if (_rooms) return _rooms;
252
+ const rows = await chRows(cfg, 'SELECT currentUser() AS u');
253
+ const member = rows[0] && rows[0].u;
254
+ if (!member) throw new Error('could not determine currentUser() for room resolution');
255
+ _rooms = roomNames(member);
256
+ return _rooms;
257
+ }
258
+
126
259
  // ── small helpers ───────────────────────────────────────────────────────────────
127
260
  const ok = (s) => ` \x1b[32m✓\x1b[0m ${s}`;
128
261
  const bad = (s) => ` \x1b[31m✗\x1b[0m ${s}`;
@@ -168,6 +301,10 @@ memhouse ${PKG.version} — agent conversation memory across 17 editors
168
301
 
169
302
  Setup onboard interactive wizard: discover → configure → ship → start
170
303
  install scriptable setup (--url --user --password --db [--yes] [--no-ship])
304
+ --print-sql print the SQL, run it yourself
305
+ with admin: --admin-user --admin-password [--member NAME]
306
+ builds house + user + rooms + grants, then verifies as
307
+ the member. The admin credential is never stored.
171
308
  setup (re)write the connection config only (--yes = no prompts)
172
309
  discover read-only preflight: editors, sessions, reachable ClickHouses
173
310
  uninstall stop daemons + remove ${HOME_DIR.replace(os.homedir(), '~')} (house data untouched)
@@ -176,6 +313,7 @@ Setup onboard interactive wizard: discover → configure →
176
313
  Data ship one incremental pass (--full | --loop [sec])
177
314
  stats per-source session/message/token counts
178
315
  search <terms…> full-text search across all sessions
316
+ sessions-query print the session rollup SQL for this credential
179
317
  start | stop | shipper loop + dashboard as background daemons
180
318
  status daemons, connection, counts, freshness (--json)
181
319
  doctor diagnose the whole pipeline
@@ -183,7 +321,14 @@ Data ship one incremental pass (--full | --loop [sec])
183
321
  Agents plugins list | install claude [--target DIR] | remove claude
184
322
  prompt print the memory system-prompt snippet
185
323
 
324
+ House deploy --local run ClickHouse in docker/podman, then install
325
+ deploy --down remove the local house (container + volume)
326
+ [--house-port N] [--tag 25.11] (--port is the dashboard)
327
+ service install run the shipper as a user service (systemd / launchd)
328
+ service uninstall | status
329
+
186
330
  Config: flags > MEMHOUSE_* env > ${ENV_FILE.replace(os.homedir(), '~')} > defaults.
331
+ Engine: MEMHOUSE_ENGINE pins docker or podman when both are installed and one cannot answer.
187
332
  `;
188
333
 
189
334
  // A skipped adapter and an editor the user does not have look identical — both
@@ -246,10 +391,244 @@ async function cmdDiscover() {
246
391
  console.log('');
247
392
  console.log(out.config ? ok(`config: ${out.config}`) : warn('no config yet — run: memhouse install (or onboard)'));
248
393
  if (out.memoryHouse) console.log(warn('memory-house detected on this machine (they coexist fine)'));
394
+ return out;
395
+ }
396
+
397
+ /**
398
+ * Can this machine stand up a house of its own?
399
+ *
400
+ * Returns the container engines on PATH, or [] — which is also what a platform with no
401
+ * deploy support returns, so callers need no second check. `deploy --local` exists and has
402
+ * since 0.4.0, but nothing ever mentioned it: a first-timer with no ClickHouse was told
403
+ * "fix the connection" by the one command whose whole job is to get them connected.
404
+ */
405
+ function deployableEngines() {
406
+ try { return require(path.join(REPO_ROOT, 'mem-house', 'deploy.js')).availableEngines() || []; }
407
+ catch { return []; }
408
+ }
409
+
410
+ /** The offer itself, so install and onboard cannot drift into wording it differently. */
411
+ function printLocalHouseOffer(engines) {
412
+ if (!engines.length) {
413
+ console.log(' No ClickHouse to point at? memhouse can run one for you, but that needs');
414
+ console.log(' docker or podman — neither is on PATH.');
415
+ return false;
416
+ }
417
+ console.log(` No ClickHouse to point at? memhouse can run one (${engines.join(' or ')}):`);
418
+ console.log(' memhouse deploy --local');
419
+ console.log(' That starts the container, generates a credential, installs and ships.');
420
+ return true;
421
+ }
422
+
423
+ // ── the member handle ───────────────────────────────────────────────────────────
424
+ // The ClickHouse user IS the identity; rooms are named for whatever the server answers
425
+ // to currentUser(). The home directory only produces a SUGGESTION, and a suggestion that
426
+ // had to be changed is printed rather than applied quietly — sanitizing collides
427
+ // (`ramazan.polat` and `ramazan-polat` both land on `ramazan_polat`), so the human has to
428
+ // see it.
429
+ function suggestMember() {
430
+ const base = path.basename(os.homedir() || '');
431
+ const clean = base.replace(/[^A-Za-z0-9_]/g, '_');
432
+ if (!/^[A-Za-z]/.test(clean)) return { handle: null, why: `'${base}' does not start with a letter` };
433
+ if (clean === 'root') return { handle: null, why: "'root' is a container artefact, not a person" };
434
+ return { handle: clean, changed: clean !== base ? base : null };
435
+ }
436
+
437
+ function generatePassword() {
438
+ // 32 chars from a set with no shell or SQL metacharacters — this value is pasted into
439
+ // terminals and quoted into unit files, and a clever password is not worth a support case.
440
+ const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
441
+ return Array.from(crypto.randomBytes(32)).map((b) => alphabet[b % alphabet.length]).join('');
442
+ }
443
+
444
+ /**
445
+ * Option 1 — the SQL, for a human with admin who would rather not hand it over.
446
+ *
447
+ * This is the whole install as statements: nothing else runs behind it, and running it by
448
+ * hand produces exactly what `--admin-user` would have produced. It is rendered from the
449
+ * same templates provision.js applies, so the two cannot drift into different houses.
450
+ */
451
+ function memberSql(db, member, password) {
452
+ const here = path.join(REPO_ROOT, 'mem-house', 'per-member');
453
+ // The templates are written unqualified because provision.js applies them with the
454
+ // house already selected. A human pastes this somewhere unknown — clickhouse-client,
455
+ // the play UI, curl — so every name is qualified here and there is no `USE`.
456
+ //
457
+ // `currentDatabase()` in the Merge engine is the trap, and it is silent:
458
+ // it is evaluated at CREATE time, not at read time, so a block run without the house
459
+ // selected builds Merge rooms pointing at `default` that return zero rows forever. The
460
+ // literal name goes in instead.
461
+ const qualify = (sql) => sql
462
+ .replace(/CREATE TABLE IF NOT EXISTS (\w+)/g, `CREATE TABLE IF NOT EXISTS ${db}.$1`)
463
+ .replace(/ AS (\w+)\nENGINE = Merge/g, ` AS ${db}.$1\nENGINE = Merge`)
464
+ .replace(/Merge\(currentDatabase\(\)/g, `Merge('${db}'`);
465
+ const rooms = qualify(fs.readFileSync(path.join(here, 'schema-member.sql.tpl'), 'utf-8')
466
+ .replaceAll('{{MEMBER}}', member));
467
+ const merge = qualify(fs.readFileSync(path.join(here, 'schema-merge.sql.tpl'), 'utf-8')
468
+ .replaceAll('{{TEMPLATE_MEMBER}}', member));
469
+ const grants = ROOM_TYPES.flatMap((t) => [
470
+ `GRANT ALL ON ${db}.${t}_${member} TO ${member};`,
471
+ `GRANT SELECT ON ${db}.${t}_${member} TO ${member} WITH GRANT OPTION;`,
472
+ ]).join('\n');
473
+ const mergeGrants = Object.values(mergeRooms())
474
+ .map((n) => `GRANT SELECT ON ${db}.${n} TO ${member};`).join('\n');
475
+ return `-- memhouse: everything '${member}' needs in house '${db}'. Run as a user with
476
+ -- ACCESS MANAGEMENT (a stock 'default' with access_management=1 will do).
477
+ --
478
+ -- Every name is qualified and there is no USE, so this runs anywhere: clickhouse-client,
479
+ -- the play UI, curl, a GUI. Order matters only in that the database comes first.
480
+ --
481
+ -- The messages room carries two text indexes. ClickHouse 25.x gates them behind
482
+ -- allow_experimental_full_text_index and refuses the CREATE without it (Code: 344);
483
+ -- 26.x accepts the setting as a no-op. The SET below covers any client that keeps a
484
+ -- session — clickhouse-client, a GUI. Over HTTP, where each statement is its own
485
+ -- request and SET does not persist, put it in the URL instead:
486
+ -- curl "\$URL/?allow_experimental_full_text_index=1&multiquery=1" --data-binary @-
487
+ -- multiquery=1 matters because this is one POST carrying many statements; without it
488
+ -- ClickHouse parses only the first and reports a syntax error on the rest.
489
+
490
+ SET allow_experimental_full_text_index = 1;
491
+
492
+ CREATE DATABASE IF NOT EXISTS ${db};
493
+ CREATE USER ${member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}';
494
+
495
+ ${rooms.trim()}
496
+
497
+ -- ALL is not re-grantable; SELECT is. The member owns their rooms outright — DROP
498
+ -- included, it is their memory — but a share can only ever be read-only.
499
+ ${grants}
500
+
501
+ -- Team-wide reads. A Merge room reduces to the rooms the CALLER holds grants for, so
502
+ -- this narrows rather than denies, and picks up members added later with no DDL.
503
+ ${merge.trim()}
504
+
505
+ ${mergeGrants}
506
+ `;
507
+ }
508
+
509
+ /**
510
+ * Mode B — admin bootstrap. Five verbs, then a reconnect AS THE MEMBER before anything is
511
+ * written, because a mode that only ever proves the admin could do it has proven nothing
512
+ * about the credential it is about to persist.
513
+ *
514
+ * The admin credential is never persisted: not to the env file, not to a unit, not to a
515
+ * log. A shipper running as the house owner makes per-member rooms decoration.
516
+ */
517
+ async function adminBootstrap(cfg, admin) {
518
+ const adminCfg = { ...cfg, user: admin.user, password: admin.password };
519
+ const q = (sql, opts) => ch(adminCfg, sql, opts);
520
+
521
+ try { await q('SELECT 1', { database: '' }); }
522
+ catch (e) { console.log(bad(`admin connection failed: ${e.message}`)); return null; }
523
+
524
+ // 1. the house
525
+ try { await q(`CREATE DATABASE IF NOT EXISTS ${cfg.db}`, { database: '' }); }
526
+ catch (e) {
527
+ try { await q('SELECT 1'); }
528
+ catch { console.log(bad(`house '${cfg.db}' does not exist and '${admin.user}' cannot create it: ${e.message}`)); return null; }
529
+ }
530
+
531
+ // 2. the user. An EXISTING user is refused: adopting one silently hands the second
532
+ // human the first's identity and rooms, and user_id MATERIALIZED currentUser() would
533
+ // stamp them identically, so nothing downstream would ever notice.
534
+ const exists = (await chRows(adminCfg, `SELECT name FROM system.users WHERE name = '${admin.member}'`, { database: '' })).length > 0;
535
+ let password = flags['member-password'] || null;
536
+ if (exists) {
537
+ if (flags['adopt-user'] !== true) {
538
+ console.log(bad(`ClickHouse user '${admin.member}' already exists in this house.`));
539
+ console.log(' Creating rooms for them would hand you their identity — user_id is stamped from');
540
+ console.log(' currentUser(), so their rows and yours would be indistinguishable.');
541
+ console.log(' If this is you on a new machine, prove it:');
542
+ console.log(` memhouse install --adopt-user --member ${admin.member} --member-password '…' …`);
543
+ console.log(` If it is someone else, pick another handle: --member <name>`);
544
+ return null;
545
+ }
546
+ if (!password) { console.log(bad('--adopt-user requires --member-password — the password is the proof')); return null; }
547
+ try { await ch({ ...cfg, user: admin.member, password }, 'SELECT 1', { database: '' }); }
548
+ catch { console.log(bad(`--adopt-user: '${admin.member}' did not authenticate with that password`)); return null; }
549
+ console.log(ok(`adopted existing user '${admin.member}' (password verified)`));
550
+ } else {
551
+ if (!password) {
552
+ password = generatePassword();
553
+ console.log('');
554
+ console.log(` password for '${admin.member}': ${password}`);
555
+ console.log(' Shown once. It goes into ~/.memhouse/env; to change it later:');
556
+ console.log(` ALTER USER ${admin.member} IDENTIFIED BY '…' then: memhouse setup --password …`);
557
+ console.log('');
558
+ }
559
+ try { await q(`CREATE USER ${admin.member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}'`, { database: '' }); }
560
+ catch (e) { console.log(bad(`could not create user '${admin.member}': ${e.message}`)); return null; }
561
+ console.log(ok(`created ClickHouse user '${admin.member}'`));
562
+ }
563
+
564
+ // 3. rooms, 4. grants, 5. Merge rooms + their grant — provision.js owns all of it, so
565
+ // there is one implementation of the grant set rather than two that drift.
566
+ const provision = path.join(REPO_ROOT, 'mem-house', 'per-member', 'provision.js');
567
+ const rc = spawnSync(process.execPath, [provision, '--member', admin.member, '--merge'], {
568
+ stdio: 'inherit',
569
+ env: { ...process.env, MEM_URL: cfg.url, MEM_USER: admin.user, MEM_PASSWORD: admin.password, MEM_DB: cfg.db },
570
+ });
571
+ if (rc.status !== 0) { console.log(bad('provisioning failed — nothing was written')); return null; }
572
+
573
+ // The step that makes this trustworthy: stop being admin, and prove the credential we
574
+ // are about to persist actually reaches the rooms.
575
+ const memberCfg = { ...cfg, user: admin.member, password };
576
+ try {
577
+ const seen = await chRows(memberCfg, `SELECT count() AS n FROM system.tables WHERE database = '${cfg.db}' AND name IN ('sessions_${admin.member}','messages_${admin.member}','tool_calls_${admin.member}')`, { database: '' });
578
+ if (Number(seen[0]?.n) !== 3) { console.log(bad(`'${admin.member}' cannot see all three of their rooms — nothing written`)); return null; }
579
+ } catch (e) { console.log(bad(`'${admin.member}' could not connect after provisioning: ${e.message}`)); return null; }
580
+ console.log(ok(`verified as '${admin.member}' — admin credential discarded, not stored`));
581
+ return memberCfg;
582
+ }
583
+
584
+ // The handle, decided once for whichever option runs. Returns null after reporting.
585
+ function resolveMemberHandle() {
586
+ const s = suggestMember();
587
+ const member = flags.member || s.handle;
588
+ if (!member) { console.log(bad(`no member handle: ${s.why}. Pass --member <name>.`)); return null; }
589
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(member)) {
590
+ console.log(bad(`'${member}' cannot name a room — expected [A-Za-z][A-Za-z0-9_]*`));
591
+ return null;
592
+ }
593
+ if (!flags.member && s.changed) console.log(warn(`member handle '${s.changed}' sanitized to '${member}' — pass --member to choose another`));
594
+ return member;
249
595
  }
250
596
 
251
597
  async function cmdInstall({ interactive }) {
252
598
  let cfg = resolveConfig();
599
+ const adminUser = flags['admin-user'];
600
+
601
+ // Option 1 — print the SQL and stop. For the common case: you have admin on this
602
+ // ClickHouse and would rather run four statements yourself than hand a credential to an
603
+ // installer. Nothing is written and nothing is contacted.
604
+ if (flags['print-sql'] === true) {
605
+ const member = resolveMemberHandle();
606
+ if (!member) return 1;
607
+ const password = flags['member-password'] || generatePassword();
608
+ console.log(memberSql(cfg.db, member, password));
609
+ console.log(`-- Then, once that has run:`);
610
+ console.log(`-- memhouse install --url ${cfg.url} --db ${cfg.db} --user ${member} --password '${password}'`);
611
+ return 0;
612
+ }
613
+
614
+ // Option 2 — admin bootstrap. Decided by one question: do you hold admin here?
615
+ if (adminUser) {
616
+ const member = resolveMemberHandle();
617
+ if (!member) return 1;
618
+ const built = await adminBootstrap(cfg, {
619
+ user: adminUser, password: flags['admin-password'] || '', member,
620
+ });
621
+ if (!built) return 1;
622
+ cfg = built;
623
+ writeEnvFile(cfg);
624
+ console.log(ok(`config written: ${ENV_FILE}`));
625
+ if (flags['no-ship'] !== true && run(SHIP_JS, [], cfg) !== 0) return 1;
626
+ console.log(ok('installed — next: memhouse start (dashboard + shipper loop)'));
627
+ console.log(' admin is needed again only for: a second member, or an ADD COLUMN rollout.');
628
+ console.log(' node mem-house/per-member/provision.js --member <name> --merge');
629
+ return 0;
630
+ }
631
+
253
632
  const haveAll = flags.yes === true || (flags.url && flags.user !== undefined);
254
633
  if (interactive || !haveAll) {
255
634
  console.log('mem-house connection (Enter keeps the default):');
@@ -263,12 +642,27 @@ async function cmdInstall({ interactive }) {
263
642
  console.log(bad(`invalid database name '${cfg.db}' — use letters, digits, underscore`));
264
643
  return 1;
265
644
  }
266
- writeEnvFile(cfg);
267
- console.log(ok(`config written: ${ENV_FILE}`));
645
+ // An existing config pointing somewhere ELSE is not a thing to overwrite in passing: a
646
+ // shipper or an installed service is still pointed at the old house, and rewriting the
647
+ // file orphans it with no error anywhere.
648
+ const prior = readEnvFile();
649
+ if (prior.MEMHOUSE_URL && flags.force !== true
650
+ && (!sameEndpoint(prior.MEMHOUSE_URL, cfg.url) || (prior.MEMHOUSE_DB && prior.MEMHOUSE_DB !== cfg.db))) {
651
+ console.log(bad(`${ENV_FILE} already points at ${prior.MEMHOUSE_URL} / ${prior.MEMHOUSE_DB || '?'}`));
652
+ console.log(` installing over it would leave any running shipper or service on the old house.`);
653
+ console.log(' memhouse setup --url … --db … (move deliberately)');
654
+ console.log(' memhouse install --force … (overwrite anyway)');
655
+ return 1;
656
+ }
268
657
  // Preflight WITHOUT selecting the house — on a fresh standalone ClickHouse the
269
658
  // database doesn't exist yet, and selecting it would fail before we can create it.
270
659
  try { await ch(cfg, 'SELECT 1', { database: '' }); }
271
- catch (e) { console.log(bad(`connection failed: ${e.message}`)); console.log(' fix the connection, then re-run: memhouse install'); return 1; }
660
+ catch (e) {
661
+ console.log(bad(`connection failed: ${e.message}`));
662
+ printLocalHouseOffer(deployableEngines());
663
+ console.log(' Already have one? Fix the connection, then re-run: memhouse install');
664
+ return 1;
665
+ }
272
666
  console.log(ok('connection verified'));
273
667
  // Ensure the house exists (standalone path). On a kernel realm the house is
274
668
  // provisioned by install-agency and this is a no-op; if the user lacks CREATE
@@ -279,7 +673,42 @@ async function cmdInstall({ interactive }) {
279
673
  catch { console.log(bad(`house '${cfg.db}' does not exist and cannot be created: ${e.message}`)); return 1; }
280
674
  }
281
675
  console.log(ok(`house '${cfg.db}' ready`));
282
- if (run(SHIP_JS, ['--ensure-schema'], cfg) !== 0) return 1;
676
+ // Rooms — the caller's own three. On a house you own this creates them and you are
677
+ // done, which is the solo path. On a house someone else owns you hold no CREATE TABLE,
678
+ // and that is not a failure to paper over: rooms and grants are minted by the owner, so
679
+ // the step reports what is missing and names the command that fixes it.
680
+ const r = await roomsFor(cfg);
681
+ // Three rooms. The session rollup is a saved query over them, not a fourth object.
682
+ const want = ROOM_TYPES.map((t) => r[t]);
683
+ const present = async () => {
684
+ const list = `'${want.join("','")}'`;
685
+ return (await chRows(cfg, `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND name IN (${list})`, { database: '' })).map((x) => x.name);
686
+ };
687
+ let have = [];
688
+ try { have = await present(); } catch (e) { console.log(bad(`could not list rooms: ${e.message}`)); return 1; }
689
+ if (have.length !== want.length) {
690
+ // Check for a pre-0.4 house BEFORE trying to mint anything. `--ensure-schema` on one
691
+ // of those dies with an UNKNOWN_TABLE stack trace, and a stack trace printed above a
692
+ // clean explanation is how a clear message gets missed.
693
+ const legacy = await looksLikePre040(cfg);
694
+ if (legacy.length) { reportPre040(cfg, r.member, legacy); return 1; }
695
+ // Try to mint them as ourselves before asking anyone for anything.
696
+ run(SHIP_JS, ['--ensure-schema'], cfg);
697
+ try { have = await present(); } catch { /* reported below */ }
698
+ }
699
+ const missing = want.filter((n) => !have.includes(n));
700
+ if (missing.length) {
701
+ console.log(bad(`'${r.member}' has no ${missing.join(', ')} in '${cfg.db}', and could not create them`));
702
+ console.log(' You hold no CREATE TABLE here. Someone with ACCESS MANAGEMENT has to run this —');
703
+ console.log(' print it with: memhouse install --print-sql --member ' + r.member);
704
+ console.log(' or hand it over: memhouse install --admin-user … --admin-password …');
705
+ return 1;
706
+ }
707
+ console.log(ok(`rooms for '${r.member}': ${want.join(', ')}`));
708
+ // Written LAST, and only once everything above proved out. Writing it first leaves a
709
+ // config file behind every failed attempt, and the next command reads it as truth.
710
+ writeEnvFile(cfg);
711
+ console.log(ok(`config written: ${ENV_FILE}`));
283
712
  if (flags['no-ship'] !== true) {
284
713
  if (run(SHIP_JS, [], cfg) !== 0) return 1;
285
714
  }
@@ -289,8 +718,32 @@ async function cmdInstall({ interactive }) {
289
718
 
290
719
  async function cmdOnboard() {
291
720
  console.log(`memhouse ${PKG.version} — onboarding\n`);
292
- await cmdDiscover();
721
+ const found = await cmdDiscover();
293
722
  console.log('');
723
+ // discover has just probed every candidate endpoint and printed the result. Walking
724
+ // straight into "ClickHouse URL:" after finding none asks the user for something they
725
+ // have already been shown they do not have.
726
+ const reachable = (found && found.clickhouse || []).filter((p) => p.reachable);
727
+ if (!reachable.length) {
728
+ const engines = deployableEngines();
729
+ if (engines.length) {
730
+ console.log(warn('No reachable ClickHouse found.'));
731
+ const yn = (await ask(`Run one locally with ${engines[0]}? (Y/n)`, 'Y')).toLowerCase();
732
+ if (yn !== 'n' && yn !== 'no') {
733
+ // Re-enter the CLI as a child rather than calling the deploy path directly. That
734
+ // path is the most heavily guarded code here — ownership, port probes, credential
735
+ // reuse, service drift — and every one of those guards is written against a fresh
736
+ // process reading its own flags. Reaching into it with a synthesised flag object
737
+ // is how one of them silently stops applying.
738
+ const rc = spawnSync(process.execPath, [__filename, 'deploy', '--local'], { stdio: 'inherit' });
739
+ return rc.status === 0 ? 0 : (rc.status || 1);
740
+ }
741
+ } else {
742
+ console.log(warn('No reachable ClickHouse found, and neither docker nor podman is on PATH.'));
743
+ console.log(' Point at one you already run, or install a container engine and use: memhouse deploy --local');
744
+ }
745
+ console.log('');
746
+ }
294
747
  const code = await cmdInstall({ interactive: true });
295
748
  if (code !== 0) return code;
296
749
  const yn = (await ask('Start the daemons now? (Y/n)', 'Y')).toLowerCase();
@@ -307,6 +760,30 @@ async function cmdStart() {
307
760
  { name: 'shipper', script: SHIP_JS, args: ['--loop', String(flags.interval || 300)] },
308
761
  { name: 'dashboard', script: SERVER_JS, args: [] },
309
762
  ];
763
+ // Anything an installed service owns must not also be started here. Those pidfiles are
764
+ // deliberately absent — `service install` removed them when it took over — so the
765
+ // "already running?" check below cannot see the service's processes at all.
766
+ //
767
+ // For the shipper that means two loops parsing and clearing the same sessions
768
+ // concurrently, each deleting rows the other just inserted.
769
+ //
770
+ // Reachable in the obvious way: after a reboot the user wants the dashboard back, which
771
+ // is not service-managed, and types `memhouse start`.
772
+ let svcStatus = { installed: false, running: false };
773
+ try { svcStatus = require(path.join(REPO_ROOT, 'mem-house', 'service.js')).status(); } catch { /* unsupported platform */ }
774
+ const owned = [
775
+ ...(svcStatus.installed ? [['shipper', svcStatus.running, 'memhouse-shipper']] : []),
776
+ ];
777
+ for (const [name, running, unit] of owned) {
778
+ const i = daemons.findIndex((d) => d.name === name);
779
+ if (i >= 0) daemons.splice(i, 1);
780
+ console.log(warn(`${name} is service-managed (${svcStatus.kind}${running ? '' : ', not running'}) — not starting a second one`));
781
+ if (!running) {
782
+ console.log(svcStatus.kind === 'systemd'
783
+ ? ` start it with: systemctl --user start ${unit}`
784
+ : ` start it with: launchctl kickstart gui/$(id -u)/com.${unit.replace('memhouse-', 'memhouse.')}`);
785
+ }
786
+ }
310
787
  for (const d of daemons) {
311
788
  if (pidOf(d.name)) { console.log(warn(`${d.name} already running (pid ${pidOf(d.name)})`)); continue; }
312
789
  const log = fs.openSync(path.join(LOG_DIR, d.name + '.log'), 'a');
@@ -320,9 +797,15 @@ async function cmdStart() {
320
797
  console.log(` dashboard → http://localhost:${cfg.port}`);
321
798
  }
322
799
 
800
+ // `solo` is here and NOT in the start list on purpose. The tier is gone, but a machine
801
+ // that ran it before this upgrade can still have a detached shim alive with its pid in
802
+ // run/solo.pid — and `uninstall` deletes MEMHOUSE_HOME, which is where its data directory
803
+ // lives. Reaping has to outlive the feature; starting must not.
804
+ const LEGACY_DAEMONS = ['solo'];
805
+
323
806
  function cmdStop() {
324
807
  let stopped = 0;
325
- for (const name of ['shipper', 'dashboard']) {
808
+ for (const name of ['shipper', 'dashboard', ...LEGACY_DAEMONS]) {
326
809
  const pid = pidOf(name);
327
810
  if (pid) { try { process.kill(pid, 'SIGTERM'); console.log(ok(`${name} stopped (pid ${pid})`)); stopped++; } catch { /* raced */ } }
328
811
  try { fs.unlinkSync(path.join(RUN_DIR, name + '.pid')); } catch { /* absent */ }
@@ -336,12 +819,15 @@ async function cmdStatus() {
336
819
  config: fs.existsSync(ENV_FILE) ? ENV_FILE : null,
337
820
  url: cfg.url, db: cfg.db, user: cfg.user,
338
821
  daemons: { shipper: pidOf('shipper'), dashboard: pidOf('dashboard') },
822
+ shipper: shipperHealth(),
339
823
  connected: false,
340
824
  };
341
825
  try {
342
826
  out.connected = true && !!(await ch(cfg, 'SELECT 1'));
343
- const s = await chRows(cfg, 'SELECT count() AS sessions FROM sessions_v');
344
- const m = await chRows(cfg, "SELECT count() AS msgs, formatDateTime(max(ingested_at), '%Y-%m-%d %H:%i:%S') AS freshest FROM messages");
827
+ const r = await roomsFor(cfg);
828
+ out.member = r.member;
829
+ const s = await chRows(cfg, `SELECT count() AS sessions FROM ${r.sessions_v}`);
830
+ const m = await chRows(cfg, `SELECT count() AS msgs, formatDateTime(max(ingested_at), '%Y-%m-%d %H:%i:%S') AS freshest FROM ${r.messages}`);
345
831
  out.sessions = Number(s[0]?.sessions || 0);
346
832
  out.messages = Number(m[0]?.msgs || 0);
347
833
  out.freshest = m[0]?.freshest || null;
@@ -350,10 +836,10 @@ async function cmdStatus() {
350
836
  if (JSON_OUT) return console.log(JSON.stringify(out, null, 2));
351
837
  console.log(out.config ? ok(`config: ${out.config}`) : warn('no config (memhouse install)'));
352
838
  console.log(out.connected ? ok(`connected: ${cfg.url} / ${cfg.db} as ${cfg.user}`) : bad(`not connected: ${out.error || cfg.url}`));
839
+ if (out.member) console.log(ok(`rooms for '${out.member}'`));
353
840
  if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${out.freshest} UTC)`));
354
- for (const [name, pid] of Object.entries(out.daemons)) {
355
- console.log(pid ? ok(`${name}: running (pid ${pid})`) : warn(`${name}: not running`));
356
- }
841
+ console.log(out.shipper.running ? ok(`shipper: running — ${out.shipper.via}`) : warn('shipper: not running'));
842
+ console.log(out.daemons.dashboard ? ok(`dashboard: running (pid ${out.daemons.dashboard})`) : warn('dashboard: not running'));
357
843
  if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${cfg.port}`);
358
844
  }
359
845
 
@@ -367,13 +853,24 @@ async function cmdDoctor() {
367
853
  add(fs.existsSync(ENV_FILE), `config ${ENV_FILE}`, 'run: memhouse install');
368
854
  try { await ch(cfg, 'SELECT 1', { database: '' }); add(true, `clickhouse reachable (${cfg.url})`); }
369
855
  catch (e) { add(false, `clickhouse reachable (${cfg.url})`, e.message); }
856
+ let rooms = null;
857
+ try { rooms = await roomsFor(cfg); } catch (e) { add(false, 'room resolution', e.message); }
858
+ if (rooms) add(true, `rooms for '${rooms.member}'`);
370
859
  try {
371
- const t = (await chRows(cfg, "SELECT name FROM system.tables WHERE database = {db:String} AND name IN ('sessions','messages','tool_calls')".replace('{db:String}', `'${cfg.db}'`), { database: '' })).length;
372
- add(t === 3, `schema: ${t}/3 tables in '${cfg.db}'`, 'run: memhouse install (ensure-schema)');
860
+ // Three rooms. The session rollup every read path goes through is a saved query over
861
+ // exactly these, so if they are here it is too — there is no fourth object to lose.
862
+ const objects = ROOM_TYPES.map((t) => rooms[t]);
863
+ const want = objects.map((n) => `'${n}'`).join(',');
864
+ const t = (await chRows(cfg, `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND name IN (${want})`, { database: '' })).length;
865
+ const legacy = t === objects.length ? [] : await looksLikePre040(cfg);
866
+ add(t === objects.length, `schema: ${t}/${objects.length} rooms in '${cfg.db}' (${objects.join(', ')})`,
867
+ legacy.length
868
+ ? `pre-0.4 house (${legacy.join(', ')}) — 0.4 cannot read it; see: memhouse install --help, then ship --full`
869
+ : `run: memhouse install, or as the owner: node mem-house/per-member/provision.js --member ${rooms.member}`);
373
870
  } catch (e) { add(false, 'schema check', e.message); }
374
871
  try {
375
- const u = await chRows(cfg, 'SELECT any(user_id) AS u FROM sessions');
376
- add((u[0]?.u ?? '') !== '' || (await chRows(cfg, 'SELECT count() AS c FROM sessions'))[0].c === 0,
872
+ const u = await chRows(cfg, `SELECT any(user_id) AS u FROM ${rooms.sessions}`);
873
+ add((u[0]?.u ?? '') !== '' || (await chRows(cfg, `SELECT count() AS c FROM ${rooms.sessions}`))[0].c === 0,
377
874
  `identity stamping (user_id='${u[0]?.u ?? ''}')`, 'writers must use async_insert=0');
378
875
  } catch { add(false, 'identity stamping', 'schema missing?'); }
379
876
  let adapterErrors = [];
@@ -435,7 +932,30 @@ async function cmdDoctor() {
435
932
  : adapterErrors.length ? adapterErrors.map((e) => `${e.source}: ${e.message}`).join('; ')
436
933
  : undefined);
437
934
  } catch (e) { add(false, 'adapters', e.message); }
438
- add(!!pidOf('shipper'), 'shipper daemon', 'memhouse start');
935
+ // Which models in this house have no price. An unpriced model is not an error — a new
936
+ // release always lands before pricing.json catches up — but it IS silent: calculateCost
937
+ // returns null and the callers drop it from the total, so the dashboard reports a
938
+ // smaller number that looks entirely plausible. The Claude 5 family sat unpriced for
939
+ // five months that way, understating a real house by 136%.
940
+ try {
941
+ const { calculateCost } = require(path.join(REPO_ROOT, 'pricing'));
942
+ const rows = await chRows(cfg,
943
+ `SELECT model, count() AS n FROM ${rooms.messages} WHERE model NOT IN ('', '<synthetic>') GROUP BY model`);
944
+ const total = rows.reduce((a, r) => a + Number(r.n), 0);
945
+ const unpriced = rows.filter((r) => calculateCost(r.model, 1e6, 0, 0, 0) === null);
946
+ const missed = unpriced.reduce((a, r) => a + Number(r.n), 0);
947
+ // Report the COUNT, and the share only when it rounds to something. "0% of messages"
948
+ // beside a real number reads as "nothing is wrong", which is the opposite of the point.
949
+ const pct = total ? (missed / total) * 100 : 0;
950
+ const share = pct >= 0.5 ? `${Math.round(pct)}% of messages` : `${missed} message${missed === 1 ? '' : 's'}`;
951
+ add(unpriced.length === 0,
952
+ unpriced.length === 0
953
+ ? `pricing: every model in this house has a price (${rows.length} models)`
954
+ : `pricing: ${unpriced.length} model${unpriced.length > 1 ? 's' : ''} unpriced — ${share} cost nothing in the dashboard (${unpriced.map((r) => r.model).join(', ')})`,
955
+ 'node sync-pricing.js --write (then re-run doctor)');
956
+ } catch { /* a house that cannot be read is already reported above */ }
957
+ const sh = shipperHealth();
958
+ add(sh.running, `shipper${sh.via ? ` — ${sh.via}` : ''}`, 'memhouse start (or: memhouse service install)');
439
959
  add(!!pidOf('dashboard'), 'dashboard daemon', 'memhouse start');
440
960
  add(fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html')), 'dashboard UI built', 'built automatically by memhouse start');
441
961
 
@@ -447,11 +967,12 @@ async function cmdSearch() {
447
967
  if (!positional.length) { console.log('usage: memhouse search <terms…>'); return 2; }
448
968
  const cfg = resolveConfig();
449
969
  const needle = positional.join(' ').toLowerCase().replace(/[%_\\]/g, '\\$&').replace(/'/g, "\\'");
970
+ const r = await roomsFor(cfg);
450
971
  const rows = await chRows(cfg, `
451
972
  SELECT session_id, any(source) AS source, any(project) AS project,
452
973
  formatDateTime(max(ts), '%Y-%m-%d %H:%i') AS at, count() AS hits,
453
974
  substring(any(text), 1, 150) AS snippet
454
- FROM messages
975
+ FROM ${r.messages}
455
976
  WHERE text_ngram LIKE '%${needle}%'
456
977
  GROUP BY session_id ORDER BY max(ts) DESC LIMIT ${Number(flags.limit) || 10}`);
457
978
  if (JSON_OUT) return console.log(JSON.stringify(rows, null, 2));
@@ -490,16 +1011,55 @@ function cmdPlugins() {
490
1011
 
491
1012
  async function cmdReset() {
492
1013
  const cfg = resolveConfig();
1014
+ const r = await roomsFor(cfg);
1015
+ const targets = ROOM_TYPES.map((t) => r[t]);
493
1016
  if (flags.yes !== true) {
494
- const a = (await ask(`This truncates ALL rows in '${cfg.db}' and re-ships. Continue? (yes/no)`, 'no')).toLowerCase();
1017
+ // Name the rooms. Under the per-member layout this only ever empties the caller's own,
1018
+ // and "ALL rows in 'mem'" would misdescribe that in the alarming direction.
1019
+ const a = (await ask(`This truncates ${targets.join(', ')} in '${cfg.db}' and re-ships. Continue? (yes/no)`, 'no')).toLowerCase();
495
1020
  if (a !== 'yes' && a !== 'y') return console.log('aborted'), 1;
496
1021
  }
497
- for (const t of ['sessions', 'messages', 'tool_calls']) await ch(cfg, `TRUNCATE TABLE IF EXISTS ${t}`);
498
- console.log(ok('house truncated'));
1022
+ // DELETE, not TRUNCATE. Historically for two reasons:
1023
+ // * TRUNCATE is its own privilege, and a member provisioned under the pre-0.4 grant
1024
+ // set (SELECT, INSERT and the two ALTER grants) did not hold it — so `reset` failed
1025
+ // with an authorization error for every normally provisioned member. Today's grant
1026
+ // set is `ALL` on the member's own rooms, which does include TRUNCATE;
1027
+ // * on the removed SHARED layout TRUNCATE was worse than unauthorized, it was wrong:
1028
+ // the rooms held every member's rows, and a row policy scopes reads, not TRUNCATE.
1029
+ // One member resetting would have emptied the house.
1030
+ // Neither reason is load-bearing now that a member's room holds only their own rows,
1031
+ // but DELETE stays: it is correct in both cases and costs nothing here. The user_id
1032
+ // value is BOUND rather than `currentUser()`, which a mutation does not evaluate in the
1033
+ // caller's context and which therefore matches nothing at all.
1034
+ const uid = (await chRows(cfg, 'SELECT currentUser() AS u'))[0]?.u;
1035
+ if (!uid) return console.log(bad('could not determine currentUser() — refusing to reset')), 1;
1036
+ const esc = uid.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
1037
+ for (const t of targets) await ch(cfg, `DELETE FROM ${t} WHERE user_id = '${esc}'`);
1038
+ console.log(ok(`cleared ${uid}'s rows from ${targets.join(', ')}`));
499
1039
  return run(SHIP_JS, ['--full'], cfg);
500
1040
  }
501
1041
 
502
1042
  function cmdUninstall() {
1043
+ // The OS service first, and this is not tidiness: it outlives the pidfile daemons by
1044
+ // design, it holds the credential inlined in its unit file, and it restarts itself. An
1045
+ // uninstall that stopped only the daemons would report success while a service kept
1046
+ // shipping transcripts — with a credential in a file the user now believes is gone.
1047
+ const svc = require(path.join(REPO_ROOT, 'mem-house', 'service.js'));
1048
+ const st = svc.status();
1049
+ // `legacy` too: a machine that ran the solo tier can have a stale unit and no current
1050
+ // one, and skipping the service step there leaves it enabled over a deleted home.
1051
+ if (st.kind && (st.installed || (st.legacy || []).length)) {
1052
+ const r = svc.uninstall();
1053
+ if (!r.ok) {
1054
+ // Removing the home now would delete the env file while a service keeps shipping
1055
+ // with the credential inlined in its unit — and report that memhouse is gone.
1056
+ console.log(bad(`service NOT removed: ${r.msg}`));
1057
+ console.log(` ${HOME_DIR} left in place. Re-run uninstall once the service is stopped.`);
1058
+ process.exitCode = 1;
1059
+ return;
1060
+ }
1061
+ console.log(ok(`service removed (${r.kind})`));
1062
+ }
503
1063
  cmdStop();
504
1064
  if (fs.existsSync(HOME_DIR)) fs.rmSync(HOME_DIR, { recursive: true });
505
1065
  console.log(ok(`removed ${HOME_DIR} (the house data in ClickHouse is untouched)`));
@@ -508,6 +1068,8 @@ function cmdUninstall() {
508
1068
  // ── dispatch ────────────────────────────────────────────────────────────────────
509
1069
  (async () => {
510
1070
  const cfg = resolveConfig();
1071
+ if (legacySoloGuard()) { process.exitCode = 2; return; }
1072
+
511
1073
  switch (cmd) {
512
1074
  case null: case 'help': console.log(HELP); break;
513
1075
  case 'version': console.log(PKG.version); break;
@@ -548,9 +1110,289 @@ function cmdUninstall() {
548
1110
  case 'status': await cmdStatus(); break;
549
1111
  case 'doctor': process.exitCode = await cmdDoctor(); break;
550
1112
  case 'search': process.exitCode = await cmdSearch(); break;
1113
+ // The session rollup is a saved query, not an object, so there is no name an agent
1114
+ // or a skill can put in a FROM clause. This prints it, resolved for whoever the
1115
+ // configured credential is — the substitute for that name.
1116
+ case 'sessions-query': {
1117
+ const cfg = resolveConfig();
1118
+ const r = await roomsFor(cfg);
1119
+ console.log(r.sessions_v);
1120
+ if (!JSON_OUT) {
1121
+ console.error(`-- rollup for '${r.member}'. Read with final=1 and join_use_nulls=1.`);
1122
+ }
1123
+ break;
1124
+ }
551
1125
  case 'plugins': process.exitCode = cmdPlugins(); break;
552
1126
  case 'prompt': process.stdout.write(fs.readFileSync(path.join(DELIVERY, 'PROMPT.md'), 'utf-8')); break;
553
1127
  case 'reset': process.exitCode = await cmdReset(); break;
1128
+ case 'deploy': {
1129
+ const dep = require(path.join(REPO_ROOT, 'mem-house', 'deploy.js'));
1130
+ if (flags.down) {
1131
+ // A shipper service outlives the house it points at. Tearing down the container
1132
+ // and volume beneath it leaves the service retrying an endpoint that is gone —
1133
+ // and a later bare `deploy --local` mints a NEW password on the same port, which
1134
+ // the service will never learn, while `status` still reports it running.
1135
+ // Only a service that points at THIS house. `service install` is supported for
1136
+ // external/kernel houses too, and refusing on any installed unit
1137
+ // would make an unrelated production shipper block the cleanup of a stale local
1138
+ // container. Compare the unit's own inlined URL against the port this container
1139
+ // publishes; if that cannot be determined, fail closed.
1140
+ let svcCfg = { installed: false, url: null };
1141
+ try { svcCfg = require(path.join(REPO_ROOT, 'mem-house', 'service.js')).installedConfig(); } catch { /* unsupported */ }
1142
+ if (svcCfg.installed) {
1143
+ const pub = dep.publishedPort();
1144
+ // Host AND port. Port alone classified a service pointing at
1145
+ // `http://remote-house:8123` as targeting a local container publishing 8123,
1146
+ // so an unrelated production shipper blocked this teardown.
1147
+ const targetsThisHouse = !svcCfg.url || !pub || sameEndpoint(svcCfg.url, `http://localhost:${pub}`);
1148
+ if (targetsThisHouse) {
1149
+ console.log(bad(`a shipper service is installed and points at ${svcCfg.url || 'a house this command cannot identify'} — removing this house would leave it retrying a dead endpoint.`));
1150
+ console.log(' memhouse service uninstall, then memhouse deploy --down');
1151
+ process.exitCode = 2; break;
1152
+ }
1153
+ console.log(warn(`a shipper service is installed but points at ${svcCfg.url} — leaving it alone`));
1154
+ }
1155
+ const r = dep.down();
1156
+ console.log(r.ok ? ok(`local ClickHouse removed (${r.engine}${r.volumeRemoved ? ', volume included' : ''})`) : bad(r.msg));
1157
+ process.exitCode = r.ok ? 0 : 1;
1158
+ break;
1159
+ }
1160
+ // Two ports are in play and they are not the same port: the house speaks ClickHouse
1161
+ // HTTP, the dashboard serves the SPA. `--port` belongs to the dashboard everywhere
1162
+ // else in this CLI (it is persisted as MEMHOUSE_PORT), so deploy takes `--house-port`
1163
+ // and the two can never be handed the same number by accident.
1164
+ const housePort = flags['house-port'];
1165
+ if (flags.port !== undefined && housePort === undefined) {
1166
+ console.log(warn('deploy: --port is the dashboard port; use --house-port for ClickHouse'));
1167
+ }
1168
+ if (!flags.local) { console.log(bad('usage: memhouse deploy --local | --down')); process.exitCode = 2; break; }
1169
+ // VALIDATE FIRST, then act. Everything below that can refuse runs before anything
1170
+ // is stopped: a safety refusal that has already killed a healthy shipper and
1171
+ // dashboard is worse than the problem it is refusing, and in the missing-credential
1172
+ // case those processes may be the last things holding a usable connection.
1173
+ const priorCfg = resolveConfig();
1174
+ // Ask the engine everything `up()` would refuse for, BEFORE anything is stopped —
1175
+ // ownership of BOTH fixed names, and the image. `up()` checks all of it too, but by
1176
+ // then the shipper and the dashboard are dead, so a foreign container or a typo'd
1177
+ // tag costs a working pipeline to discover.
1178
+ const pre = dep.preflight({ tag: flags.tag || process.env.MEMHOUSE_CH_TAG || dep.managedTag() || dep.DEFAULT_TAG });
1179
+ if (!pre.ok) { console.log(bad(pre.msg)); process.exitCode = 1; break; }
1180
+ const initialised = pre.initialised;
1181
+ // The port this house will actually bind, decided ONCE and used everywhere below.
1182
+ //
1183
+ // The persisted URL is deliberately NOT in this chain. It can point at an external
1184
+ // house — a kernel realm, ClickHouse Cloud — and reusing a remote endpoint's port
1185
+ // as a local container binding is meaningless. What carries forward is the port an
1186
+ // existing MANAGED container publishes, which is the only thing that says "the
1187
+ // local house lives here".
1188
+ const managedPort = dep.publishedPort();
1189
+ const port = String(housePort || process.env.MEMHOUSE_CH_PORT || managedPort || 8123);
1190
+ const targetUrl = `http://localhost:${port}`;
1191
+ // `initialised` is a fact about the DOCKER VOLUME. `priorCfg` is a fact about
1192
+ // whatever the user last pointed at. Those are different things, and treating any
1193
+ // non-empty password as the volume's is how they get confused: with the config
1194
+ // repointed at an external house, the external credential would be handed to a
1195
+ // container that ignores it (the image applies CLICKHOUSE_PASSWORD only when it
1196
+ // initialises a data directory), auth would fail against the password the volume
1197
+ // actually holds, and the env file would be rewritten with the wrong one — losing
1198
+ // the working external config on the way past.
1199
+ //
1200
+ // So reuse only when the persisted config IS this local house: same endpoint as the
1201
+ // one we are about to bind. When it is not, fall through to the missing-credential
1202
+ // refusal below, which already says the right things.
1203
+ const configIsLocalHouse = !!priorCfg.url && sameEndpoint(priorCfg.url, targetUrl);
1204
+ const reusable = initialised && configIsLocalHouse && priorCfg.password ? priorCfg.password : null;
1205
+
1206
+ // Every way of asking for a different password against an existing house. The image
1207
+ // applies CLICKHOUSE_PASSWORD only when it INITIALISES a data directory, so any of
1208
+ // them would be written to the config and then rejected by the server — the lockout.
1209
+ // `--password` counts: it lands in priorCfg via resolveConfig, so without this it
1210
+ // would masquerade as the credential being reused.
1211
+ const rotateAsk = flags['rotate-password'] === true ? '--rotate-password'
1212
+ : (flags.password !== undefined && flags.password !== true) ? '--password'
1213
+ : process.env.MEMHOUSE_PASSWORD ? 'MEMHOUSE_PASSWORD' : null;
1214
+ if (initialised && rotateAsk) {
1215
+ console.log(bad(`${rotateAsk} cannot change the credential of an existing house — the image only applies it when it initialises the data directory.`));
1216
+ console.log(' to rotate: ALTER USER memhouse_root IDENTIFIED BY \'…\' inside the house, then: memhouse setup --password …');
1217
+ console.log(' to start over (DESTROYS the memory): memhouse deploy --down');
1218
+ process.exitCode = 2; break;
1219
+ }
1220
+ // An initialised volume with no credential to reuse — env file deleted, emptied, or
1221
+ // never written — is the same lockout by another route: a generated password would
1222
+ // be ignored by the server and then written over the config as if it worked.
1223
+ if (initialised && !reusable) {
1224
+ console.log(bad(`the managed volume '${dep.VOLUME}' already holds a house, but no credential for it is available.`));
1225
+ if (priorCfg.password && !configIsLocalHouse) {
1226
+ console.log(` ${ENV_FILE.replace(os.homedir(), '~')} holds a credential for ${priorCfg.url}, which is not this house —`);
1227
+ console.log(` reusing it would hand ${targetUrl} a password its volume was never initialised with.`);
1228
+ }
1229
+ console.log(' a generated one would be ignored by the server: the image sets the password only at first init.');
1230
+ console.log(` recover it from the old ${ENV_FILE.replace(os.homedir(), '~')}, or reset the user from inside the house,`);
1231
+ console.log(' or start over and lose the memory: memhouse deploy --down');
1232
+ process.exitCode = 2; break;
1233
+ }
1234
+ // Moving the local house has two hazards: an occupied destination, and a shipper
1235
+ // this loop cannot see. `port` and `targetUrl` are decided above, before the
1236
+ // credential-reuse question, because answering that question needs them.
1237
+ //
1238
+ // The running image tag carries forward too. A house deployed with `--tag 26.7` and
1239
+ // then bare-redeployed would have its healthy newer container removed and its
1240
+ // volume mounted into 25.11 — and an older ClickHouse may simply refuse data and
1241
+ // metadata a newer one wrote. Nothing persists the tag, so read it off the
1242
+ // container that is running.
1243
+ const tag = flags.tag || process.env.MEMHOUSE_CH_TAG || dep.managedTag() || dep.DEFAULT_TAG;
1244
+ // The database is part of the destination, not a detail of it — see the service
1245
+ // check below, which must compare it.
1246
+ const targetDb = flags.db || process.env.MEMHOUSE_DB || priorCfg.db || 'mem';
1247
+ {
1248
+ // A service-managed shipper keeps the environment it was installed with, so ANY
1249
+ // switch that repoints the config leaves it shipping somewhere else — not only an
1250
+ // explicit port move. A bare `deploy --local` over a config pointing at an
1251
+ // external house is the case that used to slip through: `to` was empty, the check
1252
+ // was skipped, and `status` would then report a running shipper beside counts
1253
+ // from a different house.
1254
+ let svcCfg = { installed: false, url: null };
1255
+ try { svcCfg = require(path.join(REPO_ROOT, 'mem-house', 'service.js')).installedConfig(); } catch { /* unsupported */ }
1256
+ // Endpoint AND database. Same URL with a different database is still somewhere
1257
+ // else: `deploy --local --db memories` over a service holding `mem` would pass a
1258
+ // URL-only check and split reads from service writes.
1259
+ const svcElsewhere = svcCfg.installed
1260
+ && (!sameEndpoint(svcCfg.url, targetUrl) || (svcCfg.db && svcCfg.db !== targetDb));
1261
+ if (svcElsewhere) {
1262
+ const where = svcCfg.url ? `${svcCfg.url} / ${svcCfg.db || '?'}` : 'an endpoint this command cannot read';
1263
+ console.log(bad(`the shipper is service-managed and holds ${where} — deploying to ${targetUrl} / ${targetDb} would leave it shipping there.`));
1264
+ console.log(' memhouse service uninstall, then deploy, then memhouse service install');
1265
+ process.exitCode = 2; break;
1266
+ }
1267
+ // And probe the destination before demolishing anything: `run -p` only discovers
1268
+ // the conflict after the old container is gone, and by then the shipper and the
1269
+ // dashboard have been stopped too.
1270
+ //
1271
+ // The only case that needs no probe is rebinding the port a managed container
1272
+ // ALREADY holds — that is not a conflict, it is the same house. Everything else,
1273
+ // including a first deployment and a switch from an external house, is a
1274
+ // destination we have not checked. Gating on `managedPort &&` skipped exactly
1275
+ // those.
1276
+ if (port !== managedPort && await portInUse(port)) {
1277
+ console.log(bad(managedPort
1278
+ ? `port ${port} is already in use — not moving the house off ${managedPort}.`
1279
+ : `port ${port} is already in use — nothing was started or stopped.`));
1280
+ console.log(' free that port, or pick another with --house-port.');
1281
+ process.exitCode = 2; break;
1282
+ }
1283
+ }
1284
+
1285
+ // Validation passed. From here the command changes things.
1286
+ // Detached clients hold a SNAPSHOT of the connection in their environment, taken
1287
+ // when they were spawned. Leaving them up across a tier switch means a shipper and
1288
+ // dashboard still using `default` with no password against a server that now wants
1289
+ // a credential — reported as running, failing every request. `memhouse start` brings
1290
+ // them back with the new config; the install line at the end already says to run it.
1291
+ for (const name of ['shipper', 'dashboard']) {
1292
+ const pid = pidOf(name);
1293
+ if (!pid) continue;
1294
+ console.log(warn(`stopping ${name} (pid ${pid}) — it holds the old connection`));
1295
+ try { process.kill(pid, 'SIGTERM'); } catch { /* raced */ }
1296
+ try { fs.unlinkSync(path.join(RUN_DIR, `${name}.pid`)); } catch { /* absent */ }
1297
+ }
1298
+ const pw = reusable || crypto.randomBytes(16).toString('hex');
1299
+ if (reusable) console.log(ok('reusing the existing house credential (its data volume is already initialised)'));
1300
+ const r = dep.up({ password: pw, port, tag });
1301
+ if (!r.ok) { console.log(bad(r.msg)); process.exitCode = 1; break; }
1302
+ console.log(ok(`ClickHouse starting via ${r.engine} on ${r.url} (loopback only)`));
1303
+
1304
+ // PERSIST THE CREDENTIAL BEFORE WAITING. The container may initialise the volume
1305
+ // and still not answer in time — it crashes and comes back later under
1306
+ // `--restart unless-stopped`, the host is loaded, the image is cold. If the only
1307
+ // copy of a generated password leaves with this process, the next `deploy --local`
1308
+ // finds an initialised volume with nothing to reuse and correctly refuses, and the
1309
+ // house that eventually came up is unreachable forever. The image applies the
1310
+ // password only at first init, so there is no way back from that.
1311
+ if (!reusable) {
1312
+ writeEnvFile({ ...priorCfg, url: r.url, user: 'memhouse_root', password: pw });
1313
+ console.log(ok(`credential saved to ${ENV_FILE} before waiting — the volume is initialised with it`));
1314
+ }
1315
+
1316
+ if (!(await dep.waitReady(r.url))) {
1317
+ console.log(bad(`ClickHouse did not answer on ${r.url} — check: ${r.engine} logs ${dep.CONTAINER}`));
1318
+ if (!reusable) {
1319
+ console.log(` the credential is already saved in ${ENV_FILE.replace(os.homedir(), '~')}; re-run`);
1320
+ console.log(' `memhouse deploy --local` once it is up and it will be reused, not regenerated.');
1321
+ }
1322
+ process.exitCode = 1; break;
1323
+ }
1324
+ console.log(ok('ClickHouse ready'));
1325
+ // A ROOTLESS container lives in the user's systemd slice, and that slice is torn
1326
+ // down at logout unless lingering is enabled — the container takes a SIGTERM and
1327
+ // the house goes down with it. Measured on a testbed VM: `deploy --local` over ssh
1328
+ // shipped 211 sessions, the ssh session ended, and the container was
1329
+ // `Exited (143)` twenty seconds later with `Linger=no`. The volume survives, so
1330
+ // nothing is lost, but the house is gone with no explanation anywhere.
1331
+ //
1332
+ // `service install` already detects exactly this for its own unit. The container
1333
+ // needs the same check, and podman is the case that matters: it is rootless by
1334
+ // default, where docker is conventionally a system daemon that outlives logout
1335
+ // (rootless docker has the same exposure, but is the deliberate minority).
1336
+ if (process.platform === 'linux' && r.engine === 'podman') {
1337
+ let lingering = true;
1338
+ try { lingering = require(path.join(REPO_ROOT, 'mem-house', 'service.js')).lingerEnabled(); } catch { /* assume fine */ }
1339
+ if (!lingering) {
1340
+ console.log(warn('rootless podman: this container stops when you log out (lingering is off).'));
1341
+ console.log(` loginctl enable-linger ${os.userInfo().username}`);
1342
+ console.log(' Until then, after a logout: memhouse deploy --local (the data volume persists)');
1343
+ }
1344
+ }
1345
+ flags.url = r.url; flags.user = 'memhouse_root'; flags.password = pw;
1346
+ flags.db = targetDb;
1347
+ flags.yes = true;
1348
+ process.exitCode = await cmdInstall({ interactive: false });
1349
+ break;
1350
+ }
1351
+ case 'service': {
1352
+ const svc = require(path.join(REPO_ROOT, 'mem-house', 'service.js'));
1353
+ const sub = positional[0] || 'status';
1354
+ if (sub === 'install') {
1355
+ // Ask what can be asked before killing the daemon this is taking over from. A
1356
+ // failed install used to leave the machine with no shipper for a condition that
1357
+ // was knowable up front.
1358
+ const pre = svc.preflight({ envFile: ENV_FILE });
1359
+ if (!pre.ok) { console.log(bad(pre.msg)); process.exitCode = 1; break; }
1360
+ // The service supersedes the pidfile daemons, and they are not merely redundant:
1361
+ // the shim binds a fixed port, so leaving the detached one alive makes the new
1362
+ // unit fail with EADDRINUSE and flap under Restart=on-failure. Hand over rather
1363
+ // than run both. The dashboard is not service-managed, so it is left alone.
1364
+ for (const name of ['shipper']) {
1365
+ const pid = pidOf(name);
1366
+ if (!pid) continue;
1367
+ try { process.kill(pid, 'SIGTERM'); console.log(ok(`${name} daemon stopped — the service takes it over (pid ${pid})`)); } catch { /* raced */ }
1368
+ try { fs.unlinkSync(path.join(RUN_DIR, `${name}.pid`)); } catch { /* absent */ }
1369
+ }
1370
+ const r = svc.install({
1371
+ shipJs: SHIP_JS, envFile: ENV_FILE, logDir: LOG_DIR, interval: flags.interval || 300,
1372
+ // Where this install lives. The env file records the connection, not the home
1373
+ // that contains it, and an adapter override may hang off the home.
1374
+ home: HOME_DIR,
1375
+ });
1376
+ if (!r.ok) { console.log(bad(r.msg)); process.exitCode = 1; break; }
1377
+ console.log(ok(`service installed (${r.kind}): ${r.path}`));
1378
+ console.log(r.warn ? ' starts at login; `memhouse start` is no longer needed'
1379
+ : ' survives reboot; `memhouse start` is no longer needed');
1380
+ if (r.warn) console.log(warn(r.warn));
1381
+ } else if (sub === 'uninstall') {
1382
+ const r = svc.uninstall();
1383
+ console.log(r.ok ? ok(`service removed (${r.kind})`) : bad(r.msg));
1384
+ // A refusal is a failure. Silence here told automation the credential-bearing
1385
+ // unit was gone while it was still installed and possibly still shipping.
1386
+ if (!r.ok) process.exitCode = 1;
1387
+ } else {
1388
+ const st = svc.status();
1389
+ if (!st.kind) { console.log(warn(`no service integration for '${process.platform}'`)); break; }
1390
+ console.log(st.installed ? ok(`service installed (${st.kind}): ${st.path}`) : warn('service not installed'));
1391
+ console.log(st.running ? ok('service running') : warn('service not running'));
1392
+ for (const f of st.legacy || []) console.log(warn(`stale unit from an older version: ${f} — remove with: memhouse service uninstall`));
1393
+ }
1394
+ break;
1395
+ }
554
1396
  case 'uninstall': cmdUninstall(); break;
555
1397
  default:
556
1398
  console.error(`unknown command: ${cmd}\n${HELP}`);