memhouse 0.20.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/bin/memhouse.js +209 -30
  3. package/docs/guides/install.md +3 -0
  4. package/docs/guides/invite.md +7 -0
  5. package/docs/guides/search-and-resume.md +30 -0
  6. package/docs/guides/share.md +5 -0
  7. package/docs/releases/README.md +2 -0
  8. package/docs/releases/v0.20.1.md +114 -0
  9. package/docs/releases/v0.20.2.md +134 -0
  10. package/memhouse/codex-plugin.js +194 -0
  11. package/memhouse/delivery/INVITATION.md.tpl +1 -1
  12. package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
  13. package/memhouse/delivery/codex/SKILL.md +50 -0
  14. package/memhouse/delivery/plugin/reference/HOUSE.md +7 -9
  15. package/memhouse/delivery/plugin/skills/access/SKILL.md +1 -1
  16. package/memhouse/flags.js +1 -1
  17. package/memhouse/help.js +37 -0
  18. package/memhouse/house/q-cli.js +14 -1
  19. package/memhouse/house/records-to-profile.js +14 -3
  20. package/memhouse/house/schema-choice.js +15 -6
  21. package/memhouse/house/schema-profile-views.sql.tpl +53 -0
  22. package/memhouse/house/schema-profile.js +46 -6
  23. package/memhouse/house/schema-profile.sql.tpl +8 -1
  24. package/memhouse/invitation.js +8 -2
  25. package/memhouse/plugin-copies.js +13 -1
  26. package/memhouse/release-hint.js +24 -0
  27. package/memhouse/share.js +36 -4
  28. package/memhouse/shipper/capture.js +39 -9
  29. package/memhouse/shipper/log-adapter.js +18 -7
  30. package/memhouse/shipper/normalise-run.js +8 -2
  31. package/memhouse/version-notice.js +66 -0
  32. package/package.json +3 -3
  33. package/public/assets/{index-CQSJ3nhV.js → index-CcNnhfM3.js} +1 -1
  34. package/public/index.html +1 -1
  35. package/ui/package-lock.json +264 -318
  36. package/ui/package.json +5 -4
  37. package/ui/src/lib/dashboard-token.js +7 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,127 @@ Versions before 0.8.0 were beta-only. Beta installs of 0.7.x and earlier should
4
4
  uninstall and reinstall — the 0.8.0 layout is new, and an in-place `update` +
5
5
  migration path is planned work, not a promise the old versions can cash.
6
6
 
