memhouse 0.20.0 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,66 @@ 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.1 — 2026-11-02
8
+
9
+ Release notes: [`docs/releases/v0.20.1.md`](docs/releases/v0.20.1.md).
10
+
11
+ - **Dashboard: a browser that refuses sessionStorage still opens it.** The page keeps the token
12
+ in memory for that load when sessionStorage is refused; it used to show the "open it with
13
+ `memhouse dashboard`" page instead.
14
+ - **doctor names the channel when it says a release is the latest.** An unpriced model's hint now
15
+ says "the latest release on '<channel>'", so an install pinned to another line is not told it
16
+ is on the newest release overall. Offline, the hint no longer claims the install is current.
17
+ - **/mem skills: the raw-SQL recipe runs `memhouse room-sql` as written.** The reference's
18
+ schema-2 recipe wrapped it in a `q()` shell function, so every call asked for approval despite
19
+ the skills' pre-approval. It now runs `memhouse room-sql <<'SQL'` directly.
20
+ - **install, invite and `deploy --local` name a refused credential up front.** When the server
21
+ refuses the credential (ClickHouse code 516, or 194 for a missing password), they now stop
22
+ before writing anything with "could not authenticate as '<user>' at <url>", instead of reading
23
+ the house as new and failing at the next step. Code 194 counts as an authentication failure
24
+ everywhere 516 does.
25
+ - **`memhouse <command> --help` prints that command's help.** It used to print the whole page
26
+ for every command. A command with no entry of its own still gets the whole page, and
27
+ `memhouse q --help` lists the named queries (the same as `memhouse q list`).
28
+ - **`memhouse share --list` also lists the shares you received.** Beside who reads your
29
+ sessions, it names each member whose sessions this credential can read, with the command
30
+ that reads them (`memhouse q recent --member <them>`); `--json` adds `received`. An
31
+ administrator is told it reads every member's sessions instead.
32
+ - **`memhouse discover` flags an unreachable house only when it is this install's.** The
33
+ default local address (`http://localhost:8123`) is probed as a discovery; when nothing
34
+ answers there and nothing is configured to use it, it is a plain line, not a ✗. `--json`
35
+ marks each endpoint `configured`.
36
+ - **`memhouse update` on a machine with no member install checks no house.** An
37
+ administrator's machine (only `MEMHOUSE_URL` and the admin variables) used to run the schema
38
+ heal and the migration check as the default member and print its authentication failures as
39
+ the house's state. It now says there is no member install here and skips those steps.
40
+ - **`memhouse members` tells a share from a member's own tables.** Each account's line names its
41
+ own tables, then each member whose sessions it reads ("reads alice's sessions (a share,
42
+ scoped)" or "full", where the row policies can be listed), then any other grant, which
43
+ memhouse does not make itself. A scoped share used to read as a plain list of another
44
+ member's tables. `--json` adds `own`, `shares` and `other`.
45
+ - **A new minor version's first run says so.** The first CLI call of a newer major.minor on a
46
+ machine prints one line to stderr, naming the version it replaces and the shipped release
47
+ notes to read. Patch releases, downgrades and fresh installs say nothing; a stamp in the
48
+ memhouse home (`version-seen`) records the last version that ran. An install from before the
49
+ stamp is read from its home: a 0.18 install moving to 0.20.1 is told "it ran 0.18 or
50
+ earlier".
51
+ - **`memhouse plugins install codex`.** Codex gets a short, marker-delimited block in its
52
+ `AGENTS.md` (never read or print the env file or a `MEMHOUSE_*` value; `MEMHOUSE_URL` is the
53
+ house, `MEMHOUSE_PORT` the dashboard; past sessions go to the skill) and a `memhouse` skill with
54
+ the read recipes, under `CODEX_HOME` or `~/.codex`. `plugins remove codex` takes out exactly
55
+ those: the rest of `AGENTS.md` stays byte-identical, and a `skills/memhouse` that memhouse did
56
+ not install is left alone. Opt-in: nothing is written there unless it ran; the first run of a
57
+ new version refreshes a copy it finds installed. The `/mem` skills stay Claude Code's.
58
+ - **An invite's password expires.** `memhouse invite` sets the new member's password to stop
59
+ working after 7 days (`--expires <days>`, 1 to 365), by the server's clock. The invite's
60
+ output, the invite file and `MEMHOUSE-INVITATION.md` say when. `install --env` lifts the
61
+ expiry when it changes the password, and when `--keep-password` keeps it. An expired invite
62
+ is refused at install with "this invite expired at … ask whoever invited you for a new one",
63
+ before anything is contacted or written. Inviting the same name again says the unused
64
+ account must be removed first (`DROP USER <name>`); its tables are kept and reused.
65
+ `invite --print-sql` prints the same expiry, with a placeholder for the date.
66
+
7
67
  ## 0.20.0 — 2026-10-29
