memhouse 0.3.1 → 0.4.2

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 +83 -6
  2. package/bin/memhouse.js +882 -40
  3. package/editors/zed.js +55 -6
  4. package/index.js +2 -2
  5. package/mem-house/COMPETITION.md +217 -0
  6. package/mem-house/DESIGN.md +61 -32
  7. package/mem-house/delivery/AGENT-INSTALL.md +23 -3
  8. package/mem-house/delivery/PROMPT.md +23 -10
  9. package/mem-house/delivery/install.sh +9 -7
  10. package/mem-house/delivery/kernel-install.md +56 -25
  11. package/mem-house/delivery/skills/memhouse-search/SKILL.md +46 -16
  12. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +38 -7
  13. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +48 -17
  14. package/mem-house/deploy.js +333 -0
  15. package/mem-house/envfile.js +49 -0
  16. package/mem-house/per-member/INSTALL.md +183 -0
  17. package/mem-house/per-member/PLAN.md +174 -0
  18. package/mem-house/per-member/PROVISIONING.md +177 -0
  19. package/mem-house/per-member/README.md +48 -0
  20. package/mem-house/per-member/SCHEMA.md +73 -0
  21. package/mem-house/per-member/SHARING.md +102 -0
  22. package/mem-house/per-member/provision.js +142 -0
  23. package/mem-house/per-member/rooms.js +132 -0
  24. package/mem-house/per-member/schema-member.sql.tpl +85 -0
  25. package/mem-house/per-member/schema-merge.sql.tpl +24 -0
  26. package/mem-house/server/queries.js +103 -53
  27. package/mem-house/service.js +400 -0
  28. package/mem-house/shipper/ship.js +61 -18
  29. package/package.json +3 -3
  30. package/pricing.js +20 -2
  31. package/pricing.json +216 -24
  32. package/public/assets/{index-CCPh7a2Z.js → index-CtH-5i95.js} +5 -5
  33. package/public/index.html +1 -1
  34. package/ui/src/App.jsx +3 -2
  35. package/ui/src/pages/SqlViewer.jsx +39 -21
  36. package/mem-house/rls.sql +0 -17
  37. package/mem-house/schema.sql +0 -145
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ // mem — provision one member's rooms and grants. Idempotent; also the schema-rollout hook.
3
+ //
4
+ // provision.js --member <name> [--merge] [--owner-grants]
5
+ //
6
+ // Run as the OWNER. Steps, all IF NOT EXISTS / re-runnable:
7
+ // 1. create the member's three rooms from schema-member.sql.tpl
8
+ // 2. grant the member their own three rooms, TWO statements each: `ALL` (not
9
+ // re-grantable), then `SELECT WITH GRANT OPTION`. The split is the point —
10
+ // `ALL` covers the mutation privileges the shipper's clear-then-insert needs
11
+ // without naming them, and attaching grant-option to `SELECT` alone makes a share
12
+ // read-only by construction. See the notes at the grants themselves.
13
+ //
14
+ // THREE ROOMS, not four objects: the session rollup is a saved query over these
15
+ // same rooms, not a stored view, so it needs no object and no grant of its own.
16
+ // 3. --merge: create/refresh the three Merge rooms, borrowing this member's columns
17
+ // 4. grant the member SELECT on whichever Merge rooms exist — a Merge reduces to the
18
+ // rooms the caller can already read, so this is what makes the team room fail
19
+ // CLOSED rather than deny outright
20
+ //
21
+ // Grants are explicit, per room, and NEVER `ON mem.*` — a member who can read the whole
22
+ // house can read every other member's rooms, which would make this a shared house with
23
+ // longer table names. No wildcards are used anywhere; the only pattern in the design is
24
+ // the Merge regex, anchored on a fixed room type.
25
+ //
26
+ // Env: MEM_URL / MEM_USER / MEM_PASSWORD / MEM_DB, falling back to MEMHOUSE_* and then
27
+ // to the shipper's own defaults — http://localhost:8123, memhouse_root, mem.
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const { createClient } = require('@clickhouse/client');
32
+ const { ROOM_TYPES, mergeRooms, assertUsableMember } = require('./rooms');
33
+
34
+ const args = process.argv.slice(2);
35
+ const flag = (n) => args.includes(`--${n}`);
36
+ const opt = (n, d = null) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : d; };
37
+
38
+ // Defaults MUST match what the shipper and CLI use, or an operator who overrides only
39
+ // the thing that differs — a password, say — silently provisions rooms in a house the
40
+ // product never reads, or fails to authenticate as a user that does not exist.
41
+ const cfg = {
42
+ url: process.env.MEM_URL || process.env.MEMHOUSE_URL || 'http://localhost:8123',
43
+ username: process.env.MEM_USER || process.env.MEMHOUSE_USER || 'memhouse_root',
44
+ password: process.env.MEM_PASSWORD || process.env.MEMHOUSE_PASSWORD || '',
45
+ database: process.env.MEM_DB || process.env.MEMHOUSE_DB || 'mem',
46
+ };
47
+
48
+ // Statements are split on ';' at end of line — the templates contain no ';' inside a
49
+ // statement body, and keeping the splitter dumb keeps the templates readable.
50
+ function statements(sql) {
51
+ return sql
52
+ .split(/;\s*$/m)
53
+ .map((s) => s.replace(/^\s*--.*$/gm, '').trim())
54
+ .filter(Boolean);
55
+ }
56
+
57
+ /** Which of the three Merge rooms actually exist — a member may be provisioned first. */
58
+ async function mergeRoomsPresent(client, database) {
59
+ const names = Object.values(mergeRooms());
60
+ const rs = await client.query({
61
+ query: `SELECT name FROM system.tables WHERE database = {db:String} AND name IN ({names:Array(String)}) ORDER BY name`,
62
+ query_params: { db: database, names },
63
+ format: 'JSONEachRow',
64
+ });
65
+ return (await rs.json()).map((r) => r.name);
66
+ }
67
+
68
+ async function main() {
69
+ const member = opt('member');
70
+ if (!member) { console.error('usage: provision.js --member <name> [--merge]'); process.exit(2); }
71
+ assertUsableMember(member);
72
+
73
+ const here = __dirname;
74
+
75
+ // Create the house with a client that has NOT selected it — on a fresh server the
76
+ // database does not exist yet, and selecting it fails before it can be created.
77
+ const bootstrap = createClient({ ...cfg, database: '' });
78
+ await bootstrap.command({ query: `CREATE DATABASE IF NOT EXISTS ${cfg.database}` });
79
+ await bootstrap.close();
80
+
81
+ console.log(`[mem] provisioning in ${cfg.url} database '${cfg.database}' as '${cfg.username}'`);
82
+ const client = createClient({ ...cfg, clickhouse_settings: { async_insert: 0 } });
83
+
84
+ // 1. rooms
85
+ const tpl = fs.readFileSync(path.join(here, 'schema-member.sql.tpl'), 'utf-8');
86
+ const roomSql = statements(tpl.replaceAll('{{MEMBER}}', member));
87
+ for (const q of roomSql) {
88
+ // allow_experimental_full_text_index: a no-op on 26.x, required on 25.x for the
89
+ // messages text indexes.
90
+ await client.command({ query: q, clickhouse_settings: { allow_experimental_full_text_index: 1 } });
91
+ }
92
+ console.log(`[mem] rooms ready for '${member}': ${ROOM_TYPES.map((t) => `${t}_${member}`).join(', ')}`);
93
+
94
+ // 2. grants — two statements per room, and the split is the point.
95
+ //
96
+ // ALL, NOT re-grantable: the member owns their room outright, DROP and TRUNCATE
97
+ // included, because it is their memory to destroy. It also covers the mutation
98
+ // privileges the shipper needs without naming them — the shipper clears a session's
99
+ // rows before re-inserting, and which of ALTER UPDATE / ALTER DELETE a server demands
100
+ // varies by version (`DELETE FROM` is a lightweight delete, implemented as
101
+ // `ALTER TABLE … UPDATE _row_exists = 0`, so 25.11 wants ALTER UPDATE where 26.7 wanted
102
+ // ALTER DELETE). Granting one and not the other produces a pass that fails elsewhere.
103
+ //
104
+ // SELECT, re-grantable: a share is READ-ONLY BY CONSTRUCTION rather than by convention.
105
+ // `GRANT ALL … WITH GRANT OPTION` expands to 45 privileges on 26.7 — DROP TABLE,
106
+ // TRUNCATE, CREATE ROW POLICY, SYSTEM DROP REPLICA among them — and every one of those
107
+ // would become something a member could hand to a colleague while meaning "let them
108
+ // read my sessions". Measured: with this split, `GRANT SELECT … TO bob` succeeds and
109
+ // `GRANT DROP TABLE … TO bob` is refused 497.
110
+ for (const t of ROOM_TYPES) {
111
+ const room = `${cfg.database}.${t}_${member}`;
112
+ await client.command({ query: `GRANT ALL ON ${room} TO ${member}` });
113
+ await client.command({ query: `GRANT SELECT ON ${room} TO ${member} WITH GRANT OPTION` });
114
+ }
115
+ console.log(`[mem] granted ALL on 3 rooms to '${member}'; SELECT is the only re-grantable one`);
116
+
117
+ // 3. Merge rooms
118
+ if (flag('merge')) {
119
+ const mtpl = fs.readFileSync(path.join(here, 'schema-merge.sql.tpl'), 'utf-8');
120
+ for (const q of statements(mtpl.replaceAll('{{TEMPLATE_MEMBER}}', member))) {
121
+ await client.command({ query: q });
122
+ }
123
+ console.log('[mem] merge rooms ready: all_sessions, all_messages, all_tool_calls');
124
+ }
125
+
126
+ // 4. Merge-room grants. Broad on purpose, and safe for the reason the whole layout
127
+ // rests on: a Merge table reduces to the underlying rooms the CALLER holds grants for.
128
+ // Measured on 25.11 — bob with SELECT on all_messages and nothing on messages_alice
129
+ // reads his own row and none of alice's, with `_table` showing only messages_bob, and
130
+ // no error. Withholding this grant does not make anything safer; it makes the team
131
+ // room deny outright instead of failing closed, which is a different and worse thing.
132
+ const merged = await mergeRoomsPresent(client, cfg.database);
133
+ for (const room of merged) {
134
+ await client.command({ query: `GRANT SELECT ON ${cfg.database}.${room} TO ${member}` });
135
+ }
136
+ if (merged.length) console.log(`[mem] granted SELECT on ${merged.join(', ')} to '${member}'`);
137
+ else console.log('[mem] no merge rooms yet — run once with --merge to create them');
138
+
139
+ await client.close();
140
+ }
141
+
142
+ main().catch((e) => { console.error(`[mem] provision failed: ${e.message}`); process.exit(1); });
@@ -0,0 +1,132 @@
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
+ // ClickHouse usernames are permissive; room names are not. Refuse anything that would
28
+ // need quoting or could change how a Merge regex or a name-splitter reads.
29
+ function assertUsableMember(member) {
30
+ if (typeof member !== 'string' || !/^[A-Za-z][A-Za-z0-9_]*$/.test(member)) {
31
+ throw new Error(`cannot build room names for user '${member}': expected [A-Za-z][A-Za-z0-9_]*`);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * The session rollup, as SQL text rather than a name.
37
+ *
38
+ * Two properties are load bearing: the join is a LEFT JOIN, so a session with no messages
39
+ * still appears with zero aggregates instead of vanishing from every count; and
40
+ * `join_use_nulls = 1` makes the unmatched message columns NULL so `coalesce` yields true
41
+ * zeros rather than counting the placeholder row. The setting is applied by the CALLER (a
42
+ * client setting, not a trailing SETTINGS clause) because this text is used as a subquery,
43
+ * and a subquery cannot carry its own SETTINGS.
44
+ */
45
+ function sessionsRollup({ sessions, messages }) {
46
+ return `(
47
+ SELECT
48
+ s.session_id AS session_id,
49
+ any(s.source) AS source,
50
+ any(s.host) AS host,
51
+ any(s.name) AS name,
52
+ any(s.mode) AS mode,
53
+ any(s.folder) AS folder,
54
+ any(s.project) AS project,
55
+ any(s.git_branch) AS git_branch,
56
+ s.user_id AS user_id,
57
+ any(s.created_at) AS created_at,
58
+ any(s.last_updated_at) AS last_updated_at,
59
+ min(m.ts) AS started,
60
+ max(m.ts) AS ended,
61
+ coalesce(dateDiff('second', min(m.ts), max(m.ts)), 0) AS duration_sec,
62
+ count(m.seq) AS total_msgs,
63
+ countIf(m.role = 'user') AS user_msgs,
64
+ countIf(m.role = 'assistant') AS assistant_msgs,
65
+ countIf(m.is_subagent) AS subagent_msgs,
66
+ groupUniqArrayIf(m.model, m.model NOT IN ('', '<synthetic>')) AS models,
67
+ coalesce(sum(m.input_tokens), 0) AS input_tokens,
68
+ coalesce(sum(m.output_tokens), 0) AS output_tokens,
69
+ coalesce(sum(m.cache_read_tokens), 0) AS cache_read_tokens,
70
+ coalesce(sum(m.cache_write_tokens), 0) AS cache_write_tokens,
71
+ coalesce(sumIf(length(m.text), m.role = 'user'), 0) AS user_chars,
72
+ coalesce(sumIf(length(m.text), m.role = 'assistant'), 0) AS assistant_chars,
73
+ coalesce(substring(argMinIf(m.text, m.seq, m.role = 'user' AND m.text != ''), 1, 200), '') AS first_prompt
74
+ FROM ${sessions} AS s
75
+ LEFT JOIN ${messages} AS m ON m.session_id = s.session_id AND m.user_id = s.user_id
76
+ GROUP BY s.session_id, s.user_id
77
+ )`;
78
+ }
79
+
80
+ /**
81
+ * The settings every read needs. `final` collapses ReplacingMergeTree versions;
82
+ * `join_use_nulls` is what the rollup's coalesce depends on.
83
+ */
84
+ const READ_SETTINGS = { final: 1, join_use_nulls: 1 };
85
+
86
+ /**
87
+ * Names, given the member the server reported. Shared by every caller so the two
88
+ * transports in the tree (@clickhouse/client and the CLI's raw fetch) cannot drift into
89
+ * two different naming rules.
90
+ *
91
+ * `sessions_v` is the rollup as SQL text, usable in the same `FROM ... AS c` position a
92
+ * view name would occupy — which is what lets the read layer treat it as just another
93
+ * resolved name.
94
+ */
95
+ function roomNames(member, user = member) {
96
+ assertUsableMember(member);
97
+ const out = { member, user };
98
+ for (const t of ROOM_TYPES) out[t] = `${t}_${member}`;
99
+ out.sessions_v = sessionsRollup(out);
100
+ return out;
101
+ }
102
+
103
+ async function currentUser(client) {
104
+ const rs = await client.query({ query: 'SELECT currentUser() AS u', format: 'JSONEachRow' });
105
+ const rows = await rs.json();
106
+ const u = rows[0] && rows[0].u;
107
+ if (!u) throw new Error('could not determine currentUser()');
108
+ return u;
109
+ }
110
+
111
+ /**
112
+ * Resolve the rooms for whoever this client is connected as.
113
+ * Returns { sessions, messages, tool_calls, sessions_v, member, user }.
114
+ *
115
+ * `user` is the identity the server reports. Writers need the value itself, not just the
116
+ * name it produces — see ship.js's delete, which must BIND the user rather than call
117
+ * currentUser() inside a mutation, where it is not evaluated in the caller's context.
118
+ */
119
+ async function resolveRooms(client) {
120
+ const user = await currentUser(client);
121
+ return roomNames(user, user);
122
+ }
123
+
124
+ /** The three Merge rooms — team-wide read paths, owner-managed. */
125
+ function mergeRooms() {
126
+ return { sessions: 'all_sessions', messages: 'all_messages', tool_calls: 'all_tool_calls' };
127
+ }
128
+
129
+ module.exports = {
130
+ ROOM_TYPES, READ_SETTINGS, assertUsableMember,
131
+ sessionsRollup, roomNames, currentUser, resolveRooms, mergeRooms,
132
+ };
@@ -0,0 +1,85 @@
1
+ -- mem — one member's rooms. Applied per member; {{MEMBER}} is the ClickHouse username.
2
+ --
3
+ -- Room naming is TYPE-FIRST (sessions_<m>, not <m>_sessions) so the Merge rooms
4
+ -- can anchor on a fixed room type and never match themselves.
5
+ --
6
+ -- user_id MATERIALIZED currentUser() is retained even though the room names the member:
7
+ -- it keeps provenance across a share and keeps the Merge rooms meaningful. Writers must
8
+ -- use async_insert=0 or the stamp does not happen.
9
+ --
10
+ -- ClickHouse >= 26.2 natively; 25.11 works because the shipper passes
11
+ -- allow_experimental_full_text_index=1 per-query when applying this file.
12
+
13
+ CREATE TABLE IF NOT EXISTS sessions_{{MEMBER}}
14
+ (
15
+ session_id String,
16
+ source LowCardinality(String),
17
+ host LowCardinality(String),
18
+ name String DEFAULT '',
19
+ mode LowCardinality(String) DEFAULT '',
20
+ folder String DEFAULT '',
21
+ project String DEFAULT '',
22
+ git_branch String DEFAULT '',
23
+ created_at Nullable(DateTime64(3, 'UTC')),
24
+ last_updated_at Nullable(DateTime64(3, 'UTC')),
25
+ message_count UInt32 DEFAULT 0,
26
+ path String DEFAULT '',
27
+ extra JSON,
28
+ user_id String MATERIALIZED currentUser(),
29
+ ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
30
+ )
31
+ ENGINE = ReplacingMergeTree(ingested_at)
32
+ ORDER BY (session_id, user_id);
33
+
34
+ CREATE TABLE IF NOT EXISTS messages_{{MEMBER}}
35
+ (
36
+ session_id String,
37
+ seq UInt32,
38
+ source LowCardinality(String),
39
+ host LowCardinality(String),
40
+ ts DateTime64(3, 'UTC'),
41
+ role LowCardinality(String),
42
+ model LowCardinality(String) DEFAULT '',
43
+ input_tokens UInt64 DEFAULT 0,
44
+ output_tokens UInt64 DEFAULT 0,
45
+ cache_read_tokens UInt64 DEFAULT 0,
46
+ cache_write_tokens UInt64 DEFAULT 0,
47
+ text String,
48
+ project String DEFAULT '',
49
+ folder String DEFAULT '',
50
+ is_subagent Bool DEFAULT false,
51
+ extra JSON,
52
+ line_hash UInt64,
53
+ user_id String MATERIALIZED currentUser(),
54
+ ingested_at DateTime64(3, 'UTC') DEFAULT now64(3),
55
+ text_ngram String MATERIALIZED lower(text),
56
+ text_word String MATERIALIZED lower(text),
57
+ INDEX idx_text_ngram text_ngram TYPE text(tokenizer = ngrams(3)) GRANULARITY 1,
58
+ INDEX idx_text_word text_word TYPE text(tokenizer = splitByNonAlpha) GRANULARITY 1
59
+ )
60
+ ENGINE = ReplacingMergeTree(ingested_at)
61
+ ORDER BY (session_id, user_id, seq);
62
+
63
+ CREATE TABLE IF NOT EXISTS tool_calls_{{MEMBER}}
64
+ (
65
+ session_id String,
66
+ seq UInt32,
67
+ idx UInt32,
68
+ source LowCardinality(String),
69
+ host LowCardinality(String),
70
+ tool_name LowCardinality(String),
71
+ args String DEFAULT '{}',
72
+ ts DateTime64(3, 'UTC'),
73
+ project String DEFAULT '',
74
+ folder String DEFAULT '',
75
+ user_id String MATERIALIZED currentUser(),
76
+ ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
77
+ )
78
+ ENGINE = ReplacingMergeTree(ingested_at)
79
+ ORDER BY (session_id, user_id, idx);
80
+
81
+ -- NO sessions_v HERE, deliberately. The session rollup is a SAVED QUERY substituted
82
+ -- with these room names and run under the caller's own credential (see rooms.js
83
+ -- sessionsRollup). A stored view would need a name inside the `^sessions_` namespace
84
+ -- the Merge rooms select on, a fourth grant, and a fourth object to provision and roll
85
+ -- forward — for nothing the query does not already do.
@@ -0,0 +1,24 @@
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_');