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 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&multiquery=1" --data-binary @-
487
- -- multiquery=1 matters because this is one POST carrying many statements; without it
488
- -- ClickHouse parses only the first and reports a syntax error on the rest.
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': process.stdout.write(fs.readFileSync(path.join(DELIVERY, 'PROMPT.md'), 'utf-8')); break;
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'));
@@ -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
- const Database = require('better-sqlite3');
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 = new Database(ANTIGRAVITY_GLOBAL_STORAGE_DB, { readonly: true, fileMustExist: true });
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
- const Database = require('better-sqlite3');
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 = new Database(stateDbPath, { readonly: true });
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 = new Database(dbPath, { readonly: true });
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 = new Database(GLOBAL_STORAGE_DB, { readonly: true }); }
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 = new Database(chat._dbPath, { readonly: true });
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 = new Database(GLOBAL_STORAGE_DB, { readonly: true }); }
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 = new Database(GLOBAL_STORAGE_DB, { readonly: true });
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;
@@ -1,7 +1,16 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs');
3
3
  const os = require('os');
4
- const Database = require('better-sqlite3');
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 = new Database(DB_PATH, { readonly: true });
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 = new Database(DB_PATH, { readonly: true });
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
- const Database = require('better-sqlite3');
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 = new Database(THREADS_DB, { readonly: true });
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 = new Database(THREADS_DB, { readonly: true });
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 = new Database(THREADS_DB, { readonly: true });
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. Prereqs: Node >= 20.19 and a reachable
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 { member } = await resolveRooms(client);
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.2",
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": {