memhouse 0.18.9 → 0.18.11

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 (60) hide show
  1. package/AGENTS.md +194 -0
  2. package/CHANGELOG.md +1097 -0
  3. package/README.md +54 -423
  4. package/SECURITY.md +68 -0
  5. package/bin/memhouse.js +299 -133
  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 +93 -0
  16. package/docs/guides/troubleshooting.md +78 -0
  17. package/docs/guides/uninstall.md +37 -0
  18. package/docs/guides/update.md +68 -0
  19. package/docs/releases/README.md +11 -0
  20. package/docs/releases/v0.18.10.md +226 -0
  21. package/docs/releases/v0.18.11.md +191 -0
  22. package/docs/tutorials/01-first-house.md +132 -0
  23. package/docs/tutorials/02-join-a-house.md +91 -0
  24. package/docs/tutorials/03-share-your-memory.md +96 -0
  25. package/editors/claude.js +68 -10
  26. package/examples/01-local-one-machine/README.md +99 -0
  27. package/examples/01-local-one-machine/run.sh +61 -0
  28. package/examples/02-existing-clickhouse/README.md +121 -0
  29. package/examples/02-existing-clickhouse/run.sh +71 -0
  30. package/examples/03-team-house/README.md +129 -0
  31. package/examples/03-team-house/run.sh +124 -0
  32. package/examples/04-self-hosted-team/.env.example +22 -0
  33. package/examples/04-self-hosted-team/README.md +187 -0
  34. package/examples/04-self-hosted-team/compose.yml +158 -0
  35. package/examples/04-self-hosted-team/smoke.sh +113 -0
  36. package/examples/README.md +42 -0
  37. package/examples/fixtures/README.md +24 -0
  38. package/examples/fixtures/make-sessions.js +63 -0
  39. package/memhouse/DESIGN.md +9 -2
  40. package/memhouse/admin-secret.js +81 -0
  41. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
  42. package/memhouse/delivery/plugin/reference/HOUSE.md +18 -6
  43. package/memhouse/delivery/plugin/skills/access/SKILL.md +1 -1
  44. package/memhouse/delivery/plugin/skills/admin/SKILL.md +1 -1
  45. package/memhouse/delivery/plugin/skills/house/SKILL.md +1 -1
  46. package/memhouse/delivery/plugin/skills/recall/SKILL.md +34 -14
  47. package/memhouse/delivery/plugin/skills/sql/SKILL.md +1 -1
  48. package/memhouse/deploy.js +4 -2
  49. package/memhouse/flags.js +32 -5
  50. package/memhouse/host.js +11 -6
  51. package/memhouse/house/HOUSE.md +1 -1
  52. package/memhouse/house/house.js +27 -1
  53. package/memhouse/invite-target.js +37 -0
  54. package/memhouse/provision.js +12 -3
  55. package/memhouse/server/queries.js +9 -4
  56. package/memhouse/service.js +5 -0
  57. package/memhouse/shipper/ship.js +148 -31
  58. package/package.json +10 -2
  59. package/public/THIRD_PARTY_NOTICES.txt +2114 -0
  60. package/ui/vite.config.js +4 -1
@@ -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
+ }
@@ -134,8 +134,15 @@ Config file: `~/.memhouse/config.json` (`hiddenProjects`, future prefs).
134
134
  - Per-message `ts`: interpolated across the session's `[createdAt, lastUpdatedAt]`
135
135
  (adapters don't expose per-message timestamps; monotonic by `seq`; documented
136
136
  approximation, refine per-adapter later).