7
+ ## 0.20.2 — 2026-11-03
8
+
9
+ Release notes: [`docs/releases/v0.20.2.md`](docs/releases/v0.20.2.md). Read its "Read this
10
+ before upgrading".
11
+
12
+ - **Before upgrading: the first capture re-derives every session from its archive.** memhouse now
13
+ reads editors through `@agent-realm/harness-session-adapters` 1.1.0, whose adapters state
14
+ `adapter_ver` 0.3.0. A new adapter version is an upgrade (harness-sessions-sql §8, "Re-derive from
15
+ the archive"), so the first pass after `memhouse update` replays each session's archived passes
16
+ under 0.3.0 and writes its rows again above the house's revs. It reads the archive, not the
17
+ editors' stores, and writes every session once; on a large house that first pass takes longer
18
+ than a usual one. Later passes are as before. The editors memhouse reads through its own session
19
+ logs (every editor but Claude Code and Codex) re-derive in the same pass: their adapter,
20
+ `memhouse-bridge`, moves to 1.0.2 for the Cursor sub-agent links (below).
21
+ - **The profile tables follow harness-sessions-sql v1.1: a record's native fields, and the units it
22
+ came from.** Five columns, added to an existing house by the schema step (`ADD COLUMN`, at the
23
+ end of the table; a new house has them at the specification's positions):
24
+ - `sessions.native` and `session_parts.message_native`, `part_native`: the fields of the
25
+ editor's own record that the adapter declares (a Claude Code message's `message.stop_reason`,
26
+ say), as canonical JSON, `''` when there are none;
27
+ - `sessions.units` and `session_parts.message_units`: the `(source_key, generation, unit_key)`
28
+ of each archived unit the record came from, so a row joins back to its raw line in
29
+ `<db>_archive.<m>_session_units`.
30
+ A row the capture wrote before 0.20.2 holds `''` and `[]` until its session is re-derived (above).
31
+ The legacy conversion leaves the five columns out of its `content_sha` line, as v1.1 asks, so a
32
+ converted session keeps its shas.
33
+ - **A member's reading view of turns: `<m>_session_turns_view`** (harness-sessions-sql v1.1 §6,
34
+ `session_turns`, still a plan there). One row per turn of one session, a turn running from a
35
+ prompt to the next one, with its messages, tool calls, failed tool results and the models that
36
+ served it:
37
+ `SELECT * FROM <db>.<m>_session_turns_view(host = '…', harness = '…', harness_path = '…', session_id = '…', incarnation = '')`.
38
+ The schema step makes it (`CREATE OR REPLACE`). It runs as its reader (`SQL SECURITY INVOKER`),
39
+ so it shows nobody a row the tables would not, and a share does not reach it: a share is still
40
+ the six tables. It is not named `<m>_session_turns`, because a table ending in `_turns` reads as
41
+ a 0.18 member's room when memhouse looks for colliding member names.
42
+
43
+ - **`memhouse room-sql` runs `SHOW ROW POLICIES` on ClickHouse 25.11.** It appended its output
44
+ format (`FORMAT PrettyCompact`, or `JSONEachRow` with `--json`) to the statement, and 25.11 parses
45
+ no FORMAT after a bare `SHOW ROW POLICIES` or `SHOW POLICIES`, so the statement failed with a
46
+ syntax error. The format now goes as the `default_format` setting, which applies to every
47
+ statement; a FORMAT the statement names itself still wins.
48
+ - **The development layout (`<m>_turns`, `MEMHOUSE_LAYOUT=v3-dev`): an adapter upgrade writes above
49
+ the rows it replaces.** A new adapter version re-derived an unchanged file, from disk or from the
50
+ archive, at the rev the file's last pass had written, so its rows tied with the old ones and won
51
+ only by insertion order (listed under Deferred in 0.20.0). It now writes one rev above the
52
+ highest the house holds for that file, tombstones included. No derive code changed, so nothing is
53
+ re-derived. The profile tables were not affected.
54
+ - **Cursor: a chat's sub-agents are captured as its links.** Cursor names a chat's sub-agents only
55
+ in the chat's own record (`subagentComposerIds`), never in a message, and the profile capture did
56
+ not read them there, so the links from a chat to its sub-agents were missing. The log adapter
57
+ (`memhouse-bridge` 1.0.2) now reads them as links stated on the chat, one per sub-agent. No live
58
+ Cursor store has shown a sub-agent yet: the tests use synthetic sessions. Each new sub-agent
59
+ still rewrites the chat's log; placing it in the log where it appeared waits for a live sample
60
+ (`docs/design/sessions-engine.md`).
61
+ - **A session log memhouse cannot read is never rewritten into an empty one.** memhouse refuses to
62
+ rewrite the normalised log of a session that had messages into one with none, since an empty
63
+ read is far likelier a misread store than a real clear. It counted the old log's messages as 0
64
+ when the log was there but could not be read (a permission or disk error), which let that
65
+ rewrite through. Such a log now stops the session's pass with "cannot read the existing log
66
+ (<code>)", reported and retried like any other failed read.
67
+
68
+ ## 0.20.1 — 2026-11-02
69
+
70
+ Release notes: [`docs/releases/v0.20.1.md`](docs/releases/v0.20.1.md).
71
+
72
+ - **Dashboard: a browser that refuses sessionStorage still opens it.** The page keeps the token
73
+ in memory for that load when sessionStorage is refused; it used to show the "open it with
74
+ `memhouse dashboard`" page instead.
75
+ - **doctor names the channel when it says a release is the latest.** An unpriced model's hint now
76
+ says "the latest release on '<channel>'", so an install pinned to another line is not told it
77
+ is on the newest release overall. Offline, the hint no longer claims the install is current.
78
+ - **/mem skills: the raw-SQL recipe runs `memhouse room-sql` as written.** The reference's
79
+ schema-2 recipe wrapped it in a `q()` shell function, so every call asked for approval despite
80
+ the skills' pre-approval. It now runs `memhouse room-sql <<'SQL'` directly.
81
+ - **install, invite and `deploy --local` name a refused credential up front.** When the server
82
+ refuses the credential (ClickHouse code 516, or 194 for a missing password), they now stop
83
+ before writing anything with "could not authenticate as '<user>' at <url>", instead of reading
84
+ the house as new and failing at the next step. Code 194 counts as an authentication failure
85
+ everywhere 516 does.
86
+ - **`memhouse <command> --help` prints that command's help.** It used to print the whole page
87
+ for every command. A command with no entry of its own still gets the whole page, and
88
+ `memhouse q --help` lists the named queries (the same as `memhouse q list`).
89
+ - **`memhouse share --list` also lists the shares you received.** Beside who reads your
90
+ sessions, it names each member whose sessions this credential can read, with the command
91
+ that reads them (`memhouse q recent --member <them>`); `--json` adds `received`. An
92
+ administrator is told it reads every member's sessions instead.
93
+ - **`memhouse discover` flags an unreachable house only when it is this install's.** The
94
+ default local address (`http://localhost:8123`) is probed as a discovery; when nothing
95
+ answers there and nothing is configured to use it, it is a plain line, not a ✗. `--json`
96
+ marks each endpoint `configured`.
97
+ - **`memhouse update` on a machine with no member install checks no house.** An
98
+ administrator's machine (only `MEMHOUSE_URL` and the admin variables) used to run the schema
99
+ heal and the migration check as the default member and print its authentication failures as
100
+ the house's state. It now says there is no member install here and skips those steps.
101
+ - **`memhouse members` tells a share from a member's own tables.** Each account's line names its
102
+ own tables, then each member whose sessions it reads ("reads alice's sessions (a share,
103
+ scoped)" or "full", where the row policies can be listed), then any other grant, which
104
+ memhouse does not make itself. A scoped share used to read as a plain list of another
105
+ member's tables. `--json` adds `own`, `shares` and `other`.
106
+ - **A new minor version's first run says so.** The first CLI call of a newer major.minor on a
107
+ machine prints one line to stderr, naming the version it replaces and the shipped release
108
+ notes to read. Patch releases, downgrades and fresh installs say nothing; a stamp in the
109
+ memhouse home (`version-seen`) records the last version that ran. An install from before the
110
+ stamp is read from its home: a 0.18 install moving to 0.20.1 is told "it ran 0.18 or
111
+ earlier".
112
+ - **`memhouse plugins install codex`.** Codex gets a short, marker-delimited block in its
113
+ `AGENTS.md` (never read or print the env file or a `MEMHOUSE_*` value; `MEMHOUSE_URL` is the
114
+ house, `MEMHOUSE_PORT` the dashboard; past sessions go to the skill) and a `memhouse` skill with
115
+ the read recipes, under `CODEX_HOME` or `~/.codex`. `plugins remove codex` takes out exactly
116
+ those: the rest of `AGENTS.md` stays byte-identical, and a `skills/memhouse` that memhouse did
117
+ not install is left alone. Opt-in: nothing is written there unless it ran; the first run of a
118
+ new version refreshes a copy it finds installed. The `/mem` skills stay Claude Code's.
119
+ - **An invite's password expires.** `memhouse invite` sets the new member's password to stop
120
+ working after 7 days (`--expires <days>`, 1 to 365), by the server's clock. The invite's
121
+ output, the invite file and `MEMHOUSE-INVITATION.md` say when. `install --env` lifts the
122
+ expiry when it changes the password, and when `--keep-password` keeps it. An expired invite
123
+ is refused at install with "this invite expired at … ask whoever invited you for a new one",
124
+ before anything is contacted or written. Inviting the same name again says the unused
125
+ account must be removed first (`DROP USER <name>`); its tables are kept and reused.
126
+ `invite --print-sql` prints the same expiry, with a placeholder for the date.
127
+
7
128
  ## 0.20.0 — 2026-10-29
