memhouse 0.11.3 → 0.12.5

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
@@ -141,18 +141,21 @@ function writeEnvFile(cfg) {
141
141
  }
142
142
 
143
143
  // ── ClickHouse over HTTP (small read-only queries; heavy ops go via ship.js) ───
144
- async function ch(cfg, sql, { database = cfg.db, settings = null } = {}) {
144
+ async function ch(cfg, sql, { database = cfg.db, settings = null, timeout = 30000 } = {}) {
145
145
  // Both settings, always: `final` collapses ReplacingMergeTree versions, and
146
146
  // `join_use_nulls` is what the session rollup's coalesce depends on now that it is a
147
147
  // saved query rather than a view carrying its own SETTINGS clause.
148
148
  const params = new URLSearchParams({ final: '1', join_use_nulls: '1' });
149
149
  for (const [k, v] of Object.entries(settings || {})) params.set(k, String(v));
150
150
  if (database) params.set('database', database);
151
+ // Most CLI queries are small reads and 30s is plenty; a `relocate` copy of a whole room
152
+ // runs server-side but the HTTP response only returns when it FINISHES, so a large
153
+ // transfer needs a far longer ceiling — passed per call rather than raised for all.
151
154
  const res = await fetch(`${cfg.url.replace(/\/$/, '')}/?${params}`, {
152
155
  method: 'POST',
153
156
  body: sql,
154
157
  headers: { Authorization: 'Basic ' + Buffer.from(`${cfg.user}:${cfg.password}`).toString('base64') },
155
- signal: AbortSignal.timeout(30000),
158
+ signal: AbortSignal.timeout(timeout),
156
159
  });
157
160
  const text = await res.text();
158
161
  if (!res.ok) throw new Error(text.trim().split('\n')[0]);
@@ -369,6 +372,12 @@ House deploy --local run ClickHouse in docker/podman, then install
369
372
  migrate run every migration this house still needs
370
373
  [--dry-run] [--yes] (copy + atomic swap; deletes nothing)
371
374
  migrate-rooms the same, scoped to the rooms
375
+ relocate --to URL copy this whole house to a NEW ClickHouse (server-to-
376
+ server) and repoint this install — the shipper does NOT
377
+ re-ingest. [--to-user --to-password --to-db]
378
+ [--from-native-host H] [--from-native-port N]
379
+ [--insecure-native] [--keep-shipper] [--dry-run] [--yes].
380
+ Source untouched.
372
381
  service install run the shipper as a user service (systemd / launchd)
373
382
  service stop | start | restart | uninstall | status
374
383
 
@@ -867,6 +876,40 @@ async function sortingKeyProblem(cfg) {
867
876
  } catch { return null; } // unreachable house is a different check's problem
868
877
  }
869
878
 
879
+ // Start the shipper as a BACKGROUND daemon instead of blocking install on a full first
880
+ // pass. A fresh member's first ship loads the ENTIRE local backlog — yigido's was 558
881
+ // sessions / 224s — and running it synchronously (the old `run(SHIP_JS)`) made `install`
882
+ // sit silent for minutes and read as hung. Detached, install returns at once, the history
883
+ // loads in the background, and the loop keeps shipping. Mirrors cmdStart's shipper daemon,
884
+ // and defers to an installed service rather than running a second shipper beside it.
885
+ //
886
+ // Must be called AFTER finishInvite: rotation rewrites the env file, so the daemon has to
887
+ // be spawned from a RE-READ config (resolveConfig()) or it would carry the old password.
888
+ function startShipperBackground(cfg) {
889
+ let svc = { installed: false, running: false };
890
+ try { svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { /* unsupported platform */ }
891
+ if (svc.installed) {
892
+ console.log(svc.running
893
+ ? ok('shipping runs under the installed service — nothing to start')
894
+ : warn('a shipper service is installed but stopped — start it: memhouse service start'));
895
+ return;
896
+ }
897
+ const existing = pidOf('shipper');
898
+ if (existing) { console.log(ok(`shipper already running in the background (pid ${existing})`)); return; }
899
+ fs.mkdirSync(RUN_DIR, { recursive: true });
900
+ fs.mkdirSync(LOG_DIR, { recursive: true });
901
+ const logPath = path.join(LOG_DIR, 'shipper.log');
902
+ const log = fs.openSync(logPath, 'a');
903
+ const child = spawn(process.execPath, [SHIP_JS, '--loop', String(flags.interval || 300)], {
904
+ env: childEnv(cfg), detached: true, stdio: ['ignore', log, log],
905
+ });
906
+ fs.writeFileSync(path.join(RUN_DIR, 'shipper.pid'), String(child.pid));
907
+ child.unref();
908
+ console.log(ok(`shipper started in the background (pid ${child.pid}) — loading your history now`));
909
+ console.log(' a large first ship can take a few minutes; watch it finish with: memhouse status');
910
+ console.log(' dashboard (browse / search / analyze): memhouse start');
911
+ }
912
+
870
913
  async function cmdInstall({ interactive }) {
871
914
  // --env <file>: an INVITE intake. The file carries MEMHOUSE_URL/USER/PASSWORD/DB from
872
915
  // `memhouse invite` on the admin's machine; loading it into the environment BEFORE
@@ -962,12 +1005,14 @@ async function cmdInstall({ interactive }) {
962
1005
  console.log(ok(`host identity: ${me.id}`));
963
1006
  console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
964
1007
  }
965
- if (flags['no-ship'] !== true && run(SHIP_JS, [], cfg) !== 0) return 1;
966
1008
  console.log(ok('installed'));
967
1009
  console.log(' adding a housemate later is two statements for the admin:');
968
1010
  console.log(` CREATE USER <name> IDENTIFIED BY '…'; GRANT ALL ON ${cfg.db}.* TO <name>;`);
969
1011
  printGettingStarted(cfg);
970
1012
  await finishInvite(cfg);
1013
+ // The first ship loads the whole backlog; do it in the background so install returns
1014
+ // now. resolveConfig() re-reads the env file finishInvite may have just rotated.
1015
+ if (flags['no-ship'] !== true) startShipperBackground(resolveConfig());
971
1016
  return 0;
972
1017
  }
973
1018
 
@@ -1118,12 +1163,11 @@ async function cmdInstall({ interactive }) {
1118
1163
  console.log(ok(`host identity: ${me.id}`));
1119
1164
  console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
1120
1165
  }
1121
- if (flags['no-ship'] !== true) {
1122
- if (run(SHIP_JS, [], cfg) !== 0) return 1;
1123
- }
1124
1166
  console.log(ok('installed'));
1125
1167
  printGettingStarted(cfg);
1126
1168
  await finishInvite(cfg);
1169
+ // Background first ship — same reasoning as the invite branch above.
1170
+ if (flags['no-ship'] !== true) startShipperBackground(resolveConfig());
1127
1171
  return 0;
1128
1172
  }
1129
1173
 
@@ -2010,6 +2054,30 @@ async function cmdUpdate() {
2010
2054
  }
2011
2055
  console.log(ok('files updated'));
2012
2056
 
2057
+ // Refresh the Claude plugin wherever it is ALREADY installed, so `update` keeps the
2058
+ // skills in lockstep with the package instead of leaving a stale `/mem:*` behind. Only
2059
+ // dirs that already have it are touched — update never installs the plugin somewhere new.
2060
+ // The files copied are the ones npm/git just put on disk; the manifest is stamped with
2061
+ // the FRESH package version read off disk (this process still runs the pre-upgrade code,
2062
+ // so its in-memory PKG.version is a release behind).
2063
+ try {
2064
+ let fresh = PKG.version;
2065
+ try { fresh = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf-8')).version || fresh; } catch { /* keep in-memory */ }
2066
+ const installed = claudeTargets().filter((t) => isPluginInstalled(t.dir));
2067
+ for (const t of installed) {
2068
+ const dst = installPluginInto(t.dir);
2069
+ try {
2070
+ const mf = path.join(dst, '.claude-plugin', 'plugin.json');
2071
+ const m = JSON.parse(fs.readFileSync(mf, 'utf-8'));
2072
+ if (m.version !== fresh) { m.version = fresh; fs.writeFileSync(mf, `${JSON.stringify(m, null, 2)}\n`); }
2073
+ } catch { /* leave the stamp installPluginInto wrote */ }
2074
+ console.log(ok(`plugin refreshed in ${short(t.dir)}`));
2075
+ }
2076
+ if (installed.length) console.log(' reload it in Claude Code: /reload-plugins (or restart the session)');
2077
+ } catch (e) {
2078
+ console.log(warn(`could not refresh Claude plugins: ${e.message.split('\n')[0]}`));
2079
+ }
2080
+
2013
2081
  // Restarting is the half a bare `npm i -g` leaves undone. The daemons notice on their own
2014
2082
  // within a loop interval (memhouse/self-update.js), but a pilot who typed `update` should
2015
2083
  // not have to wait for it, and the dashboard's stale bundle is visible immediately.
@@ -2171,6 +2239,17 @@ function installPluginInto(dir) {
2171
2239
  }
2172
2240
  fs.mkdirSync(dst, { recursive: true });
2173
2241
  fs.cpSync(path.join(DELIVERY, 'plugin'), dst, { recursive: true });
2242
+ // Stamp the manifest with THIS memhouse's version. The source plugin.json carries a
2243
+ // FROZEN number — it sat at 0.11.0 through several releases, so every install
2244
+ // advertised the wrong version (`memhouse --version` said one thing, the plugin
2245
+ // another). The package version is the single truth; write it into the copy.
2246
+ try {
2247
+ const m = JSON.parse(fs.readFileSync(dstManifest, 'utf-8'));
2248
+ if (m.version !== PKG.version) {
2249
+ m.version = PKG.version;
2250
+ fs.writeFileSync(dstManifest, `${JSON.stringify(m, null, 2)}\n`);
2251
+ }
2252
+ } catch { /* manifest unreadable — leave the copied one rather than guess */ }
2174
2253
  return dst;
2175
2254
  }
2176
2255
 
@@ -2523,6 +2602,211 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
2523
2602
  return 0;
2524
2603
  }
