memhouse 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,22 +26,20 @@ FIRST. Only say you don't know after a search comes back empty.
26
26
  (full-text over messages), `/memhouse:sessions` (list/filter sessions),
27
27
  `/memhouse:sql` (free-form read-only SQL). Without skills, query directly.
28
28
 
29
- **Table names first.** Every member owns their rooms, so every table below takes your
30
- username as a suffix — `messages_<you>`, `sessions_<you>`, `tool_calls_<you>`. There
31
- are no unsuffixed rooms; the plain names fail with `UNKNOWN_TABLE` rather than
32
- returning nothing. Get the suffix from `SELECT currentUser()`. **There is no
33
- `sessions_v` object** — the rollup is a saved query over those same rooms, and
29
+ **Table names.** The house's rooms are three plain, shared tables — `messages`,
30
+ `sessions`, `tool_calls` — in the connection's database. Everyone in the house writes
31
+ into the same tables; `user_id` (server-stamped) says whose row it is and `host` says
32
+ which machine. Query the whole house by default; add `WHERE user_id = '<name>'` for one
33
+ person. **There is no `sessions_v` object** — the rollup is a saved query, and
34
34
  `memhouse sessions-query` prints it ready to drop into a `FROM (...) AS c`.
35
- `all_messages` / `all_sessions` / `all_tool_calls` read across every member you hold a
36
- grant for.
37
35
 
38
36
  - Find sessions about a topic (FTS, lowercase your terms):
39
- `SELECT DISTINCT session_id, any(project), min(ts) FROM messages_<you>
37
+ `SELECT DISTINCT session_id, any(project), min(ts) FROM messages
40
38
  WHERE hasToken(text_word, 'clickhouse') GROUP BY session_id
41
39
  ORDER BY 3 DESC LIMIT 10 SETTINGS final=1, join_use_nulls=1 FORMAT PrettyCompact`
42
40
  - Recent sessions: `SELECT session_id, source, project, started, first_prompt
43
41
  FROM $(memhouse sessions-query) AS c ORDER BY started DESC LIMIT 20 SETTINGS final=1, join_use_nulls=1`
44
- - Replay one session: `SELECT role, text FROM messages_<you>
42
+ - Replay one session: `SELECT role, text FROM messages
45
43
  WHERE session_id = '<id>' ORDER BY seq SETTINGS final=1, join_use_nulls=1`
46
44
 
47
45
  **Rules.**
@@ -15,7 +15,7 @@ absent grant rather than a row policy — it fails closed. There is no shared
15
15
  `sessions`/`messages`/`tool_calls` layout and no row policy in the default path; 0.4.0
16
16
  removed both. Team-wide reads are the `Merge` rooms plus a `GRANT`. The session rollup
17
17
  is a **saved query**, not a stored view — `memhouse sessions-query` prints it.
18
- Design and measurements: `../per-member/`.
18
+ Design and measurements: `../house/HOUSE.md`.
19
19
 
20
20
  This is an operator runbook, so it speaks machine vocabulary throughout — ClickHouse,
21
21
  `GRANT`, `currentUser()`. That is correct for this audience.
@@ -63,40 +63,25 @@ For each person joining, split across the two authorities:
63
63
  python3 executor/executor.py submit register-member '{"handle":"alice"}'
64
64
  python3 executor/executor.py approve <call_id> && python3 executor/executor.py drain
65
65
 