8
68
 
9
69
  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']);
@@ -3310,7 +3361,12 @@ async function cmdUpdate() {
3310
3361
  // the member's rooms moved from <name>.* to mem.<name>_*. Fix the env here, before the
3311
3362
  // shipper restarts, or its first pass refuses the old address forever.
3312
3363
  // (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 */ } }
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 */ } }
3314
3370
 
3315
3371
  // Bring the house to the schema the NEW code expects, before any daemon restarts.
3316
3372
  // A release that adds rooms (0.18.3's stat tables) used to land after the restart, so
@@ -3319,7 +3375,7 @@ async function cmdUpdate() {
3319
3375
  // 500'd — all of it self-healing eventually, all of it alarming meanwhile. Two real
3320
3376
  // members hit this within an hour of each other. The new binary owns the new schema, so
3321
3377
  // it runs it here, in the one moment the daemons are already down.
3322
- {
3378
+ if (memberHere) {
3323
3379
  const r = spawnSync(process.execPath, [path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js'), '--ensure-schema'],
3324
3380
  { env: childEnv(resolveConfig()), encoding: 'utf-8', stdio: 'pipe', timeout: 120000 });
3325
3381
  const said = `${r.stdout || ''}${r.stderr || ''}`.trim().split('\n').filter((l) => l.includes('[memhouse]')).pop();
@@ -3406,6 +3462,7 @@ async function cmdUpdate() {
3406
3462
  // non-interactive, no flag name it and print the command; NEVER auto-run — a cron
3407
3463
  // or CI invocation must not start a house-wide copy
3408
3464
  const cfg = resolveConfig();
3465
+ if (!memberHere) return 0;
3409
3466
  if (cfg.url && cfg.user && isV3(cfg)) {
3410
3467
  // Schema 3: the 0.18 migrations are about the 0.18 rooms, which this install neither reads
3411
3468
  // nor writes. What it needs is its five tables, verified — ship.js --ensure-schema does that
@@ -3529,8 +3586,23 @@ async function cmdPlugins() {
3529
3586
  // somewhere else than the directory just inspected.
3530
3587
  const self = `memhouse plugins install claude${flags.target ? ` --target ${flags.target}` : ''}`;
3531
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
+
3532
3603
  if (sub === 'list') {
3533
3604
  console.log(`skills available: ${invocations}`);
3605
+ codexLine();
3534
3606
  if (!targets.length) { console.log(warn('no Claude Code config directory found')); return 0; }
3535
3607
  for (const t of targets) {
3536
3608
  console.log(isPluginInstalled(t.dir)
@@ -3577,7 +3649,33 @@ async function cmdPlugins() {
3577
3649
  return 0;
3578
3650
  }
3579
3651
 
3580
- 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]');
3581
3679
  return 2;
3582
3680
  }
3583
3681
 
@@ -5017,9 +5115,41 @@ async function probePermissive(cfg, db) {
5017
5115
  }
5018
5116
  }
5019
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
+
5020
5148
  /** Who can read this house, what they are scoped to, and any policy left behind. */
5021
5149
  async function shareList(cfg, share) {
5022
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;
5023
5153
  let recorded = [];
5024
5154
  try {
5025
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 });
@@ -5039,9 +5169,10 @@ async function shareList(cfg, share) {
5039
5169
  const covers = isV3(cfg) ? { tables: share.V3_TABLES.map((t) => `${cfg.user}_${t}`) } : { rooms: `${cfg.user}_*` };
5040
5170
 
5041
5171
  if (!recorded.length && !policies.length) {
5042
- if (JSON_OUT) { console.log(JSON.stringify({ database: db, ...covers, grantees: [] }, null, 2)); return 0; }
5172
+ if (JSON_OUT) { console.log(JSON.stringify({ database: db, ...covers, grantees: [], received: receivedJson }, null, 2)); return 0; }
5043
5173
  console.log(ok(`nobody has been granted a read of your rooms in '${db}'`));
5044
5174
  console.log(` Share it with: memhouse share <user> [--only project=<name>]`);
5175
+ printReceived(cfg, received);
5045
5176
  return 0;
5046
5177
  }
5047
5178
  // In a shared house a grantee reads YOUR ROOMS, never the database — every other member
@@ -5060,6 +5191,7 @@ async function shareList(cfg, share) {
5060
5191
  })),
5061
5192
  policies_readable: policiesReadable,
5062
5193
  note: "memhouse's own record, not ClickHouse's grant table — a grant made by hand does not appear here",
5194
+ received: receivedJson,
5063
5195
  }, null, 2));
5064
5196
  return 0;
5065
5197
  }
@@ -5073,6 +5205,7 @@ async function shareList(cfg, share) {
5073
5205
  for (const p of scoped) console.log(` ${String(p.table).padEnd(11)} ${p.select_filter}`);
5074
5206
  }
5075
5207
  console.log(' A grant made by hand does not appear here — this list is what memhouse recorded.');
5208
+ printReceived(cfg, received);
5076
5209
 
5077
5210
  // Policies whose room is gone: dropping a house does NOT drop its policies, and a house
5078
5211
  // later recreated under the same name silently inherits them. Listing them takes a credential
@@ -5237,9 +5370,15 @@ async function offerInvite() {
5237
5370
 
5238
5371
  async function cmdInvite() {
5239
5372
  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; }
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; }
5241
5374
  try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(name, 'member'); }
5242
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
+ }
5243
5382
  const cfg = resolveConfig();
