memhouse 0.4.2 → 0.4.4
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/bin/memhouse.js +139 -4
- package/editors/antigravity.js +11 -2
- package/editors/cursor.js +16 -7
- package/editors/opencode.js +12 -3
- package/editors/zed.js +13 -4
- package/mem-house/delivery/AGENT-INSTALL-PROMPT.md +58 -0
- package/mem-house/delivery/AGENT-INSTALL.md +7 -1
- package/mem-house/per-member/schema-member.sql.tpl +39 -3
- package/mem-house/shipper/ship.js +65 -3
- package/package.json +1 -1
package/bin/memhouse.js
CHANGED
|
@@ -320,6 +320,8 @@ Data ship one incremental pass (--full | --loop [sec])
|
|
|
320
320
|
|
|
321
321
|
Agents plugins list | install claude [--target DIR] | remove claude
|
|
322
322
|
prompt print the memory system-prompt snippet
|
|
323
|
+
prompt --install print an install prompt for an agent, with this
|
|
324
|
+
machine's state and the one route that applies
|
|
323
325
|
|
|
324
326
|
House deploy --local run ClickHouse in docker/podman, then install
|
|
325
327
|
deploy --down remove the local house (container + volume)
|
|
@@ -394,6 +396,129 @@ async function cmdDiscover() {
|
|
|
394
396
|
return out;
|
|
395
397
|
}
|
|
396
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Render the agent install prompt with this machine's facts.
|
|
401
|
+
*
|
|
402
|
+
* The value is in what it removes: the CLI has already probed for a house, an engine and
|
|
403
|
+
* an existing config, so the prompt can state the ONE route that applies. A static
|
|
404
|
+
* runbook makes the agent derive that from a decision tree, which is where it picks the
|
|
405
|
+
* wrong branch — most expensively by asking a user with no ClickHouse for a ClickHouse
|
|
406
|
+
* URL, which is exactly what onboard itself used to do.
|
|
407
|
+
*/
|
|
408
|
+
async function renderInstallPrompt() {
|
|
409
|
+
const tplPath = path.join(DELIVERY, 'AGENT-INSTALL-PROMPT.md');
|
|
410
|
+
const tpl = fs.readFileSync(tplPath, 'utf-8');
|
|
411
|
+
const body = tpl.split('<!-- PROMPT BODY BELOW -->')[1] || tpl;
|
|
412
|
+
|
|
413
|
+
const cfg = resolveConfig();
|
|
414
|
+
const configured = fs.existsSync(ENV_FILE);
|
|
415
|
+
const engines = deployableEngines();
|
|
416
|
+
|
|
417
|
+
// Probe rather than assume. JSON_OUT is forced off so cmdDiscover returns its object
|
|
418
|
+
// instead of printing JSON and returning undefined.
|
|
419
|
+
const saved = JSON_OUT;
|
|
420
|
+
let found = null;
|
|
421
|
+
try {
|
|
422
|
+
found = await withSuppressedOutput(() => cmdDiscover());
|
|
423
|
+
} catch { /* fall through to the unknown-state wording */ } finally { void saved; }
|
|
424
|
+
|
|
425
|
+
const editors = (found && found.editors || []).filter((e) => e.sessions > 0);
|
|
426
|
+
const reachable = (found && found.clickhouse || []).filter((p) => p.reachable);
|
|
427
|
+
const houseUp = reachable.length > 0;
|
|
428
|
+
|
|
429
|
+
// Is an existing install actually working, or configured-but-broken? A broken one is
|
|
430
|
+
// the case a plain "install it" prompt handles worst.
|
|
431
|
+
let state = 'not installed';
|
|
432
|
+
if (configured) {
|
|
433
|
+
let ok0 = false;
|
|
434
|
+
try { ok0 = !!(await ch(cfg, 'SELECT 1')); } catch { ok0 = false; }
|
|
435
|
+
state = ok0 ? 'installed and connected' : 'configured, but the house does not answer';
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
let plan;
|
|
439
|
+
if (state === 'installed and connected') {
|
|
440
|
+
plan = `memhouse is already installed and connected to \`${cfg.url}\` (database \`${cfg.db}\`).
|
|
441
|
+
**Do not reinstall.** Verify and hand back:
|
|
442
|
+
|
|
443
|
+
1. \`memhouse doctor\` — every line should be a check mark.
|
|
444
|
+
2. \`memhouse status\` — note the session and message counts.
|
|
445
|
+
3. If the shipper is not running: \`memhouse start\`.
|
|
446
|
+
4. If \`doctor\` reports rooms missing, the house may predate 0.4.0; it will say so by
|
|
447
|
+
name and print the recovery. Follow what it prints rather than reinstalling.`;
|
|
448
|
+
} else if (state.startsWith('configured')) {
|
|
449
|
+
plan = `memhouse is configured to use \`${cfg.url}\` (database \`${cfg.db}\`) but that
|
|
450
|
+
endpoint does not answer. Do NOT reconfigure before finding out why.
|
|
451
|
+
|
|
452
|
+
1. Is the house simply stopped? If it was a local container, \`memhouse deploy --local\`
|
|
453
|
+
brings the same volume back — it does not lose data.
|
|
454
|
+
2. Is it a remote house the user's network cannot reach right now? Ask them; do not
|
|
455
|
+
repoint their config at something else on your own initiative.
|
|
456
|
+
3. Only once the user says the old house is gone for good should you set up a new one,
|
|
457
|
+
and then say plainly that past sessions re-ship from local transcripts but anything
|
|
458
|
+
whose transcript was deleted lives only in the old house.`;
|
|
459
|
+
} else if (houseUp) {
|
|
460
|
+
plan = `A ClickHouse is already reachable at \`${reachable[0].url}\`. Use it.
|
|
461
|
+
|
|
462
|
+
1. \`npm install -g memhouse --allow-scripts=better-sqlite3\`
|
|
463
|
+
The flag is not optional — six adapters (cursor, zed, opencode, goose, windsurf,
|
|
464
|
+
antigravity) read SQLite stores and silently drop out without the native build.
|
|
465
|
+
2. Ask the user for the ClickHouse username and password for that endpoint.
|
|
466
|
+
3. \`memhouse install --url ${reachable[0].url} --user <user> --password <pw>\`
|
|
467
|
+
If they hold admin on it and want memhouse to create the user, database, rooms and
|
|
468
|
+
grants for them, use \`--admin-user\`/\`--admin-password\` instead; memhouse then
|
|
469
|
+
reconnects as the new member and never stores the admin credential.
|
|
470
|
+
If they would rather run the SQL themselves, \`memhouse install --print-sql\` prints
|
|
471
|
+
it and touches nothing.
|
|
472
|
+
4. \`memhouse start\`.`;
|
|
473
|
+
} else if (engines.length) {
|
|
474
|
+
plan = `There is no ClickHouse to point at, but **${engines.join(' and ')}** is available,
|
|
475
|
+
so memhouse can run one:
|
|
476
|
+
|
|
477
|
+
1. \`npm install -g memhouse --allow-scripts=better-sqlite3\`
|
|
478
|
+
The flag is not optional — six adapters read SQLite stores and silently drop out
|
|
479
|
+
without the native build.
|
|
480
|
+
2. \`memhouse deploy --local\`
|
|
481
|
+
This starts a loopback-only ClickHouse, generates a credential, installs, creates the
|
|
482
|
+
user's rooms and runs the first ship. One command, no questions.
|
|
483
|
+
3. \`memhouse start\`.
|
|
484
|
+
|
|
485
|
+
${process.platform === 'linux' && engines.includes('podman')
|
|
486
|
+
? 'On Linux with rootless podman the container stops at logout unless lingering is on.\nIf memhouse warns about this, run what it prints (`loginctl enable-linger <user>`)\nand tell the user why it matters.' : ''}`;
|
|
487
|
+
} else {
|
|
488
|
+
plan = `There is no ClickHouse reachable and no container engine to run one with. You
|
|
489
|
+
cannot finish this install alone — say so rather than improvising.
|
|
490
|
+
|
|
491
|
+
Tell the user they need one of:
|
|
492
|
+
- a ClickHouse they already run (Cloud, a server, a kernel house) plus its credentials;
|
|
493
|
+
- docker or podman installed, after which \`memhouse deploy --local\` does everything.
|
|
494
|
+
|
|
495
|
+
You can still do the harmless half now: \`npm install -g memhouse --allow-scripts=better-sqlite3\`,
|
|
496
|
+
then \`memhouse discover\` to show them what would be shipped once a house exists.`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const out = body
|
|
500
|
+
.replaceAll('{{VERSION}}', PKG.version)
|
|
501
|
+
.replaceAll('{{PLATFORM}}', `${process.platform}/${process.arch}`)
|
|
502
|
+
.replaceAll('{{NODE}}', process.versions.node)
|
|
503
|
+
.replaceAll('{{STATE}}', state)
|
|
504
|
+
.replaceAll('{{EDITORS}}', editors.length
|
|
505
|
+
? editors.map((e) => `${e.source} (${e.sessions})`).join(', ')
|
|
506
|
+
: 'none found — memhouse would ship nothing until an editor is used')
|
|
507
|
+
.replaceAll('{{HOUSE}}', houseUp ? reachable.map((p) => p.url).join(', ') : 'none reachable')
|
|
508
|
+
.replaceAll('{{ENGINES}}', engines.length ? engines.join(', ') : 'none (no docker, no podman)')
|
|
509
|
+
.replaceAll('{{PLAN}}', plan.trim())
|
|
510
|
+
.replaceAll('{{PORT}}', String(cfg.port));
|
|
511
|
+
process.stdout.write(out.trimStart());
|
|
512
|
+
return 0;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Run a printing function with stdout swallowed, and give back its return value. */
|
|
516
|
+
async function withSuppressedOutput(fn) {
|
|
517
|
+
const write = process.stdout.write.bind(process.stdout);
|
|
518
|
+
process.stdout.write = () => true;
|
|
519
|
+
try { return await fn(); } finally { process.stdout.write = write; }
|
|
520
|
+
}
|
|
521
|
+
|
|
397
522
|
/**
|
|
398
523
|
* Can this machine stand up a house of its own?
|
|
399
524
|
*
|
|
@@ -483,9 +608,13 @@ function memberSql(db, member, password) {
|
|
|
483
608
|
-- 26.x accepts the setting as a no-op. The SET below covers any client that keeps a
|
|
484
609
|
-- session — clickhouse-client, a GUI. Over HTTP, where each statement is its own
|
|
485
610
|
-- request and SET does not persist, put it in the URL instead:
|
|
486
|
-
-- curl "\$URL/?allow_experimental_full_text_index=1
|
|
487
|
-
--
|
|
488
|
-
--
|
|
611
|
+
-- curl "\$URL/?allow_experimental_full_text_index=1" --data-binary "<one statement>"
|
|
612
|
+
--
|
|
613
|
+
-- One statement PER REQUEST over HTTP. The interface refuses a body carrying several
|
|
614
|
+
-- ("Multi-statements are not allowed"), and there is no setting that changes that —
|
|
615
|
+
-- 'multiquery' is a clickhouse-client flag and sending it as an HTTP param fails the
|
|
616
|
+
-- whole request with UNKNOWN_SETTING. Split on ';' and post each in turn, or use
|
|
617
|
+
-- clickhouse-client, which takes the file whole.
|
|
489
618
|
|
|
490
619
|
SET allow_experimental_full_text_index = 1;
|
|
491
620
|
|
|
@@ -1123,7 +1252,13 @@ function cmdUninstall() {
|
|
|
1123
1252
|
break;
|
|
1124
1253
|
}
|
|
1125
1254
|
case 'plugins': process.exitCode = cmdPlugins(); break;
|
|
1126
|
-
case 'prompt':
|
|
1255
|
+
case 'prompt':
|
|
1256
|
+
// Two audiences, two prompts. Bare `prompt` is the memory-USAGE snippet that goes
|
|
1257
|
+
// into a running agent's system prompt; `--install` is the one you hand an agent
|
|
1258
|
+
// that has not installed memhouse yet.
|
|
1259
|
+
if (flags.install === true) process.exitCode = await renderInstallPrompt();
|
|
1260
|
+
else process.stdout.write(fs.readFileSync(path.join(DELIVERY, 'PROMPT.md'), 'utf-8'));
|
|
1261
|
+
break;
|
|
1127
1262
|
case 'reset': process.exitCode = await cmdReset(); break;
|
|
1128
1263
|
case 'deploy': {
|
|
1129
1264
|
const dep = require(path.join(REPO_ROOT, 'mem-house', 'deploy.js'));
|
package/editors/antigravity.js
CHANGED
|
@@ -2,7 +2,16 @@ 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
|
-
|
|
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
|
+
}
|
|
6
15
|
const { getAppDataPath } = require('./base');
|
|
7
16
|
const adapterErrors = require('./adapter-errors');
|
|
8
17
|
|
|
@@ -235,7 +244,7 @@ function readGlobalStateValue(key) {
|
|
|
235
244
|
|
|
236
245
|
let db = null;
|
|
237
246
|
try {
|
|
238
|
-
db =
|
|
247
|
+
db = openDb(ANTIGRAVITY_GLOBAL_STORAGE_DB, { readonly: true, fileMustExist: true });
|
|
239
248
|
const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(key);
|
|
240
249
|
if (!row) return null;
|
|
241
250
|
const v = row.value;
|
package/editors/cursor.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
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
|
+
}
|
|
2
11
|
const path = require('path');
|
|
3
12
|
const fs = require('fs');
|
|
4
13
|
const os = require('os');
|
|
@@ -147,7 +156,7 @@ function getWorkspaceMap() {
|
|
|
147
156
|
|
|
148
157
|
function getComposerHeaders(stateDbPath) {
|
|
149
158
|
try {
|
|
150
|
-
const db =
|
|
159
|
+
const db = openDb(stateDbPath, { readonly: true });
|
|
151
160
|
const row = db.prepare("SELECT value FROM ItemTable WHERE key = 'composer.composerData'").get();
|
|
152
161
|
db.close();
|
|
153
162
|
if (!row) return [];
|
|
@@ -277,7 +286,7 @@ function getChats() {
|
|
|
277
286
|
// Source 1: ~/.cursor/chats store.db
|
|
278
287
|
for (const { workspace, chatId, dbPath } of getAgentStoreChats()) {
|
|
279
288
|
try {
|
|
280
|
-
const db =
|
|
289
|
+
const db = openDb(dbPath, { readonly: true });
|
|
281
290
|
const meta = readStoreMeta(db);
|
|
282
291
|
db.close();
|
|
283
292
|
if (meta) {
|
|
@@ -316,7 +325,7 @@ function getChats() {
|
|
|
316
325
|
|
|
317
326
|
// Source 2: workspaceStorage composers
|
|
318
327
|
let globalDb = null;
|
|
319
|
-
try { globalDb =
|
|
328
|
+
try { globalDb = openDb(GLOBAL_STORAGE_DB, { readonly: true }); }
|
|
320
329
|
catch (e) {
|
|
321
330
|
// Absent is normal — Cursor may simply not be installed. Unreadable is not.
|
|
322
331
|
if (fs.existsSync(GLOBAL_STORAGE_DB)) adapterErrors.record('cursor', e, GLOBAL_STORAGE_DB);
|
|
@@ -369,7 +378,7 @@ function getChats() {
|
|
|
369
378
|
|
|
370
379
|
function getMessages(chat) {
|
|
371
380
|
if (chat._type === 'agent-store') {
|
|
372
|
-
const db =
|
|
381
|
+
const db = openDb(chat._dbPath, { readonly: true });
|
|
373
382
|
const msgs = collectStoreMessages(db, chat._rootBlobId);
|
|
374
383
|
db.close();
|
|
375
384
|
// Use lastUsedModel as fallback for assistant messages without model info
|
|
@@ -382,7 +391,7 @@ function getMessages(chat) {
|
|
|
382
391
|
}
|
|
383
392
|
|
|
384
393
|
let globalDb;
|
|
385
|
-
try { globalDb =
|
|
394
|
+
try { globalDb = openDb(GLOBAL_STORAGE_DB, { readonly: true }); }
|
|
386
395
|
catch (e) {
|
|
387
396
|
// Returning [] unreported would let a re-ship overwrite this session's stored
|
|
388
397
|
// transcript with nothing.
|
|
@@ -407,7 +416,7 @@ function getMessages(chat) {
|
|
|
407
416
|
|
|
408
417
|
function getCursorAccessToken() {
|
|
409
418
|
try {
|
|
410
|
-
const db =
|
|
419
|
+
const db = openDb(GLOBAL_STORAGE_DB, { readonly: true });
|
|
411
420
|
const row = db.prepare("SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'").get();
|
|
412
421
|
db.close();
|
|
413
422
|
return row ? row.value : null;
|
package/editors/opencode.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const os = require('os');
|
|
4
|
-
|
|
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
|
+
}
|
|
5
14
|
const adapterErrors = require('./adapter-errors');
|
|
6
15
|
|
|
7
16
|
// OpenCode stores data in XDG-style paths across all platforms
|
|
@@ -28,7 +37,7 @@ const DB_PATH = getOpenCodeDbPath();
|
|
|
28
37
|
function queryDb(sql) {
|
|
29
38
|
if (!fs.existsSync(DB_PATH)) return [];
|
|
30
39
|
try {
|
|
31
|
-
const db =
|
|
40
|
+
const db = openDb(DB_PATH, { readonly: true });
|
|
32
41
|
const rows = db.prepare(sql).all();
|
|
33
42
|
db.close();
|
|
34
43
|
return rows;
|
|
@@ -85,7 +94,7 @@ function getSqliteSessions() {
|
|
|
85
94
|
function getSqliteMessages(sessionId) {
|
|
86
95
|
if (!fs.existsSync(DB_PATH)) return [];
|
|
87
96
|
try {
|
|
88
|
-
const db =
|
|
97
|
+
const db = openDb(DB_PATH, { readonly: true });
|
|
89
98
|
const messages = db.prepare(
|
|
90
99
|
`SELECT m.id as msg_id, m.data as msg_data, m.time_created
|
|
91
100
|
FROM message m WHERE m.session_id = ? ORDER BY m.time_created ASC`
|
package/editors/zed.js
CHANGED
|
@@ -2,7 +2,16 @@ const path = require('path');
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const os = require('os');
|
|
4
4
|
|
|
5
|
-
|
|
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
|
+
}
|
|
6
15
|
const adapterErrors = require('./adapter-errors');
|
|
7
16
|
|
|
8
17
|
// Zed stores data in different locations depending on the platform
|
|
@@ -69,7 +78,7 @@ function decompressZstd(buf) {
|
|
|
69
78
|
function queryDb(sql) {
|
|
70
79
|
if (!fs.existsSync(THREADS_DB)) return [];
|
|
71
80
|
try {
|
|
72
|
-
const db =
|
|
81
|
+
const db = openDb(THREADS_DB, { readonly: true });
|
|
73
82
|
const rows = db.prepare(sql).all();
|
|
74
83
|
db.close();
|
|
75
84
|
return rows;
|
|
@@ -91,7 +100,7 @@ function getThreadColumns() {
|
|
|
91
100
|
if (threadColumns) return threadColumns;
|
|
92
101
|
if (!fs.existsSync(THREADS_DB)) return new Set();
|
|
93
102
|
try {
|
|
94
|
-
const db =
|
|
103
|
+
const db = openDb(THREADS_DB, { readonly: true });
|
|
95
104
|
const cols = db.prepare('PRAGMA table_info(threads)').all().map((r) => r.name);
|
|
96
105
|
db.close();
|
|
97
106
|
// PRAGMA on a missing or renamed table SUCCEEDS with zero rows rather than
|
|
@@ -122,7 +131,7 @@ function resetCache() {
|
|
|
122
131
|
function queryBlob(id) {
|
|
123
132
|
if (!fs.existsSync(THREADS_DB)) return null;
|
|
124
133
|
try {
|
|
125
|
-
const db =
|
|
134
|
+
const db = openDb(THREADS_DB, { readonly: true });
|
|
126
135
|
const row = db.prepare('SELECT data FROM threads WHERE id = ?').get(id);
|
|
127
136
|
db.close();
|
|
128
137
|
return row ? row.data : null;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# memhouse — agent install prompt (template)
|
|
2
|
+
|
|
3
|
+
`memhouse prompt --install` renders this with THIS machine's facts substituted and
|
|
4
|
+
prints it. Feed the output to a coding agent; it has everything needed to install and
|
|
5
|
+
onboard without asking the user to look anything up.
|
|
6
|
+
|
|
7
|
+
The point of rendering rather than shipping a static runbook: the CLI has already
|
|
8
|
+
probed the machine, so the prompt states the route that applies instead of making the
|
|
9
|
+
agent reason through a decision tree and possibly pick wrong. `AGENT-INSTALL.md` is the
|
|
10
|
+
static long-form version, and it predates `deploy --local`.
|
|
11
|
+
|
|
12
|
+
Placeholders are `{{NAME}}`. Everything below the marker is the prompt body.
|
|
13
|
+
|
|
14
|
+
<!-- PROMPT BODY BELOW -->
|
|
15
|
+
You are installing **memhouse** for the user on this machine.
|
|
16
|
+
|
|
17
|
+
memhouse gives a coding agent long-term memory of past sessions. It reads the session
|
|
18
|
+
transcripts your editors already write to disk — 17 of them — parses them locally, and
|
|
19
|
+
ships typed rows into ClickHouse. Nothing is intercepted or proxied; it reads files that
|
|
20
|
+
already exist.
|
|
21
|
+
|
|
22
|
+
## What this machine looks like right now
|
|
23
|
+
|
|
24
|
+
- memhouse version: **{{VERSION}}**
|
|
25
|
+
- platform: {{PLATFORM}}, node {{NODE}}
|
|
26
|
+
- current state: **{{STATE}}**
|
|
27
|
+
- editors with sessions found: {{EDITORS}}
|
|
28
|
+
- reachable ClickHouse: {{HOUSE}}
|
|
29
|
+
- container engines available: {{ENGINES}}
|
|
30
|
+
|
|
31
|
+
## Your task
|
|
32
|
+
|
|
33
|
+
{{PLAN}}
|
|
34
|
+
|
|
35
|
+
## Rules that matter
|
|
36
|
+
|
|
37
|
+
- **Never invent a credential.** If a step needs a password for a house the user already
|
|
38
|
+
runs, ask them for it. Do not guess, do not read it out of a file they did not name.
|
|
39
|
+
- **Do not run `deploy --down`.** It destroys the container *and its volume*. It is never
|
|
40
|
+
part of an install.
|
|
41
|
+
- The install writes `~/.memhouse/env` at mode 0600. Do not print its contents; do not
|
|
42
|
+
echo a password into the transcript you are writing, which memhouse will then ship.
|
|
43
|
+
- If a command fails, read what it printed before retrying. memhouse's failures name the
|
|
44
|
+
cause and usually name the fix — it is not a generic tool that fails generically.
|
|
45
|
+
- Rooms are named for the ClickHouse user: `sessions_<them>`, `messages_<them>`,
|
|
46
|
+
`tool_calls_<them>`. There are no unsuffixed tables. If you write a query, suffix it.
|
|
47
|
+
|
|
48
|
+
## When you are done
|
|
49
|
+
|
|
50
|
+
Run `memhouse doctor` and report its output to the user. Every line should be a check
|
|
51
|
+
mark. Then tell them:
|
|
52
|
+
|
|
53
|
+
- where the dashboard is (`http://localhost:{{PORT}}`) and that `memhouse start` runs it
|
|
54
|
+
- that `memhouse search <terms>` searches every past session from every editor
|
|
55
|
+
- that you can now search their history yourself, and offer to try one query
|
|
56
|
+
|
|
57
|
+
If `doctor` reports a failure, say which line failed and what memhouse suggested. Do not
|
|
58
|
+
declare success on a partial install.
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
# mem-house — agent install
|
|
2
2
|
|
|
3
3
|
Imperative steps for an AI coding agent installing mem-house on a machine. Execute
|
|
4
|
-
in order; every command is copy-pasteable.
|
|
4
|
+
in order; every command is copy-pasteable.
|
|
5
|
+
|
|
6
|
+
**Prefer `memhouse prompt --install`.** It renders `AGENT-INSTALL-PROMPT.md` against the
|
|
7
|
+
machine it runs on — which editors have sessions, whether a ClickHouse answers, whether
|
|
8
|
+
docker or podman exists — and states the one route that applies. This file is the static
|
|
9
|
+
long-form version and assumes a reachable ClickHouse already exists, which is the case
|
|
10
|
+
`deploy --local` was added to remove. Prereqs: Node >= 20.19 and a reachable
|
|
5
11
|
ClickHouse >= 26.2 (local server, Cloud, or a kernel house — see
|
|
6
12
|
`kernel-install.md` for the kernel path). No checkout required — memhouse is on npm.
|
|
7
13
|
|
|
@@ -25,11 +25,23 @@ CREATE TABLE IF NOT EXISTS sessions_{{MEMBER}}
|
|
|
25
25
|
message_count UInt32 DEFAULT 0,
|
|
26
26
|
path String DEFAULT '',
|
|
27
27
|
extra JSON,
|
|
28
|
+
-- Who put this row here. The shipper CLEARS a session before re-inserting it (a
|
|
29
|
+
-- shorter re-parse must not leave a stale seq tail), and that clear used to remove
|
|
30
|
+
-- every row for the session regardless of origin -- destroying imported history the
|
|
31
|
+
-- adapters cannot reproduce. The clear now binds origin='ship', so anything imported
|
|
32
|
+
-- survives a re-ship of the same session.
|
|
33
|
+
-- IN THE SORTING KEY, and that is the load-bearing half. Guarding the DELETE alone
|
|
34
|
+
-- is not enough: ReplacingMergeTree collapses on the sorting key, so an imported row
|
|
35
|
+
-- and a shipped row sharing (session_id, user_id, seq) are the SAME row and the newer
|
|
36
|
+
-- ingested_at wins. Measured: 3 imported + 2 shipped rows became 3, and the two
|
|
37
|
+
-- imported ones the shipper happened to overlap were gone. Same loss as the
|
|
38
|
+
-- unguarded delete, reached through the merge instead.
|
|
39
|
+
origin LowCardinality(String) DEFAULT 'ship',
|
|
28
40
|
user_id String MATERIALIZED currentUser(),
|
|
29
41
|
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
|
|
30
42
|
)
|
|
31
43
|
ENGINE = ReplacingMergeTree(ingested_at)
|
|
32
|
-
ORDER BY (session_id, user_id);
|
|
44
|
+
ORDER BY (session_id, user_id, origin);
|
|
33
45
|
|
|
34
46
|
CREATE TABLE IF NOT EXISTS messages_{{MEMBER}}
|
|
35
47
|
(
|
|
@@ -50,6 +62,18 @@ CREATE TABLE IF NOT EXISTS messages_{{MEMBER}}
|
|
|
50
62
|
is_subagent Bool DEFAULT false,
|
|
51
63
|
extra JSON,
|
|
52
64
|
line_hash UInt64,
|
|
65
|
+
-- Who put this row here. The shipper CLEARS a session before re-inserting it (a
|
|
66
|
+
-- shorter re-parse must not leave a stale seq tail), and that clear used to remove
|
|
67
|
+
-- every row for the session regardless of origin -- destroying imported history the
|
|
68
|
+
-- adapters cannot reproduce. The clear now binds origin='ship', so anything imported
|
|
69
|
+
-- survives a re-ship of the same session.
|
|
70
|
+
-- IN THE SORTING KEY, and that is the load-bearing half. Guarding the DELETE alone
|
|
71
|
+
-- is not enough: ReplacingMergeTree collapses on the sorting key, so an imported row
|
|
72
|
+
-- and a shipped row sharing (session_id, user_id, seq) are the SAME row and the newer
|
|
73
|
+
-- ingested_at wins. Measured: 3 imported + 2 shipped rows became 3, and the two
|
|
74
|
+
-- imported ones the shipper happened to overlap were gone. Same loss as the
|
|
75
|
+
-- unguarded delete, reached through the merge instead.
|
|
76
|
+
origin LowCardinality(String) DEFAULT 'ship',
|
|
53
77
|
user_id String MATERIALIZED currentUser(),
|
|
54
78
|
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3),
|
|
55
79
|
text_ngram String MATERIALIZED lower(text),
|
|
@@ -58,7 +82,7 @@ CREATE TABLE IF NOT EXISTS messages_{{MEMBER}}
|
|
|
58
82
|
INDEX idx_text_word text_word TYPE text(tokenizer = splitByNonAlpha) GRANULARITY 1
|
|
59
83
|
)
|
|
60
84
|
ENGINE = ReplacingMergeTree(ingested_at)
|
|
61
|
-
ORDER BY (session_id, user_id, seq);
|
|
85
|
+
ORDER BY (session_id, user_id, origin, seq);
|
|
62
86
|
|
|
63
87
|
CREATE TABLE IF NOT EXISTS tool_calls_{{MEMBER}}
|
|
64
88
|
(
|
|
@@ -72,11 +96,23 @@ CREATE TABLE IF NOT EXISTS tool_calls_{{MEMBER}}
|
|
|
72
96
|
ts DateTime64(3, 'UTC'),
|
|
73
97
|
project String DEFAULT '',
|
|
74
98
|
folder String DEFAULT '',
|
|
99
|
+
-- Who put this row here. The shipper CLEARS a session before re-inserting it (a
|
|
100
|
+
-- shorter re-parse must not leave a stale seq tail), and that clear used to remove
|
|
101
|
+
-- every row for the session regardless of origin -- destroying imported history the
|
|
102
|
+
-- adapters cannot reproduce. The clear now binds origin='ship', so anything imported
|
|
103
|
+
-- survives a re-ship of the same session.
|
|
104
|
+
-- IN THE SORTING KEY, and that is the load-bearing half. Guarding the DELETE alone
|
|
105
|
+
-- is not enough: ReplacingMergeTree collapses on the sorting key, so an imported row
|
|
106
|
+
-- and a shipped row sharing (session_id, user_id, seq) are the SAME row and the newer
|
|
107
|
+
-- ingested_at wins. Measured: 3 imported + 2 shipped rows became 3, and the two
|
|
108
|
+
-- imported ones the shipper happened to overlap were gone. Same loss as the
|
|
109
|
+
-- unguarded delete, reached through the merge instead.
|
|
110
|
+
origin LowCardinality(String) DEFAULT 'ship',
|
|
75
111
|
user_id String MATERIALIZED currentUser(),
|
|
76
112
|
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
|
|
77
113
|
)
|
|
78
114
|
ENGINE = ReplacingMergeTree(ingested_at)
|
|
79
|
-
ORDER BY (session_id, user_id, idx);
|
|
115
|
+
ORDER BY (session_id, user_id, origin, idx);
|
|
80
116
|
|
|
81
117
|
-- NO sessions_v HERE, deliberately. The session rollup is a SAVED QUERY substituted
|
|
82
118
|
-- with these room names and run under the caller's own credential (see rooms.js
|
|
@@ -33,7 +33,7 @@ const crypto = require('crypto');
|
|
|
33
33
|
const { createClient } = require('@clickhouse/client');
|
|
34
34
|
const { getAllChats, getAdapterErrors, getMessages, resetCaches } = require('../../editors');
|
|
35
35
|
const adapterErrorSink = require('../../editors/adapter-errors');
|
|
36
|
-
const { resolveRooms, READ_SETTINGS } = require('../per-member/rooms');
|
|
36
|
+
const { resolveRooms, READ_SETTINGS, ROOM_TYPES } = require('../per-member/rooms');
|
|
37
37
|
|
|
38
38
|
const BATCH_ROWS = 2000; // insert batch ceiling (binding)
|
|
39
39
|
const TEXT_MAX = 50000; // messages.text truncation
|
|
@@ -112,8 +112,39 @@ function makeClient() {
|
|
|
112
112
|
// It does NOT issue grants or create the Merge rooms. A solo owner needs neither. Adding a
|
|
113
113
|
// SECOND member — grants, Merge rooms, sharing — is owner work and lives in
|
|
114
114
|
// ../per-member/provision.js.
|
|
115
|
+
/**
|
|
116
|
+
* Refuse to write into rooms whose sorting key predates `origin`.
|
|
117
|
+
*
|
|
118
|
+
* Called at the top of EVERY ship pass, not just --ensure-schema: a plain `memhouse ship`
|
|
119
|
+
* never touches ensureSchema, so a guard living only there is a guard that never runs on
|
|
120
|
+
* the path that does the damage. Found exactly that way — the refusal was in place and
|
|
121
|
+
* 137 sessions shipped straight past it into a stale-key house.
|
|
122
|
+
*/
|
|
123
|
+
async function assertOriginKeyed(client, rooms) {
|
|
124
|
+
const stale = [];
|
|
125
|
+
for (const t of ROOM_TYPES) {
|
|
126
|
+
const rs = await client.query({
|
|
127
|
+
query: `SELECT sorting_key FROM system.tables WHERE database = currentDatabase() AND name = {n:String}`,
|
|
128
|
+
query_params: { n: rooms[t] }, format: 'JSONEachRow',
|
|
129
|
+
});
|
|
130
|
+
const key = ((await rs.json())[0] || {}).sorting_key || '';
|
|
131
|
+
if (key && !/\borigin\b/.test(key)) stale.push(`${rooms[t]} (${key})`);
|
|
132
|
+
}
|
|
133
|
+
if (stale.length) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
'these rooms predate the origin sorting key, so imported rows would still be lost:\n'
|
|
136
|
+
+ stale.map((s) => ` ${s}`).join('\n')
|
|
137
|
+
+ '\n ORDER BY cannot be altered in place. Rebuild each room, then re-ship:'
|
|
138
|
+
+ '\n RENAME TABLE <room> TO <room>_old;'
|
|
139
|
+
+ '\n -- recreate from mem-house/per-member/schema-member.sql.tpl'
|
|
140
|
+
+ '\n INSERT INTO <room> SELECT *, \'ship\' AS origin FROM <room>_old;'
|
|
141
|
+
+ '\n A house with no imported rows can also just be re-shipped from scratch.');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
115
145
|
async function ensureSchema(client) {
|
|
116
|
-
const
|
|
146
|
+
const rooms = await resolveRooms(client);
|
|
147
|
+
const { member } = rooms;
|
|
117
148
|
const tpl = fs.readFileSync(path.join(__dirname, '..', 'per-member', 'schema-member.sql.tpl'), 'utf-8');
|
|
118
149
|
const sql = tpl.replaceAll('{{MEMBER}}', member);
|
|
119
150
|
const stripped = sql.split('\n').map((l) => l.replace(/--.*$/, '')).join('\n');
|
|
@@ -128,6 +159,29 @@ async function ensureSchema(client) {
|
|
|
128
159
|
clickhouse_settings: { async_insert: 0, allow_experimental_full_text_index: 1 },
|
|
129
160
|
});
|
|
130
161
|
}
|
|
162
|
+
// A house created before origin existed has no such column, and the clear binds it —
|
|
163
|
+
// an unguarded DELETE there would be the old destructive behaviour, and a guarded one
|
|
164
|
+
// would error. Add it in place; ReplacingMergeTree backfills the DEFAULT, so every
|
|
165
|
+
// pre-existing row reads as 'ship', which is what it was.
|
|
166
|
+
//
|
|
167
|
+
// Deliberately NOT applied to the Merge rooms: they take their structure from a member
|
|
168
|
+
// room at CREATE time and reject ALTER. A Merge room simply will not expose `origin`
|
|
169
|
+
// until it is recreated, which costs nothing — nothing reads origin through it.
|
|
170
|
+
for (const t of ROOM_TYPES) {
|
|
171
|
+
try {
|
|
172
|
+
await client.command({
|
|
173
|
+
query: `ALTER TABLE ${rooms[t]} ADD COLUMN IF NOT EXISTS origin LowCardinality(String) DEFAULT 'ship'`,
|
|
174
|
+
// 25.11 refuses ANY alter on a table carrying the messages text indexes unless
|
|
175
|
+
// this is set — including an ADD COLUMN that has nothing to do with them
|
|
176
|
+
// (Code: 344, SUPPORT_IS_DISABLED). Without it the backfill fails silently and
|
|
177
|
+
// the guard ends up missing on precisely the houses that need upgrading. A no-op
|
|
178
|
+
// on 26.x, and query-scoped, so it needs no server config.
|
|
179
|
+
clickhouse_settings: { allow_experimental_full_text_index: 1 },
|
|
180
|
+
});
|
|
181
|
+
} catch { /* no rights to alter is not fatal: a member on someone else's house */ }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await assertOriginKeyed(client, rooms);
|
|
131
185
|
return stmts.length;
|
|
132
186
|
}
|
|
133
187
|
|
|
@@ -285,6 +339,7 @@ async function runShip(client, opts = {}) {
|
|
|
285
339
|
// message/tool rows first (a shrunken re-parse leaves stale higher-seq rows
|
|
286
340
|
// otherwise: ReplacingMergeTree collapses same-key rows only).
|
|
287
341
|
const rooms = await resolveRooms(client);
|
|
342
|
+
await assertOriginKeyed(client, rooms);
|
|
288
343
|
const existing = await loadExisting(client, rooms);
|
|
289
344
|
const chats = getAllChats();
|
|
290
345
|
|
|
@@ -373,9 +428,16 @@ async function runShip(client, opts = {}) {
|
|
|
373
428
|
// the identical predicate with the literal value deleted 2000 rows where
|
|
374
429
|
// currentUser() deleted 0. The value is the same identity either way: it is read
|
|
375
430
|
// from the server over this very connection.
|
|
431
|
+
// origin='ship' is the third bind, and it is not cosmetic. This clear exists so a
|
|
432
|
+
// shorter re-parse cannot leave a stale seq tail behind — but scoped to
|
|
433
|
+
// (session_id, user_id) alone it deletes EVERY row for the session, including rows
|
|
434
|
+
// the adapters did not write and cannot rewrite. Measured, on a real house: an
|
|
435
|
+
// import of 135,307 messages lost 27,948 of them to one ship pass, because
|
|
436
|
+
// memory-house had captured more per session than the adapters emit. The rows the
|
|
437
|
+
// shipper owns are the only rows it may remove.
|
|
376
438
|
for (const t of ['messages', 'tool_calls']) {
|
|
377
439
|
await client.command({
|
|
378
|
-
query: `DELETE FROM ${rooms[t]} WHERE session_id = {id:String} AND user_id = {uid:String}`,
|
|
440
|
+
query: `DELETE FROM ${rooms[t]} WHERE session_id = {id:String} AND user_id = {uid:String} AND origin = 'ship'`,
|
|
379
441
|
query_params: { id, uid: rooms.user },
|
|
380
442
|
clickhouse_settings: { async_insert: 0 },
|
|
381
443
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memhouse",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
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": {
|