137
- - `seq` is the message index within the session (0-based). `tool_calls.idx` is the
138
- call index within the session. `is_subagent` = message content begins with
137
+ - `seq` is the message's place in the session. The session's own turns are dense from 0;
138
+ a folded subagent's turns are numbered from their own block,
139
+ `1,000,000,000 + slot × 100,000 + turn`, where `slot` is the subagent's rank by start
140
+ time. `tool_calls.idx` is laid out the same way (the session's own calls dense from 0,
141
+ a subagent's counted within its block). A row's key therefore never depends on how long
142
+ the parent or any other subagent is, which is what lets a session with subagents ship
143
+ tail-only; positional numbering moved every subagent row whenever the parent grew. A
144
+ parse that does not fit the blocks falls back to positional numbering. Readers order by
145
+ `seq` and count with `count()`; nothing may treat `seq` as dense. `is_subagent` = message content begins with
139
146
  `[subagent]` (the fold marker from the Claude adapter).
140
147
  - Incremental shipping: read existing `(session_id, last_updated_at, message_count)`
141
148
  (FINAL) and skip chats that haven't grown/changed; a full re-ship must remain
@@ -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.9",
4
+ "version": "0.18.11",
5
5
  "author": {
6
6
  "name": "Ramazan Polat"
7
7
  },
@@ -101,23 +101,35 @@ session finds nothing — look inside the parent:
101
101
  ```sql
102
102
  -- the subagents a session spawned
103
103
  SELECT toString(extra.agent.id) AS agent, any(toString(extra.agent.description)) AS asked_to, count() AS turns
104
- FROM messages WHERE session_id = '<sid>' AND is_subagent GROUP BY agent -- toString: a JSON path cannot be a GROUP BY key
104
+ FROM messages FINAL
105
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND is_subagent
106
+ AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
107
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
108
+ GROUP BY agent -- toString: a JSON path cannot be a GROUP BY key
105
109
  -- one subagent's transcript, in order
106
- SELECT seq, role, substring(text, 1, 2000) FROM messages
107
- WHERE session_id = '<sid>' AND extra.agent.id = '<agent>' ORDER BY seq
110
+ SELECT seq, role, substring(text, 1, 2000) AS text FROM messages FINAL
111
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND toString(extra.agent.id) = '<agent>'
112
+ AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
113
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
114
+ ORDER BY seq
108
115
  ```
109
116
 
117
+ Both carry the current-epoch filter (trap 1 below). A session whose subagents were
118
+ re-parsed holds every parse, so an unfiltered count doubles each subagent's turns, and an
119
+ unfiltered transcript repeats them. `<uid>` is the session's `user_id`, from whatever
120
+ query found it (trap 2).
121
+
110
122
  **`messages`** — one row per turn.
111
123
 
112
124
  | column | meaning |
113
125
  |---|---|
114
126
  | `session_id` | `<source>:<adapter-local id>`, e.g. `claude-code:6b1f…` |
115
- | `seq` | position in the session, from 0 |
127
+ | `seq` | order within the session. The session's own turns are `0, 1, 2, …`; a subagent's turns sit in their own block, `1,000,000,000 + slot × 100,000 + turn` (slot = the subagent's rank by start time), so `seq` is **sparse** — count rows with `count()`, never `max(seq) + 1` |
116
128
  | `source` | editor: `claude-code`, `codex`, `cursor`, … |
117
- | `host` | the machine, `<hostname>-<8 hex>` — random per install, not derived |
129
+ | `host` | the machine, `<hostname>-<8 hex>` — the 8 hex are derived from the OS machine id (random only where none can be read), so a reinstall keeps it and same-named machines differ |
118
130
  | `ts` | when the turn happened (`DateTime64(3,'UTC')`) |
119
131
  | `role` | `user`, `assistant`, `system` |
120
- | `is_subagent` | 1 for a turn that a Claude Code **subagent** (a fork) produced; folded into the parent session after the parent's own turns. Subagents are never separate sessions. |
132
+ | `is_subagent` | 1 for a turn that a Claude Code **subagent** (a fork) produced; folded into the parent session after the parent's own turns, each subagent's turns contiguous and the subagents in the order they started. Subagents are never separate sessions. |
121
133
  | `extra` | JSON. For a subagent turn: `extra.agent.id`, `extra.agent.description` (what the parent asked it to do, else the fork's own first prompt), `extra.agent.type` (`general-purpose`, `workflow-subagent`, …), `extra.agent.turn` (position within that subagent), and `extra.agent.workflow` (`wf_…`) when a Workflow run spawned it. Empty `{}` otherwise, and on rows shipped before 0.18.3. |
122
134
  | `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens` | usage; **empty for several adapters** — see below |
123
135
  | `text` | the turn, **truncated at 50,000 chars** |
@@ -13,7 +13,7 @@ Two things that look alike and are not:
13
13
  - **Invite** mints a ClickHouse *user* and their own *house*. Needs an administrator.
14
14
  - **Share** grants an existing user `SELECT` on *your* house. You can do this yourself.
15
15
 
16
- **Read `../reference/HOUSE.md` first** for the connection block. One rule dominates
16
+ **Read `../../reference/HOUSE.md` first** for the connection block. One rule dominates
17
17
  everything here: **a password must never enter this conversation.** memhouse ships this
18
18
  transcript into the house, and the archive is insert-only — a credential pasted here
19
19
  cannot be withdrawn.
@@ -12,7 +12,7 @@ A member owns a house, or rooms in one. An **administrator** owns the ClickHouse
12
12
  account, every grant. This skill is the second one, and it is the only skill allowed to
13
13
  act outside the caller's own house.
14
14
 
15
- **Read `../reference/HOUSE.md` first** — connection, schema, and the traps every query
15
+ **Read `../../reference/HOUSE.md` first** — connection, schema, and the traps every query
16
16
  here inherits.
17
17
 
18
18
  **An admin password must never enter this conversation.** memhouse ships this transcript
@@ -11,7 +11,7 @@ allowed-tools: Bash
11
11
  **Invoking this IS the request. Run the report and present it; there is nothing to
12
12
  clarify.** Read-only throughout.
13
13
 
14
- **Read `../reference/HOUSE.md` first** — connection, schema, and the epoch filter every
14
+ **Read `../../reference/HOUSE.md` first** — connection, schema, and the epoch filter every
15
15
  count below depends on.
16
16
 
17
17
  ## Nothing configured yet? Answer that instead
@@ -16,7 +16,7 @@ answer still exists.
16
16
  path — this skill retrieves rows and YOU read them. Never present a guess as a retrieval:
17
17
  if the house has nothing, say so.
18
18
 
19
- **Read `../reference/HOUSE.md` first** — connection, schema, and the three traps
19
+ **Read `../../reference/HOUSE.md` first** — connection, schema, and the three traps
20
20
  (epoch filtering, `(session_id, user_id)` joins, escaping). Every query below assumes it.
21
21
 
22
22
  ## Which shape is being asked for
@@ -43,7 +43,8 @@ found 8 messages where `%clickhouse%` AND `%ttl%` found 1,461.
43
43
  ```sql
44
44
  SELECT session_id, user_id, any(folder) AS folder,
45
45
  formatDateTime(max(ts), '%Y-%m-%d') AS day, count() AS hits,
46
- substring(anyIf(text, positionCaseInsensitive(text, 'NEEDLE') > 0), 1, 300) AS sample
46
+ min(seq) AS hit_seq,
47
+ substring(argMin(text, seq), 1, 300) AS sample
47
48
  FROM messages
48
49
  WHERE text_ngram LIKE '%needle%'
49
50
  AND (origin != 'ship'
@@ -55,8 +56,8 @@ ORDER BY hits DESC, max(ts) DESC
55
56
  LIMIT 8
56
57
  ```
57
58
 
58
- `anyIf(...)` so the sample is a line that actually matched — `any(text)` returns an
59
- arbitrary message from the session and misled two readers in three. **Order by `hits`,
59
+ Every row counted here matched, so `sample` is a line that actually matched: the
60
+ session's first hit, at `hit_seq`. Step 2 reads around that position. **Order by `hits`,
60
61
  not just recency**: a session mentioning the term 197 times outranks yesterday's passing
61
62
  reference.
62
63
 
@@ -64,15 +65,27 @@ reference.
64
65
  the match so you see the resolution, not the complaint:
65
66
 
66
67
  ```sql
67
- SELECT seq, role, substring(text, 1, 2000) AS text
68
- FROM messages
69
- WHERE session_id = '<sid>' AND user_id = '<uid>'
70
- AND seq BETWEEN <hit_seq - 3> AND <hit_seq + 12>
68
+ SELECT seq, role, is_subagent, substring(text, 1, 2000) AS text FROM (
69
+ (SELECT seq, role, is_subagent, text FROM messages FINAL
70
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND seq < <hit_seq>
71
+ AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
72
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
73
+ ORDER BY seq DESC LIMIT 3)
74
+ UNION ALL
75
+ (SELECT seq, role, is_subagent, text FROM messages FINAL
76
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND seq >= <hit_seq>
77
+ AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
78
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
79
+ ORDER BY seq LIMIT 13))
71
80
  ORDER BY seq
72
81
  ```
73
82
 
74
- Apply the epoch filter here too — an unfiltered read of a compacted session interleaves
75
- two parses of the same conversation.
83
+ `<sid>`, `<uid>` and `<hit_seq>` come from step 1's row. The window is counted in rows,
84
+ not in `seq` values: three turns before the hit and twelve after. `seq` has gaps. A
85
+ subagent's turns are numbered in a block of their own, far above the parent's, so
86
+ `seq BETWEEN hit_seq - 3 AND hit_seq + 12` could return the hit alone. The epoch filter
87
+ keeps one parse: an unfiltered read of a compacted session interleaves two versions of
88
+ the same conversation. `FINAL` collapses row versions the server has not merged yet.
76
89
 
77
90
  **3. Answer, then cite.** Lead with the answer. Under it list the sessions it came from —
78
91
  `session_id`, date, project — so the user can reopen one (`memhouse resume <session_id>`
@@ -95,13 +108,20 @@ one representative line each. Offer to read the strongest. Do not dump ten rows
95
108
  prefer it over hand-joining. For a plain list:
96
109
 
97
110
  ```sql
98
- SELECT session_id, name, source, host, project,
99
- formatDateTime(started_at, '%Y-%m-%d %H:%i') AS started, message_count
100
- FROM sessions
101
- ORDER BY started_at DESC
111
+ SELECT session_id, user_id, name, source, host, project,
112
+ formatDateTime(created_at, '%Y-%m-%d %H:%i') AS started,
113
+ formatDateTime(coalesce(last_updated_at, created_at), '%Y-%m-%d %H:%i') AS last_active,
114
+ message_count
115
+ FROM sessions FINAL
116
+ ORDER BY coalesce(last_updated_at, created_at) DESC
102
117
  LIMIT 20
103
118
  ```
104
119
 
120
+ `sessions` keeps one row per session and the newest write wins, so read it `FINAL`.
121
+ "Recent" means recently active (`last_updated_at`): a session started last month and
122
+ worked on today is recent. The room has no `started_at`; `created_at` is when the
123
+ session began.
124
+
105
125
  Narrow with `WHERE project = '…'`, `source = 'cursor'`, `host = '…'`, or a date range as
106
126
  asked. In a shared house every member's sessions are here — add `AND user_id = '<name>'`
107
127
  only when the user asks for one person's.
@@ -11,7 +11,7 @@ allowed-tools: Bash
11
11
  For **numbers**. When the answer is a passage from a past conversation, that is
12
12
  `/mem:recall` — this skill counts, groups and ranks.
13
13
 
14
- **Read `../reference/HOUSE.md` first.** Schema, connection, and the three traps. The epoch
14
+ **Read `../../reference/HOUSE.md` first.** Schema, connection, and the three traps. The epoch
15
15
  filter in particular is not optional here: this is the skill most likely to produce a
16
16
  figure someone acts on, and omitting it over-counted a real house by **34%**.
17
17
 
@@ -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',
@@ -75,4 +75,31 @@ function allowedFlags(cmd) {
75
75
  return new Set([...GLOBAL_FLAGS, ...known]);
76
76
  }
77
77
 
78
- module.exports = { GLOBAL_FLAGS, COMMAND_FLAGS, unknownFlags, allowedFlags, suggestFlag, editDistance };
78
+ // Options that never take a value. The parser used to read `--x y` as x = "y" for every
79
+ // option, so a switch swallowed the word after it: `update --no-install yes` set
80
+ // no-install to "yes" (not true) and ran the npm install it was asked to skip, and
81
+ // `share --revoke bob` revoked nobody. A switch is true when present, and the next word
82
+ // stays a positional.
83
+ const BOOLEAN_FLAGS = new Set([
84
+ 'json', 'yes', 'help', 'version', 'full', 'check', 'migrate', 'no-install', 'all-origins',
85
+ 'dry-run', 'print-sql', 'no-ship', 'keep-admin', 'ensure-schema', 'rotate-password', 'force',
86
+ 'adopt', 'allow-local', 'local', 'down', 'credentials', 'full-removal', 'insecure-native',
87
+ 'keep-shipper', 'guides', 'revoke', 'list', 'admin', 'install', 'all',
88
+ ]);
89
+
90
+ /** argv (after the command) -> { flags, positional }. */
91
+ function parseArgv(rest) {
92
+ const flags = {}; const positional = [];
93
+ for (let i = 0; i < rest.length; i++) {
94
+ const a = rest[i];
95
+ if (a.startsWith('--')) {
96
+ const key = a.slice(2);
97
+ const next = rest[i + 1];
98
+ if (!BOOLEAN_FLAGS.has(key) && next !== undefined && !next.startsWith('--')) { flags[key] = next; i++; }
99
+ else flags[key] = true;
100
+ } else positional.push(a);
101
+ }
102
+ return { flags, positional };
103
+ }
104
+
105
+ module.exports = { GLOBAL_FLAGS, COMMAND_FLAGS, BOOLEAN_FLAGS, parseArgv, unknownFlags, allowedFlags, suggestFlag, editDistance };