5244
5383
  // The URL must be STATED and must work from the INVITEE's machine. cfg.url falls back
5245
5384
  // to localhost:8123 — dead, or worse, someone else's house, on every other machine.
@@ -5291,6 +5430,9 @@ async function cmdInvite() {
5291
5430
  console.log(memberSql(db, name));
5292
5431
  // Nothing is contacted, so nothing is detected: schema 3 unless stated, as install --print-sql.
5293
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>;`);
5294
5436
  console.log(handoffLines({ url, db, member: name, invite: true }));
5295
5437
  return 0;
5296
5438
  }
@@ -5410,7 +5552,7 @@ async function cmdInvite() {
5410
5552
  // The house decides, as for an install: a new house is schema 3, one on the 0.18 rooms
5411
5553
  // keeps them, and an ambiguous one is refused.
5412
5554
  {
5413
- const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), db, name), name);
5555
+ const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), db, name), name, { user: adminCfg.user, url: adminCfg.url });
5414
5556
  if (pick.refuse) { console.log(bad(pick.refuse)); console.log(' nothing was changed'); return 1; }
5415
5557
  cfg.schema = pick.schema === '3' ? '3' : '';
5416
5558
  }
@@ -5447,6 +5589,19 @@ async function cmdInvite() {
5447
5589
  if (built) built.url = url; // the invitee reaches the house at --url, not the admin's endpoint
5448
5590
  if (!built) return 1;
5449
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
+
5450
5605
  const out = path.resolve(flags.out && flags.out !== true ? String(flags.out) : `invite-${name}.env`);
5451
5606
  const sq = envfile.quoteShell;
5452
5607
  const body = [
@@ -5465,6 +5620,8 @@ async function cmdInvite() {
5465
5620
  '# Issued by an invitation (no admin access here): install changes it so the inviter',
5466
5621
  '# no longer knows it.',
5467
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)}`] : []),
5468
5625
  '',
5469
5626
  ].join('\n');
5470
5627
  // Atomic + private + no symlink follow: write a fresh temp with O_EXCL at 0600, then