66
- # OWNER mints that member's three rooms and their grants (memhouse_root has
67
- # grant-option on mem.*). One idempotent step — see ../per-member/PROVISIONING.md:
68
- MEM_URL=https://<kernel-host>:8443 MEM_USER=memhouse_root \
69
- MEM_PASSWORD=<credential> MEM_DB=mem \
70
- node memhouse/per-member/provision.js --member alice --merge
66
+ # OWNER admits the member to the house — two statements, any DBA knows them:
67
+ # CREATE USER alice IDENTIFIED BY '…';
68
+ # GRANT ALL ON mem.* TO alice;
69
+ # (memhouse can run them: memhouse install --admin-user … --member alice)
71
70
  ```
72
71
 
73
- **Never `GRANT … ON mem.*` to a member.** A member who can read `mem.*` can read every
74
- other member's rooms, and then this is a shared house with longer table names.
75
- `provision.js` grants per room: `ALL` on each of alice's three, plus a re-grantable
76
- `SELECT` on each (that split is what makes a share read-only by construction), plus
77
- `SELECT` on the `Merge` rooms.
78
-
79
72
  Each member then runs the shipper with **their own** credential
80
- (`MEMHOUSE_USER=alice`) — it writes to `sessions_alice` / `messages_alice` /
81
- `tool_calls_alice`, resolved from `SELECT currentUser()` rather than from config, and
82
- the house stamps `user_id='alice'` on their rows, un-spoofably
83
- (`MATERIALIZED currentUser()`, `async_insert=0`).
73
+ (`MEMHOUSE_USER=alice`) — everyone writes into the SAME three tables (`sessions`,
74
+ `messages`, `tool_calls`), and the house stamps `user_id='alice'` on her rows,
75
+ un-spoofably (`MATERIALIZED currentUser()`, with `async_insert = 0 CONST` pinned on the
76
+ user so the stamp cannot be skipped). Her machines are told apart by `host`.
84
77
 
85
78
  ## 4. Visibility
86
79
 
87
- **Own-only is the default and needs no policy.** Alice is granted her own three rooms
88
- and nobody else's, so bob's rooms are not hidden from her — they are simply not hers to
89
- read, and the failure mode of a missing grant is a denial rather than a leak.
90
-
91
- **Team-wide reads are the `Merge` rooms** (`all_sessions`, `all_messages`,
92
- `all_tool_calls`) plus a `GRANT SELECT` on them, which `provision.js --merge` issues. A
93
- `Merge` room narrows to whatever underlying rooms the caller already holds grants for —
94
- measured on 26.7.1: no leak, no error — so it can be granted broadly. It also
95
- auto-discovers rooms created after it, so onboarding a member needs no DDL there.
96
-
97
- A member widens what a colleague sees by granting their own rooms directly
98
- (`GRANT SELECT ON mem.messages_alice TO bob`) — self-serve, no operator, because the
99
- member holds grant-option on their own `SELECT`. See `../per-member/SHARING.md`.
80
+ **A house is shared by its housemates.** The tables are common; `user_id` and `host` say
81
+ who wrote what, and `WHERE user_id = 'alice'` is one person. The boundary is the
82
+ DATABASE: a member of `mem` holds nothing on any other database, so two agencies on one
83
+ kernel ClickHouse cannot read each other. The model is collaborative — housemates trust
84
+ each other with the house; a separate house is the isolation mechanism.
100
85
 
101
86
  Row policies are still the only way to share a *subset* of rows, which is owner-mediated
102
87
  and documented in `SHARING.md` — they are not how isolation works.
@@ -9,7 +9,7 @@ allowed-tools: Bash
9
9
  # /memhouse:search — search conversation memory
10
10
 
11
11
  Search the full message history in the memhouse house. Every room is named for your
12
- ClickHouse user — `messages_<you>`, `sessions_<you>` — see **Room names** below.
12
+ ClickHouse user — `messages`, `sessions` — see **Room names** below.
13
13
 
14
14
  ## Connection
15
15
 
@@ -59,35 +59,25 @@ SQL
59
59
  `FORMAT PrettyCompact` for display; `FORMAT JSONEachRow` when you want to parse
60
60
  the rows yourself.
61
61
 
62
- ## Room names — always suffixed
62
+ ## Room names — plain, shared tables
63
63
 
64
- Every member owns their own rooms, named for their ClickHouse user:
65
- `messages_<you>`, `sessions_<you>`, `tool_calls_<you>`. There are no unsuffixed rooms;
66
- the bare names fail with `UNKNOWN_TABLE` rather than returning nothing. You hold no grant
67
- on anyone else's rooms, so isolation is not something a query can work around.
64
+ The house's rooms are three plain tables: `messages`, `sessions`, `tool_calls` — resolved
65
+ by the connection's database (`MEMHOUSE_DB`), not by who is asking. Everyone in the house
66
+ writes into the same tables; `user_id` (stamped by the server) says whose row it is and
67
+ `host` says which machine shipped it. Search the whole house by default; add
68
+ `AND user_id = '<name>'` only when the user asks for one person's sessions.
68
69
 
69
- Ask the server your name once, then **type the literal room name into your SQL** — do not
70
- reference a shell variable inside the query:
71
-
72
- ```bash
73
- curl -sS --fail-with-body --user "$MEMHOUSE_USER:${MEMHOUSE_PASSWORD:-}" \
74
- --data-binary "SELECT currentUser() FORMAT TabSeparated" \
75
- "$MEMHOUSE_URL/?readonly=1"
76
- # prints e.g. alice → your rooms are messages_alice, sessions_alice, tool_calls_alice
77
- ```
78
-
79
- **Keep the SQL heredoc quoted (`<<'SQL'`) and put the real name in the text.** This used to
80
- say "substitute it into every table name below" and show `messages_$MEM_ME`, which cannot
81
- expand inside a quoted heredoc — so the agent unquotes it, and then the SEARCH TERMS expand
82
- too. Both instances driving this skill did exactly that, unprompted, on the first attempt.
83
- Measured consequences, from `system.query_log`:
70
+ **Keep the SQL heredoc quoted (`<<'SQL'`).** This skill once told the agent to substitute
71
+ a shell variable into the table names, which cannot expand inside a quoted heredoc — so
72
+ the agent unquotes it, and then the SEARCH TERMS expand too. Both instances driving this
73
+ skill did exactly that, unprompted, on the first attempt. Measured consequences, from
74
+ `system.query_log`:
84
75
 
85
76
  - a term containing `$home` became the empty string, so `LIKE '%%'` matched **every row**
86
77
  and reported hits with exit 0 — silently wrong, not an error;
87
78
  - a term containing a backtick **executed the command inside it**.
88
79
 
89
- A user's search term is arbitrary text. With the heredoc quoted and the room name written
90
- out, neither can happen.
80
+ A user's search term is arbitrary text. With the heredoc quoted, neither can happen.
91
81
 
92
82
  **There is no `sessions_v` object.** The session rollup is a saved query over those same
93
83
  rooms — `memhouse sessions-query` prints it for whoever you are connected as, ready to
@@ -98,13 +88,10 @@ both rooms and a trailing `SETTINGS join_use_nulls = 1`, so it is correct wherev
98
88
  paste it. `final=1` on the connection still matters for reads of the rooms THEMSELVES,
99
89
  which carry no FINAL of their own.
100
90
 
101
- If you hold `SELECT` on them, `all_messages` / `all_sessions` / `all_tool_calls` read
102
- across every member at once, narrowed to whatever grants you actually have — a Merge room
103
- reduces to the rooms the caller can read, so it fails closed rather than denying outright.
104
91
 
105
92
  ## How to search (the FTS columns)
106
93
 
107
- `messages_<you>` carries two MATERIALIZED lowercase copies of `text`, each with a
94
+ `messages` carries two MATERIALIZED lowercase copies of `text`, each with a
108
95
  text index — search those, display `text`:
109
96
 
110
97
  - `text_ngram` (ngram index) → substring match: `text_ngram LIKE '%term%'`
@@ -120,8 +107,8 @@ phrase → one `LIKE` clause for the whole phrase.
120
107
  SELECT m.ts, m.source, m.project, m.role, m.session_id,
121
108
  s.name AS session,
122
109
  substring(m.text, 1, 300) AS snippet
123
- FROM messages_<you> AS m
124
- LEFT JOIN sessions_<you> AS s USING (session_id)
110
+ FROM messages AS m
111
+ LEFT JOIN sessions AS s USING (session_id, user_id)
125
112
  WHERE m.text_ngram LIKE '%postgres%' AND m.text_ngram LIKE '%migration%'
126
113
  ORDER BY m.ts DESC
127
114
  LIMIT 30
@@ -132,7 +119,7 @@ FORMAT PrettyCompact
132
119
 
133
120
  ```sql