8
129
 
9
130
  Release notes: [`docs/releases/v0.20.0.md`](docs/releases/v0.20.0.md). Read its "Read this
package/bin/memhouse.js CHANGED
@@ -39,6 +39,8 @@ const dashPort = require(path.join(REPO_ROOT, 'memhouse', 'server', 'dashboard-p
39
39
  const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
40
40
  const ENV_FILE = path.join(HOME_DIR, 'env');
41
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;
42
44
  const LOG_DIR = path.join(HOME_DIR, 'logs');
43
45
 
44
46
  // ── args ────────────────────────────────────────────────────────────────────────
@@ -91,6 +93,14 @@ try {
91
93
  process.exit(2);
92
94
  }
93
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
+ });
94
104
  // A new version's first run refreshes the /mem skills bound to this home (an npm upgrade does
95
105
  // not run `update`, which used to be the only refresh). After that, one stamp read per call.
96
106
  // stderr only, so --json output stays clean. memhouse/plugin-copies.js.
@@ -622,7 +632,9 @@ Setup onboard interactive wizard: discover → configure →
622
632
  and one grant on exactly those. Refuses a name whose rooms
623
633
  already hold messages; --adopt takes them over (same
624
634
  person, new credential). Not an admin? --print-sql prints
625
- 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
626
638
  passwd rotate this member's password + rewrite the env file
627
639
  (admin-assisted: --admin-user, password as for install)
628
640
  setup (re)write the connection config only (--yes = no prompts)