@@ -5506,11 +5663,12 @@ async function cmdInvite() {
5506
5663
  const page = require(path.join(REPO_ROOT, 'memhouse', 'invitation')).invitationPage({
5507
5664
  out, name, url, db: built.db, schema: isV3(cfg) ? '3' : '', install,
5508
5665
  version: prerelease ? `${base}-…` : PKG.version,
5509
- inviter: cfg.user || require('os').userInfo().username,
5666
+ inviter: cfg.user || require('os').userInfo().username, expires,
5510
5667
  });
5511
5668
  fs.writeFileSync(page.path, page.text);
5512
5669
  console.log(ok(`guide written: ${page.path}`));
5513
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`));
5514
5672
  console.log(` send ${name} BOTH files over a channel you trust (croc, a password manager — not chat):`);
5515
5673
  console.log(` ${path.basename(out)} their credential — one-time, install deletes it`);
5516
5674
  console.log(` MEMHOUSE-INVITATION.md the steps, nothing secret`);
@@ -5737,8 +5895,14 @@ async function cmdUninstall() {
5737
5895
  // invite, uninstall and friends act on first call with no separate confirm step, so a
5738
5896
  // --help that fell through to the default branch would DO the thing instead of
5739
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.
5740
5899
  if (cmd && (flags.help === true || process.argv.slice(2).some((a) => a === '-h' || a === '--help'))) {
5741
- 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;
5742
5906
  }
5743
5907
  const cfg = resolveConfig();
5744
5908
 
@@ -6027,15 +6191,21 @@ async function cmdUninstall() {
6027
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`));
6028
6192
  }
6029
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; }
6030
6197
  } catch (e) {
6031
6198
  console.log(bad(`could not read the grant table: ${e.message}`));
6032
6199
  console.log(' Pass an admin credential: memhouse members --admin-user <a> (its password from MEMHOUSE_ADMIN_PASSWORD, or --admin-password-file -)');
6033
6200
  process.exitCode = 1; break;
6034
6201
  }