134
121
  SELECT ts, project, session_id, substring(text, 1, 200) AS snippet
135
- FROM messages_<you>
122
+ FROM messages
136
123
  WHERE hasToken(text_word, 'rls')
137
124
  AND source = 'claude-code'
138
125
  AND ts > now() - INTERVAL 14 DAY
@@ -144,12 +131,12 @@ FORMAT PrettyCompact
144
131
  ### Example 3 — which sessions mention it most (then drill in)
145
132
 
146
133
  ```sql
147
- SELECT m.session_id, any(s.name) AS session, any(m.project) AS project,
134
+ SELECT m.session_id, m.user_id, any(s.name) AS session, any(m.project) AS project,
148
135
  count() AS hits, max(m.ts) AS last_hit
149
- FROM messages_<you> AS m
150
- LEFT JOIN sessions_<you> AS s USING (session_id)
136
+ FROM messages AS m
137
+ LEFT JOIN sessions AS s USING (session_id, user_id)
151
138
  WHERE m.text_ngram LIKE '%clickhouse cache%'
152
- GROUP BY m.session_id
139
+ GROUP BY m.session_id, m.user_id
153
140
  ORDER BY hits DESC
154
141
  LIMIT 10
155
142
  FORMAT PrettyCompact
@@ -163,14 +150,14 @@ FORMAT PrettyCompact
163
150
  `cursor`, `cursor-agent`, `vscode`, `zed`, `opencode`, `gemini-cli`,
164
151
  `windsurf`, `antigravity`, `copilot-cli`, `goose`, `kiro`, …)
165
152
  - Skip subagent noise → `AND NOT m.is_subagent`
166
- - Only user/assistant text lives in `messages_<you>`; tool invocations are in
167
- `tool_calls_<you>` (`tool_name`, `args`) — search `args ILIKE '%term%'` there
153
+ - Only user/assistant text lives in `messages`; tool invocations are in
154
+ `tool_calls` (`tool_name`, `args`) — search `args ILIKE '%term%'` there
168
155
  only if the user explicitly wants tool calls searched.
169
156
 
170
157
  ## Output
171
158
 
172
159
  Summarize hits grouped by session (name, source, project, date, 1–2 best
173
160
  snippets each) and give the total hit count. Offer to pull the full transcript
174
- of the best session (`SELECT role, text FROM messages_<you> WHERE session_id = '…'
161
+ of the best session (`SELECT role, text FROM messages WHERE session_id = '…'
175
162
  ORDER BY seq`). If zero hits, retry with broader/fewer terms and report the
