memhouse 0.18.12 → 0.20.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 (169) hide show
  1. package/AGENTS.md +22 -151
  2. package/CHANGELOG.md +263 -29
  3. package/LICENSE +202 -22
  4. package/LICENSES/agentlytics-MIT.txt +21 -0
  5. package/LICENSES/memhouse-MIT-until-0.18.11.txt +22 -0
  6. package/LICENSES/models.dev-MIT.txt +22 -0
  7. package/NOTICE +28 -0
  8. package/README.md +42 -20
  9. package/SECURITY.md +21 -8
  10. package/bin/memhouse.js +2223 -311
  11. package/docs/README.md +11 -9
  12. package/docs/guides/admin-password.md +37 -10
  13. package/docs/guides/configuration.md +13 -6
  14. package/docs/guides/daemons.md +64 -3
  15. package/docs/guides/import-claude-ai.md +204 -0
  16. package/docs/guides/install.md +11 -6
  17. package/docs/guides/invite.md +21 -7
  18. package/docs/guides/operate.md +114 -7
  19. package/docs/guides/search-and-resume.md +72 -3
  20. package/docs/guides/share.md +91 -52
  21. package/docs/guides/storage.md +44 -4
  22. package/docs/guides/troubleshooting.md +121 -14
  23. package/docs/guides/uninstall.md +4 -3
  24. package/docs/guides/update.md +16 -0
  25. package/docs/guides/upgrade-to-0.20.md +188 -0
  26. package/docs/releases/README.md +2 -1
  27. package/docs/releases/v0.18.10.md +5 -5
  28. package/docs/releases/v0.18.11.md +3 -3
  29. package/docs/releases/v0.20.0.md +627 -0
  30. package/docs/releases/v0.20.1.md +114 -0
  31. package/docs/tutorials/01-first-house.md +10 -7
  32. package/docs/tutorials/02-join-a-house.md +24 -15
  33. package/docs/tutorials/03-share-your-memory.md +40 -31
  34. package/editors/antigravity.js +1 -80
  35. package/editors/base.js +463 -9
  36. package/editors/bridge.js +119 -0
  37. package/editors/claude.js +273 -105
  38. package/editors/codex.js +278 -52
  39. package/editors/copilot.js +1 -70
  40. package/editors/cursor.js +1 -75
  41. package/editors/derive-fingerprints.json +18 -0
  42. package/editors/harness-paths.js +69 -0
  43. package/editors/index.js +1 -20
  44. package/editors/normalise/agy.js +910 -0
  45. package/editors/normalise/copilot-cli.js +321 -0
  46. package/editors/normalise/cursor-agent.js +852 -0
  47. package/editors/normalise/cursor.js +474 -0
  48. package/editors/normalise/devin.js +575 -0
  49. package/editors/normalise/gemini-cli.js +602 -0
  50. package/editors/normalise/goose.js +673 -0
  51. package/editors/normalise/opencode.js +633 -0
  52. package/editors/normalise/vscode.js +549 -0
  53. package/editors/normalise/zed.js +473 -0
  54. package/editors/opencode-v4.js +119 -0
  55. package/editors/scope.js +14 -3
  56. package/editors/sessions-log.js +349 -0
  57. package/editors/sqlite.js +28 -1
  58. package/editors/vscode.js +1 -72
  59. package/editors/windsurf.js +1 -143
  60. package/examples/01-local-one-machine/README.md +24 -19
  61. package/examples/01-local-one-machine/run.sh +1 -1
  62. package/examples/02-existing-clickhouse/README.md +30 -22
  63. package/examples/02-existing-clickhouse/run.sh +5 -4
  64. package/examples/03-team-house/README.md +44 -37
  65. package/examples/03-team-house/run.sh +35 -18
  66. package/examples/04-self-hosted-team/README.md +29 -14
  67. package/examples/04-self-hosted-team/compose.yml +5 -2
  68. package/examples/04-self-hosted-team/smoke.sh +26 -12
  69. package/examples/README.md +8 -4
  70. package/examples/fixtures/README.md +4 -1
  71. package/examples/fixtures/make-sessions.js +8 -5
  72. package/memhouse/admin-secret.js +89 -24
  73. package/memhouse/argv-secrets.js +64 -0
  74. package/memhouse/channel.js +49 -9
  75. package/memhouse/codex-plugin.js +194 -0
  76. package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
  77. package/memhouse/delivery/AGENT-INSTALL.md +22 -11
  78. package/memhouse/delivery/INVITATION.md.tpl +44 -45
  79. package/memhouse/delivery/PROMPT.md +53 -5
  80. package/memhouse/delivery/UPGRADE.md.tpl +1 -1
  81. package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
  82. package/memhouse/delivery/codex/SKILL.md +50 -0
  83. package/memhouse/delivery/kernel-install.md +17 -9
  84. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
  85. package/memhouse/delivery/plugin/reference/HOUSE.md +168 -41
  86. package/memhouse/delivery/plugin/skills/access/SKILL.md +13 -5
  87. package/memhouse/delivery/plugin/skills/admin/SKILL.md +14 -6
  88. package/memhouse/delivery/plugin/skills/house/SKILL.md +25 -8
  89. package/memhouse/delivery/plugin/skills/recall/SKILL.md +66 -8
  90. package/memhouse/delivery/plugin/skills/sql/SKILL.md +66 -7
  91. package/memhouse/deploy.js +1 -1
  92. package/memhouse/flags.js +35 -18
  93. package/memhouse/help.js +37 -0
  94. package/memhouse/host.js +82 -2
  95. package/memhouse/house/HOUSE.md +2 -2
  96. package/memhouse/house/convert-profile.js +638 -0
  97. package/memhouse/house/convert-v3.js +742 -0
  98. package/memhouse/house/doctor-v3.js +324 -0
  99. package/memhouse/house/host-alias.js +85 -0
  100. package/memhouse/house/house.js +3 -3
  101. package/memhouse/house/migrate.js +2 -2
  102. package/memhouse/house/profile-dedupe.js +183 -0
  103. package/memhouse/house/profile-edges.js +554 -0
  104. package/memhouse/house/profile-house.js +430 -0
  105. package/memhouse/house/profile-rollup.js +190 -0
  106. package/memhouse/house/q-cli.js +863 -0
  107. package/memhouse/house/queries-v3.js +1013 -0
  108. package/memhouse/house/reconcile-v3.js +192 -0
  109. package/memhouse/house/records-to-profile.js +174 -0
  110. package/memhouse/house/records-to-rows.js +169 -0
  111. package/memhouse/house/relocate.js +2 -1
  112. package/memhouse/house/schema-choice.js +121 -0
  113. package/memhouse/house/schema-profile.js +290 -0
  114. package/memhouse/house/schema-profile.sql.tpl +398 -0
  115. package/memhouse/house/schema-v3.js +267 -0
  116. package/memhouse/house/schema-v3.sql.tpl +213 -0
  117. package/memhouse/import/claude-ai.js +420 -0
  118. package/memhouse/import/import.js +372 -0
  119. package/memhouse/import/pyjson.js +206 -0
  120. package/memhouse/import/records.js +64 -0
  121. package/memhouse/import/writer-profile.js +192 -0
  122. package/memhouse/import/writer-v3.js +240 -0
  123. package/memhouse/import/zip.js +112 -0
  124. package/memhouse/invitation.js +102 -0
  125. package/memhouse/invitefile.js +5 -2
  126. package/memhouse/plugin-copies.js +176 -0
  127. package/memhouse/provision.js +7 -2
  128. package/memhouse/release-hint.js +24 -0
  129. package/memhouse/resume.js +4 -4
  130. package/memhouse/server/api-v3.js +632 -0
  131. package/memhouse/server/dashboard-auth.js +181 -0
  132. package/memhouse/server/dashboard-port.js +91 -0
  133. package/memhouse/server/queries.js +30 -0
  134. package/memhouse/server/server.js +118 -20
  135. package/memhouse/service.js +1 -1
  136. package/memhouse/share.js +687 -1
  137. package/memhouse/shipper/adapter-capture.js +756 -0
  138. package/memhouse/shipper/capture.js +2571 -0
  139. package/memhouse/shipper/dedupe-profile.js +237 -0
  140. package/memhouse/shipper/dedupe-v3.js +1027 -0
  141. package/memhouse/shipper/forks.js +233 -0
  142. package/memhouse/shipper/log-adapter.js +219 -0
  143. package/memhouse/shipper/maintenance.js +49 -0
  144. package/memhouse/shipper/normalise-run.js +148 -0
  145. package/memhouse/shipper/passlog.js +68 -5
  146. package/memhouse/shipper/profile-capture.js +903 -0
  147. package/memhouse/shipper/ship.js +91 -8
  148. package/memhouse/shipper/snapshots.js +193 -0
  149. package/memhouse/shipper/source-roots.js +111 -0
  150. package/memhouse/version-notice.js +66 -0
  151. package/package.json +14 -7
  152. package/public/THIRD_PARTY_NOTICES.txt +2 -2
  153. package/public/assets/index-CcNnhfM3.js +78 -0
  154. package/public/index.html +1 -1
  155. package/ui/package-lock.json +56 -37
  156. package/ui/package.json +1 -0
  157. package/ui/src/App.jsx +29 -14
  158. package/ui/src/components/ChatSidebar.jsx +69 -12
  159. package/ui/src/lib/api.js +76 -0
  160. package/ui/src/lib/dashboard-token.js +41 -0
  161. package/ui/src/main.jsx +22 -5
  162. package/ui/src/pages/Dashboard.jsx +22 -36
  163. package/ui/third-party-notices.js +119 -0
  164. package/ui/vite.config.js +5 -1
  165. package/docs/releases/v0.18.12.md +0 -59
  166. package/memhouse/COMPETITION.md +0 -221
  167. package/memhouse/DESIGN.md +0 -222
  168. package/memhouse/RESUME-AND-UPDATE.md +0 -182
  169. package/public/assets/index-CnWzrjF3.js +0 -78
package/bin/memhouse.js CHANGED
@@ -28,14 +28,19 @@ const {
28
28
  SCHEMA_VERSION, MIN_WRITER_SCHEMA, MIGRATIONS, META_TYPES, createStatement,
29
29
  } = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
30
30
  const envfile = require(path.join(REPO_ROOT, 'memhouse', 'envfile'));
31
+ const schemaChoice = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-choice'));
31
32
  const chan = require(path.join(REPO_ROOT, 'memhouse', 'channel'));
32
33
  const { consumeSecretChunk } = require(path.join(REPO_ROOT, 'memhouse', 'secret-input'));
33
34
  const { capabilitiesFrom } = require(path.join(REPO_ROOT, 'memhouse', 'capabilities'));
34
35
  const { unknownFlags, allowedFlags, suggestFlag } = require(path.join(REPO_ROOT, 'memhouse', 'flags'));
36
+ const dashAuth = require(path.join(REPO_ROOT, 'memhouse', 'server', 'dashboard-auth'));
37
+ const dashPort = require(path.join(REPO_ROOT, 'memhouse', 'server', 'dashboard-port'));
35
38
 
36
39
  const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
37
40
  const ENV_FILE = path.join(HOME_DIR, 'env');
38
41
  const RUN_DIR = path.join(HOME_DIR, 'run');
42
+ // How long an invite's password works unless --expires says otherwise.
43
+ const INVITE_DAYS = 7;
39
44
  const LOG_DIR = path.join(HOME_DIR, 'logs');
40
45
 
41
46
  // ── args ────────────────────────────────────────────────────────────────────────
@@ -45,6 +50,18 @@ const rest = cmd ? argv.slice(1) : argv;
45
50
  const { flags, positional } = require(path.join(REPO_ROOT, 'memhouse', 'flags')).parseArgv(rest);
46
51
  const JSON_OUT = flags.json === true;
47
52
 
53
+ // An admin password on argv is in `ps` while the command runs and in shell history after it.
54
+ // Deprecated in 0.18.10 and removed in 0.20.0: refused here, before any command reads it,
55
+ // with the ways that keep it off the command line. The value is never echoed.
56
+ if (flags['admin-password'] !== undefined) {
57
+ const { SAFE_FORMS } = require(path.join(REPO_ROOT, 'memhouse', 'admin-secret'));
58
+ console.error('memhouse: --admin-password on the command line was removed in 0.20.0: it shows in `ps` and in shell history.');
59
+ console.error(' Give the admin password one of these ways:');
60
+ for (const l of SAFE_FORMS) console.error(l);
61
+ console.error(' Nothing ran.');
62
+ process.exit(2);
63
+ }
64
+
48
65
  // Unknown flags stop the run — see memhouse/flags.js for why a shrug is not enough.
49
66
  (() => {
50
67
  const bad = unknownFlags(cmd, flags);
@@ -61,6 +78,42 @@ const JSON_OUT = flags.json === true;
61
78
  process.exit(2);
62
79
  })();
63
80
 
81
+ // The other passwords a command takes (--password, --member-password, --to-password): a
82
+ // --<flag>-file or an environment value becomes the flag the command reads; a value given on
83
+ // argv still works in 0.20, with a warning on every run. memhouse/argv-secrets.js.
84
+ // SECRETS_ON_ARGV: which of them were typed on the command line, as opposed to filled in.
85
+ let SECRETS_ON_ARGV = new Set();
86
+ try {
87
+ const r = require(path.join(REPO_ROOT, 'memhouse', 'argv-secrets')).intake(flags, { cmd });
88
+ for (const w of r.warnings) console.error(`memhouse: ⚠ ${w}`);
89
+ SECRETS_ON_ARGV = new Set(r.fromArgv);
90
+ } catch (e) {
91
+ console.error(`memhouse: ${e.message}`);
92
+ console.error(' Nothing ran.');
93
+ process.exit(2);
94
+ }
95
+
96
+ // A new minor version's first run here says so, once, with its release notes
97
+ // (memhouse/version-notice.js). stderr only, like the skills refresh below. It runs first: it reads
98
+ // the version that refresh records (skills-checked) before the refresh moves it.
99
+ require(path.join(REPO_ROOT, 'memhouse', 'version-notice')).checkVersionNotice({
100
+ home: process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse'),
101
+ version: require(path.join(REPO_ROOT, 'package.json')).version, repoRoot: REPO_ROOT,
102
+ log: (l) => console.error(`memhouse: ${l.replace(os.homedir(), '~')}`),
103
+ });
104
+ // A new version's first run refreshes the /mem skills bound to this home (an npm upgrade does
105
+ // not run `update`, which used to be the only refresh). After that, one stamp read per call.
106
+ // stderr only, so --json output stays clean. memhouse/plugin-copies.js.
107
+ {
108
+ const r = require(path.join(REPO_ROOT, 'memhouse', 'plugin-copies')).refreshIfStale({
109
+ home: process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse'),
110
+ version: require(path.join(REPO_ROOT, 'package.json')).version,
111
+ deliveryDir: path.join(REPO_ROOT, 'memhouse', 'delivery'), repoRoot: REPO_ROOT,
112
+ log: (l) => console.error(`memhouse: ${l.replace(os.homedir(), '~')}`),
113
+ });
114
+ for (const e of r.errors) console.error(`memhouse: could not refresh the /mem skills: ${e} — run: memhouse plugins install claude`);
115
+ }
116
+
64
117
  let ONBOARDING = false;
65
118
  let _inviteFileToShred = null;
66
119
  let _inviteWantsRotate = false;
@@ -70,6 +123,12 @@ function readEnvFile() {
70
123
  try { return envfile.parse(fs.readFileSync(ENV_FILE, 'utf-8')); } catch { return {}; }
71
124
  }
72
125
 
