memhouse 0.16.0 → 0.17.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/README.md CHANGED
@@ -99,7 +99,7 @@ an agent — so an agent can install its own memory unattended.
99
99
  config directory on the machine** — `~/.claude`, whatever `CLAUDE_CONFIG_DIR` points
100
100
  at, and each Kommander-style playbook under `~/.claude-playbooks/`. All are selected by
101
101
  default; answer with numbers to narrow it, or pass `--target DIR` for exactly one.
102
- Installing into just the default config is how `/mem:search` ends up missing from
102
+ Installing into just the default config is how `/mem:recall` ends up missing from
103
103
  the instance you actually work in, silently, since a missing skill never announces
104
104
  itself.
105
105
 
package/bin/memhouse.js CHANGED
@@ -399,6 +399,10 @@ Data ship one incremental pass (--full | --loop [sec])
399
399
  resume <session-id> print the command that reopens that session in its editor
400
400
  sessions-query print the session rollup SQL for this credential
401
401
  start | stop | shipper loop + dashboard as background daemons
402
+ share <user> let someone read this house (--only project=… |
403
+ session=… | host=… | source=… | folder=… | since=… |
404
+ until=… scopes it with row policies; --revoke
405
+ withdraws and drops them; --list shows who)
402
406
  whoami which credential is in play, and what it may do
403
407
  (--json; --admin reads MEMHOUSE_ADMIN_*)
404
408
  status daemons, connection, counts, freshness (--json)
@@ -716,7 +720,7 @@ ${rooms.trim()}
716
720
  -- The whole database, WITH GRANT OPTION. ALL on your own house reaches nothing outside it
717
721
  -- — the database is the boundary — and it lets the shipper create and evolve its own
718
722
  -- tables. The grant option makes the member the real owner: they can hand on any of their