176
- corpus size (`SELECT count() FROM messages_<you>`).
163
+ corpus size (`SELECT count() FROM messages`).
@@ -63,35 +63,25 @@ FORMAT PrettyCompact
63
63
  SQL
64
64
  ```
65
65
 
66
- ## Room names — always suffixed
66
+ ## Room names — plain, shared tables
67
67
 
68
- Every member owns their own rooms, named for their ClickHouse user:
69
- `messages_<you>`, `sessions_<you>`, `tool_calls_<you>`. There are no unsuffixed rooms;
70
- the bare names fail with `UNKNOWN_TABLE` rather than returning nothing. You hold no grant
71
- on anyone else's rooms, so isolation is not something a query can work around.
68
+ The house's rooms are three plain tables: `messages`, `sessions`, `tool_calls` — resolved
69
+ by the connection's database (`MEMHOUSE_DB`), not by who is asking. Everyone in the house
70
+ writes into the same tables; `user_id` (stamped by the server) says whose row it is and
71
+ `host` says which machine shipped it. Search the whole house by default; add
72
+ `AND user_id = '<name>'` only when the user asks for one person's sessions.
72
73
 
73
- Ask the server your name once, then **type the literal room name into your SQL** — do not
74
- reference a shell variable inside the query:
75
-
76
- ```bash
77
- curl -sS --fail-with-body --user "$MEMHOUSE_USER:${MEMHOUSE_PASSWORD:-}" \
78
- --data-binary "SELECT currentUser() FORMAT TabSeparated" \
79
- "$MEMHOUSE_URL/?readonly=1"
80
- # prints e.g. alice → your rooms are messages_alice, sessions_alice, tool_calls_alice
81
- ```
82
-
83
- **Keep the SQL heredoc quoted (`<<'SQL'`) and put the real name in the text.** This used to
84
- say "substitute it into every table name below" and show `messages_$MEM_ME`, which cannot
85
- expand inside a quoted heredoc — so the agent unquotes it, and then the SEARCH TERMS expand
86
- too. Both instances driving this skill did exactly that, unprompted, on the first attempt.
87
- Measured consequences, from `system.query_log`:
74
+ **Keep the SQL heredoc quoted (`<<'SQL'`).** This skill once told the agent to substitute
75
+ a shell variable into the table names, which cannot expand inside a quoted heredoc — so
76
+ the agent unquotes it, and then the SEARCH TERMS expand too. Both instances driving this
77
+ skill did exactly that, unprompted, on the first attempt. Measured consequences, from
78
+ `system.query_log`:
88
79
 
89
80
  - a term containing `$home` became the empty string, so `LIKE '%%'` matched **every row**
90
81
  and reported hits with exit 0 — silently wrong, not an error;
91
82
  - a term containing a backtick **executed the command inside it**.
92
83
 
93
- A user's search term is arbitrary text. With the heredoc quoted and the room name written
94
- out, neither can happen.
84
+ A user's search term is arbitrary text. With the heredoc quoted, neither can happen.
95
85
 
96
86
  **There is no `sessions_v` object.** The session rollup is a saved query over those same
97
87
  rooms — `memhouse sessions-query` prints it for whoever you are connected as, ready to
@@ -105,14 +95,11 @@ grows); without `join_use_nulls` a session with no messages reports `total_msgs
105
95
  rather than 0.
