memhouse 0.11.3 → 0.12.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/memhouse.js +291 -6
- 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 +1 -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 +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/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
|
},
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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": {
|