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
package/editors/cursor.js CHANGED
@@ -1,13 +1,4 @@
1
- // Required lazily. A top-level require makes this native module a load-time dependency
2
- // of the whole adapter set: editors/index.js imports every adapter, so one missing
3
- // binding threw before any adapter ran and `discover` reported ZERO editors — losing the
4
- // eleven that need no native code at all. The adapterErrors path exists to degrade one
5
- // adapter at a time, and a top-level throw walks straight past it.
6
- let Database = null;
7
- function openDb(...a) {
8
- if (!Database) Database = require('better-sqlite3');
9
- return new Database(...a);
10
- }
1
+ const { openReadOnly, textOf, bytesOf } = require('./sqlite');
11
2
  const path = require('path');
12
3
  const fs = require('fs');
13
4
  const os = require('os');
@@ -52,16 +43,16 @@ function hexToString(hex) {
52
43
  function readStoreMeta(db) {
53
44
  const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('0');
54
45
  if (!row) return null;
55
- const hex = typeof row.value === 'string' ? row.value : Buffer.from(row.value).toString('hex');
46
+ const hex = typeof row.value === 'string' ? row.value : bytesOf(row.value).toString('hex');
56
47
  try {
57
48
  return JSON.parse(hexToString(hex));
58
49
  } catch {
59
- try { return JSON.parse(row.value); } catch { return null; }
50
+ try { return JSON.parse(textOf(row.value)); } catch { return null; }
60
51
  }
61
52
  }
62
53
 
63
54
  function parseTreeBlob(data) {
64
- const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
55
+ const buf = bytesOf(data);
65
56
  const messageRefs = [];
66
57
  const childRefs = [];
67
58
  let offset = 0;
@@ -114,7 +105,10 @@ function collectStoreMessages(db, rootBlobId) {
114
105
  if (!row) return;
115
106
  const data = row.data;
116
107
  try {
117
- const json = typeof data === 'string' ? JSON.parse(data) : JSON.parse(data.toString('utf-8'));
108
+ // textOf, not data.toString(): a message blob arrives as a Uint8Array, whose own
109
+ // toString() renders the bytes as "123,45,…" and would send every message in the
110
+ // tree down the tree-blob path below.
111
+ const json = JSON.parse(textOf(data));
118
112
  if (json && json.role) { allMessages.push(normalizeStoreMessage(json)); return; }
119
113
  } catch { /* tree blob */ }
120
114
  const { messageRefs, childRefs } = parseTreeBlob(data);
@@ -156,11 +150,11 @@ function getWorkspaceMap() {
156
150
 
157
151
  function getComposerHeaders(stateDbPath) {
158
152
  try {
159
- const db = openDb(stateDbPath, { readonly: true });
153
+ const db = openReadOnly(stateDbPath);
160
154
  const row = db.prepare("SELECT value FROM ItemTable WHERE key = 'composer.composerData'").get();
161
155
  db.close();
162
156
  if (!row) return [];
163
- const data = JSON.parse(row.value);
157
+ const data = JSON.parse(textOf(row.value));
164
158
  return (data.allComposers || []).map((c) => ({
165
159
  composerId: c.composerId,
166
160
  name: c.name || null,
@@ -183,7 +177,7 @@ function getModelPreference(globalDb) {
183
177
  try {
184
178
  const row = globalDb.prepare("SELECT value FROM ItemTable WHERE key = 'cursor/lastSingleModelPreference'").get();
185
179
  if (!row) return null;
186
- const pref = JSON.parse(row.value);
180
+ const pref = JSON.parse(textOf(row.value));
187
181
  return pref.composer || pref.agent || null;
188
182
  } catch { return null; }
189
183
  }
@@ -197,7 +191,7 @@ function getComposerBubbles(globalDb, composerId) {
197
191
  const bubbles = [];
198
192
  for (const row of rows) {
199
193
  try {
200
- const obj = JSON.parse(row.value);
194
+ const obj = JSON.parse(textOf(row.value));
201
195
  bubbles.push(obj);
202
196
  } catch (e) {
203
197
  // Historically treated as "binary blob, skip", but a row that will not decode
@@ -286,7 +280,7 @@ function getChats() {
286
280
  // Source 1: ~/.cursor/chats store.db
287
281
  for (const { workspace, chatId, dbPath } of getAgentStoreChats()) {
288
282
  try {
289
- const db = openDb(dbPath, { readonly: true });
283
+ const db = openReadOnly(dbPath);
290
284
  const meta = readStoreMeta(db);
291
285
  db.close();
292
286
  if (meta) {
@@ -325,7 +319,7 @@ function getChats() {
325
319
 
326
320
  // Source 2: workspaceStorage composers
327
321
  let globalDb = null;
328
- try { globalDb = openDb(GLOBAL_STORAGE_DB, { readonly: true }); }
322
+ try { globalDb = openReadOnly(GLOBAL_STORAGE_DB); }
329
323
  catch (e) {
330
324
  // Absent is normal — Cursor may simply not be installed. Unreadable is not.
331
325
  if (fs.existsSync(GLOBAL_STORAGE_DB)) adapterErrors.record('cursor', e, GLOBAL_STORAGE_DB);
@@ -378,7 +372,7 @@ function getChats() {
378
372
 
379
373
  function getMessages(chat) {
380
374
  if (chat._type === 'agent-store') {
381
- const db = openDb(chat._dbPath, { readonly: true });
375
+ const db = openReadOnly(chat._dbPath);
382
376
  const msgs = collectStoreMessages(db, chat._rootBlobId);
383
377
  db.close();
384
378
  // Use lastUsedModel as fallback for assistant messages without model info
@@ -391,7 +385,7 @@ function getMessages(chat) {
391
385
  }
392
386
 
393
387
  let globalDb;
394
- try { globalDb = openDb(GLOBAL_STORAGE_DB, { readonly: true }); }
388
+ try { globalDb = openReadOnly(GLOBAL_STORAGE_DB); }
395
389
  catch (e) {
396
390
  // Returning [] unreported would let a re-ship overwrite this session's stored
397
391
  // transcript with nothing.
@@ -416,10 +410,10 @@ function getMessages(chat) {
416
410
 
417
411
  function getCursorAccessToken() {
418
412
  try {
419
- const db = openDb(GLOBAL_STORAGE_DB, { readonly: true });
413
+ const db = openReadOnly(GLOBAL_STORAGE_DB);
420
414
  const row = db.prepare("SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'").get();
421
415
  db.close();
422
- return row ? row.value : null;
416
+ return row ? textOf(row.value) : null;
423
417
  } catch { return null; }
424
418
  }
425
419
 
package/editors/goose.js CHANGED
@@ -2,6 +2,7 @@ const path = require('path');
2
2
  const fs = require('fs');
3
3
  const os = require('os');
4
4
 
5
+ const { openReadOnly, textOf } = require('./sqlite');
5
6
  const adapterErrors = require('./adapter-errors');
6
7
 
7
8
  const GOOSE_DIR = path.join(os.homedir(), '.local', 'share', 'goose', 'sessions');
@@ -9,30 +10,17 @@ const DB_PATH = path.join(GOOSE_DIR, 'sessions.db');
9
10
  const CONFIG_PATH = path.join(os.homedir(), '.config', 'goose', 'config.yaml');
10
11
 
11
12
  // ============================================================
12
- // Query SQLite via better-sqlite3 (cross-platform)
13
+ // Query SQLite via node:sqlite (cross-platform, part of Node itself)
13
14
  // ============================================================
14
15
 
15
- let Database;
16
- function getDatabase() {
17
- if (!Database) {
18
- try {
19
- Database = require('better-sqlite3');
20
- } catch {
21
- // better-sqlite3 not available
22
- }
23
- }
24
- return Database;
25
- }
26
-
16
+ // This used to swallow "SQLite is unavailable" as a case of its own, because
17
+ // better-sqlite3's binding could genuinely be absent. It cannot be now — so an open
18
+ // that fails is an open that failed, and the one catch below reports it like any other
19
+ // unreadable store.
27
20
  function queryDb(sql) {
28
21
  if (!fs.existsSync(DB_PATH)) return [];
29
- const Db = getDatabase();
30
- if (!Db) {
31
- adapterErrors.record('goose', new Error('better-sqlite3 unavailable'), DB_PATH);
32
- return [];
33
- }
34
22
  try {
35
- const db = new Db(DB_PATH, { readonly: true });
23
+ const db = openReadOnly(DB_PATH);
36
24
  const rows = db.prepare(sql).all();
37
25
  db.close();
38
26
  return rows;
@@ -201,7 +189,7 @@ function getMessagesFromDb(chat) {
201
189
  // A row we cannot decode drops a message from the transcript. Silently, that is
202
190
  // a truncation that looks complete, and a re-ship would replace the stored
203
191
  // transcript with it. Report so the shipper withholds and retries instead.
204
- try { parts = JSON.parse(row.content_json); }
192
+ try { parts = JSON.parse(textOf(row.content_json)); }
205
193
  catch (e) { adapterErrors.record('goose', e, `session ${chat.composerId}`); continue; }
206
194
  if (!Array.isArray(parts)) {
207
195
  adapterErrors.record('goose', new Error('message content_json is not an array'), `session ${chat.composerId}`);
@@ -314,7 +302,7 @@ function getMessagesFromJsonl(chat) {
314
302
  function extractSessionModel(row) {
315
303
  if (row.model_config_json) {
316
304
  try {
317
- const cfg = JSON.parse(row.model_config_json);
305
+ const cfg = JSON.parse(textOf(row.model_config_json));
318
306
  if (cfg.model) return cfg.model;
319
307
  if (cfg.model_id) return cfg.model_id;
320
308
  } catch {}
package/editors/index.js CHANGED
@@ -26,58 +26,19 @@ for (const editor of editors) {
26
26
 
27
27
  // Per-adapter failures from the last getAllChats() pass. An adapter that throws is
28
28
  // skipped so one broken editor cannot take down a scan, but the failure is recorded
29
- // here rather than discarded: a missing better-sqlite3 native binding and an editor
30
- // the user simply does not have both produce zero sessions, and only this tells them
31
- // apart. See getAdapterErrors().
32
- let adapterErrors = [];
33
-
34
- // better-sqlite3 ships a prebuilt binary via an install script. npm >= 12 blocks
35
- // install scripts by default, so a plain `npm i -g memhouse` leaves no binding and
36
- // every SQLite-backed adapter reads zero sessions.
37
- const MISSING_BINDING = /Could not locate the bindings file|Cannot find module 'better-sqlite3'/;
38
-
39
- // Adapters that read sessions out of a SQLite store, so a dead binding costs
40
- // sessions. Membership is narrower than `grep -l better-sqlite3 editors/*.js`:
41
- // windsurf/devin also requires better-sqlite3, but only in getDevinApiKey() for
42
- // usage — its getChats() is pure language-server RPC, so its sessions survive a
43
- // broken binding and listing it here would raise a false alarm. antigravity is
44
- // listed because its *offline* chats come from SQLite, though its live cascades,
45
- // like devin's, come over RPC — a failure there is partial.
46
- // Held as module references, not strings, so the reported name always matches the
47
- // adapter's own `name`.
48
- const SQLITE_BACKED = [antigravity, cursor, goose, opencode, zed].map((m) => m.name);
49
-
50
- // Construct an in-memory database. Loading the module is not enough — better-sqlite3
51
- // resolves the native binding lazily, on first open.
29
+ // here rather than discarded: an unreadable store and an editor the user simply does
30
+ // not have both produce zero sessions, and only this tells them apart. See
31
+ // getAdapterErrors().
52
32
  //
53
- // This only answers "can SQLite work at all". It deliberately cannot tell whether a
54
- // given editor's real store is readable: permissions, locking, corruption, and
55
- // SQLCipher all pass this probe and fail later. Those are reported by the adapters
56
- // themselves through adapter-errors.
57
- function probeSqlite() {
58
- try {
59
- const Database = require('better-sqlite3');
60
- const db = new Database(':memory:');
61
- db.close();
62
- return { available: true };
63
- } catch (e) {
64
- const message = ((e && e.message) || String(e)).split('\n')[0];
65
- return { available: false, message, missingBinding: MISSING_BINDING.test(message) };
66
- }
67
- }
68
-
69
- // Cache only success. A failed probe must be retried: a long-running shipper daemon
70
- // that warned about a missing binding should notice once the user installs it,
71
- // rather than repeating the warning for the life of the process.
72
- let sqliteProbe = null;
73
- function sqliteStatus() {
74
- if (sqliteProbe === null) {
75
- const result = probeSqlite();
76
- if (result.available) sqliteProbe = result;
77
- return result;
78
- }
79
- return sqliteProbe;
80
- }
33
+ // There used to be a second, coarser class beside those: better-sqlite3's native
34
+ // binding could be missing outright, taking all five SQLite-backed adapters down at
35
+ // once for a reason none of them could see, so a separate probe ran an in-memory open
36
+ // and reported the verdict against every one of them. SQLite is `node:sqlite` now — it
37
+ // arrives with Node and cannot go missing — so that probe, its MISSING_BINDING regex,
38
+ // and the `missingBinding` flag they put on every error are gone. What remains is the
39
+ // per-store reporting the adapters do themselves: permissions, locking, corruption and
40
+ // schema drift are all still real and all still land here, one line per adapter.
41
+ let adapterErrors = [];
81
42
 
82
43
  /**
83
44
  * Get all chats from all editor adapters, sorted by most recent first.
@@ -92,11 +53,7 @@ function getAllChats() {
92
53
  chats.push(...editorChats);
93
54
  } catch (e) {
94
55
  const message = (e && e.message) || String(e);
95
- adapterErrors.push({
96
- source: editor.name,
97
- message: message.split('\n')[0],
98
- missingBinding: MISSING_BINDING.test(message),
99
- });
56
+ adapterErrors.push({ source: editor.name, message: message.split('\n')[0] });
100
57
  }
101
58
  }
102
59
 
@@ -110,27 +67,19 @@ function getAllChats() {
110
67
  }
111
68
 
112
69
  /**
113
- * Adapters that could not report their sessions: [{ source, message, missingBinding }].
70
+ * Adapters that could not report their sessions: [{ source, message, detail? }].
114
71
  * Empty when every adapter ran clean.
115
72
  *
116
73
  * Two sources, because there are two ways an adapter goes quiet. One throws out of
117
- * getChats() and is recorded during the pass. The other is a dead SQLite binding,
118
- * which throws nowhere at all — the adapters catch it internally — so it is probed
119
- * for directly and reported against every SQLite-backed adapter at once.
74
+ * getChats() and is recorded during the pass. The other catches its own failure — a
75
+ * locked state.vscdb, a thread blob that will not decompress, a schema the parser does
76
+ * not know — and reports it through the adapter-errors sink so the shipper knows not to
77
+ * overwrite a good stored transcript with a partial read.
120
78
  */
121
79
  function getAdapterErrors() {
122
80
  const errors = adapterErrors.slice();
123
81
  for (const e of adapterErrorSink.recorded()) {
124
- errors.push({ source: e.source, message: e.message, detail: e.detail, missingBinding: MISSING_BINDING.test(e.message) });
125
- }
126
- const sqlite = sqliteStatus();
127
- if (!sqlite.available) {
128
- const already = new Set(errors.map((e) => e.source));
129
- for (const source of SQLITE_BACKED) {
130
- if (!already.has(source)) {
131
- errors.push({ source, message: sqlite.message, missingBinding: sqlite.missingBinding });
132
- }
133
- }
82
+ errors.push({ source: e.source, message: e.message, detail: e.detail });
134
83
  }
135
84
  // One line per adapter, not one per failed query — a broken store is usually hit
136
85
  // many times in a single scan.
@@ -1,16 +1,7 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs');
3
3
  const os = require('os');
4
- // Required lazily. A top-level require makes this native module a load-time dependency
5
- // of the whole adapter set: editors/index.js imports every adapter, so one missing
6
- // binding threw before any adapter ran and `discover` reported ZERO editors — losing the
7
- // eleven that need no native code at all. The adapterErrors path exists to degrade one
8
- // adapter at a time, and a top-level throw walks straight past it.
9
- let Database = null;
10
- function openDb(...a) {
11
- if (!Database) Database = require('better-sqlite3');
12
- return new Database(...a);
13
- }
4
+ const { openReadOnly, textOf } = require('./sqlite');
14
5
  const adapterErrors = require('./adapter-errors');
15
6
 
16
7
  // OpenCode stores data in XDG-style paths across all platforms
@@ -31,13 +22,13 @@ function getOpenCodeDbPath() {
31
22
  const DB_PATH = getOpenCodeDbPath();
32
23
 
33
24
  // ============================================================
34
- // Query SQLite using better-sqlite3
25
+ // Query SQLite using node:sqlite (built into Node — nothing to install)
35
26
  // ============================================================
36
27
 
37
28
  function queryDb(sql) {
38
29
  if (!fs.existsSync(DB_PATH)) return [];
39
30
  try {
40
- const db = openDb(DB_PATH, { readonly: true });
31
+ const db = openReadOnly(DB_PATH);
41
32
  const rows = db.prepare(sql).all();
42
33
  db.close();
43
34
  return rows;
@@ -94,7 +85,7 @@ function getSqliteSessions() {
94
85
  function getSqliteMessages(sessionId) {
95
86
  if (!fs.existsSync(DB_PATH)) return [];
96
87
  try {
97
- const db = openDb(DB_PATH, { readonly: true });
88
+ const db = openReadOnly(DB_PATH);
98
89
  const messages = db.prepare(
99
90
  `SELECT m.id as msg_id, m.data as msg_data, m.time_created
100
91
  FROM message m WHERE m.session_id = ? ORDER BY m.time_created ASC`
@@ -107,7 +98,7 @@ function getSqliteMessages(sessionId) {
107
98
  // yields a TRUNCATED history that looks complete — and a re-ship would replace
108
99
  // the full stored transcript with the shortened one. Report it so the shipper
109
100
  // refuses to overwrite good data with a partial read.
110
- try { msgData = JSON.parse(msg.msg_data); }
101
+ try { msgData = JSON.parse(textOf(msg.msg_data)); }
111
102
  catch (e) { adapterErrors.record('opencode', e, `message ${msg.msg_id}`); continue; }
112
103
 
113
104
  const role = msgData.role;
@@ -120,7 +111,7 @@ function getSqliteMessages(sessionId) {
120
111
  const contentParts = [];
121
112
  for (const part of parts) {
122
113
  let partData;
123
- try { partData = JSON.parse(part.data); }
114
+ try { partData = JSON.parse(textOf(part.data)); }
124
115
  catch (e) { adapterErrors.record('opencode', e, `part of message ${msg.msg_id}`); continue; }
125
116
  const type = partData.type;
126
117
 
@@ -0,0 +1,74 @@
1
+ // SQLite for the five adapters that read an editor's store directly — cursor, zed,
2
+ // opencode, goose, antigravity — plus windsurf's Devin API-key lookup.
3
+ //
4
+ // This was `better-sqlite3`, a native module whose prebuilt binary arrives through an
5
+ // npm install script — and npm >= 12 blocks install scripts by default, so a plain
6
+ // `npm i -g memhouse` left no binding at all. Those five adapters then read ZERO
7
+ // sessions and looked exactly like editors the pilot does not have. The fix was a flag
8
+ // (`--allow-scripts=better-sqlite3`) that had to be typed on every install and every
9
+ // upgrade, was never remembered by npm, and produced no error when omitted.
10
+ //
11
+ // `node:sqlite` is part of Node itself — stable in 24, no build step, no binary to
12
+ // misplace, nothing to allow. The whole "adapter silently skipped for a missing native
13
+ // binding" failure class is gone, and install is one command with no flags.
14
+ //
15
+ // Still required lazily, but for a different reason than before. On a Node older than
16
+ // the engines floor `require('node:sqlite')` throws, and editors/index.js imports every
17
+ // adapter at load time — a top-level require here would take the eleven adapters that
18
+ // need no SQLite at all down with the five that do, and `discover` would report zero
19
+ // editors. Lazy, that throw lands inside each adapter's own try/catch and is reported
20
+ // through adapter-errors like any other unreadable store.
21
+ let DatabaseSync = null;
22
+
23
+ /**
24
+ * Open an editor's store read-only.
25
+ *
26
+ * Every call site in this repo is a read, so there is no write mode to get wrong.
27
+ * better-sqlite3 spelled the option `readonly` and needed `fileMustExist` beside it to
28
+ * stop a typo'd path from quietly creating an empty database; node:sqlite spells it
29
+ * `readOnly` and refuses on its own — measured, a missing file throws ERR_SQLITE_ERROR
30
+ * "unable to open database file" rather than creating one. Callers that check
31
+ * fs.existsSync() first keep working unchanged; callers that do not still cannot
32
+ * conjure a store that is not there.
33
+ */
34
+ function openReadOnly(file) {
35
+ if (!DatabaseSync) ({ DatabaseSync } = require('node:sqlite'));
36
+ return new DatabaseSync(file, { readOnly: true });
37
+ }
38
+
39
+ /**
40
+ * A column's value as text, whether SQLite handed back TEXT or BLOB.
41
+ *
42
+ * SQLite types values, not columns, and these stores are not consistent about it: the
43
+ * VS Code family declares `ItemTable.value` as BLOB but stores strings in it, while
44
+ * Cursor's agent store keeps real bytes in the same shape of table. better-sqlite3
45
+ * returned a Buffer for the byte case and every `JSON.parse(row.value)` in these
46
+ * adapters worked by accident, because Buffer.toString() decodes utf-8.
47
+ *
48
+ * node:sqlite returns a plain Uint8Array, whose toString() renders "40,181,47,253,…" —
49
+ * a comma-joined list of byte values. That is not utf-8, not JSON, and not empty, so it
50
+ * does not fail loudly; it fails four frames deeper as a parse error against a store
51
+ * that is perfectly healthy. Every read that may be either type goes through here.
52
+ */
53
+ function textOf(v) {
54
+ if (v == null) return v;
55
+ if (typeof v === 'string') return v;
56
+ if (v instanceof Uint8Array) return Buffer.from(v.buffer, v.byteOffset, v.byteLength).toString('utf-8');
57
+ return String(v);
58
+ }
59
+
60
+ /**
61
+ * A column's value as a Buffer, for readers that slice it, hex-encode it, or hand it to
62
+ * zlib. Buffer IS a Uint8Array, so this is a no-op on anything better-sqlite3 would have
63
+ * returned — it exists so the byte-walking code below never has to ask which it got.
64
+ */
65
+ function bytesOf(v) {
66
+ if (v == null) return v;
67
+ if (Buffer.isBuffer(v)) return v;
68
+ if (typeof v === 'string') return Buffer.from(v, 'utf-8');
69
+ // A view over the same memory, not a copy — these blobs run to megabytes and every
70
+ // consumer here only reads.
71
+ return Buffer.from(v.buffer, v.byteOffset, v.byteLength);
72
+ }
73
+
74
+ module.exports = { openReadOnly, textOf, bytesOf };
@@ -484,13 +484,13 @@ function getDevinApiKey(appNames) {
484
484
  dbPath = path.join(HOME, '.config', appName, 'User', 'globalStorage', 'state.vscdb');
485
485
  }
486
486
  if (!fs.existsSync(dbPath)) continue;
487
- const Database = require('better-sqlite3');
488
- const db = new Database(dbPath, { readonly: true });
487
+ const { openReadOnly, textOf } = require('./sqlite');
488
+ const db = openReadOnly(dbPath);
489
489
  try {
490
490
  for (const key of keys) {
491
491
  const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(key);
492
492
  if (!row) continue;
493
- const parsed = JSON.parse(row.value);
493
+ const parsed = JSON.parse(textOf(row.value));
494
494
  if (parsed.apiKey) return parsed.apiKey;
495
495
  }
496
496
  } finally {
package/editors/zed.js CHANGED
@@ -2,16 +2,7 @@ const path = require('path');
2
2
  const fs = require('fs');
3
3
  const os = require('os');
4
4
 
5
- // Required lazily. A top-level require makes this native module a load-time dependency
6
- // of the whole adapter set: editors/index.js imports every adapter, so one missing
7
- // binding threw before any adapter ran and `discover` reported ZERO editors — losing the
8
- // eleven that need no native code at all. The adapterErrors path exists to degrade one
9
- // adapter at a time, and a top-level throw walks straight past it.
10
- let Database = null;
11
- function openDb(...a) {
12
- if (!Database) Database = require('better-sqlite3');
13
- return new Database(...a);
14
- }
5
+ const { openReadOnly, bytesOf } = require('./sqlite');
15
6
  const adapterErrors = require('./adapter-errors');
16
7
 
17
8
  // Zed stores data in different locations depending on the platform
@@ -72,13 +63,13 @@ function decompressZstd(buf) {
72
63
  }
73
64
 
74
65
  // ============================================================
75
- // Query SQLite using better-sqlite3 (cross-platform)
66
+ // Query SQLite using node:sqlite (cross-platform, no native dependency)
76
67
  // ============================================================
77
68
 
78
69
  function queryDb(sql) {
79
70
  if (!fs.existsSync(THREADS_DB)) return [];
80
71
  try {
81
- const db = openDb(THREADS_DB, { readonly: true });
72
+ const db = openReadOnly(THREADS_DB);
82
73
  const rows = db.prepare(sql).all();
83
74
  db.close();
84
75
  return rows;
@@ -100,7 +91,7 @@ function getThreadColumns() {
100
91
  if (threadColumns) return threadColumns;
101
92
  if (!fs.existsSync(THREADS_DB)) return new Set();
102
93
  try {
103
- const db = openDb(THREADS_DB, { readonly: true });
94
+ const db = openReadOnly(THREADS_DB);
104
95
  const cols = db.prepare('PRAGMA table_info(threads)').all().map((r) => r.name);
105
96
  db.close();
106
97
  // PRAGMA on a missing or renamed table SUCCEEDS with zero rows rather than
@@ -131,10 +122,14 @@ function resetCache() {
131
122
  function queryBlob(id) {
132
123
  if (!fs.existsSync(THREADS_DB)) return null;
133
124
  try {
134
- const db = openDb(THREADS_DB, { readonly: true });
125
+ const db = openReadOnly(THREADS_DB);
135
126
  const row = db.prepare('SELECT data FROM threads WHERE id = ?').get(id);
136
127
  db.close();
137
- return row ? row.data : null;
128
+ // As a Buffer, not the raw Uint8Array node:sqlite hands back. Both callers below
129
+ // treat it as one: the uncompressed path calls .toString('utf-8') on it, which on a
130
+ // bare Uint8Array yields "40,181,47,…" — the byte VALUES, comma-joined — and the
131
+ // thread would then fail JSON.parse as if it were corrupt.
132
+ return row ? bytesOf(row.data) : null;
138
133
  } catch (e) {
139
134
  // This is the message-blob read. A silent null here becomes a zero-message
140
135
  // session, which overwrites the stored transcript on re-ship.
@@ -141,11 +141,15 @@ memory. None ingest agent sessions.
141
141
  into *installed skills*. Our skills must be chosen. One independent analysis of 270
142
142
  real sessions found **only 4.8% involved an explicit history query** — a pull-only
143
143
  model gets invoked rarely.
144
- - **Install weight — we are the heaviest in the category.** AgentsView, deja-vu and
145
- ctx are single dependency-free binaries via Homebrew, Docker, `go install` or curl.
146
- We are Node, need `--allow-scripts=better-sqlite3` (five adapters silently read
147
- nothing without it), and additionally need a ClickHouse to point at. Our README
148
- spends paragraphs on `EACCES`/`sudo` triage. That is a real adoption tax.
144
+ - **Install weight — still the heaviest in the category, but half the tax is paid.**
145
+ AgentsView, deja-vu and ctx are single dependency-free binaries via Homebrew, Docker,
146
+ `go install` or curl. We are Node and need a ClickHouse to point at.
147
+ **Resolved 2026-08-16:** the native-dependency half is gone. `better-sqlite3` was
148
+ replaced by `node:sqlite`, so `npm i -g memhouse` with no flags is now a complete,
149
+ fully working install — no `--allow-scripts`, no build step, and no five-adapters-
150
+ silently-read-nothing failure mode. What remains of the tax is the runtime (Node,
151
+ not a static binary), the ClickHouse dependency, and the `EACCES`/`sudo` prefix
152
+ triage, which was never about the flag and is not fixable from our side.
149
153
  - **Semantic search.** AgentsView (opt-in index), claude-mem (Chroma),
150
154
  claude-history (local embedding model). We are FTS-only. Contested rather than
151
155
  settled — deja-vu made the same choice and was criticised for it on HN.
@@ -5,10 +5,12 @@ on your machines — across **all 17 editors** agentlytics supports — parsed l
5
5
  shipped to a typed ClickHouse store, shareable with a team, installable on the
6
6
  ultimagent kernel as an **agency**, and visible through the agentlytics dashboard.
7
7
 
8
- **One house, a set of rooms per member** — `sessions_<m>`, `messages_<m>`,
9
- `tool_calls_<m>`. All of one member's machines ship into that member's rooms, told
10
- apart by the `host` column; no member writes into another's. The shared-table layout
11
- this once had was removed in 0.4.0; see `per-member/`.
8
+ **A house is a database; its rooms are three shared tables** — `sessions`, `messages`,
9
+ `tool_calls`. Everyone in the house writes into the same tables with their own
10
+ credential; the server-stamped `user_id` says who, the install fingerprint `host` says
11
+ which machine. Sharing IS the house; the boundary between groups is the database. (The
12
+ layout has moved twice: shared-tables-with-row-policies fell in 0.4.0, per-member
13
+ suffixed rooms fell in 0.8.0 — see `house/HOUSE.md` for why.)
12
14
 
13
15
  **Agency, precisely.** In constellation terms (`../TERMINOLOGY.md`) memhouse is a
14
16
  **house** — the `mem` database — **plus a resident**: the shipper. That pairing
@@ -24,23 +26,21 @@ on = "loop" # a daemon — not an insert trigger, not a schedule
24
26
  ```
25
27
 
26
28
  The session rollup is a **routine** — house machinery, not a second resident. Since
27
- 0.4.0 it is not even an object: it is SQL text (`per-member/rooms.js`), substituted with
28
- the caller's own room names and computed during your query, for your query, writing
29
- nothing. memhouse has **no materialized views at all** — in fact no stored views at all
29
+ 0.4.0 it is not even an object: it is SQL text (`house/house.js`), computed during your
30
+ query, for your query, writing nothing. memhouse has **no materialized views at all** — in fact no stored views at all
30
31
  — so the shipper is not merely a resident, it is the only candidate in the tree. Strip
31
32
  it and every remaining moving part is a routine over rows nobody is writing any more.
32
33
 
33
34
  Note that `user_id MATERIALIZED currentUser()` is a materialized *column*: it computes
34
- during your own insert and is part of the table's definition. It is not what isolates
35
- members — the room they hold no grant on does that — but it records who wrote each row,
36
- which keeps provenance across a share and keeps the `Merge` rooms meaningful. An identity
37
- property, not what makes this an agency.
35
+ during your own insert and is part of the table's definition. It is what tells housemates'
36
+ rows apart in the shared tables — provenance, stamped by the server, with async_insert
37
+ pinned so it cannot be skipped. An identity property, not what makes this an agency.
38
38
 
39
39
  **Positioning:** an alternative agency **competing with memory-house**. It borrows
40
40
  memory-house's proven ideas (server-stamped identity, idempotent shipping,
41
41
  skills/plugin delivery) and agentlytics' proven assets (adapters, UI, cost engine) —
42
- but **not** its shared-table-plus-row-policy layout, which 0.4.0 removed in favour of
43
- one set of rooms per member. If it wins, it can become memory-house v4; until then the
42
+ but **not** its row-policy machinery: memhouse's tables are shared by people who chose
43
+ one house, and the boundary between houses is the database itself. If it wins, it can become memory-house v4; until then the
44
44
  two run side by side as separate agencies, each in its own house. Naming: the product is
45
45
  **memhouse**; the agency is **`memhouse`** and its house is the **`mem`** database
46
46
  (identifiers can't carry a dash; the owner kept the longer name, `memhouse_root`).
@@ -60,20 +60,19 @@ them free and further along; **bet 3 is the one nobody else attempts.**
60
60
  write axis this bet is precisely *moving work from routine to resident*:
61
61
  memory-house parses when you query, memhouse parses before anyone asks and
62
62
  writes the result down.
63
- 2. **Typed common schema** (`per-member/schema-member.sql.tpl`, applied once per
64
- member; `schema-merge.sql.tpl` for the team rooms). Physical typed columns (what
65
- memory-house derives in views) + `tool_calls_<m>` (memory-house has no tool table)
66
- + one `extra JSON` escape hatch per room so unnormalized adapter fields are never
67
- lost. `ReplacingMergeTree(ingested_at)`; `messages_<m>` keyed
68
- `(session_id, user_id, seq)` so a re-ship of a grown/changed session replaces stale
69
- rows (latest-wins). FTS text indexes built in (CH ≥ 26.2).
70
- 3. **Kernel-installable agency.** Same install path proven for agentlytics-agency:
71
- `install-agency{memhouse}` → house + `memhouse_root` + credential; members via
72
- `register-member` + owner `GRANT` on that member's own rooms. Own-only visibility
73
- needs no policy: a member is granted their rooms and nobody else's. The shipper is
74
- the resident that lands with it. Identity is `user_id MATERIALIZED currentUser()`
75
- (requires `async_insert=0`). Sharing is a further GRANT, issued by the member
76
- themselves (grant-option) or the owner.
63
+ 2. **Typed common schema** (`house/schema.sql.tpl` — three shared tables per house).
64
+ Physical typed columns (what memory-house derives in views) + `tool_calls`
65
+ (memory-house has no tool table) + one `extra JSON` escape hatch per room so
66
+ unnormalized adapter fields are never lost. `ReplacingMergeTree(ingested_at)`;
67
+ `messages` keyed `(session_id, user_id, origin, seq)` so a re-ship of a
68
+ grown/changed session replaces stale rows (latest-wins) and two housemates' rows
69
+ never collapse into one. FTS text indexes built in (CH ≥ 26.2).
70
+ 3. **Kernel-installable agency.** Joining any ClickHouse — a kernel's included — is a
71
+ database plus a credential: `CREATE USER`, `GRANT ALL ON <house>.* `, done. The
72
+ shipper is the resident that lands with it and creates its own rooms. Identity is
73
+ `user_id MATERIALIZED currentUser()` with `async_insert = 0 CONST` pinned on the
74
+ user; machines are told apart by the `host` fingerprint. The boundary between
75
+ agencies on one server is the database — a house's ALL reaches nothing outside it.
77
76
  4. **Borrowed UI, zero fork.** The React SPA consumes REST JSON, not tables. The
78
77
  memhouse server implements the **same `/api/*` contract** as agentlytics'
79
78
  `server.js` (same routes, same response shapes) over the memhouse schema, and
@@ -83,9 +82,9 @@ them free and further along; **bet 3 is the one nobody else attempts.**
83
82
 
84
83
  | Path | What | Notes |
85
84
  |---|---|---|
86
- | `per-member/rooms.js` | room-name resolution + the session rollup | one naming rule for both transports |
87
- | `per-member/schema-member.sql.tpl` | one member's three rooms (typed) | the owner via `provision.js`, or the member on a house they own |
88
- | `per-member/schema-merge.sql.tpl` | the three team Merge rooms | owner-managed; reduce to the caller's grants |
85
+ | `house/house.js` | room names + the session rollup + the user pin | one rule for both transports |
86
+ | `house/schema.sql.tpl` | the house's three shared tables (typed) | applied by each member's own shipper |
87
+ | `house/HOUSE.md` | the model, and what it replaced | |
89
88
  | `shipper/ship.js` | parse-on-client shipper CLI — the resident (`worker`) | reuses `../../editors`; incremental; idempotent |
90
89
  | `server/server.js` | REST API + dashboard | same API contract as agentlytics; serves `../../public` |
91
90
  | `delivery/` | delivery kit | installer, skills, plugin, AGENT-INSTALL.md, prompt |