106
96
 
107
97
  **If the `memhouse` binary is not on PATH**, you cannot print the rollup — the skills are
108
- installable on their own. Query the rooms directly instead: `sessions_<you>` for metadata
109
- and `messages_<you>` for counts, each read `FINAL`, joined on `session_id` and `user_id`.
98
+ installable on their own. Query the rooms directly instead: `sessions` for metadata
99
+ and `messages` for counts, each read `FINAL`, joined on `session_id` and `user_id`.
110
100
  Prefer the binary when it is there; a rollup you assemble by hand and one printed by a
111
101
  DIFFERENT memhouse version are the two ways this goes quietly wrong.
112
102
 
113
- If you hold `SELECT` on them, `all_messages` / `all_sessions` / `all_tool_calls` read
114
- across every member at once, narrowed to whatever grants you actually have — a Merge room
115
- reduces to the rooms the caller can read, so it fails closed rather than denying outright.
116
103
 
117
104
  ## Default listing
118
105
 
@@ -173,5 +160,5 @@ FORMAT PrettyCompact
173
160
 
174
161
  A compact table: when, name (or first prompt), source, host, project, msgs,
175
162
  tokens. Offer to pull a full transcript next
176
- (`SELECT role, text FROM messages_<you> WHERE session_id = '…' ORDER BY seq`) or to
163
+ (`SELECT role, text FROM messages WHERE session_id = '…' ORDER BY seq`) or to
177
164
  run /memhouse:search for a specific quote.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: sql
3
- description: Run free-form read-only SQL against memhouse conversation memory (typed sessions/messages/tool_calls rooms on ClickHouse, one set per member). Use for ad-hoc analytics the other memhouse skills don't cover — token spend, model/editor usage, tool rankings, activity heatmaps, busiest days/projects, cache-hit ratios, or any custom question over conversation data.
3
+ description: Run free-form read-only SQL against memhouse conversation memory (typed sessions/messages/tool_calls tables on ClickHouse, shared by the house). Use for ad-hoc analytics the other memhouse skills don't cover — token spend, model/editor usage, tool rankings, activity heatmaps, busiest days/projects, cache-hit ratios, or any custom question over conversation data.
4
4
  user-invocable: true
5
5
  argument-hint: "<question or SQL>"
6
6
  allowed-tools: Bash
@@ -73,35 +73,25 @@ SQL
73
73
 
74
74
  `FORMAT PrettyCompact` for display, `FORMAT JSONEachRow` to parse.
75
75
 
76
- ## Room names — always suffixed
76
+ ## Room names — plain, shared tables
77
77
 
