memhouse 0.18.1 → 0.18.2
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 +55 -9
- package/memhouse/delivery/INVITATION.md.tpl +4 -0
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/memhouse/envfile.js +16 -1
- package/memhouse/provision.js +4 -1
- package/memhouse/shipper/passlog.js +37 -0
- package/memhouse/shipper/ship.js +19 -3
- package/package.json +1 -1
package/bin/memhouse.js
CHANGED
|
@@ -273,14 +273,24 @@ function portOf(u) {
|
|
|
273
273
|
// "not running" for every correctly service-managed install, and `doctor` fails on a
|
|
274
274
|
// healthy machine. Returns { running, via }.
|
|
275
275
|
function shipperHealth() {
|
|
276
|
+
// "running" is the pid; whether it is DOING anything is the log. Both, always.
|
|
277
|
+
const lastPass = (() => {
|
|
278
|
+
try {
|
|
279
|
+
const passlog = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog'));
|
|
280
|
+
const f = path.join(LOG_DIR, 'shipper.log'); const st = fs.statSync(f);
|
|
281
|
+
const fd = fs.openSync(f, 'r'); const len = Math.min(st.size, 65536); const buf = Buffer.alloc(len);
|
|
282
|
+
fs.readSync(fd, buf, 0, len, st.size - len); fs.closeSync(fd);
|
|
283
|
+
return passlog.lastPass(buf.toString('utf-8'));
|
|
284
|
+
} catch { return { outcome: null }; }
|
|
285
|
+
})();
|
|
276
286
|
const pid = pidOf('shipper');
|
|
277
|
-
if (pid) return { running: true, via: `daemon (pid ${pid})
|
|
287
|
+
if (pid) return { running: true, via: `daemon (pid ${pid})`, lastPass };
|
|
278
288
|
try {
|
|
279
289
|
const svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
|
|
280
290
|
const st = svc.status();
|
|
281
|
-
if (st.installed) return { running: st.running, via: `service (${st.kind})
|
|
291
|
+
if (st.installed) return { running: st.running, via: `service (${st.kind})`, lastPass };
|
|
282
292
|
} catch { /* no service integration on this platform */ }
|
|
283
|
-
return { running: false, via: null };
|
|
293
|
+
return { running: false, via: null, lastPass };
|
|
284
294
|
}
|
|
285
295
|
|
|
286
296
|
// Room routing for the CLI's own queries. The shipper and dashboard resolve rooms through
|
|
@@ -405,7 +415,9 @@ Setup onboard interactive wizard: discover → configure →
|
|
|
405
415
|
--print-sql print the SQL, run it yourself
|
|
406
416
|
with admin: --admin-user --admin-password [--member NAME]
|
|
407
417
|
builds house + user + rooms + grants, then verifies as
|
|
408
|
-
the member. The admin credential
|
|
418
|
+
the member. The admin credential IS written to the env
|
|
419
|
+
file (mode 600) so invite/members need no flags; delete
|
|
420
|
+
those two lines if you would rather pass it each time.
|
|
409
421
|
--env FILE installs from an invite file (see: invite)
|
|
410
422
|
invite <name> mint a member and write the env file their install needs
|
|
411
423
|
(--url [--db NAME] [--out FILE]; --admin-user and
|
|
@@ -969,6 +981,16 @@ function startShipperBackground(cfg) {
|
|
|
969
981
|
console.log(' dashboard (browse / search / analyze): memhouse start');
|
|
970
982
|
}
|
|
971
983
|
|
|
984
|
+
// The store is node:sqlite, stable from Node 24. On 22 everything green-lights and the
|
|
985
|
+
// SQLite-backed adapters silently ship nothing — 36 messages went out before anyone noticed.
|
|
986
|
+
// doctor said so; install and onboard did not. Now they refuse, with the same sentence.
|
|
987
|
+
function engineOk() {
|
|
988
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
989
|
+
if (major >= 24) return true;
|
|
990
|
+
console.log(bad(`node ${process.versions.node} — need >= 24 — node:sqlite is stable there; the SQLite-backed adapters need it`));
|
|
991
|
+
process.exitCode = 1; return false;
|
|
992
|
+
}
|
|
993
|
+
|
|
972
994
|
async function cmdInstall({ interactive }) {
|
|
973
995
|
// --env <file>: an INVITE intake. The file carries MEMHOUSE_URL/USER/PASSWORD/DB from
|
|
974
996
|
// `memhouse invite` on the admin's machine; loading it into the environment BEFORE
|
|
@@ -985,6 +1007,10 @@ async function cmdInstall({ interactive }) {
|
|
|
985
1007
|
// this machine with a file that cannot authenticate on the invitee's.
|
|
986
1008
|
const missing = wanted.filter((k) => !parsed[k]);
|
|
987
1009
|
if (missing.length) { console.log(bad(`--env ${flags.env} is missing ${missing.join(', ')} — not a complete invite file?`)); return 1; }
|
|
1010
|
+
try {
|
|
1011
|
+
const h = new URL(String(parsed.MEMHOUSE_URL || '')).hostname;
|
|
1012
|
+
if (/^(localhost|127\.0\.0\.1|\[?::1\]?)$/.test(h)) console.log(warn(`${parsed.MEMHOUSE_URL} is loopback — it points at THIS machine; only right if a tunnel or port-forward puts the house here`));
|
|
1013
|
+
} catch { /* not a URL; the missing-fields check above already spoke */ }
|
|
988
1014
|
// Clear ambient MEMHOUSE_* so ONLY the file speaks (exported vars normally win over the
|
|
989
1015
|
// file; an invite intake is the one place they must not).
|
|
990
1016
|
for (const k of ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB', 'MEMHOUSE_PORT']) delete process.env[k];
|
|
@@ -1285,6 +1311,18 @@ async function finishInvite(cfg) {
|
|
|
1285
1311
|
}
|
|
1286
1312
|
if (go) {
|
|
1287
1313
|
const code = await cmdPasswd({ quiet: true });
|
|
1314
|
+
if (code === 0) {
|
|
1315
|
+
// The rotation rewrote the env FILE. But --env hoisted the invite's values into
|
|
1316
|
+
// process.env, and resolveConfig prefers process.env over the file — so the
|
|
1317
|
+
// shipper spawned a few lines later got the INVITE password, which the rotation
|
|
1318
|
+
// had just killed, and failed every pass with "Authentication failed" while every
|
|
1319
|
+
// fresh CLI call (reading the file) worked. A drill member watched an empty house
|
|
1320
|
+
// for twelve minutes. Adopt what the file now says before anything else is spawned.
|
|
1321
|
+
const drift = envfile.credentialDrift(fs.readFileSync(ENV_FILE, 'utf-8'), process.env);
|
|
1322
|
+
for (const k of drift.changed) process.env[k] = drift.values[k];
|
|
1323
|
+
if (drift.values.MEMHOUSE_PASSWORD !== undefined && flags.password !== undefined) flags.password = drift.values.MEMHOUSE_PASSWORD;
|
|
1324
|
+
if (cfg) cfg.password = process.env.MEMHOUSE_PASSWORD;
|
|
1325
|
+
}
|
|
1288
1326
|
if (code !== 0) {
|
|
1289
1327
|
// Rotation failed and its own state may be uncertain — KEEP the invite file (it
|
|
1290
1328
|
// still carries the password the config was just written from) so nothing is
|
|
@@ -1323,7 +1361,8 @@ function printGettingStarted(cfg) {
|
|
|
1323
1361
|
console.log(' /mem:sql, /mem:access, /mem:admin');
|
|
1324
1362
|
console.log(` ${mh} search <terms> find a past conversation right now`);
|
|
1325
1363
|
console.log(` ${mh} doctor every line a check mark = healthy`);
|
|
1326
|
-
console.log('
|
|
1364
|
+
if (flags['no-ship'] === true) console.log(' Shipping is OFF (--no-ship). Start it when ready: memhouse start');
|
|
1365
|
+
else console.log(' The house keeps shipping as you work; nothing else to do.');
|
|
1327
1366
|
}
|
|
1328
1367
|
|
|
1329
1368
|
async function cmdOnboard() {
|
|
@@ -1605,7 +1644,12 @@ async function cmdStatus() {
|
|
|
1605
1644
|
console.log(` ${f.writer.padEnd(28)} ${String(f.version || '?').padEnd(8)} last ship ${fleetAge(f.ageMs)}${mark}`);
|
|
1606
1645
|
}
|
|
1607
1646
|
}
|
|
1608
|
-
|
|
1647
|
+
{
|
|
1648
|
+
const fail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(out.shipper.lastPass);
|
|
1649
|
+
console.log(!out.shipper.running ? warn('shipper: not running')
|
|
1650
|
+
: fail ? warn(`shipper: running — ${out.shipper.via} — but ${fail}`)
|
|
1651
|
+
: ok(`shipper: running — ${out.shipper.via}`));
|
|
1652
|
+
}
|
|
1609
1653
|
console.log(out.daemons.dashboard ? ok(`dashboard: running (pid ${out.daemons.dashboard})`) : warn('dashboard: not running'));
|
|
1610
1654
|
if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${runningPort(cfg)}`);
|
|
1611
1655
|
// Exit non-zero when the house is unreachable. `status` is what a health check, a
|
|
@@ -1952,7 +1996,9 @@ async function cmdDoctor() {
|
|
|
1952
1996
|
'that adapter does not extract usage; the messages are stored, the numbers are not');
|
|
1953
1997
|
} catch { /* a house that cannot be read is already reported above */ }
|
|
1954
1998
|
const sh = shipperHealth();
|
|
1955
|
-
|
|
1999
|
+
const shFail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(sh.lastPass);
|
|
2000
|
+
add(sh.running && !shFail, `shipper${sh.via ? ` — ${sh.via}` : ''}${shFail ? ` — ${shFail}` : ''}`,
|
|
2001
|
+
shFail ? (sh.lastPass.auth ? 'memhouse stop && memhouse start' : 'read: ~/.memhouse/logs/shipper.log') : 'memhouse start (or: memhouse service install)');
|
|
1956
2002
|
add(!!pidOf('dashboard'), 'dashboard daemon', 'memhouse start');
|
|
1957
2003
|
add(fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html')), 'dashboard UI built', 'built automatically by memhouse start');
|
|
1958
2004
|
|
|
@@ -3891,8 +3937,8 @@ async function cmdUninstall() {
|
|
|
3891
3937
|
// fall through to HELP: forty lines of help and exit 0 is not what --version means.
|
|
3892
3938
|
case 'version': console.log(PKG.version); break;
|
|
3893
3939
|
case 'discover': await cmdDiscover(); break;
|
|
3894
|
-
case 'onboard': process.exitCode = await cmdOnboard(); break;
|
|
3895
|
-
case 'install': process.exitCode = await cmdInstall({ interactive: false }); break;
|
|
3940
|
+
case 'onboard': if (!engineOk()) break; process.exitCode = await cmdOnboard(); break;
|
|
3941
|
+
case 'install': if (!engineOk()) break; process.exitCode = await cmdInstall({ interactive: false }); break;
|
|
3896
3942
|
case 'setup': {
|
|
3897
3943
|
// Dual-mode like install: --yes (with any --url/--user/--password/--db/
|
|
3898
3944
|
// --port overrides, already resolved into cfg) skips every prompt so
|
|
@@ -38,6 +38,10 @@ going on. Do not print its contents — the last line of it is a password.
|
|
|
38
38
|
{{INSTALL}}
|
|
39
39
|
|
|
40
40
|
On most Linux distros the global install needs `sudo`; if `npm` says `EACCES`, that is why.
|
|
41
|
+
If you handed this page to an agent and it reports that a permission classifier blocked the
|
|
42
|
+
install (a global `npm install` is exactly what such classifiers stop), run this one line
|
|
43
|
+
yourself in a terminal and tell the agent to continue from step 2 — that is the intended
|
|
44
|
+
split: the person installs software, the agent does the rest.
|
|
41
45
|
`memhouse --version` should print `{{VERSION}}`.
|
|
42
46
|
|
|
43
47
|
## 2. Join the house
|
|
@@ -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.2",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ramazan Polat"
|
|
7
7
|
},
|
package/memhouse/envfile.js
CHANGED
|
@@ -46,4 +46,19 @@ function assertSingleLine(env) {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Which connection values in an env file's text differ from what a process currently holds.
|
|
51
|
+
* A running shipper was spawned with one credential; `install --env` rotates the password
|
|
52
|
+
* and rewrites the file, and a member can `memhouse passwd` under a running daemon. In
|
|
53
|
+
* both cases the FILE is the truth and the process copy is stale. Pure; used by the
|
|
54
|
+
* shipper (adopt on auth failure) and by install (adopt before spawning).
|
|
55
|
+
*/
|
|
56
|
+
function credentialDrift(text, current = {}) {
|
|
57
|
+
const file = parse(String(text || ''));
|
|
58
|
+
const keys = ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB'];
|
|
59
|
+
const changed = keys.filter((k) => file[k] !== undefined && file[k] !== current[k]);
|
|
60
|
+
const values = Object.fromEntries(changed.map((k) => [k, file[k]]));
|
|
61
|
+
return { changed, values };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { credentialDrift, parse, quoteShell, quoteSystemd, assertSingleLine };
|
package/memhouse/provision.js
CHANGED
|
@@ -61,7 +61,10 @@ function plan({ db, member, password = null }) {
|
|
|
61
61
|
{ sql: `CREATE DATABASE IF NOT EXISTS ${db}`, why: 'the house', optional: false },
|
|
62
62
|
];
|
|
63
63
|
if (password !== null) {
|
|
64
|
-
|
|
64
|
+
// No IF NOT EXISTS. The live path checks first and refuses an existing user; the printed
|
|
65
|
+
// plan used to no-op here, apply the grants, and hand out a password the server never saw —
|
|
66
|
+
// found on a realm that mints its own users. Now it fails loudly on line one instead.
|
|
67
|
+
steps.push({ sql: `CREATE USER ${member} IDENTIFIED BY ${sqlString(password)}`, why: 'the member — fails if it already exists; then this password is NOT theirs', optional: false });
|
|
65
68
|
}
|
|
66
69
|
steps.push(
|
|
67
70
|
{ sql: roomGrant(db, member), why: `their rooms — ${db}.${roomPattern(member)} — and nothing else in the house`, optional: false },
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// What the shipper's log says about its LAST pass.
|
|
2
|
+
//
|
|
3
|
+
// A daemon whose every pass fails is "running" by pid. A drill member's shipper failed
|
|
4
|
+
// auth for twelve minutes while `status` said "shipper: running" and `doctor` was green,
|
|
5
|
+
// and she — and her agent — read an empty house as "still catching up". The log beside
|
|
6
|
+
// it had the answer on every pass. This reads it. Pure; the CLI reads the file.
|
|
7
|
+
//
|
|
8
|
+
// Lines have no timestamps, so "last" is textual order: the later of the last success
|
|
9
|
+
// line and the last failure line wins.
|
|
10
|
+
|
|
11
|
+
const OK = /\[memhouse\] shipped (\d+) sessions[^\n]*?→ (\d+) msg rows/;
|
|
12
|
+
const FAIL = /\[memhouse\] pass failed: ([^\n]*)/;
|
|
13
|
+
|
|
14
|
+
/** @returns {{outcome:'ok'|'failed'|null, sessions?:number, rows?:number, why?:string, auth?:boolean}} */
|
|
15
|
+
function lastPass(logText) {
|
|
16
|
+
const text = String(logText || '');
|
|
17
|
+
let lastOk = -1; let lastFail = -1; let okM = null; let failM = null;
|
|
18
|
+
for (const m of text.matchAll(new RegExp(OK.source, 'g'))) { lastOk = m.index; okM = m; }
|
|
19
|
+
for (const m of text.matchAll(new RegExp(FAIL.source, 'g'))) { lastFail = m.index; failM = m; }
|
|
20
|
+
if (lastOk < 0 && lastFail < 0) return { outcome: null };
|
|
21
|
+
if (lastFail > lastOk) {
|
|
22
|
+
const why = failM[1].trim();
|
|
23
|
+
return { outcome: 'failed', why, auth: /Authentication failed|ACCESS_DENIED|Not enough privileges/i.test(why) };
|
|
24
|
+
}
|
|
25
|
+
return { outcome: 'ok', sessions: Number(okM[1]), rows: Number(okM[2]) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One line for a person: null when there is nothing to say (no passes yet, or the last one was fine). */
|
|
29
|
+
function describeFailure(lp) {
|
|
30
|
+
if (!lp || lp.outcome !== 'failed') return null;
|
|
31
|
+
const why = lp.why.length > 90 ? `${lp.why.slice(0, 87)}…` : lp.why;
|
|
32
|
+
return lp.auth
|
|
33
|
+
? `the last pass FAILED — ${why} — restart it: memhouse stop && memhouse start`
|
|
34
|
+
: `the last pass FAILED — ${why} — see: memhouse doctor`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { lastPass, describeFailure };
|
package/memhouse/shipper/ship.js
CHANGED
|
@@ -1251,7 +1251,7 @@ async function printStats(client) {
|
|
|
1251
1251
|
|
|
1252
1252
|
async function main() {
|
|
1253
1253
|
const argv = process.argv.slice(2);
|
|
1254
|
-
|
|
1254
|
+
let client = makeClient();
|
|
1255
1255
|
try {
|
|
1256
1256
|
if (argv.includes('--ensure-schema')) {
|
|
1257
1257
|
const n = await ensureSchema(client);
|
|
@@ -1289,6 +1289,7 @@ async function main() {
|
|
|
1289
1289
|
`${r.msgRows} msg rows, ${r.toolRows} tool rows in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
1290
1290
|
} catch (e) {
|
|
1291
1291
|
failed = true;
|
|
1292
|
+
var adoptedCredential = false;
|
|
1292
1293
|
// A connection failure arrives as an AggregateError whose own `message` is empty,
|
|
1293
1294
|
// so this printed "[memhouse] pass failed:" and nothing else, after 35 lines of
|
|
1294
1295
|
// driver internals. Dig out a cause and name the fix; `status` and `doctor`
|
|
@@ -1299,7 +1300,22 @@ async function main() {
|
|
|
1299
1300
|
if (/ECONNREFUSED|fetch failed|ENOTFOUND|EAI_AGAIN|socket hang up/i.test(why + causes.join(' '))) {
|
|
1300
1301
|
console.error(`[memhouse] ${process.env.MEMHOUSE_URL} did not answer — is the house running?`);
|
|
1301
1302
|
} else if (/Authentication failed|ACCESS_DENIED|Not enough privileges/i.test(why)) {
|
|
1302
|
-
|
|
1303
|
+
// This process was spawned with one credential; the env FILE may hold a newer one
|
|
1304
|
+
// — `install --env` rotates the invite password and rewrites the file, and
|
|
1305
|
+
// `memhouse passwd` can run under a live daemon. Retrying the dead copy for
|
|
1306
|
+
// 300s a pass while the file beside it is right is what a drill member watched
|
|
1307
|
+
// for twelve minutes. Adopt the file, rebuild the client, retry now.
|
|
1308
|
+
const envPath = require('path').join(process.env.MEMHOUSE_HOME || require('path').join(require('os').homedir(), '.memhouse'), 'env');
|
|
1309
|
+
let drift = { changed: [] };
|
|
1310
|
+
try { drift = require('../envfile').credentialDrift(require('fs').readFileSync(envPath, 'utf-8'), process.env); } catch { /* no file: nothing to adopt */ }
|
|
1311
|
+
if (drift.changed.length) {
|
|
1312
|
+
for (const k of drift.changed) process.env[k] = drift.values[k];
|
|
1313
|
+
client = makeClient();
|
|
1314
|
+
adoptedCredential = true;
|
|
1315
|
+
console.error(`[memhouse] ${envPath} holds a newer ${drift.changed.map((k) => k.replace('MEMHOUSE_', '').toLowerCase()).join('/')} — adopting it and retrying now`);
|
|
1316
|
+
} else {
|
|
1317
|
+
console.error(`[memhouse] the credential this shipper holds was rejected by ${process.env.MEMHOUSE_URL} (and ${envPath} says the same)`);
|
|
1318
|
+
}
|
|
1303
1319
|
} else if (/does not exist|UNKNOWN_TABLE|UNKNOWN_DATABASE/i.test(why)) {
|
|
1304
1320
|
console.error('[memhouse] the house is missing a room this pass needed');
|
|
1305
1321
|
}
|
|
@@ -1314,7 +1330,7 @@ async function main() {
|
|
|
1314
1330
|
}
|
|
1315
1331
|
full = false; // --full applies to the first pass only; loop passes stay incremental
|
|
1316
1332
|
if (loop) {
|
|
1317
|
-
const waitMs = failed ? Math.min(retryMs, intervalSec * 1000) : intervalSec * 1000;
|
|
1333
|
+
const waitMs = failed ? (adoptedCredential ? 1000 : Math.min(retryMs, intervalSec * 1000)) : intervalSec * 1000;
|
|
1318
1334
|
if (failed) {
|
|
1319
1335
|
console.error(`[memhouse] retrying in ${Math.round(waitMs / 1000)}s`);
|
|
1320
1336
|
retryMs = Math.min(retryMs * 2, intervalSec * 1000);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memhouse",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.2",
|
|
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": {
|