memhouse 0.7.1 → 0.9.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.
Files changed (37) hide show
  1. package/README.md +79 -43
  2. package/bin/memhouse.js +153 -147
  3. package/editors/antigravity.js +7 -15
  4. package/editors/cursor.js +18 -24
  5. package/editors/goose.js +9 -21
  6. package/editors/index.js +19 -70
  7. package/editors/opencode.js +6 -15
  8. package/editors/sqlite.js +74 -0
  9. package/editors/windsurf.js +3 -3
  10. package/editors/zed.js +10 -15
  11. package/memhouse/COMPETITION.md +9 -5
  12. package/memhouse/DESIGN.md +29 -30
  13. package/memhouse/RESUME-AND-UPDATE.md +7 -10
  14. package/memhouse/delivery/AGENT-INSTALL.md +15 -13
  15. package/memhouse/delivery/PROMPT.md +7 -9
  16. package/memhouse/delivery/kernel-install.md +18 -33
  17. package/memhouse/delivery/plugin/skills/search/SKILL.md +25 -38
  18. package/memhouse/delivery/plugin/skills/sessions/SKILL.md +15 -28
  19. package/memhouse/delivery/plugin/skills/sql/SKILL.md +23 -36
  20. package/memhouse/host.js +1 -1
  21. package/memhouse/house/HOUSE.md +74 -0
  22. package/memhouse/house/house.js +170 -0
  23. package/memhouse/{per-member/schema-member.sql.tpl → house/schema.sql.tpl} +8 -11
  24. package/memhouse/resume.js +1 -1
  25. package/memhouse/self-update.js +4 -4
  26. package/memhouse/server/queries.js +8 -2
  27. package/memhouse/shipper/ship.js +30 -37
  28. package/package.json +2 -6
  29. package/memhouse/per-member/INSTALL.md +0 -228
  30. package/memhouse/per-member/PLAN.md +0 -174
  31. package/memhouse/per-member/PROVISIONING.md +0 -177
  32. package/memhouse/per-member/README.md +0 -48
  33. package/memhouse/per-member/SCHEMA.md +0 -120
  34. package/memhouse/per-member/SHARING.md +0 -149
  35. package/memhouse/per-member/provision.js +0 -297
  36. package/memhouse/per-member/rooms.js +0 -252
  37. package/memhouse/per-member/schema-merge.sql.tpl +0 -24