78
- Every member owns their own rooms, named for their ClickHouse user:
79
- `messages_<you>`, `sessions_<you>`, `tool_calls_<you>`. There are no unsuffixed rooms;
80
- the bare names fail with `UNKNOWN_TABLE` rather than returning nothing. You hold no grant
81
- on anyone else's rooms, so isolation is not something a query can work around.
78
+ The house's rooms are three plain tables: `messages`, `sessions`, `tool_calls` — resolved
79
+ by the connection's database (`MEMHOUSE_DB`), not by who is asking. Everyone in the house
80
+ writes into the same tables; `user_id` (stamped by the server) says whose row it is and
81
+ `host` says which machine shipped it. Filter with `WHERE user_id = '<name>'` when you want
82
+ one person, or leave it off for the whole house.
82
83
 
83
- Ask the server your name once, then **type the literal room name into your SQL** — do not
84
- reference a shell variable inside the query:
85
-
86
- ```bash
87
- curl -sS --fail-with-body --user "$MEMHOUSE_USER:${MEMHOUSE_PASSWORD:-}" \
88
- --data-binary "SELECT currentUser() FORMAT TabSeparated" \
89
- "$MEMHOUSE_URL/?readonly=1"
90
- # prints e.g. alice → your rooms are messages_alice, sessions_alice, tool_calls_alice
91
- ```
92
-
93
- **Keep the SQL heredoc quoted (`<<'SQL'`) and put the real name in the text.** This used to
94
- say "substitute it into every table name below" and show `messages_$MEM_ME`, which cannot
95
- expand inside a quoted heredoc — so the agent unquotes it, and then the SEARCH TERMS expand
96
- too. Both instances driving this skill did exactly that, unprompted, on the first attempt.
97
- Measured consequences, from `system.query_log`:
84
+ **Keep the SQL heredoc quoted (`<<'SQL'`).** This skill once told the agent to substitute
85
+ a shell variable into the table names, which cannot expand inside a quoted heredoc — so
86
+ the agent unquotes it, and then the SEARCH TERMS expand too. Both instances driving this
87
+ skill did exactly that, unprompted, on the first attempt. Measured consequences, from
88
+ `system.query_log`:
98
89
 
99
90
  - a term containing `$home` became the empty string, so `LIKE '%%'` matched **every row**
100
91
  and reported hits with exit 0 — silently wrong, not an error;
101
92
  - a term containing a backtick **executed the command inside it**.
102
93
 
103
- A user's search term is arbitrary text. With the heredoc quoted and the room name written
104
- out, neither can happen.
94
+ A user's search term is arbitrary text. With the heredoc quoted, neither can happen.
105
95
 
106
96
  **There is no `sessions_v` object.** The session rollup is a saved query over those same
107
97
  rooms — `memhouse sessions-query` prints it for whoever you are connected as, ready to