6202
+ const share = require(path.join(REPO_ROOT, 'memhouse', 'share'));
6203
+ const described = (r) => share.describeAccess(r, { policies: rows.policies });
6035
6204
  if (JSON_OUT) {
6036
- console.log(JSON.stringify({ database: db, roles_read: !rolesUnread, members: rows.map((r) => ({
6205
+ console.log(JSON.stringify({ database: db, roles_read: !rolesUnread, policies_read: rows.policies !== null, members: rows.map((r) => ({
6037
6206
  name: r.u, scope: Number(r.db_wide) === 1 ? 'database' : 'rooms',
6038
6207
  rooms: Number(r.db_wide) === 1 ? null : r.list,
6208
+ ...(Number(r.db_wide) === 1 ? {} : described(r)),
6039
6209
  })) }, null, 2));
6040
6210
  break;
6041
6211
  }
@@ -6047,9 +6217,19 @@ async function cmdUninstall() {
6047
6217
  }
6048
6218
  const owners = rows.filter((r) => Number(r.db_wide) === 1);
6049
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.
6050
6222
  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)')}`);
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}`);
6053
6233
  }
6054
6234
  if (owners.length && rows.length > 1) {
6055
6235
  console.log('');
@@ -6506,7 +6686,7 @@ async function cmdUninstall() {
6506
6686
  // grants. The superuser is at hand here, so they are built now, and the member install
6507
6687
  // below finds them ready. Without this, deploy --local on 0.20 stopped at the install.
6508
6688
  const pick = schemaChoice.chooseSchema(flags.schema && flags.schema !== true ? String(flags.schema) : '',
6509
- await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), targetDb, houseUser), houseUser);
6689
+ await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), targetDb, houseUser), houseUser, { user: adminCfg.user, url: adminCfg.url });
6510
6690
  if (pick.refuse) { console.log(bad(pick.refuse)); process.exitCode = 1; break; }
6511
6691
  if (pick.schema === '3') {
6512
6692
  const v3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
@@ -128,3 +128,6 @@ The path proves nothing on its own: Homebrew's prefix is `/usr/local` on Intel a
128
128
  - `memhouse plugins install claude`: the `/mem:*` skills, for Claude Code only. Codex and the
129
129
  other editors' sessions ship all the same; an agent there reads the house with the CLI
130
130
  (`memhouse q`, `memhouse room-sql`). See [Search, resume and skills](search-and-resume.md).
131
+ - `memhouse plugins install codex`: for Codex, a short block in its `AGENTS.md` (the rules) and
132
+ a `memhouse` skill (the read recipes). Nothing is written under the Codex home unless you run
133
+ it. See [Codex](search-and-resume.md#codex).
@@ -27,6 +27,13 @@ memhouse invite alice --url https://house.example.com
27
27
  - **Alice installs with `memhouse install --env invite-alice.env`.** It offers to rotate
28
28
  the password to one only she knows, which works because she is granted `ALTER USER` on
29
29
  herself. Then it deletes the file. Once she rotates, the password you saw stops working.
30
+ - **The invite expires.** The password in the file stops working 7 days after the invite
31
+ (`--expires <days>` sets 1 to 365), counted by the server's clock. The output, the file and
32
+ the invitation page say when. Installing lifts the expiry, whether she changes the password
33
+ or keeps it with `--keep-password`. After the date, `install` refuses the file and tells her
34
+ to ask for a new invite. Her account is still there, so remove it first (`DROP USER alice`, as
35
+ the administrator; `invite` says so when it meets it), then invite her again. Her tables are
36
+ kept and reused.
30
37
 
31
38
  ## Which admin credential
32
39
 
@@ -99,6 +99,36 @@ missing skill never announces itself.
99
99
  For an agent that does not use Claude Code skills, `memhouse prompt` prints a snippet for
100
100
  its system prompt: where memory lives and how to query it.
101
101
 
102
+ ### Codex
103
+
104
+ The `/mem` skills are for Claude Code. For Codex:
105
+
106
+ ```bash
107
+ memhouse plugins install codex # under CODEX_HOME, or ~/.codex
108
+ memhouse plugins list # shows the codex copy and the command it runs
109
+ memhouse plugins remove codex
110
+ ```
111
+
112
+ It writes two things, and only when you run it:
113
+
114
+ - **A block at the top of `AGENTS.md`**, between `<!-- memhouse:begin … -->` and
115
+ `<!-- memhouse:end -->`. Codex puts this file in every prompt, so the block holds only what must
116
+ always hold: never read or print `~/.memhouse/env` or a `MEMHOUSE_*` value; `MEMHOUSE_URL` is the
117
+ house and `MEMHOUSE_PORT` the dashboard; questions about past sessions go to the `memhouse`
118
+ skill. The file is created if it is missing. Installing again replaces the block where it
119
+ stands. Removing takes out the block and the blank line after it, so the rest of the file is
120
+ what it was, byte for byte; a file that held only the block is deleted, or, when `AGENTS.md`
121
+ is a symlink, its target is emptied and the link kept. Leave the markers
122
+ alone: with one of them missing, install and remove refuse and change nothing.
123
+ - **A skill, `skills/memhouse/`**, with the read recipes: `memhouse q`, `--member` for a
124
+ housemate's sessions, `memhouse room-sql`. Codex lists it by name and description and reads
125
+ the rest when it uses it. The directory carries `.memhouse.json`; a `skills/memhouse` without
126
+ it is someone else's, and install refuses to replace it and remove leaves it.
127
+
128
+ For an instance with its own home or binary, both name it: `MEMHOUSE_HOME=<home> <binary> q …`.
129
+ After an upgrade, the first `memhouse` command refreshes both, as it does the `/mem` copies; a
130
+ block you took out by hand stays out.
131
+
102
132
  ## Writing your own queries
103
133
 
104
134
  On schema 3 (0.20), the tables are `mem.<you>_session_parts` (one row per part of a message:
@@ -106,6 +106,10 @@ included. A grant someone made by hand in SQL does not appear there. A member ca
106
106
  `system.grants` or `system.row_policies`; an administrator can, with `SHOW GRANTS FOR <user>`
107
107
  and the queries in [Operate](operate.md#who-is-here).
108
108
 
109
+ It also lists the other direction: each member whose sessions your credential can read, with
110
+ the command that reads them. A scoped share shows only the rows its owner chose, and its filter
111
+ is the owner's to see.
112
+
109
113
  ## Reading what was shared with you
110
114
 
111
115
  Shared tables sit in the same database as yours, under the other member's name, for
@@ -5,6 +5,7 @@ do, how to upgrade, what is deprecated and when it goes, and what was deferred.
5
5
 
6
6
  | version | date | headline |
7
7
  |---|---|---|
8
+ | [0.20.1](v0.20.1.md) | 2026-11-02 | invites expire; `memhouse plugins install codex`; per-command help and clearer CLI answers |
8
9
  | [0.20.0](v0.20.0.md) | 2026-10-29 | schema 3: a session is its store, nothing cut or lost, forks billed once, ten editors through normalisers; Apache-2.0 |
9
10
  | [0.18.11](v0.18.11.md) | 2026-10-10 | sessions with subagents stop re-shipping; security: no stored credential sent to `invite --url` |
10
11
  | [0.18.10](v0.18.10.md) | 2026-10-08 | security: the admin password never on argv; no member password printed; a scoped share reaches only its scope |