126
+ function passwordFromFile(f) {
127
+ if (!f) return '';
128
+ try { return fs.readFileSync(String(f).replace(/^~(?=$|\/)/, os.homedir()), 'utf-8').replace(/\r?\n$/, ''); }
129
+ catch (e) { console.error(warn(`MEMHOUSE_PASSWORD_FILE ${f}: ${e.code || e.message} — no password read`)); return ''; }
130
+ }
131
+
73
132
  function resolveConfig() {
74
133
  const file = readEnvFile();
75
134
  const pick = (flag, env, dflt) => flags[flag] !== undefined && flags[flag] !== true
@@ -79,7 +138,11 @@ function resolveConfig() {
79
138
  return {
80
139
  url: pick('url', 'MEMHOUSE_URL', 'http://localhost:8123'),
81
140
  user: pick('user', 'MEMHOUSE_USER', 'memhouse_root'),
82
- password: pick('password', 'MEMHOUSE_PASSWORD', ''),
141
+ // MEMHOUSE_PASSWORD_FILE: the password by reference (a root/owner-only
142
+ // file) — read only when no password is given directly. Never written back anywhere.
143
+ password: pick('password', 'MEMHOUSE_PASSWORD', '') || passwordFromFile(process.env.MEMHOUSE_PASSWORD_FILE ?? file.MEMHOUSE_PASSWORD_FILE),
144
+ // Remembered so a rewrite of the env file keeps the REFERENCE and never inlines the value.
145
+ passwordFile: pick('password', 'MEMHOUSE_PASSWORD', '') ? '' : (process.env.MEMHOUSE_PASSWORD_FILE ?? file.MEMHOUSE_PASSWORD_FILE ?? ''),
83
146
  // The house defaults to the USER'S OWN NAME (`sam` ships into `sam.messages`),
84
147
  // because a house is a database and the natural first house is your own. Any name
85
148
  // works — user 'alice' with house 'default' is a supported pairing.
@@ -92,20 +155,34 @@ function resolveConfig() {
92
155
  // invite/members need no --admin-* flags. Absent on an invited member's machine, and
93
156
  // nothing here invents one.
94
157
  adminUser: pick('admin-user', 'MEMHOUSE_ADMIN_USER', ''),
95
- adminPassword: pick('admin-password', 'MEMHOUSE_ADMIN_PASSWORD', ''),
158
+ // Never from a flag: --admin-password is refused at startup (memhouse/admin-secret.js).
159
+ adminPassword: process.env.MEMHOUSE_ADMIN_PASSWORD ?? file.MEMHOUSE_ADMIN_PASSWORD ?? '',
96
160
  // What this install ships — editors/scope.js. Empty means everything on the machine.
97
161
  editors: pick('editors', 'MEMHOUSE_EDITORS', ''),
98
162
  claudeRoots: pick('claude-roots', 'MEMHOUSE_CLAUDE_ROOTS', ''),
99
163
  // The npm dist-tag `memhouse update` follows. Empty = inferred from the installed
100
164
  // version (memhouse/channel.js). Set for a house on a build that is not `latest`.
101
165
  channel: pick('channel', 'MEMHOUSE_CHANNEL', ''),
166
+ // Which tables this install reads and writes: '3' = harness-sessions-sql's profile (sessions,
167
+ // session_parts, session_usage, session_links, session_lifecycle, calls + the capture
168
+ // archive's session_units and session_sources); anything
169
+ // else = the 0.18 rooms. Persisted in the env file, so every child — shipper, dashboard,
170
+ // service unit — and every later command agree on it.
171
+ schema: pick('schema', 'MEMHOUSE_SCHEMA', ''),
172
+ // What the schema-3 capture reads. Empty = the machine's default (memhouse/shipper/
173
+ // source-roots.js: the same Claude Code and Codex dirs the 0.18 shipper reads).
174
+ sourceRoots: pick('source-roots', 'MEMHOUSE_SOURCE_ROOTS', ''),
102
175
  // Did anything actually SAY which house this is, or are the values above just the
103
176
  // defaults? The defaults are not neutral — localhost:8123 as memhouse_root is a real
104
- // house on a lot of machines, usually the pilot's own. An agent whose config went
177
+ // house on a lot of machines, usually the user's own. An agent whose config went
105
178
  // missing (a temp HOME, a deleted env file, a service running as another user) would
106
179
  // otherwise connect there and read or write someone else's memory, silently and with
107
180
  // a plausible-looking result. Commands that expect YOUR house call requireConfig().
108
181
  stated: stated('url', 'MEMHOUSE_URL') || stated('user', 'MEMHOUSE_USER'),
182
+ // The two apart: an admin step needs the URL said, and takes the member names as its
183
+ // credential only when they were said (adminStepConfig), never the memhouse_root default.
184
+ urlStated: stated('url', 'MEMHOUSE_URL'),
185
+ userStated: stated('user', 'MEMHOUSE_USER'),
109
186
  };
110
187
  }
111
188
 
@@ -136,6 +213,47 @@ function requireConfig(cfg, what) {
136
213
  process.exit(2);
137
214
  }
138
215
 
216
+ /** This machine's `host` in the install's own layout: sessions-model/host/v0 on schema 3 (what
217
+ * the capture writes, MEMHOUSE_HOST_ID first, as there), the 0.18 identity on the rooms. */
218
+ function machineHost(cfg) {
219
+ const H = require(path.join(REPO_ROOT, 'memhouse', 'host.js'));
220
+ if (!isV3(cfg)) return H.identity(HOME_DIR);
221
+ return { id: process.env.MEMHOUSE_HOST_ID || H.sessionsHost(), label: H.hostLabel(), renamed: false };
222
+ }
223
+
224
+ /** Is this install on schema 3? Only the literal '3' — anything else is the 0.18 rooms. */
225
+ function isV3(cfg) { return String(cfg && cfg.schema) === '3'; }
226
+
227
+ /** The HTTP transport the schema-3 reads use (memhouse/house/q-cli.js): never `final=1`. */
228
+ function v3Transport(cfg, { timeout = 60000 } = {}) {
229
+ return require(path.join(REPO_ROOT, 'memhouse', 'house', 'q-cli')).httpTransport({
230
+ url: cfg.url, user: cfg.user, password: cfg.password, db: cfg.db, timeout,
231
+ });
232
+ }
233
+
234
+ // The member's v3 tables, as a queries-v3 `rooms` object qualified with the house database.
235
+ // `member` defaults to whoever the credential is (currentUser()); --member reads a housemate's
236
+ // tables the caller was granted.
237
+ const _v3rooms = new Map();
238
+ async function v3RoomsFor(cfg, t, member = null) {
239
+ const Qv3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'queries-v3'));
240
+ // MEMHOUSE_MEMBER: whose tables, when the credential is not the member's own (an operator
241
+ // reading a member's house, as the dashboard and the capture already honour it).
242
+ member = member || process.env.MEMHOUSE_MEMBER || readEnvFile().MEMHOUSE_MEMBER || null;
243
+ // Each member's machines under their 0.18 id fold into the machine (host-alias.js), from that
244
+ // member's own meta: a housemate's share does not grant it, so theirs simply do not fold.
245
+ const withAliases = async (rooms) => Object.assign(rooms, { hostAliases: await require(path.join(REPO_ROOT, 'memhouse', 'house', 'host-alias')).load((sql) => t.q.rows(sql), rooms) });
246
+ if (member) return withAliases(Qv3.v3Rooms(member, cfg.db));
247
+ const key = JSON.stringify([cfg.url, cfg.user, cfg.db]);
248
+ if (!_v3rooms.has(key)) {
249
+ const rows = await t.q.rows('SELECT currentUser() AS u');
250
+ const me = rows[0] && rows[0].u;
251
+ if (!me) throw new Error('could not determine currentUser()');
252
+ _v3rooms.set(key, await withAliases(Qv3.v3Rooms(me, cfg.db)));
253
+ }
254
+ return _v3rooms.get(key);
255
+ }
256
+
139
257
  function childEnv(cfg, override = {}) {
140
258
  const env = {
141
259
  ...process.env,
@@ -146,7 +264,10 @@ function childEnv(cfg, override = {}) {
146
264
  // Always set, even when empty: a child must ship THIS install's scope, never one
147
265
  // inherited from whatever shell started it.
148
266
  MEMHOUSE_EDITORS: cfg.editors || '', MEMHOUSE_CLAUDE_ROOTS: cfg.claudeRoots || '',
149
- // environment would write into another member's rooms.
267
+ // The schema, always set for the same reason: a shipper started from a shell that exported
268
+ // MEMHOUSE_SCHEMA=3 must not run the capture for a schema-2 install, or the reverse. Empty
269
+ // source roots = the capture's own default (source-roots.js).
270
+ MEMHOUSE_SCHEMA: isV3(cfg) ? '3' : '', MEMHOUSE_SOURCE_ROOTS: isV3(cfg) ? (cfg.sourceRoots || '') : '',
150
271
  ...override,
151
272
  };
152
273
  // A daemon never needs the admin credential. Inherited from an exported shell variable
@@ -165,8 +286,10 @@ function writeEnvFile(cfg) {
165
286
  '# memhouse connection — written by `memhouse install/setup`',
166
287
  `MEMHOUSE_URL=${sq(cfg.url)}`,
167
288
  `MEMHOUSE_USER=${sq(cfg.user)}`,
168
- `MEMHOUSE_PASSWORD=${sq(cfg.password)}`,
289
+ ...(cfg.passwordFile && cfg.password === passwordFromFile(cfg.passwordFile)
290
+ ? [`MEMHOUSE_PASSWORD_FILE=${sq(cfg.passwordFile)}`] : [`MEMHOUSE_PASSWORD=${sq(cfg.password)}`]),
169
291
  `MEMHOUSE_DB=${sq(cfg.db)}`,
292
+ '# The dashboard on this machine (memhouse start). The house\'s port is part of MEMHOUSE_URL.',
170
293
  `MEMHOUSE_PORT=${sq(cfg.port)}`,
171
294
  ...(cfg.editors || cfg.claudeRoots ? [
172
295
  '# What this install ships. Empty or absent means everything on this machine.',
@@ -177,6 +300,14 @@ function writeEnvFile(cfg) {
177
300
  '# The npm channel `memhouse update` follows (a dist-tag). Absent = latest.',
178
301
  `MEMHOUSE_CHANNEL=${sq(cfg.channel)}`,
179
302
  ] : []),
303
+ ...(isV3(cfg) ? [
304
+ '# Schema 3: harness-sessions-sql\'s profile tables and the capture archive.',
305
+ 'MEMHOUSE_SCHEMA=3',
306
+ ...(cfg.sourceRoots ? [
307
+ '# What the capture reads (absent = every Claude Code and Codex dir the 0.18 shipper reads).',
308
+ `MEMHOUSE_SOURCE_ROOTS=${sq(cfg.sourceRoots)}`,
309
+ ] : []),
310
+ ] : []),
180
311
  ...(cfg.adminUser ? [
181
312
  '# The admin credential for this house — present because this machine administers it',
182
313
  '# (deploy --local, or an operator install). invite/members use it; nothing else does.',
@@ -206,7 +337,14 @@ async function ch(cfg, sql, { database = cfg.db, settings = null, timeout = 3000
206
337
  signal: AbortSignal.timeout(timeout),
207
338
  });
208
339
  const text = await res.text();
209
- if (!res.ok) throw new Error(text.trim().split('\n')[0]);
340
+ if (!res.ok) {
341
+ const err = new Error(text.trim().split('\n')[0]);
342
+ // ClickHouse's own exception code, so a caller tells an authentication failure (516) from any
343
+ // other refusal by number, never by its prose (schema-choice.js isAuthFailure).
344
+ const code = Number(res.headers.get('x-clickhouse-exception-code'));
345
+ if (code) err.chCode = code;
346
+ throw err;
347
+ }
210
348
  return text.trim();
211
349
  }
212
350
  /**
@@ -270,15 +408,7 @@ function portOf(u) {
270
408
  // healthy machine. Returns { running, via }.
271
409
  function shipperHealth() {
272
410
  // "running" is the pid; whether it is DOING anything is the log. Both, always.
273
- const lastPass = (() => {
274
- try {
275
- const passlog = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog'));
276
- const f = path.join(LOG_DIR, 'shipper.log'); const st = fs.statSync(f);
277
- const fd = fs.openSync(f, 'r'); const len = Math.min(st.size, 65536); const buf = Buffer.alloc(len);
278
- fs.readSync(fd, buf, 0, len, st.size - len); fs.closeSync(fd);
279
- return passlog.lastPass(buf.toString('utf-8'));
280
- } catch { return { outcome: null }; }
281
- })();
411
+ const lastPass = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).readLastPass(LOG_DIR);
282
412
  const pid = pidOf('shipper');
283
413
  if (pid) return { running: true, via: `daemon (pid ${pid})`, lastPass };
284
414
  try {
@@ -289,6 +419,29 @@ function shipperHealth() {
289
419
  return { running: false, via: null, lastPass };
290
420
  }
291
421
 
422
+ // A daemon reads its settings and its code once, when it starts. `migrate --v3-switch` rewrites
423
+ // the env file and `npm install -g` replaces the package under a running shipper, and the old
424
+ // process keeps shipping with what it read: status then showed its failing passes as a "•" line
425
+ // and exited 0. The pid file is written when the daemon is spawned, so its mtime is the start.
426
+ // The daemon re-reads the env file itself when it adopts a moved house or a newer credential,
427
+ // and records that in run/shipper.env-read (ship.js noteEnvRead): the env file is compared with
428
+ // the later of the two. Returns what changed, or null (no daemon, a service-managed shipper,
429
+ // or nothing).
430
+ function shipperStale() {
431
+ if (!pidOf('shipper')) return null;
432
+ let started;
433
+ try { started = fs.statSync(path.join(RUN_DIR, 'shipper.pid')).mtimeMs; } catch { return null; }
434
+ let envRead = started;
435
+ try { envRead = Math.max(started, fs.statSync(path.join(RUN_DIR, 'shipper.env-read')).mtimeMs); } catch { /* never re-read */ }
436
+ const after = (p, since) => { try { return fs.statSync(p).mtimeMs > since + 1000; } catch { return false; } };
437
+ const reasons = [];
438
+ if (after(ENV_FILE, envRead)) reasons.push(`${short(ENV_FILE)} changed after it ${envRead > started ? 'last read it' : 'started'}`);
439
+ // A checkout's directory changes whenever a file is added; only an installed package's means
440
+ // it was installed (npm writes the package directory at install time).
441
+ if (!fs.existsSync(path.join(REPO_ROOT, '.git')) && after(REPO_ROOT, started)) reasons.push(`memhouse ${PKG.version} was installed after it started`);
442
+ return reasons.length ? reasons : null;
443
+ }
444
+
292
445
  // Room routing for the CLI's own queries. The shipper and dashboard resolve rooms through
293
446
  // @clickhouse/client; the CLI speaks raw HTTP, so it asks the same question over its own
294
447
  // transport and builds the names with the same shared function.
@@ -361,9 +514,9 @@ function netReason(e) {
361
514
  // printed it back on the terminal — in a wizard whose whole audience is someone typing a
362
515
  // credential in front of whoever is in the room.
363
516
  // The admin password for `adminUser`: --admin-password-file (or - for stdin), then
364
- // --admin-password (warned), then MEMHOUSE_ADMIN_PASSWORD, then the stored credential
365
- // of that SAME admin, then a TTY prompt. null = none, and the caller has been told how
366
- // to give one without putting it on the command line. See memhouse/admin-secret.js.
517
+ // MEMHOUSE_ADMIN_PASSWORD, then the stored credential of that SAME admin, then a TTY
518
+ // prompt. null = none, and the caller has been told how to give one without putting it on
519
+ // the command line. --admin-password is refused at startup. See memhouse/admin-secret.js.
367
520
  async function adminPasswordFor(adminUser, out = {}) {
368
521
  const as = require(path.join(REPO_ROOT, 'memhouse', 'admin-secret'));
369
522
  const file = readEnvFile();
@@ -373,8 +526,6 @@ async function adminPasswordFor(adminUser, out = {}) {
373
526
  adminUser, flags, stored: { user: file.MEMHOUSE_ADMIN_USER, password: file.MEMHOUSE_ADMIN_PASSWORD },
374
527
  });
375
528
  } catch (e) { console.log(bad(e.message)); return null; }
376
- // stderr: `members --json` and friends must emit nothing but JSON on stdout.
377
- if (r.source === 'flag') console.error(warn(as.ARGV_WARNING));
378
529
  out.source = r.source;
379
530
  if (r.password !== undefined) return r.password;
380
531
  if (process.stdin.isTTY) {
@@ -426,10 +577,16 @@ function pidOf(name) {
426
577
  }
427
578
 
428
579
  // Signal 0 tests for existence without delivering anything.
429
- // The port the RUNNING dashboard was started with, not the one currently configured.
580
+ // The port the RUNNING dashboard listens on (it records it; memhouse/server/dashboard-port.js),
581
+ // not the one currently configured.
430
582
  function runningPort(cfg) {
431
- try { return fs.readFileSync(path.join(RUN_DIR, 'dashboard.port'), 'utf-8').trim() || cfg.port; }
432
- catch { return cfg.port; }
583
+ return dashPort.readPort(HOME_DIR) || cfg.port;
584
+ }
585
+
586
+ /** A memhouse command as the user would type it for THIS home: an install under MEMHOUSE_HOME is
587
+ * addressed through it, or the command would act on the default home. */
588
+ function selfCmd(sub) {
589
+ return process.env.MEMHOUSE_HOME ? `MEMHOUSE_HOME=${process.env.MEMHOUSE_HOME.replace(os.homedir(), '~')} memhouse ${sub}` : `memhouse ${sub}`;
433
590
  }
434
591
 
435
592
  function alive(pid) {
@@ -454,14 +611,20 @@ memhouse ${PKG.version} — agent conversation memory across 17 editors
454
611
 
455
612
  Setup onboard interactive wizard: discover → configure → ship → start
456
613
  install scriptable setup (--url --user --password --db [--yes] [--no-ship])
614
+ a NEW house is schema 3 (harness-sessions-sql's profile + capture
615
+ archive); joining an existing house follows its schema
616
+ (--schema 2|3 states it; refused if it contradicts the
617
+ house). --source-roots DIRS pins what the capture reads
618
+ (default: every Claude Code and Codex dir)
457
619
  --print-sql print the SQL, run it yourself
458
620
  with admin: --admin-user NAME [--member NAME] builds the
459
621
  member + grants, then verifies as the member. The admin
460
622
  password comes from MEMHOUSE_ADMIN_PASSWORD (with-secret),
461
- --admin-password-file FILE|- (stdin), or a prompt; plain
462
- --admin-password works but shows in ps/history. It is NOT
623
+ --admin-password-file FILE|- (stdin), or a prompt; never
624
+ the command line (--admin-password is refused). It is NOT
463
625
  saved unless --keep-admin (then invite needs no flags).
464
- --env FILE installs from an invite file (see: invite)
626
+ --env FILE installs from an invite file (see: invite) and changes
627
+ its password; --keep-password keeps the invited one
465
628
  invite <name> mint a member and write the env file their install needs
466
629
  (--url [--db NAME] [--out FILE]; --admin-user — password
467
630
  as for install — only if this install keeps no admin
@@ -469,7 +632,9 @@ Setup onboard interactive wizard: discover → configure →
469
632
  and one grant on exactly those. Refuses a name whose rooms
470
633
  already hold messages; --adopt takes them over (same
471
634
  person, new credential). Not an admin? --print-sql prints
472
- the plan to hand to whoever is.
635
+ the plan to hand to whoever is. The invited password
636
+ expires after --expires DAYS (default 7); install's
637
+ password change lifts it
473
638
  passwd rotate this member's password + rewrite the env file
474
639
  (admin-assisted: --admin-user, password as for install)
475
640
  setup (re)write the connection config only (--yes = no prompts)
@@ -488,17 +653,35 @@ Setup onboard interactive wizard: discover → configure →
488
653
  reset clear the shipper's rows and re-ship everything (--yes to skip confirm)
489
654
  imported rows are kept; --all-origins removes those too
490
655
 
491
- Data ship one incremental pass (--full | --loop [sec])
492
- stats per-source session/message/token counts
656
+ Data ship one incremental pass (--full | --loop [sec]); on schema 3 the
657
+ capture engine (--backfill --reconcile --no-normalise --dry-run)
658
+ stats per-source session/message/token counts (--json)
493
659
  search <terms…> full-text search across all sessions
494
660
  resume <session-id> print the command that reopens that session in its editor
495
661
  sessions-query print the session rollup SQL for this credential
662
+ q <name> [--json] schema 3: a named query — search, recent, session, window,
663
+ agents, spend, models, tools, tool, activity, hosts,
664
+ coverage, machines, count, version, aggregate
665
+ (memhouse q lists them; memhouse q schema prints 2 or 3)
666
+ room-sql "<SELECT>" read-only SQL with bare table names resolved for you
667
+ (schema 3: sessions, session_parts, session_usage, calls, …) [--print] [--json]
668
+ import claude-ai <export zips | folder of them | export dir | archive dir>
669
+ schema 3 (the profile tables, or a dev house's 3.2 tables):
670
+ claude.ai conversations into the house as one batch; a dry run (counts only) unless --write. Adds the
671
+ conversations not imported yet; --update also rewrites
672
+ ones changed since; --batch ID names the batch
673
+ import claude-ai --undo <batch> [--write] tombstone a batch
674
+ import claude-ai --list the batches this house holds
496
675
  instance which memhouse this is: home, binary, house, rooms, scope, daemons, bound playbooks
497
676
  rooms what your rooms are actually called (--json)
498
677
  members who is in a house and what each can reach (--db, admin)
499
678
  convert operator: move a pre-one-layout house (a database per member) to the one layout
500
679
  (--admin-user/--admin-password, --member, --print-sql, --dry-run, --guides --out DIR)
501
680
  start | stop | shipper loop + dashboard as background daemons
681
+ dashboard open the running dashboard in a browser: writes a page holding
682
+ its token (mode 600, in the memhouse home) and opens that file;
683
+ the URL with the token is printed only to a terminal
684
+ (--no-open writes the page and opens nothing)
502
685
  share <user> let someone read this house (--only project=… |
503
686
  session=… | host=… | source=… | folder=… | since=… |
504
687
  until=… scopes it with row policies; --revoke
@@ -506,12 +689,18 @@ Data ship one incremental pass (--full | --loop [sec])
506
689
  whoami which credential is in play, and what it may do
507
690
  (--json; --admin reads MEMHOUSE_ADMIN_*)
508
691
  status daemons, connection, counts, freshness (--json)
509
- doctor diagnose the whole pipeline
692
+ doctor diagnose the whole pipeline (schema 3: --full widens the
693
+ identity and duplicate-ts checks to whole tables)
510
694
 
511
695
  Agents plugins list | install claude [--target DIR] | remove claude
696
+ the /mem skills are for Claude Code; other agents read the
697
+ house with the CLI (memhouse q, memhouse room-sql)
512
698
  acts on EVERY Claude config dir found (~/.claude,
513
699
  CLAUDE_CONFIG_DIR, ~/.claude-playbooks/*), all
514
700
  selected by default; --yes takes them all unasked
701
+ plugins install codex Codex: a block in AGENTS.md (the rules) and a
702
+ memhouse skill (the read recipes), under CODEX_HOME or
703
+ ~/.codex; plugins remove codex takes out exactly those
515
704
  prompt print the memory system-prompt snippet
516
705
  prompt --install print an install prompt for an agent, with this
517
706
  machine's state and the one route that applies
@@ -522,6 +711,21 @@ House deploy --local run ClickHouse in docker/podman, then install
522
711
  migrate run every migration this house still needs
523
712
  [--dry-run] [--yes] (copy + atomic swap; deletes nothing)
524
713
  migrate-rooms the same, scoped to the rooms
714
+ migrate --v3-prepare schema v3, step 1 (admin): fence the 0.18 writers off
715
+ (min_writer_schema 3) and rename the member's 0.18 rooms
716
+ into <db>_archive (*_pre_v3) [--member M] [--dry-run] [--yes]
717
+ migrate --v3-convert schema v3: convert <db>_archive.<m>_*_pre_v3 into
718
+ harness-sessions-sql's profile (admin; resumable)
719
+ [--member M] [--batch-rows N] [--dry-run] [--yes]
720
+ migrate --v3-reconcile every converted row's shas and every bucket checked [--member M]
721
+ migrate --v3-shares schema v3 (admin): re-create the member's recorded shares
722
+ on its schema-3 tables; refuses when the record and the
723
+ live grants disagree [--member M] [--dry-run] [--yes]
724
+ migrate --v3-switch schema v3, on each machine: this install captures into
725
+ the converted house (MEMHOUSE_SCHEMA=3) [--dry-run]
726
+ the admin steps act as MEMHOUSE_ADMIN_USER / MEMHOUSE_ADMIN_PASSWORD
727
+ (or --admin-user, --admin-password-file), as invite does;
728
+ --v3-switch acts as this machine's member
525
729
  relocate --to URL copy this whole house to a NEW ClickHouse (server-to-
526
730
  server) and repoint this install — the shipper does NOT
527
731
  re-ingest. [--to-user --to-password --to-db]
@@ -532,6 +736,8 @@ House deploy --local run ClickHouse in docker/podman, then install
532
736
  service stop | start | restart | uninstall | status
533
737
 
534
738
  Config: flags > MEMHOUSE_* env > ${ENV_FILE.replace(os.homedir(), '~')} > defaults.
739
+ House: MEMHOUSE_URL is the house: its ClickHouse HTTP(S) endpoint, port included.
740
+ MEMHOUSE_PORT is this machine's dashboard port (default 4640), never the house's.
535
741
  Engine: MEMHOUSE_ENGINE pins docker or podman when both are installed and one cannot answer.
536
742
  `;
537
743
 
@@ -566,8 +772,13 @@ async function cmdDiscover() {
566
772
  out.adapterErrors = getAdapterErrors();
567
773
  } catch (e) { out.editorsError = e.message; }
568
774
 
569
- for (const url of [...new Set([resolveConfig().url, 'http://localhost:8123'])]) {
570
- const probe = { url, reachable: false };
775
+ // The configured house, and the default local address as a discovery. An unreachable local
776
+ // address that nothing is configured to use is not a failure: it was flagged ✗ on machines
777
+ // whose house is elsewhere, and read as something to fix.
778
+ const stated = resolveConfig();
779
+ const configuredUrl = stated.stated ? stated.url : null;
780
+ for (const url of [...new Set([...(configuredUrl ? [configuredUrl] : []), 'http://localhost:8123'])]) {
781
+ const probe = { url, reachable: false, configured: url === configuredUrl };
571
782
  try {
572
783
  const res = await fetch(url.replace(/\/$/, '') + '/ping', { signal: AbortSignal.timeout(3000) });
573
784
  probe.reachable = res.ok;
@@ -600,7 +811,7 @@ async function cmdDiscover() {
600
811
  printAdapterErrors(out.adapterErrors);
601
812
  console.log('\nClickHouse endpoints:');
602
813
  for (const p of out.clickhouse) {
603
- if (!p.reachable) console.log(bad(`${p.url} — unreachable`));
814
+ if (!p.reachable) console.log(p.configured ? bad(`${p.url} — unreachable (this install's house)`) : ` · ${p.url} — no ClickHouse answers here; nothing is configured to use it`);
604
815
  else if (p.auth) console.log(warn(`${p.url} — reachable, credentials needed`));
605
816
  else console.log(ok(`${p.url} — v${p.version}${p.hasHouse ? ', house present' : ''}${p.kernel ? ', KERNEL detected' : ''}`));
606
817
  }
@@ -608,7 +819,7 @@ async function cmdDiscover() {
608
819
  // This banner is shared with `onboard`, which was therefore telling the user, mid-wizard,
609
820
  // to run the wizard they are already inside.
610
821
  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)'));
611
- if (out.memoryHouse) console.log(warn('memory-house detected on this machine (they coexist fine)'));
822
+ if (out.memoryHouse) console.log(warn('memhouse\'s predecessor tool detected on this machine (they coexist fine)'));
612
823
  return out;
613
824
  }
614
825
 
@@ -706,7 +917,8 @@ ${process.platform === 'linux' && engines.includes('podman')
706
917
  cannot finish this install alone — say so rather than improvising.
707
918
 
708
919
  Tell the user they need one of:
709
- - a ClickHouse they already run (Cloud, a server, a kernel house) plus its credentials;
920
+ - a ClickHouse they already run (Cloud, a server, or a kernel house, which requires the
921
+ Ultimagent kernel, not yet public) plus its credentials;
710
922
  - docker or podman installed, after which \`memhouse deploy --local\` does everything.
711
923
 
712
924
  You can still do the harmless half now: \`${installCommand()}\`,
@@ -802,9 +1014,25 @@ function memberSql(db, member) {
802
1014
  return render(plan({ db, member, placeholder: true }), { db, member });
803
1015
  }
804
1016
 
1017
+ /** Schema 3's additions to the plan: the eight profile tables and the capture grants (admin). */
1018
+ function memberSqlV3(db, member) {
1019
+ const v3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
1020
+ const stmts = v3.renderStatements(v3.readTemplate(), { db, member });
1021
+ return [
1022
+ `-- schema 3: the conversation tables (covered by the grant on ${db}.${member}_* above) and the`,
1023
+ `-- capture tables in ${v3.archiveDb(db)}, which only this member may read and write (no grant option).`,
1024
+ `-- Run with allow_experimental_full_text_index = 1 on servers before 26.x.`,
1025
+ `-- Tables made before harness-sessions-sql v1's types keep their old adapter_ver, child_stored and orig_sha256`,
1026
+ `-- types under IF NOT EXISTS; \`memhouse install --schema 3 --admin-user …\` retypes them.`,
1027
+ `CREATE DATABASE IF NOT EXISTS ${v3.archiveDb(db)};`,
1028
+ ...stmts.map((x) => `${x.sql};`),
1029
+ ...v3.captureGrants(db, member).map((g) => `${g};`),
1030
+ ].join('\n');
1031
+ }
1032
+
805
1033
  // The handoff lines that follow a printed plan: an env file for the member, with the
806
1034
  // password left as the placeholder the plan used. `invite` marks it MEMHOUSE_INVITE=1, so
807
- // the member's `install --env` offers to rotate it to one only they know.
1035
+ // the member's `install --env` changes it to one only their machine knows.
808
1036
  function handoffLines({ url, db, member, invite }) {
809
1037
  const { PASSWORD_PLACEHOLDER } = require(path.join(REPO_ROOT, 'memhouse', 'provision'));
810
1038
  const lines = [
@@ -815,7 +1043,8 @@ function handoffLines({ url, db, member, invite }) {
815
1043
  `-- MEMHOUSE_PASSWORD='${PASSWORD_PLACEHOLDER}'`,
816
1044
  `-- MEMHOUSE_DB='${db}'`,
817
1045
  ];
818
- if (invite) lines.push('-- MEMHOUSE_INVITE=1');
1046
+ if (isV3(resolveConfig())) lines.push('-- MEMHOUSE_SCHEMA=3');
1047
+ if (invite) lines.push('-- MEMHOUSE_INVITE=1', "-- MEMHOUSE_INVITE_EXPIRES='<the same moment in UTC, e.g. 2026-11-06T09:00:00Z>'");
819
1048
  lines.push('-- They install with: memhouse install --env <that file> (it deletes the file afterwards)');
820
1049
  if (flags['member-password'] !== undefined) {
821
1050
  lines.push('-- The --member-password you gave is not printed: use it where the placeholder stands.');
@@ -865,10 +1094,36 @@ async function adminBootstrap(cfg, admin) {
865
1094
  }
866
1095
  throw e;
867
1096
  }
1097
+ // A member's grant is the prefix wildcard <db>.<name>_*. A name that starts with another
1098
+ // member's name and an underscore (bob, bob_smith), or that another member's starts with,
1099
+ // would reach that member's tables, to read and to write. Refused before anything is created.
1100
+ {
1101
+ let collide = null;
1102
+ try { collide = require(path.join(REPO_ROOT, 'memhouse', 'share')).memberCollisions(admin.member, (await chRows(adminCfg, `SELECT name FROM system.tables WHERE database = ${sqlStr(cfg.db)}`, { database: '' })).map((r) => r.name)); }
1103
+ catch (e) { console.log(bad(`could not list the tables of '${cfg.db}' to check the name '${admin.member}': ${String(e.message).split('\n')[0]} — nothing was changed`)); return null; }
1104
+ if (collide.length) {
1105
+ const msg = `'${admin.member}' collides with ${collide.map((x) => `'${x}'`).join(', ')} in '${cfg.db}': one name is the other's plus an underscore, so one member's grant (${cfg.db}.<name>_*) would reach the other's tables`;
1106
+ if (!exists) { console.log(bad(`${msg} — nothing was changed`)); console.log(' Pick a name that neither starts with another member\'s name and an underscore, nor is one\'s start: --member <name>'); return null; }
1107
+ console.log(warn(`${msg}. This house needs fixing: docs/guides/troubleshooting.md, "Two member names that collide".`));
1108
+ }
1109
+ }
868
1110
  let password = flags['member-password'] || null;
869
1111
  if (exists) {
870
1112
  if (!password) {
871
- console.log(bad(`ClickHouse user '${admin.member}' already exists — pass --member-password to install as them.`));
1113
+ // An account still carrying VALID UNTIL holds an invite nobody installed: install lifts it.
1114
+ let pending = null;
1115
+ try {
1116
+ const r = await chRows(adminCfg, `SHOW CREATE USER \`${admin.member}\``, { database: '' });
1117
+ const m = /VALID UNTIL '([^']+)'/.exec(String(r[0] ? Object.values(r[0])[0] : ''));
1118
+ if (m && m[1] !== 'infinity') pending = m[1];
1119
+ } catch { /* not readable: the plain refusal below */ }
1120
+ if (pending) {
1121
+ console.log(bad(`ClickHouse user '${admin.member}' already exists: invited, never installed (its invite password expires at ${pending}, server time)`));
1122
+ console.log(` To invite them again, remove that account first, as the administrator: DROP USER \`${admin.member}\``);
1123
+ console.log(' then run the invite again; its tables are kept and reused');
1124
+ return null;
1125
+ }
1126
+ console.log(bad(`ClickHouse user '${admin.member}' already exists — give their password (MEMHOUSE_MEMBER_PASSWORD, or --member-password-file) to install as them.`));
872
1127
  console.log(` Or pick another handle: --member <name>`);
873
1128
  return null;
874
1129
  }
@@ -894,7 +1149,7 @@ async function adminBootstrap(cfg, admin) {
894
1149
  // we are here, name the real fix rather than leaving a raw ACCESS_DENIED.
895
1150
  if (admin.quiet && /Not enough privileges|ACCESS_DENIED/i.test(e.message || '')) {
896
1151
  console.log(' your configured credential can read users but not create them —');
897
- console.log(' pass an admin that can: memhouse invite … --admin-user <a> --admin-password <p>');
1152
+ console.log(' pass an admin that can: memhouse invite … --admin-user <a> (its password from MEMHOUSE_ADMIN_PASSWORD, or --admin-password-file -)');
898
1153
  }
899
1154
  return null;
900
1155
  }
@@ -925,7 +1180,7 @@ async function adminBootstrap(cfg, admin) {
925
1180
  skipped.push(step.why.replace(/`/g, ''));
926
1181
  }
927
1182
  }
928
- console.log(ok(`granted '${admin.member}' ${cfg.db}.${admin.member}_* — their rooms and nothing else; async_insert pinned`));
1183
+ console.log(ok(`granted '${admin.member}' ${cfg.db}.${admin.member}_* — ${isV3(cfg) ? 'their tables (the capture archive is granted below)' : 'their rooms and nothing else'}; async_insert pinned`));
929
1184
  for (const w of skipped) console.log(warn(`skipped (needs ACCESS MANAGEMENT): ${w}`));
930
1185
  } catch (e) {
931
1186
  console.log(bad(`could not grant the house to '${admin.member}': ${e.message}`));
@@ -945,6 +1200,24 @@ async function adminBootstrap(cfg, admin) {
945
1200
  return null;
946
1201
  }
947
1202
 
1203
+ // Schema 3: the admin builds the eight profile tables and grants the member its capture
1204
+ // tables — a member may create the conversation tables under its own wildcard, but nothing in
1205
+ // <db>_archive is the member's to create.
1206
+ if (isV3(cfg)) {
1207
+ const v3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
1208
+ const ta = v3Transport(adminCfg, { timeout: 120000 });
1209
+ try {
1210
+ const r = await v3.ensureProfileSchema(ta.q, { db: cfg.db, member: admin.member, createDatabases: true, grantCapture: true });
1211
+ const badT = Object.values(r.tables).filter((x) => x.problem);
1212
+ if (badT.length || r.grant_failures.length) {
1213
+ console.log(bad(`schema 3 tables for '${admin.member}' not ready: ${[...badT.map((x) => `${x.fq} ${x.problem}`), ...r.grant_failures].join('; ')}`));
1214
+ for (const d of r.denied) console.log(warn(`not permitted for '${admin.user}': ${d}`));
1215
+ return null;
1216
+ }
1217
+ console.log(ok(`schema 3: ${Object.values(r.tables).map((x) => x.fq).join(', ')}; capture tables granted to '${admin.member}'`));
1218
+ } catch (e) { console.log(bad(`could not build the schema 3 tables: ${e.message.split('\n')[0]}`)); return null; }
1219
+ }
1220
+
948
1221
  // The step that makes this trustworthy: stop being admin, and prove the credential we
949
1222
  // are about to persist can build and reach the rooms itself.
950
1223
  // The proof below runs the real shipper as the new member; it creates their rooms itself.
@@ -961,10 +1234,19 @@ async function adminBootstrap(cfg, admin) {
961
1234
  return null;
962
1235
  }
963
1236
  try {
1237
+ if (isV3(cfg)) {
1238
+ // ship.js --ensure-schema above already verified all eight as the member (keys included).
1239
+ const SP = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
1240
+ const N = SP.profileNames(cfg.db, admin.member);
1241
+ const inList = (ts) => ts.map((x) => `'${admin.member}_${x}'`).join(',');
1242
+ const seen = await chRows(memberCfg, `SELECT count() AS n FROM system.tables WHERE (database = '${cfg.db}' AND name IN (${inList(SP.CONVERSATION_TYPES)})) OR (database = '${N.archive}' AND name IN (${inList(SP.CAPTURE_TYPES)}))`, { database: '' });
1243
+ if (Number(seen[0]?.n) !== SP.PROFILE_TYPES.length) { console.log(bad(`'${admin.member}' cannot see its ${SP.PROFILE_TYPES.length} schema-3 tables — nothing written`)); return null; }
1244
+ } else {
964
1245
  const want = roomNames(admin.member);
965
1246
  const names = ROOM_TYPES.map((t) => `'${want.physical[t]}'`).join(',');
966
1247
  const seen = await chRows(memberCfg, `SELECT count() AS n FROM system.tables WHERE database = '${cfg.db}' AND name IN (${names})`, { database: '' });
967
1248
  if (Number(seen[0]?.n) !== 3) { console.log(bad(`'${admin.member}' cannot see the three rooms — nothing written`)); return null; }
1249
+ }
968
1250
  } catch (e) { console.log(bad(`'${admin.member}' could not connect after provisioning: ${e.message}`)); return null; }
969
1251
  // Whether the admin credential is kept is the CALLER's business: an operator's own
970
1252
  // install keeps it in the env file beside the member's; an invitee's file never carries it.
@@ -989,7 +1271,7 @@ function resolveMemberHandle() {
989
1271
  //
990
1272
  // `default` is deliberately NOT on this list. It is ClickHouse's own catch-all user and
991
1273
  // a poor choice of member, but it is a real one that existing houses are built on —
992
- // refusing it would lock those pilots out of their own install.
1274
+ // refusing it would lock those users out of their own install.
993
1275
  if (member.toLowerCase() === 'root') {
994
1276
  console.log(bad(`'${member}' cannot be a member — it is a container artefact, not a person.`));
995
1277
  console.log(' Pick a name that identifies a person or a machine: memhouse install --member <name>');
@@ -1083,16 +1365,26 @@ async function cmdInstall({ interactive }) {
1083
1365
  } catch { /* not a URL; the missing-fields check above already spoke */ }
1084
1366
  // Clear ambient MEMHOUSE_* so ONLY the file speaks (exported vars normally win over the
1085
1367
  // file; an invite intake is the one place they must not).
1086
- for (const k of ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB', 'MEMHOUSE_PORT', 'MEMHOUSE_CHANNEL', 'MEMHOUSE_EDITORS', 'MEMHOUSE_CLAUDE_ROOTS']) delete process.env[k];
1368
+ for (const k of ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB', 'MEMHOUSE_PORT', 'MEMHOUSE_CHANNEL', 'MEMHOUSE_EDITORS', 'MEMHOUSE_CLAUDE_ROOTS', 'MEMHOUSE_SCHEMA', 'MEMHOUSE_SOURCE_ROOTS']) delete process.env[k];
1087
1369
  for (const k of wanted) process.env[k] = parsed[k];
1088
1370
  // The invite also carries what the inviter's house is ON: its channel (a `team` house
1089
1371
  // is 0.18.x, published under `team`). Dropping it wrote an env with no pin, and the
1090
1372
  // member's first `memhouse update` followed `latest` — then the 0.17 line — and
1091
1373
  // downgraded a working team install onto the wrong layout. A flag still wins.
1092
- for (const k of ['MEMHOUSE_CHANNEL', 'MEMHOUSE_EDITORS', 'MEMHOUSE_CLAUDE_ROOTS']) if (parsed[k]) process.env[k] = parsed[k];
1374
+ // MEMHOUSE_SCHEMA too: a schema-3 house's invite says so, and an install that dropped it
1375
+ // would start the 0.18 shipper against a house that has none of its rooms.
1376
+ for (const k of ['MEMHOUSE_CHANNEL', 'MEMHOUSE_EDITORS', 'MEMHOUSE_CLAUDE_ROOTS', 'MEMHOUSE_SCHEMA']) if (parsed[k]) process.env[k] = parsed[k];
1093
1377
  console.log(ok(`using the invite file ${String(flags.env)} (nothing persisted until the install proves out)`));
1094
1378
  _inviteFileToShred = path.resolve(String(flags.env));
1095
1379
  _inviteWantsRotate = parsed.MEMHOUSE_INVITE === '1';
1380
+ // An expired invite: its password no longer works (VALID UNTIL), so say that, before the
1381
+ // server answers with a bare "authentication failed".
1382
+ const until = Date.parse(parsed.MEMHOUSE_INVITE_EXPIRES || '');
1383
+ if (Number.isFinite(until) && Date.now() > until) {
1384
+ console.log(bad(`this invite expired at ${parsed.MEMHOUSE_INVITE_EXPIRES} (UTC) — ask whoever invited you for a new one; nothing was changed`));
1385
+ _inviteFileToShred = null;
1386
+ return 1;
1387
+ }
1096
1388
  }
1097
1389
  let cfg = resolveConfig();
1098
1390
  const adminUser = flags['admin-user'];
@@ -1107,6 +1399,16 @@ async function cmdInstall({ interactive }) {
1107
1399
  // by a memhouse one-liner.
1108
1400
  try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(cfg.db, 'house'); }
1109
1401
  catch (e) { console.log(bad(e.message)); return 1; }
1402
+ // --schema 3 (or MEMHOUSE_SCHEMA=3 in the env / invite file): the conversation layer and the
1403
+ // capture archive instead of the 0.18 rooms. Only 2 and 3 exist; a typo is refused, never
1404
+ // read as "the default".
1405
+ if (!['', '2', '3'].includes(String(cfg.schema))) {
1406
+ console.log(bad(`--schema ${cfg.schema}: only 2 (the 0.18 rooms) and 3 (harness-sessions-sql's profile) exist`));
1407
+ return 1;
1408
+ }
1409
+ // As stated (flag, env or invite file), before the default applies: '' when nobody said.
1410
+ const statedSchema = String(cfg.schema);
1411
+ if (String(cfg.schema) === '2') cfg.schema = '';
1110
1412
 
1111
1413
  // Option 1 — print the SQL and stop. For the common case: you have admin on this
1112
1414
  // ClickHouse and would rather run four statements yourself than hand a credential to an
@@ -1114,7 +1416,10 @@ async function cmdInstall({ interactive }) {
1114
1416
  if (flags['print-sql'] === true) {
1115
1417
  const member = resolveMemberHandle();
1116
1418
  if (!member) return 1;
1419
+ // Nothing is contacted here, so nothing is detected: schema 3 unless stated (a new house's default).
1420
+ if (!statedSchema) cfg.schema = '3';
1117
1421
  console.log(memberSql(cfg.db, member));
1422
+ if (isV3(cfg)) console.log(memberSqlV3(cfg.db, member));
1118
1423
  // Only echo a URL that was actually STATED. --print-sql is the path where the house is
1119
1424
  // typically someone else's, run by someone else, and cfg.url falls back to
1120
1425
  // http://localhost:8123 — the one address the rest of the product refuses to guess.
@@ -1129,6 +1434,11 @@ async function cmdInstall({ interactive }) {
1129
1434
  // From a file, stdin, the environment or a prompt — never REQUIRED on argv.
1130
1435
  const adminPassword = await adminPasswordFor(String(adminUser));
1131
1436
  if (adminPassword === null) return 1;
1437
+ {
1438
+ const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows({ ...cfg, user: String(adminUser), password: adminPassword }, sql, { database: '' }), cfg.db, member), member, { user: String(adminUser), url: cfg.url });
1439
+ if (pick.refuse) { console.log(bad(pick.refuse)); console.log(' nothing was changed'); return 1; }
1440
+ cfg.schema = pick.schema === '3' ? '3' : '';
1441
+ }
1132
1442
  const built = await adminBootstrap(cfg, {
1133
1443
  user: adminUser, password: adminPassword, member,
1134
1444
  });
@@ -1147,17 +1457,17 @@ async function cmdInstall({ interactive }) {
1147
1457
  // failed install is read as truth by the next command. Placed after the write, a
1148
1458
  // wrong-key house got four green ticks and a persisted config, and `status` then added
1149
1459
  // five more on a house that can never be shipped to.
1150
- const keyProblem = await sortingKeyProblem(cfg);
1460
+ const keyProblem = isV3(cfg) ? null : await sortingKeyProblem(cfg);
1151
1461
  if (keyProblem) {
1152
1462
  console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
1153
1463
  console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
1154
1464
  // migrate-rooms, not "rebuild by hand from the template" — this advice predated the
1155
- // command and survived it, so an UPGRADING pilot (the main person who ever sees this)
1465
+ // command and survived it, so an UPGRADING user (the main person who ever sees this)
1156
1466
  // was pointed at a manual rebuild that the tool now does for them, atomically and
1157
1467
  // without deleting anything.
1158
1468
  // With the SAME connection flags — no config was written (see below), so a bare
1159
1469
  // `memhouse migrate-rooms` here would answer "no house configured" and strand the
1160
- // pilot in a loop between two refusals.
1470
+ // user in a loop between two refusals.
1161
1471
  console.log(' rebuild them (a copy + swap; nothing is deleted), then install again:');
1162
1472
  console.log(` memhouse migrate-rooms --url ${cfg.url} --db ${cfg.db} --user ${cfg.user} --password …`);
1163
1473
  console.log(' no config was written — nothing here reads as installed.');
@@ -1167,9 +1477,9 @@ async function cmdInstall({ interactive }) {
1167
1477
  console.log(ok(`config written: ${ENV_FILE}`));
1168
1478
  // Same as the member path below: the identity is minted where the machine joins.
1169
1479
  {
1170
- const me = require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity(HOME_DIR);
1171
- console.log(ok(`host identity: ${me.id}`));
1172
- console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
1480
+ const me = machineHost(cfg);
1481
+ console.log(ok(`host identity: ${me.id}${me.label ? ` (${me.label})` : ''}`));
1482
+ console.log(` every machine you install as '${cfg.user}' writes into the same ${isV3(cfg) ? 'tables' : 'rooms'}; this is what tells them apart`);
1173
1483
  }
1174
1484
  console.log(ok('installed'));
1175
1485
  if (cfg.adminUser) {
@@ -1193,7 +1503,7 @@ async function cmdInstall({ interactive }) {
1193
1503
  // haveAll so no prompt asked who you were, and resolveConfig's default was used AS A
1194
1504
  // CREDENTIAL. Refuse rather than do something else silently.
1195
1505
  // Every flag that only means something to the admin bootstrap. Left out, it was silently
1196
- // ignored on a member install (`--keep-admin` was — Codex P2 on #14).
1506
+ // ignored on a member install (`--keep-admin` was — #14).
1197
1507
  const adminOnly = ['member', 'member-password', 'keep-admin', 'admin-password-file', 'admin-password'].filter((k) => flags[k] !== undefined);
1198
1508
  if (!adminUser && adminOnly.length) {
1199
1509
  console.log(bad(`--${adminOnly.join(', --')} ${adminOnly.length > 1 ? 'are' : 'is'} only read with --admin-user`));
@@ -1242,6 +1552,13 @@ async function cmdInstall({ interactive }) {
1242
1552
  console.log(bad(`invalid database name '${cfg.db}' — use letters, digits, underscore`));
1243
1553
  return 1;
1244
1554
  }
1555
+ // Joining a house: its schema, detected, never the default (a 0.20 client on an unconverted
1556
+ // 0.18 house stays on its rooms until the admin converts it and this machine switches).
1557
+ {
1558
+ const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows(cfg, sql, { database: '' }), cfg.db, cfg.user), cfg.user, { user: cfg.user, url: cfg.url });
1559
+ if (pick.refuse) { console.log(bad(pick.refuse)); console.log(' nothing was changed'); return 1; }
1560
+ cfg.schema = pick.schema === '3' ? '3' : '';
1561
+ }
1245
1562
  // An existing config pointing somewhere ELSE is not a thing to overwrite in passing: a
1246
1563
  // shipper or an installed service is still pointed at the old house, and rewriting the
1247
1564
  // file orphans it with no error anywhere.
@@ -1288,6 +1605,32 @@ async function cmdInstall({ interactive }) {
1288
1605
  catch { console.log(bad(`house '${cfg.db}' does not exist and cannot be created: ${e.message}`)); return 1; }
1289
1606
  }
1290
1607
  console.log(ok(`house '${cfg.db}' ready`));
1608
+ if (isV3(cfg)) {
1609
+ // Schema 3: the member's eight profile tables. A member may create the six conversation
1610
+ // tables under its own grant; the two capture tables in <db>_archive are an admin's to create
1611
+ // and grant. Verified either way — keys included — before anything is written.
1612
+ const v3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
1613
+ const t = v3Transport(cfg, { timeout: 120000 });
1614
+ let r3;
1615
+ try {
1616
+ const member = (await v3RoomsFor(cfg, t)).member;
1617
+ r3 = await v3.ensureProfileSchema(t.q, { db: cfg.db, member });
1618
+ r3.member = member;
1619
+ } catch (e) { console.log(bad(`could not check the schema 3 tables: ${e.message.split('\n')[0]}`)); return 1; }
1620
+ const missing = Object.values(r3.tables).filter((x) => x.problem);
1621
+ if (missing.length) {
1622
+ console.log(bad(`schema 3: ${missing.map((x) => `${x.fq} ${x.problem}`).join('; ')}`));
1623
+ console.log(` '${r3.member}' may not create ${missing.length > 1 ? 'them' : 'it'} — the capture tables live in ${cfg.db}_archive and only an admin`);
1624
+ console.log(' creates and grants them. Either hand it over:');
1625
+ console.log(` memhouse install --schema 3 --admin-user <admin> --member ${r3.member} --url ${cfg.url} --db ${cfg.db}`);
1626
+ console.log(' or print the statements for whoever administers the server:');
1627
+ console.log(` memhouse install --schema 3 --print-sql --member ${r3.member} --db ${cfg.db}`);
1628
+ console.log(' no config was written — nothing here reads as installed.');
1629
+ return 1;
1630
+ }
1631
+ console.log(ok(`schema 3 tables for '${r3.member}': ${Object.values(r3.tables).map((x) => x.fq).join(', ')}`));
1632
+ if (r3.unhealed.length) console.log(warn(`${r3.unhealed.join(', ')}: the capture writes the old types until an admin retypes them (memhouse install --schema 3 --admin-user …)`));
1633
+ } else {
1291
1634
  // Rooms — the caller's own three. On a house you own this creates them and you are
1292
1635
  // done, which is the solo path. On a house someone else owns you hold no CREATE TABLE,
1293
1636
  // and that is not a failure to paper over: rooms and grants are minted by the owner, so
@@ -1313,7 +1656,7 @@ async function cmdInstall({ interactive }) {
1313
1656
  console.log(bad(`'${r.member}' has no ${missing.join(', ')} in '${cfg.db}', and could not create them`));
1314
1657
  console.log(' You hold no CREATE TABLE here. Someone with ACCESS MANAGEMENT has to run this —');
1315
1658
  console.log(' print it with: memhouse install --print-sql --member ' + r.member);
1316
- console.log(' or hand it over: memhouse install --admin-user … --admin-password …');
1659
+ console.log(' or hand it over: memhouse install --admin-user … --admin-password-file - (password on stdin)');
1317
1660
  return 1;
1318
1661
  }
1319
1662
  console.log(ok(`rooms for '${r.member}': ${want.join(', ')}`));
@@ -1327,17 +1670,18 @@ async function cmdInstall({ interactive }) {
1327
1670
  console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
1328
1671
  console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
1329
1672
  // migrate-rooms, not "rebuild by hand from the template" — this advice predated the
1330
- // command and survived it, so an UPGRADING pilot (the main person who ever sees this)
1673
+ // command and survived it, so an UPGRADING user (the main person who ever sees this)
1331
1674
  // was pointed at a manual rebuild that the tool now does for them, atomically and
1332
1675
  // without deleting anything.
1333
1676
  // With the SAME connection flags — no config was written (see below), so a bare
1334
1677
  // `memhouse migrate-rooms` here would answer "no house configured" and strand the
1335
- // pilot in a loop between two refusals.
1678
+ // user in a loop between two refusals.
1336
1679
  console.log(' rebuild them (a copy + swap; nothing is deleted), then install again:');
1337
1680
  console.log(` memhouse migrate-rooms --url ${cfg.url} --db ${cfg.db} --user ${cfg.user} --password …`);
1338
1681
  console.log(' no config was written — nothing here reads as installed.');
1339
1682
  return 1;
1340
1683
  }
1684
+ }
1341
1685
  // Written LAST, and only once everything above proved out. Writing it first leaves a
1342
1686
  // config file behind every failed attempt, and the next command reads it as truth.
1343
1687
  writeEnvFile(cfg);
@@ -1371,11 +1715,11 @@ async function cmdInstall({ interactive }) {
1371
1715
  // Mint this machine's identity here rather than leaving it to whatever runs first.
1372
1716
  // Installing is the moment a machine joins the member's rooms, and the id is what every
1373
1717
  // later `WHERE host = …` depends on — so it is worth naming once, out loud, at the point
1374
- // the pilot can still see it.
1718
+ // the user can still see it.
1375
1719
  {
1376
- const me = require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity(HOME_DIR);
1377
- console.log(ok(`host identity: ${me.id}`));
1378
- console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
1720
+ const me = machineHost(cfg);
1721
+ console.log(ok(`host identity: ${me.id}${me.label ? ` (${me.label})` : ''}`));
1722
+ console.log(` every machine you install as '${cfg.user}' writes into the same ${isV3(cfg) ? 'tables' : 'rooms'}; this is what tells them apart`);
1379
1723
  }
1380
1724
  console.log(ok('installed'));
1381
1725
  printGettingStarted(cfg);
@@ -1385,15 +1729,23 @@ async function cmdInstall({ interactive }) {
1385
1729
  return 0;
1386
1730
  }
1387
1731
 
1388
- // After a successful install FROM AN INVITE: offer to rotate the shared password to one
1389
- // only this machine knows (the member holds ALTER USER on themselves, so no admin), then
1390
- // delete the spent file. Interactive offers (default yes); --yes rotates unasked; a
1391
- // non-TTY without --yes only advises. All best-effort — a failed rotation never fails the
1392
- // install, it just leaves the inviter's password in place with a warning.
1732
+ // After a successful install FROM AN INVITE: change the shared password to one only this
1733
+ // machine knows (the member holds ALTER USER on themselves, so no admin), then delete the
1734
+ // spent file. A terminal is asked (default yes); --yes, or no terminal at all, rotates
1735
+ // unasked. Without a terminal nobody is there to decide, and keeping the inviter's working
1736
+ // password must never be what happens by default. The password is kept only on an explicit
1737
+ // no (the question, or --keep-password), and then the output says who still knows it. A failed
1738
+ // rotation never fails the install: it keeps the invite file and says how to retry.
1739
+ // A member holds ALTER USER on itself, so it can lift the invite's VALID UNTIL.
1740
+ async function clearInviteExpiry(cfg) {
1741
+ try { const c = cfg || resolveConfig(); await ch(c, `ALTER USER \`${c.user}\` VALID UNTIL 'infinity'`, { database: '' }); return true; }
1742
+ catch { return false; }
1743
+ }
1744
+
1393
1745
  async function finishInvite(cfg) {
1394
1746
  if (_inviteWantsRotate) {
1395
- let go = flags.yes === true;
1396
- if (!go && process.stdin.isTTY) {
1747
+ let go = flags['keep-password'] !== true;
1748
+ if (go && flags.yes !== true && process.stdin.isTTY) {
1397
1749
  const a = (await ask('This password was set by whoever invited you. Change it to one only you know now? (Y/n)', 'Y')).toLowerCase();
1398
1750
  go = a === '' || a === 'y' || a === 'yes';
1399
1751
  }
@@ -1410,6 +1762,9 @@ async function finishInvite(cfg) {
1410
1762
  for (const k of drift.changed) process.env[k] = drift.values[k];
1411
1763
  if (drift.values.MEMHOUSE_PASSWORD !== undefined && flags.password !== undefined) flags.password = drift.values.MEMHOUSE_PASSWORD;
1412
1764
  if (cfg) cfg.password = process.env.MEMHOUSE_PASSWORD;
1765
+ // The new password carries no expiry (a member's own ALTER USER … IDENTIFIED BY drops
1766
+ // VALID UNTIL); say so explicitly, in case a server keeps it.
1767
+ await clearInviteExpiry(cfg);
1413
1768
  }
1414
1769
  if (code !== 0) {
1415
1770
  // Rotation failed and its own state may be uncertain — KEEP the invite file (it
@@ -1419,7 +1774,9 @@ async function finishInvite(cfg) {
1419
1774
  return;
1420
1775
  }
1421
1776
  } else {
1422
- console.log(warn('keeping the invited password — rotate when ready: memhouse passwd'));
1777
+ console.log(warn('kept the invited password, as asked: whoever invited you still knows it. Change it with: memhouse passwd'));
1778
+ // Kept, so its expiry must go: the account would otherwise stop working on the invite's date.
1779
+ if (!(await clearInviteExpiry(cfg))) console.log(warn('could not clear the invite\'s expiry: the password stops working on its date unless you change it (memhouse passwd)'));
1423
1780
  }
1424
1781
  }
1425
1782
  if (_inviteFileToShred) {
@@ -1432,7 +1789,7 @@ async function finishInvite(cfg) {
1432
1789
 
1433
1790
  /**
1434
1791
  * What now — printed once, at the end of a successful install. An install that ends with
1435
- * one next-step line leaves the pilot at a working house they do not know how to use:
1792
+ * one next-step line leaves the user at a working house they do not know how to use:
1436
1793
  * the dashboard, the agent skills, and the health check all exist, and nothing said so.
1437
1794
  * Kept to one screen; each line is a thing to DO, not a feature list.
1438
1795
  */
@@ -1443,7 +1800,7 @@ function printGettingStarted(cfg) {
1443
1800
  console.log('');
1444
1801
  console.log(' Your house is live. From here:');
1445
1802
  console.log(` ${mh} start dashboard + shipper loop (background daemons)`);
1446
- console.log(` -> http://localhost:${cfg.port || 4640} browse, search, and analyze every session`);
1803
+ console.log(` ${mh} dashboard open the dashboard: browse, search, and analyze every session`);
1447
1804
  console.log(` ${mh} service install or: ship at login, no terminal needed`);
1448
1805
  console.log(` ${mh} plugins install claude give your agents /mem:house, /mem:recall,`);
1449
1806
  console.log(' /mem:sql, /mem:access, /mem:admin');
@@ -1501,7 +1858,7 @@ async function cmdOnboard() {
1501
1858
 
1502
1859
  // The skills were the one delivered thing onboarding never mentioned. They shipped,
1503
1860
  // they were packaged as a plugin, and the only way to find them was to already know
1504
- // `memhouse plugins install claude` — so a pilot completed the whole wizard and ended up
1861
+ // `memhouse plugins install claude` — so a user completed the whole wizard and ended up
1505
1862
  // with a house their agent could not query. Offer them here, across every Claude instance
1506
1863
  // found, all selected.
1507
1864
  const targets = claudeTargets();
@@ -1519,10 +1876,9 @@ async function cmdOnboard() {
1519
1876
  async function cmdStart() {
1520
1877
  const cfg = requireConfig(resolveConfig(), 'start');
1521
1878
  fs.mkdirSync(RUN_DIR, { recursive: true });
1522
- // Record the port this run actually used. `start --port 4673` did not persist it, so
1523
- // `status` fell back to the env file and printed `dashboard → http://localhost:4640` —
1524
- // a live process on 4673 named alongside a link to someone else's dashboard on 4640.
1525
- try { fs.writeFileSync(path.join(RUN_DIR, 'dashboard.port'), String(cfg.port)); } catch { /* best effort */ }
1879
+ // The port the dashboard listens on is recorded by the dashboard itself, once it listens
1880
+ // ($MEMHOUSE_HOME/run/dashboard.port): when the configured port is in use it takes the next
1881
+ // free one (memhouse/server/dashboard-port.js), so only the process knows which.
1526
1882
  fs.mkdirSync(LOG_DIR, { recursive: true });
1527
1883
  ensureUiBuilt();
1528
1884
  const daemons = [
@@ -1553,16 +1909,29 @@ async function cmdStart() {
1553
1909
  : ` start it with: launchctl kickstart gui/$(id -u)/com.${unit.replace('memhouse-', 'memhouse.')}`);
1554
1910
  }
1555
1911
  }
1912
+ // The dashboard's token, made here when absent, so a token file that is refused (open to
1913
+ // other users) is said before anything starts. The dashboard reads it from the file itself;
1914
+ // it is not passed in argv or the environment.
1915
+ if (daemons.some((d) => d.name === 'dashboard') && !pidOf('dashboard')) {
1916
+ try { dashAuth.ensureToken(HOME_DIR); } catch (e) {
1917
+ daemons.splice(daemons.findIndex((d) => d.name === 'dashboard'), 1);
1918
+ console.log(warn(`dashboard not started — ${e.message}`));
1919
+ }
1920
+ }
1556
1921
  const started = [];
1557
1922
  for (const d of daemons) {
1558
1923
  if (pidOf(d.name)) { console.log(warn(`${d.name} already running (pid ${pidOf(d.name)})`)); continue; }
1924
+ // A port recorded by an earlier dashboard is not this one's.
1925
+ if (d.name === 'dashboard') { try { fs.unlinkSync(dashPort.portFile(HOME_DIR)); } catch { /* none recorded */ } }
1559
1926
  const log = fs.openSync(path.join(LOG_DIR, d.name + '.log'), 'a');
1927
+ // Where this run's lines start: an exit is explained from them, not from an earlier run's.
1928
+ const logFrom = fs.fstatSync(log).size;
1560
1929
  const child = spawn(process.execPath, [d.script, ...d.args], {
1561
1930
  env: childEnv(cfg), detached: true, stdio: ['ignore', log, log],
1562
1931
  });
1563
1932
  fs.writeFileSync(path.join(RUN_DIR, d.name + '.pid'), String(child.pid));
1564
1933
  child.unref();
1565
- started.push({ ...d, pid: child.pid });
1934
+ started.push({ ...d, pid: child.pid, logFrom });
1566
1935
  }
1567
1936
 
1568
1937
  // spawn() succeeding means the PROCESS started, not that it is still alive or that it
@@ -1571,7 +1940,19 @@ async function cmdStart() {
1571
1940
  // else owned the port, which on this machine was a different house's dashboard. Give
1572
1941
  // each a moment, then confirm, and dig the reason out of the log rather than making the
1573
1942
  // user find it.
1574
- if (started.length) await new Promise((r) => setTimeout(r, 900));
1943
+ //
1944
+ // The dashboard also gets until it has recorded its port (or up to 5 s), so the port said
1945
+ // below is the one it took.
1946
+ if (started.length) {
1947
+ const dash = started.find((d) => d.name === 'dashboard');
1948
+ const t0 = Date.now();
1949
+ for (;;) {
1950
+ await new Promise((r) => setTimeout(r, 100));
1951
+ const waited = Date.now() - t0;
1952
+ if (waited < 900) continue;
1953
+ if (!dash || !alive(dash.pid) || dashPort.readPort(HOME_DIR) || waited >= 5000) break;
1954
+ }
1955
+ }
1575
1956
  let dashboardAlive = false;
1576
1957
  for (const d of started) {
1577
1958
  if (alive(d.pid)) {
@@ -1581,15 +1962,14 @@ async function cmdStart() {
1581
1962
  }
1582
1963
  try { fs.unlinkSync(path.join(RUN_DIR, d.name + '.pid')); } catch { /* already gone */ }
1583
1964
  const logPath = path.join(LOG_DIR, d.name + '.log');
1584
- let why = '';
1585
- try {
1586
- const tail = fs.readFileSync(logPath, 'utf-8').split('\n').filter(Boolean).slice(-40);
1587
- const err = tail.reverse().find((l) => /EADDRINUSE|Error|error:/i.test(l));
1588
- if (/EADDRINUSE/.test(err || '')) why = `port ${cfg.port} is already in use — free it, or set MEMHOUSE_PORT`;
1589
- else if (err) why = err.trim().slice(0, 200);
1590
- } catch { /* no log to read */ }
1591
- console.log(bad(`${d.name} exited immediately${why ? ` — ${why}` : ''}`));
1592
- if (!why) console.log(` see ${logPath}`);
1965
+ let tail = '';
1966
+ try { tail = fs.readFileSync(logPath).subarray(d.logFrom).toString('utf-8').split('\n').slice(-40).join('\n'); } catch { /* no log to read */ }
1967
+ // The shipper is what start keeps running: its exit fails the command. The dashboard is
1968
+ // optional: its exit is a warning, and start exits 0 while the shipper runs.
1969
+ const r = dashPort.earlyExit({ name: d.name, log: tail, port: cfg.port });
1970
+ console.log((r.fatal ? bad : warn)(`${d.name} exited immediately${r.why ? ` — ${r.why}` : ''}`));
1971
+ if (!r.why) console.log(` see ${logPath}`);
1972
+ if (!r.fatal) continue;
1593
1973
  process.exitCode = 1;
1594
1974
  // Partial success is the confusing case: exit 1 reads as "nothing started", and the
1595
1975
  // daemon that DID start keeps running unmentioned.
@@ -1599,8 +1979,75 @@ async function cmdStart() {
1599
1979
  }
1600
1980
  }
1601
1981
  if (dashboardAlive || (pidOf('dashboard') && !started.some((d) => d.name === 'dashboard'))) {
1602
- console.log(` dashboard → http://localhost:${cfg.port}`);
1982
+ const port = dashPort.readPort(HOME_DIR);
1983
+ if (dashboardAlive && port && String(port) !== String(cfg.port)) console.log(warn(dashPort.inUseNote(cfg.port, port)));
1984
+ console.log(` ${port ? `dashboard on port ${port}` : 'dashboard starting'} — open it with: ${selfCmd('dashboard')}`);
1985
+ }
1986
+ }
1987
+
1988
+ /**
1989
+ * Open a file with the platform's opener (macOS `open`, `xdg-open` elsewhere; none on Windows).
1990
+ * Resolves { ok } once the opener has exited 0 or is still running after 3 s, and
1991
+ * { ok: false, error } when it could not be run or exited non-zero.
1992
+ */
1993
+ function openWithPlatform(file) {
1994
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? null : 'xdg-open';
1995
+ if (!cmd) return Promise.resolve({ ok: false, error: `no opener is run on ${process.platform}` });
1996
+ return new Promise((resolve) => {
1997
+ let child;
1998
+ try { child = spawn(cmd, [file], { detached: true, stdio: 'ignore' }); } catch (e) { resolve({ ok: false, error: `${cmd}: ${e.message}` }); return; }
1999
+ const timer = setTimeout(() => { child.unref(); resolve({ ok: true }); }, 3000);
2000
+ child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, error: `${cmd}: ${e.code === 'ENOENT' ? 'not found' : e.message}` }); });
2001
+ child.on('exit', (code) => { clearTimeout(timer); resolve(code === 0 ? { ok: true } : { ok: false, error: `${cmd} exited ${code}` }); });
2002
+ });
2003
+ }
2004
+
2005
+ /**
2006
+ * `memhouse dashboard`: open the running dashboard in a browser. First the server on
2007
+ * 127.0.0.1:<port> must prove it holds this home's token (/api/proof, an HMAC of a fresh nonce):
2008
+ * anything else listening there is never given the token. Then it writes
2009
+ * $MEMHOUSE_HOME/dashboard.html (mode 600), a page that redirects to the dashboard with the token
2010
+ * in the URL's fragment, and opens that FILE, so the token is in no process's argv. The URL with
2011
+ * the token is printed only when stdout is a terminal; --json never carries it.
2012
+ */
2013
+ async function cmdDashboard() {
2014
+ const cfg = resolveConfig();
2015
+ const pid = pidOf('dashboard');
2016
+ if (!pid) {
2017
+ if (JSON_OUT) console.log(JSON.stringify({ running: false, start: selfCmd('start') }, null, 2));
2018
+ else { console.log(warn('dashboard: not running')); console.log(` start it with: ${selfCmd('start')}`); }
2019
+ return 1;
2020
+ }
2021
+ const port = Number(runningPort(cfg));
2022
+ let token;
2023
+ try { token = dashAuth.readToken(HOME_DIR); } catch (e) {
2024
+ if (JSON_OUT) console.log(JSON.stringify({ running: true, pid, port, error: e.message }, null, 2));
2025
+ else console.log(bad(e.message));
2026
+ return 1;
2027
+ }
2028
+ if (!(await dashAuth.verifyServer(port, token))) {
2029
+ const msg = `port ${port} is not your dashboard: what answers on ${dashAuth.LOOPBACK}:${port} could not prove it holds this home's token, so nothing was opened — restart yours: ${selfCmd('stop')} && ${selfCmd('start')}`;
2030
+ if (JSON_OUT) console.log(JSON.stringify({ running: true, pid, port, error: msg }, null, 2));
2031
+ else console.log(bad(msg));
2032
+ return 1;
2033
+ }
2034
+ const file = dashAuth.writeOpener(HOME_DIR, dashAuth.openUrl(port, token));
2035
+ const opened = flags['no-open'] === true ? { ok: false, skipped: true } : await openWithPlatform(file);
2036
+ if (JSON_OUT) {
2037
+ console.log(JSON.stringify({ running: true, pid, port, file, opened: opened.ok, ...(opened.error ? { open_error: opened.error } : {}) }, null, 2));
2038
+ return 0;
1603
2039
  }
2040
+ console.log(ok(`dashboard: running (pid ${pid}, port ${port})`));
2041
+ if (opened.ok) console.log(ok(`opened ${file} in the browser`));
2042
+ else {
2043
+ if (!opened.skipped) console.log(warn(`could not open a browser (${opened.error})`));
2044
+ console.log(` open ${file} in a browser on this machine`);
2045
+ }
2046
+ console.log(' that file (mode 600) holds the dashboard\'s token; the page keeps it for this browser tab only');
2047
+ // A terminal only: output written to a pipe or a file (how logs and most agent sessions
2048
+ // capture it) never holds the token.
2049
+ if (process.stdout.isTTY) console.log(` or open: ${dashAuth.openUrl(port, token)}`);
2050
+ return 0;
1604
2051
  }
1605
2052
 
1606
2053
  function cmdStop() {
@@ -1681,27 +2128,99 @@ function fleetAge(ms) {
1681
2128
  return `${Math.round(h / 24)}d ago`;
1682
2129
  }
1683
2130
 
2131
+ // Connected, but the configured member's tables are not where this install looks. ClickHouse
2132
+ // answers UNKNOWN_TABLE with the SQL that hit it, and status used to print that as "not
2133
+ // connected"; the person needs the reason and the next step. The cases seen so far: the house
2134
+ // was moved to schema 3 and this machine still reads the 0.18 rooms (the fix is --v3-switch),
2135
+ // the reverse, a credential that administers the server and owns no rooms, and no rooms at
2136
+ // all. Returns { kind, lines: [what, what to do] }, or null when the tables are where they
2137
+ // should be or nothing can be read to tell.
2138
+ async function roomsProblem(cfg) {
2139
+ let member; let names;
2140
+ try {
2141
+ member = process.env.MEMHOUSE_MEMBER || readEnvFile().MEMHOUSE_MEMBER
2142
+ || ((await chRows(cfg, 'SELECT currentUser() AS u', { database: '' }))[0] || {}).u;
2143
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(String(member || '')) || !/^[A-Za-z0-9_]+$/.test(String(cfg.db || ''))) return null;
2144
+ names = new Set((await chRows(cfg, `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND startsWith(name, '${member}_')`, { database: '' })).map((r) => r.name));
2145
+ } catch { return null; }
2146
+ const onV3 = names.has(`${member}_session_parts`);
2147
+ const onV2 = names.has(`${member}_messages`);
2148
+ if (!isV3(cfg) && !onV2 && onV3) {
2149
+ return { kind: 'moved', lines: [
2150
+ 'this house was moved to schema 3, and this machine still reads and ships the 0.18 rooms',
2151
+ 'switch it: memhouse migrate --v3-switch then: memhouse stop && memhouse start'] };
2152
+ }
2153
+ if (isV3(cfg) && !onV3 && onV2) {
2154
+ return { kind: 'not-moved', lines: [
2155
+ `this machine reads schema 3, but '${member}' in '${cfg.db}' is still on the 0.18 rooms`,
2156
+ `the house's administrator converts it first (docs/guides/upgrade-to-0.20.md); until then, MEMHOUSE_SCHEMA=2 in ${short(ENV_FILE)}`] };
2157
+ }
2158
+ if (!onV2 && !onV3) {
2159
+ // A member whose own database was converted to the one layout: the env still names it,
2160
+ // the rooms are in mem. `update` adopts that; `install` would be the wrong advice.
2161
+ if (cfg.db !== 'mem' && (await adoptMovedHouse(cfg, { fix: false })) === 'moved') {
2162
+ return { kind: 'moved-db', lines: [
2163
+ `rooms moved to mem.${member}_* — this env still says MEMHOUSE_DB=${cfg.db}`,
2164
+ 'memhouse update (rewrites the env and restarts the shipper)'] };
2165
+ }
2166
+ let caps = {};
2167
+ try { caps = capabilitiesFrom((await chRows(cfg, 'SHOW GRANTS', { database: '' })).map((r) => Object.values(r).join(' '))); } catch { /* no grants to read */ }
2168
+ if (caps.canMintUsers || caps.isSuperuser) {
2169
+ return { kind: 'admin', lines: [
2170
+ `'${cfg.user}' administers this server and owns no rooms, so there is nothing of its own to show`,
2171
+ `member commands need a member credential: memhouse install --admin-user ${cfg.user} --member <you>`] };
2172
+ }
2173
+ return { kind: 'none', lines: [
2174
+ `no tables for '${member}' in '${cfg.db}'`,
2175
+ `run: memhouse install (it builds them), or check the house name in ${short(ENV_FILE)}`] };
2176
+ }
2177
+ return null;
2178
+ }
2179
+
1684
2180
  async function cmdStatus() {
1685
2181
  // requireConfig, not resolveConfig. `status` reads a house and reports its counts as
1686
2182
  // YOURS, which is exactly the answer that must not come from a guessed URL: with no
1687
2183
  // config it connected to localhost:8123 as memhouse_root and, wherever that credential
1688
- // works, printed a stranger's session and message counts as the pilot's own.
2184
+ // works, printed a stranger's session and message counts as the user's own.
1689
2185
  const cfg = requireConfig(resolveConfig(), 'status');
1690
2186
  const out = {
1691
2187
  config: fs.existsSync(ENV_FILE) ? ENV_FILE : null,
1692
2188
  url: cfg.url, db: cfg.db, user: cfg.user,
1693
2189
  daemons: { shipper: pidOf('shipper'), dashboard: pidOf('dashboard') },
1694
- dashboard_url: pidOf('dashboard') ? `http://localhost:${runningPort(cfg)}` : null,
1695
- shipper: shipperHealth(),
2190
+ // Where it listens, and how to open it. The token is never part of this output.
2191
+ dashboard_url: pidOf('dashboard') ? `http://127.0.0.1:${runningPort(cfg)}` : null,
2192
+ dashboard_port: pidOf('dashboard') ? Number(runningPort(cfg)) : null,
2193
+ dashboard_open: pidOf('dashboard') ? selfCmd('dashboard') : null,
2194
+ shipper: { ...shipperHealth(), stale: shipperStale() },
1696
2195
  update: null, // filled below from the registry; null when current, offline, or a checkout build
1697
2196
  // Which machine this is, in the rooms' own terms. All of a member's machines write
1698
- // into one set of rooms, so `WHERE host = ...` is how the pilot separates this laptop
2197
+ // into one set of rooms, so `WHERE host = ...` is how the user separates this laptop
1699
2198
  // from the other one — and they cannot type it if nothing ever prints it.
1700
- host: require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity(HOME_DIR),
2199
+ host: machineHost(cfg),
1701
2200
  connected: false,
1702
2201
  };
2202
+ out.schema = isV3(cfg) ? 3 : 2;
1703
2203
  try {
1704
- out.connected = true && !!(await ch(cfg, 'SELECT 1'));
2204
+ // No database here: a house name that does not exist is a rooms problem (named below),
2205
+ // not an unreachable server.
2206
+ out.connected = true && !!(await ch(cfg, 'SELECT 1', { database: '' }));
2207
+ if (isV3(cfg)) {
2208
+ // Schema 3: counts, freshness and the machines from sessions FINAL — one row per session,
2209
+ // no scan of turns. The capture keeps no heartbeat row, so a machine's "last ship" is the
2210
+ // newest write of a session row it owns (max(ingested_at)).
2211
+ const t = v3Transport(cfg);
2212
+ const rooms = await v3RoomsFor(cfg, t);
2213
+ out.member = rooms.member;
2214
+ out.tables = { session_parts: rooms.parts, session_usage: rooms.usage, calls: rooms.calls, sessions: rooms.sessions };
2215
+ const st = await qc().statusV3(t.exec, rooms);
2216
+ out.sessions = st.sessions; out.messages = st.messages; out.freshest = st.freshest;
2217
+ out.fleet = st.hosts.map((h) => {
2218
+ const ageMs = h.last_write_ms ? Date.now() - Number(h.last_write_ms) : null;
2219
+ return { writer: `${rooms.member}@${h.host}`, host: h.host, version: null, schema: 3, lastShip: h.last_write_at, lastActive: h.last_active_at, sessions: Number(h.sessions), harnesses: h.harnesses, ageMs, verdict: ageMs !== null && ageMs > 48 * 3600 * 1000 ? 'stale' : 'ok' };
2220
+ });
2221
+ const sr = sourceRootsFor(cfg);
2222
+ out.source_roots = { defaulted: sr.defaulted, error: sr.error, roots: sr.roots.map((r) => ({ harness: r.harness, name: r.name, dir: r.dir })) };
2223
+ } else {
1705
2224
  const r = await roomsFor(cfg);
1706
2225
  out.member = r.member;
1707
2226
  const s = await chRows(cfg, `SELECT count() AS sessions FROM ${r.sessions_v}`);
@@ -1710,23 +2229,46 @@ async function cmdStatus() {
1710
2229
  out.messages = Number(m[0]?.msgs || 0);
1711
2230
  out.freshest = m[0]?.freshest || null;
1712
2231
  out.fleet = await fleetState(cfg);
2232
+ }
1713
2233
  // Nobody runs `update --check`. A member sat four days behind a release that fixed the
1714
2234
  // very thing they were about to hit, with nothing on any screen saying so.
1715
2235
  try { out.update = chan.behind({ version: PKG.version, pinned: cfg.channel || null, tags: await chan.fetchTags() }); }
1716
2236
  catch { /* offline: say nothing rather than guess */ }
1717
- } catch (e) { out.connected = false; out.error = netReason(e); }
1718
-
1719
- if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); return out.connected ? 0 : 1; }
2237
+ } catch (e) {
2238
+ // Connected, but the rooms could not be read: name why (roomsProblem) instead of
2239
+ // reporting a reachable house as unreachable with ClickHouse's UNKNOWN_TABLE.
2240
+ // `connected` keeps its meaning — the house answered AND the rooms read — because
2241
+ // health checks gate on it; `reachable` says the server itself answered.
2242
+ out.reachable = out.connected;
2243
+ out.connected = false;
2244
+ const p = out.reachable ? await roomsProblem(cfg) : null;
2245
+ if (p) { out.rooms_problem = p.kind; out.problem = p.lines; } else out.error = netReason(e);
2246
+ }
2247
+ if (out.reachable === undefined) out.reachable = out.connected;
2248
+
2249
+ // A shipper that predates the switch or the install, and whose passes fail, is not a house
2250
+ // that is fine: say so in the exit code too, since health checks and agents gate on it.
2251
+ const staleFailing = !!(out.shipper.stale && out.shipper.lastPass && out.shipper.lastPass.outcome === 'failed');
2252
+ const healthy = out.connected && !staleFailing;
2253
+ if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); return healthy ? 0 : 1; }
1720
2254
  console.log(out.config ? ok(`config: ${out.config}`) : warn('no config (memhouse install)'));
1721
- console.log(out.connected
2255
+ console.log(out.reachable
1722
2256
  ? ok(`connected: ${cfg.url} / ${cfg.db} as ${cfg.user}`)
1723
2257
  : bad(`not connected: ${cfg.url} — ${out.error || 'no reason given'}`));
1724
- if (!out.connected) console.log(' diagnose it with: memhouse doctor');
1725
- if (out.member) console.log(ok(`rooms for '${out.member}'`));
2258
+ if (!out.reachable) console.log(' diagnose it with: memhouse doctor');
2259
+ if (out.problem) { console.log(bad(out.problem[0])); console.log(` ${out.problem[1]}`); }
2260
+ else if (out.reachable && out.error) { console.log(bad(`the house answered, but reading it failed: ${out.error}`)); console.log(' diagnose it with: memhouse doctor'); }
2261
+ if (out.member) console.log(ok(out.schema === 3 ? `schema 3 — tables for '${out.member}': ${Object.values(out.tables).join(', ')}` : `rooms for '${out.member}'`));
2262
+ if (out.source_roots) {
2263
+ const sr = out.source_roots;
2264
+ console.log(sr.error ? bad(`capture roots: ${sr.error}`)
2265
+ : sr.roots.length ? ok(`capture reads${sr.defaulted ? ' (default roots)' : ''}: ${sr.roots.map((r) => `${r.harness}:${r.name}=${short(r.dir)}`).join(', ')}`)
2266
+ : warn('capture reads nothing: no Claude Code projects dir or Codex sessions dir here (set MEMHOUSE_SOURCE_ROOTS)'));
2267
+ }
1726
2268
  // The member owns the rooms; the host says which of their machines wrote a row.
1727
- 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)` : ''}`));
2269
+ console.log(ok(`host: ${out.host.id}${out.host.label ? ` (${out.host.label})` : ''}${out.host.renamed ? ` (this machine now answers to '${out.host.current_hostname}' — the id is kept so its history stays one machine)` : ''}`));
1728
2270
  if (out.connected && !out.messages) console.log(ok(`house: empty — ${out.sessions} sessions, 0 messages (nothing shipped yet)`));
1729
- else if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${out.freshest} UTC)`));
2271
+ else if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${String(out.freshest).replace('T', ' ').replace(/\.\d+Z$/, '')} UTC)`));
1730
2272
  if (out.fleet && out.fleet.length) {
1731
2273
  // A writer appears in this list because it has rows — the list is GROUP BY over the
1732
2274
  // messages, not a registry — so a machine that was re-identified (before host ids were
@@ -1746,7 +2288,7 @@ async function cmdStatus() {
1746
2288
  : f.verdict === 'outdated' ? ' ⚠ supports an older schema — refusing every pass until updated'
1747
2289
  : f.verdict === 'stale' ? ' • stale' : '';
1748
2290
  const tag = retired.includes(f) ? ' · retired' : mark;
1749
- console.log(` ${f.writer.padEnd(28)} ${String(f.version || '?').padEnd(8)} last ship ${fleetAge(f.ageMs)}${tag}`);
2291
+ console.log(` ${f.writer.padEnd(28)} ${String(f.version || (f.harnesses ? f.harnesses.join(',') : '?')).padEnd(f.harnesses ? 18 : 8)} last ship ${fleetAge(f.ageMs)}${f.sessions !== undefined ? `, ${f.sessions} sessions` : ''}${tag}`);
1750
2292
  }
1751
2293
  if (retired.length && flags.all !== true) {
1752
2294
  const oldest = fleetAge(Math.max(...retired.map((f) => f.ageMs)));
@@ -1755,23 +2297,75 @@ async function cmdStatus() {
1755
2297
  }
1756
2298
  {
1757
2299
  const fail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(out.shipper.lastPass);
2300
+ const stale = out.shipper.running && out.shipper.stale;
1758
2301
  console.log(!out.shipper.running ? warn('shipper: not running')
2302
+ : stale ? (staleFailing ? bad : warn)(`shipper: running — ${out.shipper.via} — stale: ${stale.join('; ')}; it still runs what it read at start${staleFailing ? `, and its last pass failed (${out.shipper.lastPass.why.length > 90 ? `${out.shipper.lastPass.why.slice(0, 87)}…` : out.shipper.lastPass.why})` : ''}`)
1759
2303
  : fail ? warn(`shipper: running — ${out.shipper.via} — but ${fail}`)
1760
2304
  : ok(`shipper: running — ${out.shipper.via}`));
2305
+ if (stale) console.log(' restart it: memhouse stop && memhouse start');
1761
2306
  }
1762
2307
  if (out.update) console.log(warn(`update available: ${out.update.target} on '${out.update.channel}' — you have ${PKG.version} — run: memhouse update`));
1763
- console.log(out.daemons.dashboard ? ok(`dashboard: running (pid ${out.daemons.dashboard})`) : warn('dashboard: not running'));
1764
- if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${runningPort(cfg)}`);
2308
+ console.log(out.daemons.dashboard
2309
+ ? ok(`dashboard: running (pid ${out.daemons.dashboard}, port ${out.dashboard_port}) — open it with: ${out.dashboard_open}`)
2310
+ : warn('dashboard: not running'));
1765
2311
  // Exit non-zero when the house is unreachable. `status` is what a health check, a
1766
2312
  // cron, or an agent gates on; printing '✗ not connected' and exiting 0 tells every
1767
2313
  // one of them the house is fine.
1768
- return out.connected ? 0 : 1;
2314
+ return healthy ? 0 : 1;
2315
+ }
2316
+
2317
+ /**
2318
+ * `memhouse doctor --confirm-empty-store <harness>:<store>`: the store's sessions were deleted. A pass
2319
+ * refuses to take an empty list for that (it is what an unreadable store looks like); this records,
2320
+ * in this home's local state, that the next pass may, while the store is there and its list still
2321
+ * empty. Each session is then taken as deleted only on the editor's reader's answer that it is gone
2322
+ * (memhouse/shipper/adapter-capture.js, THE VANISH SWEEP).
2323
+ */
2324
+ function confirmEmptyStore(value) {
2325
+ const v = value === true || value == null ? '' : String(value);
2326
+ const i = v.indexOf(':');
2327
+ if (i <= 0 || i === v.length - 1) {
2328
+ console.error(bad('--confirm-empty-store takes <harness>:<store>, as the shipper log\'s "vanish refused" line names it'));
2329
+ return 2;
2330
+ }
2331
+ const harness = v.slice(0, i); const store = v.slice(i + 1);
2332
+ const ac = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'adapter-capture'));
2333
+ const pc = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'profile-capture'));
2334
+ const done = ac.confirmEmptyStore(HOME_DIR, pc.NS, harness, store);
2335
+ if (!done.confirmed.length) {
2336
+ console.error(bad(done.notRefused.length
2337
+ ? `${harness} ${store}: its last capture pass did not refuse it as empty, so there is nothing to confirm`
2338
+ : `${harness} ${store}: no capture pass of this home has swept that store, so there is nothing to confirm`));
2339
+ return 1;
2340
+ }
2341
+ const n = done.confirmed[0].n;
2342
+ console.log(ok(`${harness} ${store}: confirmed empty, for the ${n} session${n === 1 ? '' : 's'} its last pass refused. The next capture pass takes each as deleted when the editor's reader answers it is gone; nothing else is removed. If the house holds other sessions of it by then, the confirmation is dropped.`));
2343
+ if (!ac.storeReadable({ harness_path: store })) console.log(warn(`${store} is not there or cannot be read: the confirmation waits until it is, with its list still empty`));
2344
+ return 0;
1769
2345
  }
1770
2346
 
2347
+ // Prices ship in the package (pricing.json, refreshed for each release). A model newer than this
2348
+ // build has none until a release adds it; nothing on the machine can price it before then.
2349
+ // Whether `memhouse update` helps depends on whether a newer release exists: on the latest one,
2350
+ // sending the reader to update loops (update says current, the gap stays).
2351
+ let _behind;
2352
+ // Whether a newer release is out on this install's channel, read once per doctor
2353
+ // (memhouse/release-hint.js). fetchTags returns null offline; that is "unknown", not "current".
2354
+ async function releaseBehind(cfg) {
2355
+ if (_behind === undefined) {
2356
+ let tags = null;
2357
+ try { tags = await chan.fetchTags(); } catch { /* offline: unknown, not current */ }
2358
+ _behind = require(path.join(REPO_ROOT, 'memhouse', 'release-hint')).releaseState({ version: PKG.version, pinned: cfg.channel || null, tags });
2359
+ }
2360
+ return _behind;
2361
+ }
2362
+ function pricingHint(b) { return require(path.join(REPO_ROOT, 'memhouse', 'release-hint')).pricingHint(b, PKG.version); }
2363
+
1771
2364
  async function cmdDoctor() {
2365
+ if (flags['confirm-empty-store'] !== undefined) return confirmEmptyStore(flags['confirm-empty-store']);
1772
2366
  const cfg = resolveConfig();
1773
2367
  const checks = [];
1774
- const add = (okFlag, label, hint) => checks.push({ ok: okFlag, label, hint });
2368
+ const add = (okFlag, label, hint, o = {}) => checks.push({ ok: okFlag, label, hint, ...o });
1775
2369
 
1776
2370
  // 24 because SQLite comes from `node:sqlite`, which is stable there. It works on 22.13+
1777
2371
  // but prints an ExperimentalWarning on every command, and 20 is past EOL. Below the
@@ -1795,8 +2389,25 @@ async function cmdDoctor() {
1795
2389
  }
1796
2390
  return renderDoctor(checks);
1797
2391
  }
1798
- try { await ch(cfg, 'SELECT 1', { database: '' }); add(true, `clickhouse reachable (${cfg.url})`); }
1799
- catch (e) { add(false, `clickhouse reachable (${cfg.url})`, netReason(e)); }
2392
+ let reachable = false;
2393
+ try { await ch(cfg, 'SELECT 1', { database: '' }); add(true, `clickhouse reachable (${cfg.url})`); reachable = true; }
2394
+ catch (e) {
2395
+ // Reached, and the credential refused (ClickHouse's code): named as that, with its fix.
2396
+ add(false, `clickhouse reachable (${cfg.url})`, schemaChoice.isAuthFailure(e)
2397
+ ? `${schemaChoice.authFailureText(cfg.user, cfg.url)} — check MEMHOUSE_USER and MEMHOUSE_PASSWORD in ${short(ENV_FILE)}`
2398
+ : netReason(e));
2399
+ }
2400
+ // The tables are not where this install looks (moved to schema 3, not yet moved, an
2401
+ // administrator credential with no rooms, none at all): say which, with the fix, and skip the
2402
+ // room checks, which would only repeat ClickHouse's UNKNOWN_TABLE at each one.
2403
+ const problem = reachable ? await roomsProblem(cfg) : null;
2404
+ if (problem) {
2405
+ add(false, problem.lines[0], problem.lines[1]);
2406
+ add(false, 'room checks', 'skipped — see the line above');
2407
+ await doctorTail(cfg, add);
2408
+ return renderDoctor(checks);
2409
+ }
2410
+ if (isV3(cfg)) return doctorV3(cfg, checks, add);
1800
2411
  let rooms = null;
1801
2412
  // Room resolution asks the server who you are (SELECT currentUser()). It fails when
1802
2413
  // the house is unreachable or the credential is rejected — both already reported by
@@ -1913,7 +2524,7 @@ async function cmdDoctor() {
1913
2524
  // What the house says about ITSELF — its schema generation and whether a rebuild was
1914
2525
  // left half-done. A migration that failed between the copy and the swap leaves rooms
1915
2526
  // that still work and a `<room>__migrating` nobody would notice; the events table is
1916
- // the only place that shows it, so doctor reads it rather than the pilot.
2527
+ // the only place that shows it, so doctor reads it rather than the user.
1917
2528
  try {
1918
2529
  const meta = new Map((await chRows(cfg, `SELECT key, value FROM ${physicalRoom('meta', cfg.user)} FINAL`))
1919
2530
  .map((m) => [m.key, String(m.value)]));
@@ -1925,7 +2536,7 @@ async function cmdDoctor() {
1925
2536
  // The ROOMS are the truth; this table is the paperwork. A house whose keys are
1926
2537
  // already current but whose record is missing — a fresh install by an older client,
1927
2538
  // or a member with no rights on meta — is not un-migrated, and telling it to
1928
- // run migrate-rooms sends the pilot to rebuild rooms that are already correct.
2539
+ // run migrate-rooms sends the user to rebuild rooms that are already correct.
1929
2540
  const recorded = at === String(SCHEMA_VERSION);
1930
2541
  add(!stuck && (recorded || keysCorrect),
1931
2542
  stuck ? `house record: migration ${last.id} is ${last.status} (last touched ${last.at})`
@@ -1945,7 +2556,7 @@ async function cmdDoctor() {
1945
2556
  // A pre-0.10 memhouse on ANOTHER machine reads none of this house's record and still
1946
2557
  // deletes before re-inserting — on migrated rooms that delete reaches every retained
1947
2558
  // parse of a session it re-ships. It cannot be stopped by code here; it can only be
1948
- // named, loudly, where the pilot looks.
2559
+ // named, loudly, where the user looks.
1949
2560
  try {
1950
2561
  const fleet = await fleetState(cfg);
1951
2562
  if (fleet && fleet.length) {
@@ -2079,11 +2690,10 @@ async function cmdDoctor() {
2079
2690
  // beside a real number reads as "nothing is wrong", which is the opposite of the point.
2080
2691
  const pct = total ? (missed / total) * 100 : 0;
2081
2692
  const share = pct >= 0.5 ? `${Math.round(pct)}% of messages` : `${missed} message${missed === 1 ? '' : 's'}`;
2082
- add(unpriced.length === 0,
2083
- unpriced.length === 0
2084
- ? `pricing: every model in this house has a price (${rows.length} models)`
2085
- : `pricing: ${unpriced.length} model${unpriced.length > 1 ? 's' : ''} unpriced — ${share} cost nothing in the dashboard (${unpriced.map((r) => r.model).join(', ')})`,
2086
- 'node sync-pricing.js --write (then re-run doctor)');
2693
+ // A gap in the cost figures, not a fault in the house: a warning, never a failure.
2694
+ if (!unpriced.length) add(true, `pricing: every model in this house has a price (${rows.length} models)`);
2695
+ else add(true, `pricing: ${unpriced.length} model${unpriced.length > 1 ? 's' : ''} unpriced — ${share} cost nothing in the dashboard (${unpriced.map((r) => r.model).join(', ')})`,
2696
+ pricingHint(await releaseBehind(cfg)), { warn: true });
2087
2697
 
2088
2698
  // A source whose rows carry NO tokens costs nothing in the dashboard, which reads as
2089
2699
  // "this editor is free" rather than "this editor's usage was never parsed". That is
@@ -2092,7 +2702,7 @@ async function cmdDoctor() {
2092
2702
  // case: editors/zed.js contains no usage handling at all.
2093
2703
  // origin='ship' only. Imported history came from another product that may never have
2094
2704
  // recorded usage — on this machine four of the five zero-token sources are imports
2095
- // from memory-house, which is a fact about the past, not an adapter that needs fixing.
2705
+ // from the predecessor tool, which is a fact about the past, not an adapter that needs fixing.
2096
2706
  // Naming them alongside a live adapter gap makes the real one easy to dismiss.
2097
2707
  const zero = await chRows(cfg,
2098
2708
  `SELECT source, count() AS n FROM ${rooms.messages_raw} FINAL WHERE role = 'assistant' AND origin = 'ship' GROUP BY source HAVING sum(input_tokens) + sum(output_tokens) = 0 ORDER BY source`);
@@ -2109,30 +2719,406 @@ async function cmdDoctor() {
2109
2719
  const moved = await adoptMovedHouse(cfg, { fix: false });
2110
2720
  if (moved === 'moved') add(false, `rooms moved to mem.${physicalRoom('messages', cfg.user).replace(/_messages$/, '')}_* — this env still says MEMHOUSE_DB=${cfg.db}`, 'memhouse update (rewrites the env and restarts the shipper)');
2111
2721
  } catch { /* connection problems are reported above */ }
2722
+ await doctorTail(cfg, add);
2723
+ return renderDoctor(checks);
2724
+ }
2725
+
2726
+ // What every doctor ends with, on either schema: the release, the daemons, the UI.
2727
+ async function doctorTail(cfg, add) {
2112
2728
  {
2113
2729
  // The same shape doctor gives everything else: what is wrong, and the command.
2114
- let up = null;
2115
- try { up = chan.behind({ version: PKG.version, pinned: cfg.channel || null, tags: await chan.fetchTags() }); } catch { /* offline */ }
2730
+ const { up } = await releaseBehind(cfg);
2116
2731
  if (up) add(false, `memhouse ${PKG.version} — ${up.target} is out on '${up.channel}'`, 'memhouse update');
2117
2732
  }
2118
2733
  const sh = shipperHealth();
2119
2734
  const shFail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(sh.lastPass);
2120
- add(sh.running && !shFail, `shipper${sh.via ? ` — ${sh.via}` : ''}${shFail ? ` — ${shFail}` : ''}`,
2735
+ add(sh.running && !shFail, `shipper${sh.via ? ` — ${sh.via}` : ''}${shFail ? ` — ${shFail}` : ''}${isV3(cfg) && sh.running && !shFail ? ' (schema-3 capture)' : ''}`,
2121
2736
  shFail ? (sh.lastPass.auth ? 'memhouse stop && memhouse start' : 'read: ~/.memhouse/logs/shipper.log') : 'memhouse start (or: memhouse service install)');
2122
- add(!!pidOf('dashboard'), 'dashboard daemon', 'memhouse start');
2123
- add(fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html')), 'dashboard UI built', 'built automatically by memhouse start');
2737
+ // The last pass's vanish sweeps (passlog.js vanishChecks): a refused one fails, naming the command an
2738
+ // emptied store needs; a listed session's source its list left out that still read is a warning.
2739
+ for (const c of require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).vanishChecks(sh.lastPass)) add(c.ok, c.label, c.hint, { warn: !!c.warn });
2740
+ // The dashboard is optional: the house, the capture and the /mem skills work without it. Not
2741
+ // running is said as a warning, never a failure: doctor's exit is the house's health, and a
2742
+ // reader chasing "memory looks wrong" must not be sent after a page nobody opened.
2743
+ if (pidOf('dashboard')) add(true, 'dashboard daemon');
2744
+ else add(true, 'dashboard daemon: not running — optional; memhouse start runs it', undefined, { warn: true });
2745
+ if (fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html'))) add(true, 'dashboard UI built');
2746
+ else add(true, 'dashboard UI: not built — optional; memhouse start builds it', undefined, { warn: true });
2747
+ // The /mem skills bound to this home: older than this memhouse means agents are taught a
2748
+ // layout and verbs this version does not have. A first run refreshes them (plugin-copies.js);
2749
+ // doctor names any that a refresh could not reach.
2750
+ try {
2751
+ const stale = pluginCopies.staleCopies({ home: HOME_DIR, version: PKG.version });
2752
+ const mine = pluginCopies.claudeTargets().filter((t) => pluginCopies.isPluginInstalled(t.dir) && pluginCopies.boundHome(t.dir) && fs.realpathSync(pluginCopies.boundHome(t.dir)) === fs.realpathSync(HOME_DIR));
2753
+ if (stale.length) add(false, `/mem skills older than memhouse ${PKG.version} in ${stale.map((s) => `${short(s.dir)} (${s.have || 'unknown version'})`).join(', ')}`, 'memhouse plugins install claude, then /reload-plugins in Claude Code');
2754
+ else if (mine.length) add(true, `/mem skills ${PKG.version} in ${mine.map((t) => short(t.dir)).join(', ')}`);
2755
+ } catch { /* nothing installed, or a home that does not exist yet */ }
2756
+ }
2124
2757
 
2758
+ /**
2759
+ * doctor on a schema-3 install: the five v3 tables (memhouse/house/doctor-v3.js), what the
2760
+ * capture will read, and the shared tail. Nothing here reads the 0.18 rooms. `--full` widens
2761
+ * the identity and duplicate-ts checks from the last 14 days to the whole tables.
2762
+ */
2763
+ async function doctorV3(cfg, checks, add) {
2764
+ let t; let rooms = null;
2765
+ try { t = v3Transport(cfg, { timeout: flags.full === true ? 600000 : 60000 }); rooms = await v3RoomsFor(cfg, t); }
2766
+ catch (e) { add(false, 'member', `${e.message} — fix the connection above first`); }
2767
+ if (rooms) {
2768
+ add(true, `member is '${rooms.member}' — schema 3: ${rooms.sessions}, ${rooms.parts}, ${rooms.usage}, ${rooms.calls} (+ ${cfg.db}_archive.${rooms.member}_session_units, _session_sources)`);
2769
+ const hostMod = require(path.join(REPO_ROOT, 'memhouse', 'host.js'));
2770
+ const me = hostMod.identity(HOME_DIR);
2771
+ // Schema 3 keys rows by sessions-model/host/v0; memhouse's own id is this machine's pre-0.20 alias.
2772
+ const sessionsHost = process.env.MEMHOUSE_HOST_ID || hostMod.sessionsHost();
2773
+ try {
2774
+ for (const c of await require(path.join(REPO_ROOT, 'memhouse', 'house', 'doctor-v3')).houseChecks(t, {
2775
+ db: cfg.db, member: rooms.member, full: flags.full === true, hostId: sessionsHost, hostAliases: me.id !== sessionsHost ? [me.id] : [],
2776
+ user: cfg.user, url: cfg.url, pricingHint: pricingHint(await releaseBehind(cfg)),
2777
+ })) add(c.ok, c.label, c.hint, c.warn ? { warn: true } : {});
2778
+ } catch (e) { add(false, 'v3 house checks', e.message); }
2779
+ }
2780
+ // The package that captures Claude Code and Codex on the profile layout. Missing, a pass ships
2781
+ // nothing for them; doctor says so instead of letting an empty house look healthy.
2782
+ {
2783
+ const ac = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'adapter-capture'));
2784
+ const st = ac.packageStatus();
2785
+ const said = st.ok ? null : ac.packageMessage(st.error); // loadPackage's own errors say what and where
2786
+ add(st.ok, st.ok ? `harness-session-adapters ${st.version || '(version unknown)'}: Claude Code and Codex are captured` : said,
2787
+ st.ok || said === st.error ? undefined : `${st.error} — install @agent-realm/harness-session-adapters where memhouse is installed, or set MEMHOUSE_SESSION_ADAPTERS to a package checkout for development`);
2788
+ }
2789
+ // What the capture reads — the schema-3 counterpart of the 0.18 adapters check.
2790
+ try {
2791
+ const sr = sourceRootsFor(cfg);
2792
+ if (sr.error) add(false, `capture roots: ${sr.error}`, 'fix MEMHOUSE_SOURCE_ROOTS / MEMHOUSE_CLAUDE_ROOTS in the env file');
2793
+ else if (!sr.roots.length) add(false, 'capture roots: none — no Claude Code projects dir or Codex sessions dir found', 'set MEMHOUSE_SOURCE_ROOTS (comma-separated dirs) — memhouse setup --source-roots …');
2794
+ else {
2795
+ const ads = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'capture')).defaultAdapters();
2796
+ const per = sr.roots.map((r) => { let n = 0; try { n = ads[r.harness].sessions(r).length; } catch { /* unreadable */ } return `${r.harness}:${r.name} ${n}`; });
2797
+ add(true, `capture roots${sr.defaulted ? ' (default)' : ''}: ${per.join(', ')} sessions on disk — compare with what the house holds: memhouse stats`);
2798
+ }
2799
+ } catch (e) { add(false, 'capture roots', e.message); }
2800
+ await doctorTail(cfg, add);
2125
2801
  return renderDoctor(checks);
2126
2802
  }
2127
2803
 
2128
2804
  function renderDoctor(checks) {
2129
- for (const c of checks) console.log(c.ok ? ok(c.label) : bad(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`));
2805
+ for (const c of checks) console.log(c.warn ? warn(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`) : c.ok ? ok(c.label) : bad(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`));
2130
2806
  return checks.every((c) => c.ok) ? 0 : 1;
2131
2807
  }
2132
2808
 
2809
+ // ── schema 3: the named queries from a shell ────────────────────────────────────
2810
+ //
2811
+ // `memhouse q <name>` runs one named query of memhouse/house/queries-v3.js (see q-cli.js for
2812
+ // the names and their flags) — what the /mem skills call on a schema-3 house instead of SQL
2813
+ // over the 0.18 rooms. `memhouse room-sql` is the escape hatch: read-only SQL over the member's
2814
+ // tables by their bare names. Neither ever sets `final=1`; FINAL is per table, in the SQL.
2815
+
2816
+ function qc() { return require(path.join(REPO_ROOT, 'memhouse', 'house', 'q-cli')); }
2817
+
2818
+ /** The roots a schema-3 capture on this install reads (the env file's scope applied). */
2819
+ function sourceRootsFor(cfg) {
2820
+ const sr = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'source-roots'));
2821
+ // discoverClaudeRoots reads process.env, so this install's scope goes there for the call.
2822
+ const keep = { c: process.env.MEMHOUSE_CLAUDE_ROOTS, e: process.env.MEMHOUSE_EDITORS };
2823
+ process.env.MEMHOUSE_CLAUDE_ROOTS = cfg.claudeRoots || '';
2824
+ process.env.MEMHOUSE_EDITORS = cfg.editors || '';
2825
+ try {
2826
+ return sr.effectiveSourceRoots({ env: { ...process.env, MEMHOUSE_SOURCE_ROOTS: cfg.sourceRoots || '' } });
2827
+ } finally {
2828
+ for (const [k, v] of [['MEMHOUSE_CLAUDE_ROOTS', keep.c], ['MEMHOUSE_EDITORS', keep.e]]) {
2829
+ if (v === undefined) delete process.env[k]; else process.env[k] = v;
2830
+ }
2831
+ }
2832
+ }
2833
+
2834
+ function qError(e, extra = {}) {
2835
+ const usage = e && (e.status === 400 || e.name === 'QueryInputError');
2836
+ if (JSON_OUT) console.log(JSON.stringify({ error: usage ? (e.notFound ? 'not_found' : 'bad_request') : 'query_failed', message: e.message, ...extra }));
2837
+ else console.log(bad(e.message));
2838
+ return usage && !e.notFound ? 2 : 1;
2839
+ }
2840
+
2841
+ async function cmdQ() {
2842
+ const cfg = resolveConfig();
2843
+ let parsed;
2844
+ try { parsed = qc().parseQ(positional, flags); } catch (e) { return qError(e); }
2845
+ const schema = isV3(cfg) ? 3 : 2;
2846
+ if (parsed.name === 'schema' && !cfg.stated) {
2847
+ // With no house there is no schema to report. "2" here read as a fact about a house that
2848
+ // does not exist, and the skills branch on this answer.
2849
+ if (JSON_OUT) console.log(JSON.stringify({ schema: null, source: null, configured: false }));
2850
+ else console.log('none (no house configured: memhouse install)');
2851
+ return 0;
2852
+ }
2853
+ if (parsed.name === 'schema' || parsed.name === 'list') {
2854
+ const r = await qc().runQ(null, null, parsed, { schema });
2855
+ if (JSON_OUT) console.log(JSON.stringify(parsed.name === 'schema'
2856
+ ? { schema, source: qc().schemaOf({ env: process.env, file: readEnvFile() }).source, configured: cfg.stated } : r));
2857
+ else for (const l of qc().renderQ(parsed.name, r)) console.log(l);
2858
+ return 0;
2859
+ }
2860
+ if (schema !== 3) {
2861
+ const msg = 'memhouse q reads the schema-3 tables (harness-sessions-sql\'s profile: sessions, session_parts, session_usage, calls); this install is on the 0.18 rooms (MEMHOUSE_SCHEMA is not 3). Use the SQL in the /mem skills, or memhouse search / stats.';
2862
+ if (JSON_OUT) console.log(JSON.stringify({ error: 'schema', schema, message: msg })); else console.log(bad(msg));
2863
+ return 2;
2864
+ }
2865
+ requireConfig(cfg, 'q');
2866
+ try {
2867
+ const t = v3Transport(cfg);
2868
+ const rooms = await v3RoomsFor(cfg, t, parsed.member);
2869
+ const r = await qc().runQ(t.exec, rooms, parsed, { schema });
2870
+ if (JSON_OUT) console.log(JSON.stringify(r));
2871
+ else for (const l of qc().renderQ(parsed.name, r)) console.log(l);
2872
+ return r && r.error ? 1 : 0;
2873
+ } catch (e) { return qError(e); }
2874
+ }
2875
+
2876
+ async function cmdRoomSql() {
2877
+ const cfg = resolveConfig();
2878
+ let sql = positional.join(' ');
2879
+ if ((!sql || sql === '-') && !process.stdin.isTTY) { try { sql = fs.readFileSync(0, 'utf-8'); } catch { sql = ''; } }
2880
+ if (sql === '-') sql = '';
2881
+ requireConfig(cfg, 'room-sql');
2882
+ const schema = isV3(cfg) ? 3 : 2;
2883
+ const names = schema === 3 ? ['sessions', 'session_parts', 'session_usage', 'session_links', 'session_lifecycle', 'calls', 'meta', 'events'] : ['messages', 'tool_calls', 'sessions', 'meta', 'events'];
2884
+ try {
2885
+ const t = v3Transport(cfg, { timeout: 120000 });
2886
+ let member = flags.member && flags.member !== true ? String(flags.member) : (process.env.MEMHOUSE_MEMBER || readEnvFile().MEMHOUSE_MEMBER || null);
2887
+ if (!member) member = ((await t.q.rows('SELECT currentUser() AS u'))[0] || {}).u;
2888
+ if (!sql.trim()) {
2889
+ const map = Object.fromEntries(names.map((n) => [n, `${cfg.db}.${member}_${n}`]));
2890
+ if (JSON_OUT) { console.log(JSON.stringify({ schema, member, database: cfg.db, tables: map })); return 0; }
2891
+ console.log(`memhouse room-sql "<SELECT …>" (or SQL on stdin) — read-only, bare names resolve for '${member}' (schema ${schema}):`);
2892
+ for (const [n, fq] of Object.entries(map)) console.log(` ${n.padEnd(17)} → ${fq}`);
2893
+ if (schema === 3) console.log('\n Read them FINAL and filter is_deleted = 0 AND removed = 0; a session is (host, harness, harness_path, session_id, incarnation).');
2894
+ return 0;
2895
+ }
2896
+ const body0 = qc().rewriteRooms(sql, { member, db: cfg.db, schema });
2897
+ qc().assertRead(body0);
2898
+ if (flags.print === true) { console.log(body0); return 0; }
2899
+ const stripped = body0.trim().replace(/;\s*$/, '');
2900
+ if (JSON_OUT) {
2901
+ if (qc().hasFormat(stripped)) return qError(Object.assign(new Error('--json picks the format itself — drop the FORMAT clause'), { status: 400 }));
2902
+ const text = await t.raw(`${stripped}\nFORMAT JSONEachRow`, { database: cfg.db });
2903
+ console.log(JSON.stringify(text.split('\n').filter(Boolean).map((l) => JSON.parse(l))));
2904
+ return 0;
2905
+ }
2906
+ const text = await t.raw(qc().hasFormat(stripped) ? stripped : `${stripped}\nFORMAT PrettyCompact`, { database: cfg.db });
2907
+ process.stdout.write(text.endsWith('\n') || !text ? text : `${text}\n`);
2908
+ return 0;
2909
+ } catch (e) { return qError(e); }
2910
+ }
2911
+
2912
+ // `memhouse import claude-ai`: a claude.ai export into the house (memhouse/import/). A dry run
2913
+ // unless --write; --undo <batch> tombstones a batch, --list shows them. Counts only, never content.
2914
+ const IMPORT_USAGE = [
2915
+ 'usage: memhouse import claude-ai <export zips… | folder of export zips | unpacked export folder | archive folder> [--update] [--batch ID] [--write]',
2916
+ ' memhouse import claude-ai --undo <batch> [--write]',
2917
+ ' memhouse import claude-ai --list',
2918
+ ' A dry run unless --write. Guide: docs/guides/import-claude-ai.md',
2919
+ ];
2920
+
2921
+ function importOut(obj, lines) {
2922
+ if (JSON_OUT) console.log(JSON.stringify(obj, null, 2));
2923
+ else for (const l of lines) console.log(l);
2924
+ }
2925
+
2926
+ async function cmdImport() {
2927
+ const usage = (msg) => {
2928
+ if (JSON_OUT) console.log(JSON.stringify({ error: 'usage', message: msg }));
2929
+ else { if (msg) console.log(bad(msg)); for (const l of IMPORT_USAGE) console.log(l); }
2930
+ return 2;
2931
+ };
2932
+ if (positional[0] !== 'claude-ai') return usage(positional[0] ? `nothing imports from '${positional[0]}' — the one source is claude-ai` : '');
2933
+ const listing = flags.list === true;
2934
+ const undoing = flags.undo !== undefined;
2935
+ // The export arrives as part zips (conversations-000.zip, design_chats-000.zip, …): several
2936
+ // inputs are the parts of one export.
2937
+ const inputs = positional.slice(1);
2938
+ const input = inputs.length > 0;
2939
+ if (flags.undo === true) return usage('--undo takes the batch to undo (memhouse import claude-ai --list shows them)');
2940
+ if (flags.batch === true) return usage('--batch takes a batch id');
2941
+ if ([listing, undoing, input].filter(Boolean).length !== 1) return usage('give one of: the export (its zips, or a folder), --undo <batch>, --list');
2942
+ if ((listing || undoing) && (flags.update === true || flags.batch !== undefined)) return usage('--update and --batch go with an input path');
2943
+ if (listing && flags.write === true) return usage('--list writes nothing; drop --write');
2944
+ const cfg = resolveConfig();
2945
+ if (!isV3(cfg)) return usage('import writes schema-3 tables; this install is on the 0.18 rooms (MEMHOUSE_SCHEMA is not 3)');
2946
+ requireConfig(cfg, 'import');
2947
+ const IM = require(path.join(REPO_ROOT, 'memhouse', 'import', 'import'));
2948
+ const CA = require(path.join(REPO_ROOT, 'memhouse', 'import', 'claude-ai'));
2949
+ const V3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-v3'));
2950
+ const fail = (e) => {
2951
+ const refusal = e instanceof IM.ImportRefusal || e instanceof CA.ImportInputError;
2952
+ if (JSON_OUT) console.log(JSON.stringify({ error: refusal ? 'refused' : 'failed', message: e.message }));
2953
+ else console.log(bad(e.message));
2954
+ return refusal ? 2 : 1;
2955
+ };
2956
+ let parsed = null;
2957
+ if (input) {
2958
+ try { parsed = CA.readInput(inputs.map((p) => p.replace(/^~(?=\/|$)/, os.homedir()))); } catch (e) { return fail(e); }
2959
+ }
2960
+ try {
2961
+ const t = v3Transport(cfg, { timeout: 600000 });
2962
+ const rooms = await v3RoomsFor(cfg, t);
2963
+ const names = V3.v3Names(cfg.db, rooms.member);
2964
+ const house = IM.houseOver({ rows: (sql) => t.q.rows(sql), write: (sql, settings) => t.raw(sql, { write: true, settings }) });
2965
+ if (listing) {
2966
+ const batches = await IM.list(house, names);
2967
+ importOut({ member: names.member, batches }, batches.length
2968
+ ? [`claude.ai import batches for '${names.member}':`, ...batches.map((b) => ` ${b.batch.padEnd(32)} ${String(b.sessions).padStart(6)} sessions ${String(b.messages).padStart(7)} messages ${b.first || '-'} .. ${b.last || '-'}${b.status ? ` (${b.status})` : ''}`)]
2969
+ : [warn(`no claude.ai import batch in '${names.member}'`)]);
2970
+ return 0;
2971
+ }
2972
+ if (undoing) {
2973
+ const batch = IM.checkBatchId(flags.undo);
2974
+ const r = await IM.undo(house, names, batch, { write: flags.write === true, version: PKG.version });
2975
+ const b = r.before;
2976
+ const what = `${b.sessions} sessions, ${b.turns} turns, ${b.calls} calls`;
2977
+ if (!b.sessions && !b.turns && !b.calls && !r.finishing) { importOut(r, [warn(`batch '${batch}' holds no live rows — nothing to undo`)]); return 0; }
2978
+ if (!r.written) {
2979
+ importOut({ ...r, dry_run: true }, [`undo ${batch} — dry run, nothing written`,
2980
+ r.finishing ? ' an undo of it stopped after its last tombstone; it would be recorded as undone' : ` would tombstone ${what}`,
2981
+ ` to do it: memhouse import claude-ai --undo ${batch} --write`]);
2982
+ return 0;
2983
+ }
2984
+ if (r.finishing) { importOut(r, [ok(`undo ${batch}: its rows were already tombstoned; recorded as undone`)]); return 0; }
2985
+ const left = r.after.sessions + r.after.turns + r.after.calls;
2986
+ importOut(r, [left ? bad(`undo ${batch}: ${left} live rows remain`) : ok(`undo ${batch}: tombstoned ${what}; FINAL reads no longer show them`)]);
2987
+ return left ? 1 : 0;
2988
+ }
2989
+ const p = await IM.plan(house, names, parsed, { update: flags.update === true });
2990
+ const batch = flags.batch !== undefined ? IM.checkBatchId(flags.batch) : IM.newBatchId();
2991
+ const cats = Object.entries(p.input.categories).map(([k, v]) => `${k} ${v}`).join(', ');
2992
+ const skipped = Object.entries(p.skipped_blocks).sort().map(([k, v]) => `${k} ${v}`).join(', ');
2993
+ const s = p.sessions;
2994
+ const repairN = p.repair.length;
2995
+ const stopped = p.stopped_batches;
2996
+ const report = [
2997
+ ` input ${p.input.kind}: ${p.input.conversations} conversations${cats ? ` (${cats})` : ''}${p.input.kind === 'parts' ? ` from ${p.input.read.join(', ')}` : ''}`,
2998
+ ...(p.input.ignored.length ? [` ignored ${p.input.ignored.join(', ')} (not conversations)`] : []),
2999
+ ` house '${names.member}', layout ${p.layout}: ${p.house.claude_ai_sessions} claude.ai sessions`,
3000
+ ` sessions new ${s.new} · already imported ${s.same}${s.older ? ` (${s.older} older in the input than in the house)` : ''} · changed since ${s.changed}${s.changed ? ` (${s.changed_newer} newer, ${s.changed_content} same time, other content)` : ''}${s.changed && !s.update ? ', not written; --update rewrites them' : ''} · empty ${s.empty}${s.duplicates ? ` · duplicates ${s.duplicates}` : ''}`,
3001
+ ` ${flags.write === true ? 'writing' : 'would write'} ${p.write.sessions} sessions, ${p.write.turns} turns, ${p.write.calls} calls${p.write.rewritten ? ` (${p.write.rewritten} rewritten over earlier rows)` : ''}`,
3002
+ ...(p.span ? [` span ${p.span[0]} .. ${p.span[1]}`] : []),
3003
+ ...(skipped ? [` not stored ${skipped}`] : []),
3004
+ ...(repairN ? [` repair ${repairN} session row(s) a stopped run left unfilled: ${flags.write === true ? 'refreshing' : 'a --write refreshes them'}`] : []),
3005
+ ...(stopped.length ? [` stopped batch ${stopped.join(', ')}: its rows went in, its run did not finish; ${flags.write === true ? 'recording it applied' : 'a --write records it applied'}`] : []),
3006
+ ];
3007
+ // A repair alone uses no batch.
3008
+ const summary = { ...p, work: undefined, batch: p.write.sessions ? batch : null };
3009
+ if (flags.write !== true) {
3010
+ importOut({ ...summary, dry_run: true }, ['memhouse import claude-ai — dry run, nothing written', ...report,
3011
+ ...(p.write.sessions || repairN || stopped.length ? [` to ${p.write.sessions ? 'write it' : 'repair'}: memhouse import claude-ai ${inputs.map((x) => (/^[\w@%+=:,./~-]+$/.test(x) ? x : `'${x.replace(/'/g, "'\\''")}'`)).join(' ')} --write${s.update ? ' --update' : ''}`] : [ok('nothing to write')])]);
3012
+ return 0;
3013
+ }
3014
+ if (!p.write.sessions) {
3015
+ // Nothing to write; the rows a stopped run left are still refreshed (no batch is recorded).
3016
+ const r = repairN || stopped.length ? await IM.apply(house, names, p, { batch, version: PKG.version }) : null;
3017
+ const done = [...(r && r.repaired ? [`${r.repaired} session row(s) refreshed`] : []), ...(r && r.completed.length ? [`batch ${r.completed.join(', ')} applied`] : [])];
3018
+ importOut({ ...summary, batch: null, written: null, repaired: r ? r.repaired : 0, completed: r ? r.completed : [] }, ['memhouse import claude-ai', ...report,
3019
+ ok(done.length ? `nothing to write; ${done.join('; ')}` : 'nothing to write')]);
3020
+ return 0;
3021
+ }
3022
+ if (!JSON_OUT) { console.log(`memhouse import claude-ai — batch ${batch}`); for (const l of report) console.log(l); }
3023
+ const r = await IM.apply(house, names, p, { batch, version: PKG.version, onProgress: (m) => { if (!JSON_OUT) console.log(` ${m}`); } });
3024
+ const c = r.counts;
3025
+ const match = c.sessions === r.written.sessions && c.turns === r.written.turns && c.calls === r.written.calls;
3026
+ importOut({ ...summary, ...r }, [
3027
+ match ? ok(`batch ${batch}: ${c.sessions} sessions, ${c.messages} messages, ${c.turns} turns, ${c.calls} calls read back`)
3028
+ : bad(`batch ${batch}: read back ${c.sessions} sessions, ${c.turns} turns, ${c.calls} calls against ${r.written.sessions}, ${r.written.turns}, ${r.written.calls} written`),
3029
+ ...(r.completed.length ? [` stopped batch ${r.completed.join(', ')} applied`] : []),
3030
+ ` undo: memhouse import claude-ai --undo ${batch} --write`,
3031
+ ]);
3032
+ return match ? 0 : 1;
3033
+ } catch (e) { return fail(e); }
3034
+ }
3035
+
3036
+ async function cmdSearchV3(cfg) {
3037
+ const p = { ...flags };
3038
+ delete p.json;
3039
+ try {
3040
+ const parsed = qc().parseQ(['search', ...positional], p);
3041
+ const t = v3Transport(cfg);
3042
+ const r = await qc().runQ(t.exec, await v3RoomsFor(cfg, t), parsed);
3043
+ if (JSON_OUT) console.log(JSON.stringify(r.hits, null, 2));
3044
+ else if (!r.hits.length) console.log(warn(`no matches for ${r.terms.map((x) => `'${x}'`).join(' and ')}`));
3045
+ else for (const l of qc().renderQ('search', r)) console.log(l);
3046
+ return 0;
3047
+ } catch (e) { return qError(e); }
3048
+ }
3049
+
3050
+ async function cmdResumeV3(cfg) {
3051
+ const { resumeFor } = require(path.join(REPO_ROOT, 'memhouse', 'resume'));
3052
+ let rows; let label;
3053
+ try {
3054
+ const t = v3Transport(cfg);
3055
+ const rooms = await v3RoomsFor(cfg, t);
3056
+ const ref = qc().parseRef(positional[0], flags);
3057
+ label = rooms.sessions;
3058
+ rows = await t.exec(qc().resumeSpec(rooms, ref));
3059
+ } catch (e) { return qError(e); }
3060
+ // The ref names the session's store, so the same id on two hosts or in two stores is never confused.
3061
+ const view = (r) => ({ session_id: require(path.join(REPO_ROOT, 'memhouse', 'house', 'queries-v3')).sessionRef(r), harness: r.harness, native_id: r.session_id, source: r.harness, folder: r.folder, name: r.name, origin: r.origin, at: r.ended_ms ? new Date(Number(r.ended_ms)).toISOString().slice(0, 16).replace('T', ' ') : null });
3062
+ if (!rows.length) {
3063
+ if (JSON_OUT) return console.log(JSON.stringify({ error: 'no such session', session_id: positional[0] }, null, 2)), 1;
3064
+ console.log(bad(`no session '${positional[0]}' in ${label}`));
3065
+ console.log(' find one with: memhouse search <terms>');
3066
+ return 1;
3067
+ }
3068
+ if (rows.length > 1) {
3069
+ if (JSON_OUT) return console.log(JSON.stringify({ error: 'ambiguous', matches: rows.map(view) }, null, 2)), 1;
3070
+ console.log(warn(`'${positional[0]}' matches ${rows.length} sessions — name one exactly:`));
3071
+ for (const r of rows.map(view)) console.log(` ${r.session_id} ${r.at || '-'} ${r.name || '-'}`);
3072
+ return 1;
3073
+ }
3074
+ const row = view(rows[0]);
3075
+ const res = resumeFor(row);
3076
+ if (JSON_OUT) return console.log(JSON.stringify({ ...row, ...res }, null, 2)), (res.ok ? 0 : 1);
3077
+ console.log(`\x1b[1m${row.session_id}\x1b[0m ${row.at || '-'} ${row.name || '-'}`);
3078
+ if (!res.ok) { console.log(bad(res.reason)); if (res.folder) console.log(` the session ran in: ${res.folder}`); return 1; }
3079
+ console.log('');
3080
+ console.log(` ${res.command}`);
3081
+ console.log('');
3082
+ if (!res.folder) console.log(warn('no folder was recorded for this session — run it from the right directory yourself'));
3083
+ return 0;
3084
+ }
3085
+
3086
+ async function cmdStats(cfg) {
3087
+ requireConfig(cfg, 'stats');
3088
+ // Same reasons status names, before a query (or the shipper's --stats) meets the missing
3089
+ // tables and passes ClickHouse's error and SQL through.
3090
+ const problem = await roomsProblem(cfg);
3091
+ if (problem) {
3092
+ if (JSON_OUT) console.log(JSON.stringify({ error: problem.kind, message: problem.lines.join(' — ') }));
3093
+ else { console.log(bad(problem.lines[0])); console.log(` ${problem.lines[1]}`); }
3094
+ return 1;
3095
+ }
3096
+ if (isV3(cfg)) {
3097
+ try {
3098
+ const t = v3Transport(cfg);
3099
+ const s = await qc().statsV3(t.exec, await v3RoomsFor(cfg, t));
3100
+ if (JSON_OUT) console.log(JSON.stringify(s, null, 2)); else for (const l of qc().renderStats(s)) console.log(l);
3101
+ return 0;
3102
+ } catch (e) { return qError(e); }
3103
+ }
3104
+ if (!JSON_OUT) return run(SHIP_JS, ['--stats'], cfg);
3105
+ // Schema 2, --json: the same per-source rollup ship.js --stats prints, as data.
3106
+ try {
3107
+ const r = await roomsFor(cfg);
3108
+ const rows = await chRows(cfg, `SELECT source, count() AS sessions, sum(total_msgs) AS messages,
3109
+ sum(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) AS tokens
3110
+ FROM ${r.sessions_v} GROUP BY source ORDER BY sessions DESC`);
3111
+ const out = rows.map((x) => ({ source: x.source, sessions: Number(x.sessions), messages: Number(x.messages), tokens: Number(x.tokens) }));
3112
+ const total = out.reduce((a, x) => ({ sessions: a.sessions + x.sessions, messages: a.messages + x.messages, tokens: a.tokens + x.tokens }), { sessions: 0, messages: 0, tokens: 0 });
3113
+ console.log(JSON.stringify({ schema: 2, sources: out, total }, null, 2));
3114
+ return 0;
3115
+ } catch (e) { console.log(JSON.stringify({ error: 'query_failed', message: netReason(e) })); return 1; }
3116
+ }
3117
+
2133
3118
  async function cmdSearch() {
2134
3119
  if (!positional.length) { console.log('usage: memhouse search <terms…>'); return 2; }
2135
3120
  const cfg = requireConfig(resolveConfig(), 'search');
3121
+ if (isV3(cfg)) return cmdSearchV3(cfg);
2136
3122
  const needle = positional.join(' ').toLowerCase().replace(/[%_\\]/g, '\\$&').replace(/'/g, "\\'");
2137
3123
  const r = await roomsFor(cfg);
2138
3124
  const rows = await chRows(cfg, `
@@ -2154,14 +3140,14 @@ async function cmdSearch() {
2154
3140
  }
2155
3141
  }
2156
3142
 
2157
- // `search` finds the session; this hands the pilot back into it. memhouse has been a
3143
+ // `search` finds the session; this hands the user back into it. memhouse has been a
2158
3144
  // read-only archive — you could find the conversation that solved this before and not
2159
3145
  // return to it.
2160
3146
  //
2161
3147
  // It PRINTS the command and does not run it, and that is the whole design. Running it would
2162
3148
  // have to guess a terminal, inherit this process's cwd, and hope the id is still live; when
2163
- // any of that is wrong the CLI does not fail, it opens a NEW session — the pilot loses the
2164
- // transcript they asked for while the tool reports success. Printed, the pilot reads it,
3149
+ // any of that is wrong the CLI does not fail, it opens a NEW session — the user loses the
3150
+ // transcript they asked for while the tool reports success. Printed, the user reads it,
2165
3151
  // pastes it, and owns the result.
2166
3152
  async function cmdResume() {
2167
3153
  if (!positional.length) {
@@ -2169,12 +3155,13 @@ async function cmdResume() {
2169
3155
  return 2;
2170
3156
  }
2171
3157
  const cfg = requireConfig(resolveConfig(), 'resume');
3158
+ if (isV3(cfg)) return cmdResumeV3(cfg);
2172
3159
  const { resumeFor } = require(path.join(REPO_ROOT, 'memhouse', 'resume'));
2173
3160
  // Same escaping as search: this value is interpolated into SQL, and a session id is not
2174
3161
  // guaranteed to be tame — it comes from whatever the editor wrote on disk.
2175
3162
  const want = positional[0].replace(/[\\']/g, '\\$&');
2176
3163
  const r = await roomsFor(cfg);
2177
- // Two ways to name a session, because there are two ways it gets in front of the pilot:
3164
+ // Two ways to name a session, because there are two ways it gets in front of the user:
2178
3165
  // the canonical `<source>:<id>` that search and the prompt print, and the bare native id
2179
3166
  // that the editor's own UI shows. FINAL because sessions is a ReplacingMergeTree and a
2180
3167
  // re-shipped session has an older row underneath — without it a moved project resolves to
@@ -2212,7 +3199,7 @@ async function cmdResume() {
2212
3199
  if (!res.ok) {
2213
3200
  console.log(bad(res.reason));
2214
3201
  // The folder is still worth printing: for a GUI editor it is the one actionable thing
2215
- // memhouse knows, and opening it is what the pilot would do next anyway.
3202
+ // memhouse knows, and opening it is what the user would do next anyway.
2216
3203
  if (res.folder) console.log(` the session ran in: ${res.folder}`);
2217
3204
  return 1;
2218
3205
  }
@@ -2226,7 +3213,7 @@ async function cmdResume() {
2226
3213
  // Which kind of installation is this process running out of? Every upgrade path below
2227
3214
  // depends on the answer, and getting it wrong is not a no-op: running `git pull` in a
2228
3215
  // global install does nothing visible, and `npm i -g` from a checkout installs the
2229
- // PUBLISHED version over the branch the pilot was testing.
3216
+ // PUBLISHED version over the branch the user was testing.
2230
3217
  function installKind() {
2231
3218
  const root = fs.realpathSync(REPO_ROOT);
2232
3219
  // npx unpacks into a cache directory keyed by a hash. Nothing there is upgradable — the
@@ -2262,7 +3249,11 @@ async function cmdUpdate() {
2262
3249
  // written at all.
2263
3250
  const stored = readEnvFile().MEMHOUSE_CHANNEL || null;
2264
3251
  const pinned = asked || cfg0.channel || null;
2265
- const tags = await chan.fetchTags();
3252
+ // The tags come from the registry npm installs from (npm config get registry), not always npmjs.
3253
+ const reg = await chan.readTags();
3254
+ const tags = reg.tags;
3255
+ const registry = chan.shownRegistry(reg.registry);
3256
+ const unanswered = `the registry (${registry}) could not be read: ${reg.error}`;
2266
3257
  const pick = chan.pickChannel({ version: PKG.version, pinned, tags });
2267
3258
  // --channel pins for this run AND every later one: the point of pinning is that the next
2268
3259
  // person to type `memhouse update` does not have to remember it. Only a channel the
@@ -2271,7 +3262,7 @@ async function cmdUpdate() {
2271
3262
  // that does not exist, --no-install included, which checks no channel at all (A5).
2272
3263
  if (asked && asked !== stored) {
2273
3264
  if (!tags) {
2274
- console.log(warn(`the registry did not answer, so channel '${asked}' cannot be checked — not pinned`));
3265
+ console.log(warn(`${unanswered} — channel '${asked}' cannot be checked, so it is not pinned`));
2275
3266
  } else if (!tags[asked]) {
2276
3267
  console.log(bad(`no channel '${asked}' on the registry — nothing pinned, nothing changed`));
2277
3268
  console.log(` channels: ${Object.keys(tags).join(', ')}`);
@@ -2282,10 +3273,14 @@ async function cmdUpdate() {
2282
3273
  }
2283
3274
  }
2284
3275
  const latest = pick.target;
2285
- if (JSON_OUT && flags.check) return console.log(JSON.stringify({ kind, root, current: PKG.version, channel: pick.channel, target: pick.target, reason: pick.reason, tags }, null, 2));
3276
+ if (JSON_OUT && flags.check) return console.log(JSON.stringify({ kind, root, current: PKG.version, registry, registry_error: reg.error, channel: pick.channel, target: pick.target, reason: pick.reason, tags }, null, 2));
2286
3277
  console.log(` installed ${PKG.version} (${kind}: ${root})`);
3278
+ console.log(` registry ${registry}${tags ? '' : ` (could not be read: ${reg.error})`}`);
2287
3279
  console.log(` channel ${pick.channel || '—'} (${pick.reason})`);
2288
- console.log(` target ${pick.target || (tags ? 'nothing to install' : 'unknown — the registry did not answer')}`);
3280
+ console.log(` target ${pick.target || (tags ? 'nothing to install' : 'unknown')}`);
3281
+ // A registry that does not answer is a network or npm-config matter: --channel cannot help,
3282
+ // since a channel is checked against the same registry.
3283
+ const registryHint = () => console.log(` npm installs from ${registry} (npm config get registry); once it answers, run memhouse update again`);
2289
3284
  if (latest && latest === PKG.version && kind !== 'checkout') console.log(ok('already current'));
2290
3285
  if (flags.check) return 0;
2291
3286
  // --no-install asks for the restart and the house checks only; the files are already in
@@ -2296,10 +3291,11 @@ async function cmdUpdate() {
2296
3291
  const noInstall = flags['no-install'] === true;
2297
3292
  if (!pick.channel && kind !== 'checkout' && !noInstall) {
2298
3293
  console.log(warn('no automatic update for this install'));
2299
- console.log(` ${pick.reason}`);
3294
+ if (!tags) { console.log(` ${unanswered}`); registryHint(); } else console.log(` ${pick.reason}`);
2300
3295
  return 1;
2301
3296
  }
2302
3297
  if (pick.channel && !pick.target && kind !== 'checkout' && !noInstall) {
3298
+ if (!tags) { console.log(bad(`${unanswered} — nothing to install, nothing changed`)); registryHint(); return 1; }
2303
3299
  console.log(bad(`channel '${pick.channel}' has no version on the registry — nothing to install, nothing changed`));
2304
3300
  return 1;
2305
3301
  }
@@ -2326,7 +3322,7 @@ async function cmdUpdate() {
2326
3322
 
2327
3323
  if (noInstall) {
2328
3324
  // The files were already replaced by other means — a hand-typed `npm i -g`, a tarball,
2329
- // a configuration manager. The npm/git half is exactly what such a pilot has already
3325
+ // a configuration manager. The npm/git half is exactly what such a user has already
2330
3326
  // done, and the half a bare install leaves undone (restart, migrations, schema heal)
2331
3327
  // is exactly what they came here for.
2332
3328
  console.log(warn('--no-install: files assumed current; restarting and checking the house only'));
@@ -2364,7 +3360,13 @@ async function cmdUpdate() {
2364
3360
  // The house may have been converted to the one layout since this machine last ran:
2365
3361
  // the member's rooms moved from <name>.* to mem.<name>_*. Fix the env here, before the
2366
3362
  // shipper restarts, or its first pass refuses the old address forever.
2367
- try { await adoptMovedHouse(resolveConfig()); } catch { /* doctor will say */ }
3363
+ // (Schema 3 has no such move: its tables were created in place, under the configured house.)
3364
+ // A machine with no member install (an administrator's, with only MEMHOUSE_URL and the
3365
+ // MEMHOUSE_ADMIN_* variables) has no house of its own to bring up to date: the steps below would
3366
+ // connect as the default member and print its authentication failures as the house's state.
3367
+ const memberHere = !!resolveConfig().userStated;
3368
+ if (!memberHere) console.log(ok('no member install on this machine: no house schema or migration to check here (members and the admin do that on theirs)'));
3369
+ if (memberHere && !isV3(resolveConfig())) { try { await adoptMovedHouse(resolveConfig()); } catch { /* doctor will say */ } }
2368
3370
 
2369
3371
  // Bring the house to the schema the NEW code expects, before any daemon restarts.
2370
3372
  // A release that adds rooms (0.18.3's stat tables) used to land after the restart, so
@@ -2373,7 +3375,7 @@ async function cmdUpdate() {
2373
3375
  // 500'd — all of it self-healing eventually, all of it alarming meanwhile. Two real
2374
3376
  // members hit this within an hour of each other. The new binary owns the new schema, so
2375
3377
  // it runs it here, in the one moment the daemons are already down.
2376
- {
3378
+ if (memberHere) {
2377
3379
  const r = spawnSync(process.execPath, [path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js'), '--ensure-schema'],
2378
3380
  { env: childEnv(resolveConfig()), encoding: 'utf-8', stdio: 'pipe', timeout: 120000 });
2379
3381
  const said = `${r.stdout || ''}${r.stderr || ''}`.trim().split('\n').filter((l) => l.includes('[memhouse]')).pop();
@@ -2406,19 +3408,19 @@ async function cmdUpdate() {
2406
3408
  }
2407
3409
 
2408
3410
  // Restarting is the half a bare `npm i -g` leaves undone. The daemons notice on their own
2409
- // within a loop interval (memhouse/self-update.js), but a pilot who typed `update` should
3411
+ // within a loop interval (memhouse/self-update.js), but a user who typed `update` should
2410
3412
  // not have to wait for it, and the dashboard's stale bundle is visible immediately.
2411
3413
  if (svc.installed) {
2412
3414
  // REINSTALL the unit, do not merely advise a restart. Two reasons, both measured on
2413
- // testbed. The unit inlines its template AND the env at service-install time, so a
3415
+ // a test VM. The unit inlines its template AND the env at service-install time, so a
2414
3416
  // fix shipped in a release (Restart=on-failure -> always was one; the self-update
2415
3417
  // handover exits 0 and an on-failure unit stays DEAD after every upgrade) never
2416
3418
  // reaches an existing install through a restart. And the advice route ends with a
2417
- // pilot who did everything `update` said and still has a dead shipper.
3419
+ // user who did everything `update` said and still has a dead shipper.
2418
3420
  // service.js install is idempotent: same home replaces in place and starts it.
2419
3421
  try {
2420
3422
  const svcmod = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
2421
- // Keep the interval the pilot chose. The refresh used to hardcode 300, silently
3423
+ // Keep the interval the user chose. The refresh used to hardcode 300, silently
2422
3424
  // rewriting a `service install --interval 30` unit on every update.
2423
3425
  let interval = 300;
2424
3426
  try {
@@ -2452,14 +3454,23 @@ async function cmdUpdate() {
2452
3454
  console.log(warn('no daemons were running — start them with: memhouse start'));
2453
3455
  }
2454
3456
 
2455
- // The new release may need the HOUSE moved too, and upgrade time is when the pilot is
3457
+ // The new release may need the HOUSE moved too, and upgrade time is when the user is
2456
3458
  // watching — a migration named here beats one discovered as a refusing service in a log
2457
- // nobody reads. Three behaviours, chosen by the pilot:
3459
+ // nobody reads. Three behaviours, chosen by the user:
2458
3460
  // memhouse update --migrate run whatever is pending, unasked (--yes implies it)
2459
3461
  // interactive name what is pending and ask
2460
3462
  // non-interactive, no flag name it and print the command; NEVER auto-run — a cron
2461
3463
  // or CI invocation must not start a house-wide copy
2462
3464
  const cfg = resolveConfig();
3465
+ if (!memberHere) return 0;
3466
+ if (cfg.url && cfg.user && isV3(cfg)) {
3467
+ // Schema 3: the 0.18 migrations are about the 0.18 rooms, which this install neither reads
3468
+ // nor writes. What it needs is its five tables, verified — ship.js --ensure-schema does that
3469
+ // on a schema-3 install (it never runs a capture pass in that mode).
3470
+ const code = run(SHIP_JS, ['--ensure-schema'], cfg);
3471
+ if (code !== 0) { console.log(warn('the schema 3 tables need attention — memhouse doctor')); return code; }
3472
+ return 0;
3473
+ }
2463
3474
  if (cfg.url && cfg.user) {
2464
3475
  try {
2465
3476
  const r = await roomsFor(cfg);
@@ -2497,91 +3508,17 @@ async function cmdUpdate() {
2497
3508
  return 0;
2498
3509
  }
2499
3510
 
2500
- // Every Claude Code instance on this machine, not just the default one.
2501
- //
2502
- // A pilot rarely has one. `~/.claude` is the stock install, `CLAUDE_CONFIG_DIR` points at
2503
- // whichever they are running right now, and playbook installs live under
2504
- // `~/.claude-playbooks/<name>[/playbook]`, each a complete config directory with its own
2505
- // skills/. Installing into one and calling it done leaves /mem:recall missing from
2506
- // every other instance the pilot uses — silently, because a missing skill does not announce
2507
- // itself, it just never appears.
2508
- //
2509
- // The discovery is the ADAPTER's (editors/claude.js discoverClaudeRoots), reused rather
2510
- // than reimplemented: it already ports memory-house's discover_roots and runs on every
2511
- // ship. A second copy here would drift the first time a playbook layout changes — which is
2512
- // the same defect this repo just retired in install.sh.
2513
- function claudeTargets() {
2514
- const seen = new Map(); // realpath -> { dir, why }
2515
- const add = (dir, why) => {
2516
- if (!dir) return;
2517
- let isDir = false;
2518
- try { isDir = fs.statSync(dir).isDirectory(); } catch { /* missing */ }
2519
- if (!isDir) return;
2520
- let rp; try { rp = fs.realpathSync(dir); } catch { rp = dir; }
2521
- if (!seen.has(rp)) seen.set(rp, { dir, why });
2522
- };
2523
- // The instance the caller is running under comes first, and is NOT subject to the
2524
- // adapter's "has this been used?" test — a fresh config dir has no projects/ yet, and it
2525
- // is exactly where the pilot wants the skills.
2526
- add(process.env.CLAUDE_CONFIG_DIR, 'CLAUDE_CONFIG_DIR');
2527
- add(path.join(os.homedir(), '.claude'), 'default');
2528
- try {
2529
- const { discoverClaudeRoots } = require(path.join(REPO_ROOT, 'editors', 'claude'));
2530
- for (const r of discoverClaudeRoots()) add(r, 'playbook');
2531
- } catch { /* adapter unavailable — the two above still stand */ }
2532
- return [...seen.values()];
2533
- }
2534
-
3511
+ // The plugin's installed copies: discovery, install and the first-run refresh live in
3512
+ // memhouse/plugin-copies.js, so the shipper keeps them current too.
3513
+ const pluginCopies = require(path.join(REPO_ROOT, 'memhouse', 'plugin-copies'));
3514
+ const claudeTargets = () => pluginCopies.claudeTargets();
2535
3515
  const short = (p) => p.replace(os.homedir(), '~');
2536
- const PLUGIN_MARK = path.join('skills', 'mem', '.claude-plugin', 'plugin.json');
2537
- const isPluginInstalled = (dir) => fs.existsSync(path.join(dir, PLUGIN_MARK));
2538
-
2539
- function installPluginInto(dir) {
2540
- // The plugin was named `memhouse` until 0.10.0. A leftover copy under the old name
2541
- // would load BESIDE the new one — /memhouse:search and /mem:recall both resolving, one
2542
- // of them stale forever. Remove it only when it is provably OURS (it carries our
2543
- // plugin.json); a directory someone else named `memhouse` is not ours to delete.
2544
- const legacy = path.join(dir, 'skills', 'memhouse');
2545
- const legacyManifest = path.join(legacy, '.claude-plugin', 'plugin.json');
2546
- if (fs.existsSync(legacyManifest)) {
2547
- let lname = null;
2548
- try { lname = JSON.parse(fs.readFileSync(legacyManifest, 'utf-8')).name; } catch { /* unreadable */ }
2549
- if (lname === 'memhouse' || lname === 'mem') {
2550
- fs.rmSync(legacy, { recursive: true });
2551
- console.log(ok(`removed the pre-0.10 plugin at ${short(legacy)} (renamed to 'mem')`));
2552
- }
2553
- }
2554
- const dst = path.join(dir, 'skills', 'mem');
2555
- // REPLACE, not overlay. cpSync over an existing install refreshes the skills and
2556
- // leaves anything else standing — a machine that once had a build with extra skills
2557
- // kept offering /mem:replay and /mem:house forever, stale, beside the real ones.
2558
- // Ownership means OUR MANIFEST, checked by name — "any plugin.json" would have deleted
2559
- // an unrelated plugin that happened to pick the same directory name.
2560
- const dstManifest = path.join(dst, '.claude-plugin', 'plugin.json');
2561
- if (fs.existsSync(dstManifest)) {
2562
- let name = null;
2563
- try { name = JSON.parse(fs.readFileSync(dstManifest, 'utf-8')).name; } catch { /* unreadable */ }
2564
- if (name === 'mem' || name === 'memhouse') fs.rmSync(dst, { recursive: true });
2565
- else throw new Error(`skills/mem in ${dir} belongs to plugin '${name || '(unreadable manifest)'}' — refusing to replace it`);
2566
- }
2567
- fs.mkdirSync(dst, { recursive: true });
2568
- fs.cpSync(path.join(DELIVERY, 'plugin'), dst, { recursive: true });
2569
- // Stamp the manifest with THIS memhouse's version. The source plugin.json carries a
2570
- // FROZEN number — it sat at 0.11.0 through several releases, so every install
2571
- // advertised the wrong version (`memhouse --version` said one thing, the plugin
2572
- // another). The package version is the single truth; write it into the copy.
2573
- try {
2574
- const m = JSON.parse(fs.readFileSync(dstManifest, 'utf-8'));
2575
- if (m.version !== PKG.version) {
2576
- m.version = PKG.version;
2577
- fs.writeFileSync(dstManifest, `${JSON.stringify(m, null, 2)}\n`);
2578
- }
2579
- } catch { /* manifest unreadable — leave the copied one rather than guess */ }
2580
- return dst;
2581
- }
3516
+ const PLUGIN_MARK = pluginCopies.PLUGIN_MARK;
3517
+ const isPluginInstalled = (dir) => pluginCopies.isPluginInstalled(dir);
3518
+ const installPluginInto = (dir) => pluginCopies.installInto(dir, { deliveryDir: DELIVERY, version: PKG.version, log: (l) => console.log(ok(short(l))) });
2582
3519
 
2583
3520
  // Which instances to act on. EVERYTHING IS SELECTED BY DEFAULT: with several Claude
2584
- // installs the answer is almost always "all of them", and a pilot who wanted one would have
3521
+ // installs the answer is almost always "all of them", and a user who wanted one would have
2585
3522
  // passed --target. Numbers narrow it, 'n' skips.
2586
3523
  //
2587
3524
  // No prompt at all when there is nothing to choose (one target), when an agent is driving
@@ -2599,7 +3536,7 @@ async function chooseTargets(targets, verb) {
2599
3536
  if (a === 'y' || a === 'yes' || a === '') return targets;
2600
3537
  const picked = [...new Set(a.split(/[\s,]+/).map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= targets.length))];
2601
3538
  // An unparseable answer must not quietly mean "all" — that would install into places the
2602
- // pilot was in the middle of narrowing down. Say so and take none.
3539
+ // user was in the middle of narrowing down. Say so and take none.
2603
3540
  if (!picked.length) { console.log(warn(`did not understand '${a}' — nothing selected`)); return []; }
2604
3541
  return picked.map((n) => targets[n - 1]);
2605
3542
  }
@@ -2649,8 +3586,23 @@ async function cmdPlugins() {
2649
3586
  // somewhere else than the directory just inspected.
2650
3587
  const self = `memhouse plugins install claude${flags.target ? ` --target ${flags.target}` : ''}`;
2651
3588
 
3589
+ // Codex: one block in <codex home>/AGENTS.md and one skill, opt-in (memhouse/codex-plugin.js).
3590
+ const cxp = require(path.join(REPO_ROOT, 'memhouse', 'codex-plugin'));
3591
+ const cxHome = cxp.codexHome();
3592
+ const codexLine = () => {
3593
+ const st = cxp.status({ codexHome: cxHome });
3594
+ if (st.installed) {
3595
+ const o = st.owner;
3596
+ const runs = cxp.invocation({ home: o.home, bin: o.bin });
3597
+ console.log(ok(`codex: installed in ${short(cxHome)} (${o.version || 'unknown version'}) — runs \`${runs}\`${st.block ? '' : '; the AGENTS.md block is missing'}`));
3598
+ } else if (st.foreignSkill) console.log(warn(`codex: ${short(st.foreignSkill)} is not memhouse's — left alone`));
3599
+ else console.log(` codex: not installed in ${short(cxHome)} — memhouse plugins install codex`);
3600
+ if (st.damaged) console.log(warn(`codex: ${st.damaged}`));
3601
+ };
3602
+
2652
3603
  if (sub === 'list') {
2653
3604
  console.log(`skills available: ${invocations}`);
3605
+ codexLine();
2654
3606
  if (!targets.length) { console.log(warn('no Claude Code config directory found')); return 0; }
2655
3607
  for (const t of targets) {
2656
3608
  console.log(isPluginInstalled(t.dir)
@@ -2697,7 +3649,33 @@ async function cmdPlugins() {
2697
3649
  return 0;
2698
3650
  }
2699
3651
 
2700
- console.log('usage: memhouse plugins [list | install claude | remove claude] [--target DIR] [--yes]');
3652
+ if (sub === 'install' && positional[1] === 'codex') {
3653
+ try {
3654
+ const r = cxp.install({ codexHome: cxHome, home: HOME_DIR, bin: thisBin, version: PKG.version, deliveryDir: DELIVERY });
3655
+ console.log(ok(`codex: ${r.created ? 'created' : 'updated'} the memhouse block in ${short(r.file)}`));
3656
+ console.log(ok(`codex: skill ${short(r.dir)} — Codex lists it as 'memhouse'`));
3657
+ console.log(` the block and the skill run \`${r.mh}\`; remove both with: memhouse plugins remove codex`);
3658
+ console.log(' loads in the next Codex session');
3659
+ return 0;
3660
+ } catch (e) { console.log(bad(`codex: ${e.message}`)); return 1; }
3661
+ }
3662
+
3663
+ if (sub === 'remove' && positional[1] === 'codex') {
3664
+ let r;
3665
+ try { r = cxp.remove({ codexHome: cxHome }); } catch (e) { console.log(bad(`codex: ${e.message}`)); return 1; }
3666
+ if (r.block) {
3667
+ const f = short(cxp.agentsFile(cxHome));
3668
+ console.log(ok(r.fileDeleted ? `codex: removed ${f} (it held only the memhouse block)`
3669
+ : r.emptiedLink ? `codex: removed the memhouse block from ${f}; it held only the block, so its link target is now empty and the link stays`
3670
+ : `codex: removed the memhouse block from ${f}; the rest of the file is as it was`));
3671
+ }
3672
+ if (r.skill) console.log(ok(`codex: removed the skill ${short(cxp.skillDir(cxHome))}`));
3673
+ if (r.foreignSkill) console.log(warn(`codex: ${short(r.foreignSkill)} is not memhouse's (no ${cxp.OWNER_FILE}) — left alone`));
3674
+ if (!r.block && !r.skill) console.log(warn(`codex: nothing of memhouse's in ${short(cxHome)}`));
3675
+ return 0;
3676
+ }
3677
+
3678
+ console.log('usage: memhouse plugins [list | install claude|codex | remove claude|codex] [--target DIR] [--yes]');
2701
3679
  return 2;
2702
3680
  }
2703
3681
 
@@ -2790,11 +3768,46 @@ async function houseEvent(cfg, e) {
2790
3768
  } catch { /* an unwritable log is not a reason to abandon a rebuild */ }
2791
3769
  }
2792
3770
 
3771
+ /** Write one meta row. Returns null, or the error: a caller whose record matters checks it. */
2793
3772
  async function houseMeta(cfg, key, value) {
2794
3773
  try {
2795
3774
  await ch(cfg, `INSERT INTO ${physicalRoom('meta', cfg.user)} (key, value) VALUES (${sqlStr(key)}, ${sqlStr(value)})`,
2796
3775
  { settings: { async_insert: 0 } });
2797
- } catch { /* same */ }
3776
+ return null;
3777
+ } catch (e) { return e; }
3778
+ }
3779
+
3780
+ /**
3781
+ * Record a share's state (`share:<user>` in the member's meta), then read it back: meta is
3782
+ * latest-wins, so a record that did not land leaves the old one standing. A revoke still recorded
3783
+ * as granted is the dangerous case: `migrate --v3-shares` would take a leftover grant for a share
3784
+ * to move and grant it again. The read-back decides, even after a write error: an INSERT whose
3785
+ * answer was lost may have landed. (A read that reaches a replica the write has not reached yet
3786
+ * would report a landed record as lost: a false alarm, never a silent loss.) Returns null, or why
3787
+ * the record could not be confirmed.
3788
+ */
3789
+ async function shareRecord(cfg, user, value) {
3790
+ const e = await houseMeta(cfg, `share:${user}`, value);
3791
+ try {
3792
+ const r = await chRows(cfg, `SELECT argMax(value, updated_at) AS v FROM ${physicalRoom('meta', cfg.user)} WHERE key = ${sqlStr(`share:${user}`)}`);
3793
+ const v = r[0] ? String(r[0].v) : '';
3794
+ if (v === value) return null;
3795
+ return e ? `the write failed: ${netReason(e)}` : `it reads back as '${v}'`;
3796
+ } catch (e2) { return e ? `the write failed: ${netReason(e)}` : `it could not be read back: ${netReason(e2)}`; }
3797
+ }
3798
+
3799
+ /**
3800
+ * Say that a share's new state could not be confirmed as recorded, what follows, and how to record
3801
+ * it: the record still says what it said before, and share --list and migrate --v3-shares act on
3802
+ * that. `rerun` is the command, its scope quoted for the shell.
3803
+ */
3804
+ function shareRecordLost({ user, revoked, why, rerun }) {
3805
+ console.log(bad(`${revoked ? `'${user}''s grants are withdrawn` : `'${user}' is shared`}, but memhouse could not confirm the ${revoked ? 'revoke' : 'share'} is recorded: ${why}.`));
3806
+ console.log(' Until it is, the record still says what it said before: memhouse share --list shows that, and memhouse migrate --v3-shares acts on it.');
3807
+ console.log(revoked
3808
+ ? ' If it says granted, --v3-shares would take a grant the reader still holds for a share to move, and grant it again.'
3809
+ : ' With no record before, --v3-shares refuses the grant as one memhouse did not record; with an older one, it would put that one back.');
3810
+ console.log(` Run again: ${rerun}`);
2798
3811
  }
2799
3812
 
2800
3813
  /**
@@ -2908,12 +3921,12 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
2908
3921
  // A 0.9.0 shipper passes its own key check against the migrated rooms (it only looks
2909
3922
  // for `origin`) and keeps writing — harmless — but its per-session DELETE clear removes
2910
3923
  // a re-shipped session's rows across ALL epochs, destroying exactly the superseded
2911
- // parses this schema exists to keep. Say so here, where the pilot is looking.
3924
+ // parses this schema exists to keep. Say so here, where the user is looking.
2912
3925
  console.log(warn('if OTHER machines ship into this house as you, upgrade them promptly:'));
2913
3926
  console.log(' a pre-0.10 shipper elsewhere still deletes before re-inserting, and on the');
2914
3927
  console.log(' migrated rooms that delete reaches every retained parse of a session it re-ships.');
2915
3928
  // assumeYes carries a consent ALREADY GIVEN one level up — `update` prompted (or took
2916
- // --migrate/--yes) before calling here, and asking twice teaches pilots that prompts
3929
+ // --migrate/--yes) before calling here, and asking twice teaches users that prompts
2917
3930
  // are noise. It is never set on a direct `memhouse migrate`.
2918
3931
  if (flags.yes !== true && !assumeYes) {
2919
3932
  const what = pending.map((x) => x.migration.id).join(', ');
@@ -2946,7 +3959,12 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
2946
3959
  // The floor under future writers: any 0.10+ memhouse reads this at pass start and
2947
3960
  // refuses if it is too old for the house. Pre-0.10 releases read nothing — hence the
2948
3961
  // REVOKE advice below, which is the only enforcement that reaches them.
2949
- await houseMeta(cfg, 'min_writer_schema', String(MIN_WRITER_SCHEMA));
3962
+ const fence = await houseMeta(cfg, 'min_writer_schema', String(MIN_WRITER_SCHEMA));
3963
+ if (fence) {
3964
+ // The same write failing failed the migrations' schema_version too (memhouse/house/migrate.js).
3965
+ console.log(warn(`could not record min_writer_schema = ${MIN_WRITER_SCHEMA}: ${netReason(fence)}. memhouse writers older than this schema will not refuse this house.`));
3966
+ console.log(` Record it by hand, as this member or the house's admin: INSERT INTO ${cfg.db}.${physicalRoom('meta', cfg.user)} (key, value) VALUES ('schema_version', '${SCHEMA_VERSION}'), ('min_writer_schema', '${MIN_WRITER_SCHEMA}')`);
3967
+ }
2950
3968
 
2951
3969
  console.log(`\n${ok(`house is at schema ${SCHEMA_VERSION}`)}`);
2952
3970
  console.log(' verify with: memhouse doctor');
@@ -2961,6 +3979,611 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
2961
3979
  return 0;
2962
3980
  }
2963
3981
 
3982
+ /**
3983
+ * `memhouse migrate --v3-convert | --v3-reconcile [--member <m>] [--batch-rows N] [--dry-run]`
3984
+ * — the conversion of memhouse 0.18's rooms into harness-sessions-sql's profile (harness-sessions-sql §4.8, step
3985
+ * B3), and its check. An ADMIN step: it reads `<db>_archive.<m>_*_pre_v3`, which no member is
3986
+ * granted. So it acts as the admin (adminStepConfig: MEMHOUSE_ADMIN_USER / MEMHOUSE_ADMIN_PASSWORD
3987
+ * or --admin-user, as invite and members) and --member names whose rooms are converted; it
3988
+ * defaults to the member this machine is configured as.
3989
+ *
3990
+ * The work lives in memhouse/house/convert-profile.js (its SQL reads the 0.18 rooms through
3991
+ * convert-v3.js); this is transport and conversation only.
3992
+ */
3993
+ const V3_MIGRATE_FLAGS = ['v3-prepare', 'v3-convert', 'v3-reconcile', 'v3-shares', 'v3-switch'];
3994
+
3995
+ /** Schema 3: the house's own record (meta, events) — created on a new house, kept from 0.18 on a converted one. */
3996
+ async function ensureMetaTablesV3(cfg, member) {
3997
+ const tpl = fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl'), 'utf-8');
3998
+ for (const t of ['meta', 'events']) {
3999
+ try { await ch(cfg, createStatement(tpl, t, `${cfg.db}.${member}_${t}`).replace('CREATE TABLE ', 'CREATE TABLE IF NOT EXISTS '), { database: '' }); }
4000
+ catch { /* no rights, or already there */ }
4001
+ }
4002
+ }
4003
+
4004
+ /** Run plan steps of one phase in order; stops at the first refusal. Returns null, or the error. */
4005
+ async function runPhase(run, steps, phase) {
4006
+ for (const st of steps.filter((x) => x.phase === phase)) {
4007
+ try { await run(st.sql); } catch (e) { return e; }
4008
+ }
4009
+ return null;
4010
+ }
4011
+
4012
+ /**
4013
+ * `memhouse share` on schema 3 (memhouse/share.js shareV3Plan): SELECT on exactly this member's
4014
+ * six conversation tables, a scope as a row policy on each, the 0.18 wildcard never granted (and
4015
+ * taken back where an old share left one). Fails closed at every step, as the 0.18 share does.
4016
+ */
4017
+ async function cmdShareV3(cfg, share) {
4018
+ const Q = require(path.join(REPO_ROOT, 'memhouse', 'house', 'queries-v3'));
4019
+ const db = cfg.db; const member = cfg.user;
4020
+ const run = (sql) => ch(cfg, sql, { database: db });
4021
+ const rows = (sql) => chRows(cfg, sql, { database: db });
4022
+ await ensureMetaTablesV3(cfg, member);
4023
+ const tables = share.V3_TABLES.map((t) => `${db}.${member}_${t}`);
4024
+ const bare = !positional[0] && flags.only === undefined && flags.revoke === undefined;
4025
+ if (flags.list === true || bare) return shareList(cfg, share);
4026
+ const user = positional[0];
4027
+ if (!user) { console.log(bad('usage: memhouse share <user> [--only <scope>] [--revoke] | memhouse share --list')); return 2; }
4028
+ try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(user, 'user'); }
4029
+ catch (e) { console.log(bad(e.message)); return 1; }
4030
+ if (user === member) { console.log(bad(`'${user}' is you — you already read your own house`)); return 1; }
4031
+
4032
+ // A 0.18 full share is the wildcard <m>_*, which a schema-3 share never grants. Does the reader
4033
+ // still hold one (a share made before the conversion that the admin has not moved)? null: could
4034
+ // not be read. And what else does that wildcard match: the tables of a colliding member, or a
4035
+ // table that is not ours (share.memberCollisions, share.wildcardOnRevoke)? The users too, since a
4036
+ // member with no table in sight has no table to find it by: those whose names collide with ours
4037
+ // and who may be members here (share.holdsMemberGrant; unreadable counts). null: not listed.
4038
+ let heldWildcard = null;
4039
+ try {
4040
+ heldWildcard = (await rows(`SHOW GRANTS FOR ${share.ident(user)}`)).map((r) => String(Object.values(r)[0]))
4041
+ .some((l) => { const m = /^GRANT\s+(.+?)\s+ON\s+(\S+)\s+TO\b/.exec(l); return !!m && /\bSELECT\b|\bALL\b/.test(m[1]) && m[2].replace(/`/g, '') === `${db}.${member}_*`; });
4042
+ } catch { /* not readable with this credential: said below */ }
4043
+ let collisions = null; let houseTables = null; let users = null;
4044
+ try {
4045
+ houseTables = (await rows(`SELECT name FROM system.tables WHERE database = ${sqlStr(db)}`)).map((r) => r.name);
4046
+ collisions = share.memberCollisions(member, houseTables);
4047
+ } catch { /* unlisted: collisions stays null */ }
4048
+ try {
4049
+ users = await share.collidingUsers({ users: (await rows('SELECT name FROM system.users')).map((r) => r.name), member, db,
4050
+ read: async (x) => (await rows(`SHOW GRANTS FOR ${share.ident(x)}`)).map((r) => String(Object.values(r)[0])) });
4051
+ } catch { users = null; }
4052
+
4053
+ if (flags.revoke === true) {
4054
+ const steps = share.revokeV3Plan({ db, member, user });
4055
+ // The wildcard too when the reader holds it, unless it also matches a table that is not ours:
4056
+ // then a REVOKE would strip the reader's grants there (share.wildcardOnRevoke).
4057
+ const wc = share.wildcardOnRevoke({ member, collisions, heldWildcard, tables: houseTables, users });
4058
+ if (wc.revoke) steps.unshift(share.wildcardRevoke({ db, member, user }));
4059
+ // Every grant first; the filters only once that is confirmed (the other order fails open).
4060
+ const e = await runPhase(run, steps, 'grant');
4061
+ if (e) {
4062
+ console.log(bad(`could not revoke: ${netReason(e)}`));
4063
+ console.log(` Nothing else was changed: any row policies for '${user}' are still in place.`);
4064
+ return 1;
4065
+ }
4066
+ // The grants are gone, so the share is revoked whatever happens to the filters next. The record
4067
+ // is read back (shareRecord): a lost "revoked" is said, and the command exits 1.
4068
+ const lost = await shareRecord(cfg, user, `revoked ${new Date().toISOString().slice(0, 10)}`);
4069
+ const sayLost = () => { if (lost) shareRecordLost({ user, revoked: true, why: lost, rerun: `memhouse share ${user} --revoke` }); };
4070
+ // What the reader still reads of this member: a REVOKE from them leaves a role's privileges and
4071
+ // any grant memhouse did not make, so the revoke is not done while any remain, nor while that
4072
+ // cannot be read. Said plainly, exit 1 (share.revokeCheck). Checked before the filters go, so a
4073
+ // filter that cannot be dropped never hides it.
4074
+ const N3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-v3')).v3Names(db, member);
4075
+ const known3 = share.memberTargets({ db, archive: N3.archive, member, legacy: N3.legacy });
4076
+ const readGrants = async (name) => {
4077
+ try { return (await rows(`SHOW GRANTS FOR ${share.ident(name)}`)).map((r) => String(Object.values(r)[0])); }
4078
+ catch (e) { throw new Error(`${name === user ? `the grants of '${user}'` : `what role '${name}' gives`} could not be read: ${String(e.message || e).split('\n')[0]}`); }
4079
+ };
4080
+ // Every colliding name, by table or by user (wc.colliding; null: unknown), so a colliding
4081
+ // member's own grant is never called a stray one.
4082
+ const roleCheck = await share.revokeCheck({ read: readGrants, user, known: known3.known, wildcard: known3.wildcard, db, collisions: wc.colliding });
4083
+ const ec = await runPhase(run, steps, 'clear');
4084
+ for (const l of roleCheck.lines) console.log(bad(l));
4085
+ const withdrawn = roleCheck.done ? `revoked '${user}' on ${tables.join(', ')}` : `withdrew '${user}''s own grants on ${tables.join(', ')}`;
4086
+ if (ec) {
4087
+ console.log(warn(`${withdrawn}, but a row policy could not be dropped: ${ec.message.split('\n')[0]}`));
4088
+ console.log(` It filters nothing without a grant, but a later share would inherit it. Run again: memhouse share ${user} --revoke`);
4089
+ sayLost();
4090
+ return 1;
4091
+ }
4092
+ // Both are said when both hold: what remains, and a 0.18 wildcard left for the admin.
4093
+ if (wc.left) {
4094
+ console.log(warn(`${withdrawn}, but ${heldWildcard
4095
+ ? `they still hold your 0.18 grant ${db}.${member}_*, and ${share.wildcardReachText(wc, db)}, so it was left for the admin`
4096
+ : `whether they still hold a 0.18 grant on ${db}.${member}_* could not be read`}.`));
4097
+ // The share is recorded as revoked now, so --v3-shares would refuse it (share.classifyShare).
4098
+ // Not when that record was lost: what --v3-shares would do then is said by sayLost, and the
4099
+ // run it asks for says this line once the record lands.
4100
+ if (!lost) console.log(` ${share.wildcardAdminLine({ wc, db, member, user })}`);
4101
+ }
4102
+ sayLost();
4103
+ if (!roleCheck.done || wc.left || lost) return 1;
4104
+ console.log(ok(`revoked '${user}' — SELECT withdrawn from ${tables.join(', ')} and its row policies dropped`));
4105
+ return 0;
4106
+ }
4107
+
4108
+ const scopeRaw = flags.only !== undefined && flags.only !== true ? String(flags.only).trim() : null;
4109
+ if (flags.only === true || scopeRaw === '') { console.log(bad('--only needs a scope, e.g. --only project=memhouse or --only session=<ref>')); return 1; }
4110
+ let scope = null;
4111
+ if (scopeRaw) {
4112
+ try {
4113
+ scope = share.parseScope(scopeRaw);
4114
+ for (const t of share.V3_TABLES) share.scopePredicateV3(scope, t, sqlStr, Q.parseSessionRef); // a malformed ref fails here, before anything
4115
+ } catch (e) { console.log(bad(e.message)); return 1; }
4116
+ // Proven, or nothing: an unanswered probe is no evidence that a policy is safe here.
4117
+ const verdict = await probePermissive(cfg, db);
4118
+ if (verdict !== true) {
4119
+ console.log(bad(verdict === false ? 'this ClickHouse hides every row from readers that have no row policy.' : 'could not verify how this ClickHouse treats readers without a row policy.'));
4120
+ console.log(' Scoping one person could blindfold everyone else you have shared with, and yourself.');
4121
+ console.log(' Nothing was changed. Ask whoever runs the server for users_without_row_policies_can_read_rows = true.');
4122
+ return 1;
4123
+ }
4124
+ }
4125
+ // Only a known "no 0.18 wildcard" lets a share through: beside the wildcard it would narrow
4126
+ // nothing, and an answer that could not be read is no evidence (share.shareGate).
4127
+ const gate = share.shareGate(heldWildcard);
4128
+ if (gate) {
4129
+ // Only where a revoke would take it back (share.wildcardOnRevoke) is that the advice, and then
4130
+ // the admin is not needed.
4131
+ const wc = share.wildcardOnRevoke({ member, collisions, heldWildcard: true, tables: houseTables, users });
4132
+ if (gate.refuse === 'held') {
4133
+ console.log(bad(`'${user}' still holds your 0.18 share, ${db}.${member}_* — every table of yours, meta and events included.`));
4134
+ console.log(` A schema-3 share would sit beside it, not replace it. Nothing was changed. ${wc.revoke
4135
+ ? `Take it back first: memhouse share ${user} --revoke`
4136
+ : `A revoke would leave it for the admin: ${share.wildcardReachText(wc, db)}.`}`);
4137
+ } else {
4138
+ console.log(bad(`could not read what '${user}' holds, so whether they still hold your 0.18 share (${db}.${member}_*) is unknown.`));
4139
+ console.log(' A schema-3 share beside it would narrow nothing. Nothing was changed.');
4140
+ }
4141
+ if (!(gate.refuse === 'held' && wc.revoke)) {
4142
+ // --v3-shares moves a share recorded as granted (share.classifyShare): beside a held wildcard
4143
+ // only a full one, so a scoped one whose reader's grants could not be read is 'check'.
4144
+ // null: the record could not be read.
4145
+ let movable = null;
4146
+ try {
4147
+ const r = await rows(`SELECT argMax(value, updated_at) AS v FROM ${physicalRoom('meta', member)} WHERE key = ${sqlStr(`share:${user}`)}`);
4148
+ const rec = share.parseShareRecord(r[0] ? r[0].v : '');
4149
+ movable = !rec.granted ? false : !rec.scopeRaw ? true : gate.refuse === 'held' ? false : 'check';
4150
+ } catch { /* unread */ }
4151
+ console.log(` ${share.wildcardAdminLine({ wc, movable, db, member, user })}`);
4152
+ }
4153
+ return 1;
4154
+ }
4155
+ let steps;
4156
+ try { steps = share.shareV3Plan({ db, member, user, scope, sqlStr, parseSessionRef: Q.parseSessionRef }); }
4157
+ catch (e) { console.log(bad(e.message)); console.log(' nothing was changed'); return 1; }
4158
+ // A scoped share writes its filters BEFORE it grants anything: a refusal leaves the grantee
4159
+ // with exactly what they had before.
4160
+ const ef = await runPhase(run, steps, 'filter');
4161
+ if (ef) {
4162
+ console.log(bad(`could not scope: ${ef.message.split('\n')[0]}`));
4163
+ console.log(` NOTHING WAS GRANTED — '${user}' can read no more than before. \`memhouse share ${user} --revoke\` clears any filter written.`);
4164
+ return 1;
4165
+ }
4166
+ const eg = await runPhase(run, steps, 'grant');
4167
+ if (eg) {
4168
+ console.log(bad(`could not grant: ${netReason(eg)}`));
4169
+ // Withdraw what landed, and drop filters only once that is confirmed.
4170
+ const back = share.revokeV3Plan({ db, member, user });
4171
+ const withdrawn = !(await runPhase(run, back, 'grant'));
4172
+ if (withdrawn) { await runPhase(run, back, 'clear'); console.log(` '${user}' was left with no access to your tables — nothing wider than before.`); }
4173
+ else if (scope) console.log(` Could not confirm '${user}' lost every grant, so the filters stay. Retry, or: memhouse share ${user} --revoke`);
4174
+ else console.log(` Could not confirm what '${user}' holds now. Retry, or: memhouse share ${user} --revoke`);
4175
+ return 1;
4176
+ }
4177
+ // A full share drops leftover filters last. If that fails the reader is narrower than asked,
4178
+ // never wider: not recorded as full, and said so.
4179
+ const ec = await runPhase(run, steps, 'clear');
4180
+ if (ec) {
4181
+ console.log(bad(`'${user}' holds SELECT on ${tables.join(', ')}, but an earlier filter could not be dropped: ${ec.message.split('\n')[0]}`));
4182
+ console.log(` They see no more than that filter allows. Run again: memhouse share ${user}`);
4183
+ return 1;
4184
+ }
4185
+ const lost = await shareRecord(cfg, user, `granted ${new Date().toISOString().slice(0, 10)}${scopeRaw ? ` scope=${scopeRaw}` : ''}`);
4186
+ if (!scope) {
4187
+ console.log(ok(`'${user}' can now read every session in ${tables.join(', ')}`));
4188
+ console.log(' Those six tables only: not the capture archive, not meta or events.');
4189
+ } else {
4190
+ console.log(ok(`'${user}' can read your sessions where ${scopeRaw}`));
4191
+ for (const t of share.V3_TABLES) {
4192
+ try {
4193
+ const r = await rows(`SELECT count() AS n FROM ${member}_${t} WHERE ${share.scopePredicateV3(scope, t, sqlStr, Q.parseSessionRef)}`);
4194
+ const all = await rows(`SELECT count() AS n FROM ${member}_${t}`);
4195
+ console.log(` ${t.padEnd(9)} ${r[0].n} of ${all[0].n} rows`);
4196
+ } catch { /* counting is a courtesy */ }
4197
+ }
4198
+ }
4199
+ console.log(` Withdraw with: memhouse share ${user} --revoke`);
4200
+ if (lost) { shareRecordLost({ user, revoked: false, why: lost, rerun: `memhouse share ${user}${scopeRaw ? ` --only ${envfile.quoteShell(scopeRaw)}` : ''}` }); return 1; }
4201
+ return 0;
4202
+ }
4203
+
4204
+ /**
4205
+ * `memhouse migrate --v3-shares [--member M] [--dry-run] [--yes]` — after --v3-convert, the
4206
+ * member's shares re-created on its schema-3 tables (admin). Only shares memhouse recorded in the
4207
+ * member's meta are re-created, each printed before it is applied. Refuses — changing nothing — when
4208
+ * the record and the live grants on the 0.18 rooms disagree, and lists the difference. The 0.18
4209
+ * grants (the wildcard, the room grants) and row policies are taken back as each share moves.
4210
+ * The member's own SHOW ROW POLICIES, which members made before 0.20 hold, is taken back too
4211
+ * (share.showPoliciesRevokes): it opens every member's scoped shares, and a share does not need it.
4212
+ * Safe to run again on a member whose shares have moved; then that is all it does.
4213
+ */
4214
+ async function cmdV3Shares(cfg, member, q, N) {
4215
+ const share = require(path.join(REPO_ROOT, 'memhouse', 'share'));
4216
+ const Q = require(path.join(REPO_ROOT, 'memhouse', 'house', 'queries-v3'));
4217
+ const db = cfg.db;
4218
+ // Every read fails closed: a record, a grant list or a policy list that cannot be read stops
4219
+ // the command before anything changes (share.readLiveShares).
4220
+ const targets = share.memberTargets({ db, archive: N.archive, member, legacy: N.legacy });
4221
+ const NEW = targets.after;
4222
+ let st0;
4223
+ try { st0 = await share.readLiveShares(q, { db, member, meta: N.meta, targets, sqlStr }); }
4224
+ catch (e) { console.log(bad(`${e.message} — nothing changed`)); return 1; }
4225
+ const { recorded, started, live, viaRole, admins, collisions, policiesOf } = st0;
4226
+ let own;
4227
+ try { own = share.showPoliciesRevokes((await q.rows(`SHOW GRANTS FOR ${share.ident(member)}`)).map((r) => String(Object.values(r)[0])), member); }
4228
+ catch (e) { console.log(bad(`could not read the grants of '${member}': ${e.message.split('\n')[0]} — nothing changed`)); return 1; }
4229
+ const ownLine = () => {
4230
+ if (!own.length) return;
4231
+ console.log(` '${member}' still holds SHOW ROW POLICIES, which members no longer receive. Take it back by hand:`);
4232
+ for (const s of own) console.log(` ${s};`);
4233
+ };
4234
+ // Two members whose names collide (bob, bob_smith): the shorter name's grant `<db>.bob_*` matches
4235
+ // bob_smith's tables too, and a longer name's can match the shorter one's (bob_session_* matches
4236
+ // bob_session_parts). No share is safe to move in such a house, and a REVOKE on a wildcard that
4237
+ // matches another member's tables would strip the grants there.
4238
+ if (collisions.length) {
4239
+ console.log(bad(`'${member}' shares its name's prefix with ${collisions.map((x) => `'${x}'`).join(', ')}: one name's grant <db>.<name>_* reaches the other's tables, to read and to write.`));
4240
+ console.log(' Nothing was changed. This is the house to fix first: docs/guides/troubleshooting.md, "Two member names that collide".');
4241
+ ownLine();
4242
+ return 1;
4243
+ }
4244
+ const diffs = []; const plan = [];
4245
+ for (const [u, rec] of recorded) {
4246
+ try { share.assertName(u, 'user'); } catch (e) { diffs.push(`${u}: ${e.message}`); continue; }
4247
+ const on = live.get(u) || new Set();
4248
+ const st = share.classifyShare({ rec, on, policies: policiesOf(u), started: started.has(u), targets });
4249
+ if (st.diff) { diffs.push(`${u}: ${st.diff}`); continue; }
4250
+ if (st.skip) continue;
4251
+ let scope = null;
4252
+ if (rec.scopeRaw) {
4253
+ try { scope = share.parseScope(rec.scopeRaw); for (const t of share.V3_TABLES) share.scopePredicateV3(scope, t, sqlStr, Q.parseSessionRef); }
4254
+ catch (e) { diffs.push(`${u}: recorded scope '${rec.scopeRaw}' does not translate: ${e.message}`); continue; }
4255
+ }
4256
+ const steps = share.moveSharePlan({ db, member, user: u, scope, legacy: N.legacy, holdsWildcard: on.has(targets.wildcard), sqlStr, parseSessionRef: Q.parseSessionRef });
4257
+ plan.push({ user: u, scope, scopeRaw: rec.scopeRaw, resume: !!st.resume, steps });
4258
+ }
4259
+ for (const u of live.keys()) if (!recorded.has(u)) diffs.push(`${u}: holds ${[...live.get(u)].join(', ')}, but memhouse recorded no share — a grant made by hand?`);
4260
+ // memhouse grants a share to the reader itself, never through a role: what a role reaches is a
4261
+ // grant it did not record, and a move would leave it in place (a REVOKE from the reader does not
4262
+ // touch the role's privileges).
4263
+ diffs.push(...share.roleDifferences(viaRole));
4264
+ if (diffs.length) {
4265
+ console.log(bad(`the shares memhouse recorded for '${member}' and the grants ClickHouse holds disagree — nothing was changed:`));
4266
+ for (const d of diffs) console.log(bad(` ${d}`));
4267
+ console.log(' Settle each one (re-share or revoke it on 0.18, or by hand), then run this again.');
4268
+ ownLine();
4269
+ return 1;
4270
+ }
4271
+ if (admins.length) console.log(` server administrators (they read everything by their own global grants; any other grant of theirs was checked like anyone's): ${admins.join(', ')}`);
4272
+ if (!plan.length && !own.length) { console.log(ok(`'${member}': no share to move`)); return 0; }
4273
+ // A scoped share is a row policy, and on a server that hides rows from readers without one,
4274
+ // the first policy would blind the member and every full reader. Proven before, or nothing.
4275
+ if (plan.some((p) => p.scope)) {
4276
+ const verdict = await probePermissive(cfg, db);
4277
+ if (verdict !== true) {
4278
+ console.log(bad(verdict === false ? 'this ClickHouse hides every row from readers that have no row policy.' : 'could not verify how this ClickHouse treats readers without a row policy.'));
4279
+ console.log(' A scoped share is a row policy; nothing was changed. Ask whoever runs the server for users_without_row_policies_can_read_rows = true.');
4280
+ return 1;
4281
+ }
4282
+ }
4283
+ if (own.length) {
4284
+ console.log(` ${member} its own grant: SHOW ROW POLICIES, which members no longer receive`);
4285
+ for (const s of own) console.log(` ${s}`);
4286
+ }
4287
+ for (const p of plan) {
4288
+ console.log(` ${p.user} ${p.scopeRaw ? `scoped: ${p.scopeRaw}` : 'full'}${p.resume ? ' (finishing an earlier run)' : ''}`);
4289
+ for (const st of p.steps) console.log(` ${st.sql}`);
4290
+ }
4291
+ if (flags['dry-run'] === true) { console.log(' dry run: nothing changed'); return 0; }
4292
+ if (flags.yes !== true) {
4293
+ const a = (await ask(`Apply these grants for '${member}'? (yes/no)`, 'no')).toLowerCase();
4294
+ if (a !== 'yes' && a !== 'y') return console.log('aborted'), 1;
4295
+ }
4296
+ const run = (sql) => q.sql(sql);
4297
+ for (const s of own) {
4298
+ try { await run(s); } catch (e) { console.log(bad(`could not take SHOW ROW POLICIES back from '${member}' (${e.message.split('\n')[0]}); stopped — nothing else changed`)); return 1; }
4299
+ }
4300
+ if (own.length) console.log(ok(`'${member}' no longer holds SHOW ROW POLICIES`));
4301
+ if (!plan.length) { console.log(ok(`'${member}': no share to move`)); return 0; }
4302
+ // meta is latest-wins on updated_at: each mark gets a time strictly after the one before, so
4303
+ // 'done' can never tie with its 'started'.
4304
+ let lastMs = 0;
4305
+ const mark = (u, v) => {
4306
+ lastMs = Math.max(Date.now(), lastMs + 1);
4307
+ return q.sql(`INSERT INTO ${share.qual(N.meta)} (key, value, updated_at) VALUES (${sqlStr(`v3share:${u}`)}, ${sqlStr(`${v} ${new Date(lastMs).toISOString().slice(0, 10)}`)}, fromUnixTimestamp64Milli(${lastMs}))`);
4308
+ };
4309
+ for (const p of plan) {
4310
+ // Marked before the first change: a run stopped part-way is finished by the next one, which
4311
+ // accepts only the states between the recorded 0.18 share and its schema-3 form.
4312
+ try { await mark(p.user, 'started'); } catch (e) { console.log(bad(`${p.user}: could not record the start (${e.message.split('\n')[0]}) — nothing changed for ${p.user}; stopped`)); return 1; }
4313
+ const ef = await runPhase(run, p.steps, 'filter');
4314
+ if (ef) { console.log(bad(`${p.user}: could not scope (${ef.message.split('\n')[0]}) — nothing granted for ${p.user}; stopped`)); return 1; }
4315
+ const eg = await runPhase(run, p.steps, 'grant');
4316
+ if (eg) { console.log(bad(`${p.user}: could not grant (${eg.message.split('\n')[0]}); stopped — run again once fixed (each step is safe to repeat)`)); return 1; }
4317
+ const ec = await runPhase(run, p.steps, 'clear');
4318
+ if (ec) { console.log(bad(`${p.user}: the schema-3 share is in place, but a 0.18 grant or filter could not be removed (${ec.message.split('\n')[0]}); stopped — run again once fixed`)); return 1; }
4319
+ // Fail closed here too: a 'started' left behind would keep the next run's relaxed checks on.
4320
+ try { await mark(p.user, 'done'); } catch (e) { console.log(bad(`${p.user}: the share is moved, but its completion could not be recorded (${e.message.split('\n')[0]}); stopped — run again`)); return 1; }
4321
+ console.log(ok(`${p.user}: ${p.scopeRaw ? `scoped share (${p.scopeRaw})` : 'full share'} now on ${NEW.join(', ')}`));
4322
+ }
4323
+ return 0;
4324
+ }
4325
+
4326
+ /**
4327
+ * `memhouse migrate --v3-prepare [--member M] [--dry-run] [--yes]` — the first admin step of the
4328
+ * schema-3 upgrade (docs/guides/upgrade-to-0.20.md; source-identity.md §7 steps 2 and 7):
4329
+ * 1. fence: meta min_writer_schema = 3 in the member's house, so every 0.18 writer refuses
4330
+ * (ship.js assertWriterSupported) — and a 0.20 one still on the rooms is told to switch;
4331
+ * 2. rename the member's three 0.18 rooms into <db>_archive as *_pre_v3, in ONE statement,
4332
+ * which is what `--v3-convert` reads. Nothing is deleted.
4333
+ * Refuses before changing anything: a room missing, a target already there (prepared before:
4334
+ * said so, exit 0 when complete), or no admin rights. --dry-run prints the plan only.
4335
+ */
4336
+ async function cmdV3Prepare(cfg, member, q, N) {
4337
+ const LEGACY = ['messages', 'tool_calls', 'sessions'];
4338
+ const src = Object.fromEntries(LEGACY.map((t) => [t, `${cfg.db}.${member}_${t}`]));
4339
+ const dst = Object.fromEntries(LEGACY.map((t) => [t, N.legacy[t]]));
4340
+ const fq = (x) => x.split('.').map((p) => `\`${p}\``).join('.');
4341
+ const names = [...Object.values(src), ...Object.values(dst)];
4342
+ const have = new Map((await q.rows(`SELECT concat(database, '.', name) AS fq, total_rows AS n FROM system.tables
4343
+ WHERE concat(database, '.', name) IN (${names.map(sqlStr).join(', ')})`)).map((r) => [r.fq, Number(r.n) || 0]));
4344
+ const missing = LEGACY.filter((t) => !have.has(src[t])); const there = LEGACY.filter((t) => have.has(dst[t]));
4345
+ if (missing.length === LEGACY.length && there.length === LEGACY.length) {
4346
+ console.log(ok(`'${member}' is prepared already: ${Object.values(dst).join(', ')}`));
4347
+ console.log(' next: memhouse migrate --v3-convert --member ' + member);
4348
+ return 0;
4349
+ }
4350
+ if (missing.length || there.length) {
4351
+ console.log(bad(`'${member}' cannot be prepared as it stands — nothing was changed:`));
4352
+ for (const t of missing) console.log(bad(` ${src[t]} is not there`));
4353
+ for (const t of there) console.log(bad(` ${dst[t]} exists already`));
4354
+ console.log(' A half-done earlier attempt? Compare with the guide (docs/guides/upgrade-to-0.20.md) before going on.');
4355
+ return 1;
4356
+ }
4357
+ console.log(` fence: ${cfg.db}.${member}_meta min_writer_schema = 3 (0.18 writers stop; a 0.20 one is told to switch)`);
4358
+ for (const t of LEGACY) console.log(` rename: ${src[t]} (${have.get(src[t])} rows) -> ${dst[t]}`);
4359
+ if (flags['dry-run'] === true) { console.log(' dry run: nothing changed'); return 0; }
4360
+ if (flags.yes !== true) {
4361
+ const a = (await ask(`Prepare '${member}' for schema 3? (yes/no)`, 'no')).toLowerCase();
4362
+ if (a !== 'yes' && a !== 'y') return console.log('aborted'), 1;
4363
+ }
4364
+ try {
4365
+ await q.sql(`INSERT INTO ${fq(`${cfg.db}.${member}_meta`)} (key, value) VALUES ('min_writer_schema', '3')`, { async_insert: 0 });
4366
+ await q.sql(`CREATE DATABASE IF NOT EXISTS ${fq(N.archive)}`);
4367
+ await q.sql(`RENAME TABLE ${LEGACY.map((t) => `${fq(src[t])} TO ${fq(dst[t])}`).join(', ')}`);
4368
+ } catch (e) {
4369
+ console.log(bad(`stopped: ${e.message.split('\n')[0]}`));
4370
+ console.log(' What completed stands (the fence is harmless alone); re-run once the cause is fixed.');
4371
+ return 1;
4372
+ }
4373
+ console.log(ok(`'${member}' prepared: 0.18 writers fenced off, the rooms are ${Object.values(dst).join(', ')}`));
4374
+ console.log(' next: memhouse migrate --v3-convert --member ' + member + ' then --v3-reconcile, then --v3-switch on each machine');
4375
+ return 0;
4376
+ }
4377
+
4378
+ /**
4379
+ * `memhouse migrate --v3-switch [--dry-run]` — on each machine of a converted house: this install
4380
+ * captures into schema 3 from now on (MEMHOUSE_SCHEMA=3 in its env file; nothing else in the file
4381
+ * changes). Refuses unless the house holds this member's conversation tables; a running shipper
4382
+ * keeps the old setting until it is restarted.
4383
+ */
4384
+ async function cmdV3Switch(cfg) {
4385
+ if (isV3(cfg)) { console.log(ok('this install is on schema 3 already')); return 0; }
4386
+ const N = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile')).profileNames(cfg.db, cfg.user);
4387
+ const [db, parts] = N.session_parts.split('.');
4388
+ let present = 0;
4389
+ try { present = Number(((await chRows(cfg, `SELECT count() AS n FROM system.tables WHERE database = ${sqlStr(db)} AND name = ${sqlStr(parts)}`, { database: '' }))[0] || {}).n) || 0; }
4390
+ catch (e) {
4391
+ // The member's own step: a refused credential is this machine's, so its env file is the fix.
4392
+ console.log(bad(schemaChoice.isAuthFailure(e)
4393
+ ? `${schemaChoice.authFailureText(cfg.user, cfg.url)} — check MEMHOUSE_USER and MEMHOUSE_PASSWORD in ${short(ENV_FILE)}`
4394
+ : `could not check the house: ${e.message.split('\n')[0]}`));
4395
+ return 1;
4396
+ }
4397
+ if (!present) {
4398
+ console.log(bad(`the house has no ${N.session_parts} yet — it is not converted. Nothing changed.`));
4399
+ console.log(' An admin converts it first: memhouse migrate --v3-prepare, --v3-convert, --v3-reconcile (docs/guides/upgrade-to-0.20.md).');
4400
+ return 1;
4401
+ }
4402
+ // The conversion lays the tables first: a member fenced off its 0.18 rooms is converted only once
4403
+ // the conversion ran to the end (its profile_converted mark).
4404
+ const det = await require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-choice')).detectHouseSchema((sql) => chRows(cfg, sql, { database: '' }), cfg.db, cfg.user);
4405
+ if (det.fenced && !det.converted) {
4406
+ console.log(bad(`the conversion of '${cfg.user}' has not finished. Nothing changed.`));
4407
+ console.log(' Its admin runs memhouse migrate --v3-convert to the end first (a re-run skips the finished buckets).');
4408
+ return 1;
4409
+ }
4410
+ console.log(` ${path.join(HOME_DIR, 'env')}: MEMHOUSE_SCHEMA=3`);
4411
+ if (flags['dry-run'] === true) { console.log(' dry run: nothing changed'); return 0; }
4412
+ writeEnvFile({ ...cfg, schema: '3' });
4413
+ console.log(ok('this install captures into schema 3 from now on'));
4414
+ // This machine knows both its ids; record them so its converted rows read as this machine
4415
+ // (host-alias.js). The capture records it too, so a failure here costs nothing but a pass.
4416
+ try {
4417
+ const hostMod = require(path.join(REPO_ROOT, 'memhouse', 'host'));
4418
+ const neu = process.env.MEMHOUSE_HOST_ID || hostMod.sessionsHost();
4419
+ const old = (() => { try { return hostMod.identity().id; } catch { return ''; } })();
4420
+ const t = v3Transport(cfg);
4421
+ const wrote = await require(path.join(REPO_ROOT, 'memhouse', 'house', 'host-alias')).record(
4422
+ { rows: (sql) => t.q.rows(sql), run: (sql) => t.q.sql(sql) },
4423
+ { meta: N.meta, sessions: N.sessions }, old, neu);
4424
+ if (wrote) console.log(ok(`this machine's 0.18 id ${old} is recorded as ${neu}: its converted sessions read as this machine`));
4425
+ } catch { /* the first capture pass records it */ }
4426
+ console.log(' restart the shipper so it reads the new setting: memhouse stop && memhouse start');
4427
+ return 0;
4428
+ }
4429
+
4430
+ /**
4431
+ * The credential an admin step acts as (memhouse/admin-secret.js adminStepCredential): the admin
4432
+ * names, as invite, members and convert take them, with the password from adminPasswordFor; for
4433
+ * 0.20 also the member names, with a note on stderr. Refused before anything connects otherwise,
4434
+ * naming what is missing. The URL and the house stay the configured ones.
4435
+ * Returns { cfg }, or { exit } once the refusal is said.
4436
+ */
4437
+ async function adminStepConfig(base, step) {
4438
+ if (flags['admin-user'] === true) { console.log(bad('--admin-user needs a name, e.g. --admin-user default')); return { exit: 1 }; }
4439
+ const pick = require(path.join(REPO_ROOT, 'memhouse', 'admin-secret')).adminStepCredential({
4440
+ step, adminUser: base.adminUser,
4441
+ adminPasswordGiven: Boolean(process.env.MEMHOUSE_ADMIN_PASSWORD) || flags['admin-password-file'] !== undefined,
4442
+ memberUser: base.userStated ? base.user : '', urlStated: base.urlStated,
4443
+ });
4444
+ if (pick.refuse) {
4445
+ if (JSON_OUT) console.log(JSON.stringify({ ok: false, error: 'no_admin_credential', command: step, missing: pick.missing, message: pick.refuse, fix: pick.fix }, null, 2));
4446
+ else {
4447
+ console.log(bad(pick.refuse));
4448
+ console.log(` Set ${pick.missing.length > 1 ? 'them' : 'it'} for this shell, then run the step again:`);
4449
+ for (const l of pick.fix) console.log(` ${l}`);
4450
+ }
4451
+ return { exit: 2 };
4452
+ }
4453
+ // stderr, so a --json reader's stdout stays one document.
4454
+ if (pick.via === 'member') { console.error(`memhouse: ${pick.note}`); return { cfg: base }; }
4455
+ const password = await adminPasswordFor(pick.user);
4456
+ if (password === null) return { exit: 1 };
4457
+ return { cfg: { ...base, user: pick.user, password } };
4458
+ }
4459
+
4460
+ async function cmdMigrateV3() {
4461
+ // --v3-switch is the member's step, on each of its machines: its own credential, never an admin's.
4462
+ if (flags['v3-switch'] === true) return cmdV3Switch(requireConfig(resolveConfig(), 'migrate'));
4463
+ const base = resolveConfig();
4464
+ const step = `migrate --${['v3-prepare', 'v3-shares', 'v3-reconcile'].find((f) => flags[f] === true) || 'v3-convert'}`;
4465
+ const house = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
4466
+ // Whose rooms: --member, else the member this machine is configured as. Never the memhouse_root
4467
+ // default, which on an admin's machine with no install named nobody.
4468
+ const member = flags.member !== undefined && flags.member !== true ? String(flags.member) : (base.userStated ? base.user : '');
4469
+ if (!member) { console.log(bad(`${step} needs --member <m>: no member is configured here to default to — nothing was contacted`)); return 2; }
4470
+ try { house.assertUsableName(base.db, 'house'); house.assertUsableName(member, 'member'); }
4471
+ catch (e) { console.log(bad(e.message)); return 1; }
4472
+ const got = await adminStepConfig(base, step);
4473
+ if (!got.cfg) return got.exit;
4474
+ const cfg = got.cfg;
4475
+ const v3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-v3'));
4476
+ const SP = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
4477
+ const conv = require(path.join(REPO_ROOT, 'memhouse', 'house', 'convert-v3'));
4478
+ const cp = require(path.join(REPO_ROOT, 'memhouse', 'house', 'convert-profile'));
4479
+ // Long ceilings: a bucket is one server-side INSERT … SELECT whose response only returns
4480
+ // when it finishes.
4481
+ const q = {
4482
+ sql: (sql, settings) => ch(cfg, sql, { settings, timeout: 3600000, database: '' }),
4483
+ rows: (sql, settings) => chRows(cfg, sql, { settings, timeout: 3600000, database: '' }),
4484
+ };
4485
+ const N = v3.v3Names(cfg.db, member);
4486
+ const PN = SP.profileNames(cfg.db, member);
4487
+ // Every admin step reads the house's tables first, and that is where its credential is first
4488
+ // used: a credential the server refuses is named as that (by ClickHouse's code), never as tables
4489
+ // that could not be listed.
4490
+ const det = await schemaChoice.detectHouseSchema((sql) => chRows(cfg, sql, { database: '' }), cfg.db, member);
4491
+ if (det.authFailed) {
4492
+ const msg = `${schemaChoice.authFailureText(cfg.user, cfg.url)} — set MEMHOUSE_ADMIN_USER and MEMHOUSE_ADMIN_PASSWORD to the house's administrator; nothing changed`;
4493
+ if (JSON_OUT) console.log(JSON.stringify({ ok: false, error: 'authentication', member, user: cfg.user, url: cfg.url, message: msg })); else console.log(bad(msg));
4494
+ return 2;
4495
+ }
4496
+ // Imported sessions on the development layout live only in <m>_turns: preparing or converting
4497
+ // the member moves it to the profile, where they would be gone. Refused until this build can
4498
+ // import into the profile (schema-choice.js importsNotice).
4499
+ if (flags['v3-prepare'] === true || (flags['v3-shares'] !== true && flags['v3-reconcile'] !== true)) {
4500
+ // Fail closed: a house whose tables cannot be listed may hold imports nobody counted.
4501
+ if (det.unreadable) {
4502
+ const msg = `could not list ${cfg.db}'s tables, so imported sessions on the development layout cannot be ruled out — nothing changed`;
4503
+ if (JSON_OUT) console.log(JSON.stringify({ ok: false, error: 'unreadable', member, message: msg })); else console.log(bad(msg));
4504
+ return 2;
4505
+ }
4506
+ const notice = schemaChoice.importsNotice(det, member);
4507
+ if (notice && !schemaChoice.importWritesProfile() && flags['dry-run'] !== true) {
4508
+ if (JSON_OUT) console.log(JSON.stringify({ ok: false, error: 'imports', member, imported_sessions: det.devImports, message: notice }));
4509
+ else { console.log(bad(notice)); console.log(' Nothing changed.'); }
4510
+ return 2;
4511
+ }
4512
+ if (notice && !JSON_OUT) console.log(warn(notice));
4513
+ }
4514
+ if (flags['v3-prepare'] === true) return cmdV3Prepare(cfg, member, q, N);
4515
+ if (flags['v3-shares'] === true) return cmdV3Shares(cfg, member, q, N);
4516
+ const out = (o) => { if (JSON_OUT) console.log(JSON.stringify(o, null, 2)); };
4517
+ const batchRows = flags['batch-rows'] !== undefined ? Number(flags['batch-rows']) : conv.BATCH_ROWS;
4518
+ if (!(batchRows > 0)) { console.log(bad('--batch-rows must be a positive number')); return 1; }
4519
+
4520
+ if (flags['v3-reconcile'] === true) {
4521
+ // The profile conversion's check: every converted row's shas from its columns, every bucket complete.
4522
+ const r = await cp.reconcileProfile(q, { db: cfg.db, member }, { batchRows });
4523
+ if (JSON_OUT) { out(r); return r.ok ? 0 : 1; }
4524
+ for (const [t, x] of Object.entries(r.tables)) {
4525
+ const wrong = (x.mismatched || 0) + (x.msg_mismatched || 0) + (x.cmp_mismatched || 0);
4526
+ console.log((wrong ? bad : ok)(`${PN[t]}: ${x.rows || 0} converted row(s), ${wrong} whose sha does not match its columns`));
4527
+ }
4528
+ for (const t of ['parts', 'calls', 'sessions']) console.log((r[t].converted_keys === r[t].legacy_keys ? ok : bad)(`${t}: ${r[t].converted_keys}/${r[t].legacy_keys} keys`));
4529
+ if (r.incomplete_buckets.length) console.log(bad(`${r.incomplete_buckets.length} of ${r.buckets} bucket(s) incomplete — re-run memhouse migrate --v3-convert`));
4530
+ return r.ok ? 0 : 1;
4531
+ }
4532
+
4533
+ const dry = flags['dry-run'] === true;
4534
+ if (!dry) {
4535
+ if (flags.yes !== true) {
4536
+ console.log(` convert ${N.legacy.messages}, ${N.legacy.tool_calls}, ${N.legacy.sessions}`);
4537
+ console.log(` into ${PN.session_parts}, ${PN.session_usage}, ${PN.calls}, ${PN.sessions} (nothing is deleted or altered)`);
4538
+ const a = (await ask(`Convert '${member}'? (yes/no)`, 'no')).toLowerCase();
4539
+ if (a !== 'yes' && a !== 'y') return console.log('aborted'), 1;
4540
+ }
4541
+ // Admin rights create the databases and grant the member its capture tables; a member's
4542
+ // own credential skips what it may not do, and the verification below says what is missing.
4543
+ const s = await SP.ensureProfileSchema(q, { db: cfg.db, member, createDatabases: true, grantCapture: true });
4544
+ if (!JSON_OUT) {
4545
+ console.log(` ClickHouse ${s.version}`);
4546
+ for (const [t, x] of Object.entries(s.tables)) console.log((x.problem ? bad : ok)(`${x.fq}${x.problem ? `: ${x.problem}` : ''}`));
4547
+ for (const d of s.denied) console.log(warn(`not permitted: ${d}`));
4548
+ for (const d of s.grant_failures) console.log(warn(`grant failed: ${d}`));
4549
+ }
4550
+ if (!SP.CONVERSATION_TYPES.every((t) => !s.tables[t].problem)) {
4551
+ out({ ok: false, schema: s });
4552
+ if (!JSON_OUT) console.log(bad('the conversation tables are not there — nothing converted'));
4553
+ return 1;
4554
+ }
4555
+ }
4556
+ const ledger = {
4557
+ event: (e) => ch(cfg, `INSERT INTO ${N.events} (kind, id, status, host, to_version, rows_before, rows_after, detail) VALUES (`
4558
+ + [e.kind, e.id, e.status, e.host || '', '3'].map(sqlStr).join(', ')
4559
+ + `, ${Number(e.rows_before) || 0}, ${Number(e.rows_after) || 0}, ${sqlStr(e.detail || '')})`,
4560
+ { settings: { async_insert: 0 }, database: '' }),
4561
+ };
4562
+ const host = require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity().id;
4563
+ const ui = JSON_OUT ? null : { ok: (m) => console.log(ok(m)) };
4564
+ let r;
4565
+ try { r = await cp.convertProfile(q, { db: cfg.db, member, ledger, host, forceAfterDedupe: flags['force-after-dedupe'] === true }, { batchRows, dryRun: dry }, ui); }
4566
+ catch (e) {
4567
+ out({ ok: false, error: e.message.split('\n')[0] });
4568
+ if (!JSON_OUT) {
4569
+ console.log(bad(`conversion stopped: ${e.message.split('\n')[0]}`));
4570
+ console.log(' what completed stands; re-run the same command — finished buckets are skipped.');
4571
+ }
4572
+ return 1;
4573
+ }
4574
+ // A dry run is a report: it succeeds whatever it finds pending, in JSON as in text.
4575
+ if (JSON_OUT) { out(r); return r.ok || (dry && !r.refused) ? 0 : 1; }
4576
+ if (r.refused) { console.log(bad(r.refused)); console.log(' --force-after-dedupe overrides, knowingly.'); return 1; }
4577
+ const line = (t) => `${t}: ${r[t].converted_keys}/${r[t].legacy_keys} keys`
4578
+ + (r[t].legacy_rows !== undefined ? ` from ${r[t].legacy_rows} legacy rows` : '')
4579
+ + (dry ? '' : ` — ${r[t].buckets_inserted} bucket(s) written, ${r[t].buckets_skipped} already complete`);
4580
+ for (const t of ['parts', 'calls', 'sessions']) console.log((r[t].converted_keys === r[t].legacy_keys ? ok : warn)(line(t)));
4581
+ if (r.parts.collisions) console.log(warn(`${r.parts.collisions} legacy message row(s) share a profile key with another (different user_id on one session/seq)`));
4582
+ if (dry) console.log(warn(`dry run — ${r.pending_buckets.length} of ${r.buckets} bucket(s) pending, nothing was changed`));
4583
+ else console.log((r.ok ? ok : bad)(r.ok ? `converted in ${r.seconds}s — check with: memhouse migrate --v3-reconcile --member ${member}` : 'incomplete — re-run; finished buckets are skipped'));
4584
+ return r.ok || dry ? 0 : 1;
4585
+ }
4586
+
2964
4587
  /**
2965
4588
  * `memhouse relocate --to <url>` — copy a whole house to a NEW ClickHouse, server-to-
2966
4589
  * server, then repoint this install at it. The point is to move WITHOUT the shipper
@@ -2968,7 +4591,7 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
2968
4591
  * sees every old session already present and ships only genuinely new work.
2969
4592
  *
2970
4593
  * The copy is a ClickHouse remoteSecure() INSERT SELECT — the destination pulls each room
2971
- * directly from the source over the native protocol; the pilot's laptop is never in the
4594
+ * directly from the source over the native protocol; the user's laptop is never in the
2972
4595
  * data path. Provenance is carried, not restamped (insert_allow_materialized_columns=1),
2973
4596
  * exactly as `migrate`'s rebuildRoom does, so a shared house keeps every member's user_id.
2974
4597
  *
@@ -2976,7 +4599,7 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
2976
4599
  * destination and the local env file, so a failed run leaves the old house intact.
2977
4600
  *
2978
4601
  * SECURITY: the source password is spliced into the remoteSecure() call, which runs on
2979
- * the DESTINATION and lands in ITS query_log. Two consequences, both stated to the pilot:
4602
+ * the DESTINATION and lands in ITS query_log. Two consequences, both stated to the user:
2980
4603
  * it never touches THIS transcript (the SQL is never printed), but rotate the source
2981
4604
  * credential afterward if the destination's logs are not yours to trust.
2982
4605
  *
@@ -3184,7 +4807,7 @@ async function cmdNightly() {
3184
4807
  const { kind, root } = installKind();
3185
4808
  if (kind !== 'checkout') {
3186
4809
  console.log(bad(`nightly builds come from a checkout — this is a ${kind} install (${root})`));
3187
- console.log(' git clone https://github.com/agent-realm/memhouse && cd memhouse && memhouse nightly');
4810
+ console.log(' run `memhouse nightly` from inside a git checkout of the memhouse repository');
3188
4811
  return 1;
3189
4812
  }
3190
4813
  // A dirty package.json cannot be restored by checkout without eating the user's edits.
@@ -3231,7 +4854,7 @@ async function cmdNightly() {
3231
4854
  }
3232
4855
 
3233
4856
  /**
3234
- * `memhouse invite <name> --url <house-url> --admin-user <a> --admin-password <p>
4857
+ * `memhouse invite <name> --url <house-url> --admin-user <a>` (password from MEMHOUSE_ADMIN_PASSWORD or --admin-password-file)
3235
4858
  * [--db <house>] [--out <file>]`
3236
4859
  *
3237
4860
  * Mint a member on the server and hand back the ONE FILE their install needs — this
@@ -3268,6 +4891,7 @@ async function cmdShare() {
3268
4891
  const share = require(path.join(REPO_ROOT, 'memhouse', 'share'));
3269
4892
  const cfg = requireConfig(resolveConfig(), 'share');
3270
4893
  if (!cfg.user) return 1;
4894
+ if (isV3(cfg)) return cmdShareV3(cfg, share);
3271
4895
  const db = cfg.db;
3272
4896
  // In a SHARED database a member owns three named rooms, not the database. Granting
3273
4897
  // `ON <db>.*` there would hand over every housemate's rooms as well — it fails closed
@@ -3322,10 +4946,11 @@ async function cmdShare() {
3322
4946
  try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); dropped++; }
3323
4947
  catch (e) { console.log(warn(`could not drop the policy on ${room}: ${e.message.split('\n')[0]}`)); }
3324
4948
  }
3325
- await houseMeta(cfg, `share:${user}`, `revoked ${new Date().toISOString().slice(0, 10)}`);
4949
+ const lost = await shareRecord(cfg, user, `revoked ${new Date().toISOString().slice(0, 10)}`);
3326
4950
  console.log(ok(`revoked '${user}' — SELECT withdrawn and ${dropped} row polic${dropped === 1 ? 'y' : 'ies'} dropped`));
3327
4951
  console.log(' Dropping the grant alone would have left the policies, and a later re-share');
3328
4952
  console.log(' would have quietly reinherited this scope.');
4953
+ if (lost) { shareRecordLost({ user, revoked: true, why: lost, rerun: `memhouse share ${user} --revoke` }); return 1; }
3329
4954
  return 0;
3330
4955
  }
3331
4956
 
@@ -3421,26 +5046,30 @@ async function cmdShare() {
3421
5046
 
3422
5047
  if (!scope) {
3423
5048
  // A previous partial share leaves policies that would still be filtering. Widening to
3424
- // a full share has to clear them or "full" is a lie.
3425
- let cleared = 0;
5049
+ // a full share has to clear them or "full" is a lie. They are dropped by name, unseen:
5050
+ // members do not hold SHOW ROW POLICIES, so whether this was a scoped share is told by
5051
+ // memhouse's own record of it, read before the new one replaces it.
5052
+ let wasScoped = false;
5053
+ try {
5054
+ const r = await rows(`SELECT argMax(value, updated_at) AS v FROM ${physicalRoom('meta', cfg.user)} WHERE key = ${sqlStr(`share:${user}`)}`);
5055
+ wasScoped = !!share.parseShareRecord(r[0] ? r[0].v : '').scopeRaw;
5056
+ } catch { /* unread: the drops below run all the same */ }
3426
5057
  for (const room of ROOM_TYPES) {
3427
- try {
3428
- const had = await rows(`SELECT count() AS n FROM system.row_policies WHERE database = ${sqlStr(db)} AND short_name = ${sqlStr(share.policyName(user, room))}`);
3429
- if (Number(had[0] && had[0].n) > 0) { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); cleared++; }
3430
- } catch { /* nothing to clear */ }
5058
+ try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); } catch { /* nothing to clear */ }
3431
5059
  }
3432
- await houseMeta(cfg, `share:${user}`, `granted ${new Date().toISOString().slice(0, 10)}`);
5060
+ const lost = await shareRecord(cfg, user, `granted ${new Date().toISOString().slice(0, 10)}`);
3433
5061
  console.log(ok(`'${user}' can now read every session in your rooms (${db}.${mine.pattern})`));
3434
- if (cleared) console.log(warn(`cleared ${cleared} row polic${cleared === 1 ? 'y' : 'ies'} from an earlier scoped share — this is now a FULL share`));
5062
+ if (wasScoped) console.log(warn('cleared the row policies of an earlier scoped share — this is now a FULL share'));
3435
5063
  console.log(` Everything: every project, machine and editor, including anything ever pasted`);
3436
5064
  console.log(" into a session of yours — housemates' rooms are untouched.");
3437
5065
  console.log(` Narrow it with: memhouse share ${user} --only project=<name>`);
3438
5066
  console.log(` Withdraw with: memhouse share ${user} --revoke`);
5067
+ if (lost) { shareRecordLost({ user, revoked: false, why: lost, rerun: `memhouse share ${user}` }); return 1; }
3439
5068
  return 0;
3440
5069
  }
3441
5070
 
3442
5071
  // The filters are already in place — built above, before the grant.
3443
- await houseMeta(cfg, `share:${user}`, `granted ${new Date().toISOString().slice(0, 10)} scope=${scopeRaw}`);
5072
+ const lost = await shareRecord(cfg, user, `granted ${new Date().toISOString().slice(0, 10)} scope=${scopeRaw}`);
3444
5073
 
3445
5074
  // Say what they can actually reach, counted through their own filter.
3446
5075
  console.log(ok(`'${user}' can read your rooms in '${db}' where ${scopeRaw}`));
@@ -3455,6 +5084,7 @@ async function cmdShare() {
3455
5084
  console.log(' per-session filter, so a scoped reader gets none of them.');
3456
5085
  console.log(` Widen to everything: memhouse share ${user}`);
3457
5086
  console.log(` Withdraw: memhouse share ${user} --revoke`);
5087
+ if (lost) { shareRecordLost({ user, revoked: false, why: lost, rerun: `memhouse share ${user} --only ${envfile.quoteShell(scopeRaw)}` }); return 1; }
3458
5088
  return 0;
3459
5089
  }
3460
5090
 
@@ -3485,22 +5115,64 @@ async function probePermissive(cfg, db) {
3485
5115
  }
3486
5116
  }
3487
5117
 
5118
+ /**
5119
+ * Whose sessions this credential can read in the house: a share received is the housemate's
5120
+ * tables in this same database, and system.tables lists only what a credential may read. Every
5121
+ * member has a <name>_sessions table on either layout. Returns { all } for a credential that
5122
+ * reads every house (an administrator: everyone would be listed), else { members }, or null when
5123
+ * the tables cannot be listed.
5124
+ */
5125
+ async function sharesReceived(cfg) {
5126
+ try {
5127
+ const caps = capabilitiesFrom(await readGrants(cfg, cfg.user));
5128
+ if (caps.canReadEveryHouse || caps.isSuperuser) return { all: true, members: [] };
5129
+ const rows = await chRows(cfg, `SELECT name FROM system.tables WHERE database = ${sqlStr(cfg.db)} AND endsWith(name, '_sessions')`, { database: '' });
5130
+ const members = rows.map((r) => String(r.name).slice(0, -'_sessions'.length))
5131
+ .filter((m) => m && m !== cfg.user && /^[A-Za-z][A-Za-z0-9_]*$/.test(m)).sort();
5132
+ return { all: false, members };
5133
+ } catch { return null; }
5134
+ }
5135
+
5136
+ /** The received half of `share --list`: whose sessions this credential reads, and how. */
5137
+ function printReceived(cfg, received) {
5138
+ console.log('');
5139
+ if (!received) { console.log(` whose sessions you can read: could not list the tables in '${cfg.db}'`); return; }
5140
+ if (received.all) { console.log(` this credential reads every member's sessions in '${cfg.db}' (it administers the server)`); return; }
5141
+ if (!received.members.length) { console.log(` nobody has shared their sessions with you in '${cfg.db}'`); return; }
5142
+ console.log(` whose sessions you can read in '${cfg.db}' (shared with you; a scoped share shows only the rows its owner chose):`);
5143
+ for (const m of received.members) {
5144
+ console.log(` ${m.padEnd(16)} ${isV3(cfg) ? `memhouse q recent --member ${m}` : `memhouse room-sql "SELECT … FROM ${m}_messages …"`}`);
5145
+ }
5146
+ }
5147
+
3488
5148
  /** Who can read this house, what they are scoped to, and any policy left behind. */
3489
5149
  async function shareList(cfg, share) {
3490
5150
  const db = cfg.db;
5151
+ const received = await sharesReceived(cfg);
5152
+ const receivedJson = received ? (received.all ? { all: true } : received.members.map((m) => ({ member: m }))) : null;
3491
5153
  let recorded = [];
3492
5154
  try {
3493
5155
  recorded = await chRows(cfg, `SELECT substring(key, 7) AS user, value AS state FROM ${physicalRoom('meta', cfg.user)} FINAL WHERE key LIKE 'share:%' ORDER BY key`, { database: db });
3494
5156
  } catch { /* unreadable record */ }
3495
- let policies = [];
5157
+ // The policies themselves only where this credential may list them: a member does not hold
5158
+ // SHOW ROW POLICIES (provision.js), so a member's list is memhouse's record, scope included.
5159
+ let policies = []; let policiesReadable = true;
3496
5160
  try {
3497
5161
  policies = await chRows(cfg, `SELECT short_name, table, select_filter FROM system.row_policies WHERE database = ${sqlStr(db)} ORDER BY short_name`, { database: db });
3498
- } catch { /* members may not read system.row_policies on every server */ }
5162
+ } catch { policiesReadable = false; }
5163
+ // Only the policies on this member's own tables (share.ownPolicies). The list can include other
5164
+ // members' policies, on tables this credential cannot see: those are theirs, and checked against
5165
+ // system.tables they would read as orphans to drop.
5166
+ const own = share.ownPolicies(policies, { db, member: cfg.user, schema3: isV3(cfg) });
5167
+ policies = own.mine;
5168
+ // What a share covers: on schema 3 the six profile tables, never the 0.18 wildcard.
5169
+ const covers = isV3(cfg) ? { tables: share.V3_TABLES.map((t) => `${cfg.user}_${t}`) } : { rooms: `${cfg.user}_*` };
3499
5170
 
3500
5171
  if (!recorded.length && !policies.length) {
3501
- if (JSON_OUT) { console.log(JSON.stringify({ database: db, rooms: `${cfg.user}_*`, grantees: [] }, null, 2)); return 0; }
5172
+ if (JSON_OUT) { console.log(JSON.stringify({ database: db, ...covers, grantees: [], received: receivedJson }, null, 2)); return 0; }
3502
5173
  console.log(ok(`nobody has been granted a read of your rooms in '${db}'`));
3503
5174
  console.log(` Share it with: memhouse share <user> [--only project=<name>]`);
5175
+ printReceived(cfg, received);
3504
5176
  return 0;
3505
5177
  }
3506
5178
  // In a shared house a grantee reads YOUR ROOMS, never the database — every other member
@@ -3510,27 +5182,38 @@ async function shareList(cfg, share) {
3510
5182
  // and any caller parsing it got prose. It is the audit surface; it should be readable
3511
5183
  // by something other than a person.
3512
5184
  console.log(JSON.stringify({
3513
- database: db, rooms: `${cfg.user}_*`,
5185
+ database: db, ...covers,
3514
5186
  grantees: recorded.map((r) => ({
3515
- user: r.user, state: r.state,
3516
- scoped: policies.filter((p) => String(p.short_name).startsWith(`mh_share_${r.user}_`))
3517
- .map((p) => ({ room: p.table, filter: p.select_filter })),
5187
+ user: r.user, state: r.state, scope: share.parseShareRecord(r.state).scopeRaw,
5188
+ // The live filters, or null where this credential cannot list them.
5189
+ scoped: policiesReadable ? own.grants.filter((p) => String(p.short_name).startsWith(`mh_share_${r.user}_`))
5190
+ .map((p) => ({ room: p.table, filter: p.select_filter })) : null,
3518
5191
  })),
5192
+ policies_readable: policiesReadable,
3519
5193
  note: "memhouse's own record, not ClickHouse's grant table — a grant made by hand does not appear here",
5194
+ received: receivedJson,
3520
5195
  }, null, 2));
3521
5196
  return 0;
3522
5197
  }
3523
- console.log(` who can read your rooms in '${db}' — memhouse's own record, not ClickHouse's grant table:`);
5198
+ console.log(` who can read your ${isV3(cfg) ? 'sessions' : 'rooms'} in '${db}' — memhouse's own record, not ClickHouse's grant table:`);
3524
5199
  for (const r of recorded) {
3525
- const scoped = policies.filter((p) => String(p.short_name).startsWith(`mh_share_${r.user}_`));
3526
- const whole = ' (all your rooms)';
3527
- console.log(` ${String(r.user).padEnd(16)} ${r.state}${scoped.length ? '' : (String(r.state).startsWith('granted') ? whole : '')}`);
5200
+ const scoped = own.grants.filter((p) => String(p.short_name).startsWith(`mh_share_${r.user}_`));
5201
+ const whole = isV3(cfg) ? ` (every session: ${share.V3_TABLES.join(', ')})` : ' (all your rooms)';
5202
+ // Full only when neither the record nor a live filter says otherwise.
5203
+ const full = String(r.state).startsWith('granted') && !share.parseShareRecord(r.state).scopeRaw && !scoped.length;
5204
+ console.log(` ${String(r.user).padEnd(16)} ${r.state}${full ? whole : ''}`);
3528
5205
  for (const p of scoped) console.log(` ${String(p.table).padEnd(11)} ${p.select_filter}`);
3529
5206
  }
3530
5207
  console.log(' A grant made by hand does not appear here — this list is what memhouse recorded.');
5208
+ printReceived(cfg, received);
3531
5209
 
3532
5210
  // Policies whose room is gone: dropping a house does NOT drop its policies, and a house
3533
- // later recreated under the same name silently inherits them.
5211
+ // later recreated under the same name silently inherits them. Listing them takes a credential
5212
+ // that may read system.row_policies, which a member's does not: an admin's check.
5213
+ if (!policiesReadable) {
5214
+ console.log(' Row policies left behind by a dropped table are listed by an admin: docs/guides/operate.md.');
5215
+ return 0;
5216
+ }
3534
5217
  const orphans = [];
3535
5218
  for (const p of policies) {
3536
5219
  try {
@@ -3687,9 +5370,15 @@ async function offerInvite() {
3687
5370
 
3688
5371
  async function cmdInvite() {
3689
5372
  const name = positional[0];
3690
- if (!name) { console.log('usage: memhouse invite <name> --url <house-url> [--admin-user … [--admin-password …]] [--db <house>] [--out <file>] [--adopt] [--print-sql]'); return 2; }
5373
+ if (!name) { console.log('usage: memhouse invite <name> --url <house-url> [--admin-user … [--admin-password-file FILE|-]] [--db <house>] [--out <file>] [--adopt] [--schema 2|3] [--expires DAYS] [--print-sql]'); return 2; }
3691
5374
  try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(name, 'member'); }
3692
5375
  catch (e) { console.log(bad(e.message)); return 1; }
5376
+ // How long the invite's password works (VALID UNTIL): checked before anything is contacted.
5377
+ const expiresDays = flags.expires === undefined ? INVITE_DAYS : flags.expires === true ? NaN : Number(flags.expires);
5378
+ if (!Number.isInteger(expiresDays) || expiresDays < 1 || expiresDays > 365) {
5379
+ console.log(bad(`--expires takes a whole number of days, 1 to 365 (default ${INVITE_DAYS}); got '${flags.expires === true ? '' : flags.expires}'`));
5380
+ return 2;
5381
+ }
3693
5382
  const cfg = resolveConfig();
3694
5383
  // The URL must be STATED and must work from the INVITEE's machine. cfg.url falls back
3695
5384
  // to localhost:8123 — dead, or worse, someone else's house, on every other machine.
@@ -3725,11 +5414,25 @@ async function cmdInvite() {
3725
5414
  // admin" is only useful if you can hand them something exact. This prints the same
3726
5415
  // statements adminBootstrap would run, so the person with the credential runs four
3727
5416
  // lines instead of installing anything. Nothing is contacted and nothing is written.
5417
+ // The invitee's layout. --schema states it, and so does an exported MEMHOUSE_SCHEMA (set
5418
+ // for this command, as an exported MEMHOUSE_ADMIN_USER is). The inviter's own env file
5419
+ // says nothing about the house, and an empty admin home says nothing at all (found by
5420
+ // example 03: an invite from one minted 0.18 rooms in a schema-3 house).
5421
+ const statedSchema = flags.schema && flags.schema !== true ? String(flags.schema) : String(process.env.MEMHOUSE_SCHEMA || '');
5422
+ if (!['', '2', '3'].includes(statedSchema)) {
5423
+ console.log(bad(`--schema ${statedSchema}: only 2 (the 0.18 rooms) and 3 (harness-sessions-sql's profile) exist`));
5424
+ return 1;
5425
+ }
3728
5426
  if (flags['print-sql'] === true) {
3729
5427
  // memberSql prints its own header naming the house and the credential it needs; a
3730
5428
  // second one above it said the same thing twice, and in a shared house said the wrong
3731
5429
  // thing ("their own house" — they get rooms in a shared one).
3732
5430
  console.log(memberSql(db, name));
5431
+ // Nothing is contacted, so nothing is detected: schema 3 unless stated, as install --print-sql.
5432
+ if (statedSchema !== '2') console.log(memberSqlV3(db, name));
5433
+ // The same expiry a run of invite sets. A placeholder, like the password's: the statement
5434
+ // does not parse until a quoted moment in the server's time zone stands in its place.
5435
+ console.log(`-- The invited password expires, as \`memhouse invite\` sets it: put the moment ${expiresDays} day${expiresDays === 1 ? '' : 's'}\n-- from now, quoted, in the server's time zone, where <valid-until> stands. install lifts it.\nALTER USER \`${name}\` VALID UNTIL <valid-until>;`);
3733
5436
  console.log(handoffLines({ url, db, member: name, invite: true }));
3734
5437
  return 0;
3735
5438
  }
@@ -3790,7 +5493,7 @@ async function cmdInvite() {
3790
5493
  // Reachability FIRST, and reported as itself. Folding it into the grants read makes an
3791
5494
  // unreachable host — a typo, a loopback-bound house, a tunnel that is down — come back
3792
5495
  // as "you are only a member", which sends the reader after a privilege they already
3793
- // have. Caught on the testbed, where a wrong --url produced exactly that.
5496
+ // have. Caught on a test VM, where a wrong --url produced exactly that.
3794
5497
  // The member credential is stored, so it is checked against the house it was saved for
3795
5498
  // (target.url), never against --url; --url's reachability was checked above, unauthenticated.
3796
5499
  try {
@@ -3846,12 +5549,21 @@ async function cmdInvite() {
3846
5549
  // to invite, and a house that does not exist yet is the ordinary case.
3847
5550
  const adminCfg = { ...cfg, url: provisionUrl, user: adminUser, password: adminPass || '', db, stated: true };
3848
5551
 
3849
- const room = physicalRoom('messages', name);
5552
+ // The house decides, as for an install: a new house is schema 3, one on the 0.18 rooms
5553
+ // keeps them, and an ambiguous one is refused.
5554
+ {
5555
+ const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), db, name), name, { user: adminCfg.user, url: adminCfg.url });
5556
+ if (pick.refuse) { console.log(bad(pick.refuse)); console.log(' nothing was changed'); return 1; }
5557
+ cfg.schema = pick.schema === '3' ? '3' : '';
5558
+ }
5559
+
5560
+ // Schema 3: the conversation table that holds the messages is <name>_session_parts.
5561
+ const room = isV3(cfg) ? `${name}_session_parts` : physicalRoom('messages', name);
3850
5562
  let occupied = null;
3851
5563
  try {
3852
5564
  const exists = await chRows(adminCfg, `SELECT count() AS n FROM system.tables WHERE database = '${db.replace(/'/g, "\\'")}' AND name = '${room.replace(/'/g, "\\'")}'`, { database: '' });
3853
5565
  if (Number(exists[0] && exists[0].n) > 0) {
3854
- const r = await chRows(adminCfg, `SELECT count() AS msgs, uniqExact(user_id) AS writers FROM ${room}`, { database: db });
5566
+ const r = await chRows(adminCfg, `SELECT count() AS msgs, uniqExact(${isV3(cfg) ? 'writer' : 'user_id'}) AS writers FROM ${room}`, { database: db });
3855
5567
  const msgs = Number(r[0] && r[0].msgs) || 0;
3856
5568
  if (msgs > 0) occupied = { msgs, writers: Number(r[0].writers) || 0 };
3857
5569
  }
@@ -3877,22 +5589,39 @@ async function cmdInvite() {
3877
5589
  if (built) built.url = url; // the invitee reaches the house at --url, not the admin's endpoint
3878
5590
  if (!built) return 1;
3879
5591
 
5592
+ // The invite's password expires: a file that sits unused, or was read where it should not
5593
+ // have been, stops working on its own. The server's own clock sets the date (VALID UNTIL is
5594
+ // read in its time zone); install's rotation clears it, as does --keep-password.
5595
+ let expires = null;
5596
+ try {
5597
+ const adminCfg = { ...cfg, url: provisionUrl, db, user: adminUser, password: adminPass || '' };
5598
+ const r = (await chRows(adminCfg, `SELECT toString(now() + toIntervalDay(${expiresDays})) AS lit, toUnixTimestamp(now() + toIntervalDay(${expiresDays})) AS ts`, { database: '' }))[0];
5599
+ await ch(adminCfg, `ALTER USER \`${name}\` VALID UNTIL ${sqlStr(r.lit)}`, { database: '' });
5600
+ expires = new Date(Number(r.ts) * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
5601
+ } catch (e) {
5602
+ console.log(warn(`could not set the invite's expiry (${String(e.message).split('\n')[0]}): its password works until install changes it`));
5603
+ }
5604
+
3880
5605
  const out = path.resolve(flags.out && flags.out !== true ? String(flags.out) : `invite-${name}.env`);
3881
5606
  const sq = envfile.quoteShell;
3882
5607
  const body = [
3883
- `# memhouse invite for '${name}' — THIS FILE IS A CREDENTIAL. Treat it like a password:`,
3884
- '# hand it over a channel you trust, and delete it after install.',
3885
- '# The person who created it knows the password inside — so when you install, memhouse',
3886
- '# OFFERS to change it to one only you know (you were granted ALTER USER on yourself).',
5608
+ `# memhouse invite for '${name}' — THIS FILE IS A CREDENTIAL. Do not print it, paste it,`,
5609
+ '# or open it in an agent: it holds a password, and whatever an agent reads stays in its',
5610
+ '# transcript. Hand it over a channel you trust.',
3887
5611
  `# Install: memhouse install --env ${path.basename(out)}`,
5612
+ '# Install changes the password inside to a new one only your machine knows (whoever made',
5613
+ '# this file knows the current one), saves it in ~/.memhouse/env, and deletes this file.',
3888
5614
  `MEMHOUSE_URL=${sq(built.url)}`,
3889
5615
  `MEMHOUSE_USER=${sq(built.user)}`,
3890
5616
  `MEMHOUSE_PASSWORD=${sq(built.password)}`,
3891
5617
  `MEMHOUSE_DB=${sq(built.db)}`,
3892
5618
  ...(cfg.channel ? [`MEMHOUSE_CHANNEL=${sq(cfg.channel)}`] : []),
3893
- '# This credential was issued by an invitation (no admin access here); memhouse offers',
3894
- '# to rotate it on install so the inviter no longer knows it.',
5619
+ ...(isV3(cfg) ? ['# Schema 3: harness-sessions-sql\'s profile tables and the capture archive.', 'MEMHOUSE_SCHEMA=3'] : []),
5620
+ '# Issued by an invitation (no admin access here): install changes it so the inviter',
5621
+ '# no longer knows it.',
3895
5622
  'MEMHOUSE_INVITE=1',
5623
+ ...(expires ? [`# The password above stops working at ${expires} (UTC): install before then, or ask for a new invite.`,
5624
+ `MEMHOUSE_INVITE_EXPIRES=${sq(expires)}`] : []),
3896
5625
  '',
3897
5626
  ].join('\n');
3898
5627
  // Atomic + private + no symlink follow: write a fresh temp with O_EXCL at 0600, then
@@ -3913,13 +5642,12 @@ async function cmdInvite() {
3913
5642
  return 1;
3914
5643
  }
3915
5644
  console.log(ok(`invite written: ${out}`));
3916
- // The guide that travels with the credential. Rendered, not linked: it names THIS
3917
- // invitee's file, this house, and — until the build is published — the exact tarball
3918
- // to install, none of which a static page can carry. Nothing secret goes into it.
5645
+ // The guide that travels with the credential (memhouse/invitation.js). Rendered, not
5646
+ // linked: it names THIS invitee's file, this house, the tables this invite created, and —
5647
+ // until the build is published — the exact tarball to install, none of which a static page
5648
+ // can carry. Nothing secret goes into it.
3919
5649
  const prerelease = /-/.test(PKG.version);
3920
- const guide = path.join(path.dirname(out), 'MEMHOUSE-INVITATION.md');
3921
5650
  try {
3922
- const tpl = fs.readFileSync(path.join(DELIVERY, 'INVITATION.md.tpl'), 'utf-8');
3923
5651
  // A nightly stamps its own version into the filename, so the guide names the shape of
3924
5652
  // the file, not a name it cannot know; the version check below matches on the base.
3925
5653
  const base = PKG.version.split('-')[0];
@@ -3931,27 +5659,27 @@ async function cmdInvite() {
3931
5659
  ? `This house runs a pre-release build, so install the tarball you were sent rather than the\n`
3932
5660
  + `published package. From the directory you saved it in:\n\n\`\`\`\nnpm install -g ./memhouse-*.tgz\n\`\`\``
3933
5661
  : `\`\`\`\nnpm install -g memhouse@${PKG.version}\n\`\`\``;
3934
- fs.writeFileSync(guide, tpl
3935
- .replaceAll('{{NAME}}', name)
3936
- .replaceAll('{{FILE}}', path.basename(out))
3937
- .replaceAll('{{URL}}', url)
3938
- .replaceAll('{{DB}}', built.db)
3939
- .replaceAll('{{VERSION}}', prerelease ? `${base}-…` : PKG.version)
3940
- .replaceAll('{{INSTALL}}', install)
3941
- .replaceAll('{{INVITER}}', cfg.user || require('os').userInfo().username));
3942
- console.log(ok(`guide written: ${guide}`));
5662
+ // The layout is the one provisioned above (schemaChoice), never the inviter's own install.
5663
+ const page = require(path.join(REPO_ROOT, 'memhouse', 'invitation')).invitationPage({
5664
+ out, name, url, db: built.db, schema: isV3(cfg) ? '3' : '', install,
5665
+ version: prerelease ? `${base}-…` : PKG.version,
5666
+ inviter: cfg.user || require('os').userInfo().username, expires,
5667
+ });
5668
+ fs.writeFileSync(page.path, page.text);
5669
+ console.log(ok(`guide written: ${page.path}`));
3943
5670
  } catch (e) { console.log(warn(`could not write the invitation guide: ${e.message}`)); }
5671
+ if (expires) console.log(ok(`the invite expires at ${expires} (UTC), in ${expiresDays} day${expiresDays === 1 ? '' : 's'} (--expires <days> sets it); after that, invite again`));
3944
5672
  console.log(` send ${name} BOTH files over a channel you trust (croc, a password manager — not chat):`);
3945
5673
  console.log(` ${path.basename(out)} their credential — one-time, install deletes it`);
3946
5674
  console.log(` MEMHOUSE-INVITATION.md the steps, nothing secret`);
3947
5675
  if (prerelease && !cfg.channel) console.log(` and the tarball this build came from (memhouse nightly --out …) — ${PKG.version} is not on npm`);
3948
- console.log(` they run: memhouse install --env ${path.basename(out)} (rotates the password to one only they know)`);
5676
+ console.log(` they run: memhouse install --env ${path.basename(out)} (changes the password to one only their machine knows, then deletes the file)`);
3949
5677
  console.log(` once installed, they are a member — sharing works both ways: /mem:access ${name}`);
3950
5678
  return 0;
3951
5679
  }
3952
5680
 
3953
5681
  /**
3954
- * `memhouse passwd [--password <new>] --admin-user <a> --admin-password <p>`
5682
+ * `memhouse passwd [--password <new>] --admin-user <a>` (admin password from MEMHOUSE_ADMIN_PASSWORD or --admin-password-file)
3955
5683
  *
3956
5684
  * Rotate THIS install's member password and rewrite the env file. Admin-assisted by
3957
5685
  * ClickHouse's rules — changing any password takes the ALTER USER privilege, which a
@@ -4040,7 +5768,7 @@ async function cmdUninstall() {
4040
5768
  if (flags.yes !== true) {
4041
5769
  const fullT = flags['full-removal'] === true;
4042
5770
  const credsT = fullT || flags.credentials === true;
4043
- const removes = ['the shipper/dashboard daemons and the OS service'];
5771
+ const removes = ['the shipper/dashboard daemons and the OS service', "the dashboard's token"];
4044
5772
  if (credsT) removes.push(`the house connection and its credential (${ENV_FILE.replace(os.homedir(), '~')})`);
4045
5773
  if (fullT) removes.push("this machine's host identity (a reinstall becomes a NEW host)");
4046
5774
  const keeps = [];
@@ -4132,7 +5860,10 @@ async function cmdUninstall() {
4132
5860
  const p = path.join(HOME_DIR, d);
4133
5861
  if (fs.existsSync(p)) fs.rmSync(p, { recursive: true });
4134
5862
  }
4135
- console.log(ok('removed runtime state (pidfiles, logs)'));
5863
+ // The dashboard's token, and the page `memhouse dashboard` writes with it. The next start
5864
+ // makes a new token.
5865
+ for (const f of [dashAuth.tokenPath(HOME_DIR), dashAuth.openerPath(HOME_DIR)]) fs.rmSync(f, { force: true });
5866
+ console.log(ok("removed runtime state (pidfiles, logs, the dashboard's token)"));
4136
5867
 
4137
5868
  if (creds) {
4138
5869
  if (fs.existsSync(ENV_FILE)) {
@@ -4164,11 +5895,35 @@ async function cmdUninstall() {
4164
5895
  // invite, uninstall and friends act on first call with no separate confirm step, so a
4165
5896
  // --help that fell through to the default branch would DO the thing instead of
4166
5897
  // describing it. One check ahead of the switch, for every command, is the whole fix.
5898
+ // The command's own entries, not the whole page (memhouse/help.js); `q` lists its named queries.
4167
5899
  if (cmd && (flags.help === true || process.argv.slice(2).some((a) => a === '-h' || a === '--help'))) {
4168
- console.log(HELP); return;
5900
+ if (cmd === 'q') {
5901
+ const schema = isV3(resolveConfig()) ? 3 : 2;
5902
+ for (const l of qc().renderQ('list', await qc().runQ(null, null, qc().parseQ([], {}), { schema }))) console.log(l);
5903
+ return;
5904
+ }
5905
+ console.log(require(path.join(REPO_ROOT, 'memhouse', 'help')).helpFor(HELP, cmd, PKG.version) || HELP); return;
4169
5906
  }
4170
5907
  const cfg = resolveConfig();
4171
5908
 
5909
+ // Commands that act on the 0.18 rooms — deleting, re-shipping, granting, rebuilding or moving
5910
+ // them. On a schema-3 install they would act on tables this install no longer reads (or fail
5911
+ // halfway), so they stop here and say so. Reset and relocate on schema 3 come later (share is ported).
5912
+ {
5913
+ const legacyOnly = ['reset', 'relocate', 'migrate-rooms', 'convert'];
5914
+ const v3Migrate = V3_MIGRATE_FLAGS.some((f) => flags[f] === true);
5915
+ if (isV3(cfg) && (legacyOnly.includes(cmd) || (cmd === 'migrate' && !v3Migrate))) {
5916
+ const msg = `memhouse ${cmd} works on the 0.18 rooms; this install is on schema 3 (MEMHOUSE_SCHEMA=3) — nothing ran.`;
5917
+ if (JSON_OUT) console.log(JSON.stringify({ error: 'schema', schema: 3, command: cmd, message: msg }));
5918
+ else {
5919
+ console.log(bad(msg));
5920
+ if (cmd === 'migrate') console.log(' the schema-3 steps are: memhouse migrate --v3-prepare | --v3-convert | --v3-reconcile [--member M], then --v3-switch on each machine');
5921
+ }
5922
+ process.exitCode = 2;
5923
+ return;
5924
+ }
5925
+ }
5926
+
4172
5927
  switch (cmd) {
4173
5928
  case null: {
4174
5929
  // Bare `memhouse`. If an invite file is where the user is standing — or in their
@@ -4219,6 +5974,50 @@ async function cmdUninstall() {
4219
5974
  process.exitCode = 1;
4220
5975
  break;
4221
5976
  }
5977
+ if (!['', '2', '3'].includes(String(c.schema))) {
5978
+ console.log(bad(`--schema ${c.schema}: only 2 (the 0.18 rooms) and 3 (harness-sessions-sql's profile) exist`));
5979
+ process.exitCode = 1;
5980
+ break;
5981
+ }
5982
+ // A credential that administers the server, saved here, is an administrator password on
5983
+ // disk that every process running as this user can read — and memhouse ships as a
5984
+ // member, so it buys nothing day to day. Store one only on request (--keep-admin, as
5985
+ // install says it), or on a yes at a terminal; never because --yes waved it through.
5986
+ // Re-saving the credential already configured here (a port change) does not ask again.
5987
+ // An administrator password given as --password is refused outright, --keep-admin or
5988
+ // not: it is the admin password on the command line, which --admin-password no longer
5989
+ // accepts either.
5990
+ const prior = readEnvFile();
5991
+ const same = prior.MEMHOUSE_URL === c.url && prior.MEMHOUSE_USER === c.user;
5992
+ const onArgv = SECRETS_ON_ARGV.has('password');
5993
+ if (onArgv || (!same && flags['keep-admin'] !== true)) {
5994
+ let grants = [];
5995
+ try { grants = (await chRows(c, 'SHOW GRANTS', { database: '' })).map((r) => Object.values(r).join(' ')); } catch { /* unreachable: nothing to judge by */ }
5996
+ const caps = capabilitiesFrom(grants);
5997
+ if ((caps.canMintUsers || caps.isSuperuser) && onArgv) {
5998
+ console.log(bad(`'${c.user}' administers this server, and an administrator password on the command line is refused (it shows in \`ps\` and in shell history).`));
5999
+ console.log(' Give it in MEMHOUSE_PASSWORD, or --password-file <file, mode 600> / --password-file - (stdin), or at the prompt, and add --keep-admin to store it.');
6000
+ console.log(' Nothing written.');
6001
+ process.exitCode = 2;
6002
+ break;
6003
+ }
6004
+ if ((caps.canMintUsers || caps.isSuperuser) && !same && flags['keep-admin'] !== true) {
6005
+ console.log(warn(`'${c.user}' administers this server${caps.isSuperuser ? ' (reads every house, creates users and houses)' : ' (creates users)'}.`));
6006
+ console.log(` Saving it puts an administrator password in ${short(ENV_FILE)}, readable by every process run as you.`);
6007
+ console.log(' memhouse ships as a member: make one with memhouse install --admin-user ' + c.user + ' --member <you>');
6008
+ console.log(' (the admin password from MEMHOUSE_ADMIN_PASSWORD or --admin-password-file, and not stored).');
6009
+ let go = false;
6010
+ if (process.stdin.isTTY && flags.yes !== true) {
6011
+ const a = (await ask(' Store this administrator credential anyway? (y/N)', 'N')).toLowerCase();
6012
+ go = a === 'y' || a === 'yes';
6013
+ }
6014
+ if (!go) {
6015
+ console.log(bad('nothing written. To store an administrator credential on purpose: memhouse setup … --keep-admin'));
6016
+ process.exitCode = 1;
6017
+ break;
6018
+ }
6019
+ }
6020
+ }
4222
6021
  writeEnvFile(c);
4223
6022
  console.log(ok(`config written: ${ENV_FILE}`));
4224
6023
  break;
@@ -4231,11 +6030,17 @@ async function cmdUninstall() {
4231
6030
  if (flags['ensure-schema'] === true) args.push('--ensure-schema');
4232
6031
  if (flags.full === true) args.push('--full');
4233
6032
  if (flags.loop !== undefined) { args.push('--loop'); if (flags.loop !== true) args.push(String(flags.loop)); }
6033
+ // Schema v3 capture (memhouse/shipper/capture.js): --v3, or MEMHOUSE_SCHEMA=3 in the env.
6034
+ for (const f of ['v3', 'backfill', 'reconcile', 'no-dedupe', 'duplicates', 'redupe', 'no-bridge', 'no-normalise', 'cleanup', 'no-cleanup', 'dry-run']) if (flags[f] === true) args.push(`--${f}`);
4234
6035
  process.exitCode = run(SHIP_JS, args, cfg);
4235
6036
  break;
4236
6037
  }
4237
- case 'stats': requireConfig(cfg, 'stats'); process.exitCode = run(SHIP_JS, ['--stats'], cfg); break;
6038
+ case 'stats': process.exitCode = await cmdStats(cfg); break;
6039
+ case 'q': process.exitCode = await cmdQ(); break;
6040
+ case 'room-sql': process.exitCode = await cmdRoomSql(); break;
6041
+ case 'import': process.exitCode = await cmdImport(); break;
4238
6042
  case 'start': await cmdStart(); break;
6043
+ case 'dashboard': process.exitCode = await cmdDashboard(); break;
4239
6044
  case 'stop': cmdStop(); break;
4240
6045
  case 'status': process.exitCode = await cmdStatus(); break;
4241
6046
  case 'doctor': process.exitCode = await cmdDoctor(); break;
@@ -4247,6 +6052,15 @@ async function cmdUninstall() {
4247
6052
  // configured credential is — the substitute for that name.
4248
6053
  case 'sessions-query': {
4249
6054
  const cfg = requireConfig(resolveConfig(), 'sessions-query');
6055
+ if (isV3(cfg)) {
6056
+ // Schema 3: the sessions table IS the rollup (exact totals, one row per session).
6057
+ const t = v3Transport(cfg);
6058
+ let rooms;
6059
+ try { rooms = await v3RoomsFor(cfg, t); } catch (e) { process.exitCode = qError(e); break; }
6060
+ if (!JSON_OUT) console.log(`-- memhouse sessions for '${rooms.member}' (schema 3) — the sessions table read FINAL, tombstones dropped; 0.18 column names kept as aliases.`);
6061
+ console.log(qc().sessionsQueryV3(rooms));
6062
+ break;
6063
+ }
4250
6064
  const r = await roomsFor(cfg);
4251
6065
  // On STDOUT and inside the SQL. This used to go to stderr, so `sessions-query >
4252
6066
  // q.sql` kept the query and dropped the instruction it depended on. The rollup now
@@ -4266,7 +6080,7 @@ async function cmdUninstall() {
4266
6080
  const au = flags['admin-user'] && flags['admin-user'] !== true ? String(flags['admin-user']) : (cfg.adminUser || null);
4267
6081
  const ap = au ? await adminPasswordFor(au) : '';
4268
6082
  if (ap === null) { process.exitCode = 1; break; }
4269
- if (!au) { console.log(bad('convert needs an admin credential: --admin-user/--admin-password (or the one this instance keeps)')); process.exitCode = 1; break; }
6083
+ if (!au) { console.log(bad('convert needs an admin credential: --admin-user with MEMHOUSE_ADMIN_PASSWORD or --admin-password-file (or the one this instance keeps)')); process.exitCode = 1; break; }
4270
6084
  const who = { ...cfg, user: au, password: ap };
4271
6085
  const only = flags.member && flags.member !== true ? String(flags.member) : null;
4272
6086
  // Candidates: a user whose own database holds `messages` and who has no rooms in `db` yet.
@@ -4358,42 +6172,64 @@ async function cmdUninstall() {
4358
6172
  // Reading ANOTHER account's grants needs privilege; without it ClickHouse simply
4359
6173
  // returns fewer rows. Say so rather than presenting a short list as the whole truth.
4360
6174
  const who = au ? { ...cfg, user: au, password: ap } : cfg;
4361
- let rows = [];
6175
+ let rows = []; let rolesUnread = false;
4362
6176
  try {
4363
6177
  // `is_wildcard` arrived in 26.x. On 25.11 — the default local tag — a wildcard grant
4364
6178
  // is enforced identically but its row carries only the bare prefix (`alice_`), so
4365
6179
  // without the column the trailing underscore is the marker.
4366
6180
  const hasWild = Number((await chRows(who, "SELECT count() AS n FROM system.columns WHERE database = 'system' AND table = 'grants' AND name = 'is_wildcard'", { database: '' }))[0]?.n) > 0;
4367
- const star = hasWild ? "if(is_wildcard, '*', '')" : "if(endsWith(table, '_'), '*', '')";
4368
- rows = await chRows(who,
4369
- `SELECT user_name AS u, max(table IS NULL) AS db_wide,
4370
- arrayStringConcat(arraySort(groupUniqArray(concat(table, ${star}))), ', ') AS tables
4371
- FROM system.grants
4372
- WHERE database = ${sqlStr(db)} AND user_name IS NOT NULL
4373
- GROUP BY user_name ORDER BY user_name`, { database: '' });
6181
+ // The house and its capture archive: a grant on a member's archive tables is access too.
6182
+ const archive = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile')).archiveDb(db);
6183
+ const share = require(path.join(REPO_ROOT, 'memhouse', 'share'));
6184
+ const grants = await chRows(who, share.membersGrantsSQL({ db, archive, hasWild, sqlStr }), { database: '' });
6185
+ // Who holds which role: a credential that cannot read it is told so, never shown less silently.
6186
+ let roleGrants = [];
6187
+ try { roleGrants = await chRows(who, share.MEMBERS_ROLE_GRANTS_SQL, { database: '' }); }
6188
+ catch (e) {
6189
+ rolesUnread = true;
6190
+ // stderr: a --json reader gets only JSON on stdout (and roles_read: false in it).
6191
+ console.error(warn(`could not read who holds which role (${String(e.message).split('\n')[0]}): access held through a role is not shown`));
6192
+ }
6193
+ rows = share.membersAccess({ grants, roleGrants, db });
6194
+ // Whether a share is scoped: its row policies, where this credential can list them.
6195
+ try { rows.policies = await chRows(who, `SELECT short_name, table FROM system.row_policies WHERE database = ${sqlStr(db)}`, { database: '' }); }
6196
+ catch { rows.policies = null; }
4374
6197
  } catch (e) {
4375
6198
  console.log(bad(`could not read the grant table: ${e.message}`));
4376
- console.log(' Pass an admin credential: memhouse members --admin-user <a> --admin-password <p>');
6199
+ console.log(' Pass an admin credential: memhouse members --admin-user <a> (its password from MEMHOUSE_ADMIN_PASSWORD, or --admin-password-file -)');
4377
6200
  process.exitCode = 1; break;
4378
6201
  }
6202
+ const share = require(path.join(REPO_ROOT, 'memhouse', 'share'));
6203
+ const described = (r) => share.describeAccess(r, { policies: rows.policies });
4379
6204
  if (JSON_OUT) {
4380
- console.log(JSON.stringify({ database: db, members: rows.map((r) => ({
6205
+ console.log(JSON.stringify({ database: db, roles_read: !rolesUnread, policies_read: rows.policies !== null, members: rows.map((r) => ({
4381
6206
  name: r.u, scope: Number(r.db_wide) === 1 ? 'database' : 'rooms',
4382
- rooms: Number(r.db_wide) === 1 ? null : String(r.tables || '').split(', ').filter(Boolean),
6207
+ rooms: Number(r.db_wide) === 1 ? null : r.list,
6208
+ ...(Number(r.db_wide) === 1 ? {} : described(r)),
4383
6209
  })) }, null, 2));
4384
6210
  break;
4385
6211
  }
4386
6212
  if (!rows.length) {
4387
6213
  console.log(ok(`no member holds a grant on '${db}'`));
4388
6214
  console.log(` Either it is empty, or this credential cannot see other accounts' grants.`);
4389
- console.log(' With an admin: memhouse members --db ' + db + ' --admin-user <a> --admin-password <p>');
6215
+ console.log(' With an admin: memhouse members --db ' + db + ' --admin-user <a> (its password from MEMHOUSE_ADMIN_PASSWORD, or --admin-password-file -)');
4390
6216
  break;
4391
6217
  }
4392
6218
  const owners = rows.filter((r) => Number(r.db_wide) === 1);
4393
6219
  console.log(` '${db}' — ${rows.length} member(s):`);
6220
+ // Each account's own tables, the members it reads (a share: scoped or full where the row
6221
+ // policies can be listed), and anything else, which memhouse does not grant itself.
4394
6222
  for (const r of rows) {
4395
- if (Number(r.db_wide) === 1) console.log(` ${String(r.u).padEnd(16)} the whole database`);
4396
- else console.log(` ${String(r.u).padEnd(16)} ${String(r.tables || '(none)')}`);
6223
+ if (Number(r.db_wide) === 1) { console.log(` ${String(r.u).padEnd(16)} the whole database`); continue; }
6224
+ const d = described(r);
6225
+ const pad = ' '.repeat(21);
6226
+ const lines = [];
6227
+ if (d.own.length) lines.push(`own: ${d.own.join(', ')}`);
6228
+ for (const s of d.shares) lines.push(`reads ${s.from}'s sessions (a share${s.scoped === null ? '' : s.scoped ? ', scoped' : ', full'}): ${s.tables.join(', ')}`);
6229
+ if (d.other.length) lines.push(`other grants (not memhouse's own shape — check them): ${d.other.join(', ')}`);
6230
+ if (!lines.length) lines.push('(none)');
6231
+ console.log(` ${String(r.u).padEnd(16)} ${lines[0]}`);
6232
+ for (const l of lines.slice(1)) console.log(`${pad}${l}`);
4397
6233
  }
4398
6234
  if (owners.length && rows.length > 1) {
4399
6235
  console.log('');
@@ -4425,7 +6261,16 @@ async function cmdUninstall() {
4425
6261
  try { return fs.realpathSync(b.home) === realHome; } catch { return b.home === HOME_DIR; }
4426
6262
  });
4427
6263
  let rooms = null; let counts = {};
4428
- if (cfg.stated && cfg.user) {
6264
+ if (cfg.stated && cfg.user && isV3(cfg)) {
6265
+ // Schema 3: the conversation tables in the house, the capture tables in <db>_archive.
6266
+ const SP = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
6267
+ const N = SP.profileNames(cfg.db, cfg.user);
6268
+ const list = [...SP.PROFILE_TYPES, 'meta', 'events'].map((k) => N[k]);
6269
+ try {
6270
+ for (const r of await chRows(cfg, `SELECT concat(database, '.', name) AS fq, total_rows AS n FROM system.tables WHERE (database = ${sqlStr(cfg.db)} OR database = ${sqlStr(N.archive)}) AND startsWith(name, ${sqlStr(`${cfg.user}_`)})`, { database: '' })) counts[r.fq] = Number(r.n);
6271
+ } catch { /* unreachable house: tables still listed, counts absent */ }
6272
+ rooms = { v3: list };
6273
+ } else if (cfg.stated && cfg.user) {
4429
6274
  rooms = roomNames(cfg.user);
4430
6275
  try {
4431
6276
  // system.tables, not system.parts: a member holds no grant on the latter, and the
@@ -4437,21 +6282,42 @@ async function cmdUninstall() {
4437
6282
  const out = {
4438
6283
  instance: name, home: HOME_DIR, envFile: ENV_FILE, binary: thisBin, version: PKG.version, channel: cfg.channel || null,
4439
6284
  house: cfg.stated ? { url: cfg.url, db: cfg.db, member: cfg.user, adminCredential: !!cfg.adminUser, adminUser: cfg.adminUser || null } : null,
4440
- rooms: rooms ? [...ROOM_TYPES, ...META_TYPES].map((t) => ({ table: `${cfg.db}.${rooms.physical[t]}`, rows: counts[rooms.physical[t]] ?? null })) : [],
4441
- ships: scope.describe({ MEMHOUSE_EDITORS: cfg.editors, MEMHOUSE_CLAUDE_ROOTS: cfg.claudeRoots }),
4442
- host: host ? host.id : null, daemons: { ...daemons, dashboardUrl: daemons.dashboard ? `http://localhost:${cfg.port}` : null },
6285
+ schema: isV3(cfg) ? 3 : 2,
6286
+ rooms: !rooms ? [] : rooms.v3 ? rooms.v3.map((fq) => ({ table: fq, rows: counts[fq] ?? null }))
6287
+ : [...ROOM_TYPES, ...META_TYPES].map((t) => ({ table: `${cfg.db}.${rooms.physical[t]}`, rows: counts[rooms.physical[t]] ?? null })),
6288
+ ships: isV3(cfg) ? (() => { const sr = sourceRootsFor(cfg); return sr.error ? `capture roots: ${sr.error}` : `capture (schema 3)${sr.defaulted ? ', default roots' : ''}: ${sr.roots.map((r) => `${r.harness}:${r.name}=${short(r.dir)}`).join(', ') || 'none found'}`; })()
6289
+ : scope.describe({ MEMHOUSE_EDITORS: cfg.editors, MEMHOUSE_CLAUDE_ROOTS: cfg.claudeRoots }),
6290
+ host: host ? host.id : null,
6291
+ daemons: { ...daemons, dashboardUrl: daemons.dashboard ? `http://127.0.0.1:${runningPort(cfg)}` : null, dashboardOpen: daemons.dashboard ? selfCmd('dashboard') : null },
4443
6292
  boundPlaybooks: bound,
4444
6293
  };
6294
+ // `schema` above is what THIS install reads. On a machine left behind by a conversion it
6295
+ // printed "schema 2" as if that were the house's, beside a house that had moved on, and
6296
+ // only `status` told the truth. Ask the house the same question status asks.
6297
+ if (cfg.stated && cfg.user) {
6298
+ let p = null;
6299
+ try { p = await roomsProblem(cfg); } catch { /* unreachable house: nothing to compare */ }
6300
+ if (p) { out.rooms_problem = p.kind; out.problem = p.lines; out.stranded = ['moved', 'not-moved', 'moved-db'].includes(p.kind); }
6301
+ }
4445
6302
  if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); break; }
4446
6303
  const line = (k, v) => console.log(` ${k.padEnd(9)} ${v}`);
4447
6304
  line('instance', `${name} (${short(HOME_DIR)})`);
4448
6305
  line('binary', `${short(thisBin)} ${PKG.version}${cfg.channel ? ` channel ${cfg.channel}` : ''}`);
4449
6306
  if (!out.house) { line('house', 'none configured — memhouse install'); break; }
4450
- line('house', `${cfg.url} db ${cfg.db} member ${cfg.user} admin credential: ${cfg.adminUser ? `yes (${cfg.adminUser})` : 'no'}`);
4451
- line('rooms', out.rooms.map((r) => `${r.table}${r.rows === null ? '' : ` ${r.rows}`}`).join(' · '));
6307
+ line('house', `${cfg.url} db ${cfg.db} member ${cfg.user} schema ${out.schema}${out.stranded ? ' (what this install reads; the house disagrees)' : ''} admin credential: ${cfg.adminUser ? `yes (${cfg.adminUser})` : 'no'}`);
6308
+ if (out.problem) {
6309
+ console.log(bad(`${out.stranded ? 'stranded: ' : ''}${out.problem[0]}`));
6310
+ console.log(` ${out.problem[1]}`);
6311
+ console.log(' details: memhouse status');
6312
+ }
6313
+ // Rows, not sessions: a table's row count holds every version not yet merged (a session is
6314
+ // rewritten on each change), so beside status's session count it read as a second, different
6315
+ // number of sessions. Said as rows, with where sessions are counted.
6316
+ line(out.schema === 3 ? 'tables' : 'rooms', out.rooms.map((r) => `${r.table}${r.rows === null ? '' : ` ${r.rows} rows`}`).join(' · '));
6317
+ if (out.rooms.some((r) => r.rows !== null)) line('', 'rows on disk, versions not yet merged included: sessions are counted by memhouse status and stats');
4452
6318
  line('ships', out.ships);
4453
6319
  line('host', host ? host.id : 'not minted yet (first ship mints it)');
4454
- line('daemons', `shipper ${daemons.shipper ? `running (pid ${daemons.shipper})` : 'stopped'} dashboard ${daemons.dashboard ? `running (pid ${daemons.dashboard}) → http://localhost:${cfg.port}` : 'stopped'}`);
6320
+ line('daemons', `shipper ${daemons.shipper ? `running (pid ${daemons.shipper})` : 'stopped'} dashboard ${daemons.dashboard ? `running (pid ${daemons.dashboard}, port ${runningPort(cfg)}) — open it with: ${selfCmd('dashboard')}` : 'stopped'}`);
4455
6321
  line('bound', bound.length ? bound.map(short).join(', ') : 'no playbook is bound to this instance — memhouse plugins install claude');
4456
6322
  break;
4457
6323
  }
@@ -4460,6 +6326,21 @@ async function cmdUninstall() {
4460
6326
  // configured house. It resolves through roomNames(), so it cannot drift from what the
4461
6327
  // shipper writes to.
4462
6328
  const cfg = requireConfig(resolveConfig(), 'rooms');
6329
+ if (isV3(cfg)) {
6330
+ const t = v3Transport(cfg);
6331
+ let member;
6332
+ try { member = (await v3RoomsFor(cfg, t)).member; } catch (e) { process.exitCode = qError(e); break; }
6333
+ const SP = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
6334
+ const N = SP.profileNames(cfg.db, member);
6335
+ const tables = Object.fromEntries([...SP.PROFILE_TYPES, 'meta', 'events'].map((k) => [k, N[k]]));
6336
+ if (JSON_OUT) { console.log(JSON.stringify({ schema: 3, database: cfg.db, archive: N.archive, member, pattern: `${member}_*`, tables }, null, 2)); break; }
6337
+ console.log(`${cfg.db} — your tables, schema 3 (harness-sessions-sql's profile, ${cfg.db}.${member}_*):`);
6338
+ for (const k of SP.CONVERSATION_TYPES) console.log(` ${tables[k]}`);
6339
+ console.log(` capture layer (the shipper's only): ${N.session_units}, ${N.session_sources}`);
6340
+ console.log(` record of the house: ${N.meta}, ${N.events}`);
6341
+ console.log(`\n Bare names in memhouse room-sql resolve to these. Read them FINAL and filter is_deleted = 0 AND removed = 0; a session is (host, harness, harness_path, session_id, incarnation).`);
6342
+ break;
6343
+ }
4463
6344
  const r = await roomsFor(cfg);
4464
6345
  const names = {};
4465
6346
  for (const t of [...ROOM_TYPES, ...META_TYPES]) names[t] = r.physical[t];
@@ -4484,7 +6365,9 @@ async function cmdUninstall() {
4484
6365
  // wrong for anyone whose house or MEMHOUSE_HOME differs, which is the whole point
4485
6366
  // of having those knobs. `--install` already renders; bare `prompt` did not.
4486
6367
  const pcfg = resolveConfig();
4487
- process.stdout.write(fs.readFileSync(path.join(DELIVERY, 'PROMPT.md'), 'utf-8')
6368
+ // The snippet for THIS install's schema: the schema-3 text teaches `memhouse q` and the
6369
+ // profile tables; the schema-2 text the 0.18 rooms and their epoch filter.
6370
+ process.stdout.write(qc().renderSchemaBlocks(fs.readFileSync(path.join(DELIVERY, 'PROMPT.md'), 'utf-8'), isV3(pcfg) ? 3 : 2)
4488
6371
  .replaceAll('{{DB}}', pcfg.db)
4489
6372
  .replaceAll('{{ENV_FILE}}', ENV_FILE));
4490
6373
  }
@@ -4495,7 +6378,17 @@ async function cmdUninstall() {
4495
6378
  case 'invite': process.exitCode = await cmdInvite(); break;
4496
6379
  case 'passwd': process.exitCode = await cmdPasswd(); break;
4497
6380
  case 'nightly': process.exitCode = await cmdNightly(); break;
4498
- case 'migrate': process.exitCode = await cmdMigrate({}); break;
6381
+ case 'migrate': {
6382
+ // The admin flags are read by the admin steps only. Beside --v3-switch (the member's step) or
6383
+ // a plain migrate (the member's rooms) they would be ignored, so they are refused.
6384
+ const adminOnly = ['admin-user', 'admin-password-file'].filter((k) => flags[k] !== undefined);
6385
+ if (adminOnly.length && (flags['v3-switch'] === true || !V3_MIGRATE_FLAGS.some((f) => flags[f] === true))) {
6386
+ console.log(bad(`--${adminOnly.join(', --')} ${adminOnly.length > 1 ? 'are' : 'is'} read by the admin steps only: --v3-prepare, --v3-convert, --v3-reconcile, --v3-shares`));
6387
+ process.exitCode = 2; break;
6388
+ }
6389
+ process.exitCode = V3_MIGRATE_FLAGS.some((f) => flags[f] === true) ? await cmdMigrateV3() : await cmdMigrate({});
6390
+ break;
6391
+ }
4499
6392
  case 'migrate-rooms': process.exitCode = await cmdMigrate({ component: 'rooms' }); break;
4500
6393
  case 'relocate': process.exitCode = await cmdRelocate(); break;
4501
6394
  case 'deploy': {
@@ -4611,7 +6504,7 @@ async function cmdUninstall() {
4611
6504
  console.log(' to start over (DESTROYS the memory): memhouse deploy --down');
4612
6505
  process.exitCode = 2; break;
4613
6506
  }
4614
- // Who owns this house. A local deploy is the pilot's own machine and their own
6507
+ // Who owns this house. A local deploy is the user's own machine and their own
4615
6508
  // memory, so the house user is THEM — suggested from the OS username (`sam`, not
4616
6509
  // `memhouse_root`) — created by the container itself at first init with full
4617
6510
  // rights. No admin/member split, no provisioning dance: a URI and a credential is
@@ -4627,7 +6520,7 @@ async function cmdUninstall() {
4627
6520
  const storedUser = readEnvFile().MEMHOUSE_USER;
4628
6521
  // No stored user means the env file is gone or emptied — the stored PASSWORD is
4629
6522
  // gone with it, so the `initialised && !reusable` refusal below already owns this
4630
- // case. Refusing `--user` here on a guessed 'memhouse_root' would tell the pilot
6523
+ // case. Refusing `--user` here on a guessed 'memhouse_root' would tell the user
4631
6524
  // their house "was initialised with memhouse_root" on no evidence at all.
4632
6525
  if (storedUser && flags.user && flags.user !== true && flags.user !== storedUser) {
4633
6526
  console.log(bad(`--user cannot rename the user of an existing house — the image creates it only when it initialises the data directory.`));
@@ -4788,6 +6681,25 @@ async function cmdUninstall() {
4788
6681
  process.exitCode = 1; break;
4789
6682
  }
4790
6683
  console.log(ok(`member '${houseUser}' holds ${targetDb}.${houseUser}_* and nothing else; '${superUser}' administers the house`));
6684
+ // Schema 3, the layout a new house gets (a member already on the 0.18 rooms keeps
6685
+ // them): its capture tables live in <db>_archive, which only an admin creates and
6686
+ // grants. The superuser is at hand here, so they are built now, and the member install
6687
+ // below finds them ready. Without this, deploy --local on 0.20 stopped at the install.
6688
+ const pick = schemaChoice.chooseSchema(flags.schema && flags.schema !== true ? String(flags.schema) : '',
6689
+ await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), targetDb, houseUser), houseUser, { user: adminCfg.user, url: adminCfg.url });
6690
+ if (pick.refuse) { console.log(bad(pick.refuse)); process.exitCode = 1; break; }
6691
+ if (pick.schema === '3') {
6692
+ const v3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
6693
+ let built = null;
6694
+ try { built = await v3.ensureProfileSchema(v3Transport(adminCfg, { timeout: 120000 }).q, { db: targetDb, member: houseUser, createDatabases: true, grantCapture: true }); }
6695
+ catch (e) { console.log(bad(`could not build the schema 3 tables: ${String(e.message).split('\n')[0]}`)); process.exitCode = 1; break; }
6696
+ const badT = Object.values(built.tables).filter((x) => x.problem);
6697
+ if (badT.length || built.grant_failures.length) {
6698
+ console.log(bad(`schema 3 tables for '${houseUser}' not ready: ${[...badT.map((x) => `${x.fq} ${x.problem}`), ...built.grant_failures].join('; ')}`));
6699
+ process.exitCode = 1; break;
6700
+ }
6701
+ console.log(ok(`schema 3: ${Object.values(built.tables).map((x) => x.fq).join(', ')}; capture tables granted to '${houseUser}'`));
6702
+ }
4791
6703
  } else if (initialised && !priorCfg.adminUser) {
4792
6704
  console.log(warn(`'${houseUser}' is both member and superuser here — this house predates fencing. It works; \`memhouse doctor\` says how to fence it.`));
4793
6705
  }
@@ -4796,7 +6708,7 @@ async function cmdUninstall() {
4796
6708
  // volume survives, so nothing is lost, but the house is gone with no explanation.
4797
6709
  //
4798
6710
  // Whether it happens depends on logind's KillUserProcesses. Measured once on a
4799
- // testbed VM as `Exited (143)` twenty seconds after the ssh session ended; measured
6711
+ // test VM as `Exited (143)` twenty seconds after the ssh session ended; measured
4800
6712
  // again later on Ubuntu 24.04, which ships KillUserProcesses=no, and the container
4801
6713
  // and the user manager both survived 44 seconds with zero sessions and Linger=no.
4802
6714
  // So the warning is conditional, and it is worded as a possibility rather than a