@@ -115,22 +105,19 @@ grows); without `join_use_nulls` a session with no messages reports `total_msgs
115
105
  rather than 0.
116
106
 
117
107
  **If the `memhouse` binary is not on PATH**, you cannot print the rollup — the skills are
118
- installable on their own. Query the rooms directly instead: `sessions_<you>` for metadata
119
- and `messages_<you>` for counts, each read `FINAL`, joined on `session_id` and `user_id`.
108
+ installable on their own. Query the rooms directly instead: `sessions` for metadata and
109
+ `messages` for counts, each read `FINAL`, joined on `session_id` **and `user_id`** — the
110
+ tables are shared, and joining on `session_id` alone can merge two housemates' rows.
120
111
  Prefer the binary when it is there; a rollup you assemble by hand and one printed by a
121
112
  DIFFERENT memhouse version are the two ways this goes quietly wrong.
122
113
 
123
- If you hold `SELECT` on them, `all_messages` / `all_sessions` / `all_tool_calls` read
124
- across every member at once, narrowed to whatever grants you actually have — a Merge room
125
- reduces to the rooms the caller can read, so it fails closed rather than denying outright.
126
-
127
114
  ## Schema (the house)
128
115
 
129
116
  | Object | Kind | Columns |
130
117
  |---|---|---|
131
- | `sessions_<you>` | table, 1 row/session | `session_id, source, host, name, mode, folder, project, git_branch, created_at, last_updated_at, message_count, path, extra JSON, user_id, ingested_at` |
132
- | `messages_<you>` | table, 1 row/message | `session_id, seq, source, host, ts, role, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, text, project, folder, is_subagent, extra JSON, line_hash, user_id, ingested_at` + FTS columns `text_ngram`/`text_word` (lowercased; see /memhouse:search) |
133
- | `tool_calls_<you>` | table, 1 row/tool call | `session_id, seq, idx, source, host, tool_name, args, ts, project, folder, user_id, ingested_at` |
118
+ | `sessions` | table, 1 row/session | `session_id, source, host, name, mode, folder, project, git_branch, created_at, last_updated_at, message_count, path, extra JSON, user_id, ingested_at` |
119
+ | `messages` | table, 1 row/message | `session_id, seq, source, host, ts, role, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, text, project, folder, is_subagent, extra JSON, line_hash, user_id, ingested_at` + FTS columns `text_ngram`/`text_word` (lowercased; see /memhouse:search) |
120
+ | `tool_calls` | table, 1 row/tool call | `session_id, seq, idx, source, host, tool_name, args, ts, project, folder, user_id, ingested_at` |
134
121
  | the rollup | **saved query**, not an object — `memhouse sessions-query` prints it | `session_id, source, host, name, mode, folder, project, git_branch, user_id, created_at, last_updated_at, started, ended, duration_sec, total_msgs, user_msgs, assistant_msgs, subagent_msgs, models, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, user_chars, assistant_chars, first_prompt` |
135
122
 
136
123
  Notes: `source` = editor id (`claude-code`, `codex`, `cursor`, `cursor-agent`,
@@ -147,7 +134,7 @@ Token spend by model:
147
134
  ```sql
148
135
  SELECT model, sum(input_tokens) AS in_tok, sum(output_tokens) AS out_tok,
149
136
  sum(cache_read_tokens) AS cache_read
150
- FROM messages_<you>
137
+ FROM messages
151
138
  WHERE model NOT IN ('', '<synthetic>')
152
139
  GROUP BY model
153
140
  ORDER BY out_tok DESC
