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.
- package/README.md +79 -43
- package/bin/memhouse.js +153 -147
- package/editors/antigravity.js +7 -15
- package/editors/cursor.js +18 -24
- package/editors/goose.js +9 -21
- package/editors/index.js +19 -70
- package/editors/opencode.js +6 -15
- package/editors/sqlite.js +74 -0
- package/editors/windsurf.js +3 -3
- package/editors/zed.js +10 -15
- package/memhouse/COMPETITION.md +9 -5
- package/memhouse/DESIGN.md +29 -30
- package/memhouse/RESUME-AND-UPDATE.md +7 -10
- package/memhouse/delivery/AGENT-INSTALL.md +15 -13
- package/memhouse/delivery/PROMPT.md +7 -9
- package/memhouse/delivery/kernel-install.md +18 -33
- package/memhouse/delivery/plugin/skills/search/SKILL.md +25 -38
- package/memhouse/delivery/plugin/skills/sessions/SKILL.md +15 -28
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +23 -36
- package/memhouse/host.js +1 -1
- package/memhouse/house/HOUSE.md +74 -0
- package/memhouse/house/house.js +170 -0
- package/memhouse/{per-member/schema-member.sql.tpl → house/schema.sql.tpl} +8 -11
- package/memhouse/resume.js +1 -1
- package/memhouse/self-update.js +4 -4
- package/memhouse/server/queries.js +8 -2
- package/memhouse/shipper/ship.js +30 -37
- package/package.json +2 -6
- package/memhouse/per-member/INSTALL.md +0 -228
- package/memhouse/per-member/PLAN.md +0 -174
- package/memhouse/per-member/PROVISIONING.md +0 -177
- package/memhouse/per-member/README.md +0 -48
- package/memhouse/per-member/SCHEMA.md +0 -120
- package/memhouse/per-member/SHARING.md +0 -149
- package/memhouse/per-member/provision.js +0 -297
- package/memhouse/per-member/rooms.js +0 -252
- package/memhouse/per-member/schema-merge.sql.tpl +0 -24
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The house.
|
|
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`. Everyone in the house writes into the SAME tables, each with
|
|
6
|
+
// their own credential, and two columns say who and where every row came from:
|
|
7
|
+
//
|
|
8
|
+
// user_id String MATERIALIZED currentUser() — stamped by the server, unforgeable by
|
|
9
|
+
// clients (async_insert is pinned to 0
|
|
10
|
+
// on the user so the stamp cannot be
|
|
11
|
+
// skipped)
|
|
12
|
+
// host LowCardinality(String) — the machine's fingerprint, minted once
|
|
13
|
+
// per install (../host.js)
|
|
14
|
+
//
|
|
15
|
+
// That pair IS the provenance model. Alice and Bob point their shippers at `team_a` and
|
|
16
|
+
// both write; `WHERE user_id = 'alice'` is one person, `WHERE host = '…'` is one machine.
|
|
17
|
+
// The model is collaborative — housemates trust each other with the house — and the
|
|
18
|
+
// boundary between houses is the database: joining someone's ClickHouse takes a database
|
|
19
|
+
// and a credential (`GRANT ALL ON team_a.* TO alice`), nothing else, because ALL on your
|
|
20
|
+
// own database reaches nothing outside it.
|
|
21
|
+
//
|
|
22
|
+
// THIS REPLACES THE PER-MEMBER LAYOUT. Rooms were `sessions_<member>` with per-member
|
|
23
|
+
// grants, Merge rooms for team reads, and isolation between members of one database.
|
|
24
|
+
// All of it is gone: suffixed names made every client resolve its room names first (and
|
|
25
|
+
// `FROM messages` a documented trap), the Merge rooms existed only to undo the
|
|
26
|
+
// splitting, and per-member isolation inside a shared house solved an adversarial
|
|
27
|
+
// problem the product does not have. Separate houses isolate; one house shares.
|
|
28
|
+
//
|
|
29
|
+
// `sessions_v` IS A SAVED QUERY, NOT AN OBJECT — substituted into the same `FROM … AS c`
|
|
30
|
+
// position a view name would occupy, running under the caller's own credential. It was
|
|
31
|
+
// briefly a stored view; that cost an object to provision and grant, and a name for the
|
|
32
|
+
// old Merge selectors to swallow.
|
|
33
|
+
|
|
34
|
+
const ROOM_TYPES = ['sessions', 'messages', 'tool_calls'];
|
|
35
|
+
|
|
36
|
+
// The one server-side setting a member carries, applied directly on the user with
|
|
37
|
+
// ADD SETTING (bare `ALTER USER … SETTINGS` REPLACES the user's whole list — measured, it
|
|
38
|
+
// wiped an operator-set ceiling; ADD SETTING merges and upserts). There is no settings
|
|
39
|
+
// profile: the shared object detached members on replace and its server-global name made
|
|
40
|
+
// two houses on one server rewrite each other's.
|
|
41
|
+
//
|
|
42
|
+
// The pin is correctness, not policy. `user_id MATERIALIZED currentUser()` is computed
|
|
43
|
+
// during the INSERT, and an ASYNC insert flushes outside that context — measured on
|
|
44
|
+
// 25.11.9.34, `async_insert=1` stores user_id as the EMPTY STRING while a sync insert
|
|
45
|
+
// stamps correctly. The shipper always passes async_insert=0; the pin guards every OTHER
|
|
46
|
+
// holder of the credential. CONST refuses the override; a client that never mentions the
|
|
47
|
+
// setting is held at 0 and its insert lands stamped.
|
|
48
|
+
const MEMBER_PIN = 'async_insert = 0 CONST';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The global-install command that will actually work HERE.
|
|
52
|
+
*
|
|
53
|
+
* The flag half of this is gone: the command carried `--allow-scripts=better-sqlite3`
|
|
54
|
+
* for as long as SQLite was a native module, and omitting it silently cost five
|
|
55
|
+
* adapters. `node:sqlite` needs no install script, so the base command is now the plain
|
|
56
|
+
* one everybody already types.
|
|
57
|
+
*
|
|
58
|
+
* The `sudo` half stays, because it was never about the flag. A bare install fails with
|
|
59
|
+
* EACCES on any distro-packaged Node, because the global prefix is root-owned — measured
|
|
60
|
+
* on a clean Ubuntu machine, exit 243. `sudo` is right for exactly one of the two causes
|
|
61
|
+
* and makes the other worse, and what tells them apart is who owns the prefix — so look,
|
|
62
|
+
* rather than guess.
|
|
63
|
+
*/
|
|
64
|
+
function installCommand() {
|
|
65
|
+
const base = 'npm install -g memhouse';
|
|
66
|
+
try {
|
|
67
|
+
const { execFileSync } = require('child_process');
|
|
68
|
+
const prefix = execFileSync('npm', ['prefix', '-g'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
69
|
+
const st = require('fs').statSync(prefix);
|
|
70
|
+
if (st.uid !== process.getuid()) return `sudo ${base}`;
|
|
71
|
+
} catch { /* npm not on PATH, or an unreadable prefix: fall through to the plain form */ }
|
|
72
|
+
return base;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Identifier check for anything we splice into DDL unquoted — house (database) names and
|
|
76
|
+
// user names. Refuse what would need quoting rather than quote it: a name a person cannot
|
|
77
|
+
// type bare into their own SQL is a name they will get wrong. `default` is deliberately
|
|
78
|
+
// ALLOWED as a house name — a stock container's default database is a real place to keep
|
|
79
|
+
// a house, and the pilot may pick user 'alice' with house 'default'. Only the names that
|
|
80
|
+
// are not a database at all are refused.
|
|
81
|
+
const RESERVED_DBS = new Set(['system', 'information_schema']);
|
|
82
|
+
function assertUsableName(name, what = 'name') {
|
|
83
|
+
if (typeof name !== 'string' || !/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) {
|
|
84
|
+
throw new Error(`'${name}' cannot be a ${what} — expected [A-Za-z][A-Za-z0-9_]*`);
|
|
85
|
+
}
|
|
86
|
+
if (RESERVED_DBS.has(name.toLowerCase())) {
|
|
87
|
+
throw new Error(`'${name}' cannot be a ${what} — it is ClickHouse's own`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The session rollup as SQL text, usable in the same `FROM ... AS c` position a view name
|
|
93
|
+
* would occupy. Groups by (session_id, user_id): two housemates' rows never merge, even
|
|
94
|
+
* on a colliding session_id.
|
|
95
|
+
*/
|
|
96
|
+
function sessionsRollup({ sessions, messages }) {
|
|
97
|
+
return `(
|
|
98
|
+
SELECT
|
|
99
|
+
s.session_id AS session_id,
|
|
100
|
+
any(s.source) AS source,
|
|
101
|
+
any(s.host) AS host,
|
|
102
|
+
any(s.name) AS name,
|
|
103
|
+
any(s.mode) AS mode,
|
|
104
|
+
any(s.folder) AS folder,
|
|
105
|
+
any(s.project) AS project,
|
|
106
|
+
any(s.git_branch) AS git_branch,
|
|
107
|
+
s.user_id AS user_id,
|
|
108
|
+
any(s.created_at) AS created_at,
|
|
109
|
+
any(s.last_updated_at) AS last_updated_at,
|
|
110
|
+
min(m.ts) AS started,
|
|
111
|
+
max(m.ts) AS ended,
|
|
112
|
+
coalesce(dateDiff('second', min(m.ts), max(m.ts)), 0) AS duration_sec,
|
|
113
|
+
count(m.seq) AS total_msgs,
|
|
114
|
+
countIf(m.role = 'user') AS user_msgs,
|
|
115
|
+
countIf(m.role = 'assistant') AS assistant_msgs,
|
|
116
|
+
countIf(m.is_subagent) AS subagent_msgs,
|
|
117
|
+
groupUniqArrayIf(m.model, m.model NOT IN ('', '<synthetic>')) AS models,
|
|
118
|
+
coalesce(sum(m.input_tokens), 0) AS input_tokens,
|
|
119
|
+
coalesce(sum(m.output_tokens), 0) AS output_tokens,
|
|
120
|
+
coalesce(sum(m.cache_read_tokens), 0) AS cache_read_tokens,
|
|
121
|
+
coalesce(sum(m.cache_write_tokens), 0) AS cache_write_tokens,
|
|
122
|
+
coalesce(sumIf(length(m.text), m.role = 'user'), 0) AS user_chars,
|
|
123
|
+
coalesce(sumIf(length(m.text), m.role = 'assistant'), 0) AS assistant_chars,
|
|
124
|
+
coalesce(substring(argMinIf(m.text, m.seq, m.role = 'user' AND m.text != ''), 1, 200), '') AS first_prompt
|
|
125
|
+
FROM ${sessions} AS s FINAL
|
|
126
|
+
LEFT JOIN ${messages} AS m FINAL ON m.session_id = s.session_id AND m.user_id = s.user_id
|
|
127
|
+
GROUP BY s.session_id, s.user_id
|
|
128
|
+
SETTINGS join_use_nulls = 1
|
|
129
|
+
)`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The settings every read needs. `final` collapses ReplacingMergeTree versions;
|
|
134
|
+
* `join_use_nulls` is what the rollup's coalesce depends on.
|
|
135
|
+
*/
|
|
136
|
+
const READ_SETTINGS = { final: 1, join_use_nulls: 1 };
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Room names. They are constants now — `sessions`, `messages`, `tool_calls`, resolved by
|
|
140
|
+
* the connection's database rather than by who is asking — but the shape survives from
|
|
141
|
+
* the per-member layout so every consumer keeps addressing rooms the same way, and
|
|
142
|
+
* `user` still carries the server-reported identity: writers need the VALUE, not the
|
|
143
|
+
* name it produces. See ship.js's delete, which must BIND the user rather than call
|
|
144
|
+
* currentUser() inside a mutation, where it is not evaluated in the caller's context.
|
|
145
|
+
*/
|
|
146
|
+
function roomNames(user) {
|
|
147
|
+
const out = { member: user, user };
|
|
148
|
+
for (const t of ROOM_TYPES) out[t] = t;
|
|
149
|
+
out.sessions_v = sessionsRollup(out);
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function currentUser(client) {
|
|
154
|
+
const rs = await client.query({ query: 'SELECT currentUser() AS u', format: 'JSONEachRow' });
|
|
155
|
+
const rows = await rs.json();
|
|
156
|
+
const u = rows[0] && rows[0].u;
|
|
157
|
+
if (!u) throw new Error('could not determine currentUser()');
|
|
158
|
+
return u;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Resolve the rooms for whoever this client is connected as. */
|
|
162
|
+
async function resolveRooms(client) {
|
|
163
|
+
return roomNames(await currentUser(client));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
ROOM_TYPES, READ_SETTINGS, MEMBER_PIN,
|
|
168
|
+
installCommand, assertUsableName,
|
|
169
|
+
sessionsRollup, roomNames, currentUser, resolveRooms,
|
|
170
|
+
};
|
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
--
|
|
2
|
-
--
|
|
3
|
-
--
|
|
4
|
-
--
|
|
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.
|
|
1
|
+
-- The house's three rooms — plain, shared tables. Everyone in the house writes into
|
|
2
|
+
-- these same tables with their own credential; `user_id MATERIALIZED currentUser()`
|
|
3
|
+
-- (server-stamped, async_insert pinned to 0 on the user) says who, and `host` says
|
|
4
|
+
-- which machine. There is no per-member table and no Merge room: the database IS the
|
|
5
|
+
-- boundary, and a housemate is anyone granted on it.
|
|
9
6
|
--
|
|
10
7
|
-- ClickHouse >= 26.2 natively — 25.11 works because the shipper passes
|
|
11
8
|
-- allow_experimental_full_text_index=1 per-query when applying this file.
|
|
12
9
|
|
|
13
|
-
CREATE TABLE IF NOT EXISTS
|
|
10
|
+
CREATE TABLE IF NOT EXISTS sessions
|
|
14
11
|
(
|
|
15
12
|
session_id String,
|
|
16
13
|
source LowCardinality(String),
|
|
@@ -47,7 +44,7 @@ CREATE TABLE IF NOT EXISTS sessions_{{MEMBER}}
|
|
|
47
44
|
ENGINE = ReplacingMergeTree(ingested_at)
|
|
48
45
|
ORDER BY (session_id, user_id);
|
|
49
46
|
|
|
50
|
-
CREATE TABLE IF NOT EXISTS
|
|
47
|
+
CREATE TABLE IF NOT EXISTS messages
|
|
51
48
|
(
|
|
52
49
|
session_id String,
|
|
53
50
|
seq UInt32,
|
|
@@ -88,7 +85,7 @@ CREATE TABLE IF NOT EXISTS messages_{{MEMBER}}
|
|
|
88
85
|
ENGINE = ReplacingMergeTree(ingested_at)
|
|
89
86
|
ORDER BY (session_id, user_id, origin, seq);
|
|
90
87
|
|
|
91
|
-
CREATE TABLE IF NOT EXISTS
|
|
88
|
+
CREATE TABLE IF NOT EXISTS tool_calls
|
|
92
89
|
(
|
|
93
90
|
session_id String,
|
|
94
91
|
seq UInt32,
|
package/memhouse/resume.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// `source: 'claude-code'` on every chat it returns. This table was written against the
|
|
27
27
|
// module names first and would have refused to resume a single Claude Code session — 508 of
|
|
28
28
|
// them on the machine it was tested on. Read the values out of a real house
|
|
29
|
-
// (`SELECT DISTINCT source FROM
|
|
29
|
+
// (`SELECT DISTINCT source FROM sessions`), never off the adapter's name.
|
|
30
30
|
const RESUMERS = {
|
|
31
31
|
// claude --help, 2026-08-12: "-r, --resume [value] Resume a conversation by session ID"
|
|
32
32
|
'claude-code': {
|
package/memhouse/self-update.js
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// of the CLI you just typed, not the version the daemon is running. On macminim a shipper
|
|
7
7
|
// ran 1d16h out of a directory that had been MOVED — the fix was live, the machine was not.
|
|
8
8
|
//
|
|
9
|
-
// npm's own `postinstall` is the obvious hook and the wrong one.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// even when it works.
|
|
9
|
+
// npm's own `postinstall` is the obvious hook and the wrong one. npm >= 12 blocks install
|
|
10
|
+
// scripts by default, so it would not run at all on a stock install — the same default
|
|
11
|
+
// that cost this product its SQLite binding until `node:sqlite` removed the need for one —
|
|
12
|
+
// and a package install that restarts a user's daemons is a surprise even when it works.
|
|
13
13
|
//
|
|
14
14
|
// So the daemon notices for itself. Once per loop pass it compares what is on disk against
|
|
15
15
|
// what it booted with, and when they differ it hands over:
|
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
|
|
19
19
|
const { createClient, ClickHouseLogLevel } = require('@clickhouse/client');
|
|
20
20
|
const { calculateCost, normalizeModelName } = require('../../pricing');
|
|
21
|
-
const { resolveRooms, READ_SETTINGS } = require('../
|
|
21
|
+
const { resolveRooms, READ_SETTINGS } = require('../house/house');
|
|
22
22
|
|
|
23
23
|
const config = {
|
|
24
24
|
url: process.env.MEMHOUSE_URL || 'http://localhost:8123',
|
|
25
25
|
username: process.env.MEMHOUSE_USER || 'memhouse_root',
|
|
26
26
|
password: process.env.MEMHOUSE_PASSWORD || '',
|
|
27
|
-
database: process.env.MEMHOUSE_DB || '
|
|
27
|
+
database: process.env.MEMHOUSE_DB || process.env.MEMHOUSE_USER, // house defaults to the user's own name
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
let _client = null;
|
|
@@ -925,6 +925,12 @@ async function rawQuery(sql) {
|
|
|
925
925
|
// Cut the result off rather than failing a query that was legitimately large.
|
|
926
926
|
result_overflow_mode: 'break',
|
|
927
927
|
max_execution_time: 30,
|
|
928
|
+
// Result caps bound what LEAVES the server; this bounds what the query does inside
|
|
929
|
+
// it — a huge GROUP BY hits none of the caps above while eating the box's memory.
|
|
930
|
+
// Used to come from the member settings profile; that object is gone (it detached
|
|
931
|
+
// members on replace and its name was server-global), so the one caller that pipes
|
|
932
|
+
// arbitrary SQL carries its own ceiling.
|
|
933
|
+
max_memory_usage: 8000000000,
|
|
928
934
|
});
|
|
929
935
|
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
|
930
936
|
return { columns, rows, count: rows.length, truncated: rows.length >= RAW_MAX_ROWS };
|
package/memhouse/shipper/ship.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// memhouse SHIPPER — parse-on-client (bet #1 in ../DESIGN.md). Runs the 17 editor
|
|
3
|
-
// adapters locally and ships TYPED rows into
|
|
3
|
+
// adapters locally and ships TYPED rows into the house's shared rooms — sessions /
|
|
4
4
|
// messages_<m> / tool_calls_<m>, where <m> is `SELECT currentUser()` and never config
|
|
5
|
-
// (../
|
|
5
|
+
// (../house/schema.sql.tpl, ../house/house.js). All of one member's
|
|
6
6
|
// machines land in that member's rooms, told apart by the `host` column; no member
|
|
7
7
|
// writes into another's. The typed evolution of agency/ingest.js: same host id,
|
|
8
8
|
// same ts interpolation, same batching discipline — but rows land in physical
|
|
@@ -43,7 +43,7 @@ try { ({ createClient, ClickHouseLogLevel } = require('@clickhouse/client')); }
|
|
|
43
43
|
catch {
|
|
44
44
|
console.error("[memhouse] dependency '@clickhouse/client' is not installed.");
|
|
45
45
|
console.error(`[memhouse] from a checkout: npm install --prefix ${require('path').join(__dirname, '..', '..')}`);
|
|
46
|
-
console.error(`[memhouse] from an npm install: ${require('../
|
|
46
|
+
console.error(`[memhouse] from an npm install: ${require('../house/house').installCommand()}`);
|
|
47
47
|
process.exit(2);
|
|
48
48
|
}
|
|
49
49
|
const { getAllChats, getAdapterErrors, getMessages, resetCaches } = require('../../editors');
|
|
@@ -52,7 +52,7 @@ const selfUpdate = require('../self-update');
|
|
|
52
52
|
// Captured at require time, which is as close to "what this process booted with" as it
|
|
53
53
|
// gets. Taking it later would record whatever an upgrade had already replaced.
|
|
54
54
|
const selfSnap = selfUpdate.snapshot(__filename);
|
|
55
|
-
const { resolveRooms, READ_SETTINGS, ROOM_TYPES } = require('../
|
|
55
|
+
const { resolveRooms, READ_SETTINGS, ROOM_TYPES } = require('../house/house');
|
|
56
56
|
|
|
57
57
|
const BATCH_ROWS = 2000; // insert batch ceiling (binding)
|
|
58
58
|
const TEXT_MAX = 50000; // messages.text truncation
|
|
@@ -203,7 +203,7 @@ function makeClient() {
|
|
|
203
203
|
url: process.env.MEMHOUSE_URL,
|
|
204
204
|
username: process.env.MEMHOUSE_USER,
|
|
205
205
|
password: process.env.MEMHOUSE_PASSWORD || '',
|
|
206
|
-
database: process.env.MEMHOUSE_DB || '
|
|
206
|
+
database: process.env.MEMHOUSE_DB || process.env.MEMHOUSE_USER, // house defaults to the user's own name
|
|
207
207
|
request_timeout: 300000, // full re-ships move tens of MB; don't cut inserts short
|
|
208
208
|
clickhouse_settings: {
|
|
209
209
|
// Int64/UInt64 back as JSON numbers — our values (counts, tokens) are < 2^53.
|
|
@@ -221,7 +221,7 @@ function makeClient() {
|
|
|
221
221
|
});
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
// Create the
|
|
224
|
+
// Create the house's rooms, from ../house/schema.sql.tpl. Comments are
|
|
225
225
|
// stripped BEFORE the ';' split — schema comments legitimately contain semicolons.
|
|
226
226
|
//
|
|
227
227
|
// This is the solo path: on a house you own, `memhouse install` mints your three rooms and
|
|
@@ -229,9 +229,8 @@ function makeClient() {
|
|
|
229
229
|
// same identity the rooms' user_id is stamped with, so a client cannot name its way into
|
|
230
230
|
// someone else's rooms.
|
|
231
231
|
//
|
|
232
|
-
// It
|
|
233
|
-
//
|
|
234
|
-
// ../per-member/provision.js.
|
|
232
|
+
// It issues no grants: joining a house is the admin's two statements (CREATE USER +
|
|
233
|
+
// GRANT ALL ON the house), and once granted, each member's own shipper runs this.
|
|
235
234
|
/**
|
|
236
235
|
* Refuse to write into rooms whose sorting key is wrong for `origin` — in EITHER
|
|
237
236
|
* direction. Two different houses are broken in two opposite ways:
|
|
@@ -251,7 +250,7 @@ function makeClient() {
|
|
|
251
250
|
* Refuse, legibly, when the caller's rooms are not there.
|
|
252
251
|
*
|
|
253
252
|
* Without this the first thing to touch a roomless house is loadExisting's SELECT, and the
|
|
254
|
-
* user gets `Unknown table expression identifier '
|
|
253
|
+
* user gets `Unknown table expression identifier 'sessions' in scope SELECT session_id,
|
|
255
254
|
* last_updated_at, message_count, extra FROM ...` — a raw ClickHouse identifier error with
|
|
256
255
|
* no next step, from a command that may be running inside a service loop. `install` and
|
|
257
256
|
* `doctor` both handle the identical situation properly; ship was the one that did not.
|
|
@@ -305,7 +304,7 @@ function templateColumns(tpl) {
|
|
|
305
304
|
*/
|
|
306
305
|
async function warnMissingColumns(client, rooms) {
|
|
307
306
|
let tpl;
|
|
308
|
-
try { tpl = fs.readFileSync(path.join(__dirname, '..', '
|
|
307
|
+
try { tpl = fs.readFileSync(path.join(__dirname, '..', 'house', 'schema.sql.tpl'), 'utf-8'); }
|
|
309
308
|
catch { return; }
|
|
310
309
|
const want = templateColumns(tpl);
|
|
311
310
|
const missing = [];
|
|
@@ -375,7 +374,7 @@ async function assertOriginKeyed(client, rooms) {
|
|
|
375
374
|
+ wrong.map((s) => ` ${s}`).join('\n')
|
|
376
375
|
+ '\n ORDER BY cannot be altered in place. Rebuild each room, then re-ship:'
|
|
377
376
|
+ '\n RENAME TABLE <room> TO <room>_old;'
|
|
378
|
-
+ `\n -- recreate from ${path.join(__dirname, '..', '
|
|
377
|
+
+ `\n -- recreate from ${path.join(__dirname, '..', 'house', 'schema.sql.tpl')}`
|
|
379
378
|
+ '\n INSERT INTO <room> SELECT * FROM <room>_old;'
|
|
380
379
|
+ '\n (add `, \'ship\' AS origin` to the SELECT only if <room>_old has no origin column)'
|
|
381
380
|
+ '\n A house with no imported rows can also just be re-shipped from scratch.');
|
|
@@ -384,18 +383,19 @@ async function assertOriginKeyed(client, rooms) {
|
|
|
384
383
|
|
|
385
384
|
async function ensureSchema(client) {
|
|
386
385
|
const rooms = await resolveRooms(client);
|
|
387
|
-
const
|
|
388
|
-
const
|
|
389
|
-
const sql = tpl.replaceAll('{{MEMBER}}', member);
|
|
386
|
+
const tpl = fs.readFileSync(path.join(__dirname, '..', 'house', 'schema.sql.tpl'), 'utf-8');
|
|
387
|
+
const sql = tpl; // plain shared tables — nothing to render
|
|
390
388
|
const stripped = sql.split('\n').map((l) => l.replace(/--.*$/, '')).join('\n');
|
|
391
389
|
const stmts = stripped.split(';').map((s) => s.trim()).filter(Boolean);
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
390
|
+
// Two failure shapes are survivable here and both are skipped rather than fatal:
|
|
391
|
+
//
|
|
392
|
+
// - a permission denial. A read-only credential can still run --ensure-schema for
|
|
393
|
+
// its ADD COLUMN rollout; ClickHouse checks the grant BEFORE existence, so IF NOT
|
|
394
|
+
// EXISTS does not save the CREATEs. Skip what we may not do and carry on.
|
|
395
|
+
// - a create RACE. Two housemates' first-ever ships can run these CREATEs
|
|
396
|
+
// concurrently, and IF NOT EXISTS is not atomic against a simultaneous creator —
|
|
397
|
+
// the loser can get TABLE_ALREADY_EXISTS or a metadata-file collision. The table
|
|
398
|
+
// exists either way, which is the outcome this function wants.
|
|
399
399
|
let denied = 0;
|
|
400
400
|
for (const q of stmts) {
|
|
401
401
|
try {
|
|
@@ -410,6 +410,7 @@ async function ensureSchema(client) {
|
|
|
410
410
|
} catch (e) {
|
|
411
411
|
const m = e && e.message ? e.message : String(e);
|
|
412
412
|
if (/Not enough privileges|ACCESS_DENIED/i.test(m)) { denied++; continue; }
|
|
413
|
+
if (/ALREADY_EXISTS|already exists/i.test(m)) { continue; } // lost a create race — the winner made it
|
|
413
414
|
throw e;
|
|
414
415
|
}
|
|
415
416
|
}
|
|
@@ -422,13 +423,6 @@ async function ensureSchema(client) {
|
|
|
422
423
|
// would error. Add it in place; ReplacingMergeTree backfills the DEFAULT, so every
|
|
423
424
|
// pre-existing row reads as 'ship', which is what it was.
|
|
424
425
|
//
|
|
425
|
-
// Deliberately NOT applied to the Merge rooms — but not for the reason this comment used
|
|
426
|
-
// to give. It claimed they "reject ALTER"; they do not, on either 26.7 or 25.11, where
|
|
427
|
-
// `ALTER TABLE all_messages ADD COLUMN …` succeeds. The real reason is that a Merge room
|
|
428
|
-
// is a VIEW over whatever `^messages_` matches: a column added to it is cosmetic, is not
|
|
429
|
-
// backed by the underlying rooms, and drifts from them the moment a member is added. It
|
|
430
|
-
// takes its structure from a member room at CREATE time, so recreating it is the only
|
|
431
|
-
// correct way to change it — and nothing reads `origin` through it anyway.
|
|
432
426
|
// EVERY column the template declares, not just `origin`.
|
|
433
427
|
//
|
|
434
428
|
// This used to add exactly one column, which meant any OTHER column missing from a room
|
|
@@ -798,18 +792,17 @@ async function runShip(client, opts = {}) {
|
|
|
798
792
|
return { sessions, skipped, msgRows, toolRows, unreadable };
|
|
799
793
|
}
|
|
800
794
|
|
|
801
|
-
// Warn once per adapter per pass. `warned` carries across the two call sites so
|
|
802
|
-
//
|
|
795
|
+
// Warn once per adapter per pass. `warned` carries across the two call sites so one
|
|
796
|
+
// unreadable store is not reported twice in the same run.
|
|
797
|
+
//
|
|
798
|
+
// The loud case used to be a missing better-sqlite3 binding, which took five adapters
|
|
799
|
+
// out together and came with a reinstall command attached. SQLite ships with Node now;
|
|
800
|
+
// what is left is per-store, and each error already says what happened.
|
|
803
801
|
function reportAdapterErrors(warned) {
|
|
804
802
|
const errors = getAdapterErrors().filter((e) => !warned.has(e.source));
|
|
805
803
|
if (!errors.length) return;
|
|
806
804
|
for (const e of errors) warned.add(e.source);
|
|
807
|
-
const
|
|
808
|
-
if (noBinding.length) {
|
|
809
|
-
console.log(`[memhouse] WARNING: ${noBinding.length} adapter(s) skipped, sessions NOT shipped — better-sqlite3 has no native binding: ${noBinding.join(', ')}`);
|
|
810
|
-
console.log(`[memhouse] fix: ${require('../per-member/rooms').installCommand()}`);
|
|
811
|
-
}
|
|
812
|
-
for (const e of errors.filter((x) => !x.missingBinding)) {
|
|
805
|
+
for (const e of errors) {
|
|
813
806
|
console.log(`[memhouse] WARNING: ${e.source} skipped — ${e.message}`);
|
|
814
807
|
}
|
|
815
808
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memhouse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "memhouse — agent conversation memory across 17 editors: parse-on-client shippers, a typed ClickHouse store with one set of rooms per member, kernel-agency install, and the agentlytics dashboard",
|
|
5
5
|
"main": "bin/memhouse.js",
|
|
6
6
|
"bin": {
|
|
@@ -43,10 +43,7 @@
|
|
|
43
43
|
"author": "Ramazan Polat <ramazanpolat@gmail.com> (built on agentlytics by fkadev)",
|
|
44
44
|
"license": "MIT",
|
|
45
45
|
"engines": {
|
|
46
|
-
"node": ">=
|
|
47
|
-
},
|
|
48
|
-
"allowScripts": {
|
|
49
|
-
"better-sqlite3": true
|
|
46
|
+
"node": ">=24.0.0"
|
|
50
47
|
},
|
|
51
48
|
"repository": {
|
|
52
49
|
"type": "git",
|
|
@@ -54,7 +51,6 @@
|
|
|
54
51
|
},
|
|
55
52
|
"dependencies": {
|
|
56
53
|
"@clickhouse/client": "^1.23.1",
|
|
57
|
-
"better-sqlite3": "^12.6.2",
|
|
58
54
|
"express": "^4.22.1"
|
|
59
55
|
}
|
|
60
56
|
}
|