@@ -1,252 +0,0 @@
1
- // Room-name resolution.
2
- //
3
- // Every member owns their rooms, named TYPE-FIRST: `sessions_<member>`,
4
- // `messages_<member>`, `tool_calls_<member>`. Type-first is what lets the Merge rooms
5
- // anchor on a fixed room type (`^sessions_`) and never match themselves.
6
- //
7
- // THIS IS THE ONLY LAYOUT. There was a shared one — three rooms named `sessions`,
8
- // `messages`, `tool_calls`, with row policies narrowing each caller to their own rows —
9
- // and it is gone. A shared read is a Merge room plus a GRANT, which is strictly less
10
- // machinery than a policy that has to be right on every table and every read path; and
11
- // isolation by absent grant fails closed, where a row policy fails open the moment one is
12
- // missing.
13
- //
14
- // The member is never supplied by the caller — it is read back from the server with
15
- // currentUser(), the same value the rooms' user_id column is stamped with. A client that
16
- // could name its own member could write into someone else's rooms.
17
- //
18
- // `sessions_v` IS A SAVED QUERY, NOT A ROOM. It was briefly a stored view, which cost a
19
- // name inside the `^sessions_` namespace the Merge rooms select on (silently merged into
20
- // `all_sessions`, doubling every count), a fourth grant per member, and a fourth object to
21
- // provision, roll forward and drop. A saved query is substituted with the caller's own
22
- // room names and runs under the caller's credential, so it inherits their grants and
23
- // raises no view-ownership question at all.
24
-
25
- const ROOM_TYPES = ['sessions', 'messages', 'tool_calls'];
26
-
27
- /**
28
- * What a member is granted on their own rooms. ONE definition, because there are two paths
29
- * that provision a house — provision.js and `install --print-sql` — and when only the first
30
- * was narrowed the second went on emitting `GRANT ALL` for a release. GRANT ALL includes
31
- * CREATE TABLE **on the member's own room name**, so a member owns the name rather than the
32
- * data and can replace the room with a Merge over everyone's, doubling other members' rows
33
- * in the team room.
34
- *
35
- * ALTER UPDATE and ALTER DELETE both: `DELETE FROM` is a lightweight delete implemented as
36
- * `ALTER TABLE … UPDATE _row_exists = 0`, and which of the two a server demands varies by
37
- * version. ALTER ADD COLUMN for ensureSchema's rollout. Nothing that defines an object.
38
- */
39
- const MEMBER_PRIVS = 'SELECT, INSERT, ALTER UPDATE, ALTER DELETE, ALTER ADD COLUMN, OPTIMIZE';
40
-
41
- /**
42
- * Resource ceilings for a member, as a settings profile.
43
- *
44
- * Here for the same reason MEMBER_PRIVS is here: there are two paths that provision a
45
- * house — provision.js and `install --print-sql` — and a change made to one of them and
46
- * not the other has now happened eight times. Anything a member is GIVEN belongs in this
47
- * file, so both paths read the same definition.
48
- *
49
- * MIN..MAX, not CONST and not MAX alone.
50
- *
51
- * A plain default is advisory — a member writes `SETTINGS max_memory_usage = …` on their
52
- * query and overrides it. MAX alone is not enough either: in ClickHouse **0 means
53
- * unlimited** for these, and 0 satisfies any MAX, so `SETTINGS max_memory_usage = 0`
54
- * removed the ceiling entirely. MIN 1 closes that.
55
- *
56
- * CONST closes it too and was tried first — but CONST refuses EVERY change, including the
57
- * server's own. `rawQuery` in memhouse/server/queries.js pins max_result_rows and
58
- * max_execution_time to protect the dashboard from an unbounded query, and under CONST
59
- * those pins were refused with Code 452, so /api/query returned an error for every input
60
- * on any house provisioned after that change. A ceiling has to stop a member RAISING the
61
- * limit while still letting anyone LOWER it.
62
- *
63
- * max_memory_usage is per QUERY; max_memory_usage_for_user bounds the member across
64
- * concurrent queries, without which N parallel queries x the per-query ceiling is
65
- * unbounded.
66
- *
67
- * Limits are generous on purpose: the point is to stop one member exhausting a shared
68
- * server, not to make an honest full re-ship fail — a 20M-row scan runs inside them.
69
- *
70
- * KNOWN GAP, deliberately not closed here: ALTER UPDATE, ALTER DELETE and OPTIMIZE are in
71
- * MEMBER_PRIVS and execute in the background merge pool, where these per-query settings do
72
- * not apply. A member looping `OPTIMIZE … FINAL` on a large room can still load the box.
73
- * Bounding that needs server-level merge-pool configuration, which is the operator's, not
74
- * something a per-member profile can express.
75
- */
76
- /**
77
- * The global-install command that will actually work HERE.
78
- *
79
- * Every site that printed `npm install -g memhouse --allow-scripts=better-sqlite3` printed
80
- * a line that fails with EACCES on any distro-packaged Node, because the global prefix is
81
- * root-owned — measured on a clean Ubuntu machine, exit 243, including as step 1 of
82
- * `prompt --install`, which advertises itself as rendered for this machine. The README
83
- * learned this; eight code sites had not.
84
- *
85
- * `sudo` is right for exactly one of the two causes and makes the other worse, and what
86
- * tells them apart is who owns the prefix — so look, rather than guess.
87
- */
88
- function installCommand() {
89
- const base = 'npm install -g memhouse --allow-scripts=better-sqlite3';
90
- try {
91
- const { execFileSync } = require('child_process');
92
- const prefix = execFileSync('npm', ['prefix', '-g'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
93
- const st = require('fs').statSync(prefix);
94
- if (st.uid !== process.getuid()) return `sudo ${base}`;
95
- } catch { /* npm not on PATH, or an unreadable prefix: fall through to the plain form */ }
96
- return base;
97
- }
98
-
99
- const MEMBER_PROFILE = 'memhouse_member';
100
- const MEMBER_PROFILE_SETTINGS = [
101
- 'max_memory_usage = 8000000000 MIN 1 MAX 8000000000',
102
- 'max_memory_usage_for_user = 16000000000 MIN 1 MAX 16000000000',
103
- 'max_execution_time = 600 MIN 1 MAX 600',
104
- 'max_threads = 16 MIN 1 MAX 16',
105
- 'max_result_rows = 10000000 MIN 1 MAX 10000000',
106
- // Provenance, pinned server-side rather than trusted to every client.
107
- //
108
- // `user_id MATERIALIZED currentUser()` is what records who wrote a row — but a
109
- // MATERIALIZED column is computed during the INSERT, and an ASYNC insert flushes
110
- // outside that context: measured on 25.11.9.34, `async_insert=1` stores user_id as the
111
- // EMPTY STRING while a sync insert on the same table stamps correctly. An unattributed
112
- // row is invisible to every identity-bound path at once, including its own owner's
113
- // `reset`.
114
- //
115
- // The shipper already passes async_insert=0 on every insert, so memhouse itself was
116
- // never the risk. The risk is everything ELSE holding a member credential — an agent
117
- // following the /memhouse:sql skill, a migration script, a psql-habit one-liner. CONST
118
- // makes the house refuse (SETTING_CONSTRAINT_VIOLATION) instead of silently accepting
119
- // rows nobody owns, which is the difference between a guarantee and a convention.
120
- // `doctor` still checks for blank user_id — that stays, as the detector for rows
121
- // written before this profile existed.
122
- 'async_insert = 0 CONST',
123
- ].join(', ');
124
-
125
- // ClickHouse usernames are permissive; room names are not. Refuse anything that would
126
- // need quoting or could change how a Merge regex or a name-splitter reads.
127
- function assertUsableMember(member) {
128
- if (typeof member !== 'string' || !/^[A-Za-z][A-Za-z0-9_]*$/.test(member)) {
129
- throw new Error(`cannot build room names for user '${member}': expected [A-Za-z][A-Za-z0-9_]*`);
130
- }
131
- // The reservation lives HERE, not only in the CLI, because provision.js is the admin
132
- // entry point INSTALL.md sends you to for a second member — and it went as far as
133
- // creating sessions_root/messages_root/tool_calls_root before dying on the grant,
134
- // leaving three orphan rooms nothing removes. Case-insensitive: Root and ROOT are the
135
- // same account. `default` is deliberately NOT reserved — a poor member name, but a real
136
- // one that existing houses are built on.
137
- if (member.toLowerCase() === 'root') {
138
- throw new Error("'root' cannot be a member: it is a container artefact, not a person");
139
- }
140
- }
141
-
142
- /**
143
- * The session rollup, as SQL text rather than a name.
144
- *
145
- * Two properties are load bearing: the join is a LEFT JOIN, so a session with no messages
146
- * still appears with zero aggregates instead of vanishing from every count; and
147
- * `join_use_nulls = 1` makes the unmatched message columns NULL so `coalesce` yields true
148
- * zeros rather than counting the placeholder row.
149
- *
150
- * BOTH ARE NOW CARRIED BY THE TEXT ITSELF — `FINAL` on each room and a trailing
151
- * `SETTINGS join_use_nulls = 1`. It used to depend on the caller passing `final=1` and
152
- * `join_use_nulls=1`, on the belief that a subquery cannot carry its own SETTINGS. It can
153
- * (verified on 26.7 and 25.11), and the belief was expensive: every consumer that forgot
154
- * the settings silently counted each message once per undeleted ReplacingMergeTree
155
- * version. Measured right after a `ship --full` — 2x, and 3x three ships later. It does
156
- * not self-heal; the error grows with each pass until a merge happens to collapse the
157
- * parts. `memhouse sessions-query > q.sql` lost the warning entirely, because it was
158
- * printed on stderr as a SQL comment that never reached the SQL.
159
- *
160
- * Passing final=1 as well is harmless, so READ_SETTINGS stays as it is for direct room
161
- * reads, which still need it.
162
- */
163
- function sessionsRollup({ sessions, messages }) {
164
- return `(
165
- SELECT
166
- s.session_id AS session_id,
167
- any(s.source) AS source,
168
- any(s.host) AS host,
169
- any(s.name) AS name,
170
- any(s.mode) AS mode,
171
- any(s.folder) AS folder,
172
- any(s.project) AS project,
173
- any(s.git_branch) AS git_branch,
174
- s.user_id AS user_id,
175
- any(s.created_at) AS created_at,
176
- any(s.last_updated_at) AS last_updated_at,
177
- min(m.ts) AS started,
178
- max(m.ts) AS ended,
179
- coalesce(dateDiff('second', min(m.ts), max(m.ts)), 0) AS duration_sec,
180
- count(m.seq) AS total_msgs,
181
- countIf(m.role = 'user') AS user_msgs,
182
- countIf(m.role = 'assistant') AS assistant_msgs,
183
- countIf(m.is_subagent) AS subagent_msgs,
184
- groupUniqArrayIf(m.model, m.model NOT IN ('', '<synthetic>')) AS models,
185
- coalesce(sum(m.input_tokens), 0) AS input_tokens,
186
- coalesce(sum(m.output_tokens), 0) AS output_tokens,
187
- coalesce(sum(m.cache_read_tokens), 0) AS cache_read_tokens,
188
- coalesce(sum(m.cache_write_tokens), 0) AS cache_write_tokens,
189
- coalesce(sumIf(length(m.text), m.role = 'user'), 0) AS user_chars,
190
- coalesce(sumIf(length(m.text), m.role = 'assistant'), 0) AS assistant_chars,
191
- coalesce(substring(argMinIf(m.text, m.seq, m.role = 'user' AND m.text != ''), 1, 200), '') AS first_prompt
192
- FROM ${sessions} AS s FINAL
193
- LEFT JOIN ${messages} AS m FINAL ON m.session_id = s.session_id AND m.user_id = s.user_id
194
- GROUP BY s.session_id, s.user_id
195
- SETTINGS join_use_nulls = 1
196
- )`;
197
- }
198
-
199
- /**
200
- * The settings every read needs. `final` collapses ReplacingMergeTree versions;
201
- * `join_use_nulls` is what the rollup's coalesce depends on.
202
- */
203
- const READ_SETTINGS = { final: 1, join_use_nulls: 1 };
204
-
205
- /**
206
- * Names, given the member the server reported. Shared by every caller so the two
207
- * transports in the tree (@clickhouse/client and the CLI's raw fetch) cannot drift into
208
- * two different naming rules.
209
- *
210
- * `sessions_v` is the rollup as SQL text, usable in the same `FROM ... AS c` position a
211
- * view name would occupy — which is what lets the read layer treat it as just another
212
- * resolved name.
213
- */
214
- function roomNames(member, user = member) {
215
- assertUsableMember(member);
216
- const out = { member, user };
217
- for (const t of ROOM_TYPES) out[t] = `${t}_${member}`;
218
- out.sessions_v = sessionsRollup(out);
219
- return out;
220
- }
221
-
222
- async function currentUser(client) {
223
- const rs = await client.query({ query: 'SELECT currentUser() AS u', format: 'JSONEachRow' });
224
- const rows = await rs.json();
225
- const u = rows[0] && rows[0].u;
226
- if (!u) throw new Error('could not determine currentUser()');
227
- return u;
228
- }
229
-
230
- /**
231
- * Resolve the rooms for whoever this client is connected as.
232
- * Returns { sessions, messages, tool_calls, sessions_v, member, user }.
233
- *
234
- * `user` is the identity the server reports. Writers need the value itself, not just the
235
- * name it produces — see ship.js's delete, which must BIND the user rather than call
236
- * currentUser() inside a mutation, where it is not evaluated in the caller's context.
237
- */
238
- async function resolveRooms(client) {
239
- const user = await currentUser(client);
240
- return roomNames(user, user);
241
- }
242
-
243
- /** The three Merge rooms — team-wide read paths, owner-managed. */
244
- function mergeRooms() {
245
- return { sessions: 'all_sessions', messages: 'all_messages', tool_calls: 'all_tool_calls' };
246
- }
247
-
248
- module.exports = {
249
- MEMBER_PRIVS, installCommand, MEMBER_PROFILE, MEMBER_PROFILE_SETTINGS,
250
- ROOM_TYPES, READ_SETTINGS, assertUsableMember,
251
- sessionsRollup, roomNames, currentUser, resolveRooms, mergeRooms,
252
- };
@@ -1,24 +0,0 @@
1
- -- mem — the Merge rooms. Owner-managed, created once per house.
2
- --
3
- -- Team-wide reads. Each is a regex over room names anchored on the ROOM TYPE, so it
4
- -- matches member rooms only and can never match itself (`^messages_` does not match
5
- -- `all_messages`).
6
- --
7
- -- Three measured properties this relies on (ClickHouse 26.7.1):
8
- -- * fails closed on permissions — a caller sees only the underlying rooms they hold
9
- -- grants for, with no leak and no error, so these can be granted broadly —
10
- -- * auto-discovers rooms created after them, so member onboarding needs no DDL here —
11
- -- * tolerates schema drift, returning a column's default for rooms lacking it — which
12
- -- is what makes schema rollout progressive. Merge REJECTS mutations, so a new column
13
- -- is added with ADD COLUMN, on the Merge room FIRST.
14
- --
15
- -- {{TEMPLATE_MEMBER}} is any existing member — the Merge room borrows its column list.
16
-
17
- CREATE TABLE IF NOT EXISTS all_sessions AS sessions_{{TEMPLATE_MEMBER}}
18
- ENGINE = Merge(currentDatabase(), '^sessions_');
19
-
20
- CREATE TABLE IF NOT EXISTS all_messages AS messages_{{TEMPLATE_MEMBER}}
21
- ENGINE = Merge(currentDatabase(), '^messages_');
22
-
23
- CREATE TABLE IF NOT EXISTS all_tool_calls AS tool_calls_{{TEMPLATE_MEMBER}}
24
- ENGINE = Merge(currentDatabase(), '^tool_calls_');