@@ -686,6 +698,9 @@ Agents plugins list | install claude [--target DIR] | remove
686
698
  acts on EVERY Claude config dir found (~/.claude,
687
699
  CLAUDE_CONFIG_DIR, ~/.claude-playbooks/*), all
688
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
689
704
  prompt print the memory system-prompt snippet
690
705
  prompt --install print an install prompt for an agent, with this
691
706
  machine's state and the one route that applies
@@ -757,8 +772,13 @@ async function cmdDiscover() {
757
772
  out.adapterErrors = getAdapterErrors();
758
773
  } catch (e) { out.editorsError = e.message; }
759
774
 
760
- for (const url of [...new Set([resolveConfig().url, 'http://localhost:8123'])]) {
761
- 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 };
762
782
  try {
763
783
  const res = await fetch(url.replace(/\/$/, '') + '/ping', { signal: AbortSignal.timeout(3000) });
764
784
  probe.reachable = res.ok;
@@ -791,7 +811,7 @@ async function cmdDiscover() {
791
811
  printAdapterErrors(out.adapterErrors);
792
812
  console.log('\nClickHouse endpoints:');
793
813
  for (const p of out.clickhouse) {
794
- 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`);
795
815
  else if (p.auth) console.log(warn(`${p.url} — reachable, credentials needed`));
796
816
  else console.log(ok(`${p.url} — v${p.version}${p.hasHouse ? ', house present' : ''}${p.kernel ? ', KERNEL detected' : ''}`));
797
817
  }
@@ -1024,7 +1044,7 @@ function handoffLines({ url, db, member, invite }) {
1024
1044
  `-- MEMHOUSE_DB='${db}'`,
1025
1045
  ];
1026
1046
  if (isV3(resolveConfig())) lines.push('-- MEMHOUSE_SCHEMA=3');
1027
- if (invite) lines.push('-- MEMHOUSE_INVITE=1');
1047
+ if (invite) lines.push('-- MEMHOUSE_INVITE=1', "-- MEMHOUSE_INVITE_EXPIRES='<the same moment in UTC, e.g. 2026-11-06T09:00:00Z>'");
1028
1048
  lines.push('-- They install with: memhouse install --env <that file> (it deletes the file afterwards)');
1029
1049
  if (flags['member-password'] !== undefined) {
1030
1050
  lines.push('-- The --member-password you gave is not printed: use it where the placeholder stands.');
@@ -1090,6 +1110,19 @@ async function adminBootstrap(cfg, admin) {
1090
1110
  let password = flags['member-password'] || null;
1091
1111
  if (exists) {
1092
1112
  if (!password) {
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
+ }
1093
1126
  console.log(bad(`ClickHouse user '${admin.member}' already exists — give their password (MEMHOUSE_MEMBER_PASSWORD, or --member-password-file) to install as them.`));
1094
1127
  console.log(` Or pick another handle: --member <name>`);
1095
1128
  return null;
@@ -1344,6 +1377,14 @@ async function cmdInstall({ interactive }) {
1344
1377
  console.log(ok(`using the invite file ${String(flags.env)} (nothing persisted until the install proves out)`));
1345
1378
  _inviteFileToShred = path.resolve(String(flags.env));
1346
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
+ }
1347
1388
  }
1348
1389
  let cfg = resolveConfig();
1349
1390
  const adminUser = flags['admin-user'];
@@ -1394,7 +1435,7 @@ async function cmdInstall({ interactive }) {
1394
1435
  const adminPassword = await adminPasswordFor(String(adminUser));
1395
1436
  if (adminPassword === null) return 1;
1396
1437
  {
1397
- const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows({ ...cfg, user: String(adminUser), password: adminPassword }, sql, { database: '' }), cfg.db, member), member);
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 });
1398
1439
  if (pick.refuse) { console.log(bad(pick.refuse)); console.log(' nothing was changed'); return 1; }
1399
1440
  cfg.schema = pick.schema === '3' ? '3' : '';
1400
1441
  }
@@ -1514,7 +1555,7 @@ async function cmdInstall({ interactive }) {
1514
1555
  // Joining a house: its schema, detected, never the default (a 0.20 client on an unconverted
1515
1556
  // 0.18 house stays on its rooms until the admin converts it and this machine switches).
1516
1557
  {
1517
- const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows(cfg, sql, { database: '' }), cfg.db, cfg.user), cfg.user);
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 });
1518
1559
  if (pick.refuse) { console.log(bad(pick.refuse)); console.log(' nothing was changed'); return 1; }
1519
1560
  cfg.schema = pick.schema === '3' ? '3' : '';
1520
1561
  }
@@ -1695,6 +1736,12 @@ async function cmdInstall({ interactive }) {
1695
1736
  // password must never be what happens by default. The password is kept only on an explicit
1696
1737
  // no (the question, or --keep-password), and then the output says who still knows it. A failed
1697
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
+
1698
1745
  async function finishInvite(cfg) {
1699
1746
  if (_inviteWantsRotate) {
1700
1747
  let go = flags['keep-password'] !== true;
@@ -1715,6 +1762,9 @@ async function finishInvite(cfg) {
1715
1762
  for (const k of drift.changed) process.env[k] = drift.values[k];
1716
1763
  if (drift.values.MEMHOUSE_PASSWORD !== undefined && flags.password !== undefined) flags.password = drift.values.MEMHOUSE_PASSWORD;
1717
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);
1718
1768
  }
1719
1769
  if (code !== 0) {
1720
1770
  // Rotation failed and its own state may be uncertain — KEEP the invite file (it
@@ -1725,6 +1775,8 @@ async function finishInvite(cfg) {
1725
1775
  }
1726
1776
  } else {
1727
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)'));
1728
1780
  }
1729
1781
  }
1730
1782
  if (_inviteFileToShred) {
@@ -2297,18 +2349,17 @@ function confirmEmptyStore(value) {
2297
2349
  // Whether `memhouse update` helps depends on whether a newer release exists: on the latest one,
2298
2350
  // sending the reader to update loops (update says current, the gap stays).
2299
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".
2300
2354
  async function releaseBehind(cfg) {
2301
2355
  if (_behind === undefined) {
2302
- try { _behind = { known: true, up: chan.behind({ version: PKG.version, pinned: cfg.channel || null, tags: await chan.fetchTags() }) }; }
2303
- catch { _behind = { known: false, up: null }; } // offline: unknown, not current
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 });
2304
2359
  }
2305
2360
  return _behind;
2306
2361
  }
2307
- function pricingHint(b) {
2308
- if (b.up) return `memhouse update installs ${b.up.target}, which may price it`;
2309
- if (b.known) return `this is the latest release (${PKG.version}) and it has no price for it: its cost stays unknown until a release adds one`;
2310
- return 'prices ship with each release: a newer one may add them (memhouse update), and on the latest one their cost stays unknown until a release does';
2311
- }
2362
+ function pricingHint(b) { return require(path.join(REPO_ROOT, 'memhouse', 'release-hint')).pricingHint(b, PKG.version); }
2312
2363
 
2313
2364
  async function cmdDoctor() {
2314
2365
  if (flags['confirm-empty-store'] !== undefined) return confirmEmptyStore(flags['confirm-empty-store']);
@@ -2845,14 +2896,13 @@ async function cmdRoomSql() {
2845
2896
  const body0 = qc().rewriteRooms(sql, { member, db: cfg.db, schema });
2846
2897
  qc().assertRead(body0);
2847
2898
  if (flags.print === true) { console.log(body0); return 0; }
2848
- const stripped = body0.trim().replace(/;\s*$/, '');
2899
+ // The format travels as a setting, never appended to the statement (q-cli roomSqlRequest).
2900
+ const req = qc().roomSqlRequest(body0, { json: JSON_OUT });
2901
+ const text = await t.raw(req.body, { database: cfg.db, settings: req.settings });
2849
2902
  if (JSON_OUT) {
2850
- if (qc().hasFormat(stripped)) return qError(Object.assign(new Error('--json picks the format itself — drop the FORMAT clause'), { status: 400 }));
2851
- const text = await t.raw(`${stripped}\nFORMAT JSONEachRow`, { database: cfg.db });
2852
2903
  console.log(JSON.stringify(text.split('\n').filter(Boolean).map((l) => JSON.parse(l))));
2853
2904
  return 0;
2854
2905
  }
2855
- const text = await t.raw(qc().hasFormat(stripped) ? stripped : `${stripped}\nFORMAT PrettyCompact`, { database: cfg.db });
2856
2906
  process.stdout.write(text.endsWith('\n') || !text ? text : `${text}\n`);
2857
2907
  return 0;
2858
2908
  } catch (e) { return qError(e); }
@@ -3310,7 +3360,12 @@ async function cmdUpdate() {
3310
3360
  // the member's rooms moved from <name>.* to mem.<name>_*. Fix the env here, before the
3311
3361
  // shipper restarts, or its first pass refuses the old address forever.
3312
3362
  // (Schema 3 has no such move: its tables were created in place, under the configured house.)
3313
- if (!isV3(resolveConfig())) { try { await adoptMovedHouse(resolveConfig()); } catch { /* doctor will say */ } }
3363
+ // A machine with no member install (an administrator's, with only MEMHOUSE_URL and the
3364
+ // MEMHOUSE_ADMIN_* variables) has no house of its own to bring up to date: the steps below would
3365
+ // connect as the default member and print its authentication failures as the house's state.
3366
+ const memberHere = !!resolveConfig().userStated;
3367
+ 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)'));
3368
+ if (memberHere && !isV3(resolveConfig())) { try { await adoptMovedHouse(resolveConfig()); } catch { /* doctor will say */ } }
3314
3369
 
