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 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 is never stored.
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(' The house keeps shipping as you work; nothing else to do.');
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
- console.log(out.shipper.running ? ok(`shipper: running — ${out.shipper.via}`) : warn('shipper: not running'));
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
- add(sh.running, `shipper${sh.via ? ` — ${sh.via}` : ''}`, 'memhouse start (or: memhouse service install)');
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.1",
4
+ "version": "0.18.2",
5
5
  "author": {
6
6
  "name": "Ramazan Polat"
7
7
  },
@@ -46,4 +46,19 @@ function assertSingleLine(env) {
46
46
  }
47
47
  }
48
48
 
49
- module.exports = { parse, quoteShell, quoteSystemd, assertSingleLine };
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 };
@@ -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
- steps.push({ sql: `CREATE USER IF NOT EXISTS ${member} IDENTIFIED BY ${sqlString(password)}`, why: 'the member', optional: false });
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 };
@@ -1251,7 +1251,7 @@ async function printStats(client) {
1251
1251
 
1252
1252
  async function main() {
1253
1253
  const argv = process.argv.slice(2);
1254
- const client = makeClient();
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
- console.error(`[memhouse] the credential in ${process.env.MEMHOUSE_HOME || '~/.memhouse'}/env was rejected by ${process.env.MEMHOUSE_URL}`);
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.1",
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": {