2525
2604
 
2605
+ /**
2606
+ * `memhouse relocate --to <url>` — copy a whole house to a NEW ClickHouse, server-to-
2607
+ * server, then repoint this install at it. The point is to move WITHOUT the shipper
2608
+ * re-ingesting: once the new host holds a faithful copy, the shipper's skip predicate
2609
+ * sees every old session already present and ships only genuinely new work.
2610
+ *
2611
+ * The copy is a ClickHouse remoteSecure() INSERT SELECT — the destination pulls each room
2612
+ * directly from the source over the native protocol; the pilot's laptop is never in the
2613
+ * data path. Provenance is carried, not restamped (insert_allow_materialized_columns=1),
2614
+ * exactly as `migrate`'s rebuildRoom does, so a shared house keeps every member's user_id.
2615
+ *
2616
+ * Nothing on the SOURCE is touched — relocate only reads it and only writes the
2617
+ * destination and the local env file, so a failed run leaves the old house intact.
2618
+ *
2619
+ * SECURITY: the source password is spliced into the remoteSecure() call, which runs on
2620
+ * the DESTINATION and lands in ITS query_log. Two consequences, both stated to the pilot:
2621
+ * it never touches THIS transcript (the SQL is never printed), but rotate the source
2622
+ * credential afterward if the destination's logs are not yours to trust.
2623
+ *
2624
+ * Flags: --to (required), --to-user/--to-password/--to-db (default: the source's),
2625
+ * --from-native-port (default 9440), --insecure-native (remote()+9000, no TLS),
2626
+ * --keep-shipper (don't stop it for the copy), --dry-run, --yes.
2627
+ */
2628
+ async function cmdRelocate() {
2629
+ const rel = require(path.join(REPO_ROOT, 'memhouse', 'house', 'relocate.js'));
2630
+ const house = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
2631
+ const stated = (f) => flags[f] !== undefined && flags[f] !== true;
2632
+
2633
+ const to = flags.to;
2634
+ if (!to || to === true) { console.log(bad('relocate needs a destination: memhouse relocate --to <url>')); return 1; }
2635
+ const src = requireConfig(resolveConfig(), 'relocate');
2636
+ const dest = {
2637
+ url: to,
2638
+ user: stated('to-user') ? flags['to-user'] : src.user,
2639
+ password: stated('to-password') ? flags['to-password'] : src.password,
2640
+ db: stated('to-db') ? flags['to-db'] : src.db,
2641
+ port: src.port, stated: true,
2642
+ };
2643
+ if (sameEndpoint(src.url, dest.url)) {
2644
+ console.log(bad(`source and destination are the same server (${dest.url}) — nothing to relocate`)); return 1;
2645
+ }
2646
+ for (const [n, who] of [[src.db, 'source house'], [dest.db, 'destination house']]) {
2647
+ try { house.assertUsableName(n, who); } catch (e) { console.log(bad(e.message)); return 1; }
2648
+ }
2649
+
2650
+ const sq = (s) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
2651
+ const bq = (s) => `\`${String(s).replace(/`/g, '``')}\``;
2652
+ const tpl = fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl'), 'utf-8');
2653
+ let nat;
2654
+ try {
2655
+ nat = rel.nativeEndpoint(src.url, {
2656
+ host: stated('from-native-host') ? flags['from-native-host'] : null,
2657
+ port: stated('from-native-port') ? flags['from-native-port'] : null,
2658
+ insecure: flags['insecure-native'] === true,
2659
+ });
2660
+ } catch (e) { console.log(bad(e.message)); return 1; }
2661
+
2662
+ const countFinal = async (cfg, t) => Number((await chRows(cfg, `SELECT count() AS c FROM ${bq(t)}`, { database: cfg.db }))[0]?.c || 0);
2663
+
2664
+ // ── preflight: source reachable, current schema, and its counts ──────────────────
2665
+ let srcMember;
2666
+ try { srcMember = (await chRows(src, 'SELECT currentUser() AS u', { database: '' }))[0]?.u; }
2667
+ catch (e) { console.log(bad(`source not reachable: ${netReason(e)}`)); return 1; }
2668
+ try {
2669
+ const rec = await chRows(src, "SELECT value FROM house_meta FINAL WHERE key = 'schema_version'", { database: src.db });
2670
+ const v = rec.length ? Number(rec[0].value) || 0 : 0;
2671
+ if (v !== SCHEMA_VERSION) {
2672
+ console.log(bad(`source house is at schema ${v || 'pre-record'}; this memhouse is ${SCHEMA_VERSION}.`));
2673
+ console.log(' Migrate the source first, then relocate: memhouse migrate');
2674
+ return 1;
2675
+ }
2676
+ } catch {
2677
+ console.log(bad('source house has no house_meta — migrate it first: memhouse migrate'));
2678
+ return 1;
2679
+ }
2680
+ const base = {};
2681
+ for (const t of ROOM_TYPES) base[t] = await countFinal(src, t);
2682
+
2683
+ // ── preflight: destination reachable ─────────────────────────────────────────────
2684
+ let destMember;
2685
+ try { destMember = (await chRows(dest, 'SELECT currentUser() AS u', { database: '' }))[0]?.u; }
2686
+ catch (e) { console.log(bad(`destination not reachable: ${netReason(e)}`)); return 1; }
2687
+
2688
+ console.log(` relocate ${src.user}@${new URL(src.url).host} (house '${src.db}')`);
2689
+ console.log(` -> ${dest.user}@${new URL(dest.url).host} (house '${dest.db}')`);
2690
+ console.log(` source rooms: sessions ${base.sessions}, messages ${base.messages}, tool_calls ${base.tool_calls}`);
2691
+ console.log(` copy transport: ${nat.fn}(${nat.addr}) [source password never printed]`);
2692
+
2693
+ if (flags['dry-run'] === true) {
2694
+ console.log(warn('dry run — nothing was created, copied, or repointed'));
2695
+ return 0;
2696
+ }
2697
+ if (flags.yes !== true) {
2698
+ const a = (await ask(`Copy this house to ${new URL(dest.url).host} and repoint this install? (yes/no)`, 'no')).toLowerCase();
2699
+ if (a !== 'yes' && a !== 'y') return console.log('aborted'), 1;
2700
+ }
2701
+
2702
+ // ── freeze the source shipper (best-effort) ──────────────────────────────────────
2703
+ let restart = null;
2704
+ const health = shipperHealth();
2705
+ if (health.running && flags['keep-shipper'] !== true) {
2706
+ if (String(health.via || '').startsWith('service')) {
2707
+ const svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
2708
+ const st = svc.status();
2709
+ const stop = st.kind === 'systemd'
2710
+ ? ['systemctl', ['--user', 'stop', 'memhouse-shipper']]
2711
+ : ['launchctl', ['bootout', `gui/${process.getuid()}/com.memhouse.shipper`]];
2712
+ const r = spawnSync(stop[0], stop[1], { stdio: 'pipe', encoding: 'utf-8' });
2713
+ if (r.status === 0) { console.log(ok('shipper stopped for the copy')); restart = { kind: 'service', st }; }
2714
+ else console.log(warn(`could not stop the shipper (${(r.stderr || '').trim().split('\n')[0] || 'exit ' + r.status}); continuing — late writes dedupe`));
2715
+ } else {
2716
+ const pid = pidOf('shipper');
2717
+ if (pid) { try { process.kill(pid, 'SIGTERM'); console.log(ok(`shipper daemon stopped (pid ${pid})`)); restart = { kind: 'daemon' }; } catch { /* raced */ } }
2718
+ }
2719
+ }
2720
+
2721
+ try {
2722
+ // ── schema on the destination (no ingest) ──────────────────────────────────────
2723
+ await ch(dest, `CREATE DATABASE IF NOT EXISTS ${bq(dest.db)}`, { database: '' });
2724
+ for (const t of [...META_TYPES, ...ROOM_TYPES]) {
2725
+ await ch(dest, createStatement(tpl, t, t).replace('CREATE TABLE ', 'CREATE TABLE IF NOT EXISTS '),
2726
+ { database: dest.db, settings: { allow_experimental_full_text_index: 1 } });
2727
+ }
2728
+ const destBefore = await countFinal(dest, 'messages');
2729
+ if (destBefore > 0 && flags.yes !== true) {
2730
+ console.log(warn(`destination already holds ${destBefore} messages — copy is idempotent (ReplacingMergeTree dedupes), continuing`));
2731
+ }
2732
+
2733
+ // ── probe native reachability BEFORE the big copy (never logs the password) ─────
2734
+ const probe = `SELECT count() AS c FROM ${nat.fn}(${sq(nat.addr)}, ${sq(src.db)}, 'sessions', ${sq(src.user)}, ${sq(src.password)})`;
2735
+ try { await chRows(dest, probe, { database: dest.db, timeout: 60000 }); }
2736
+ catch (e) {
2737
+ console.log(bad(`the destination cannot reach the source over ${nat.fn} at ${nat.addr}: ${netReason(e)}`));
2738
+ console.log(' open the source native port (default 9440 TLS), or pass --from-native-port / --insecure-native.');
2739
+ throw new Error('relocate-preflight');
2740
+ }
2741
+
2742
+ // ── copy each table, provenance carried ────────────────────────────────────────
2743
+ for (const t of [...ROOM_TYPES, ...META_TYPES]) {
2744
+ const destCols = (await chRows(dest, `SELECT name FROM system.columns WHERE database = ${sq(dest.db)} AND table = ${sq(t)}`, { database: dest.db })).map((r) => r.name);
2745
+ const srcCols = (await chRows(src, `SELECT name FROM system.columns WHERE database = ${sq(src.db)} AND table = ${sq(t)}`, { database: src.db })).map((r) => r.name);
2746
+ const cols = rel.copyColumns(destCols, srcCols);
2747
+ if (!cols.length) { console.log(warn(`${t}: no shared columns — skipped`)); continue; }
2748
+ const list = cols.map(bq).join(', ');
2749
+ // house_meta carries only durable facts; the per-host heartbeats regenerate.
2750
+ const where = t === 'house_meta'
2751
+ ? " WHERE key IN ('schema_version','min_writer_schema','house_id') OR key LIKE 'share:%'" : '';
2752
+ const copySql = `INSERT INTO ${bq(t)} (${list}) SELECT ${list} FROM ${nat.fn}(${sq(nat.addr)}, ${sq(src.db)}, ${sq(t)}, ${sq(src.user)}, ${sq(src.password)})${where}`
2753
+ + ' SETTINGS insert_allow_materialized_columns = 1, allow_experimental_full_text_index = 1';
2754
+ await ch(dest, copySql, { database: dest.db, timeout: 3600000 });
2755
+ console.log(ok(`${t}: copied`));
2756
+ }
2757
+
2758
+ // ── verify — the hard gate before repointing ───────────────────────────────────
2759
+ let allGood = true;
2760
+ for (const t of ROOM_TYPES) {
2761
+ const d = await countFinal(dest, t);
2762
+ const good = d >= base[t];
2763
+ console.log((good ? ok : bad)(`${t}: source ${base[t]} -> dest ${d}`));
2764
+ if (!good) allGood = false;
2765
+ }
2766
+ if (!allGood) {
2767
+ console.log(bad('row counts do not match — NOT repointing. The source is untouched; inspect the destination.'));
2768
+ throw new Error('relocate-verify');
2769
+ }
2770
+
2771
+ // ── repoint the local config (old env kept alongside) ──────────────────────────
2772
+ try { fs.copyFileSync(ENV_FILE, `${ENV_FILE}.pre-relocate`); console.log(ok(`previous config kept at ${`${ENV_FILE}.pre-relocate`.replace(os.homedir(), '~')}`)); }
2773
+ catch { /* no prior env file — first config */ }
2774
+ writeEnvFile(dest);
2775
+ console.log(ok(`config repointed to ${dest.url} (house '${dest.db}')`));
2776
+ } catch (e) {
2777
+ if (!['relocate-preflight', 'relocate-verify'].includes(e.message)) console.log(bad(`relocate failed: ${e.message.split('\n')[0]}`));
2778
+ console.log(warn('the SOURCE house was not touched — your data is safe there.'));
2779
+ if (restart) console.log(warn('the shipper was stopped; restart it: memhouse service start (or memhouse start)'));
2780
+ return 1;
2781
+ }
2782
+
2783
+ // ── restart the shipper against the new host ─────────────────────────────────────
2784
+ if (restart) {
2785
+ if (restart.kind === 'service') {
2786
+ const st = restart.st;
2787
+ const start = st.kind === 'systemd'
2788
+ ? ['systemctl', ['--user', 'start', 'memhouse-shipper']]
2789
+ : ['launchctl', ['bootstrap', `gui/${process.getuid()}`, st.path]];
2790
+ const r = spawnSync(start[0], start[1], { stdio: 'pipe', encoding: 'utf-8' });
2791
+ console.log(r.status === 0 ? ok('shipper restarted — now writing to the new host')
2792
+ : warn('restart the shipper yourself: memhouse service start'));
2793
+ } else {
2794
+ console.log(warn('restart the shipper to pick up the new host: memhouse start (or memhouse service start)'));
2795
+ }
2796
+ } else if (shipperHealth().running) {
2797
+ console.log(warn('a shipper is still running against the OLD host — restart it to pick up the new config.'));
2798
+ }
2799
+
2800
+ console.log('');
2801
+ console.log(ok('relocate complete.'));
2802
+ console.log(' the shipper will NOT re-ingest: every copied session is already present, so its');
2803
+ console.log(' skip predicate ships only new work from here.');
2804
+ console.log(' verify: memhouse status');
2805
+ console.log(' keep the OLD house until you are satisfied, then decommission it.');
2806
+ if (!flags['insecure-native']) console.log(warn('the source password reached the destination server (its query_log) — rotate it if those logs are not yours to trust.'));
2807
+ return 0;
2808
+ }
2809
+
2526
2810
  /**
2527
2811
  * `memhouse nightly [--out DIR]` — build an installable, version-stamped tarball from
2528
2812
  * this checkout, without publishing anything.
@@ -3011,6 +3295,7 @@ async function cmdUninstall() {
3011
3295
  case 'nightly': process.exitCode = await cmdNightly(); break;
3012
3296
  case 'migrate': process.exitCode = await cmdMigrate({}); break;
3013
3297
  case 'migrate-rooms': process.exitCode = await cmdMigrate({ component: 'rooms' }); break;
3298
+ case 'relocate': process.exitCode = await cmdRelocate(); break;
3014
3299
  case 'deploy': {
3015
3300
  const dep = require(path.join(REPO_ROOT, 'memhouse', 'deploy.js'));
3016
3301
  if (flags.down) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mem",
3
3
  "description": "Conversation memory for AI agents over the memhouse typed ClickHouse store: ask, search, browse, analyze, check, share, and invite across all 17 supported editors and machines. Skills install as /mem:hello, /mem:ask, /mem:search, /mem:sessions, /mem:share, /mem:invite, /mem:sql, /mem:status, and /mem:users.",
4
- "version": "0.11.0",
4
+ "version": "0.12.5",
5
5
  "author": {
6
6
  "name": "Ramazan Polat"
7
7
  },
@@ -39,7 +39,7 @@ if [ -z "${MEMHOUSE_URL:-}" ] || [ -z "${MEMHOUSE_USER:-}" ]; then
39
39
  fi
40
40
 
41
41
  curl -sS --fail-with-body --user "$MEMHOUSE_USER:${MEMHOUSE_PASSWORD:-}" \
42
- --data-binary @- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-mem}&final=1&join_use_nulls=1&readonly=1" <<'SQL'
42
+ --data-binary @- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-$MEMHOUSE_USER}&final=1&join_use_nulls=1&readonly=1" <<'SQL'
43
43
  <the query>
44
44
  FORMAT JSONEachRow
45
45
  SQL
@@ -57,7 +57,7 @@ about a FRIEND's memory — "how did yigit fix X", "in yigit's house", "ask yigi
57
57
  that is not `system` / `information_schema` / `default` and not your own `$MEMHOUSE_DB`
58
58
  is a house shared with you. Match the named person to one; set `HOUSE=yigit`. With no
59
59
  house named, `HOUSE=$MEMHOUSE_DB`.
60
- 2. **Point the connection at it:** replace `database=${MEMHOUSE_DB:-mem}` with
60
+ 2. **Point the connection at it:** replace `database=${MEMHOUSE_DB:-$MEMHOUSE_USER}` with
61
61
  `database=<HOUSE>` in the recipe above. The rooms (`messages`, `sessions`) resolve by
62
62
  the connection's database, so retrieval, the transcript read, and the citation all run
63
63
  against that house — leave the table names bare.
@@ -26,7 +26,7 @@ set -a; [ -f "$MH_ENV" ] && . "$MH_ENV"; set +a
26
26
  if [ -n "${MEMHOUSE_URL:-}" ] && [ -n "${MEMHOUSE_USER:-}" ]; then
27
27
  curl -sS -m 5 --user "$MEMHOUSE_USER:${MEMHOUSE_PASSWORD:-}" \
28
28
  --data-binary "SELECT uniqExact(session_id), count(), formatDateTime(min(ts),'%Y-%m-%d') FROM messages" \
29
- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-mem}&final=1&readonly=1" 2>&1
29
+ "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-$MEMHOUSE_USER}&final=1&readonly=1" 2>&1
30
30
  else
31
31
  echo "NOT_CONFIGURED"
32
32
  fi
@@ -50,7 +50,7 @@ fi
50
50
  # in 50 samples). What actually leaks a credential is PRINTING it, which the rule above
51
51
  # covers.
52
52
  curl -sS --fail-with-body --user "$MEMHOUSE_USER:${MEMHOUSE_PASSWORD:-}" \
53
- --data-binary @- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-mem}&final=1&join_use_nulls=1&readonly=1" <<'SQL'
53
+ --data-binary @- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-$MEMHOUSE_USER}&final=1&join_use_nulls=1&readonly=1" <<'SQL'
54
54
  <the query>
55
55
  FORMAT PrettyCompact
56
56
  SQL
@@ -155,7 +155,7 @@ the connection's `database`, so one change redirects every query in this skill.
155
155
  "in yigit", "house yigit", "from yigit" — set `HOUSE=yigit`; otherwise leave
156
156
  `HOUSE=$MEMHOUSE_DB`.
157
157
  2. **Point the connection at it:** in the recipe above, replace
158
- `database=${MEMHOUSE_DB:-mem}` with `database=<HOUSE>`. Leave the table names bare —
158
+ `database=${MEMHOUSE_DB:-$MEMHOUSE_USER}` with `database=<HOUSE>`. Leave the table names bare —
159
159
  they resolve to that house. Nothing else in the query changes.
160
160
  3. **A house wins over `in <project>`.** A name that matches a readable house is a HOUSE,
161
161
  not a project filter — otherwise "in yigit" silently filters YOUR house's project
@@ -57,7 +57,7 @@ fi
57
57
  # in 50 samples). What actually leaks a credential is PRINTING it, which the rule above
58
58
  # covers.
59
59
  curl -sS --fail-with-body --user "$MEMHOUSE_USER:${MEMHOUSE_PASSWORD:-}" \
60
- --data-binary @- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-mem}&final=1&join_use_nulls=1&readonly=1" <<'SQL'
60
+ --data-binary @- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-$MEMHOUSE_USER}&final=1&join_use_nulls=1&readonly=1" <<'SQL'
61
61
  <the query>
62
62
  FORMAT PrettyCompact
63
63
  SQL
@@ -39,7 +39,7 @@ fi
39
39
  # NO readonly=1 on this skill's grant/revoke calls — GRANT is refused under readonly.
40
40
  # The list query below still pins it.
41
41
  curl -sS --fail-with-body --user "$MEMHOUSE_USER:${MEMHOUSE_PASSWORD:-}" \
42
- --data-binary @- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-mem}" <<'SQL'
42
+ --data-binary @- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-$MEMHOUSE_USER}" <<'SQL'
43
43
  <the statement>
44
44
  SQL
45
45
  ```
@@ -84,7 +84,7 @@ fi
84
84
  # in 50 samples). What actually leaks a credential is PRINTING it, which the rule above
85
85
  # covers.
86
86
  curl -sS --fail-with-body --user "$MEMHOUSE_USER:${MEMHOUSE_PASSWORD:-}" \
87
- --data-binary @- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-mem}&final=1&join_use_nulls=1&readonly=1" <<'SQL'
87
+ --data-binary @- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-$MEMHOUSE_USER}&final=1&join_use_nulls=1&readonly=1" <<'SQL'
88
88
  <the query>
89
89
  FORMAT PrettyCompact
90
90
  SQL
@@ -106,7 +106,7 @@ friend's memory by pointing the connection at THEIR house — same credentials,
106
106
  `database`. `SHOW DATABASES` lists what you may read; a name that is not `system` /
107
107
  `information_schema` / `default` and not your own `$MEMHOUSE_DB` is a house shared with
108
108
  you. When the user names one ("query yigit's house", "on yigit", "from yigit"), replace
109
- `database=${MEMHOUSE_DB:-mem}` with `database=<house>` in the connection recipe and leave
109
+ `database=${MEMHOUSE_DB:-$MEMHOUSE_USER}` with `database=<house>` in the connection recipe and leave
110
110
  the table names bare — or, to join across houses in one query, qualify tables explicitly
111
111
  (`yigit.messages`). Read-only: a refused SELECT means that house was not shared with you.
112
112
 
@@ -36,7 +36,7 @@ if [ -z "${MEMHOUSE_URL:-}" ] || [ -z "${MEMHOUSE_USER:-}" ]; then
36
36
  fi
37
37
 
38
38
  curl -sS --fail-with-body --user "$MEMHOUSE_USER:${MEMHOUSE_PASSWORD:-}" \
39
- --data-binary @- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-mem}&final=1&join_use_nulls=1&readonly=1" <<'SQL'
39
+ --data-binary @- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-$MEMHOUSE_USER}&final=1&join_use_nulls=1&readonly=1" <<'SQL'
40
40
  <the query>
41
41
  FORMAT PrettyCompact
42
42
  SQL
@@ -37,7 +37,7 @@ if [ -z "${MEMHOUSE_URL:-}" ] || [ -z "${MEMHOUSE_USER:-}" ]; then
37
37
  fi
38
38
 
39
39
  curl -sS --fail-with-body --user "$MEMHOUSE_USER:${MEMHOUSE_PASSWORD:-}" \
40
- --data-binary @- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-mem}&readonly=1" <<'SQL'
40
+ --data-binary @- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-$MEMHOUSE_USER}&readonly=1" <<'SQL'
41
41
  <the query>
42
42
  FORMAT PrettyCompact
43
43
  SQL
@@ -0,0 +1,59 @@
1
+ // Pure helpers for `memhouse relocate` — the host-to-host copy that moves a whole house
2
+ // to a new ClickHouse and repoints the shipper, WITHOUT the shipper re-ingesting from
3
+ // local session files. The orchestration lives in bin/memhouse.js (cmdRelocate); the
4
+ // transport-free, side-effect-free decisions live here so they can be unit-tested.
5
+ //
6
+ // See docs/design/host-repoint-reconciliation.md for the wider design this is one piece
7
+ // of. Relocate is the "data does follow the pointer, on purpose" path.
8
+
9
+ // The two room columns that are MATERIALIZED and DERIVED from `text` — the lowercased,
10
+ // text-indexed copies. They are recomputed by the destination table's own expression on
11
+ // INSERT, so they are never carried across; carrying them would also fail, since they are
12
+ // not insertable even with insert_allow_materialized_columns.
13
+ const DERIVED = ['text_ngram', 'text_word'];
14
+
15
+ // house_meta keys that are DURABLE facts of the house — worth carrying to the new host.
16
+ // Everything else in house_meta is a per-host heartbeat (client_version, client_schema,
17
+ // last_ship:<writer>) that the new shipper rewrites for itself on its first run; carrying
18
+ // a stale one would misreport which machine last shipped until that run lands.
19
+ function isDurableMetaKey(key) {
20
+ return key === 'schema_version'
21
+ || key === 'min_writer_schema'
22
+ || key === 'house_id' // forward-compat: harmless if absent today
23
+ || /^share:/.test(key);
24
+ }
25
+
26
+ /**
27
+ * The columns to carry when copying `srcTable` into `destTable`, given both sides' column
28
+ * lists. The intersection (a column the destination lacks cannot be written; one the
29
+ * source lacks cannot be read) minus the derived pair. Order follows the destination, so
30
+ * the SELECT and INSERT lists line up positionally.
31
+ */
32
+ function copyColumns(destCols, srcCols) {
33
+ const src = new Set(srcCols);
34
+ return destCols.filter((c) => src.has(c) && !DERIVED.includes(c));
35
+ }
36
+
37
+ /**
38
+ * Resolve the ClickHouse NATIVE endpoint to read the source house from, given its HTTP(S)
39
+ * URL. remoteSecure()/remote() speak the native TCP protocol, NOT HTTP — a different port
40
+ * from the one in MEMHOUSE_URL. Secure (TLS) native defaults to 9440; plain to 9000.
41
+ *
42
+ * @param url the source MEMHOUSE_URL (http(s)://host:8123)
43
+ * @param opts.host override the host — the DESTINATION's route to the source may differ
44
+ * from the pilot's URL (NAT, split-horizon DNS, a private peering name)
45
+ * @param opts.port override the native port
46
+ * @param opts.insecure use remote() + plain 9000 instead of remoteSecure() + 9440
47
+ * @returns { fn: 'remoteSecure'|'remote', host, port, addr: 'host:port' }
48
+ */
49
+ function nativeEndpoint(url, { host = null, port = null, insecure = false } = {}) {
50
+ if (!host) {
51
+ try { host = new URL(url).hostname; } catch { throw new Error(`source URL is not a URL: ${url}`); }
52
+ }
53
+ if (!host) throw new Error(`source URL has no host: ${url}`);
54
+ const fn = insecure ? 'remote' : 'remoteSecure';
55
+ const p = String(port || (insecure ? 9000 : 9440));
56
+ return { fn, host, port: p, addr: `${host}:${p}` };
57
+ }
58
+
59
+ module.exports = { DERIVED, isDurableMetaKey, copyColumns, nativeEndpoint };
@@ -9,8 +9,9 @@
9
9
  // columns instead of a raw JSON blob.
