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 +303 -7
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/memhouse/delivery/plugin/skills/ask/SKILL.md +2 -2
- package/memhouse/delivery/plugin/skills/hello/SKILL.md +15 -1
- package/memhouse/delivery/plugin/skills/search/SKILL.md +2 -2
- package/memhouse/delivery/plugin/skills/sessions/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/share/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +2 -2
- package/memhouse/delivery/plugin/skills/status/SKILL.md +28 -3
- package/memhouse/delivery/plugin/skills/users/SKILL.md +1 -1
- package/memhouse/house/relocate.js +59 -0
- package/memhouse/shipper/ship.js +21 -4
- package/package.json +1 -1
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(
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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–
|
|
114
|
-
|
|
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
|
|
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 };
|
package/memhouse/shipper/ship.js
CHANGED
|
@@ -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,
|
|
13
|
-
//
|
|
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
|
|
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
|
-
|
|
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.
|
|
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": {
|