719
- -- own data (\`/mem:share\` still opens only a read-only SELECT window, but the owner is not
723
+ -- own data (\`/mem:access\` still opens only a read-only SELECT window, but the owner is not
720
724
  -- boxed into read-only sharing of their own house). Scoped to this db, and no CREATE USER
721
725
  -- rides with it — a member still cannot mint accounts or reach another house.
722
726
  GRANT ALL ON ${db}.* TO ${member} WITH GRANT OPTION;
@@ -731,9 +735,9 @@ GRANT ALL ON ${db}.* TO ${member} WITH GRANT OPTION;
731
735
  -- trust model, flagged by \`memhouse doctor\`, and re-asserted on every \`ship --ensure-schema\`.
732
736
  GRANT ALTER USER ON ${member} TO ${member};
733
737
 
734
- -- See the OTHER members, read-only. SHOW USERS lets \`/mem:users\` list who is on this
738
+ -- See the OTHER members, read-only. SHOW USERS lets \`/mem:house\` list who is on this
735
739
  -- ClickHouse (names only — no passwords, no data) so a member can find who to share with.
736
- -- It grants no read of anyone's rows; reading a housemate's memory still needs /mem:share.
740
+ -- It grants no read of anyone's rows; reading a housemate's memory still needs /mem:access.
737
741
  GRANT SHOW USERS ON *.* TO ${member};
738
742
 
739
743
  -- \`memhouse relocate\` runs FROM the destination, pulling the source over remoteSecure() —
@@ -808,7 +812,7 @@ async function adminBootstrap(cfg, admin) {
808
812
  password = generatePassword();
809
813
  // For a local install the password must be SHOWN — it is the user's only copy. For
810
814
  // an invite it must NOT: the caller writes it to the credential file, and printing
811
- // it here would land it in the terminal and, via /mem:invite, in a transcript
815
+ // it here would land it in the terminal and, via /mem:access, in a transcript
812
816
  // memhouse itself ships. admin.quiet is the invite path.
813
817
  if (!admin.quiet) {
814
818
  console.log('');
@@ -839,7 +843,7 @@ async function adminBootstrap(cfg, admin) {
839
843
  // create and evolve the rooms (--ensure-schema below).
840
844
  try {
841
845
  // ALL, WITH GRANT OPTION: the database is theirs, so they may do anything with their
842
- // own data AND hand any of it on. /mem:share still opens only a read-only window (it
846
+ // own data AND hand any of it on. /mem:access still opens only a read-only window (it
843
847
  // grants SELECT), but the owner is not boxed into read-only sharing of their own house.
844
848
  // Scoped to their db: the grant option reaches nothing outside it, and no CREATE USER
845
849
  // comes with it, so a member still cannot mint accounts or touch another house.
@@ -851,12 +855,12 @@ async function adminBootstrap(cfg, admin) {
851
855
  // self-rotation.
852
856
  try { await q(`GRANT ALTER USER ON ${admin.member} TO ${admin.member}`, { database: '' }); }
853
857
  catch { /* admin without access-management: passwd stays admin-assisted for this member */ }
854
- // See the OTHER members, read-only: SHOW USERS lets `/mem:users` list who is on this
858
+ // See the OTHER members, read-only: SHOW USERS lets `/mem:house` list who is on this
855
859
  // ClickHouse (names only — no passwords, no data) so a member can find who to share
856
- // with. It grants no read of anyone's rows; that still needs an explicit /mem:share.
860
+ // with. It grants no read of anyone's rows; that still needs an explicit /mem:access.
857
861
  // Best-effort like ALTER USER above — an admin without access-management just skips it.
858
862
  try { await q(`GRANT SHOW USERS ON *.* TO ${admin.member}`, { database: '' }); }
859
- catch { /* no access-management: /mem:users section 4 stays admin-only for this member */ }
863
+ catch { /* no access-management: /mem:house section 4 stays admin-only for this member */ }
860
864
  // REMOTE: `memhouse relocate` runs FROM the destination, pulling the source over
861
865
  // remoteSecure() — a table function ClickHouse gates behind its own access type,
862
866
  // separate from any GRANT ALL on a database. Without it every relocate a member runs
@@ -1143,14 +1147,29 @@ async function cmdInstall({ interactive }) {
1143
1147
  // shipper or an installed service is still pointed at the old house, and rewriting the
1144
1148
  // file orphans it with no error anywhere.
1145
1149
  const prior = readEnvFile();
1146
- if (prior.MEMHOUSE_URL && flags.force !== true
1147
- && (!sameEndpoint(prior.MEMHOUSE_URL, cfg.url) || (prior.MEMHOUSE_DB && prior.MEMHOUSE_DB !== cfg.db))) {
1150
+ const displaces = Boolean(prior.MEMHOUSE_URL)
1151
+ && (!sameEndpoint(prior.MEMHOUSE_URL, cfg.url) || (prior.MEMHOUSE_DB && prior.MEMHOUSE_DB !== cfg.db));
1152
+ if (displaces && flags.force !== true) {
1148
1153
  console.log(bad(`${ENV_FILE} already points at ${prior.MEMHOUSE_URL} / ${prior.MEMHOUSE_DB || '?'}`));
1149
1154
  console.log(` installing over it would leave any running shipper or service on the old house.`);
1150
1155
  console.log(' memhouse setup --url … --db … (move deliberately)');
1151
1156
  console.log(' memhouse install --force … (overwrite anyway)');
1152
1157
  return 1;
1153
1158
  }
1159
+ if (displaces) {
1160
+ // --force used to overwrite the file and say nothing. The password inside is often
1161
+ // the ONLY copy — an invited member cannot mint another — so replacing it silently
1162
+ // orphans a house that still exists and still costs disk. A drill found this by
1163
+ // taking the hatch the refusal above offers: the arriver read "already exists",
1164
+ // reached for --force, and destroyed a working credential without being told.
1165
+ // `relocate` already keeps the old file for the same operation; so does this now.
1166
+ console.log(warn(`replacing the credential for ${prior.MEMHOUSE_USER || '?'}@${prior.MEMHOUSE_URL} (house '${prior.MEMHOUSE_DB || '?'}')`));
1167
+ try {
1168
+ fs.copyFileSync(ENV_FILE, `${ENV_FILE}.pre-install`);
1169
+ console.log(` previous config kept at ${`${ENV_FILE}.pre-install`.replace(os.homedir(), '~')} — it holds that password`);
1170
+ } catch { console.log(warn(' could not keep a copy of the previous config')); }
1171
+ console.log(' that house still exists; nothing was deleted from the server.');
1172
+ }
1154
1173
  // Preflight WITHOUT selecting the house — on a fresh standalone ClickHouse the
1155
1174
  // database doesn't exist yet, and selecting it would fail before we can create it.
1156
1175
  try { await ch(cfg, 'SELECT 1', { database: '' }); }
@@ -1224,6 +1243,28 @@ async function cmdInstall({ interactive }) {
1224
1243
  // config file behind every failed attempt, and the next command reads it as truth.
1225
1244
  writeEnvFile(cfg);
1226
1245
  console.log(ok(`config written: ${ENV_FILE}`));
1246
+ // A running shipper holds the OLD house until it restarts. It does not fail — it keeps
1247
+ // shipping, to the house that is no longer configured, while `status` shows a green
1248
+ // "shipper: running" beside a green "house: empty" and the new house stays at zero.
1249
+ // Measured in a drill: 145 sessions "skipped" into a house holding no rows, because the
1250
+ // skip predicate was still being evaluated against the old one. `relocate` restarts
1251
+ // deliberately for exactly this reason; install used to do nothing at all.
1252
+ if (displaces && shipperHealth().running) {
1253
+ const st = (() => { try { return require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { return null; } })();
1254
+ const viaService = String(shipperHealth().via || '').startsWith('service');
1255
+ if (viaService && st) {
1256
+ const cmd = st.kind === 'systemd'
1257
+ ? ['systemctl', ['--user', 'restart', 'memhouse-shipper']]
1258
+ : ['launchctl', ['kickstart', '-k', `gui/${process.getuid()}/com.memhouse.shipper`]];
1259
+ const r = spawnSync(cmd[0], cmd[1], { stdio: 'pipe', encoding: 'utf-8' });
1260
+ console.log(r.status === 0 ? ok('shipper restarted against the new house')
1261
+ : warn('restart the shipper yourself, or it keeps writing to the old house: memhouse service restart'));
1262
+ } else {
1263
+ const pid = pidOf('shipper');
1264
+ if (pid) { try { process.kill(pid, 'SIGTERM'); console.log(ok(`stopped the shipper (pid ${pid}) — it was pointed at the old house`)); } catch { /* raced */ } }
1265
+ console.log(' start it against the new one: memhouse start');
1266
+ }
1267
+ }
1227
1268
  // Mint this machine's identity here rather than leaving it to whatever runs first.
1228
1269
  // Installing is the moment a machine joins the member's rooms, and the id is what every
1229
1270
  // later `WHERE host = …` depends on — so it is worth naming once, out loud, at the point
@@ -1286,9 +1327,9 @@ function printGettingStarted(cfg) {
1286
1327
  console.log(' memhouse start dashboard + shipper loop (background daemons)');
1287
1328
  console.log(` -> http://localhost:${cfg.port || 4640} browse, search, and analyze every session`);
1288
1329
  console.log(' memhouse service install or: ship at login, no terminal needed');
1289
- console.log(' memhouse plugins install claude give your agents /mem:hello, /mem:ask, /mem:search,');
1290
- console.log(' /mem:sessions, /mem:share, /mem:invite, /mem:sql,');
1291
- console.log(' /mem:status, /mem:users');
1330
+ console.log(' memhouse plugins install claude give your agents /mem:house, /mem:recall,');
1331
+ console.log(' /mem:recall, /mem:access, /mem:sql,');
1332
+ console.log(' /mem:house');
1292
1333
  console.log(' memhouse search <terms> find a past conversation right now');
1293
1334
  console.log(' memhouse doctor every line a check mark = healthy');
1294
1335
  console.log(' The house keeps shipping as you work; nothing else to do.');
@@ -1348,7 +1389,7 @@ async function cmdOnboard() {
1348
1389
  const targets = claudeTargets();
1349
1390
  if (targets.length) {
1350
1391
  console.log('');
1351
- console.log(`Claude Code skills: ${fs.readdirSync(path.join(DELIVERY, 'plugin', 'skills')).map((n) => `/mem:${n}`).join(', ')}`);
1392
+ console.log(`Claude Code skills: ${skillNames().map((n) => `/mem:${n}`).join(', ')}`);
1352
1393
  const chosen = await chooseTargets(targets, 'Install into');
1353
1394
  for (const t of chosen) console.log(ok(`installed skills into ${short(installPluginInto(t.dir))}`));
1354
1395
  if (chosen.length) console.log(' they load next time that Claude Code starts');
@@ -2245,7 +2286,7 @@ async function cmdUpdate() {
2245
2286
  // A pilot rarely has one. `~/.claude` is the stock install, `CLAUDE_CONFIG_DIR` points at
2246
2287
  // whichever they are running right now, and Kommander-style playbooks live under
2247
2288
  // `~/.claude-playbooks/<name>[/playbook]`, each a complete config directory with its own
2248
- // skills/. Installing into one and calling it done leaves /mem:search missing from
2289
+ // skills/. Installing into one and calling it done leaves /mem:recall missing from
2249
2290
  // every other instance the pilot uses — silently, because a missing skill does not announce
2250
2291
  // itself, it just never appears.
2251
2292
  //
@@ -2281,7 +2322,7 @@ const isPluginInstalled = (dir) => fs.existsSync(path.join(dir, PLUGIN_MARK));
2281
2322
 
2282
2323
  function installPluginInto(dir) {
2283
2324
  // The plugin was named `memhouse` until 0.10.0. A leftover copy under the old name
2284
- // would load BESIDE the new one — /memhouse:search and /mem:search both resolving, one
2325
+ // would load BESIDE the new one — /memhouse:search and /mem:recall both resolving, one
2285
2326
  // of them stale forever. Remove it only when it is provably OURS (it carries our
2286
2327
  // plugin.json); a directory someone else named `memhouse` is not ours to delete.
2287
2328
  const legacy = path.join(dir, 'skills', 'memhouse');
@@ -2297,7 +2338,7 @@ function installPluginInto(dir) {
2297
2338
  const dst = path.join(dir, 'skills', 'mem');
2298
2339
  // REPLACE, not overlay. cpSync over an existing install refreshes the skills and
2299
2340
  // leaves anything else standing — a machine that once had a build with extra skills
2300
- // kept offering /mem:replay and /mem:status forever, stale, beside the real ones.
2341
+ // kept offering /mem:replay and /mem:house forever, stale, beside the real ones.
2301
2342
  // Ownership means OUR MANIFEST, checked by name — "any plugin.json" would have deleted
2302
2343
  // an unrelated plugin that happened to pick the same directory name.
2303
2344
  const dstManifest = path.join(dst, '.claude-plugin', 'plugin.json');
@@ -2351,15 +2392,27 @@ async function chooseTargets(targets, verb) {
2351
2392
  //
2352
2393
  // Claude Code gives a skill a namespace only when it arrives inside a plugin: a directory
2353
2394
  // under <config>/skills/ containing .claude-plugin/plugin.json loads as
2354
- // `mem@skills-dir` and its skills become /mem:search, /mem:sessions,
2395
+ // `mem@skills-dir` and its skills become /mem:recall,
2355
2396
  // /mem:sql. Copied in flat, the same three files register as unrelated top-level
2356
2397
  // skills named after their folders — which is what this used to do, while plugin.json sat
2357
2398
  // unread one directory away claiming the colon form. Driving a real Claude Code is what
2358
- // caught it: `/mem:search` answered `Unknown command. Did you mean /memhouse-search?`
2399
+ // caught it: `/mem:recall` answered `Unknown command. Did you mean /memhouse-search?`
2400
+ /**
2401
+ * The plugin's skills, by name. A directory is a skill only when it carries a SKILL.md —
2402
+ * `plugin/` also holds `reference/`, shared prose the skills point at, and listing that
2403
+ * as `/mem:reference` would advertise a command nobody can run.
2404
+ */
2405
+ function skillNames() {
2406
+ const dir = path.join(DELIVERY, 'plugin', 'skills');
2407
+ try {
2408
+ return fs.readdirSync(dir).filter((n) => fs.existsSync(path.join(dir, n, 'SKILL.md'))).sort();
2409
+ } catch { return []; }
2410
+ }
2411
+
2359
2412
  async function cmdPlugins() {
2360
2413
  const sub = positional[0] || 'list';
2361
2414
  const pluginSrc = path.join(DELIVERY, 'plugin');
2362
- const names = fs.readdirSync(path.join(pluginSrc, 'skills'));
2415
+ const names = skillNames(); // SKILL.md-bearing dirs only — `reference/` is not a skill
2363
2416
  const invocations = names.map((n) => `/mem:${n}`).join(', ');
2364
2417
  // --target overrides the discovery rather than joining it: given one, that is the only
2365
2418
  // directory touched.
@@ -2949,7 +3002,7 @@ async function cmdNightly() {
2949
3002
  * machine's config is never touched and nothing local ships. The server half is exactly
2950
3003
  * adminBootstrap (create user if absent, create house, GRANT ALL + SELECT WITH GRANT
2951
3004
  * OPTION, async pin, then verify AS THE MEMBER), so an invited member is
2952
- * indistinguishable from one minted by a local admin install — /mem:share works for
3005
+ * indistinguishable from one minted by a local admin install — /mem:access works for
2953
3006
  * them on day one.
2954
3007
  *
2955
3008
  * The output file IS a credential. The header says so, the handoff advice names safe
@@ -2968,6 +3021,200 @@ async function cmdNightly() {
2968
3021
  * can ask "is there an admin credential in this environment?" without inventing a place
2969
3022
  * to store one. Nothing here prints or persists a password.
2970
3023
  */
3024
+ /**
3025
+ * `memhouse share <user> [--only <scope>] [--revoke]`, `memhouse share --list`
3026
+ *
3027
+ * Full share is a GRANT and nothing else. Partial share adds one ROW POLICY per room, so
3028
+ * the grantee reads only what the scope allows. See memhouse/share.js for why there is
3029
+ * deliberately no permissive catch-all policy, and what was measured to rule it out.
3030
+ */
3031
+ async function cmdShare() {
3032
+ const share = require(path.join(REPO_ROOT, 'memhouse', 'share'));
3033
+ const cfg = requireConfig(resolveConfig(), 'share');
3034
+ if (!cfg.user) return 1;
3035
+ const db = cfg.db;
3036
+
3037
+ // Statements, not reads: this is the one command in the read family that writes, so it
3038
+ // does NOT go through the readonly connection the skills use.
3039
+ const run = (sql) => ch(cfg, sql, { database: db });
3040
+ const rows = (sql) => chRows(cfg, sql, { database: db });
3041
+
3042
+ // List only when asked for it, or when given nothing at all. `--only` or `--revoke`
3043
+ // without a user is a mistake worth naming, not a reason to print the list.
3044
+ const bare = !positional[0] && flags.only === undefined && flags.revoke === undefined;
3045
+ if (flags.list === true || bare) return shareList(cfg, share);
3046
+
3047
+ const user = positional[0];
3048
+ if (!user) { console.log(bad('usage: memhouse share <user> [--only <scope>] [--revoke] | memhouse share --list')); return 2; }
3049
+ // The name is spliced into GRANT/REVOKE/CREATE POLICY unquoted, so it must be a name
3050
+ // ClickHouse accepts bare. Refuse rather than quote: a handle you cannot type plainly
3051
+ // is one that will be gotten wrong somewhere else.
3052
+ try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(user, 'user'); }
3053
+ catch (e) { console.log(bad(e.message)); return 1; }
3054
+ if (user === cfg.user) { console.log(bad(`'${user}' is you — you already read your own house`)); return 1; }
3055
+
3056
+ // ── revoke ────────────────────────────────────────────────────────────────────
3057
+ // Dropping the grant alone leaves the policies behind, and they are NOT inert: a later
3058
+ // re-share silently inherits the old scope. Measured — a re-granted user saw 2,121 rows
3059
+ // instead of the whole house, with nothing on any surface explaining the filter.
3060
+ if (flags.revoke === true) {
3061
+ let dropped = 0;
3062
+ for (const room of ROOM_TYPES) {
3063
+ try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${room}`); dropped++; }
3064
+ catch (e) { console.log(warn(`could not drop the policy on ${room}: ${e.message.split('\n')[0]}`)); }
3065
+ }
3066
+ try { await run(`REVOKE SELECT ON ${db}.* FROM ${user}`); }
3067
+ catch (e) { console.log(bad(`could not revoke: ${netReason(e)}`)); return 1; }
3068
+ await houseMeta(cfg, `share:${user}`, `revoked ${new Date().toISOString().slice(0, 10)}`);
3069
+ console.log(ok(`revoked '${user}' — SELECT withdrawn and ${dropped} row polic${dropped === 1 ? 'y' : 'ies'} dropped`));
3070
+ console.log(' Dropping the grant alone would have left the policies, and a later re-share');
3071
+ console.log(' would have quietly reinherited this scope.');
3072
+ return 0;
3073
+ }
3074
+
3075
+ // ── grant ─────────────────────────────────────────────────────────────────────
3076
+ const scopeRaw = flags.only !== undefined && flags.only !== true ? String(flags.only) : null;
3077
+ let scope = null;
3078
+ if (flags.only === true) { console.log(bad('--only needs a scope, e.g. --only project=memhouse or --only session=<id>')); return 1; }
3079
+ if (scopeRaw) {
3080
+ try { scope = share.parseScope(scopeRaw); } catch (e) { console.log(bad(e.message)); return 1; }
3081
+ }
3082
+
3083
+ // A partial share only behaves if readers WITHOUT a policy still see every row. That is
3084
+ // `users_without_row_policies_can_read_rows`, which is server config rather than a query
3085
+ // setting and whose default has moved between versions — so measure it, do not assume.
3086
+ if (scope) {
3087
+ const verdict = await probePermissive(cfg, db);
3088
+ if (verdict === false) {
3089
+ console.log(bad('this ClickHouse hides every row from readers that have no row policy.'));
3090
+ console.log(' Scoping one person would blindfold everyone else you have already shared with,');
3091
+ console.log(' and yourself. Nothing was changed. Ask whoever runs the server to set:');
3092
+ console.log(' <access_control_improvements>');
3093
+ console.log(' <users_without_row_policies_can_read_rows>true</users_without_row_policies_can_read_rows>');
3094
+ console.log(' </access_control_improvements>');
3095
+ console.log(' A full share (no --only) is unaffected and works today.');
3096
+ return 1;
3097
+ }
3098
+ if (verdict === null) console.log(warn('could not verify how this server treats readers without a policy — continuing'));
3099
+ }
3100
+
3101
+ try { await run(`GRANT SELECT ON ${db}.* TO ${user}`); }
3102
+ catch (e) { console.log(bad(`could not grant: ${netReason(e)}`)); return 1; }
3103
+
3104
+ if (!scope) {
3105
+ // A previous partial share leaves policies that would still be filtering. Widening to
3106
+ // a full share has to clear them or "full" is a lie.
3107
+ let cleared = 0;
3108
+ for (const room of ROOM_TYPES) {
3109
+ try {
3110
+ const had = await rows(`SELECT count() AS n FROM system.row_policies WHERE database = ${sqlStr(db)} AND short_name = ${sqlStr(share.policyName(user, room))}`);
3111
+ if (Number(had[0] && had[0].n) > 0) { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${room}`); cleared++; }
3112
+ } catch { /* nothing to clear */ }
3113
+ }
3114
+ await houseMeta(cfg, `share:${user}`, `granted ${new Date().toISOString().slice(0, 10)}`);
3115
+ console.log(ok(`'${user}' can now read every session in '${db}'`));
3116
+ if (cleared) console.log(warn(`cleared ${cleared} row polic${cleared === 1 ? 'y' : 'ies'} from an earlier scoped share — this is now a FULL share`));
3117
+ console.log(` Everything: every project, machine and editor, including anything ever pasted`);
3118
+ console.log(` into a session. Narrow it with: memhouse share ${user} --only project=<name>`);
3119
+ console.log(` Withdraw with: memhouse share ${user} --revoke`);
3120
+ return 0;
3121
+ }
3122
+
3123
+ for (const room of ROOM_TYPES) {
3124
+ const pred = share.scopePredicate(scope, room, sqlStr);
3125
+ try { await run(`CREATE ROW POLICY OR REPLACE ${share.policyName(user, room)} ON ${db}.${room} USING ${pred} TO ${user}`); }
3126
+ catch (e) {
3127
+ console.log(bad(`could not scope ${room}: ${e.message.split('\n')[0]}`));
3128
+ console.log(' PARTIALLY APPLIED — a share filtered on some rooms and not others leaks.');
3129
+ console.log(` Put it right with: memhouse share ${user} --revoke`);
3130
+ return 1;
3131
+ }
3132
+ }
3133
+ await houseMeta(cfg, `share:${user}`, `granted ${new Date().toISOString().slice(0, 10)} scope=${scopeRaw}`);
3134
+
3135
+ // Say what they can actually reach, counted through their own filter.
3136
+ console.log(ok(`'${user}' can read '${db}' where ${scopeRaw}`));
3137
+ for (const room of ROOM_TYPES) {
3138
+ try {
3139
+ const r = await rows(`SELECT count() AS n FROM ${room} WHERE ${share.scopePredicate(scope, room, sqlStr)}`);
3140
+ const all = await rows(`SELECT count() AS n FROM ${room}`);
3141
+ console.log(` ${room.padEnd(11)} ${r[0].n} of ${all[0].n} rows`);
3142
+ } catch { /* counting is a courtesy */ }
3143
+ }
3144
+ console.log(` Widen to everything: memhouse share ${user}`);
3145
+ console.log(` Withdraw: memhouse share ${user} --revoke`);
3146
+ return 0;
3147
+ }
3148
+
3149
+ /**
3150
+ * Does a reader with NO row policy still see rows once a policy exists on the table?
3151
+ *
3152
+ * Server config decides this and SQL cannot read it, so measure: put a deny-everything
3153
+ * policy on a scratch table aimed at everyone EXCEPT us, then read it. Rows back means
3154
+ * unpolicied readers are unaffected. Needs no second account and no admin.
3155
+ *
3156
+ * true = permissive, false = restrictive, null = could not tell.
3157
+ */
3158
+ async function probePermissive(cfg, db) {
3159
+ const t = `_mh_probe_${process.pid}`;
3160
+ const run = (sql) => ch(cfg, sql, { database: db });
3161
+ try {
3162
+ await run(`CREATE TABLE IF NOT EXISTS ${t} (x UInt8) ENGINE = MergeTree ORDER BY x`);
3163
+ await run(`INSERT INTO ${t} VALUES (1)`, { settings: { async_insert: 0 } });
3164
+ await run(`CREATE ROW POLICY OR REPLACE ${t}_p ON ${db}.${t} USING 0 TO ALL EXCEPT ${cfg.user}`);
3165
+ const r = await chRows(cfg, `SELECT count() AS n FROM ${t}`, { database: db });
3166
+ return Number(r[0] && r[0].n) === 1;
3167
+ } catch { return null; } finally {
3168
+ try { await run(`DROP ROW POLICY IF EXISTS ${t}_p ON ${db}.${t}`); } catch { /* nothing staged */ }
3169
+ try { await run(`DROP TABLE IF EXISTS ${t} SYNC`); } catch { /* nothing staged */ }
3170
+ }
3171
+ }
3172
+
3173
+ /** Who can read this house, what they are scoped to, and any policy left behind. */
3174
+ async function shareList(cfg, share) {
3175
+ const db = cfg.db;
3176
+ let recorded = [];
3177
+ try {
3178
+ recorded = await chRows(cfg, "SELECT substring(key, 7) AS user, value AS state FROM house_meta FINAL WHERE key LIKE 'share:%' ORDER BY key", { database: db });
3179
+ } catch { /* unreadable record */ }
3180
+ let policies = [];
3181
+ try {
3182
+ policies = await chRows(cfg, `SELECT short_name, table, select_filter FROM system.row_policies WHERE database = ${sqlStr(db)} ORDER BY short_name`, { database: db });
3183
+ } catch { /* members may not read system.row_policies on every server */ }
3184
+
3185
+ if (!recorded.length && !policies.length) {
3186
+ console.log(ok(`nobody has been granted a read of '${db}'`));
3187
+ console.log(` Share it with: memhouse share <user> [--only project=<name>]`);
3188
+ return 0;
3189
+ }
3190
+ console.log(` who can read '${db}' — memhouse's own record, not ClickHouse's grant table:`);
3191
+ for (const r of recorded) {
3192
+ const scoped = policies.filter((p) => String(p.short_name).startsWith(`mh_share_${r.user}_`));
3193
+ console.log(` ${String(r.user).padEnd(16)} ${r.state}${scoped.length ? '' : (String(r.state).startsWith('granted') ? ' (full house)' : '')}`);
3194
+ for (const p of scoped) console.log(` ${String(p.table).padEnd(11)} ${p.select_filter}`);
3195
+ }
3196
+ console.log(' A grant made by hand does not appear here — this list is what memhouse recorded.');
3197
+
3198
+ // Policies whose room is gone: dropping a house does NOT drop its policies, and a house
3199
+ // later recreated under the same name silently inherits them.
3200
+ const orphans = [];
3201
+ for (const p of policies) {
3202
+ try {
3203
+ const e = await chRows(cfg, `SELECT count() AS n FROM system.tables WHERE database = ${sqlStr(db)} AND name = ${sqlStr(p.table)}`, { database: db });
3204
+ if (Number(e[0] && e[0].n) === 0) orphans.push(p);
3205
+ } catch { /* cannot tell */ }
3206
+ }
3207
+ if (orphans.length) {
3208
+ console.log('');
3209
+ console.log(warn(`${orphans.length} row polic${orphans.length === 1 ? 'y points' : 'ies point'} at a table that no longer exists:`));
3210
+ for (const p of orphans) console.log(` ${p.short_name} ON ${db}.${p.table}`);
3211
+ console.log(' Dropping a table or a house leaves its policies behind, and recreating one under');
3212
+ console.log(' the same name silently reinherits them. Drop them:');
3213
+ for (const p of orphans) console.log(` DROP ROW POLICY ${p.short_name} ON ${db}.${p.table}`);
3214
+ }
3215
+ return 0;
3216
+ }
3217
+
2971
3218
  async function cmdWhoami() {
2972
3219
  const cfg = resolveConfig();
2973
3220
  const wantAdmin = flags.admin === true;
@@ -3238,7 +3485,7 @@ async function cmdInvite() {
3238
3485
  console.log(` hand it to ${name} over a channel you trust (croc, a password manager — not chat).`);
3239
3486
  console.log(` they run: memhouse install --env ${path.basename(out)}`);
3240
3487
  console.log(` then they should rotate the password you now both know: memhouse passwd`);
3241
- console.log(` once installed, they are a member — sharing works both ways: /mem:share ${name}`);
3488
+ console.log(` once installed, they are a member — sharing works both ways: /mem:access ${name}`);
3242
3489
  return 0;
3243
3490
  }
3244
3491
 
@@ -3550,6 +3797,7 @@ async function cmdUninstall() {
3550
3797
  }
3551
3798
  break;
3552
3799
  case 'reset': process.exitCode = await cmdReset(); break;
3800
+ case 'share': process.exitCode = await cmdShare(); break;
3553
3801
  case 'whoami': process.exitCode = await cmdWhoami(); break;
3554
3802
  case 'invite': process.exitCode = await cmdInvite(); break;
3555
3803
  case 'passwd': process.exitCode = await cmdPasswd(); break;
@@ -58,8 +58,8 @@ mark. Then tell them:
58
58
 
59
59
  - where the dashboard is (`http://localhost:{{PORT}}`) and that `memhouse start` runs it
60
60
  - that `memhouse search <terms>` searches every past session from every editor
61
- - that `memhouse plugins install claude` gives their agents /mem:ask, /mem:search,
62
- /mem:sessions, /mem:share, /mem:sql, /mem:status, /mem:users — offer to run it
61
+ - that `memhouse plugins install claude` gives their agents /mem:recall, /mem:sql,
62
+ /mem:house, /mem:access and /mem:admin — offer to run it
63
63
  - that you can now search their history yourself, and offer to try one query
64
64
 
65
65
  If `doctor` reports a failure, say which line failed and what memhouse suggested. Do not
@@ -115,8 +115,8 @@ memhouse plugins install claude
115
115
  ```
116
116
 
117
117
  Installs the memhouse plugin into `<claude-config>/skills/mem`, which loads next
118
- session as `mem@skills-dir` and exposes `/mem:ask`, `/mem:search`, `/mem:sessions`,
119
- `/mem:share`, `/mem:sql`, `/mem:status` and `/mem:users`. They read the connection from
118
+ session as `mem@skills-dir` and exposes `/mem:recall`, `/mem:sql`, `/mem:house`,
119
+ `/mem:access` and `/mem:admin`. They read the connection from
120
120
  `$MEMHOUSE_HOME/env` (default
121
121
  `~/.memhouse/env`), with exported `MEMHOUSE_*` vars taking precedence, and refuse to run
122
122
  rather than guessing a URL when neither is set. `memhouse plugins list` shows what is
@@ -23,12 +23,18 @@ work that is not in your current context ("that session where…", "how did I so
23
23
  X before", "did we ever…", "what was I working on last week"), search memhouse
24
24
  FIRST. Only say you don't know after a search comes back empty.
25
25
 
26
- **How to query.** Prefer the installed skills when present: `/mem:ask` (answer a
27
- question from memory, with citations), `/mem:search` (full-text over messages),
28
- `/mem:sessions` (list/filter sessions), `/mem:sql` (free-form read-only SQL),
29
- `/mem:status` (what the system holds and its health), `/mem:share` and `/mem:users`
30
- (read-only sharing between users), `/mem:invite` (mint a new member — inviting comes
31
- before sharing), `/mem:hello` (introduce the system). Without skills, query directly.
26
+ **How to query.** Prefer the installed skills when present:
27
+
28
+ - `/mem:recall` — find past sessions and answer from them, with citations. This is the
29
+ one you want for almost every "have I seen this before" question.
30
+ - `/mem:sql` — read-only SQL when the answer is a number: spend, model mix, tool
31
+ rankings, activity over time.
32
+ - `/mem:house` — what the system is, whether it is shipping, what it holds, and who can
33
+ read it.
34
+ - `/mem:access` — bring someone onto the server, or grant a housemate read-only access.
35
+ - `/mem:admin` — server-wide administration; needs an administrator credential.
36
+
37
+ Without skills, query directly.
32
38
 
33
39
  **Table names.** The house's rooms are three plain, shared tables — `messages`,
34
40
  `sessions`, `tool_calls` — in the connection's database. Everyone in the house writes
@@ -66,12 +72,12 @@ the retained epochs are the only place that content still exists.
66
72
  **Rules.**
67
73
  - Memory is READ-ONLY for you: never INSERT/ALTER/DROP the ROOMS (sessions, messages,
68
74
  tool_calls) — ingestion belongs to the memhouse shipper alone. The one exception is
69
- membership administration through the `memhouse` CLI: `/mem:invite` mints a user and
70
- `/mem:share` grants read access, both via the tool, not by you writing to the rooms.
75
+ membership administration through the `memhouse` CLI: `/mem:access` mints a user and
76
+ `/mem:access` grants read access, both via the tool, not by you writing to the rooms.
71
77
  - Quote retrieved content as *the user's past sessions*, and cite the session_id
72
78
  when the user may want to dig deeper.
73
79
  - Other users' HOUSES (other databases on the same server) are readable only when
74
- shared with this credential (`/mem:share` on their side). Query a shared house by
80
+ shared with this credential (`/mem:access` on their side). Query a shared house by
75
81
  qualified names — `polat.messages` — with the same CUR filter. An empty result
76
82
  means "nothing visible", not "nothing ever happened".
77
83
  - Do not paste credentials from `{{ENV_FILE}}` into responses, commits, or logs.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mem",
3
- "description": "Conversation memory for AI agents over the memhouse typed ClickHouse store: ask, search, browse, analyze, check, share, and invite across all 17 supported editors and machines, plus administration of the ClickHouse behind it. Skills install as /mem:hello, /mem:ask, /mem:search, /mem:sessions, /mem:share, /mem:invite, /mem:sql, /mem:status, /mem:users, and /mem:admin.",
4
- "version": "0.16.0",
3
+ "description": "Conversation memory for AI agents over the memhouse typed ClickHouse store: recall past sessions and answer from them, run analytics, check the house, bring people in, and administer the server. Skills install as /mem:recall, /mem:sql, /mem:house, /mem:access, and /mem:admin.",
4
+ "version": "0.17.1",
5
5
  "author": {
6
6
  "name": "Ramazan Polat"
7
7
  },