memhouse 0.11.3 → 0.12.6

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
 
@@ -678,6 +687,11 @@ GRANT SELECT ON ${db}.* TO ${member} WITH GRANT OPTION;
678
687
  -- trust model, flagged by \`memhouse doctor\`, and re-asserted on every \`ship --ensure-schema\`.
679
688
  GRANT ALTER USER ON ${member} TO ${member};
680
689
 
690
+ -- See the OTHER members, read-only. SHOW USERS lets \`/mem:users\` list who is on this
691
+ -- ClickHouse (names only — no passwords, no data) so a member can find who to share with.
692
+ -- It grants no read of anyone's rows; reading a housemate's memory still needs /mem:share.
693
+ GRANT SHOW USERS ON *.* TO ${member};
694
+
681
695
  -- Pin async_insert on the user (ADD SETTING merges; a bare SETTINGS clause would replace
682
696
  -- the user's whole list). The pin keeps the user_id stamp honest: a MATERIALIZED
683
697
  -- currentUser() is computed during the INSERT, and an async flush stores it as the empty
@@ -786,8 +800,14 @@ async function adminBootstrap(cfg, admin) {
786
800
  // self-rotation.
787
801
  try { await q(`GRANT ALTER USER ON ${admin.member} TO ${admin.member}`, { database: '' }); }
788
802
  catch { /* admin without access-management: passwd stays admin-assisted for this member */ }
803
+ // See the OTHER members, read-only: SHOW USERS lets `/mem:users` list who is on this
804
+ // ClickHouse (names only — no passwords, no data) so a member can find who to share
805
+ // with. It grants no read of anyone's rows; that still needs an explicit /mem:share.
806
+ // Best-effort like ALTER USER above — an admin without access-management just skips it.
807
+ try { await q(`GRANT SHOW USERS ON *.* TO ${admin.member}`, { database: '' }); }
808
+ catch { /* no access-management: /mem:users section 4 stays admin-only for this member */ }
789
809
  await q(`ALTER USER ${admin.member} ADD SETTING ${MEMBER_PIN}`, { database: '' });
790
- console.log(ok(`granted the house: ALL ON ${cfg.db}.* to '${admin.member}', async_insert pinned`));
810
+ console.log(ok(`granted the house: ALL ON ${cfg.db}.* to '${admin.member}', SHOW USERS, async_insert pinned`));
791
811
  } catch (e) {
792
812
  console.log(bad(`could not grant the house to '${admin.member}': ${e.message}`));
793
813
  if (createdUser) {
@@ -867,6 +887,40 @@ async function sortingKeyProblem(cfg) {
867
887
  } catch { return null; } // unreachable house is a different check's problem
868
888
  }
869
889
 
890
+ // Start the shipper as a BACKGROUND daemon instead of blocking install on a full first
891
+ // pass. A fresh member's first ship loads the ENTIRE local backlog — yigido's was 558
892
+ // sessions / 224s — and running it synchronously (the old `run(SHIP_JS)`) made `install`
893
+ // sit silent for minutes and read as hung. Detached, install returns at once, the history
894
+ // loads in the background, and the loop keeps shipping. Mirrors cmdStart's shipper daemon,
895
+ // and defers to an installed service rather than running a second shipper beside it.
896
+ //
897
+ // Must be called AFTER finishInvite: rotation rewrites the env file, so the daemon has to
898
+ // be spawned from a RE-READ config (resolveConfig()) or it would carry the old password.
899
+ function startShipperBackground(cfg) {
900
+ let svc = { installed: false, running: false };
901
+ try { svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { /* unsupported platform */ }
902
+ if (svc.installed) {
903
+ console.log(svc.running
904
+ ? ok('shipping runs under the installed service — nothing to start')
905
+ : warn('a shipper service is installed but stopped — start it: memhouse service start'));
906
+ return;
907
+ }
908
+ const existing = pidOf('shipper');
909
+ if (existing) { console.log(ok(`shipper already running in the background (pid ${existing})`)); return; }
910
+ fs.mkdirSync(RUN_DIR, { recursive: true });
911
+ fs.mkdirSync(LOG_DIR, { recursive: true });
912
+ const logPath = path.join(LOG_DIR, 'shipper.log');
913
+ const log = fs.openSync(logPath, 'a');
914
+ const child = spawn(process.execPath, [SHIP_JS, '--loop', String(flags.interval || 300)], {
915
+ env: childEnv(cfg), detached: true, stdio: ['ignore', log, log],
916
+ });
917
+ fs.writeFileSync(path.join(RUN_DIR, 'shipper.pid'), String(child.pid));
918
+ child.unref();
919
+ console.log(ok(`shipper started in the background (pid ${child.pid}) — loading your history now`));
920
+ console.log(' a large first ship can take a few minutes; watch it finish with: memhouse status');
921
+ console.log(' dashboard (browse / search / analyze): memhouse start');
922
+ }
923
+
870
924
  async function cmdInstall({ interactive }) {
871
925
  // --env <file>: an INVITE intake. The file carries MEMHOUSE_URL/USER/PASSWORD/DB from
872
926
  // `memhouse invite` on the admin's machine; loading it into the environment BEFORE
@@ -962,12 +1016,14 @@ async function cmdInstall({ interactive }) {
962
1016
  console.log(ok(`host identity: ${me.id}`));
963
1017
  console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
964
1018
  }
965
- if (flags['no-ship'] !== true && run(SHIP_JS, [], cfg) !== 0) return 1;
966
1019
  console.log(ok('installed'));
967
1020
  console.log(' adding a housemate later is two statements for the admin:');
968
1021
  console.log(` CREATE USER <name> IDENTIFIED BY '…'; GRANT ALL ON ${cfg.db}.* TO <name>;`);
969
1022
  printGettingStarted(cfg);
970
1023
  await finishInvite(cfg);
1024
+ // The first ship loads the whole backlog; do it in the background so install returns
1025
+ // now. resolveConfig() re-reads the env file finishInvite may have just rotated.
1026
+ if (flags['no-ship'] !== true) startShipperBackground(resolveConfig());
971
1027
  return 0;
972
1028
  }
973
1029
 
@@ -1118,12 +1174,11 @@ async function cmdInstall({ interactive }) {
1118
1174
  console.log(ok(`host identity: ${me.id}`));
1119
1175
  console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
1120
1176
  }
1121
- if (flags['no-ship'] !== true) {
1122
- if (run(SHIP_JS, [], cfg) !== 0) return 1;
1123
- }
1124
1177
  console.log(ok('installed'));
1125
1178
  printGettingStarted(cfg);
1126
1179
  await finishInvite(cfg);
1180
+ // Background first ship — same reasoning as the invite branch above.
1181
+ if (flags['no-ship'] !== true) startShipperBackground(resolveConfig());
1127
1182
  return 0;
1128
1183
  }
1129
1184
 
@@ -2010,6 +2065,30 @@ async function cmdUpdate() {
2010
2065
  }
2011
2066
  console.log(ok('files updated'));
2012
2067
 
2068
+ // Refresh the Claude plugin wherever it is ALREADY installed, so `update` keeps the
2069
+ // skills in lockstep with the package instead of leaving a stale `/mem:*` behind. Only
2070
+ // dirs that already have it are touched — update never installs the plugin somewhere new.
2071
+ // The files copied are the ones npm/git just put on disk; the manifest is stamped with
2072
+ // the FRESH package version read off disk (this process still runs the pre-upgrade code,
2073
+ // so its in-memory PKG.version is a release behind).
2074
+ try {
2075
+ let fresh = PKG.version;
2076
+ try { fresh = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf-8')).version || fresh; } catch { /* keep in-memory */ }
2077
+ const installed = claudeTargets().filter((t) => isPluginInstalled(t.dir));
2078
+ for (const t of installed) {
2079
+ const dst = installPluginInto(t.dir);
2080
+ try {
2081
+ const mf = path.join(dst, '.claude-plugin', 'plugin.json');
2082
+ const m = JSON.parse(fs.readFileSync(mf, 'utf-8'));
2083
+ if (m.version !== fresh) { m.version = fresh; fs.writeFileSync(mf, `${JSON.stringify(m, null, 2)}\n`); }
2084
+ } catch { /* leave the stamp installPluginInto wrote */ }
2085
+ console.log(ok(`plugin refreshed in ${short(t.dir)}`));
2086
+ }
2087
+ if (installed.length) console.log(' reload it in Claude Code: /reload-plugins (or restart the session)');
2088
+ } catch (e) {
2089
+ console.log(warn(`could not refresh Claude plugins: ${e.message.split('\n')[0]}`));
2090
+ }
2091
+
2013
2092
  // Restarting is the half a bare `npm i -g` leaves undone. The daemons notice on their own
2014
2093
  // within a loop interval (memhouse/self-update.js), but a pilot who typed `update` should
2015
2094
  // not have to wait for it, and the dashboard's stale bundle is visible immediately.
@@ -2171,6 +2250,17 @@ function installPluginInto(dir) {
2171
2250
  }
2172
2251
  fs.mkdirSync(dst, { recursive: true });
2173
2252
  fs.cpSync(path.join(DELIVERY, 'plugin'), dst, { recursive: true });
2253
+ // Stamp the manifest with THIS memhouse's version. The source plugin.json carries a
2254
+ // FROZEN number — it sat at 0.11.0 through several releases, so every install
2255
+ // advertised the wrong version (`memhouse --version` said one thing, the plugin
2256
+ // another). The package version is the single truth; write it into the copy.
2257
+ try {
2258
+ const m = JSON.parse(fs.readFileSync(dstManifest, 'utf-8'));
2259
+ if (m.version !== PKG.version) {
2260
+ m.version = PKG.version;
2261
+ fs.writeFileSync(dstManifest, `${JSON.stringify(m, null, 2)}\n`);
2262
+ }
2263
+ } catch { /* manifest unreadable — leave the copied one rather than guess */ }
2174
2264
  return dst;
2175
2265
  }
2176
2266
 
@@ -2523,6 +2613,211 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
2523
2613
  return 0;
2524
2614
  }
2525
2615
 
2616
+ /**
2617
+ * `memhouse relocate --to <url>` — copy a whole house to a NEW ClickHouse, server-to-
2618
+ * server, then repoint this install at it. The point is to move WITHOUT the shipper
2619
+ * re-ingesting: once the new host holds a faithful copy, the shipper's skip predicate
2620
+ * sees every old session already present and ships only genuinely new work.
2621
+ *
2622
+ * The copy is a ClickHouse remoteSecure() INSERT SELECT — the destination pulls each room
2623
+ * directly from the source over the native protocol; the pilot's laptop is never in the
2624
+ * data path. Provenance is carried, not restamped (insert_allow_materialized_columns=1),
2625
+ * exactly as `migrate`'s rebuildRoom does, so a shared house keeps every member's user_id.
2626
+ *
2627
+ * Nothing on the SOURCE is touched — relocate only reads it and only writes the
2628
+ * destination and the local env file, so a failed run leaves the old house intact.
2629
+ *
2630
+ * SECURITY: the source password is spliced into the remoteSecure() call, which runs on
2631
+ * the DESTINATION and lands in ITS query_log. Two consequences, both stated to the pilot:
2632
+ * it never touches THIS transcript (the SQL is never printed), but rotate the source
2633
+ * credential afterward if the destination's logs are not yours to trust.
2634
+ *
2635
+ * Flags: --to (required), --to-user/--to-password/--to-db (default: the source's),
2636
+ * --from-native-port (default 9440), --insecure-native (remote()+9000, no TLS),
2637
+ * --keep-shipper (don't stop it for the copy), --dry-run, --yes.
2638
+ */
2639
+ async function cmdRelocate() {
2640
+ const rel = require(path.join(REPO_ROOT, 'memhouse', 'house', 'relocate.js'));
2641
+ const house = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
2642
+ const stated = (f) => flags[f] !== undefined && flags[f] !== true;
2643
+
2644
+ const to = flags.to;
2645
+ if (!to || to === true) { console.log(bad('relocate needs a destination: memhouse relocate --to <url>')); return 1; }
2646
+ const src = requireConfig(resolveConfig(), 'relocate');
2647
+ const dest = {
2648
+ url: to,
2649
+ user: stated('to-user') ? flags['to-user'] : src.user,
2650
+ password: stated('to-password') ? flags['to-password'] : src.password,
2651
+ db: stated('to-db') ? flags['to-db'] : src.db,
2652
+ port: src.port, stated: true,
2653
+ };
2654
+ if (sameEndpoint(src.url, dest.url)) {
2655
+ console.log(bad(`source and destination are the same server (${dest.url}) — nothing to relocate`)); return 1;
2656
+ }
2657
+ for (const [n, who] of [[src.db, 'source house'], [dest.db, 'destination house']]) {
2658
+ try { house.assertUsableName(n, who); } catch (e) { console.log(bad(e.message)); return 1; }
2659
+ }
2660
+
2661
+ const sq = (s) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
2662
+ const bq = (s) => `\`${String(s).replace(/`/g, '``')}\``;
2663
+ const tpl = fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl'), 'utf-8');
2664
+ let nat;
2665
+ try {
2666
+ nat = rel.nativeEndpoint(src.url, {
2667
+ host: stated('from-native-host') ? flags['from-native-host'] : null,
2668
+ port: stated('from-native-port') ? flags['from-native-port'] : null,
2669
+ insecure: flags['insecure-native'] === true,
2670
+ });
2671
+ } catch (e) { console.log(bad(e.message)); return 1; }
2672
+
2673
+ const countFinal = async (cfg, t) => Number((await chRows(cfg, `SELECT count() AS c FROM ${bq(t)}`, { database: cfg.db }))[0]?.c || 0);
2674
+
2675
+ // ── preflight: source reachable, current schema, and its counts ──────────────────
2676
+ let srcMember;
2677
+ try { srcMember = (await chRows(src, 'SELECT currentUser() AS u', { database: '' }))[0]?.u; }
2678
+ catch (e) { console.log(bad(`source not reachable: ${netReason(e)}`)); return 1; }
2679
+ try {
2680
+ const rec = await chRows(src, "SELECT value FROM house_meta FINAL WHERE key = 'schema_version'", { database: src.db });
2681
+ const v = rec.length ? Number(rec[0].value) || 0 : 0;
2682
+ if (v !== SCHEMA_VERSION) {
2683
+ console.log(bad(`source house is at schema ${v || 'pre-record'}; this memhouse is ${SCHEMA_VERSION}.`));
2684
+ console.log(' Migrate the source first, then relocate: memhouse migrate');
2685
+ return 1;
2686
+ }
2687
+ } catch {
2688
+ console.log(bad('source house has no house_meta — migrate it first: memhouse migrate'));
2689
+ return 1;
2690
+ }
2691
+ const base = {};
2692
+ for (const t of ROOM_TYPES) base[t] = await countFinal(src, t);
2693
+
2694
+ // ── preflight: destination reachable ─────────────────────────────────────────────
2695
+ let destMember;
2696
+ try { destMember = (await chRows(dest, 'SELECT currentUser() AS u', { database: '' }))[0]?.u; }
2697
+ catch (e) { console.log(bad(`destination not reachable: ${netReason(e)}`)); return 1; }
2698
+
2699
+ console.log(` relocate ${src.user}@${new URL(src.url).host} (house '${src.db}')`);
2700
+ console.log(` -> ${dest.user}@${new URL(dest.url).host} (house '${dest.db}')`);
2701
+ console.log(` source rooms: sessions ${base.sessions}, messages ${base.messages}, tool_calls ${base.tool_calls}`);
2702
+ console.log(` copy transport: ${nat.fn}(${nat.addr}) [source password never printed]`);
2703
+
2704
+ if (flags['dry-run'] === true) {
2705
+ console.log(warn('dry run — nothing was created, copied, or repointed'));
2706
+ return 0;
2707
+ }
2708
+ if (flags.yes !== true) {
2709
+ const a = (await ask(`Copy this house to ${new URL(dest.url).host} and repoint this install? (yes/no)`, 'no')).toLowerCase();
2710
+ if (a !== 'yes' && a !== 'y') return console.log('aborted'), 1;
2711
+ }
2712
+
2713
+ // ── freeze the source shipper (best-effort) ──────────────────────────────────────
2714
+ let restart = null;
2715
+ const health = shipperHealth();
2716
+ if (health.running && flags['keep-shipper'] !== true) {
2717
+ if (String(health.via || '').startsWith('service')) {
2718
+ const svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
2719
+ const st = svc.status();
2720
+ const stop = st.kind === 'systemd'
2721
+ ? ['systemctl', ['--user', 'stop', 'memhouse-shipper']]
2722
+ : ['launchctl', ['bootout', `gui/${process.getuid()}/com.memhouse.shipper`]];
2723
+ const r = spawnSync(stop[0], stop[1], { stdio: 'pipe', encoding: 'utf-8' });
2724
+ if (r.status === 0) { console.log(ok('shipper stopped for the copy')); restart = { kind: 'service', st }; }
2725
+ else console.log(warn(`could not stop the shipper (${(r.stderr || '').trim().split('\n')[0] || 'exit ' + r.status}); continuing — late writes dedupe`));
2726
+ } else {
2727
+ const pid = pidOf('shipper');
2728
+ if (pid) { try { process.kill(pid, 'SIGTERM'); console.log(ok(`shipper daemon stopped (pid ${pid})`)); restart = { kind: 'daemon' }; } catch { /* raced */ } }
2729
+ }
2730
+ }
2731
+
2732
+ try {
2733
+ // ── schema on the destination (no ingest) ──────────────────────────────────────
2734
+ await ch(dest, `CREATE DATABASE IF NOT EXISTS ${bq(dest.db)}`, { database: '' });
2735
+ for (const t of [...META_TYPES, ...ROOM_TYPES]) {
2736
+ await ch(dest, createStatement(tpl, t, t).replace('CREATE TABLE ', 'CREATE TABLE IF NOT EXISTS '),
2737
+ { database: dest.db, settings: { allow_experimental_full_text_index: 1 } });
2738
+ }
2739
+ const destBefore = await countFinal(dest, 'messages');
2740
+ if (destBefore > 0 && flags.yes !== true) {
2741
+ console.log(warn(`destination already holds ${destBefore} messages — copy is idempotent (ReplacingMergeTree dedupes), continuing`));
2742
+ }
2743
+
2744
+ // ── probe native reachability BEFORE the big copy (never logs the password) ─────
2745
+ const probe = `SELECT count() AS c FROM ${nat.fn}(${sq(nat.addr)}, ${sq(src.db)}, 'sessions', ${sq(src.user)}, ${sq(src.password)})`;
2746
+ try { await chRows(dest, probe, { database: dest.db, timeout: 60000 }); }
2747
+ catch (e) {
2748
+ console.log(bad(`the destination cannot reach the source over ${nat.fn} at ${nat.addr}: ${netReason(e)}`));
2749
+ console.log(' open the source native port (default 9440 TLS), or pass --from-native-port / --insecure-native.');
2750
+ throw new Error('relocate-preflight');
2751
+ }
2752
+
2753
+ // ── copy each table, provenance carried ────────────────────────────────────────
2754
+ for (const t of [...ROOM_TYPES, ...META_TYPES]) {
2755
+ 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);
2756
+ 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);
2757
+ const cols = rel.copyColumns(destCols, srcCols);
2758
+ if (!cols.length) { console.log(warn(`${t}: no shared columns — skipped`)); continue; }
2759
+ const list = cols.map(bq).join(', ');
2760
+ // house_meta carries only durable facts; the per-host heartbeats regenerate.
2761
+ const where = t === 'house_meta'
2762
+ ? " WHERE key IN ('schema_version','min_writer_schema','house_id') OR key LIKE 'share:%'" : '';
2763
+ 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}`
2764
+ + ' SETTINGS insert_allow_materialized_columns = 1, allow_experimental_full_text_index = 1';
2765
+ await ch(dest, copySql, { database: dest.db, timeout: 3600000 });
2766
+ console.log(ok(`${t}: copied`));
2767
+ }
2768
+
2769
+ // ── verify — the hard gate before repointing ───────────────────────────────────
2770
+ let allGood = true;
2771
+ for (const t of ROOM_TYPES) {
2772
+ const d = await countFinal(dest, t);
2773
+ const good = d >= base[t];
2774
+ console.log((good ? ok : bad)(`${t}: source ${base[t]} -> dest ${d}`));
2775
+ if (!good) allGood = false;
2776
+ }
2777
+ if (!allGood) {
2778
+ console.log(bad('row counts do not match — NOT repointing. The source is untouched; inspect the destination.'));
2779
+ throw new Error('relocate-verify');
2780
+ }
2781
+
2782
+ // ── repoint the local config (old env kept alongside) ──────────────────────────
2783
+ try { fs.copyFileSync(ENV_FILE, `${ENV_FILE}.pre-relocate`); console.log(ok(`previous config kept at ${`${ENV_FILE}.pre-relocate`.replace(os.homedir(), '~')}`)); }
2784
+ catch { /* no prior env file — first config */ }
2785
+ writeEnvFile(dest);
2786
+ console.log(ok(`config repointed to ${dest.url} (house '${dest.db}')`));
2787
+ } catch (e) {
2788
+ if (!['relocate-preflight', 'relocate-verify'].includes(e.message)) console.log(bad(`relocate failed: ${e.message.split('\n')[0]}`));
2789
+ console.log(warn('the SOURCE house was not touched — your data is safe there.'));
2790
+ if (restart) console.log(warn('the shipper was stopped; restart it: memhouse service start (or memhouse start)'));
2791
+ return 1;
2792
+ }
2793
+
2794
+ // ── restart the shipper against the new host ─────────────────────────────────────
2795
+ if (restart) {
2796
+ if (restart.kind === 'service') {
2797
+ const st = restart.st;
2798
+ const start = st.kind === 'systemd'
2799
+ ? ['systemctl', ['--user', 'start', 'memhouse-shipper']]
2800
+ : ['launchctl', ['bootstrap', `gui/${process.getuid()}`, st.path]];
2801
+ const r = spawnSync(start[0], start[1], { stdio: 'pipe', encoding: 'utf-8' });
2802
+ console.log(r.status === 0 ? ok('shipper restarted — now writing to the new host')
2803
+ : warn('restart the shipper yourself: memhouse service start'));
2804
+ } else {
2805
+ console.log(warn('restart the shipper to pick up the new host: memhouse start (or memhouse service start)'));
2806
+ }
2807
+ } else if (shipperHealth().running) {
2808
+ console.log(warn('a shipper is still running against the OLD host — restart it to pick up the new config.'));
2809
+ }
2810
+
2811
+ console.log('');
2812
+ console.log(ok('relocate complete.'));
2813
+ console.log(' the shipper will NOT re-ingest: every copied session is already present, so its');
2814
+ console.log(' skip predicate ships only new work from here.');
2815
+ console.log(' verify: memhouse status');
2816
+ console.log(' keep the OLD house until you are satisfied, then decommission it.');
2817
+ 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.'));
2818
+ return 0;
2819
+ }
2820
+
2526
2821
  /**
2527
2822
  * `memhouse nightly [--out DIR]` — build an installable, version-stamped tarball from
2528
2823
  * this checkout, without publishing anything.
@@ -3011,6 +3306,7 @@ async function cmdUninstall() {
3011
3306
  case 'nightly': process.exitCode = await cmdNightly(); break;
3012
3307
  case 'migrate': process.exitCode = await cmdMigrate({}); break;
3013
3308
  case 'migrate-rooms': process.exitCode = await cmdMigrate({ component: 'rooms' }); break;
3309
+ case 'relocate': process.exitCode = await cmdRelocate(); break;
3014
3310
  case 'deploy': {
3015
3311
  const dep = require(path.join(REPO_ROOT, 'memhouse', 'deploy.js'));
3016
3312
  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.6",
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
@@ -103,5 +103,19 @@ Also worth naming: `memhouse start` serves a dashboard at http://localhost:4640
103
103
  browsing and analytics outside the chat, and everything here is read-only for agents —
104
104
  only the shipper writes.
105
105
 
106
+ **Freshness check** — quietly compare the installed client to the latest release; mention
107
+ it ONLY if there's a real gap (don't clutter the welcome otherwise):
108
+
109
+ ```bash
110
+ INSTALLED=$(memhouse version 2>/dev/null | head -1 | tr -d '[:space:]')
111
+ LATEST=$(curl -sS -m 5 https://registry.npmjs.org/memhouse/latest 2>/dev/null | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
112
+ echo "installed=${INSTALLED:-unknown} latest=${LATEST:-unknown}"
113
+ ```
114
+
115
+ If the installed version is a MINOR or MAJOR behind (or many patches), add one line:
116
+ "heads-up — you're on 0.A.B, 0.C.D is out; `memhouse update` upgrades it and refreshes
117
+ these `/mem:*` skills." A patch or two behind isn't worth mentioning here. Never run the
118
+ update yourself.
119
+
106
120
  End by offering one concrete action: a search for something they mentioned recently, or
107
121
  `/mem:status` if they seem unsure it's working. One offer, not a menu.
@@ -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
@@ -106,9 +106,34 @@ rows but NO `client_*` record is a **pre-0.10 memhouse still writing** — flag
106
106
  re-ships delete retained parses, and the fixes are upgrade it or (as admin)
107
107
  `REVOKE ALTER DELETE, ALTER UPDATE ON <db>.* FROM <member>`.
108
108
 
109
+ **5. Client freshness** — is the installed memhouse behind the published release? This is a
110
+ shell check, not SQL (it reads the local binary and the npm registry, no house involved):
111
+
112
+ ```bash
113
+ INSTALLED=$(memhouse version 2>/dev/null | head -1 | tr -d '[:space:]')
114
+ LATEST=$(curl -sS -m 5 https://registry.npmjs.org/memhouse/latest 2>/dev/null | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
115
+ echo "installed=${INSTALLED:-unknown} latest=${LATEST:-unknown}"
116
+ ```
117
+
118
+ Compare the two as `MAJOR.MINOR.PATCH`. Report freshness, and size the nudge to the gap:
119
+
120
+ - **up to date** (installed ≥ latest) — one line: "client 0.X.Y, current."
121
+ - **small gap** (same major+minor, a patch or two behind) — a gentle note: "a newer patch
122
+ 0.X.Z is out; `memhouse update` when convenient."
123
+ - **big gap** (latest MAJOR or MINOR is ahead, or many patches) — surface it prominently and
124
+ **offer to update**: "your memhouse is 0.A.B but 0.C.D is released — run **`memhouse
125
+ update`** to upgrade; it also refreshes the `/mem:*` plugin, then `/reload-plugins`."
126
+ - either side **unknown** (no binary on PATH, or the registry did not answer) — say which,
127
+ don't guess. A plugin-only install with no CLI can't self-update; point at
128
+ `npm i -g memhouse`.
129
+
130
+ Never run `memhouse update` yourself — it restarts daemons and may migrate the house.
131
+ Offer the command; the pilot runs it.
132
+
109
133
  ## Presenting it
110
134
 
111
135
  Lead with one line of verdict ("healthy: N sessions across M editors, last ingest X
112
136
  minutes ago" — or the one thing wrong). Then the per-editor table, then anything from
113
- sections 3–4 that needs action. Skip empty sections; do not pad. If the house is
114
- unreachable, say which URL refused and stop — never guess another endpoint.
137
+ sections 3–5 that needs action. A **big version gap (section 5) is an action** — put the
138
+ `memhouse update` offer near the top, not buried. Skip empty sections; do not pad. If the
139
+ house is unreachable, say which URL refused and stop — never guess another endpoint.
@@ -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.6",
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": {