memhouse 0.12.7 → 0.14.0

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
@@ -76,12 +76,22 @@ memhouse update [--check] upgrade, restart daemons, check the s
76
76
  memhouse start | stop dashboard + shipper as daemons
77
77
  memhouse service install | uninstall survive a reboot
78
78
  memhouse deploy --local | --down stand up (or remove) a local house
79
- memhouse plugins install claude /mem:search, :sessions, :sql —
80
- into every Claude Code config dir found
79
+ memhouse invite <name> mint a member + house, hand them one env file
80
+ memhouse passwd rotate this member's password
81
+ memhouse migrate | migrate-rooms [--dry-run] [--yes]
82
+ run whatever this house still needs
83
+ memhouse relocate --to <url> copy this house to a new ClickHouse, then repoint
84
+ memhouse nightly [--out DIR] build an installable tarball from this checkout
85
+ memhouse plugins install claude 9 skills — ask, hello, invite, search,
86
+ sessions, share, sql, status, users — into
87
+ every Claude Code config dir found
81
88
  memhouse prompt memory snippet for an agent's system prompt
82
89
  memhouse prompt --install an install prompt, rendered for this machine
83
90
  ```
84
91
 
92
+ Any command's own usage: `memhouse <command> --help` (or `-h`) — always prints usage,
93
+ never runs the command.
94
+
85
95
  Every command works both ways: interactive for you, `--yes` / flags / `--json` for
86
96
  an agent — so an agent can install its own memory unattended.
87
97
 
package/bin/memhouse.js CHANGED
@@ -28,6 +28,7 @@ const {
28
28
  SCHEMA_VERSION, MIN_WRITER_SCHEMA, MIGRATIONS, META_TYPES, createStatement,
29
29
  } = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
30
30
  const envfile = require(path.join(REPO_ROOT, 'memhouse', 'envfile'));
31
+ const { consumeSecretChunk } = require(path.join(REPO_ROOT, 'memhouse', 'secret-input'));
31
32
 
32
33
  const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
33
34
  const ENV_FILE = path.join(HOME_DIR, 'env');
@@ -270,16 +271,20 @@ function askSecret(label, dflt = '') {
270
271
  const wasRaw = stdin.isRaw;
271
272
  stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf-8');
272
273
  let buf = '';
273
- const onData = (ch) => {
274
- if (ch === '\r' || ch === '\n' || ch === '\u0004') {
275
- stdin.removeListener('data', onData);
276
- stdin.setRawMode(!!wasRaw); stdin.pause();
277
- process.stdout.write('\n');
278
- return resolve(buf || dflt);
279
- }
280
- if (ch === '\u0003') { process.stdout.write('\n'); process.exit(130); }
281
- if (ch === '\u007f' || ch === '\b') { buf = buf.slice(0, -1); return; }
282
- buf += ch;
274
+ // One 'data' event carries a CHUNK, not a keystroke — see memhouse/secret-input.js
275
+ // for why that distinction is load-bearing and what it broke.
276
+ let done = false;
277
+ const onData = (chunk) => {
278
+ if (done) return;
279
+ const r = consumeSecretChunk(buf, chunk);
280
+ buf = r.buf;
281
+ if (r.interrupted) { process.stdout.write('\n'); process.exit(130); }
282
+ if (!r.done) return;
283
+ done = true;
284
+ stdin.removeListener('data', onData);
285
+ stdin.setRawMode(!!wasRaw); stdin.pause();
286
+ process.stdout.write('\n');
287
+ resolve(buf || dflt);
283
288
  };
284
289
  stdin.on('data', onData);
285
290
  });
@@ -325,8 +330,11 @@ Setup onboard interactive wizard: discover → configure →
325
330
  --env FILE installs from an invite file (see: invite)
326
331
  invite <name> mint a member + house on the server and write the env
327
332
  file their install needs (--url --admin-user
328
- --admin-password [--db NAME] [--out FILE]); local
329
- machine untouched
333
+ [--admin-password, else prompted] [--db NAME]
334
+ [--out FILE]); local machine untouched. Refuses a house
335
+ that already holds someone's messages — --adopt if
336
+ sharing it is intended. Not an admin? --print-sql gives
337
+ the statements to hand to whoever is
330
338
  passwd rotate this member's password + rewrite the env file
331
339
  (admin-assisted: --admin-user --admin-password)
332
340
  --print-sql print the SQL, run it yourself
@@ -690,6 +698,12 @@ GRANT ALTER USER ON ${member} TO ${member};
690
698
  -- It grants no read of anyone's rows; reading a housemate's memory still needs /mem:share.
691
699
  GRANT SHOW USERS ON *.* TO ${member};
692
700
 
701
+ -- \`memhouse relocate\` runs FROM the destination, pulling the source over remoteSecure() —
702
+ -- a table function ClickHouse gates behind its own access type, separate from GRANT ALL on
703
+ -- a database. Without this, relocate fails at the native-reachability probe with
704
+ -- ACCESS_DENIED (source untouched, nothing lost — just unusable until granted).
705
+ GRANT REMOTE ON *.* TO ${member};
706
+
693
707
  -- Pin async_insert on the user (ADD SETTING merges; a bare SETTINGS clause would replace
694
708
  -- the user's whole list). The pin keeps the user_id stamp honest: a MATERIALIZED
695
709
  -- currentUser() is computed during the INSERT, and an async flush stores it as the empty
@@ -805,8 +819,16 @@ async function adminBootstrap(cfg, admin) {
805
819
  // Best-effort like ALTER USER above — an admin without access-management just skips it.
806
820
  try { await q(`GRANT SHOW USERS ON *.* TO ${admin.member}`, { database: '' }); }
807
821
  catch { /* no access-management: /mem:users section 4 stays admin-only for this member */ }
822
+ // REMOTE: `memhouse relocate` runs FROM the destination, pulling the source over
823
+ // remoteSecure() — a table function ClickHouse gates behind its own access type,
824
+ // separate from any GRANT ALL on a database. Without it every relocate a member runs
825
+ // fails at the native-reachability probe with ACCESS_DENIED, source untouched, no
826
+ // data lost — just relocate unusable until an admin grants this by hand. Best-effort
827
+ // like the two grants above.
828
+ try { await q(`GRANT REMOTE ON *.* TO ${admin.member}`, { database: '' }); }
829
+ catch { /* no access-management: relocate stays admin-assisted for this member */ }
808
830
  await q(`ALTER USER ${admin.member} ADD SETTING ${MEMBER_PIN}`, { database: '' });
809
- console.log(ok(`granted the house: ALL ON ${cfg.db}.* to '${admin.member}', SHOW USERS, async_insert pinned`));
831
+ console.log(ok(`granted the house: ALL ON ${cfg.db}.* to '${admin.member}', SHOW USERS, REMOTE, async_insert pinned`));
810
832
  } catch (e) {
811
833
  console.log(bad(`could not grant the house to '${admin.member}': ${e.message}`));
812
834
  if (createdUser) {
@@ -2898,7 +2920,7 @@ async function cmdNightly() {
2898
2920
  */
2899
2921
  async function cmdInvite() {
2900
2922
  const name = positional[0];
2901
- if (!name) { console.log('usage: memhouse invite <name> --url <house-url> --admin-user … --admin-password … [--db <house>] [--out <file>]'); return 2; }
2923
+ if (!name) { console.log('usage: memhouse invite <name> --url <house-url> [--admin-user … [--admin-password …]] [--db <house>] [--out <file>] [--adopt] [--print-sql]'); return 2; }
2902
2924
  try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(name, 'member'); }
2903
2925
  catch (e) { console.log(bad(e.message)); return 1; }
2904
2926
  const cfg = resolveConfig();
@@ -2921,27 +2943,106 @@ async function cmdInvite() {
2921
2943
  try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(db, 'house'); }
2922
2944
  catch (e) { console.log(bad(e.message)); return 1; }
2923
2945
 
2946
+ // The way out for someone who is NOT the administrator. Being unable to invite is not a
2947
+ // bug to be worked around — a member holds no CREATE USER by design — but "ask your
2948
+ // admin" is only useful if you can hand them something exact. This prints the same
2949
+ // statements adminBootstrap would run, so the person with the credential runs four
2950
+ // lines instead of installing anything. Nothing is contacted and nothing is written.
2951
+ if (flags['print-sql'] === true) {
2952
+ const password = flags['member-password'] && flags['member-password'] !== true
2953
+ ? String(flags['member-password']) : generatePassword();
2954
+ console.log(`-- memhouse: give '${name}' an account and their own house on this ClickHouse.`);
2955
+ console.log(`-- Run as a user with ACCESS MANAGEMENT (a stock 'default' will do).`);
2956
+ console.log(memberSql(db, name, password));
2957
+ console.log('-- Then send them these four lines — they are a credential, so use a channel');
2958
+ console.log('-- you trust (croc, a password manager), not chat:');
2959
+ console.log(`-- MEMHOUSE_URL='${url}'`);
2960
+ console.log(`-- MEMHOUSE_USER='${name}'`);
2961
+ console.log(`-- MEMHOUSE_PASSWORD='${password}'`);
2962
+ console.log(`-- MEMHOUSE_DB='${db}'`);
2963
+ console.log(`-- They install with: memhouse install --url ${url} --user ${name} --password '…' --db ${db}`);
2964
+ return 0;
2965
+ }
2966
+
2924
2967
  // Whose credential mints the member. Explicit --admin-* wins; otherwise TRY THE
2925
- // CONFIGURED ONE — an install made as an admin-capable ClickHouse user (access
2926
- // management) can invite with no extra flags, which is the common case for the person
2927
- // who set the house up. Fall back to asking only when the stored credential cannot
2928
- // read system.users (the proxy for "can it provision").
2968
+ // CONFIGURED ONE — a `deploy --local` house makes its member the superuser
2969
+ // (CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1), so the person who stood the house up
2970
+ // invites with no extra flags.
2929
2971
  let adminUser = flags['admin-user'];
2930
2972
  let adminPass = flags['admin-password'];
2931
2973
  if (!adminUser) {
2932
2974
  const c = requireConfig(cfg, 'invite');
2933
2975
  if (!c.user) return 1;
2976
+ // Ask what this credential may actually DO, not whether it can look at a table.
2977
+ // The old probe was `SELECT 1 FROM system.users` — but since 0.12.6 every member is
2978
+ // granted SHOW USERS, so every member passed it, was told "it can manage users on
2979
+ // this house", and then died at CREATE DATABASE with a raw ACCESS_DENIED. A probe
2980
+ // that says yes to everyone is worse than no probe: it turns "you are not an admin"
2981
+ // into a confusing failure three steps later.
2982
+ //
2983
+ // chRows appends FORMAT itself, so DO NOT pass one — a statement carrying FORMAT
2984
+ // twice is a syntax error, which the catch below turned into "no grants" and refused
2985
+ // EVERY credential, a real superuser included. Only a clean machine could show that:
2986
+ // this machine's credential is a member either way, so the bug was invisible here.
2987
+ // Reachability FIRST, and reported as itself. Folding it into the grants read makes an
2988
+ // unreachable host — a typo, a loopback-bound house, a tunnel that is down — come back
2989
+ // as "you are only a member", which sends the reader after a privilege they already
2990
+ // have. Caught on the testbed, where a wrong --url produced exactly that.
2934
2991
  try {
2935
- await ch({ ...c, url }, 'SELECT 1 FROM system.users LIMIT 1', { database: '' });
2992
+ await ch({ ...c, url }, 'SELECT 1', { database: '' });
2993
+ } catch (e) {
2994
+ console.log(bad(`cannot reach ${url} as '${c.user}': ${netReason(e)}`));
2995
+ console.log(' --url is the address the INVITEE will use, and invite checks it from here first.');
2996
+ console.log(' A `deploy --local` house is bound to loopback on purpose, so no LAN address');
2997
+ console.log(' reaches it and no invitee could either — publish it (tunnel, reverse proxy)');
2998
+ console.log(' before inviting, or use --print-sql and let the invitee be told the address.');
2999
+ return 1;
3000
+ }
3001
+ let grants = '';
3002
+ try {
3003
+ const g = await chRows({ ...c, url }, `SHOW GRANTS FOR ${c.user}`, { database: '' });
3004
+ grants = g.map((r) => Object.values(r).join(' ')).join('\n');
3005
+ } catch { /* reachable but grants unreadable — treated as "not an admin" below */ }
3006
+ // Two capabilities, and a superuser states them in more than one shape: CREATE USER
3007
+ // may arrive as itself or under ACCESS MANAGEMENT, and the database half may be an
3008
+ // explicit CREATE DATABASE or the umbrella CREATE on *.* — which is what
3009
+ // `deploy --local` grants. Match the meaning, not one spelling.
3010
+ const canMintUsers = /\bACCESS MANAGEMENT\b|\bCREATE USER\b/.test(grants);
3011
+ const canMintHouses = /\bCREATE DATABASE\b/.test(grants)
3012
+ || grants.split('\n').some((l) => /\bCREATE\b/.test(l) && /\bON \*\.\*/.test(l));
3013
+ const canProvision = canMintUsers && canMintHouses;
3014
+ if (canProvision) {
2936
3015
  adminUser = c.user; adminPass = c.password;
2937
- console.log(ok(`inviting as your own credential '${c.user}' (it can manage users on this house)`));
2938
- } catch {
2939
- console.log(bad('your configured credential cannot create users on this house.'));
2940
- console.log(' Pass an admin that can: --admin-user <a> --admin-password <p>');
3016
+ console.log(ok(`inviting as your own credential '${c.user}' (it can create users and houses here)`));
3017
+ } else {
3018
+ console.log(bad(`'${c.user}' is a MEMBER of this ClickHouse, not an administrator — it cannot create accounts.`));
3019
+ console.log(' Inviting mints a ClickHouse user and a database, which needs CREATE USER and');
3020
+ console.log(' CREATE DATABASE. A member deliberately holds neither: that is the boundary that');
3021
+ console.log(' keeps one housemate out of another house.');
3022
+ console.log('');
3023
+ console.log(' If you RUN this ClickHouse — use the admin credential you created it with');
3024
+ console.log(' (for a stock server that is `default`); memhouse never stores it, so pass it');
3025
+ console.log(' per invite and it is used for one connection and discarded:');
3026
+ console.log(` memhouse invite ${name} --url ${url} --admin-user default`);
3027
+ console.log(' (leave --admin-password off and it is prompted for, so it stays out of');
3028
+ console.log(' your shell history and the process list)');
3029
+ console.log('');
3030
+ console.log(' If SOMEONE ELSE runs it — you cannot invite, and no flag changes that.');
3031
+ console.log(' Print the statements and send them to whoever administers the server:');
3032
+ console.log(` memhouse invite ${name} --url ${url} --print-sql`);
2941
3033
  return 1;
2942
3034
  }
2943
3035
  } else if (adminPass === undefined) {
2944
- console.log(bad('--admin-user given without --admin-password')); return 1;
3036
+ // Prompt rather than refuse. A password given as --admin-password lands in the
3037
+ // process list for the life of the request and in shell history unless the caller
3038
+ // remembered a leading space; asking for it keeps it in this process only.
3039
+ // Non-TTY (an agent, CI) still gets the old refusal — there is nobody to ask.
3040
+ if (!process.stdin.isTTY) {
3041
+ console.log(bad('--admin-user given without --admin-password (no TTY to prompt on)'));
3042
+ return 1;
3043
+ }
3044
+ adminPass = await askSecret(` password for '${adminUser}'`);
3045
+ if (!adminPass) { console.log(bad('no password given')); return 1; }
2945
3046
  }
2946
3047
 
2947
3048
  // WHERE to connect to provision. --url is the address the INVITEE will use — it may be
@@ -2951,6 +3052,42 @@ async function cmdInvite() {
2951
3052
  // EXPLICIT --admin-* (where the admin typed the target themselves) provisions at --url.
2952
3053
  // Either way the invite FILE carries --url, the invitee's path.
2953
3054
  const provisionUrl = flags['admin-user'] ? url : cfg.url;
3055
+
3056
+ // Is there already a house here, with somebody's memory in it? The house is created
3057
+ // with CREATE DATABASE IF NOT EXISTS, so inviting a name whose database already exists
3058
+ // ADOPTS it — same output as a fresh one, and the invitee lands on top of rows that are
3059
+ // not theirs. (The user half is safe: adminBootstrap refuses an existing ClickHouse
3060
+ // user, so no sitting member's password is ever rotated out from under them.)
3061
+ //
3062
+ // Found the hard way: an invite meant for one person was sent to another, who shipped
3063
+ // 3,733 messages under it. Re-inviting the intended person reported success and handed
3064
+ // them the first person's memory, with nothing on any surface saying so.
3065
+ //
3066
+ // Read-only, best-effort: an admin that cannot count rows should not lose the ability
3067
+ // to invite, and a house that does not exist yet is the ordinary case.
3068
+ const adminCfg = { ...cfg, url: provisionUrl, user: adminUser, password: adminPass || '', db, stated: true };
3069
+ let occupied = null;
3070
+ try {
3071
+ const exists = await chRows(adminCfg, `SELECT count() AS n FROM system.tables WHERE database = '${db.replace(/'/g, "\\'")}' AND name = 'messages'`, { database: '' });
3072
+ if (Number(exists[0] && exists[0].n) > 0) {
3073
+ const r = await chRows(adminCfg, 'SELECT count() AS msgs, uniqExact(user_id) AS writers FROM messages', { database: db });
3074
+ const msgs = Number(r[0] && r[0].msgs) || 0;
3075
+ if (msgs > 0) occupied = { msgs, writers: Number(r[0].writers) || 0 };
3076
+ }
3077
+ } catch { /* cannot tell — provisioning below will surface any real access problem */ }
3078
+ if (occupied && flags.adopt !== true) {
3079
+ console.log(bad(`house '${db}' already exists and holds ${occupied.msgs} messages from ${occupied.writers} writer(s) — NOT inviting.`));
3080
+ console.log(` Inviting '${name}' here would hand them somebody else's memory, and their`);
3081
+ console.log(' first ship would land on top of it. Nothing has been changed.');
3082
+ console.log(' Pick a different handle: memhouse invite <other-name> --url …');
3083
+ console.log(` Or a different house: memhouse invite ${name} --url … --db <house>`);
3084
+ console.log(` If sharing this house IS the intent, say so: --adopt`);
3085
+ return 1;
3086
+ }
3087
+ if (occupied) {
3088
+ console.log(warn(`adopting existing house '${db}' — ${occupied.msgs} messages already here (--adopt)`));
3089
+ }
3090
+
2954
3091
  const built = await adminBootstrap({ ...cfg, url: provisionUrl, db, stated: true }, {
2955
3092
  user: adminUser, password: adminPass || '', member: name, quiet: true,
2956
3093
  });
