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,96 @@
1
+ # Tutorial 3: share your memory
2
+
3
+ Two members of one house: `alice` and `bob`. Each owns their rooms, and neither can read
4
+ the other's. In this tutorial, alice lets bob read one project's history, then everything,
5
+ then takes it back. Bob reads it from his own agent.
6
+
7
+ **You need:** two members of the same house. If you have one house of your own, see
8
+ [Invite members](../guides/invite.md) to add a second.
9
+
10
+ ## 1. Alice shares one project
11
+
12
+ On alice's machine:
13
+
14
+ ```bash
15
+ memhouse share bob --only project=memhouse
16
+ ```
17
+
18
+ What this does:
19
+
20
+ - grants bob `SELECT` on exactly three of alice's rooms: `alice_sessions`,
21
+ `alice_messages` and `alice_tool_calls`;
22
+ - adds row policies so that, in those rooms, bob sees only rows where
23
+ `project = 'memhouse'`.
24
+
25
+ Bob gets nothing else. Alice's stats tables, `meta` and `events` stay closed to him, as
26
+ does any room alice gains later.
27
+
28
+ Other scopes work the same way:
29
+
30
+ - `session=…`
31
+ - `host=…`
32
+ - `source=…` (an editor, as stored: `claude-code`, `codex`, …)
33
+ - `folder=…`
34
+ - `since=…` and `until=…` (dates)
35
+
36
+ Before it creates the first policy, `share` checks how the server treats readers that
37
+ have no policy of their own: alice, and anyone with a full share. On some server
38
+ configurations, a table's first row policy blinds those readers to every row. On such a
39
+ server, `share` refuses rather than lock alice out of her own rooms.
40
+
41
+ ## 2. Bob reads it
42
+
43
+ Bob's credential can now see three of alice's rooms. From any ClickHouse client, or
44
+ `/mem:sql` in bob's agent:
45
+
46
+ ```sql
47
+ SELECT project, count() FROM mem.alice_messages GROUP BY project
48
+ -- one row: memhouse
49
+
50
+ SELECT count() FROM mem.alice_session_stats
51
+ -- refused: bob holds no grant on it
52
+ ```
53
+
54
+ A query that strays outside the scope returns nothing, or is refused for the tables bob
55
+ was never granted. It never returns more than the scope.
56
+
57
+ ## 3. Alice widens it to everything
58
+
59
+ ```bash
60
+ memhouse share bob
61
+ ```
62
+
63
+ A **full share** with no `--only`. Bob can now read all of `mem.alice_*`, including the
64
+ stats, `meta` (alice's other share records among it) and `events`. The command says so
65
+ when it runs. Use a full share for someone you would hand your laptop to.
66
+
67
+ Going back from full to scoped is the same `--only` command. It takes the wildcard grant
68
+ back first, so a failure part-way leaves bob with less access, never more.
69
+
70
+ ## 4. See who can read what
71
+
72
+ ```bash
73
+ memhouse share --list
74
+ ```
75
+
76
+ This prints memhouse's own record of the shares: each user, full or scoped, and the scope.
77
+ A grant someone made by hand in SQL does not appear.
78
+
79
+ ## 5. Take it back
80
+
81
+ ```bash
82
+ memhouse share bob --revoke
83
+ ```
84
+
85
+ This withdraws the grants first, then drops the row policies. If the revoke is refused
86
+ part-way, the policies stay, so bob never ends up with an unfiltered share.
87
+
88
+ ## What sharing cannot do
89
+
90
+ - **Recall what was already read.** Bob may have copied rows while he could read them.
91
+ - **Write.** A share is read-only. Only alice writes into alice's rooms.
92
+ - **End with a password change.** Grants belong to the user, not the password. To cut
93
+ bob off, revoke.
94
+
95
+ More detail, including what a full share exposes and why scoped shares are table by
96
+ table: [Share and revoke](../guides/share.md).
package/editors/claude.js CHANGED
@@ -74,13 +74,18 @@ function getChats() {
74
74
  } catch { /* no index */ }
75
75
 
76
76
  // Scan all .jsonl files on disk (some may not be in the index)
77
- let files;
78
- try { files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl')); } catch { continue; }
77
+ let names;
78
+ try { names = fs.readdirSync(dir); } catch { continue; }
79
+ const files = names.filter(f => f.endsWith('.jsonl'));
80
+ // A session with subagents also has a directory named for it; one without has none, so
81
+ // this listing is how the mtime check below stays free for the sessions it cannot help.
82
+ const sessionDirs = new Set(names.filter(f => !f.endsWith('.jsonl')));
79
83
 
80
84
  for (const file of files) {
81
85
  const sessionId = file.replace('.jsonl', '');
82
86
  const fullPath = path.join(dir, file);
83
87
  const entry = indexed.get(sessionId);
88
+ const subTouched = sessionDirs.has(sessionId) ? newestSubagentMtime(path.join(dir, sessionId, 'subagents')) : 0;
84
89
 
85
90
  if (entry) {
86
91
  // Use index metadata
@@ -89,7 +94,7 @@ function getChats() {
89
94
  composerId: sessionId,
90
95
  name: cleanPrompt(entry.firstPrompt),
91
96
  createdAt: entry.created ? new Date(entry.created).getTime() : null,
92
- lastUpdatedAt: entry.modified ? new Date(entry.modified).getTime() : null,
97
+ lastUpdatedAt: latest(entry.modified ? new Date(entry.modified).getTime() : null, subTouched),
93
98
  mode: 'claude',
94
99
  folder: entry.projectPath || decodedFolder,
95
100
  encrypted: false,
@@ -107,7 +112,7 @@ function getChats() {
107
112
  composerId: sessionId,
108
113
  name: meta.firstPrompt ? cleanPrompt(meta.firstPrompt) : null,
109
114
  createdAt: meta.timestamp || stat.birthtime.getTime(),
110
- lastUpdatedAt: stat.mtime.getTime(),
115
+ lastUpdatedAt: latest(stat.mtime.getTime(), subTouched),
111
116
  mode: 'claude',
112
117
  folder: meta.cwd || decodedFolder,
113
118
  encrypted: false,
@@ -195,7 +200,7 @@ function cleanPrompt(prompt) {
195
200
  // Parse one Claude session .jsonl into the adapter's message shape. When
196
201
  // `isSubagent` is set, each message is tagged `[subagent]` so folded subagent turns
197
202
  // are clearly attributed in the transcript.
198
- function parseSessionFile(filePath, isSubagent, agent = null) {
203
+ function parseSessionFile(filePath, isSubagent, agent = null, info = null) {
199
204
  const messages = [];
200
205
  let lines;
201
206
  try { lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean); } catch { return messages; }
@@ -205,7 +210,9 @@ function parseSessionFile(filePath, isSubagent, agent = null) {
205
210
  // cited, or told apart from a sibling's. An agent looking for the fork as its own session
206
211
  // concluded it was never shipped. Every turn now carries the agent's id, the description
207
212
  // the parent gave it, and its position within that subagent — in `extra`, which is not
208
- // part of the line hash, so nothing already shipped is re-shipped for this.
213
+ // part of the line hash. The position is not only a label any more: the shipper numbers
214
+ // a subagent's rows from it (seq = block + turn, see subagentSeq in ship.js), so `turn`
215
+ // must count exactly the turns this parse keeps, in order.
209
216
  let turn = 0;
210
217
  const stamp = (m) => { if (agent) { m._agent = { ...agent, turn: turn++ }; } return m; };
211
218
 
@@ -228,6 +235,7 @@ function parseSessionFile(filePath, isSubagent, agent = null) {
228
235
  // time simply omit it.
229
236
  const _ts = obj.timestamp ? Date.parse(obj.timestamp) : undefined;
230
237
  const at = Number.isFinite(_ts) ? _ts : undefined;
238
+ if (info && info.firstTs === undefined && at !== undefined) info.firstTs = at;
231
239
 
232
240
  if (obj.type === 'user' && obj.message) {
233
241
  const content = extractContent(obj.message.content);
@@ -249,6 +257,10 @@ function parseSessionFile(filePath, isSubagent, agent = null) {
249
257
  return messages;
250
258
  }
251
259
 
260
+ // How long a subagent transcript without a single timestamped line may hold back its
261
+ // session (see getMessages). A real one gets its first line within seconds.
262
+ const SUBAGENT_STAMP_GRACE_MS = 10 * 60 * 1000;
263
+
252
264
  function getMessages(chat) {
253
265
  const filePath = chat._fullPath;
254
266
  if (!filePath || !fs.existsSync(filePath)) return [];
@@ -262,30 +274,76 @@ function getMessages(chat) {
262
274
  const subagentsDir = path.join(filePath.replace(/\.jsonl$/, ''), 'subagents');
263
275
  const subFiles = subagentFiles(subagentsDir);
264
276
  const named = subFiles.length ? subagentNames(filePath) : new Map();
277
+ const folded = [];
278
+ let unstamped = null;
265
279
  for (const { file, workflow } of subFiles) {
266
280
  const id = path.basename(file).replace(/^agent-/, '').replace(/\.jsonl$/, '');
267
281
  const meta = named.get(id) || {};
268
282
  const agent = { id, description: meta.description || '', type: meta.type || (workflow ? 'workflow-subagent' : ''), file: path.basename(file) };
269
283
  if (workflow) agent.workflow = workflow;
270
- const turns = parseSessionFile(file, true, agent);
284
+ const info = {};
285
+ const turns = parseSessionFile(file, true, agent, info);
271
286
  // A fork the parent never named (a Workflow-run agent, or a parent from before the
272
287
  // ids were reported) is described by its own first prompt — what it was told to do.
273
288
  if (!agent.description) {
274
289
  const first = turns.find((m) => m.role === 'user');
275
290
  if (first) { const d = String(first.content).replace(/^\[subagent\] /, '').replace(/\s+/g, ' ').trim().slice(0, 160); for (const m of turns) m._agent.description = d; }
276
291
  }
277
- messages.push(...turns);
292
+ folded.push({ key: path.relative(subagentsDir, file), firstTs: info.firstTs, turns });
293
+ // A subagent caught before its first line is written has no start time, so it ranks
294
+ // last — and when its first line lands with an EARLIER timestamp than a sibling's, it
295
+ // moves, renumbers every sibling after it, and the session forks and re-ships whole
296
+ // (GLM-F1). So a fresh one defers the whole session to the next pass, when it has a
297
+ // time. Bounded by age: a file that never gets a timestamped line stops deferring after
298
+ // SUBAGENT_STAMP_GRACE_MS and ranks last, as before.
299
+ if (info.firstTs === undefined && !unstamped) {
300
+ let age = Infinity;
301
+ try { age = Date.now() - fs.statSync(file).mtimeMs; } catch { /* gone: nothing to wait for */ }
302
+ if (age < SUBAGENT_STAMP_GRACE_MS) unstamped = path.relative(subagentsDir, file);
303
+ }
304
+ }
305
+ // Each subagent gets a SLOT, and the shipper numbers its rows from that slot rather than
306
+ // from where they happen to land in this array (see subagentSeq in ship.js). The slot is
307
+ // the subagent's rank by when it started — the timestamp on its transcript's first line —
308
+ // because that is the one order a new subagent cannot disturb: it started after every
309
+ // subagent already on disk, so it ranks last. The file-name order this used to fold in
310
+ // does not have that property: agent ids are random, so a new fork sorted into the middle
311
+ // and pushed every later subagent's rows down, and a session with subagents re-shipped
312
+ // whole on every pass (one 6,120-line session was stored 60 times in a day). A file with
313
+ // no timestamp yet ranks after every one that has one; the path breaks ties.
314
+ folded.sort((a, b) => (a.firstTs === undefined) - (b.firstTs === undefined)
315
+ || (a.firstTs || 0) - (b.firstTs || 0) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
316
+ folded.forEach(({ turns }, slot) => { for (const m of turns) m._agent.slot = slot; messages.push(...turns); });
317
+ if (unstamped) {
318
+ Object.defineProperty(messages, '_defer', { value: `subagent ${unstamped} has no timestamped line yet`, enumerable: false });
278
319
  }
279
320
 
280
321
  return messages;
281
322
  }
282
323
 
324
+ /**
325
+ * When a session was last written, counting its subagents. The shipper's incremental skip
326
+ * compares `lastUpdatedAt` with what the house recorded, and a subagent writes only its own
327
+ * file: a background agent that runs on after its parent goes quiet left the parent's mtime
328
+ * (and the index's `modified`) where they were, so its turns shipped at the parent's next
329
+ * write, or never. Stat calls only, and only for a session that has a directory.
330
+ */
331
+ function newestSubagentMtime(subagentsDir) {
332
+ let newest = 0;
333
+ for (const { file } of subagentFiles(subagentsDir)) {
334
+ try { newest = Math.max(newest, fs.statSync(file).mtimeMs); } catch { /* vanished mid-pass */ }
335
+ }
336
+ return Math.floor(newest);
337
+ }
338
+ function latest(a, b) { return b && (!a || b > a) ? b : a; }
339
+
283
340
  /**
284
341
  * Every subagent transcript under a session, wherever Claude Code put it:
285
342
  * <session>/subagents/agent-<id>.jsonl — Agent tool forks
286
343
  * <session>/subagents/workflows/wf_<id>/agent-<id>.jsonl — Workflow-run agents
287
- * journal.jsonl and *.meta.json are bookkeeping, not transcripts. Sorted for a stable
288
- * fold order. On real data the workflow layer held 100 of 165 forks and shipped none.
344
+ * journal.jsonl and *.meta.json are bookkeeping, not transcripts. Sorted so the listing is
345
+ * deterministic; the fold ORDER is set by start time in getMessages, not by this.
346
+ * On real data the workflow layer held 100 of 165 forks and shipped none.
289
347
  */
290
348
  function subagentFiles(subagentsDir) {
291
349
  const out = [];
@@ -0,0 +1,99 @@
1
+ # 01 — one machine
2
+
3
+ The smallest memhouse: one person, one laptop, a ClickHouse in a local container. It shows
4
+ the whole loop — a house, a shipping pass, the rows in it, a search that finds a past
5
+ conversation — in about a minute.
6
+
7
+ ## What it does
8
+
9
+ 1. `memhouse deploy --local` starts ClickHouse in Docker or Podman, bound to `127.0.0.1`
10
+ only, with a generated password. It creates two accounts: an administrator, and **you**
11
+ as a member who holds `mem.<you>_*` and nothing else. Both credentials go into the
12
+ memhouse home's `env` file (mode 600); nothing is typed or shown.
13
+ 2. It installs memhouse against that house and starts a background shipper.
14
+ 3. `memhouse ship` runs one pass in the foreground.
15
+ 4. `memhouse status` shows what the house holds; `memhouse search` finds a session.
16
+
17
+ The script ships two made-up sessions from [`../fixtures`](../fixtures/), never yours, and
18
+ works in its own home (`~/.memhouse-example-01`), so an install you already have is left
19
+ alone.
20
+
21
+ ## Needs
22
+
23
+ - Node.js 24 or newer, and `npm install -g memhouse`
24
+ - Docker or Podman, and port 8123 free (or set `HOUSE_PORT`)
25
+ - No earlier `memhouse deploy --local` on this machine. The container and volume names are
26
+ fixed (`memhouse-clickhouse`, `memhouse-data`), so a second local house refuses to start.
27
+ If you have one, you already have this example's result; try [02](../02-existing-clickhouse/).
28
+
29
+ ## Run
30
+
31
+ ```bash
32
+ ./run.sh
33
+ ```
34
+
35
+ | Variable | Default | |
36
+ |---|---|---|
37
+ | `MEMHOUSE_BIN` | `memhouse` | the command to run, e.g. `node ~/memhouse/bin/memhouse.js` |
38
+ | `EXAMPLE_HOME` | `~/.memhouse-example-01` | this example's memhouse home |
39
+ | `HOUSE_PORT` | `8123` | the local port ClickHouse binds |
40
+
41
+ ## What you should see
42
+
43
+ Trimmed from a run on a Linux VM with Docker, ClickHouse 26.7 and memhouse 0.18.9:
44
+
45
+ ```
46
+ == 1. a house: ClickHouse in a container, bound to loopback, plus the install
47
+ ✓ ClickHouse starting via docker on http://localhost:8123 (loopback only)
48
+ ✓ ClickHouse ready
49
+ ✓ member 'polat' holds mem.polat_* and nothing else; 'memhouse_root' administers the house
50
+ ✓ rooms for 'polat': polat_sessions, polat_messages, polat_tool_calls
51
+ ✓ installed
52
+ ✓ shipper started in the background (pid 617049) — loading your history now
53
+
54
+ == 2. one shipping pass
55
+ [memhouse] shipped 2 sessions (0 skipped) → 6 msg rows, 2 tool rows in 0.7s
56
+
57
+ == 3. what the house holds
58
+ ✓ connected: http://localhost:8123 / mem as polat
59
+ ✓ house: 2 sessions, 6 messages (freshest ingest 2026-10-07 21:08:06 UTC)
60
+ ✓ fleet: 1 active writer(s)
61
+ polat@testbed-7edeca38 0.18.9 last ship 0m ago
62
+ ✓ shipper: running — daemon (pid 617049)
63
+
64
+ == 4. find a past conversation
65
+ claude-code:421460e4-5e66-4c1c-afa4-f1b198c28c25 claude-code alpha 2026-10-07 19:07 (1 hit)
66
+ The deploy fails with ClickHouse error ACCESS_DENIED on INSERT. Why?
67
+ ```
68
+
69
+ Three messages per session in the house, not four: the tool result is folded into the
70
+ tool call it answers.
71
+
72
+ To look around, start the dashboard in the example's home (pick another port if 4640 is
73
+ taken by a real install):
74
+
75
+ ```bash
76
+ MEMHOUSE_HOME=~/.memhouse-example-01 MEMHOUSE_PORT=4641 memhouse start
77
+ ```
78
+
79
+ ## Teardown
80
+
81
+ ```bash
82
+ ./run.sh --teardown
83
+ ```
84
+
85
+ Stops the shipper (by the pid memhouse recorded), removes the container **and its data
86
+ volume**, and deletes the example's home.
87
+
88
+ ## For real
89
+
90
+ The same thing with your own sessions is two commands, in your default home:
91
+
92
+ ```bash
93
+ npm install -g memhouse
94
+ memhouse deploy --local
95
+ ```
96
+
97
+ Every editor memhouse knows is read (`memhouse discover` lists what it finds first). Then
98
+ `memhouse start` for the dashboard and `memhouse plugins install claude` to give your agents
99
+ `/mem:recall` and the other skills.
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+ # Example 01 — one person, one machine.
3
+ #
4
+ # A house in a local container, two made-up sessions shipped into it, then read back.
5
+ # Runs in its own memhouse home, so an install you already have is never touched.
6
+ #
7
+ # ./run.sh build it and show it
8
+ # ./run.sh --teardown stop the shipper, remove the container and its volume, delete the home
9
+ #
10
+ # Environment (all optional):
11
+ # MEMHOUSE_BIN the memhouse command (default: memhouse)
12
+ # EXAMPLE_HOME where this example's install lives (default: ~/.memhouse-example-01)
13
+ # HOUSE_PORT the local port ClickHouse binds (default: 8123)
14
+ set -euo pipefail
15
+
16
+ HERE=$(cd "$(dirname "$0")" && pwd)
17
+ MH=${MEMHOUSE_BIN:-memhouse}
18
+ mh() { $MH "$@"; }
19
+
20
+ # Only this example's home speaks. An exported credential from another install would
21
+ # otherwise outrank the home's own env file (flags > MEMHOUSE_* > env file).
22
+ unset MEMHOUSE_URL MEMHOUSE_USER MEMHOUSE_PASSWORD MEMHOUSE_DB MEMHOUSE_ADMIN_USER MEMHOUSE_ADMIN_PASSWORD
23
+ export MEMHOUSE_HOME=${EXAMPLE_HOME:-$HOME/.memhouse-example-01}
24
+ HOUSE_PORT=${HOUSE_PORT:-8123}
25
+
26
+ if [ "${1:-}" = "--teardown" ]; then
27
+ mh stop || true
28
+ mh deploy --down --yes
29
+ rm -rf "$MEMHOUSE_HOME"
30
+ echo "example 01 removed"
31
+ exit 0
32
+ fi
33
+
34
+ mkdir -p "$MEMHOUSE_HOME"
35
+
36
+ # Made-up sessions, and nothing else: the shipper reads only this directory.
37
+ FIX="$MEMHOUSE_HOME/example-sessions"
38
+ if [ ! -d "$FIX/projects" ]; then
39
+ echo "== writing two made-up Claude Code sessions to $FIX"
40
+ node "$HERE/../fixtures/make-sessions.js" "$FIX" me alpha beta
41
+ fi
42
+ export MEMHOUSE_EDITORS=claude MEMHOUSE_CLAUDE_ROOTS="$FIX"
43
+
44
+ echo "== 1. a house: ClickHouse in a container, bound to loopback, plus the install"
45
+ mh deploy --local --house-port "$HOUSE_PORT"
46
+
47
+ echo
48
+ echo "== 2. one shipping pass (deploy also started a background shipper; this one is in the foreground)"
49
+ mh ship
50
+
51
+ echo
52
+ echo "== 3. what the house holds"
53
+ mh status
54
+
55
+ echo
56
+ echo "== 4. find a past conversation"
57
+ mh search ACCESS_DENIED
58
+
59
+ echo
60
+ echo "Done. Open the dashboard: MEMHOUSE_HOME=$MEMHOUSE_HOME $MH start (then http://localhost:4640)"
61
+ echo "Remove everything: $0 --teardown"
@@ -0,0 +1,121 @@
1
+ # 02 — an existing ClickHouse
2
+
3
+ You already run ClickHouse — a server of your own, your company's cluster, or ClickHouse
4
+ Cloud — and want memhouse to keep its house there. You hold an account on it that can
5
+ create users. This example builds a **member** account with it, installs as that member,
6
+ and keeps only the member's credential on disk.
7
+
8
+ ## What it does
9
+
10
+ 1. `memhouse whoami --admin` checks that the admin credential really can create users and
11
+ grant, before anything is changed.
12
+ 2. `memhouse install --admin-user …` creates the member (default: your login name), grants
13
+ it `mem.<member>_*` and nothing else, creates its rooms, and verifies the install by
14
+ connecting **as the member**. The admin credential is used for this one command and not
15
+ saved (add `--keep-admin` if this machine should be able to invite people later).
16
+ 3. `memhouse ship --ensure-schema` creates any room or stat table that is missing — safe to
17
+ re-run, and the thing to run after an upgrade that adds one — then `memhouse ship`
18
+ ships two made-up sessions.
19
+ 4. `memhouse whoami` and `memhouse status` show the credential this machine now keeps and
20
+ what the house holds.
21
+
22
+ ## Needs
23
+
24
+ - Node.js 24 or newer, and `npm install -g memhouse`
25
+ - The ClickHouse HTTP endpoint, e.g. `https://ch.example.com:8443` (ClickHouse Cloud:
26
+ the HTTPS endpoint from the console). memhouse's CI runs on ClickHouse 25.11 and 26.7.
27
+ - An account that can create users and databases and grant what it holds. A server's
28
+ first administrator qualifies: the `default` user of a fresh server, or the user the
29
+ Docker image creates with `CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1`. Step 1 tells you
30
+ whether yours does before anything changes.
31
+ - The admin password where a tool can lend it to one command. It never goes on a command
32
+ line: `ps` and shell history would keep it.
33
+
34
+ ## Run
35
+
36
+ ```bash
37
+ export MEMHOUSE_URL=https://ch.example.com:8443
38
+ export MEMHOUSE_ADMIN_USER=default
39
+ with-secret MEMHOUSE_ADMIN_PASSWORD=keychain:clickhouse-admin -- ./run.sh
40
+ ```
41
+
42
+ No secret tool? Read it into this shell without echo:
43
+
44
+ ```bash
45
+ read -rs MEMHOUSE_ADMIN_PASSWORD && export MEMHOUSE_ADMIN_PASSWORD
46
+ ./run.sh
47
+ unset MEMHOUSE_ADMIN_PASSWORD
48
+ ```
49
+
50
+ | Variable | Default | |
51
+ |---|---|---|
52
+ | `MEMHOUSE_URL` | (required) | the ClickHouse HTTP(S) endpoint |
53
+ | `MEMHOUSE_ADMIN_USER` | (required) | the account that creates the member |
54
+ | `MEMHOUSE_ADMIN_PASSWORD` | (required) | its password, lent by a secret tool |
55
+ | `MEMBER` | your login name | the member to create |
56
+ | `MEMHOUSE_BIN` | `memhouse` | the command to run |
57
+ | `EXAMPLE_HOME` | `~/.memhouse-example-02` | this example's memhouse home |
58
+
59
+ The same install without the script, giving the password on stdin instead of the
60
+ environment:
61
+
62
+ ```bash
63
+ printf '%s' "$PASSWORD" | memhouse install --url "$MEMHOUSE_URL" --admin-user default \
64
+ --member alice --admin-password-file - --yes
65
+ ```
66
+
67
+ ## What you should see
68
+
69
+ Trimmed from a run against ClickHouse 26.7.1.1315 with memhouse 0.18.9 (the test endpoint is shown as `ch.example.com`):
70
+
71
+ ```
72
+ == 1. whoami --admin: is this credential really an administrator?
73
+ ✓ admin at https://ch.example.com:8443 — administrator
74
+ credential from: MEMHOUSE_ADMIN_* environment
75
+ may create users: yes
76
+
77
+ == 2. install: build member 'polat' with the admin credential, then verify as the member
78
+ ✓ created ClickHouse user 'polat'
79
+ ✓ granted 'polat' mem.polat_* — their rooms and nothing else; async_insert pinned
80
+ ✓ verified as 'polat' with the member credential
81
+ ✓ installed
82
+ the admin credential was used for this install and not saved. To invite later:
83
+ with-secret MEMHOUSE_ADMIN_PASSWORD=<reference> -- memhouse invite <name> --url … --admin-user admin
84
+
85
+ == 3. make sure every room and stat table exists, then ship once
86
+ [memhouse] schema ensured (5 statements)
87
+ [memhouse] shipped 2 sessions (0 skipped) → 6 msg rows, 2 tool rows in 0.2s
88
+
89
+ == 4. whoami: the credential this machine keeps, and what it may do
90
+ ✓ polat at https://ch.example.com:8443 — member
91
+ may create users: no
92
+
93
+ == 5. status
94
+ ✓ house: 2 sessions, 6 messages
95
+ ✓ fleet: 1 active writer(s)
96
+ ```
97
+
98
+ Step 2 generates the member password and writes it only to the home's `env` file (mode
99
+ 600). From 0.18.10 no command prints it; earlier versions showed it once on this step.
100
+
101
+ ## Teardown
102
+
103
+ ```bash
104
+ ./run.sh --teardown
105
+ ```
106
+
107
+ Deletes the example's home. The member and its rooms stay in the house: dropping them is
108
+ an administrator's decision. As the admin:
109
+
110
+ ```sql
111
+ DROP USER polat;
112
+ -- then each of mem.polat_sessions, _messages, _tool_calls, _meta, _events,
113
+ -- _session_stats, _session_model_stats, _session_tool_stats
114
+ ```
115
+
116
+ ## For real
117
+
118
+ Leave out the two fixture flags (`--editors claude --claude-roots …`) and the
119
+ `EXAMPLE_HOME`, and every editor on the machine ships into the house. Your other machines
120
+ join the same rooms by installing with the same member credential (`memhouse install --url
121
+ … --user <member> --password …`, or an invite file — see [03](../03-team-house/)).
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bash
2
+ # Example 02 — point memhouse at a ClickHouse you already run.
3
+ #
4
+ # The admin credential builds a member account (rooms mem.<member>_*, one grant on exactly
5
+ # those), the install is verified AS that member, and the admin credential is then dropped:
6
+ # what stays on disk is the member's. The admin password never touches the command line.
7
+ #
8
+ # with-secret MEMHOUSE_ADMIN_PASSWORD=<reference> -- ./run.sh
9
+ # ./run.sh --teardown stop anything this home started and delete the home
10
+ #
11
+ # Environment:
12
+ # MEMHOUSE_URL required the ClickHouse HTTP endpoint, e.g. https://ch.example.com:8443
13
+ # MEMHOUSE_ADMIN_USER required an account that can create users and grant
14
+ # MEMHOUSE_ADMIN_PASSWORD required its password — lent by with-secret, never typed into argv
15
+ # MEMBER optional the member to create (default: your login name)
16
+ # MEMHOUSE_BIN optional the memhouse command (default: memhouse)
17
+ # EXAMPLE_HOME optional where this install lives (default: ~/.memhouse-example-02)
18
+ set -euo pipefail
19
+
20
+ HERE=$(cd "$(dirname "$0")" && pwd)
21
+ MH=${MEMHOUSE_BIN:-memhouse}
22
+ mh() { $MH "$@"; }
23
+ export MEMHOUSE_HOME=${EXAMPLE_HOME:-$HOME/.memhouse-example-02}
24
+
25
+ if [ "${1:-}" = "--teardown" ]; then
26
+ mh stop || true
27
+ rm -rf "$MEMHOUSE_HOME"
28
+ echo "example 02 home removed. The member and its rooms stay in the house; dropping them is an admin's call."
29
+ exit 0
30
+ fi
31
+
32
+ : "${MEMHOUSE_URL:?set MEMHOUSE_URL to your ClickHouse HTTP endpoint}"
33
+ : "${MEMHOUSE_ADMIN_USER:?set MEMHOUSE_ADMIN_USER to an account that can create users}"
34
+ : "${MEMHOUSE_ADMIN_PASSWORD:?lend it with: with-secret MEMHOUSE_ADMIN_PASSWORD=<reference> -- $0}"
35
+ # A login name like "first.last" is not a ClickHouse user name memhouse accepts.
36
+ MEMBER=${MEMBER:-$(id -un | tr -c 'A-Za-z0-9_\n' '_' | sed 's/^[^A-Za-z]*//')}
37
+ # Only the admin variables and the URL come from outside. A member credential exported
38
+ # by some other install would outrank this home's env file.
39
+ unset MEMHOUSE_USER MEMHOUSE_PASSWORD MEMHOUSE_DB
40
+
41
+ mkdir -p "$MEMHOUSE_HOME"
42
+ FIX="$MEMHOUSE_HOME/example-sessions"
43
+ [ -d "$FIX/projects" ] || node "$HERE/../fixtures/make-sessions.js" "$FIX" "$MEMBER" alpha beta >/dev/null
44
+
45
+ echo "== 1. whoami --admin: is this credential really an administrator?"
46
+ mh whoami --admin
47
+
48
+ echo
49
+ echo "== 2. install: build member '$MEMBER' with the admin credential, then verify as the member"
50
+ # --editors/--claude-roots keep this example to the made-up sessions above. Drop both to
51
+ # ship your real ones. --no-ship: nothing runs in the background; step 3 ships once.
52
+ mh install --url "$MEMHOUSE_URL" --admin-user "$MEMHOUSE_ADMIN_USER" --member "$MEMBER" \
53
+ --editors claude --claude-roots "$FIX" --yes --no-ship
54
+
55
+ # From here on the member credential in the env file is the only one in play.
56
+ unset MEMHOUSE_ADMIN_USER MEMHOUSE_ADMIN_PASSWORD
57
+
58
+ echo
59
+ echo "== 3. make sure every room and stat table exists, then ship once"
60
+ # --ensure-schema only creates what is missing, as the member, and ships nothing; it is
61
+ # safe to re-run, and the thing to run after an upgrade that adds a table.
62
+ mh ship --ensure-schema
63
+ mh ship
64
+
65
+ echo
66
+ echo "== 4. whoami: the credential this machine keeps, and what it may do"
67
+ mh whoami
68
+
69
+ echo
70
+ echo "== 5. status"
71
+ mh status