memhouse 0.11.2 → 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/README.md +5 -0
- package/bin/memhouse.js +291 -6
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/memhouse/delivery/plugin/skills/ask/SKILL.md +21 -2
- package/memhouse/delivery/plugin/skills/hello/SKILL.md +7 -1
- package/memhouse/delivery/plugin/skills/search/SKILL.md +26 -3
- 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 +12 -2
- package/memhouse/delivery/plugin/skills/status/SKILL.md +1 -1
- 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/README.md
CHANGED
|
@@ -336,6 +336,11 @@ Deeper reading: `memhouse/DESIGN.md` (the four bets),
|
|
|
336
336
|
`memhouse/per-member/SCHEMA.md`, `memhouse/delivery/kernel-install.md`,
|
|
337
337
|
`memhouse/COMPETITION.md`.
|
|
338
338
|
|
|
339
|
+
Deferred designs (captured, not yet built): `docs/design/host-repoint-reconciliation.md`
|
|
340
|
+
— what should happen when the shipper is repointed at a new, empty host (house identity,
|
|
341
|
+
local binding state, and a refuse-then-`init-here` gate so transcripts never land on the
|
|
342
|
+
wrong server).
|
|
343
|
+
|
|
339
344
|
In constellation terms (`TERMINOLOGY.md`) memhouse is an **agency**: a **house**
|
|
340
345
|
— the `mem` database — plus a **resident** working in it, the shipper. The house
|
|
341
346
|
alone would hold; the shipper is what makes it act. It competes with
|
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
|
|
|
@@ -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.
|
|
4
|
+
"version": "0.12.5",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ramazan Polat"
|
|
7
7
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ask
|
|
3
|
-
description: Answer a question FROM conversation memory — retrieve the relevant past sessions out of the memhouse ClickHouse store, read the actual transcripts, and synthesize an answer with citations. Use when the user asks something their past work already answered, e.g. "how did I fix X last time", "what approach did we settle on for Y", "why did we choose Z", "have I solved this error before", "what did the reviewer say about W". This is the retrieve-and-answer skill; /mem:search is find-the-session, /mem:sql is run-a-query.
|
|
3
|
+
description: Answer a question FROM conversation memory — retrieve the relevant past sessions out of the memhouse ClickHouse store, read the actual transcripts, and synthesize an answer with citations. Use when the user asks something their past work already answered, e.g. "how did I fix X last time", "what approach did we settle on for Y", "why did we choose Z", "have I solved this error before", "what did the reviewer say about W". Also answers from a friend's house shared with you when they name it ("how did yigit fix X", "ask yigit's memory"). This is the retrieve-and-answer skill; /mem:search is find-the-session, /mem:sql is run-a-query.
|
|
4
4
|
user-invocable: true
|
|
5
5
|
argument-hint: "<question about past work>"
|
|
6
6
|
allowed-tools: Bash
|
|
@@ -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
|
|
@@ -47,6 +47,25 @@ SQL
|
|
|
47
47
|
|
|
48
48
|
`readonly=1` is pinned on every request: this skill never writes.
|
|
49
49
|
|
|
50
|
+
## Answering from another person's house (shared with you)
|
|
51
|
+
|
|
52
|
+
The house is a ClickHouse database; a share is a read-only GRANT on it. When the user asks
|
|
53
|
+
about a FRIEND's memory — "how did yigit fix X", "in yigit's house", "ask yigit's memory",
|
|
54
|
+
"from yigit" — answer from THEIR house without changing your credentials:
|
|
55
|
+
|
|
56
|
+
1. **Resolve the house.** `SHOW DATABASES` returns what your credential may read; a name
|
|
57
|
+
that is not `system` / `information_schema` / `default` and not your own `$MEMHOUSE_DB`
|
|
58
|
+
is a house shared with you. Match the named person to one; set `HOUSE=yigit`. With no
|
|
59
|
+
house named, `HOUSE=$MEMHOUSE_DB`.
|
|
60
|
+
2. **Point the connection at it:** replace `database=${MEMHOUSE_DB:-$MEMHOUSE_USER}` with
|
|
61
|
+
`database=<HOUSE>` in the recipe above. The rooms (`messages`, `sessions`) resolve by
|
|
62
|
+
the connection's database, so retrieval, the transcript read, and the citation all run
|
|
63
|
+
against that house — leave the table names bare.
|
|
64
|
+
3. **Cite the house.** Say whose memory the answer came from ("from yigit's house") so the
|
|
65
|
+
user never mistakes a friend's session for their own.
|
|
66
|
+
4. **Read-only, thin by design.** A refused SELECT means the house was not shared with you
|
|
67
|
+
(or the share was revoked) — say so; never guess another database.
|
|
68
|
+
|
|
50
69
|
## The method — retrieve, read, answer, cite
|
|
51
70
|
|
|
52
71
|
**Escaping, before anything touches SQL.** Search terms, session ids and user ids are
|
|
@@ -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
|
|
@@ -92,6 +92,12 @@ And around other people — **inviting comes before sharing**:
|
|
|
92
92
|
hand them one env file; they install with it and have their own memory
|
|
93
93
|
- `/mem:share <name>` — once they exist, open a read-only window into YOUR memory
|
|
94
94
|
(`/mem:share revoke <name>` closes it); `/mem:users` shows who's around
|
|
95
|
+
- **Read a friend's shared memory** — once they've shared theirs with you, name their
|
|
96
|
+
house right in the question: `/mem:ask <question> in <their-house>`, or
|
|
97
|
+
`/mem:search <terms> in <their-house>` / `/mem:sql … on <their-house>`. It reads THEIR
|
|
98
|
+
house read-only, with your own credentials; `/mem:users` lists the houses shared with
|
|
99
|
+
you. (A house is a whole ClickHouse database — sharing grants read on it, not on single
|
|
100
|
+
sessions.)
|
|
95
101
|
|
|
96
102
|
Also worth naming: `memhouse start` serves a dashboard at http://localhost:4640 for
|
|
97
103
|
browsing and analytics outside the chat, and everything here is read-only for agents —
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: search
|
|
3
|
-
description: Full-text search across ALL shipped agent conversations — every session from all 17 supported editors (Claude Code, Codex, Cursor, VS Code, Zed, OpenCode, Gemini CLI, …), every project, every machine — stored in the memhouse ClickHouse. Use whenever the user refers to something from the past that isn't in the current context, e.g. "what did I say about X", "find that conversation about Y", "when did I work on Z", "did I ever try W", "the chat where we discussed it", "remind me how I did it". Reach for this before saying you don't know about prior work.
|
|
3
|
+
description: Full-text search across ALL shipped agent conversations — every session from all 17 supported editors (Claude Code, Codex, Cursor, VS Code, Zed, OpenCode, Gemini CLI, …), every project, every machine — stored in the memhouse ClickHouse. Use whenever the user refers to something from the past that isn't in the current context, e.g. "what did I say about X", "find that conversation about Y", "when did I work on Z", "did I ever try W", "the chat where we discussed it", "remind me how I did it". Searches your own house by default, or a friend's house shared with you when they name it ("find X in yigit's memory"). Reach for this before saying you don't know about prior work.
|
|
4
4
|
user-invocable: true
|
|
5
5
|
argument-hint: "<search terms> [in <project>] [last <N> days] [from <editor>]"
|
|
6
6
|
allowed-tools: Bash
|
|
@@ -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
|
|
@@ -142,9 +142,32 @@ LIMIT 10
|
|
|
142
142
|
FORMAT PrettyCompact
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
+
## Reading another person's house (shared with you)
|
|
146
|
+
|
|
147
|
+
Every house is its own ClickHouse database on the same server; a share is a read-only
|
|
148
|
+
GRANT on that database. To read a friend's memory you keep YOUR credentials and point the
|
|
149
|
+
connection at THEIR house — the rooms (`messages`, `sessions`, `tool_calls`) resolve by
|
|
150
|
+
the connection's `database`, so one change redirects every query in this skill.
|
|
151
|
+
|
|
152
|
+
1. **Resolve the target house.** `SHOW DATABASES` lists what your credential may read; the
|
|
153
|
+
names that are not `system` / `information_schema` / `default` and not your own
|
|
154
|
+
`$MEMHOUSE_DB` are houses shared with you. If the user names one — "yigit's memory",
|
|
155
|
+
"in yigit", "house yigit", "from yigit" — set `HOUSE=yigit`; otherwise leave
|
|
156
|
+
`HOUSE=$MEMHOUSE_DB`.
|
|
157
|
+
2. **Point the connection at it:** in the recipe above, replace
|
|
158
|
+
`database=${MEMHOUSE_DB:-$MEMHOUSE_USER}` with `database=<HOUSE>`. Leave the table names bare —
|
|
159
|
+
they resolve to that house. Nothing else in the query changes.
|
|
160
|
+
3. **A house wins over `in <project>`.** A name that matches a readable house is a HOUSE,
|
|
161
|
+
not a project filter — otherwise "in yigit" silently filters YOUR house's project
|
|
162
|
+
column and returns nothing. If a real project in your own house shares the name, keep
|
|
163
|
+
the possessive ("<name>'s memory") or say "project <name>" to force the project reading.
|
|
164
|
+
4. **Read-only, thin by design.** If the SELECT is refused, that house was not shared with
|
|
165
|
+
you (or the share was revoked) — say so; never guess another database.
|
|
166
|
+
|
|
145
167
|
## Qualifiers
|
|
146
168
|
|
|
147
|
-
- `in <project>` → `AND m.project ILIKE '%<project>%'`
|
|
169
|
+
- `in <project>` → `AND m.project ILIKE '%<project>%'` — but a name matching a readable
|
|
170
|
+
house is a HOUSE (see above), not a project.
|
|
148
171
|
- `last N days` → `AND m.ts > now() - INTERVAL N DAY`
|
|
149
172
|
- `from <editor>` → `AND m.source = '<id>'` (ids: `claude-code`, `codex`,
|
|
150
173
|
`cursor`, `cursor-agent`, `vscode`, `zed`, `opencode`, `gemini-cli`,
|
|
@@ -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
|
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sql
|
|
3
|
-
description: Run free-form read-only SQL against memhouse conversation memory (typed sessions/messages/tool_calls tables on ClickHouse, shared by the house). Use for ad-hoc analytics the other memhouse skills don't cover — token spend, model/editor usage, tool rankings, activity heatmaps, busiest days/projects, cache-hit ratios, or any custom question over conversation data.
|
|
3
|
+
description: Run free-form read-only SQL against memhouse conversation memory (typed sessions/messages/tool_calls tables on ClickHouse, shared by the house). Use for ad-hoc analytics the other memhouse skills don't cover — token spend, model/editor usage, tool rankings, activity heatmaps, busiest days/projects, cache-hit ratios, or any custom question over conversation data — over your own house, or a friend's house shared with you when they name it.
|
|
4
4
|
user-invocable: true
|
|
5
5
|
argument-hint: "<question or SQL>"
|
|
6
6
|
allowed-tools: Bash
|
|
@@ -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
|
|
@@ -100,6 +100,16 @@ writes into the same tables; `user_id` (stamped by the server) says whose row it
|
|
|
100
100
|
`host` says which machine shipped it. Filter with `WHERE user_id = '<name>'` when you want
|
|
101
101
|
one person, or leave it off for the whole house.
|
|
102
102
|
|
|
103
|
+
**Another person's house (shared with you).** A house is a database; a share is a
|
|
104
|
+
read-only GRANT on it. Because the rooms resolve by the connection's database, you read a
|
|
105
|
+
friend's memory by pointing the connection at THEIR house — same credentials, different
|
|
106
|
+
`database`. `SHOW DATABASES` lists what you may read; a name that is not `system` /
|
|
107
|
+
`information_schema` / `default` and not your own `$MEMHOUSE_DB` is a house shared with
|
|
108
|
+
you. When the user names one ("query yigit's house", "on yigit", "from yigit"), replace
|
|
109
|
+
`database=${MEMHOUSE_DB:-$MEMHOUSE_USER}` with `database=<house>` in the connection recipe and leave
|
|
110
|
+
the table names bare — or, to join across houses in one query, qualify tables explicitly
|
|
111
|
+
(`yigit.messages`). Read-only: a refused SELECT means that house was not shared with you.
|
|
112
|
+
|
|
103
113
|
**Keep the SQL heredoc quoted (`<<'SQL'`).** This skill once told the agent to substitute
|
|
104
114
|
a shell variable into the table names, which cannot expand inside a quoted heredoc — so
|
|
105
115
|
the agent unquotes it, and then the SEARCH TERMS expand too. Both instances driving this
|
|
@@ -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
|
|
@@ -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.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": {
|