memhouse 0.18.8 → 0.18.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/AGENTS.md +194 -0
  2. package/CHANGELOG.md +970 -0
  3. package/README.md +54 -423
  4. package/SECURITY.md +68 -0
  5. package/bin/memhouse.js +202 -92
  6. package/docs/README.md +46 -0
  7. package/docs/guides/admin-password.md +95 -0
  8. package/docs/guides/configuration.md +109 -0
  9. package/docs/guides/daemons.md +53 -0
  10. package/docs/guides/install.md +128 -0
  11. package/docs/guides/invite.md +87 -0
  12. package/docs/guides/operate.md +112 -0
  13. package/docs/guides/search-and-resume.md +95 -0
  14. package/docs/guides/share.md +102 -0
  15. package/docs/guides/storage.md +90 -0
  16. package/docs/guides/troubleshooting.md +78 -0
  17. package/docs/guides/uninstall.md +35 -0
  18. package/docs/guides/update.md +68 -0
  19. package/docs/releases/README.md +10 -0
  20. package/docs/releases/v0.18.10.md +226 -0
  21. package/docs/tutorials/01-first-house.md +132 -0
  22. package/docs/tutorials/02-join-a-house.md +91 -0
  23. package/docs/tutorials/03-share-your-memory.md +96 -0
  24. package/examples/01-local-one-machine/README.md +99 -0
  25. package/examples/01-local-one-machine/run.sh +61 -0
  26. package/examples/02-existing-clickhouse/README.md +121 -0
  27. package/examples/02-existing-clickhouse/run.sh +71 -0
  28. package/examples/03-team-house/README.md +129 -0
  29. package/examples/03-team-house/run.sh +124 -0
  30. package/examples/04-self-hosted-team/.env.example +22 -0
  31. package/examples/04-self-hosted-team/README.md +187 -0
  32. package/examples/04-self-hosted-team/compose.yml +158 -0
  33. package/examples/04-self-hosted-team/smoke.sh +113 -0
  34. package/examples/README.md +42 -0
  35. package/examples/fixtures/README.md +24 -0
  36. package/examples/fixtures/make-sessions.js +63 -0
  37. package/memhouse/admin-secret.js +81 -0
  38. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
  39. package/memhouse/deploy.js +4 -2
  40. package/memhouse/flags.js +4 -4
  41. package/memhouse/provision.js +12 -3
  42. package/memhouse/server/queries.js +18 -4
  43. package/memhouse/server/server.js +9 -1
  44. package/memhouse/service.js +5 -0
  45. package/package.json +10 -2
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bash
2
+ # Smoke test for the self-hosted team house: everything goes through the TLS proxy.
3
+ #
4
+ # ./smoke.sh docker compose up -d with ./.env, then test it
5
+ # SKIP_UP=1 ./smoke.sh test a stack that is already running (see below)
6
+ # KEEP=1 ./smoke.sh keep the `smoke` member and its rooms afterwards
7
+ #
8
+ # With SKIP_UP=1 nothing is read from .env; give the target instead:
9
+ # MEMHOUSE_URL https://<domain>[:port]
10
+ # MEMHOUSE_ADMIN_USER / MEMHOUSE_ADMIN_PASSWORD (lend the password with with-secret)
11
+ # CA_FILE the CA that signed the proxy's certificate, if not a public one
12
+ #
13
+ # What it proves: the proxy answers over TLS; the passwordless `default` user is gone; an
14
+ # admin can invite a member; the member installs, ships, and reads its rooms back; a
15
+ # BACKUP to the backups disk restores with the same row count.
16
+ set -euo pipefail
17
+
18
+ HERE=$(cd "$(dirname "$0")" && pwd)
19
+ MH=${MEMHOUSE_BIN:-memhouse}
20
+ mh() { $MH "$@"; }
21
+ WORK=$(mktemp -d)
22
+ unset MEMHOUSE_USER MEMHOUSE_PASSWORD MEMHOUSE_DB
23
+
24
+ pass=0; fail=0
25
+ ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); }
26
+ bad() { printf ' FAIL %s\n %s\n' "$1" "${2:-}"; fail=$((fail+1)); }
27
+
28
+ if [ "${SKIP_UP:-0}" != "1" ]; then
29
+ [ -f "$HERE/.env" ] || { echo "no .env — cp .env.example .env, fill it in, chmod 600"; exit 1; }
30
+ (cd "$HERE" && docker compose up -d --wait)
31
+ set -a; . "$HERE/.env"; set +a
32
+ port=${MEMHOUSE_HTTPS_PORT:-443}
33
+ MEMHOUSE_URL="https://$MEMHOUSE_DOMAIN$([ "$port" = 443 ] || echo ":$port")"
34
+ MEMHOUSE_ADMIN_USER=${MEMHOUSE_ADMIN_USER:-admin}
35
+ if [ "${MEMHOUSE_TLS:-internal}" = "internal" ]; then
36
+ (cd "$HERE" && docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt "$WORK/caddy-root.crt")
37
+ CA_FILE="$WORK/caddy-root.crt"
38
+ fi
39
+ fi
40
+ : "${MEMHOUSE_URL:?}" "${MEMHOUSE_ADMIN_USER:?}" "${MEMHOUSE_ADMIN_PASSWORD:?}"
41
+ export MEMHOUSE_URL MEMHOUSE_ADMIN_USER MEMHOUSE_ADMIN_PASSWORD
42
+ CURL=(curl -sS)
43
+ if [ -n "${CA_FILE:-}" ]; then
44
+ CURL+=(--cacert "$CA_FILE")
45
+ export NODE_EXTRA_CA_CERTS="$CA_FILE" # memhouse is Node: it trusts this CA too
46
+ fi
47
+
48
+ # Passwords go to curl on stdin (-K -), never on its command line.
49
+ q_as() { printf 'user = "%s:%s"\n' "$1" "$2" | "${CURL[@]}" -K - --data-binary "$3" "$MEMHOUSE_URL/" 2>&1; }
50
+ admin_q() { q_as "$MEMHOUSE_ADMIN_USER" "$MEMHOUSE_ADMIN_PASSWORD" "$1"; }
51
+ member_q() { ( set -a; . "$WORK/home/env"; set +a; q_as "$MEMHOUSE_USER" "$MEMHOUSE_PASSWORD" "$1" ); }
52
+
53
+ cleanup() {
54
+ if [ "${KEEP:-0}" != "1" ] && [ "${CREATED:-0}" = "1" ]; then
55
+ admin_q "DROP USER IF EXISTS smoke" >/dev/null || true
56
+ for t in $(admin_q "SELECT name FROM system.tables WHERE database = 'mem' AND startsWith(name, 'smoke_') FORMAT TSV"); do
57
+ admin_q "DROP TABLE IF EXISTS mem.$t SYNC" >/dev/null || true
58
+ done
59
+ fi
60
+ rm -rf "$WORK"
61
+ }
62
+ trap cleanup EXIT
63
+
64
+ echo "smoke test against $MEMHOUSE_URL"
65
+
66
+ r=$("${CURL[@]}" "$MEMHOUSE_URL/ping" 2>&1 || true)
67
+ [ "$r" = "Ok." ] && ok "the proxy answers over TLS (certificate verified)" || bad "no TLS answer from the proxy" "$r"
68
+
69
+ r=$(q_as default "" "SELECT 1" || true)
70
+ case "$r" in *AUTHENTICATION_FAILED*|*"Authentication failed"*|*REQUIRED_PASSWORD*) ok "the passwordless default user is gone";; *) bad "the default user still answers" "$r";; esac
71
+
72
+ r=$(admin_q "SELECT version()")
73
+ case "$r" in [0-9]*) ok "admin reaches ClickHouse $r";; *) bad "admin login failed" "$r"; exit 1;; esac
74
+
75
+ n=$(admin_q "SELECT count() FROM system.users WHERE name = 'smoke' FORMAT TSV")
76
+ [ "$n" = "0" ] || { bad "a user named smoke already exists — refusing to touch it"; exit 1; }
77
+ CREATED=1
78
+
79
+ # A loopback URL is right only through a tunnel; invite refuses one unless told so.
80
+ case "$MEMHOUSE_URL" in https://localhost*|https://127.0.0.1*) LOCAL=--allow-local;; *) LOCAL=;; esac
81
+ export MEMHOUSE_HOME="$WORK/admin"; mkdir -p "$MEMHOUSE_HOME"
82
+ out=$(cd "$WORK" && mh invite smoke --url "$MEMHOUSE_URL" --admin-user "$MEMHOUSE_ADMIN_USER" --out "$WORK/smoke.env" $LOCAL 2>&1 || true)
83
+ case "$out" in *"invite written"*) ok "admin invited member 'smoke'";; *) bad "invite failed" "$(printf '%s' "$out" | tail -3)"; exit 1;; esac
84
+
85
+ export MEMHOUSE_HOME="$WORK/home"; mkdir -p "$MEMHOUSE_HOME"
86
+ node "$HERE/../fixtures/make-sessions.js" "$WORK/sessions" smoke alpha beta >/dev/null
87
+ out=$(unset MEMHOUSE_ADMIN_USER MEMHOUSE_ADMIN_PASSWORD
88
+ mh install --env "$WORK/smoke.env" --editors claude --claude-roots "$WORK/sessions" --yes --no-ship 2>&1 || true)
89
+ case "$out" in *"installed"*) ok "the member installed from the invite file, over https";; *) bad "install failed" "$(printf '%s' "$out" | grep '✗' | head -3)"; exit 1;; esac
90
+ out=$(unset MEMHOUSE_ADMIN_USER MEMHOUSE_ADMIN_PASSWORD; mh ship 2>&1 || true)
91
+ case "$out" in *"shipped 2 sessions"*) ok "shipped: $(printf '%s' "$out" | tail -1 | sed 's/^\[memhouse\] //')";; *) bad "ship failed" "$out";; esac
92
+
93
+ r=$(member_q "SELECT count() FROM mem.smoke_messages FINAL FORMAT TSV")
94
+ [ "$r" = "6" ] && ok "the member reads its 6 messages back through the proxy" || bad "read-back count" "$r"
95
+ r=$(member_q "CREATE TABLE mem.not_mine (x UInt8) ENGINE = MergeTree ORDER BY x" || true)
96
+ case "$r" in *ACCESS_DENIED*|*"Not enough privileges"*) ok "the member cannot create tables outside mem.smoke_*";; *) bad "the member created a table outside its rooms" "$r";; esac
97
+
98
+ ts=$(date -u +%Y-%m-%d-%H_%M_%S)
99
+ r=$(admin_q "BACKUP TABLE mem.smoke_messages TO Disk('backups', 'mem-smoke-$ts.zip')")
100
+ case "$r" in *BACKUP_CREATED*) ok "BACKUP to the backups disk";; *) bad "backup failed" "$r";; esac
101
+ r=$(admin_q "RESTORE TABLE mem.smoke_messages AS mem.smoke_restore_check FROM Disk('backups', 'mem-smoke-$ts.zip')")
102
+ case "$r" in *RESTORED*) ok "RESTORE from it";; *) bad "restore failed" "$r";; esac
103
+ r=$(admin_q "SELECT count() FROM mem.smoke_restore_check FORMAT TSV")
104
+ [ "$r" = "6" ] && ok "the restored copy holds the same 6 rows" || bad "restored count" "$r"
105
+
106
+ r=$(admin_q "SELECT status FROM system.backups WHERE name LIKE '%''mem-2%' AND startsWith(toString(status), 'BACKUP') ORDER BY start_time DESC LIMIT 1 FORMAT TSV")
107
+ case "$r" in BACKUP_CREATED) ok "the backup service has written a nightly backup since the server started";;
108
+ '') printf ' info no nightly backup yet this server lifetime (BACKUP_ON_START=1 runs one at start)\n';;
109
+ *) bad "the last nightly backup did not complete" "$r";; esac
110
+
111
+ echo
112
+ echo "$pass passed, $fail failed"
113
+ [ "$fail" = 0 ]
@@ -0,0 +1,42 @@
1
+ # memhouse examples
2
+
3
+ Four working setups, smallest first. Each folder has a README and a script you can run as
4
+ it is. They all ship **made-up sessions** (from [`fixtures/`](fixtures/)) rather than
5
+ yours, and each installs into a memhouse home of its own, so an install you already have
6
+ is never touched.
7
+
8
+ | # | Example | Who it is for | Needs |
9
+ |---|---|---|---|
10
+ | 01 | [One machine](01-local-one-machine/) | one person, a house in a local container | Node 24, Docker or Podman |
11
+ | 02 | [An existing ClickHouse](02-existing-clickhouse/) | you already run ClickHouse, or use ClickHouse Cloud | Node 24, an admin account on it |
12
+ | 03 | [A team house](03-team-house/) | an admin, three members, a full share and a scoped one | Node 24, a throwaway ClickHouse and its admin |
13
+ | 04 | [Self-hosted for a team](04-self-hosted-team/) | ClickHouse behind TLS, nightly backups, members on other machines | a Linux server with Docker, a DNS name |
14
+
15
+ To do any of this for real, with your own sessions, skip the script and follow the
16
+ README's "For real" section: it is the same commands without the fixture flags.
17
+
18
+ ## Running them
19
+
20
+ ```bash
21
+ npm install -g memhouse # or set MEMHOUSE_BIN to a checkout: "node /path/to/bin/memhouse.js"
22
+ cd examples/01-local-one-machine
23
+ ./run.sh
24
+ ```
25
+
26
+ Admin passwords are never typed on a command line. The scripts read them from
27
+ `MEMHOUSE_ADMIN_PASSWORD`, which you lend to one command from wherever you keep secrets:
28
+
29
+ ```bash
30
+ with-secret MEMHOUSE_ADMIN_PASSWORD=keychain:clickhouse-admin -- ./run.sh
31
+ ```
32
+
33
+ Any tool that sets an environment variable for one command does the same job: 1Password's
34
+ `op run`, or a `read -rs MEMHOUSE_ADMIN_PASSWORD && export MEMHOUSE_ADMIN_PASSWORD` in the
35
+ same shell, which keeps it off the screen and out of shell history.
36
+
37
+ ## How they were tested
38
+
39
+ Last run on 2026-10-08, memhouse 0.18.9 (main `ccdcfb8`), against ClickHouse 26.7.1.1315,
40
+ the version memhouse's own production house runs: 01 on a disposable Linux VM with Docker,
41
+ 02 and 03 against a disposable ClickHouse, 04 as the full compose stack. The output each
42
+ README quotes is from that run.
@@ -0,0 +1,24 @@
1
+ # fixtures — made-up sessions
2
+
3
+ `make-sessions.js` writes a few Claude Code transcripts for one made-up person, laid out
4
+ the way Claude Code lays out `~/.claude`, so an example can ship real rows without
5
+ shipping your conversations.
6
+
7
+ ```bash
8
+ node make-sessions.js <claude-root> <person> [project ...] # default projects: alpha beta
9
+ ```
10
+
11
+ Each project gets one session: a question, a tool call (`memhouse whoami`) and its result,
12
+ and an answer. The `alpha` session is about an `ACCESS_DENIED` error and the `beta` one
13
+ about rotating a password, so a search can tell them apart. memhouse records the project
14
+ as the last segment of the session's folder (`/home/<person>/work/alpha`), which is what a
15
+ scoped share like `--only project=alpha` matches.
16
+
17
+ Point memhouse at the directory, and at nothing else, with:
18
+
19
+ ```bash
20
+ MEMHOUSE_EDITORS=claude MEMHOUSE_CLAUDE_ROOTS=<claude-root> memhouse ship
21
+ ```
22
+
23
+ or persist the same scope at install time with `--editors claude --claude-roots <claude-root>`.
24
+ The script prints one line per session: `<project> <session-id>`.
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ // Write a few made-up Claude Code sessions for one person, so an example can ship real
3
+ // transcripts without shipping YOURS.
4
+ //
5
+ // node make-sessions.js <claude-root> <person> [project ...]
6
+ //
7
+ // <claude-root> is laid out the way Claude Code lays out ~/.claude: projects/<encoded
8
+ // folder>/<session-id>.jsonl. Point memhouse at it with
9
+ //
10
+ // MEMHOUSE_EDITORS=claude MEMHOUSE_CLAUDE_ROOTS=<claude-root> memhouse ship
11
+ //
12
+ // and nothing else on the machine is read. Each project gets one session: a question, a
13
+ // tool call, an answer. The project memhouse records is the folder's last segment, so
14
+ // `--only project=alpha` in a scoped share matches the session written for `alpha`.
15
+ //
16
+ // Prints one line per session: <project> <session-id>.
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const crypto = require('crypto');
21
+
22
+ const [root, person, ...rest] = process.argv.slice(2);
23
+ if (!root || !person || !/^[A-Za-z][A-Za-z0-9_]*$/.test(person)) {
24
+ console.error('usage: make-sessions.js <claude-root> <person> [project ...]');
25
+ process.exit(2);
26
+ }
27
+ const projects = rest.length ? rest : ['alpha', 'beta'];
28
+
29
+ // What each made-up session is about — distinct words, so a search can tell them apart.
30
+ const TOPICS = {
31
+ alpha: { ask: 'The deploy fails with ClickHouse error ACCESS_DENIED on INSERT. Why?',
32
+ answer: 'The member holds INSERT on its own rooms only. The deploy wrote to a table outside the mem.<member>_* pattern; write to the member rooms instead.' },
33
+ beta: { ask: 'How do I rotate the shipper password without losing data?',
34
+ answer: 'Run memhouse passwd. It rotates the credential and rewrites the env file; the rows in the house are not touched.' },
35
+ };
36
+ const topic = (p) => TOPICS[p] || { ask: `What is the state of project ${p}?`, answer: `Project ${p} is a made-up example.` };
37
+
38
+ const now = Date.now();
39
+ for (const [i, project] of projects.entries()) {
40
+ const folder = `/home/${person}/work/${project}`;
41
+ const dir = path.join(root, 'projects', folder.replace(/\//g, '-'));
42
+ fs.mkdirSync(dir, { recursive: true });
43
+ const sid = crypto.randomUUID();
44
+ // An hour apart per project, a few seconds apart per turn, all in the recent past.
45
+ const t = (s) => new Date(now - (projects.length - i) * 3600e3 + s * 1000).toISOString();
46
+ const { ask, answer } = topic(project);
47
+ const base = { sessionId: sid, cwd: folder, gitBranch: 'main', version: '2.1.0' };
48
+ const lines = [
49
+ { ...base, type: 'user', uuid: 'u1', timestamp: t(0), message: { role: 'user', content: ask } },
50
+ { ...base, type: 'assistant', uuid: 'a1', parentUuid: 'u1', timestamp: t(4),
51
+ message: { role: 'assistant', model: 'claude-example', content: [
52
+ { type: 'text', text: 'Let me look at the grants first.' },
53
+ { type: 'tool_use', id: `toolu_${i}`, name: 'Bash', input: { command: 'memhouse whoami' } },
54
+ ], usage: { input_tokens: 120, output_tokens: 30 } } },
55
+ { ...base, type: 'user', uuid: 'u2', parentUuid: 'a1', timestamp: t(6),
56
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: `toolu_${i}`, content: `member ${person}` }] } },
57
+ { ...base, type: 'assistant', uuid: 'a2', parentUuid: 'u2', timestamp: t(10),
58
+ message: { role: 'assistant', model: 'claude-example', content: [{ type: 'text', text: answer }],
59
+ usage: { input_tokens: 180, output_tokens: 60 } } },
60
+ ];
61
+ fs.writeFileSync(path.join(dir, `${sid}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
62
+ console.log(`${project} ${sid}`);
63
+ }
@@ -0,0 +1,81 @@
1
+ // Where an ADMIN password comes from — and why the command line is the last place.
2
+ //
3
+ // A password passed as `--admin-password <p>` is in the process list (`ps`) for as long
4
+ // as the command runs and in shell history unless the caller remembered a leading space.
5
+ // The only scriptable admin path used to demand exactly that: `--admin-user` as a flag
6
+ // turned off every other source, so an agent or CI job that held the password in an
7
+ // environment variable (or in a secret store, via `with-secret`) still had to put it on
8
+ // argv. Found by O's sandbox rehearsal (finding 6).
9
+ //
10
+ // Order, first answer wins:
11
+ // 1. --admin-password-file <path> a file holding the password (trailing newline dropped)
12
+ // --admin-password-file - read it from stdin (printf '%s' "$P" | memhouse …)
13
+ // 2. --admin-password <p> still accepted, with a warning — never required
14
+ // 3. MEMHOUSE_ADMIN_PASSWORD the environment; `with-secret` lends it to one command
15
+ // 4. the stored credential only when it belongs to the SAME admin user
16
+ // Nothing here prompts: the caller decides whether a TTY prompt is the next step.
17
+ //
18
+ // Pure apart from the one file/stdin read, which is injectable, so it is unit tested.
19
+
20
+ const fs = require('fs');
21
+
22
+ // Strip exactly one trailing newline: `echo secret > f` writes one, and a password that
23
+ // legitimately ends in whitespace must survive.
24
+ function stripOneNewline(s) {
25
+ return String(s).replace(/\r?\n$/, '');
26
+ }
27
+
28
+ function readSecretFile(spec, { readFile = fs.readFileSync, stdinIsTTY = process.stdin.isTTY } = {}) {
29
+ if (spec === '-') {
30
+ if (stdinIsTTY) throw new Error('--admin-password-file - reads the password from stdin, but stdin is a terminal — pipe it in, or leave the flag off to be prompted');
31
+ return stripOneNewline(readFile(0, 'utf-8'));
32
+ }
33
+ let text;
34
+ try { text = readFile(spec, 'utf-8'); } catch (e) { throw new Error(`--admin-password-file ${spec}: ${e.code === 'ENOENT' ? 'no such file' : e.message.split('\n')[0]}`); }
35
+ return stripOneNewline(text);
36
+ }
37
+
38
+ /**
39
+ * @param {object} o
40
+ * @param {string} o.adminUser the admin user this password is for
41
+ * @param {object} o.flags parsed CLI flags
42
+ * @param {object} [o.env] process.env (injectable)
43
+ * @param {object} [o.stored] { user, password } this install keeps, if any
44
+ * @param {object} [o.io] { readFile, stdinIsTTY } for tests
45
+ * @returns {{ password: string|undefined, source: 'file'|'stdin'|'flag'|'env'|'stored'|null }}
46
+ */
47
+ function resolveAdminPassword({ adminUser, flags = {}, env = process.env, stored = null, io = {} }) {
48
+ const fileFlag = flags['admin-password-file'];
49
+ const argvFlag = flags['admin-password'];
50
+ if (fileFlag !== undefined && argvFlag !== undefined) {
51
+ throw new Error('give the admin password one way: --admin-password-file or --admin-password, not both');
52
+ }
53
+ if (fileFlag !== undefined) {
54
+ if (fileFlag === true || fileFlag === '') throw new Error('--admin-password-file needs a path, or - for stdin');
55
+ const password = readSecretFile(String(fileFlag), io);
56
+ return { password, source: fileFlag === '-' ? 'stdin' : 'file' };
57
+ }
58
+ if (argvFlag !== undefined && argvFlag !== true) return { password: String(argvFlag), source: 'flag' };
59
+ if (env && typeof env.MEMHOUSE_ADMIN_PASSWORD === 'string' && env.MEMHOUSE_ADMIN_PASSWORD !== '') {
60
+ return { password: env.MEMHOUSE_ADMIN_PASSWORD, source: 'env' };
61
+ }
62
+ if (stored && stored.user && stored.password && stored.user === adminUser) {
63
+ return { password: stored.password, source: 'stored' };
64
+ }
65
+ return { password: undefined, source: null };
66
+ }
67
+
68
+ // One line for a caller that took the password off argv anyway.
69
+ const ARGV_WARNING = '--admin-password on the command line is visible in `ps` and shell history, and is deprecated: it is removed in 0.19.0. Use MEMHOUSE_ADMIN_PASSWORD (with-secret) or --admin-password-file -';
70
+
71
+ // What to tell a caller with no TTY and no password from any source.
72
+ function missingAdminPasswordHelp(adminUser) {
73
+ return [
74
+ `no password for admin '${adminUser}', and no TTY to prompt on. Give it without putting it on the command line:`,
75
+ ` with-secret MEMHOUSE_ADMIN_PASSWORD=<reference> -- memhouse …`,
76
+ ` printf '%s' "$PASSWORD" | memhouse … --admin-password-file -`,
77
+ ` memhouse … --admin-password-file <file, mode 600>`,
78
+ ];
79
+ }
80
+
81
+ module.exports = { resolveAdminPassword, readSecretFile, stripOneNewline, ARGV_WARNING, missingAdminPasswordHelp };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mem",
3
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.18.8",
4
+ "version": "0.18.10",
5
5
  "author": {
6
6
  "name": "Ramazan Polat"
7
7
  },
@@ -159,7 +159,9 @@ function up({ password, port = 8123, tag = DEFAULT_TAG, user = 'memhouse_root' }
159
159
  '--label', `${OWNER_LABEL}=1`,
160
160
  '--restart', 'unless-stopped',
161
161
  '-e', `CLICKHOUSE_USER=${user}`,
162
- '-e', `CLICKHOUSE_PASSWORD=${password}`,
162
+ // By NAME only: the value goes in the child's environment below, never in argv, where
163
+ // `ps` would show it for as long as `docker run` lives.
164
+ '-e', 'CLICKHOUSE_PASSWORD',
163
165
  // The house itself is created by `memhouse install`, not here: pre-creating it
164
166
  // would skip the step an install is supposed to prove.
165
167
  '-e', 'CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1',
@@ -168,7 +170,7 @@ function up({ password, port = 8123, tag = DEFAULT_TAG, user = 'memhouse_root' }
168
170
  '--ulimit', 'nofile=262144:262144',
169
171
  image,
170
172
  ];
171
- const r = spawnSync(eng, args, { encoding: 'utf-8' });
173
+ const r = spawnSync(eng, args, { encoding: 'utf-8', env: { ...process.env, CLICKHOUSE_PASSWORD: password } });
172
174
  if (r.status !== 0) {
173
175
  // The volume exists but the image never touched it — an unpullable tag, a port bind
174
176
  // that failed. Leaving it labelled would make the NEXT deploy read an empty volume as
package/memhouse/flags.js CHANGED
@@ -16,11 +16,11 @@ const COMMAND_FLAGS = {
16
16
  null: [], help: [], version: [],
17
17
  whoami: ['admin'],
18
18
  share: ['only', 'revoke', 'list'],
19
- discover: [], stats: [], stop: [], doctor: [], 'sessions-query': [], rooms: [], instance: [], members: ['db','admin-user','admin-password'], convert: ['url','db','admin-user','admin-password','member','print-sql','dry-run','guides','out'],
19
+ discover: [], stats: [], stop: [], doctor: [], 'sessions-query': [], rooms: [], instance: [], members: ['db','admin-user','admin-password','admin-password-file'], convert: ['url','db','admin-user','admin-password','admin-password-file','member','print-sql','dry-run','guides','out'],
20
20
  status: ['all'], start: [],
21
21
  onboard: ['url', 'user', 'password', 'db', 'port', 'no-ship', 'target', 'house-port', 'tag', 'local'],
22
22
  install: ['url', 'user', 'password', 'db', 'port', 'no-ship', 'env', 'print-sql', 'member',
23
- 'member-password', 'admin-user', 'admin-password', 'ensure-schema', 'rotate-password', 'force',
23
+ 'member-password', 'admin-user', 'admin-password', 'admin-password-file', 'keep-admin', 'ensure-schema', 'rotate-password', 'force',
24
24
  'editors', 'claude-roots', 'channel'],
25
25
  setup: ['url', 'user', 'password', 'db', 'port', 'admin-user', 'admin-password', 'editors', 'claude-roots', 'channel'],
26
26
  ship: ['full', 'loop', 'ensure-schema'],
@@ -28,9 +28,9 @@ const COMMAND_FLAGS = {
28
28
  resume: [],
29
29
  update: ['check', 'migrate', 'no-install', 'channel'],
30
30
  reset: ['all-origins'],
31
- invite: ['url', 'db', 'out', 'admin-user', 'admin-password', 'adopt', 'print-sql',
31
+ invite: ['url', 'db', 'out', 'admin-user', 'admin-password', 'admin-password-file', 'adopt', 'print-sql',
32
32
  'allow-local', 'member-password'],
33
- passwd: ['password', 'admin-user', 'admin-password', 'print-sql', 'member'],
33
+ passwd: ['password', 'admin-user', 'admin-password', 'admin-password-file', 'print-sql', 'member'],
34
34
  nightly: ['out'],
35
35
  migrate: ['dry-run'], 'migrate-rooms': ['dry-run'],
36
36
  relocate: ['to', 'to-user', 'to-password', 'to-db', 'from-native-host', 'from-native-port',
@@ -54,13 +54,22 @@ function sqlString(v) {
54
54
  *
55
55
  * @returns {Array<{sql:string, why:string, optional:boolean}>}
56
56
  */
57
- function plan({ db, member, password = null }) {
57
+ // What a PRINTED plan carries where the password goes. Deliberately not a quoted string:
58
+ // run as printed, `IDENTIFIED BY <member-password>` does not parse, so nobody can create a
59
+ // member whose password is the placeholder text. A printed plan never carries a real
60
+ // password: printed output ends up in terminals, scrollback and agent transcripts, and
61
+ // memhouse ships transcripts into the house (G23).
62
+ const PASSWORD_PLACEHOLDER = '<member-password>';
63
+
64
+ function plan({ db, member, password = null, placeholder = false }) {
58
65
  assertUsableName(db, 'house');
59
66
  assertUsableName(member, 'member');
60
67
  const steps = [
61
68
  { sql: `CREATE DATABASE IF NOT EXISTS ${db}`, why: 'the house', optional: false },
62
69
  ];
63
- if (password !== null) {
70
+ if (placeholder) {
71
+ steps.push({ sql: `CREATE USER ${member} IDENTIFIED BY ${PASSWORD_PLACEHOLDER}`, why: `the member — replace ${PASSWORD_PLACEHOLDER} with a quoted password you generate, e.g. '…' from \`openssl rand -hex 16\`; as printed this line does not parse, on purpose`, optional: false });
72
+ } else if (password !== null) {
64
73
  // No IF NOT EXISTS. The live path checks first and refuses an existing user; the printed
65
74
  // plan used to no-op here, apply the grants, and hand out a password the server never saw —
66
75
  // found on a realm that mints its own users. Now it fails loudly on line one instead.
@@ -100,4 +109,4 @@ function render(steps, { db, member }) {
100
109
  return out.join('\n');
101
110
  }
102
111
 
103
- module.exports = { ROOM_PRIVILEGES, roomGrant, plan, render };
112
+ module.exports = { ROOM_PRIVILEGES, PASSWORD_PLACEHOLDER, roomGrant, plan, render };
@@ -471,10 +471,24 @@ async function getChats(opts = {}) {
471
471
  // Price the fifty rows on the page, not the twelve hundred in the house. This query
472
472
  // set was the most expensive thing the dashboard ran — 1,413,624 rows read and 272 MiB
473
473
  // per call, three endpoints calling it — and getChats used ~4% of what it asked for.
474
- const perChat = await computePerChatCosts(f, {
475
- ids: rows.map((r) => String(r.id)),
476
- users: rows.map((r) => String(r.user_id)),
477
- });
474
+ // In CHUNKS. The scope travels as `{scopeIds:Array(String)}` query parameters, and the
475
+ // driver puts query parameters in the REQUEST URI — so a page of N sessions builds a URI
476
+ // of roughly 60 bytes per id, twice (ids and users). Zeo is reached through a Cloudflare
477
+ // tunnel, which rejects a URI far below ClickHouse's own 1 MiB `http_max_uri_size`: at
478
+ // 925 sessions the request came back 500 with an EMPTY message (the driver reports a
479
+ // transport-level refusal as `new Error('')`), so `/api/chats?limit=1000` — exactly what
480
+ // the Sessions page asks for — returned 500 and the page rendered blank. 900 worked,
481
+ // 925 did not. Chunked, the URI stays small whatever the page size, and the per-chunk
482
+ // results merge because each chunk is disjoint by construction.
483
+ const COST_SCOPE_CHUNK = 250;
484
+ const perChat = [];
485
+ for (let i = 0; i < rows.length; i += COST_SCOPE_CHUNK) {
486
+ const slice = rows.slice(i, i + COST_SCOPE_CHUNK);
487
+ perChat.push(...await computePerChatCosts(f, {
488
+ ids: slice.map((r) => String(r.id)),
489
+ users: slice.map((r) => String(r.user_id)),
490
+ }));
491
+ }
478
492
  const costBySession = {};
479
493
  const topModelBySession = {};
480
494
  for (const r of perChat) {
@@ -49,7 +49,15 @@ function parseDateOpts(query) {
49
49
 
50
50
  // Route helper: async handler with the uniform 500 shape.
51
51
  const route = (fn) => async (req, res) => {
52
- try { await fn(req, res); } catch (err) { res.status(500).json({ error: err.message }); }
52
+ try { await fn(req, res); } catch (err) {
53
+ // `{"error":""}` and nothing in the log was the whole diagnosis for a 500 that made the
54
+ // Sessions page render blank: the ClickHouse driver reports a transport-level refusal as
55
+ // `new Error('')` — no message, no code, no own keys. An empty error is a dead end for
56
+ // whoever is looking, so say what we do know: the class of failure and the route.
57
+ const why = (err && err.message) || (err && err.code) || `${err && err.constructor ? err.constructor.name : 'Error'} with no message — often the server or a proxy in front of it refusing the request (URI too long, body too large, timeout)`;
58
+ console.error(`[memhouse] ${req.method} ${req.originalUrl} failed: ${why}`);
59
+ res.status(500).json({ error: String(why) });
60
+ }
53
61
  };
54
62
 
55
63
  app.get('/api/ping', (req, res) => res.json({ app: 'agentlytics', pid: process.pid }));
@@ -190,6 +190,11 @@ function install({ shipJs, envFile, logDir, interval = 300, home = null, force =
190
190
  // another member's rooms — which either fails on permissions or, if it can read them,
191
191
  // quietly ships into the wrong place.
192
192
  const env = parseEnvFile(envFile);
193
+ // The shipper never uses the admin credential, and the unit inlines every key it is
194
+ // given — a kept admin password would land in a second file and in the daemon's
195
+ // environment for its whole life.
196
+ delete env.MEMHOUSE_ADMIN_USER;
197
+ delete env.MEMHOUSE_ADMIN_PASSWORD;
193
198
  // MEMHOUSE_HOME is not in the env file — it is where the env file itself lives — and a
194
199
  // service installed under a custom home must keep using it, or it reads a different
195
200
  // config after a reboot than the one just written.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memhouse",
3
- "version": "0.18.8",
3
+ "version": "0.18.10",
4
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": {
@@ -20,7 +20,15 @@
20
20
  "ui/vite.config.js",
21
21
  "ui/eslint.config.js",
22
22
  "public/",
23
- "README.md"
23
+ "README.md",
24
+ "docs/README.md",
25
+ "docs/tutorials/",
26
+ "docs/guides/",
27
+ "docs/releases/",
28
+ "examples/",
29
+ "AGENTS.md",
30
+ "SECURITY.md",
31
+ "CHANGELOG.md"
24
32
  ],
25
33
  "scripts": {
26
34
  "start": "node bin/memhouse.js",