3315
3370
  // Bring the house to the schema the NEW code expects, before any daemon restarts.
3316
3371
  // A release that adds rooms (0.18.3's stat tables) used to land after the restart, so
@@ -3319,7 +3374,7 @@ async function cmdUpdate() {
3319
3374
  // 500'd — all of it self-healing eventually, all of it alarming meanwhile. Two real
3320
3375
  // members hit this within an hour of each other. The new binary owns the new schema, so
3321
3376
  // it runs it here, in the one moment the daemons are already down.
3322
- {
3377
+ if (memberHere) {
3323
3378
  const r = spawnSync(process.execPath, [path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js'), '--ensure-schema'],
3324
3379
  { env: childEnv(resolveConfig()), encoding: 'utf-8', stdio: 'pipe', timeout: 120000 });
3325
3380
  const said = `${r.stdout || ''}${r.stderr || ''}`.trim().split('\n').filter((l) => l.includes('[memhouse]')).pop();
@@ -3406,6 +3461,7 @@ async function cmdUpdate() {
3406
3461
  // non-interactive, no flag name it and print the command; NEVER auto-run — a cron
3407
3462
  // or CI invocation must not start a house-wide copy
3408
3463
  const cfg = resolveConfig();
3464
+ if (!memberHere) return 0;
3409
3465
  if (cfg.url && cfg.user && isV3(cfg)) {
3410
3466
  // Schema 3: the 0.18 migrations are about the 0.18 rooms, which this install neither reads
3411
3467
  // nor writes. What it needs is its five tables, verified — ship.js --ensure-schema does that
@@ -3529,8 +3585,23 @@ async function cmdPlugins() {
3529
3585
  // somewhere else than the directory just inspected.
3530
3586
  const self = `memhouse plugins install claude${flags.target ? ` --target ${flags.target}` : ''}`;
3531
3587
 
3588
+ // Codex: one block in <codex home>/AGENTS.md and one skill, opt-in (memhouse/codex-plugin.js).
3589
+ const cxp = require(path.join(REPO_ROOT, 'memhouse', 'codex-plugin'));
3590
+ const cxHome = cxp.codexHome();
3591
+ const codexLine = () => {
3592
+ const st = cxp.status({ codexHome: cxHome });
3593
+ if (st.installed) {
3594
+ const o = st.owner;
3595
+ const runs = cxp.invocation({ home: o.home, bin: o.bin });
3596
+ console.log(ok(`codex: installed in ${short(cxHome)} (${o.version || 'unknown version'}) — runs \`${runs}\`${st.block ? '' : '; the AGENTS.md block is missing'}`));
3597
+ } else if (st.foreignSkill) console.log(warn(`codex: ${short(st.foreignSkill)} is not memhouse's — left alone`));
3598
+ else console.log(` codex: not installed in ${short(cxHome)} — memhouse plugins install codex`);
3599
+ if (st.damaged) console.log(warn(`codex: ${st.damaged}`));
3600
+ };
3601
+
3532
3602
  if (sub === 'list') {
3533
3603
  console.log(`skills available: ${invocations}`);
3604
+ codexLine();
3534
3605
  if (!targets.length) { console.log(warn('no Claude Code config directory found')); return 0; }
3535
3606
  for (const t of targets) {
3536
3607
  console.log(isPluginInstalled(t.dir)
@@ -3577,7 +3648,33 @@ async function cmdPlugins() {
3577
3648
  return 0;
3578
3649
  }
3579
3650
 
3580
- console.log('usage: memhouse plugins [list | install claude | remove claude] [--target DIR] [--yes]');
3651
+ if (sub === 'install' && positional[1] === 'codex') {
3652
+ try {
3653
+ const r = cxp.install({ codexHome: cxHome, home: HOME_DIR, bin: thisBin, version: PKG.version, deliveryDir: DELIVERY });
3654
+ console.log(ok(`codex: ${r.created ? 'created' : 'updated'} the memhouse block in ${short(r.file)}`));
3655
+ console.log(ok(`codex: skill ${short(r.dir)} — Codex lists it as 'memhouse'`));
3656
+ console.log(` the block and the skill run \`${r.mh}\`; remove both with: memhouse plugins remove codex`);
3657
+ console.log(' loads in the next Codex session');
3658
+ return 0;
3659
+ } catch (e) { console.log(bad(`codex: ${e.message}`)); return 1; }
3660
+ }
3661
+
3662
+ if (sub === 'remove' && positional[1] === 'codex') {
3663
+ let r;
3664
+ try { r = cxp.remove({ codexHome: cxHome }); } catch (e) { console.log(bad(`codex: ${e.message}`)); return 1; }
3665
+ if (r.block) {
3666
+ const f = short(cxp.agentsFile(cxHome));
3667
+ console.log(ok(r.fileDeleted ? `codex: removed ${f} (it held only the memhouse block)`
3668
+ : 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`
3669
+ : `codex: removed the memhouse block from ${f}; the rest of the file is as it was`));
3670
+ }
3671
+ if (r.skill) console.log(ok(`codex: removed the skill ${short(cxp.skillDir(cxHome))}`));
3672
+ if (r.foreignSkill) console.log(warn(`codex: ${short(r.foreignSkill)} is not memhouse's (no ${cxp.OWNER_FILE}) — left alone`));
3673
+ if (!r.block && !r.skill) console.log(warn(`codex: nothing of memhouse's in ${short(cxHome)}`));
3674
+ return 0;
3675
+ }
3676
+
3677
+ console.log('usage: memhouse plugins [list | install claude|codex | remove claude|codex] [--target DIR] [--yes]');
3581
3678
  return 2;
3582
3679
  }
3583
3680
 
@@ -5017,9 +5114,41 @@ async function probePermissive(cfg, db) {
5017
5114
  }
5018
5115
  }
5019
5116
 
5117
+ /**
5118
+ * Whose sessions this credential can read in the house: a share received is the housemate's
5119
+ * tables in this same database, and system.tables lists only what a credential may read. Every
5120
+ * member has a <name>_sessions table on either layout. Returns { all } for a credential that
5121
+ * reads every house (an administrator: everyone would be listed), else { members }, or null when
5122
+ * the tables cannot be listed.
5123
+ */
5124
+ async function sharesReceived(cfg) {
5125
+ try {
5126
+ const caps = capabilitiesFrom(await readGrants(cfg, cfg.user));
5127
+ if (caps.canReadEveryHouse || caps.isSuperuser) return { all: true, members: [] };
5128
+ const rows = await chRows(cfg, `SELECT name FROM system.tables WHERE database = ${sqlStr(cfg.db)} AND endsWith(name, '_sessions')`, { database: '' });
5129
+ const members = rows.map((r) => String(r.name).slice(0, -'_sessions'.length))
5130
+ .filter((m) => m && m !== cfg.user && /^[A-Za-z][A-Za-z0-9_]*$/.test(m)).sort();
5131
+ return { all: false, members };
5132
+ } catch { return null; }
5133
+ }
5134
+
5135
+ /** The received half of `share --list`: whose sessions this credential reads, and how. */
5136
+ function printReceived(cfg, received) {
5137
+ console.log('');
5138
+ if (!received) { console.log(` whose sessions you can read: could not list the tables in '${cfg.db}'`); return; }
5139
+ if (received.all) { console.log(` this credential reads every member's sessions in '${cfg.db}' (it administers the server)`); return; }
5140
+ if (!received.members.length) { console.log(` nobody has shared their sessions with you in '${cfg.db}'`); return; }
5141
+ console.log(` whose sessions you can read in '${cfg.db}' (shared with you; a scoped share shows only the rows its owner chose):`);
5142
+ for (const m of received.members) {
5143
+ console.log(` ${m.padEnd(16)} ${isV3(cfg) ? `memhouse q recent --member ${m}` : `memhouse room-sql "SELECT … FROM ${m}_messages …"`}`);
5144
+ }
5145
+ }
5146
+
5020
5147
  /** Who can read this house, what they are scoped to, and any policy left behind. */
5021
5148
  async function shareList(cfg, share) {
5022
5149
  const db = cfg.db;
5150
+ const received = await sharesReceived(cfg);
5151
+ const receivedJson = received ? (received.all ? { all: true } : received.members.map((m) => ({ member: m }))) : null;
5023
5152
  let recorded = [];
5024
5153
  try {
5025
5154
  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 });
@@ -5039,9 +5168,10 @@ async function shareList(cfg, share) {
5039
5168
  const covers = isV3(cfg) ? { tables: share.V3_TABLES.map((t) => `${cfg.user}_${t}`) } : { rooms: `${cfg.user}_*` };
5040
5169
 
5041
5170
  if (!recorded.length && !policies.length) {
5042
- if (JSON_OUT) { console.log(JSON.stringify({ database: db, ...covers, grantees: [] }, null, 2)); return 0; }
5171
+ if (JSON_OUT) { console.log(JSON.stringify({ database: db, ...covers, grantees: [], received: receivedJson }, null, 2)); return 0; }
5043
5172
  console.log(ok(`nobody has been granted a read of your rooms in '${db}'`));
5044
5173
  console.log(` Share it with: memhouse share <user> [--only project=<name>]`);
5174
+ printReceived(cfg, received);
5045
5175
  return 0;
5046
5176
  }
5047
5177
  // In a shared house a grantee reads YOUR ROOMS, never the database — every other member
@@ -5060,6 +5190,7 @@ async function shareList(cfg, share) {
5060
5190
  })),
5061
5191
  policies_readable: policiesReadable,
5062
5192
  note: "memhouse's own record, not ClickHouse's grant table — a grant made by hand does not appear here",
5193
+ received: receivedJson,
5063
5194
  }, null, 2));
5064
5195
  return 0;
5065
5196
  }
@@ -5073,6 +5204,7 @@ async function shareList(cfg, share) {
5073
5204
  for (const p of scoped) console.log(` ${String(p.table).padEnd(11)} ${p.select_filter}`);
5074
5205
  }
5075
5206
  console.log(' A grant made by hand does not appear here — this list is what memhouse recorded.');
5207
+ printReceived(cfg, received);
5076
5208
 
5077
5209
  // Policies whose room is gone: dropping a house does NOT drop its policies, and a house
5078
5210
  // later recreated under the same name silently inherits them. Listing them takes a credential
@@ -5237,9 +5369,15 @@ async function offerInvite() {
5237
5369
 
5238
5370
  async function cmdInvite() {
5239
5371
  const name = positional[0];
5240
- 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] [--print-sql]'); return 2; }
5372
+ 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; }
5241
5373
  try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(name, 'member'); }
5242
5374
  catch (e) { console.log(bad(e.message)); return 1; }
5375
+ // How long the invite's password works (VALID UNTIL): checked before anything is contacted.
5376
+ const expiresDays = flags.expires === undefined ? INVITE_DAYS : flags.expires === true ? NaN : Number(flags.expires);
5377
+ if (!Number.isInteger(expiresDays) || expiresDays < 1 || expiresDays > 365) {
5378
+ console.log(bad(`--expires takes a whole number of days, 1 to 365 (default ${INVITE_DAYS}); got '${flags.expires === true ? '' : flags.expires}'`));
5379
+ return 2;
5380
+ }
5243
5381
  const cfg = resolveConfig();
5244
5382
  // The URL must be STATED and must work from the INVITEE's machine. cfg.url falls back
5245
5383
  // to localhost:8123 — dead, or worse, someone else's house, on every other machine.
@@ -5291,6 +5429,9 @@ async function cmdInvite() {
5291
5429
  console.log(memberSql(db, name));
5292
5430
  // Nothing is contacted, so nothing is detected: schema 3 unless stated, as install --print-sql.
5293
5431
  if (statedSchema !== '2') console.log(memberSqlV3(db, name));
5432
+ // The same expiry a run of invite sets. A placeholder, like the password's: the statement
5433
+ // does not parse until a quoted moment in the server's time zone stands in its place.
5434
+ 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>;`);
5294
5435
  console.log(handoffLines({ url, db, member: name, invite: true }));
5295
5436
  return 0;
5296
5437
  }
@@ -5410,7 +5551,7 @@ async function cmdInvite() {
5410
5551
  // The house decides, as for an install: a new house is schema 3, one on the 0.18 rooms
5411
5552
  // keeps them, and an ambiguous one is refused.
5412
5553
  {
5413
- const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), db, name), name);
5554
+ const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), db, name), name, { user: adminCfg.user, url: adminCfg.url });
5414
5555
  if (pick.refuse) { console.log(bad(pick.refuse)); console.log(' nothing was changed'); return 1; }
5415
5556
  cfg.schema = pick.schema === '3' ? '3' : '';
5416
5557
  }
@@ -5447,6 +5588,19 @@ async function cmdInvite() {
5447
5588
  if (built) built.url = url; // the invitee reaches the house at --url, not the admin's endpoint
5448
5589
  if (!built) return 1;
5449
5590
 
5591
+ // The invite's password expires: a file that sits unused, or was read where it should not
5592
+ // have been, stops working on its own. The server's own clock sets the date (VALID UNTIL is
5593
+ // read in its time zone); install's rotation clears it, as does --keep-password.
5594
+ let expires = null;
5595
+ try {
5596
+ const adminCfg = { ...cfg, url: provisionUrl, db, user: adminUser, password: adminPass || '' };
5597
+ const r = (await chRows(adminCfg, `SELECT toString(now() + toIntervalDay(${expiresDays})) AS lit, toUnixTimestamp(now() + toIntervalDay(${expiresDays})) AS ts`, { database: '' }))[0];
5598
+ await ch(adminCfg, `ALTER USER \`${name}\` VALID UNTIL ${sqlStr(r.lit)}`, { database: '' });
5599
+ expires = new Date(Number(r.ts) * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
5600
+ } catch (e) {
5601
+ console.log(warn(`could not set the invite's expiry (${String(e.message).split('\n')[0]}): its password works until install changes it`));
5602
+ }
5603
+
5450
5604
  const out = path.resolve(flags.out && flags.out !== true ? String(flags.out) : `invite-${name}.env`);
5451
5605
  const sq = envfile.quoteShell;
5452
5606
  const body = [
@@ -5465,6 +5619,8 @@ async function cmdInvite() {
5465
5619
  '# Issued by an invitation (no admin access here): install changes it so the inviter',
5466
5620
  '# no longer knows it.',
5467
5621
  'MEMHOUSE_INVITE=1',
5622
+ ...(expires ? [`# The password above stops working at ${expires} (UTC): install before then, or ask for a new invite.`,
5623
+ `MEMHOUSE_INVITE_EXPIRES=${sq(expires)}`] : []),
5468
5624
  '',
5469
5625
  ].join('\n');
5470
5626
  // Atomic + private + no symlink follow: write a fresh temp with O_EXCL at 0600, then
@@ -5506,11 +5662,12 @@ async function cmdInvite() {
5506
5662
  const page = require(path.join(REPO_ROOT, 'memhouse', 'invitation')).invitationPage({
5507
5663
  out, name, url, db: built.db, schema: isV3(cfg) ? '3' : '', install,
5508
5664
  version: prerelease ? `${base}-…` : PKG.version,
5509
- inviter: cfg.user || require('os').userInfo().username,
5665
+ inviter: cfg.user || require('os').userInfo().username, expires,
5510
5666
  });
5511
5667
  fs.writeFileSync(page.path, page.text);
5512
5668
  console.log(ok(`guide written: ${page.path}`));
5513
5669
  } catch (e) { console.log(warn(`could not write the invitation guide: ${e.message}`)); }
5670
+ 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`));
5514
5671
  console.log(` send ${name} BOTH files over a channel you trust (croc, a password manager — not chat):`);
5515
5672
  console.log(` ${path.basename(out)} their credential — one-time, install deletes it`);
5516
5673
  console.log(` MEMHOUSE-INVITATION.md the steps, nothing secret`);
@@ -5737,8 +5894,14 @@ async function cmdUninstall() {
5737
5894
  // invite, uninstall and friends act on first call with no separate confirm step, so a
5738
5895
  // --help that fell through to the default branch would DO the thing instead of
5739
5896
  // describing it. One check ahead of the switch, for every command, is the whole fix.
5897
+ // The command's own entries, not the whole page (memhouse/help.js); `q` lists its named queries.
5740
5898
  if (cmd && (flags.help === true || process.argv.slice(2).some((a) => a === '-h' || a === '--help'))) {
5741
- console.log(HELP); return;
5899
+ if (cmd === 'q') {
5900
+ const schema = isV3(resolveConfig()) ? 3 : 2;
5901
+ for (const l of qc().renderQ('list', await qc().runQ(null, null, qc().parseQ([], {}), { schema }))) console.log(l);
5902
+ return;
5903
+ }
5904
+ console.log(require(path.join(REPO_ROOT, 'memhouse', 'help')).helpFor(HELP, cmd, PKG.version) || HELP); return;
5742
5905
  }
5743
5906
  const cfg = resolveConfig();
5744
5907
 
@@ -6027,15 +6190,21 @@ async function cmdUninstall() {
6027
6190
  console.error(warn(`could not read who holds which role (${String(e.message).split('\n')[0]}): access held through a role is not shown`));
6028
6191
  }
6029
6192
  rows = share.membersAccess({ grants, roleGrants, db });
6193
+ // Whether a share is scoped: its row policies, where this credential can list them.
6194
+ try { rows.policies = await chRows(who, `SELECT short_name, table FROM system.row_policies WHERE database = ${sqlStr(db)}`, { database: '' }); }
6195
+ catch { rows.policies = null; }
6030
6196
  } catch (e) {
6031
6197
  console.log(bad(`could not read the grant table: ${e.message}`));
6032
6198
  console.log(' Pass an admin credential: memhouse members --admin-user <a> (its password from MEMHOUSE_ADMIN_PASSWORD, or --admin-password-file -)');
6033
6199
  process.exitCode = 1; break;
6034
6200
  }
6201
+ const share = require(path.join(REPO_ROOT, 'memhouse', 'share'));
6202
+ const described = (r) => share.describeAccess(r, { policies: rows.policies });
6035
6203
  if (JSON_OUT) {
6036
- console.log(JSON.stringify({ database: db, roles_read: !rolesUnread, members: rows.map((r) => ({
6204
+ console.log(JSON.stringify({ database: db, roles_read: !rolesUnread, policies_read: rows.policies !== null, members: rows.map((r) => ({
6037
6205
  name: r.u, scope: Number(r.db_wide) === 1 ? 'database' : 'rooms',
6038
6206
  rooms: Number(r.db_wide) === 1 ? null : r.list,
6207
+ ...(Number(r.db_wide) === 1 ? {} : described(r)),
6039
6208
  })) }, null, 2));
6040
6209
  break;
6041
6210
  }
@@ -6047,9 +6216,19 @@ async function cmdUninstall() {
6047
6216
  }
6048
6217
  const owners = rows.filter((r) => Number(r.db_wide) === 1);
6049
6218
  console.log(` '${db}' — ${rows.length} member(s):`);
6219
+ // Each account's own tables, the members it reads (a share: scoped or full where the row
6220
+ // policies can be listed), and anything else, which memhouse does not grant itself.
6050
6221
  for (const r of rows) {
6051
- if (Number(r.db_wide) === 1) console.log(` ${String(r.u).padEnd(16)} the whole database`);
6052
- else console.log(` ${String(r.u).padEnd(16)} ${String(r.tables || '(none)')}`);
6222
+ if (Number(r.db_wide) === 1) { console.log(` ${String(r.u).padEnd(16)} the whole database`); continue; }
6223
+ const d = described(r);
6224
+ const pad = ' '.repeat(21);
6225
+ const lines = [];
6226
+ if (d.own.length) lines.push(`own: ${d.own.join(', ')}`);
6227
+ 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(', ')}`);
6228
+ if (d.other.length) lines.push(`other grants (not memhouse's own shape — check them): ${d.other.join(', ')}`);
6229
+ if (!lines.length) lines.push('(none)');
6230
+ console.log(` ${String(r.u).padEnd(16)} ${lines[0]}`);
6231
+ for (const l of lines.slice(1)) console.log(`${pad}${l}`);
6053
6232
  }
6054
6233
  if (owners.length && rows.length > 1) {
6055
6234
  console.log('');
@@ -6506,7 +6685,7 @@ async function cmdUninstall() {
6506
6685
  // grants. The superuser is at hand here, so they are built now, and the member install
6507
6686
  // below finds them ready. Without this, deploy --local on 0.20 stopped at the install.
6508
6687
  const pick = schemaChoice.chooseSchema(flags.schema && flags.schema !== true ? String(flags.schema) : '',
6509
- await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), targetDb, houseUser), houseUser);
6688
+ await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), targetDb, houseUser), houseUser, { user: adminCfg.user, url: adminCfg.url });
6510
6689
  if (pick.refuse) { console.log(bad(pick.refuse)); process.exitCode = 1; break; }
6511
6690
  if (pick.schema === '3') {
6512
6691
  const v3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));