@@ -3202,6 +3339,13 @@ async function cmdUninstall() {
3202
3339
  if (flags.version === true || process.argv.slice(2).some((a) => a === '-v' || a === '-V')) {
3203
3340
  console.log(PKG.version); return;
3204
3341
  }
3342
+ // `memhouse <cmd> --help` (or `-h`) must never reach a command's own logic — passwd,
3343
+ // invite, uninstall and friends act on first call with no separate confirm step, so a
3344
+ // --help that fell through to the default branch would DO the thing instead of
3345
+ // describing it. One check ahead of the switch, for every command, is the whole fix.
3346
+ if (cmd && (flags.help === true || process.argv.slice(2).some((a) => a === '-h' || a === '--help'))) {
3347
+ console.log(HELP); return;
3348
+ }
3205
3349
  const cfg = resolveConfig();
3206
3350
 
3207
3351
  switch (cmd) {
@@ -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. Skills install as /mem:hello, /mem:ask, /mem:search, /mem:sessions, /mem:share, /mem:invite, /mem:sql, /mem:status, and /mem:users.",
4
- "version": "0.12.7",
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.14.0",
5
5
  "author": {
6
6
  "name": "Ramazan Polat"
7
7
  },
@@ -0,0 +1,128 @@
1
+ ---
2
+ name: admin
3
+ description: Operate the memhouse ClickHouse as its administrator — list and inspect every user and house, provision or remove members, grant and revoke access, and read server-wide health (disk, parts, mutations, running queries). Use when the user asks to "do X as admin", "manage users", "drop a house", "see disk usage", "who is on this server", or when a member-scoped skill refused for want of privileges. Requires an admin credential; says so plainly when there is not one.
4
+ user-invocable: true
5
+ argument-hint: "<what you want done>"
6
+ allowed-tools: Bash
7
+ ---
8
+
9
+ # /mem:admin — operate the house as its administrator
10
+
11
+ A member owns one database. An **administrator** owns the ClickHouse: every house, every
12
+ account, every grant. This skill is the second one, and it is the only skill allowed to
13
+ act outside the caller's own house.
14
+
15
+ Everything here runs through the same rule as `/mem:invite`: **an admin password must
16
+ never enter this conversation.** memhouse ships this transcript into the house you are
17
+ administering, so a password pasted in chat is a password published to the archive.
18
+ Resolve it from the environment or a file, never from a prompt, and never echo it.
19
+
20
+ ## 1. Find an admin credential — never ask for one in chat
21
+
22
+ Try these in order and stop at the first that works. **Never print any of these files or
23
+ the variables in them** — no `cat`, no `env | grep`, no `set -x`.
24
+
25
+ ```bash
26
+ MH_HOME="${MEMHOUSE_HOME:-$HOME/.memhouse}"
27
+ # a) an explicit admin credential in the environment
28
+ ADM_U=${MEMHOUSE_ADMIN_USER-}; ADM_P=${MEMHOUSE_ADMIN_PASSWORD-}
29
+ # b) an admin env file kept beside the member config
30
+ if [ -z "$ADM_U" ] && [ -f "$MH_HOME/admin.env" ]; then
31
+ set -a; . "$MH_HOME/admin.env"; set +a
32
+ ADM_U=${MEMHOUSE_ADMIN_USER:-${MEMHOUSE_USER-}}; ADM_P=${MEMHOUSE_ADMIN_PASSWORD:-${MEMHOUSE_PASSWORD-}}
33
+ fi
34
+ # c) the ordinary member credential — on a house the pilot stood up themselves
35
+ # (memhouse deploy --local) the member IS the superuser
36
+ set -a; [ -f "$MH_HOME/env" ] && . "$MH_HOME/env"; set +a
37
+ : "${ADM_U:=${MEMHOUSE_USER-}}"; : "${ADM_P:=${MEMHOUSE_PASSWORD-}}"
38
+ ```
39
+
40
+ Then **prove** it is actually an admin rather than assuming — a member can often read
41
+ `system.users` while holding nothing else:
42
+
43
+ ```bash
44
+ curl -sS --fail-with-body --user "$ADM_U:$ADM_P" \
45
+ --data-binary "SHOW GRANTS FOR $ADM_U FORMAT TSV" "$MEMHOUSE_URL/"
46
+ ```
47
+
48
+ Admin means the grants carry `ACCESS MANAGEMENT`, or `CREATE USER`/`CREATE DATABASE` at
49
+ `*.*` — not merely `SHOW USERS`, which every member has. If none of a/b/c qualifies,
50
+ **stop and say so**:
51
+
52
+ > This needs administrator access to the ClickHouse at `<url>`, and the credential
53
+ > configured here is the member account `<user>` — it owns its own house and nothing
54
+ > else. To use `/mem:admin`, put an admin credential in `~/.memhouse/admin.env`
55
+ > (`MEMHOUSE_ADMIN_USER=` / `MEMHOUSE_ADMIN_PASSWORD=`, mode 600) or export
56
+ > `MEMHOUSE_ADMIN_USER` / `MEMHOUSE_ADMIN_PASSWORD`, then ask again. Whoever operates
57
+ > the server has it; on a house from `memhouse deploy --local` it is the account already
58
+ > in `~/.memhouse/env`.
59
+
60
+ Do not offer to carry on with reduced powers, do not suggest the user paste a password
61
+ into the chat, and do not improvise around the refusal. Say which of a/b/c you tried.
62
+
63
+ ## 2. Run the request
64
+
65
+ Use the resolved credential for every query. `--fail-with-body` so ClickHouse's own error
66
+ is visible; add `&readonly=1` on anything that only reads.
67
+
68
+ ```bash
69
+ adm() { curl -sS --fail-with-body --user "$ADM_U:$ADM_P" --data-binary "$1" "$MEMHOUSE_URL/${2-}"; }
70
+ adm "SELECT … FORMAT PrettyCompact" "?readonly=1"
71
+ ```
72
+
73
+ Reads worth knowing — reach for these before inventing SQL:
74
+
75
+ | Question | Query |
76
+ |---|---|
77
+ | who exists | `SELECT name FROM system.users ORDER BY name` |
78
+ | what can someone do | `SHOW GRANTS FOR <user>` |
79
+ | every house and its size | `SELECT database, formatReadableSize(sum(bytes_on_disk)) AS disk, sum(rows) AS rows FROM system.parts WHERE active GROUP BY database ORDER BY sum(bytes_on_disk) DESC` |
80
+ | a house's rooms | `SELECT table, formatReadableSize(sum(bytes_on_disk)) AS disk, sum(rows) AS rows FROM system.parts WHERE active AND database = '<db>' GROUP BY table` |
81
+ | who writes where | `SELECT user_id, host, count() FROM <db>.messages GROUP BY user_id, host` |
82
+ | running queries | `SELECT query_id, user, elapsed, formatReadableSize(memory_usage) AS mem, substring(query,1,120) AS q FROM system.processes` |
83
+ | unfinished mutations | `SELECT database, table, mutation_id, command, parts_to_do, latest_fail_reason FROM system.mutations WHERE NOT is_done` |
84
+ | merge backlog | `SELECT database, table, elapsed, progress FROM system.merges` |
85
+
86
+ Writes follow the shapes memhouse itself uses, so an admin-made member is identical to an
87
+ invited one — a member gets `GRANT ALL ON <db>.* … WITH GRANT OPTION`, plus `SHOW USERS`,
88
+ self-scoped `ALTER USER`, `REMOTE`, and the async-insert pin. **Prefer the CLI over raw
89
+ SQL when one exists**: `memhouse invite <name> --url …` provisions a member correctly
90
+ (and refuses a house that already holds someone's messages); this skill is for the things
91
+ it does not cover.
92
+
93
+ ## 3. Destructive work — look first, then confirm
94
+
95
+ `DROP DATABASE`, `DROP USER`, `REVOKE`, `ALTER … DELETE`, `TRUNCATE`, `KILL QUERY` and
96
+ password rotation all change something a person depends on. There are no backups and no
97
+ undo: a dropped house is gone, and the transcripts behind it may have aged out of the
98
+ editors that produced them.
99
+
100
+ Before any of them:
101
+
102
+ 1. **Look at the target and report what is actually there** — row counts per room, who
103
+ wrote them, the date span, which machines. A house you expected to be empty may not
104
+ be.
105
+ 2. **Name the collateral.** Dropping a user stops that account's shipper on every machine
106
+ it runs on. Revoking a grant closes a share the other person may be mid-query on.
107
+ Rotating a password breaks every machine shipping under it.
108
+ 3. **Get an explicit yes for that specific object**, quoting the counts you just read.
109
+ A general "yes do admin things" is not consent to drop a populated house.
110
+ 4. **Verify afterwards** and say what is now true.
111
+
112
+ If the user has already told you exactly what to destroy and why, and the counts match
113
+ their description, proceed — do not re-litigate a decision they have made. Report the
114
+ outcome plainly, including the row counts destroyed.
115
+
116
+ ## Never
117
+
118
+ Never accept an admin password typed into the conversation, and never print one — not in
119
+ a command, not in an echo, not in an error. If a command needs it, expand a shell
120
+ variable (`--admin-password "$ADM_P"`), so the value never appears in the transcript.
121
+
122
+ Never widen a member's grants beyond what `memhouse invite` issues without the user
123
+ asking for that specific privilege and being told what it allows. Never grant
124
+ `ACCESS MANAGEMENT`, `CREATE USER`, or anything at `*.*` casually — those make a second
125
+ administrator.
126
+
127
+ Never touch a house that is not the subject of the request. Never run a destructive
128
+ statement whose predicate you have not first run as a `SELECT`.
@@ -30,9 +30,10 @@ substituted into a command — that burns the credential into the transcript on
30
30
  Only ask about a **shared house** if the user brings one up; default is the invitee's
31
31
  own house (omit --db; never pass `--db default` for "the default house").
32
32
 
33
- 3. TRY IT BARE FIRST — run this via the Bash tool. `memhouse invite` uses the caller's
34
- OWN configured credential when it can manage users, which is the common case for
35
- whoever set the house up, so no admin is needed:
33
+ 3. TRY IT BARE FIRST — run this via the Bash tool, from the directory the file should
34
+ land in (step 4). `memhouse invite` uses the caller's OWN configured credential when
35
+ it can manage users, which is the common case for whoever set the house up, so no
36
+ admin is needed:
36
37
 
37
38
  ```
38
39
  memhouse invite <name> --url <url> [--db <shared-house>]
@@ -41,25 +42,89 @@ substituted into a command — that burns the credential into the transcript on
41
42
  If it prints `invite written: …`, go to step 4 — you are done, and no admin secret
42
43
  ever touched this conversation.
43
44
 
44
- ONLY if it fails with "your configured credential cannot create users" does
45
- provisioning need a real admin. Do NOT take the admin password in chat — memhouse
46
- archives this transcript. Hand the user the line to run THEMSELVES (a leading space
47
- keeps it out of shell history):
45
+ Otherwise read WHICH refusal came back; they need different answers.
48
46
 
49
- ```
50
- memhouse invite <name> --url <url> [--db <shared-house>] --admin-user <admin> --admin-password 'PASTE_IT_HERE'
51
- ```
47
+ **"cannot reach `<url>` …"** — nothing to do with privileges. The address is wrong,
48
+ or the house is bound to loopback (`memhouse deploy --local` binds to 127.0.0.1 on
49
+ purpose, so no LAN address reaches it and no invitee could either). Ask the user for
50
+ the address the invitee will actually use; do not retry with a guess.
51
+
52
+ **"`<user>` is a MEMBER of this ClickHouse, not an administrator"** — inviting needs
53
+ CREATE USER and CREATE DATABASE, which a member deliberately does not hold. Two cases,
54
+ and you must ask which one applies rather than assuming:
55
+
56
+ - **They run the ClickHouse.** They have the admin credential; memhouse never stores
57
+ it. Do NOT take that password in chat — memhouse archives this transcript. Hand them
58
+ the line to run THEMSELVES, and leave `--admin-password` OFF so it is prompted for,
59
+ keeping it out of both the process list and shell history:
60
+
61
+ ```
62
+ memhouse invite <name> --url <url> [--db <shared-house>] --admin-user <admin>
63
+ ```
64
+
65
+ **You cannot run that line yourself.** The prompt needs a TTY and your Bash tool has
66
+ none, so it refuses with "no TTY to prompt on" — and the fix is NOT to add
67
+ `--admin-password`, which would put the credential in this transcript. The user runs
68
+ it in their own terminal; you wait for them to say it is done.
69
+
70
+ - **Somebody else runs it.** They cannot invite, and no flag changes that. Print the
71
+ statements for whoever administers the server — this contacts nothing and needs no
72
+ credential:
73
+
74
+ ```
75
+ memhouse invite <name> --url <url> [--db <shared-house>] --print-sql
76
+ ```
52
77
 
53
- 4. When it succeeds it prints `invite written: invite-<name>.env`. Relay to the user:
54
- - hand `invite-<name>.env` to the invitee over a trusted channel (croc, a password
55
- manager — not chat); the file IS a password
56
- - the invitee runs `memhouse install --env invite-<name>.env` — it OFFERS to change
57
- the password to one only they know (they were granted rotation on their own account),
58
- then deletes the file; so the password you set stops working once they install
59
- - once installed, sharing works: `/mem:share <name>` on either side
78
+ The output ends with the four `MEMHOUSE_*` lines the invitee needs. Those lines are
79
+ a credential: tell the user to send them over a trusted channel, and **never print
80
+ them into the conversation yourself**.
81
+
82
+ **If it refuses with "house `<db>` already exists and holds N messages", STOP.** That
83
+ house is somebody's memory, and inviting into it would hand the invitee someone else's
84
+ sessions. Do not pass `--adopt` on your own initiative — report the row count and the
85
+ writer count to the user and let them choose: a different handle, a different `--db`,
86
+ or `--adopt` if sharing that house is genuinely what they want.
87
+
88
+ 4. **Put the file where the user can attach it.** The CLI writes `invite-<name>.env`
89
+ into the CURRENT directory, and the user has to send it by hand — so run the command
90
+ from their downloads directory (on macOS and most Linux desktops `~/Downloads`), or
91
+ move it there afterwards. Never leave it in a git repo: it is a password, and
92
+ `invite-*.env` is only gitignored in this repo, not in whatever checkout the user
93
+ happened to be standing in.
94
+
95
+ Confirm it is there and still mode `600`, and say the full path. **Never print the
96
+ file's contents** — the password inside would land in a transcript memhouse ships.
97
+
98
+ 5. **Say whether the house was fresh.** Run
99
+ `memhouse stats` or query `SELECT count() FROM <db>.messages` and tell the user
100
+ plainly: a brand-new house reads 0. This is a second line of defence behind step 3's
101
+ refusal, and it is how the user learns the invitee is starting clean.
102
+
103
+ 6. **Write the message the user will send.** They are about to hand over a file plus an
104
+ explanation, so compose it for them rather than making them write it. Put it in a
105
+ fenced block they can copy whole, and cover:
106
+ - what memhouse is, in a sentence or two — the invitee may never have heard of it
107
+ - that the attached `invite-<name>.env` IS a password: don't forward it, don't paste
108
+ it into chat
109
+ - `npm install -g memhouse` (Node 24+, no flags, nothing compiles), then
110
+ `memhouse install --env invite-<name>.env`
111
+ - that install OFFERS to change the password to one only they know, then deletes the
112
+ file — so they should say yes, because the inviter knows the current one
113
+ - `memhouse onboard` to start shipping, `memhouse status` to check it worked
114
+ - that their house is theirs alone; the inviter cannot read it unless they run
115
+ `/mem:share <inviter>`
116
+
117
+ If the invitee is REPLACING an existing setup (they were shipping under another
118
+ credential), add `memhouse ship --full` after the install — a plain incremental pass
119
+ skips sessions the new house has no record of, so their history would not follow them.
120
+
121
+ Then: once installed, sharing works both ways — `/mem:share <name>`.
60
122
 
61
123
  ## Never
62
124
 
63
125
  Never create a ClickHouse user by running raw SQL with an admin password you were given
64
126
  in chat. Never invent an admin credential. Never suggest granting beyond what `memhouse
65
127
  invite` does. If the binary is absent, stop and say so — do not improvise the SQL.
128
+
129
+ Never print the invite file's contents, and never pass `--adopt` unless the user asked
130
+ for it after being told what is already in that house.
@@ -0,0 +1,39 @@
1
+ // Consuming a hidden-input chunk, as pure logic so it can be tested without a terminal.
2
+ //
3
+ // The subtlety this exists for: a raw-mode stdin 'data' event carries a CHUNK, not a
4
+ // keystroke. While someone types, each chunk happens to be one character, so a handler
5
+ // that compares the whole chunk against '\r' looks correct for as long as anyone tests it
6
+ // by hand. The moment a password is PASTED — which is how a credential out of a password
7
+ // manager actually arrives — the chunk is 'hunter2\r', equal to no terminator, and the
8
+ // prompt hangs forever with the secret sitting in the buffer. Anything piping input hits
9
+ // the same wall.
10
+ //
11
+ // So: walk the chunk. Callers own the terminal; this owns the decision.
12
+
13
+ const ENTER = ['\r', '\n', '\u0004']; // CR, LF, EOT
14
+ const INTERRUPT = '\u0003'; // ctrl-C
15
+ const ERASE = ['\u007f', '\b']; // DEL, BS
16
+
17
+ /**
18
+ * Fold one chunk into the accumulated buffer.
19
+ *
20
+ * @param {string} buf what has been read so far
21
+ * @param {string} chunk the raw 'data' payload (may hold any number of characters)
22
+ * @returns {{buf: string, done: boolean, interrupted: boolean}}
23
+ * `done` — a terminator was seen; `buf` is the final value and anything after the
24
+ * terminator in the same chunk is deliberately discarded (it belongs to whatever
25
+ * prompt comes next, not this one).
26
+ * `interrupted` — ctrl-C was seen; the caller decides how to exit.
27
+ */
28
+ function consumeSecretChunk(buf, chunk) {
29
+ let out = String(buf == null ? '' : buf);
30
+ for (const ch of String(chunk == null ? '' : chunk)) {
31
+ if (ch === INTERRUPT) return { buf: out, done: false, interrupted: true };
32
+ if (ENTER.includes(ch)) return { buf: out, done: true, interrupted: false };
33
+ if (ERASE.includes(ch)) { out = out.slice(0, -1); continue; }
34
+ out += ch;
35
+ }
36
+ return { buf: out, done: false, interrupted: false };
37
+ }
38
+
39
+ module.exports = { consumeSecretChunk };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "memhouse",
3
- "version": "0.12.7",
4
- "description": "memhouse — agent conversation memory across 17 editors: parse-on-client shippers, a typed ClickHouse store with one set of rooms per member, kernel-agency install, and the agentlytics dashboard",
3
+ "version": "0.14.0",
4
+ "description": "memhouse \u2014 agent conversation memory across 17 editors: parse-on-client shippers, a typed ClickHouse store with one set of rooms per member, kernel-agency install, and the agentlytics dashboard",
5
5
  "main": "bin/memhouse.js",
6
6
  "bin": {
7
7
  "memhouse": "./bin/memhouse.js"