10
10
  //
11
11
  // Data-plane rules (binding, see DESIGN.md):
12
- // - JSONEachRow, batches <= 2000 rows, async_insert=0 on EVERY insert — the house
13
- // stamps user_id MATERIALIZED currentUser(), which is empty during async flushes.
12
+ // - JSONEachRow, gzip-compressed request body, batches capped at 2000 rows OR ~4 MB
13
+ // (whichever first), async_insert=0 on EVERY insert — the house stamps user_id
14
+ // MATERIALIZED currentUser(), which is empty during async flushes.
14
15
  // - DateTime64 values travel as 'YYYY-MM-DD HH:MM:SS.mmm' UTC strings (plain
15
16
  // format; ISO 'T'/'Z' forms parse unreliably under JSONEachRow). Nullable → null.
16
17
  // - Int64-bound values are integer-coerced (some adapters emit fractional ms).
@@ -68,7 +69,12 @@ const {
68
69
  // house_events would otherwise roll out to nobody.
69
70
  const ALL_TABLES = [...ROOM_TYPES, ...META_TYPES];
70
71
 
71
- const BATCH_ROWS = 2000; // insert batch ceiling (binding)
72
+ const BATCH_ROWS = 2000; // insert batch ceiling by ROW COUNT
73
+ // …and by BYTES. A row cap alone let a batch of 2000 wide messages reach ~27 MB, and
74
+ // pushing that up through a proxy/tunnel is what dominated insert time (see createClient).
75
+ // Flush at whichever ceiling hits first so no single upload is enormous, even uncompressed.
76
+ // Override with MEMHOUSE_BATCH_BYTES for a tighter (slow link) or looser (LAN) path.
77
+ const BATCH_BYTES = Math.max(256 * 1024, Number(process.env.MEMHOUSE_BATCH_BYTES) || 4 * 1024 * 1024);
72
78
  const TEXT_MAX = 50000; // messages.text truncation
73
79
  const ARGS_MAX = 20000; // tool_calls.args truncation
74
80
 
@@ -218,6 +224,11 @@ function makeClient() {
218
224
  username: process.env.MEMHOUSE_USER,
219
225
  password: process.env.MEMHOUSE_PASSWORD || '',
220
226
  database: process.env.MEMHOUSE_DB || process.env.MEMHOUSE_USER, // house defaults to the user's own name
227
+ // gzip the INSERT body. Measured on a real house behind a Cloudflare tunnel: a
228
+ // 2000-row batch was ~27 MB of JSON and the insert spent 76s in NetworkReceive alone
229
+ // (index build was 0.8s, CPU 1.1s) — the upload, not ClickHouse, was the whole cost.
230
+ // Conversation text compresses ~5-10x, so this turns tens of MB on the wire into a few.
231
+ compression: { request: true },
221
232
  request_timeout: 300000, // full re-ships move tens of MB; don't cut inserts short
222
233
  clickhouse_settings: {
223
234
  // Int64/UInt64 back as JSON numbers — our values (counts, tokens) are < 2^53.
@@ -1011,6 +1022,7 @@ async function runShip(client, opts = {}) {
1011
1022
  reportAdapterErrors(warned);
1012
1023
 
1013
1024
  const batches = { sessions: [], messages: [], tool_calls: [] };
1025
+ const batchBytes = { sessions: 0, messages: 0, tool_calls: 0 };
1014
1026
  const flush = async (table) => {
1015
1027
  if (!batches[table].length) return;
1016
1028
  await client.insert({
@@ -1020,10 +1032,15 @@ async function runShip(client, opts = {}) {
1020
1032
  clickhouse_settings: { async_insert: 0 }, // binding: user_id stamping breaks otherwise
1021
1033
  });
1022
1034
  batches[table] = [];
1035
+ batchBytes[table] = 0;
1023
1036
  };
1024
1037
  const push = async (table, row) => {
1025
1038
  batches[table].push(row);
1026
- if (batches[table].length >= BATCH_ROWS) await flush(table);
1039
+ // Accumulate the serialized size so a few very wide rows (a 50k-char message) flush the
1040
+ // batch as readily as 2000 small ones. The client re-serializes on insert; this extra
1041
+ // stringify is trivial next to the network time it exists to bound.
1042
+ batchBytes[table] += Buffer.byteLength(JSON.stringify(row));
1043
+ if (batches[table].length >= BATCH_ROWS || batchBytes[table] >= BATCH_BYTES) await flush(table);
1027
1044
  };
1028
1045
 
1029
1046
  const seen = new Set(); // adapters must not double-ship a session id within a pass
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memhouse",
3
- "version": "0.11.3",
3
+ "version": "0.12.5",
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": {