memhouse 0.18.5 → 0.18.7
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 +59 -5
- package/memhouse/channel.js +24 -1
- package/memhouse/convert.js +16 -1
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/memhouse/flags.js +1 -1
- package/memhouse/host.js +55 -2
- package/memhouse/server/queries.js +42 -11
- package/memhouse/server/server.js +2 -0
- package/package.json +1 -1
package/bin/memhouse.js
CHANGED
|
@@ -28,6 +28,7 @@ const {
|
|
|
28
28
|
SCHEMA_VERSION, MIN_WRITER_SCHEMA, MIGRATIONS, META_TYPES, createStatement,
|
|
29
29
|
} = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
|
|
30
30
|
const envfile = require(path.join(REPO_ROOT, 'memhouse', 'envfile'));
|
|
31
|
+
const chan = require(path.join(REPO_ROOT, 'memhouse', 'channel'));
|
|
31
32
|
const { consumeSecretChunk } = require(path.join(REPO_ROOT, 'memhouse', 'secret-input'));
|
|
32
33
|
const { capabilitiesFrom } = require(path.join(REPO_ROOT, 'memhouse', 'capabilities'));
|
|
33
34
|
const { unknownFlags, allowedFlags, suggestFlag } = require(path.join(REPO_ROOT, 'memhouse', 'flags'));
|
|
@@ -1612,6 +1613,7 @@ async function cmdStatus() {
|
|
|
1612
1613
|
daemons: { shipper: pidOf('shipper'), dashboard: pidOf('dashboard') },
|
|
1613
1614
|
dashboard_url: pidOf('dashboard') ? `http://localhost:${runningPort(cfg)}` : null,
|
|
1614
1615
|
shipper: shipperHealth(),
|
|
1616
|
+
update: null, // filled below from the registry; null when current, offline, or a checkout build
|
|
1615
1617
|
// Which machine this is, in the rooms' own terms. All of a member's machines write
|
|
1616
1618
|
// into one set of rooms, so `WHERE host = ...` is how the pilot separates this laptop
|
|
1617
1619
|
// from the other one — and they cannot type it if nothing ever prints it.
|
|
@@ -1628,6 +1630,10 @@ async function cmdStatus() {
|
|
|
1628
1630
|
out.messages = Number(m[0]?.msgs || 0);
|
|
1629
1631
|
out.freshest = m[0]?.freshest || null;
|
|
1630
1632
|
out.fleet = await fleetState(cfg);
|
|
1633
|
+
// Nobody runs `update --check`. A member sat four days behind a release that fixed the
|
|
1634
|
+
// very thing they were about to hit, with nothing on any screen saying so.
|
|
1635
|
+
try { out.update = chan.behind({ version: PKG.version, pinned: cfg.channel || null, tags: await chan.fetchTags() }); }
|
|
1636
|
+
catch { /* offline: say nothing rather than guess */ }
|
|
1631
1637
|
} catch (e) { out.connected = false; out.error = netReason(e); }
|
|
1632
1638
|
|
|
1633
1639
|
if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); return out.connected ? 0 : 1; }
|
|
@@ -1642,13 +1648,29 @@ async function cmdStatus() {
|
|
|
1642
1648
|
if (out.connected && !out.messages) console.log(ok(`house: empty — ${out.sessions} sessions, 0 messages (nothing shipped yet)`));
|
|
1643
1649
|
else if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${out.freshest} UTC)`));
|
|
1644
1650
|
if (out.fleet && out.fleet.length) {
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1651
|
+
// A writer appears in this list because it has rows — the list is GROUP BY over the
|
|
1652
|
+
// messages, not a registry — so a machine that was re-identified (before host ids were
|
|
1653
|
+
// derived from the machine) stays here forever, and cannot be tidied away without
|
|
1654
|
+
// deleting its sessions. Retired writers are therefore folded into one line instead of
|
|
1655
|
+
// shouting a permanent warning: they are history, not a fleet to maintain. `--all`
|
|
1656
|
+
// prints every one. A writer is RETIRED when it has been silent past the cutoff; a
|
|
1657
|
+
// pre-0.10 writer that is still active keeps its warning, because that one can still bite.
|
|
1658
|
+
const RETIRED_MS = 14 * 24 * 3600 * 1000;
|
|
1659
|
+
const retired = out.fleet.filter((f) => f.ageMs !== null && f.ageMs > RETIRED_MS);
|
|
1660
|
+
const shown = flags.all === true ? out.fleet : out.fleet.filter((f) => !retired.includes(f));
|
|
1661
|
+
const live = out.fleet.filter((f) => !retired.includes(f));
|
|
1662
|
+
const badOnes = live.filter((f) => f.verdict !== 'ok');
|
|
1663
|
+
console.log((badOnes.length ? warn : ok)(`fleet: ${live.length} active writer(s)${retired.length ? `, ${retired.length} retired` : ''}`));
|
|
1664
|
+
for (const f of shown) {
|
|
1648
1665
|
const mark = f.verdict === 'legacy' ? ' ⚠ pre-0.10 — upgrade it (or revoke its mutation grants); its re-ships delete retained parses'
|
|
1649
1666
|
: f.verdict === 'outdated' ? ' ⚠ supports an older schema — refusing every pass until updated'
|
|
1650
1667
|
: f.verdict === 'stale' ? ' • stale' : '';
|
|
1651
|
-
|
|
1668
|
+
const tag = retired.includes(f) ? ' · retired' : mark;
|
|
1669
|
+
console.log(` ${f.writer.padEnd(28)} ${String(f.version || '?').padEnd(8)} last ship ${fleetAge(f.ageMs)}${tag}`);
|
|
1670
|
+
}
|
|
1671
|
+
if (retired.length && flags.all !== true) {
|
|
1672
|
+
const oldest = fleetAge(Math.max(...retired.map((f) => f.ageMs)));
|
|
1673
|
+
console.log(` ${String(`${retired.length} retired writer(s)`).padEnd(28)} ${' '.padEnd(8)} silent ${oldest} and longer — their sessions are still here: memhouse status --all`);
|
|
1652
1674
|
}
|
|
1653
1675
|
}
|
|
1654
1676
|
{
|
|
@@ -1657,6 +1679,7 @@ async function cmdStatus() {
|
|
|
1657
1679
|
: fail ? warn(`shipper: running — ${out.shipper.via} — but ${fail}`)
|
|
1658
1680
|
: ok(`shipper: running — ${out.shipper.via}`));
|
|
1659
1681
|
}
|
|
1682
|
+
if (out.update) console.log(warn(`update available: ${out.update.target} on '${out.update.channel}' — you have ${PKG.version} — run: memhouse update`));
|
|
1660
1683
|
console.log(out.daemons.dashboard ? ok(`dashboard: running (pid ${out.daemons.dashboard})`) : warn('dashboard: not running'));
|
|
1661
1684
|
if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${runningPort(cfg)}`);
|
|
1662
1685
|
// Exit non-zero when the house is unreachable. `status` is what a health check, a
|
|
@@ -2006,6 +2029,12 @@ async function cmdDoctor() {
|
|
|
2006
2029
|
const moved = await adoptMovedHouse(cfg, { fix: false });
|
|
2007
2030
|
if (moved === 'moved') add(false, `rooms moved to mem.${physicalRoom('messages', cfg.user).replace(/_messages$/, '')}_* — this env still says MEMHOUSE_DB=${cfg.db}`, 'memhouse update (rewrites the env and restarts the shipper)');
|
|
2008
2031
|
} catch { /* connection problems are reported above */ }
|
|
2032
|
+
{
|
|
2033
|
+
// The same shape doctor gives everything else: what is wrong, and the command.
|
|
2034
|
+
let up = null;
|
|
2035
|
+
try { up = chan.behind({ version: PKG.version, pinned: cfg.channel || null, tags: await chan.fetchTags() }); } catch { /* offline */ }
|
|
2036
|
+
if (up) add(false, `memhouse ${PKG.version} — ${up.target} is out on '${up.channel}'`, 'memhouse update');
|
|
2037
|
+
}
|
|
2009
2038
|
const sh = shipperHealth();
|
|
2010
2039
|
const shFail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(sh.lastPass);
|
|
2011
2040
|
add(sh.running && !shFail, `shipper${sh.via ? ` — ${sh.via}` : ''}${shFail ? ` — ${shFail}` : ''}`,
|
|
@@ -2146,7 +2175,6 @@ function installKind() {
|
|
|
2146
2175
|
// Each of those has cost a real machine real sessions. This command owns all three.
|
|
2147
2176
|
async function cmdUpdate() {
|
|
2148
2177
|
const { kind, root } = installKind();
|
|
2149
|
-
const chan = require(path.join(REPO_ROOT, 'memhouse', 'channel'));
|
|
2150
2178
|
const cfg0 = resolveConfig();
|
|
2151
2179
|
// --channel pins for this run AND every later one: the point of pinning is that the next
|
|
2152
2180
|
// person to type `memhouse update` does not have to remember it.
|
|
@@ -2232,6 +2260,21 @@ async function cmdUpdate() {
|
|
|
2232
2260
|
// shipper restarts, or its first pass refuses the old address forever.
|
|
2233
2261
|
try { await adoptMovedHouse(resolveConfig()); } catch { /* doctor will say */ }
|
|
2234
2262
|
|
|
2263
|
+
// Bring the house to the schema the NEW code expects, before any daemon restarts.
|
|
2264
|
+
// A release that adds rooms (0.18.3's stat tables) used to land after the restart, so
|
|
2265
|
+
// the freshly started shipper failed its first passes with "has no <member>_sessions…",
|
|
2266
|
+
// `status` quoted that failure, and the dashboard served a page whose every request
|
|
2267
|
+
// 500'd — all of it self-healing eventually, all of it alarming meanwhile. Two real
|
|
2268
|
+
// members hit this within an hour of each other. The new binary owns the new schema, so
|
|
2269
|
+
// it runs it here, in the one moment the daemons are already down.
|
|
2270
|
+
{
|
|
2271
|
+
const r = spawnSync(process.execPath, [path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js'), '--ensure-schema'],
|
|
2272
|
+
{ env: childEnv(resolveConfig()), encoding: 'utf-8', stdio: 'pipe', timeout: 120000 });
|
|
2273
|
+
const said = `${r.stdout || ''}${r.stderr || ''}`.trim().split('\n').filter((l) => l.includes('[memhouse]')).pop();
|
|
2274
|
+
if (r.status === 0) console.log(ok(`house schema current${said ? ` — ${said.replace('[memhouse] ', '')}` : ''}`));
|
|
2275
|
+
else console.log(warn(`could not bring the house to this version's schema — the daemons start anyway; run: memhouse doctor${said ? `\n ${said}` : ''}`));
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2235
2278
|
// Refresh the Claude plugin wherever it is ALREADY installed, so `update` keeps the
|
|
2236
2279
|
// skills in lockstep with the package instead of leaving a stale `/mem:*` behind. Only
|
|
2237
2280
|
// dirs that already have it are touched — update never installs the plugin somewhere new.
|
|
@@ -4107,6 +4150,17 @@ async function cmdUninstall() {
|
|
|
4107
4150
|
if (use.length) await ch(who, `INSERT INTO ${db}.${ev} (${use.join(', ')}) VALUES (${use.map((c) => want[c]).join(', ')})`, { database: '' });
|
|
4108
4151
|
else console.log(warn(` ${p.member}: events room has none of the expected columns — no event recorded`));
|
|
4109
4152
|
} catch (e) { console.log(warn(` ${p.member}: event not recorded — ${(e && e.message ? e.message : String(e)).split('\n')[0].slice(0, 120)}`)); }
|
|
4153
|
+
// The rooms moved, but the house also needs what THIS version adds — the
|
|
4154
|
+
// per-member stat tables the dashboard reads. `ensureSchema` makes them, and a
|
|
4155
|
+
// plain `ship` never calls it, so leaving them to the member meant a dashboard
|
|
4156
|
+
// that scanned millions of rows (or 500'd) until someone happened to run
|
|
4157
|
+
// `ship --ensure-schema`. Create them here, as the admin, while we are already
|
|
4158
|
+
// holding the member's rooms in mind.
|
|
4159
|
+
try {
|
|
4160
|
+
const st = convert.statTables(db, p.member);
|
|
4161
|
+
for (const sql of st) await ch(who, sql, { database: '' });
|
|
4162
|
+
if (st.length) console.log(ok(` ${p.member}: dashboard tables created (${st.length})`));
|
|
4163
|
+
} catch (e) { console.log(warn(` ${p.member}: dashboard tables not created — their first ship will do it: ${(e && e.message ? e.message : String(e)).split('\n')[0].slice(0, 90)}`)); }
|
|
4110
4164
|
converted++;
|
|
4111
4165
|
console.log(ok(`${p.member}: rooms now ${db}.${p.member}_* — ${p.shares.length} share(s) carried${p.policies.length ? `, ${p.policies.length} row polic${p.policies.length === 1 ? 'y' : 'ies'}` : ''}`));
|
|
4112
4166
|
}
|
package/memhouse/channel.js
CHANGED
|
@@ -60,4 +60,27 @@ async function fetchTags(fetchImpl = globalThis.fetch) {
|
|
|
60
60
|
} catch { return null; }
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Is this install behind its channel? Pure — the caller supplies the tags, so nothing here
|
|
65
|
+
* reaches the network and `status` stays offline-safe.
|
|
66
|
+
*
|
|
67
|
+
* Only a strictly newer target counts: a prerelease, a version ahead of the tag, or a
|
|
68
|
+
* registry that did not answer all return null, because "unknown" must never read as
|
|
69
|
+
* "you are behind" on a health screen someone gates on.
|
|
70
|
+
*
|
|
71
|
+
* @returns {{target: string, channel: string}|null}
|
|
72
|
+
*/
|
|
73
|
+
function behind({ version, pinned = null, tags = null }) {
|
|
74
|
+
if (!tags) return null;
|
|
75
|
+
const pick = pickChannel({ version, pinned, tags });
|
|
76
|
+
if (!pick.target || pick.target === version) return null;
|
|
77
|
+
const n = (v) => String(v).split('-')[0].split('.').map(Number);
|
|
78
|
+
const [a, b] = [n(pick.target), n(version)];
|
|
79
|
+
for (let i = 0; i < 3; i++) {
|
|
80
|
+
if ((a[i] || 0) > (b[i] || 0)) return { target: pick.target, channel: pick.channel };
|
|
81
|
+
if ((a[i] || 0) < (b[i] || 0)) return null;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { behind, pickChannel, fetchTags, isPrerelease, lineOf };
|
package/memhouse/convert.js
CHANGED
|
@@ -57,4 +57,19 @@ function render(steps) {
|
|
|
57
57
|
return steps.map((s) => `-- ${s.why}${s.optional ? ' (optional)' : ''}${s.when ? ` (only when ${s.when})` : ''}\n${s.sql};`).join('\n');
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
/**
|
|
61
|
+
* The CREATE statements for a member's dashboard tables, in the one layout. `convert` runs
|
|
62
|
+
* these so a converted house is complete the moment it is converted — the alternative was
|
|
63
|
+
* a dashboard with nothing precomputed until someone ran `ship --ensure-schema` by hand.
|
|
64
|
+
* Same source of truth the shipper uses, so the two cannot drift.
|
|
65
|
+
*/
|
|
66
|
+
function statTables(db, member) {
|
|
67
|
+
const house = require('./house/house');
|
|
68
|
+
// statCreateStatements builds unqualified names (the member runs it inside their own
|
|
69
|
+
// house); convert runs as the admin with no default database, so qualify every name.
|
|
70
|
+
return house.statCreateStatements(house.roomNames(member))
|
|
71
|
+
.map((sql) => sql.replace(/\b(CREATE TABLE IF NOT EXISTS |FROM )(?!\()/g, (m0) => m0)
|
|
72
|
+
.replace(new RegExp(`\\b(${member}_(?:session_stats|session_model_stats|session_tool_stats|sessions|messages|tool_calls|meta|events))\\b`, 'g'), `${db}.$1`));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { planMember, render, statTables, LEGACY, STAT_VIEWS, STAT_TABLES };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mem",
|
|
3
3
|
"description": "Conversation memory for AI agents over the memhouse typed ClickHouse store: recall past sessions and answer from them, run analytics, check the house, bring people in, and administer the server. Skills install as /mem:recall, /mem:sql, /mem:house, /mem:access, and /mem:admin.",
|
|
4
|
-
"version": "0.18.
|
|
4
|
+
"version": "0.18.7",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ramazan Polat"
|
|
7
7
|
},
|
package/memhouse/flags.js
CHANGED
|
@@ -17,7 +17,7 @@ const COMMAND_FLAGS = {
|
|
|
17
17
|
whoami: ['admin'],
|
|
18
18
|
share: ['only', 'revoke', 'list'],
|
|
19
19
|
discover: [], stats: [], stop: [], doctor: [], 'sessions-query': [], rooms: [], instance: [], members: ['db','admin-user','admin-password'], convert: ['url','db','admin-user','admin-password','member','print-sql','dry-run','guides','out'],
|
|
20
|
-
status: [], start: [],
|
|
20
|
+
status: ['all'], start: [],
|
|
21
21
|
onboard: ['url', 'user', 'password', 'db', 'port', 'no-ship', 'target', 'house-port', 'tag', 'local'],
|
|
22
22
|
install: ['url', 'user', 'password', 'db', 'port', 'no-ship', 'env', 'print-sql', 'member',
|
|
23
23
|
'member-password', 'admin-user', 'admin-password', 'ensure-schema', 'rotate-password', 'force',
|
package/memhouse/host.js
CHANGED
|
@@ -53,6 +53,59 @@ function read(home = homeDir()) {
|
|
|
53
53
|
return null;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* A fingerprint of THIS MACHINE, not of this install.
|
|
58
|
+
*
|
|
59
|
+
* It used to be `randomBytes(16)`, which lives only in host.json — so wiping MEMHOUSE_HOME
|
|
60
|
+
* (`uninstall --full-removal`, a reset, a new laptop image) made the same physical machine
|
|
61
|
+
* come back as a stranger. One MacBook read as three writers in the fleet list, each
|
|
62
|
+
* holding a slice of the same machine's history.
|
|
63
|
+
*
|
|
64
|
+
* Now it is derived from a stable per-machine id — IOPlatformUUID on macOS,
|
|
65
|
+
* /etc/machine-id (or the D-Bus one) on Linux, MachineGuid on Windows — hashed with a
|
|
66
|
+
* fixed salt so the raw hardware id never leaves the machine and cannot be recovered from
|
|
67
|
+
* a row. Same machine, same fingerprint, reinstall after reinstall. Where no such id can
|
|
68
|
+
* be read, it falls back to random: an identity that is merely unstable is far better than
|
|
69
|
+
* one that collides, and the fallback is recorded so callers can say which happened.
|
|
70
|
+
*
|
|
71
|
+
* @returns {string} 32 hex characters
|
|
72
|
+
*/
|
|
73
|
+
function machineFingerprint(raw = stableMachineId()) {
|
|
74
|
+
const SALT = 'memhouse/host/v1'; // versioned: changing it re-identifies every machine
|
|
75
|
+
return raw
|
|
76
|
+
? crypto.createHash('sha256').update(`${SALT}:${raw}`).digest('hex').slice(0, 32)
|
|
77
|
+
: crypto.randomBytes(16).toString('hex');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The OS's own machine id, or null when it cannot be read. Never stored, never shipped. */
|
|
81
|
+
function stableMachineId() {
|
|
82
|
+
const run = (cmd, args) => {
|
|
83
|
+
try {
|
|
84
|
+
const r = require('child_process').execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 });
|
|
85
|
+
return String(r || '').trim() || null;
|
|
86
|
+
} catch { return null; }
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
if (process.platform === 'darwin') {
|
|
90
|
+
const out = run('/usr/sbin/ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice']);
|
|
91
|
+
const m = out && /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out);
|
|
92
|
+
return m ? m[1] : null;
|
|
93
|
+
}
|
|
94
|
+
if (process.platform === 'linux') {
|
|
95
|
+
for (const f of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
96
|
+
try { const v = fs.readFileSync(f, 'utf-8').trim(); if (v) return v; } catch { /* next */ }
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
if (process.platform === 'win32') {
|
|
101
|
+
const out = run('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid']);
|
|
102
|
+
const m = out && /MachineGuid\s+REG_SZ\s+(\S+)/.exec(out);
|
|
103
|
+
return m ? m[1] : null;
|
|
104
|
+
}
|
|
105
|
+
} catch { /* fall through */ }
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
56
109
|
/**
|
|
57
110
|
* The identity of this machine, creating it on first use.
|
|
58
111
|
*
|
|
@@ -69,7 +122,7 @@ function identity(home = homeDir()) {
|
|
|
69
122
|
let rec = read(home);
|
|
70
123
|
if (!rec) {
|
|
71
124
|
rec = {
|
|
72
|
-
fingerprint:
|
|
125
|
+
fingerprint: machineFingerprint(),
|
|
73
126
|
hostname,
|
|
74
127
|
platform: os.platform(),
|
|
75
128
|
arch: os.arch(),
|
|
@@ -98,4 +151,4 @@ function identity(home = homeDir()) {
|
|
|
98
151
|
};
|
|
99
152
|
}
|
|
100
153
|
|
|
101
|
-
module.exports = { identity, read, filePath, idFrom, FILE };
|
|
154
|
+
module.exports = { identity, read, filePath, idFrom, machineFingerprint, stableMachineId, FILE };
|
|
@@ -27,23 +27,54 @@ const config = {
|
|
|
27
27
|
database: process.env.MEMHOUSE_DB || process.env.MEMHOUSE_USER, // house defaults to the user's own name
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The house may have moved under this process: the operator converts a pre-one-layout
|
|
32
|
+
* house and the member's rooms go from `<name>.*` to `mem.<name>_*`, while this server
|
|
33
|
+
* still holds the database its env named at boot. The shipper and `update` learned to
|
|
34
|
+
* adopt that; the dashboard did not, so every endpoint answered 500 behind a page that
|
|
35
|
+
* simply looked blank. Checked once at startup, cheap, and never fatal.
|
|
36
|
+
*
|
|
37
|
+
* @returns {Promise<'current'|'moved'|'unknown'>}
|
|
38
|
+
*/
|
|
39
|
+
async function adoptMovedHouse() {
|
|
40
|
+
const me = config.username;
|
|
41
|
+
if (!me || config.database === 'mem') return 'current';
|
|
42
|
+
const probe = createClient({ ...clientOptions(), database: 'system' });
|
|
43
|
+
try {
|
|
44
|
+
const rs = await probe.query({
|
|
45
|
+
query: `SELECT database, name FROM system.tables WHERE (database = {old:String} AND name IN ('messages', {mine:String})) OR (database = 'mem' AND name = {mine:String})`,
|
|
46
|
+
query_params: { old: config.database, mine: `${me}_messages` },
|
|
47
|
+
format: 'JSONEachRow',
|
|
48
|
+
});
|
|
49
|
+
const rows = await rs.json();
|
|
50
|
+
const has = (d, n) => rows.some((r) => r.database === d && r.name === n);
|
|
51
|
+
if (has(config.database, `${me}_messages`) || has(config.database, 'messages')) return 'current';
|
|
52
|
+
if (!has('mem', `${me}_messages`)) return 'unknown';
|
|
53
|
+
console.log(`[memhouse] rooms moved: ${config.database}.messages is now mem.${me}_messages — reading house 'mem'`);
|
|
54
|
+
config.database = 'mem';
|
|
55
|
+
_client = null; // rebuilt with the new database on the next call
|
|
56
|
+
return 'moved';
|
|
57
|
+
} catch { return 'unknown'; }
|
|
58
|
+
finally { try { await probe.close(); } catch { /* nothing to close */ } }
|
|
59
|
+
}
|
|
60
|
+
|
|
30
61
|
let _client = null;
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
_client = createClient({
|
|
34
|
-
// See the note in shipper/ship.js: the driver's ERROR-level dump lands ahead of every
|
|
35
|
-
// message we print, so a handled refusal reads like a crash. MEMHOUSE_DEBUG=1 restores it.
|
|
62
|
+
function clientOptions() {
|
|
63
|
+
return {
|
|
36
64
|
log: { level: process.env.MEMHOUSE_DEBUG ? ClickHouseLogLevel.DEBUG : ClickHouseLogLevel.OFF },
|
|
37
65
|
url: config.url,
|
|
38
66
|
username: config.username,
|
|
39
67
|
password: config.password,
|
|
40
68
|
database: config.database,
|
|
41
69
|
request_timeout: 60000,
|
|
42
|
-
clickhouse_settings: {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
70
|
+
clickhouse_settings: { ...READ_SETTINGS, output_format_json_quote_64bit_integers: 0 },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function getClient() {
|
|
74
|
+
if (_client) return _client;
|
|
75
|
+
// See the note in shipper/ship.js: the driver's ERROR-level dump lands ahead of every
|
|
76
|
+
// message we print, so a handled refusal reads like a crash. MEMHOUSE_DEBUG=1 restores it.
|
|
77
|
+
_client = createClient(clientOptions());
|
|
47
78
|
return _client;
|
|
48
79
|
}
|
|
49
80
|
|
|
@@ -1101,7 +1132,7 @@ async function schema() {
|
|
|
1101
1132
|
};
|
|
1102
1133
|
}
|
|
1103
1134
|
|
|
1104
|
-
module.exports = {
|
|
1135
|
+
module.exports = { adoptMovedHouse,
|
|
1105
1136
|
getClient, config,
|
|
1106
1137
|
getOverview, getDailyActivity, getDashboardStats,
|
|
1107
1138
|
getChats, countChats, getChat,
|
|
@@ -435,6 +435,8 @@ app.get('*', (req, res) => {
|
|
|
435
435
|
});
|
|
436
436
|
|
|
437
437
|
if (require.main === module) {
|
|
438
|
+
// Before serving: has the house moved under us? (see queries.adoptMovedHouse)
|
|
439
|
+
qy.adoptMovedHouse().catch(() => {});
|
|
438
440
|
app.listen(PORT, HOST, () => {
|
|
439
441
|
console.log(`[memhouse] dashboard → http://localhost:${PORT} (house '${qy.config.database}' @ ${qy.config.url.replace(/\/\/.*@/, '//')})`
|
|
440
442
|
+ (HOST !== '127.0.0.1' ? ` [bound to ${HOST} — remotely reachable]` : ''));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memhouse",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.7",
|
|
4
4
|
"description": "memhouse \u2014 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": {
|