@@ -158,7 +145,7 @@ Which tools do I use most (memhouse exclusive — memory-house has no tool table
158
145
 
159
146
  ```sql
160
147
  SELECT tool_name, count() AS calls, uniqExact(session_id) AS sessions
161
- FROM tool_calls_<you>
148
+ FROM tool_calls
162
149
  GROUP BY tool_name
163
150
  ORDER BY calls DESC
164
151
  LIMIT 20
@@ -169,7 +156,7 @@ Busiest days, last two weeks:
169
156
 
170
157
  ```sql
171
158
  SELECT toDate(ts) AS day, uniqExact(session_id) AS sessions, count() AS msgs
172
- FROM messages_<you>
159
+ FROM messages
173
160
  WHERE ts > now() - INTERVAL 14 DAY
174
161
  GROUP BY day
175
162
  ORDER BY day DESC
@@ -180,7 +167,7 @@ Activity by editor this week / subagent share:
180
167
 
181
168
  ```sql
182
169
  SELECT source, count() AS msgs, countIf(is_subagent) AS subagent_msgs
183
- FROM messages_<you>
170
+ FROM messages
184
171
  WHERE ts > now() - INTERVAL 7 DAY
185
172
  GROUP BY source
186
173
  ORDER BY msgs DESC
package/memhouse/host.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Which machine shipped this row.
2
2
  //
3
3
  // All of one member's machines ship into that member's rooms — that is the point of the
4
- // per-member layout — and the `host` column is the only thing telling them apart. So the
4
+ // shared-house layout — and the `host` column is the only thing telling them apart. So the
5
5
  // value has to be BOTH unique per machine and stable across that machine's lifetime.
6
6
  //
7
7
  // It used to be derived, `sha256(hostname|platform|arch)`, and that fails in both
@@ -0,0 +1,74 @@
1
+ # The house — a database, three shared rooms
2
+
3
+ **A house is a ClickHouse database.** Any database, named whatever its people name it —
4
+ `polat`, `team_a`, even `default`. Its rooms are three plain tables: `sessions`,
5
+ `messages`, `tool_calls`. There is nothing else to provision: no per-member tables, no
6
+ Merge rooms, no views, no settings profile.
7
+
8
+ **Everyone in the house writes into the same tables**, each with their own credential,
9
+ and two columns say where every row came from:
10
+
11
+ | Column | Meaning | Comes from |
12
+ |---|---|---|
13
+ | `user_id` | who wrote it | `String MATERIALIZED currentUser()` — stamped by the server, unforgeable by clients. `async_insert = 0 CONST` is pinned on each user (`ADD SETTING`, which merges — a bare `SETTINGS` clause replaces the user's whole list) because an async flush stores the stamp as the empty string (measured on 25.11.9.34) |
14
+ | `host` | which machine | the install's fingerprint (`../host.js`) — random, minted once, so two machines with the same hostname cannot collide and renaming one cannot split its history |
15
+
16
+ `WHERE user_id = 'alice'` is one person. `WHERE host = 'macbook-4127a95b'` is one
17
+ machine. No filter is the whole house — which is what a team dashboard wants.
18
+
19
+ ## Joining a house
20
+
21
+ The whole tenancy model is the database boundary:
22
+
23
+ ```sql
24
+ CREATE USER alice IDENTIFIED BY '…';
25
+ GRANT ALL ON team_a.* TO alice;
26
+ ALTER USER alice ADD SETTING async_insert = 0 CONST;
27
+ ```
28
+
29
+ `ALL` on your own house reaches nothing outside it, and it is what lets alice's own
30
+ shipper create and evolve the rooms (`ship.js --ensure-schema`) — no admin has to run
31
+ memhouse code. Joining a ClickHouse that happens to run a kernel is the same three
32
+ statements: a database and a credential, nothing else.
33
+
34
+ The model is **collaborative**: housemates trust each other with the house. Narrower
35
+ grants can be layered on later if a team wants them; the isolation mechanism between
36
+ groups that do NOT trust each other is a separate house.
37
+
38
+ ## One user on two machines, same session id
39
+
40
+ Session ids are minted per machine, so this needs a synced home directory to happen at
41
+ all — but when it does (the same `~/.claude` on two synced machines), both shippers ship
42
+ the same `(session_id, user_id)`. The keys deliberately do NOT include `host`, so the
43
+ rows collapse to the LAST shipper's copy: one logical session stays one session, with
44
+ `host` recording who shipped it most recently. Keying on host instead would duplicate
45
+ every such session and double what the rollup counts. An interleaved clear+insert
46
+ between the two shippers can transiently drop rows; the next pass's skip predicate sees
47
+ the count mismatch and re-ships — self-healing, at the price of churn. Truly parallel
48
+ shippers per user are the deferred "ephemeral fingerprint" expert feature, not today's.
49
+
50
+ ## The rollup is a saved query, not an object
51
+
52
+ `sessions_v` is SQL text (house.js `sessionsRollup`), substituted into the same
53
+ `FROM … AS c` position a view name would occupy and run under the caller's own
54
+ credential. It groups by `(session_id, user_id)` — two housemates' rows never merge,
55
+ even on a colliding session_id — and carries its own `FINAL` and
56
+ `SETTINGS join_use_nulls = 1`, so it is correct wherever it is pasted.
57
+
58
+ ## What this replaced, and why
59
+
60
+ The per-member layout: rooms named `sessions_<member>` with a narrowed per-member grant
61
+ set, Merge rooms (`all_sessions`…) for team reads, and isolation between members of one
62
+ database. Retired because every piece of it was cost paid for an adversarial model the
63
+ product does not have:
64
+
65
+ - suffixed names made every client resolve its room names before writing SQL, and
66
+ `FROM messages` a documented trap the acceptance suite had to test for;
67
+ - the Merge rooms existed only to undo the splitting the suffixes created;
68
+ - the narrowed grant set existed to stop housemates attacking each other — but
69
+ housemates are collaborators, and groups that are not do not share a database.
70
+
71
+ The origin story that survives: rows carry `origin` (in the messages/tool_calls sorting
72
+ keys, deliberately NOT in sessions'), the shipper's clear binds
73
+ `(session_id, user_id, origin='ship')`, and imported history survives every re-ship.
74
+ See the comments in `schema.sql.tpl` — they are the authority on the keys.