memhouse 0.8.0 → 0.11.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 +84 -47
- package/bin/memhouse.js +993 -89
- 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 +37 -12
- package/memhouse/RESUME-AND-UPDATE.md +7 -10
- package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +10 -2
- package/memhouse/delivery/AGENT-INSTALL.md +56 -32
- package/memhouse/delivery/PROMPT.md +37 -13
- package/memhouse/delivery/kernel-install.md +4 -4
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +14 -5
- package/memhouse/delivery/plugin/skills/ask/SKILL.md +112 -0
- package/memhouse/delivery/plugin/skills/hello/SKILL.md +76 -0
- package/memhouse/delivery/plugin/skills/invite/SKILL.md +65 -0
- package/memhouse/delivery/plugin/skills/search/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/sessions/SKILL.md +3 -3
- package/memhouse/delivery/plugin/skills/share/SKILL.md +130 -0
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +34 -11
- package/memhouse/delivery/plugin/skills/status/SKILL.md +114 -0
- package/memhouse/delivery/plugin/skills/users/SKILL.md +97 -0
- package/memhouse/house/HOUSE.md +40 -5
- package/memhouse/house/house.js +191 -10
- package/memhouse/house/migrate.js +277 -0
- package/memhouse/house/migrations/0100-epoch-key.js +68 -0
- package/memhouse/house/schema.sql.tpl +96 -25
- package/memhouse/self-update.js +4 -4
- package/memhouse/service.js +7 -2
- package/memhouse/shipper/ship.js +471 -131
- package/package.json +3 -6
package/editors/antigravity.js
CHANGED
|
@@ -2,16 +2,7 @@ const { execSync, execFileSync } = require('child_process');
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const os = require('os');
|
|
5
|
-
|
|
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, textOf } = require('./sqlite');
|
|
15
6
|
const { getAppDataPath } = require('./base');
|
|
16
7
|
const adapterErrors = require('./adapter-errors');
|
|
17
8
|
|
|
@@ -244,13 +235,14 @@ function readGlobalStateValue(key) {
|
|
|
244
235
|
|
|
245
236
|
let db = null;
|
|
246
237
|
try {
|
|
247
|
-
|
|
238
|
+
// fileMustExist is gone with better-sqlite3, not lost: node:sqlite refuses to
|
|
239
|
+
// create a database it is opening read-only, and the existsSync above still stands.
|
|
240
|
+
db = openReadOnly(ANTIGRAVITY_GLOBAL_STORAGE_DB);
|
|
248
241
|
const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(key);
|
|
249
242
|
if (!row) return null;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
return v == null ? null : String(v);
|
|
243
|
+
// ItemTable.value is declared BLOB and usually holds TEXT — textOf covers both,
|
|
244
|
+
// including the Uint8Array node:sqlite returns for the byte case.
|
|
245
|
+
return row.value == null ? null : textOf(row.value);
|
|
254
246
|
} catch (e) {
|
|
255
247
|
// Only the offline half of getChats() reads this store; live cascades come over
|
|
256
248
|
// RPC. So a failure here costs some sessions, not all of them.
|
package/editors/cursor.js
CHANGED
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
|
|
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 :
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
13
|
+
// Query SQLite via node:sqlite (cross-platform, part of Node itself)
|
|
13
14
|
// ============================================================
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 =
|
|
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:
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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,
|
|
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
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
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.
|
package/editors/opencode.js
CHANGED
|
@@ -1,16 +1,7 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const os = require('os');
|
|
4
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 };
|
package/editors/windsurf.js
CHANGED
|
@@ -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
|
|
488
|
-
const db =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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.
|
package/memhouse/COMPETITION.md
CHANGED
|
@@ -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 —
|
|
145
|
-
ctx are single dependency-free binaries via Homebrew, Docker,
|
|
146
|
-
We are Node
|
|
147
|
-
|
|
148
|
-
|
|
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.
|
package/memhouse/DESIGN.md
CHANGED
|
@@ -64,9 +64,10 @@ them free and further along; **bet 3 is the one nobody else attempts.**
|
|
|
64
64
|
Physical typed columns (what memory-house derives in views) + `tool_calls`
|
|
65
65
|
(memory-house has no tool table) + one `extra JSON` escape hatch per room so
|
|
66
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
|
|
69
|
-
|
|
67
|
+
`messages` keyed `(session_id, user_id, origin, epoch, seq)` so a re-ship of a
|
|
68
|
+
grown session replaces stale rows (latest-wins), two housemates' rows never
|
|
69
|
+
collapse into one, and a re-parse that SHRANK or was rewritten lands under a new
|
|
70
|
+
epoch instead of over what it replaces. FTS text indexes built in (CH ≥ 26.2).
|
|
70
71
|
3. **Kernel-installable agency.** Joining any ClickHouse — a kernel's included — is a
|
|
71
72
|
database plus a credential: `CREATE USER`, `GRANT ALL ON <house>.* `, done. The
|
|
72
73
|
shipper is the resident that lands with it and creates its own rooms. Identity is
|
|
@@ -116,15 +117,39 @@ Config file: `~/.memhouse/config.json` (`hiddenProjects`, future prefs).
|
|
|
116
117
|
- Incremental shipping: read existing `(session_id, last_updated_at, message_count)`
|
|
117
118
|
(FINAL) and skip chats that haven't grown/changed; a full re-ship must remain
|
|
118
119
|
safe (ReplacingMergeTree collapses).
|
|
119
|
-
-
|
|
120
|
-
collapses same-key rows but cannot remove a row the new parse no
|
|
121
|
-
shrunken re-parse
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
120
|
+
- **The shipper is insert-only, and nothing may reintroduce a destructive verb.**
|
|
121
|
+
`ReplacingMergeTree` collapses same-key rows but cannot remove a row the new parse no
|
|
122
|
+
longer produces, so a shrunken re-parse leaves a stale `seq` tail. The shipper used to
|
|
123
|
+
clear the session's rows first; that destroyed content held nowhere else, because
|
|
124
|
+
Claude Code compacts transcripts and deletes them after `cleanupPeriodDays` (30 by
|
|
125
|
+
default), and a shorter re-parse cannot be told apart from a fixed adapter bug. Instead
|
|
126
|
+
`decideEpoch` bumps the session's `epoch` when the new parse is shorter than the stored
|
|
127
|
+
one, or diverges from it at an overlapping `seq` (compared on the stored `line_hash`),
|
|
128
|
+
and writes underneath the old parse rather than over it.
|
|
129
|
+
- **memhouse is an accumulator, not a mirror.** Absence of a session on disk is never a
|
|
130
|
+
signal: retention cleanup, a manual delete, a second laptop, an unmounted folder, a
|
|
131
|
+
locked SQLite file and a thrown adapter all produce the identical observation. A
|
|
132
|
+
session that disappears is never cleared and never re-inserted. **No `sync` and no
|
|
133
|
+
`prune` feature may be built here** — either would destroy the archive by design.
|
|
134
|
+
- Reads see one parse per session. `roomNames()` resolves `messages`/`tool_calls` to a
|
|
135
|
+
current-epoch subquery and the bare tables only as `*_raw`, so retention cannot silently
|
|
136
|
+
inflate a rollup, and a read path written by someone who has never heard of epochs is
|
|
137
|
+
still correct. Measured cost of the filter: **+10 ms** across the dashboard's queries on
|
|
138
|
+
a real 66k-message house (all of them under 200 ms), and **+13–37 ms** on a synthetic
|
|
139
|
+
1M-message / 20k-session one. The single shape that grows is a one-session read
|
|
140
|
+
(5 ms → 18 ms at 1M rows): the `IN` aggregates the whole room regardless of the outer
|
|
141
|
+
session filter. If that ever matters, the answer is a small materialized current-epoch
|
|
142
|
+
table maintained by the shipper — never a filter each caller has to remember.
|
|
143
|
+
- **A mutation predicate must bind the user, never call `currentUser()`.** No mutation
|
|
144
|
+
survives in the shipper, but the finding outlives it — `migrate-rooms` and `reset` still
|
|
145
|
+
run statements where it applies. A mutation does not evaluate `currentUser()` in the
|
|
146
|
+
caller's context: measured on ClickHouse 25.11 and chdb, the same `DELETE` matched 0
|
|
147
|
+
rows with `user_id = currentUser()` and 2000 with the value bound — silently, both
|
|
148
|
+
times. Read the identity once (`SELECT currentUser()`) and pass it as a query parameter.
|
|
149
|
+
The same trap bites the other way in a rebuild: `user_id` is `MATERIALIZED
|
|
150
|
+
currentUser()`, so an `INSERT SELECT` that does not carry it explicitly (under
|
|
151
|
+
`insert_allow_materialized_columns=1`) restamps every copied row with whoever ran the
|
|
152
|
+
migration.
|
|
128
153
|
- Costs are computed in the server from `pricing.js` (repo root) over per-model
|
|
129
154
|
token sums; `<synthetic>` and empty models excluded from model lists.
|
|
130
155
|
|