memhouse 0.18.0 → 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/README.md +13 -0
- package/bin/memhouse.js +192 -15
- package/memhouse/binding.js +81 -0
- package/memhouse/delivery/INVITATION.md.tpl +24 -3
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/memhouse/delivery/plugin/reference/HOUSE.md +7 -0
- package/memhouse/delivery/plugin/skills/access/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/house/SKILL.md +16 -0
- package/memhouse/envfile.js +16 -1
- package/memhouse/flags.js +1 -1
- package/memhouse/invitefile.js +38 -0
- 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/README.md
CHANGED
|
@@ -83,6 +83,7 @@ memhouse deploy --local | --down stand up (or remove) a local house
|
|
|
83
83
|
memhouse invite <name> mint a member + house, hand them one env file
|
|
84
84
|
memhouse members [--db X] who is in a house, and what each can reach
|
|
85
85
|
memhouse whoami [--admin] which credential is in play, and what it may do
|
|
86
|
+
memhouse instance which memhouse this is: home, binary, house, rooms, scope, bindings
|
|
86
87
|
memhouse rooms what your rooms are actually called
|
|
87
88
|
memhouse share <user> [--only …|--revoke|--list]
|
|
88
89
|
let a housemate read yours, in whole or in part
|
|
@@ -162,6 +163,18 @@ build sits on no tag and is never auto-updated; `update --check` says so and nam
|
|
|
162
163
|
way forward. An invite from a house on a channel carries it, so the invitee follows it
|
|
163
164
|
too.
|
|
164
165
|
|
|
166
|
+
## Instances
|
|
167
|
+
|
|
168
|
+
An instance is a home: an env file naming a house, the daemons shipping to it, and the
|
|
169
|
+
playbooks whose sessions it ships (`MEMHOUSE_CLAUDE_ROOTS`). A machine can run several,
|
|
170
|
+
each under its own `MEMHOUSE_HOME`, on different channels. `plugins install claude` from an
|
|
171
|
+
instance installs into exactly the playbooks that instance ships and **binds** them: it
|
|
172
|
+
writes `MEMHOUSE_HOME` and `MEMHOUSE_BIN` into each playbook's `settings.json` `env`, which
|
|
173
|
+
Claude Code applies to every session there. So the `/mem:*` skills in a bound playbook read
|
|
174
|
+
that instance's house and run that instance's binary, whatever the shell they were started
|
|
175
|
+
from had. `plugins list` shows the binding; `plugins remove` takes it out. Everything else
|
|
176
|
+
in `settings.json` is left as it was.
|
|
177
|
+
|
|
165
178
|
## Going back into a session
|
|
166
179
|
|
|
167
180
|
`search` finds the conversation; `resume` hands you back into it.
|
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
|
|
@@ -438,6 +450,7 @@ Data ship one incremental pass (--full | --loop [sec])
|
|
|
438
450
|
search <terms…> full-text search across all sessions
|
|
439
451
|
resume <session-id> print the command that reopens that session in its editor
|
|
440
452
|
sessions-query print the session rollup SQL for this credential
|
|
453
|
+
instance which memhouse this is: home, binary, house, rooms, scope, daemons, bound playbooks
|
|
441
454
|
rooms what your rooms are actually called (--json)
|
|
442
455
|
members who is in a house and what each can reach (--db, admin)
|
|
443
456
|
start | stop | shipper loop + dashboard as background daemons
|
|
@@ -968,6 +981,16 @@ function startShipperBackground(cfg) {
|
|
|
968
981
|
console.log(' dashboard (browse / search / analyze): memhouse start');
|
|
969
982
|
}
|
|
970
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
|
+
|
|
971
994
|
async function cmdInstall({ interactive }) {
|
|
972
995
|
// --env <file>: an INVITE intake. The file carries MEMHOUSE_URL/USER/PASSWORD/DB from
|
|
973
996
|
// `memhouse invite` on the admin's machine; loading it into the environment BEFORE
|
|
@@ -984,6 +1007,10 @@ async function cmdInstall({ interactive }) {
|
|
|
984
1007
|
// this machine with a file that cannot authenticate on the invitee's.
|
|
985
1008
|
const missing = wanted.filter((k) => !parsed[k]);
|
|
986
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 */ }
|
|
987
1014
|
// Clear ambient MEMHOUSE_* so ONLY the file speaks (exported vars normally win over the
|
|
988
1015
|
// file; an invite intake is the one place they must not).
|
|
989
1016
|
for (const k of ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB', 'MEMHOUSE_PORT']) delete process.env[k];
|
|
@@ -1235,6 +1262,10 @@ async function cmdInstall({ interactive }) {
|
|
|
1235
1262
|
// deliberately for exactly this reason; install used to do nothing at all.
|
|
1236
1263
|
if (displaces && shipperHealth().running) {
|
|
1237
1264
|
const st = (() => { try { return require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { return null; } })();
|
|
1265
|
+
// Same server and database, different user, is a credential swap, not a house move —
|
|
1266
|
+
// "old house" reads as data loss when nothing moved. A drill flagged the conflation.
|
|
1267
|
+
const sameHouse = prior.MEMHOUSE_URL && sameEndpoint(prior.MEMHOUSE_URL, cfg.url) && (!prior.MEMHOUSE_DB || prior.MEMHOUSE_DB === cfg.db);
|
|
1268
|
+
const whatMoved = sameHouse ? 'old credential' : 'old house';
|
|
1238
1269
|
const viaService = String(shipperHealth().via || '').startsWith('service');
|
|
1239
1270
|
if (viaService && st) {
|
|
1240
1271
|
const cmd = st.kind === 'systemd'
|
|
@@ -1242,10 +1273,10 @@ async function cmdInstall({ interactive }) {
|
|
|
1242
1273
|
: ['launchctl', ['kickstart', '-k', `gui/${process.getuid()}/com.memhouse.shipper`]];
|
|
1243
1274
|
const r = spawnSync(cmd[0], cmd[1], { stdio: 'pipe', encoding: 'utf-8' });
|
|
1244
1275
|
console.log(r.status === 0 ? ok('shipper restarted against the new house')
|
|
1245
|
-
: warn(
|
|
1276
|
+
: warn(`restart the shipper yourself, or it keeps writing under the ${whatMoved}: memhouse service restart`));
|
|
1246
1277
|
} else {
|
|
1247
1278
|
const pid = pidOf('shipper');
|
|
1248
|
-
if (pid) { try { process.kill(pid, 'SIGTERM'); console.log(ok(`stopped the shipper (pid ${pid}) — it was
|
|
1279
|
+
if (pid) { try { process.kill(pid, 'SIGTERM'); console.log(ok(`stopped the shipper (pid ${pid}) — it was running under the ${whatMoved}`)); } catch { /* raced */ } }
|
|
1249
1280
|
console.log(' start it against the new one: memhouse start');
|
|
1250
1281
|
}
|
|
1251
1282
|
}
|
|
@@ -1280,6 +1311,18 @@ async function finishInvite(cfg) {
|
|
|
1280
1311
|
}
|
|
1281
1312
|
if (go) {
|
|
1282
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
|
+
}
|
|
1283
1326
|
if (code !== 0) {
|
|
1284
1327
|
// Rotation failed and its own state may be uncertain — KEEP the invite file (it
|
|
1285
1328
|
// still carries the password the config was just written from) so nothing is
|
|
@@ -1318,7 +1361,8 @@ function printGettingStarted(cfg) {
|
|
|
1318
1361
|
console.log(' /mem:sql, /mem:access, /mem:admin');
|
|
1319
1362
|
console.log(` ${mh} search <terms> find a past conversation right now`);
|
|
1320
1363
|
console.log(` ${mh} doctor every line a check mark = healthy`);
|
|
1321
|
-
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.');
|
|
1322
1366
|
}
|
|
1323
1367
|
|
|
1324
1368
|
async function cmdOnboard() {
|
|
@@ -1600,7 +1644,12 @@ async function cmdStatus() {
|
|
|
1600
1644
|
console.log(` ${f.writer.padEnd(28)} ${String(f.version || '?').padEnd(8)} last ship ${fleetAge(f.ageMs)}${mark}`);
|
|
1601
1645
|
}
|
|
1602
1646
|
}
|
|
1603
|
-
|
|
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
|
+
}
|
|
1604
1653
|
console.log(out.daemons.dashboard ? ok(`dashboard: running (pid ${out.daemons.dashboard})`) : warn('dashboard: not running'));
|
|
1605
1654
|
if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${runningPort(cfg)}`);
|
|
1606
1655
|
// Exit non-zero when the house is unreachable. `status` is what a health check, a
|
|
@@ -1947,7 +1996,9 @@ async function cmdDoctor() {
|
|
|
1947
1996
|
'that adapter does not extract usage; the messages are stored, the numbers are not');
|
|
1948
1997
|
} catch { /* a house that cannot be read is already reported above */ }
|
|
1949
1998
|
const sh = shipperHealth();
|
|
1950
|
-
|
|
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)');
|
|
1951
2002
|
add(!!pidOf('dashboard'), 'dashboard daemon', 'memhouse start');
|
|
1952
2003
|
add(fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html')), 'dashboard UI built', 'built automatically by memhouse start');
|
|
1953
2004
|
|
|
@@ -2417,7 +2468,19 @@ async function cmdPlugins() {
|
|
|
2417
2468
|
const invocations = names.map((n) => `/mem:${n}`).join(', ');
|
|
2418
2469
|
// --target overrides the discovery rather than joining it: given one, that is the only
|
|
2419
2470
|
// directory touched.
|
|
2420
|
-
|
|
2471
|
+
// Targets: --target names one; otherwise an instance that ships specific playbooks
|
|
2472
|
+
// (MEMHOUSE_CLAUDE_ROOTS) installs into exactly those — the plugin belongs where the
|
|
2473
|
+
// sessions come from; failing both, every Claude config dir on the machine.
|
|
2474
|
+
const binding = require(path.join(REPO_ROOT, 'memhouse', 'binding'));
|
|
2475
|
+
const scope = require(path.join(REPO_ROOT, 'editors', 'scope'));
|
|
2476
|
+
const icfg = resolveConfig();
|
|
2477
|
+
const scoped = !flags.target && icfg.claudeRoots
|
|
2478
|
+
? scope.parseList(icfg.claudeRoots).map(scope.expandHome).map((d) => ({ dir: d, why: 'this instance ships it' }))
|
|
2479
|
+
: null;
|
|
2480
|
+
const targets = flags.target ? [{ dir: flags.target, why: '--target' }] : (scoped || claudeTargets());
|
|
2481
|
+
// What the skills in a bound playbook run and read: this binary, this home.
|
|
2482
|
+
const thisBin = (() => { try { return fs.realpathSync(process.argv[1]); } catch { return process.argv[1]; } })();
|
|
2483
|
+
const boundLabel = (dir) => { const b = binding.boundTo(binding.readSettings(dir)); return b ? `bound to ${short(b.home)}` : 'unbound — sessions use whatever MEMHOUSE_HOME the shell has'; };
|
|
2421
2484
|
// Whatever --target was given has to reappear in the advice, or pasting it installs
|
|
2422
2485
|
// somewhere else than the directory just inspected.
|
|
2423
2486
|
const self = `memhouse plugins install claude${flags.target ? ` --target ${flags.target}` : ''}`;
|
|
@@ -2427,7 +2490,7 @@ async function cmdPlugins() {
|
|
|
2427
2490
|
if (!targets.length) { console.log(warn('no Claude Code config directory found')); return 0; }
|
|
2428
2491
|
for (const t of targets) {
|
|
2429
2492
|
console.log(isPluginInstalled(t.dir)
|
|
2430
|
-
? ok(`installed in ${short(t.dir)} (${t.why})`)
|
|
2493
|
+
? ok(`installed in ${short(t.dir)} (${t.why}) — ${boundLabel(t.dir)}`)
|
|
2431
2494
|
: warn(`not installed in ${short(t.dir)} (${t.why})`));
|
|
2432
2495
|
}
|
|
2433
2496
|
if (!targets.some((t) => isPluginInstalled(t.dir))) console.log(` install with: ${self}`);
|
|
@@ -2442,7 +2505,13 @@ async function cmdPlugins() {
|
|
|
2442
2505
|
}
|
|
2443
2506
|
const chosen = await chooseTargets(targets, 'Install');
|
|
2444
2507
|
if (!chosen.length) { console.log(warn('nothing installed')); return 0; }
|
|
2445
|
-
for (const t of chosen)
|
|
2508
|
+
for (const t of chosen) {
|
|
2509
|
+
console.log(ok(`installed ${names.length} skills into ${short(installPluginInto(t.dir))}`));
|
|
2510
|
+
try {
|
|
2511
|
+
binding.bind(t.dir, { home: HOME_DIR, bin: thisBin });
|
|
2512
|
+
console.log(ok(` bound: sessions under ${short(t.dir)} read ${short(HOME_DIR)} and run ${short(thisBin)}`));
|
|
2513
|
+
} catch (e) { console.log(warn(` not bound: ${e.message}`)); }
|
|
2514
|
+
}
|
|
2446
2515
|
console.log(` loads as mem@skills-dir next session — invoke ${invocations}`);
|
|
2447
2516
|
return 0;
|
|
2448
2517
|
}
|
|
@@ -2458,6 +2527,8 @@ async function cmdPlugins() {
|
|
|
2458
2527
|
// else's work.
|
|
2459
2528
|
try { fs.rmdirSync(path.join(t.dir, 'skills')); } catch { /* not empty: leave it */ }
|
|
2460
2529
|
console.log(ok(`removed ${short(path.join(t.dir, 'skills', 'mem'))}`));
|
|
2530
|
+
try { if (binding.unbind(t.dir)) console.log(ok(` unbound: MEMHOUSE_HOME/MEMHOUSE_BIN removed from ${short(path.join(t.dir, 'settings.json'))}`)); }
|
|
2531
|
+
catch (e) { console.log(warn(` could not unbind: ${e.message}`)); }
|
|
2461
2532
|
}
|
|
2462
2533
|
return 0;
|
|
2463
2534
|
}
|
|
@@ -3335,6 +3406,53 @@ async function cmdWhoami() {
|
|
|
3335
3406
|
return 0;
|
|
3336
3407
|
}
|
|
3337
3408
|
|
|
3409
|
+
/**
|
|
3410
|
+
* Bare `memhouse` with an invite file nearby: offer to process it. Returns null when there
|
|
3411
|
+
* is nothing to offer (caller prints help), else the exit code of the install it ran or
|
|
3412
|
+
* declined. Never touches the file's password: what it shows is the user, the house and
|
|
3413
|
+
* the server, which the guide beside the file shows too.
|
|
3414
|
+
*/
|
|
3415
|
+
async function offerInvite() {
|
|
3416
|
+
const inv = require(path.join(REPO_ROOT, 'memhouse', 'invitefile'));
|
|
3417
|
+
// The current directory only. The guide says "from the directory holding the file", and
|
|
3418
|
+
// reaching into ~/Downloads surfaced unrelated invitations from other houses — and could
|
|
3419
|
+
// auto-join the wrong one under --yes. If they are somewhere else, they name the file.
|
|
3420
|
+
const found = inv.findInvites([process.cwd()]);
|
|
3421
|
+
if (!found.length) return null;
|
|
3422
|
+
const described = found.map((f) => ({ ...f, ...inv.describeInvite((() => { try { return fs.readFileSync(f.file, 'utf-8'); } catch { return ''; } })()) }));
|
|
3423
|
+
const usable = described.filter((d) => d.complete);
|
|
3424
|
+
if (!usable.length) {
|
|
3425
|
+
console.log(warn(`found ${found.length === 1 ? short(found[0].file) : `${found.length} invite files`} — not a complete invite (missing ${described[0].missing.join(', ')})`));
|
|
3426
|
+
return null;
|
|
3427
|
+
}
|
|
3428
|
+
const cfg = resolveConfig();
|
|
3429
|
+
const interactive = process.stdin.isTTY && flags.yes !== true;
|
|
3430
|
+
console.log(ok(`found an invitation: ${short(usable[0].file)}${usable.length > 1 ? ` (and ${usable.length - 1} more)` : ''}`));
|
|
3431
|
+
let pick = usable[0];
|
|
3432
|
+
if (usable.length > 1) {
|
|
3433
|
+
usable.forEach((d, i) => console.log(` ${i + 1}) ${short(d.file)} join ${d.url} as '${d.user}'`));
|
|
3434
|
+
if (!interactive) { console.log(' several invitations here — name one: memhouse install --env <file>'); return 1; }
|
|
3435
|
+
const a = (await ask('Which one? (number, or n)', '1')).trim().toLowerCase();
|
|
3436
|
+
const n = Number(a);
|
|
3437
|
+
if (!Number.isInteger(n) || n < 1 || n > usable.length) { console.log(' nothing done.'); return 1; }
|
|
3438
|
+
pick = usable[n - 1];
|
|
3439
|
+
}
|
|
3440
|
+
console.log(` it joins ${pick.url}, house '${pick.db}', as member '${pick.user}'${pick.channel ? `, channel ${pick.channel}` : ''}.`);
|
|
3441
|
+
if (cfg.stated && cfg.user) {
|
|
3442
|
+
console.log(warn(`this machine already ships to ${cfg.url} as '${cfg.user}' — joining REPLACES that (the old config is kept as env.pre-install).`));
|
|
3443
|
+
}
|
|
3444
|
+
console.log(' Joining rotates the password to one only this machine knows, deletes the file, and starts shipping.');
|
|
3445
|
+
if (!interactive) {
|
|
3446
|
+
if (flags.yes === true) { flags.env = pick.file; flags.force = cfg.stated && cfg.user ? true : flags.force; return cmdInstall({ interactive: false }); }
|
|
3447
|
+
console.log(` Join with: memhouse install --env ${path.basename(pick.file)} (or: memhouse --yes)`);
|
|
3448
|
+
return 0;
|
|
3449
|
+
}
|
|
3450
|
+
const a = (await ask(cfg.stated && cfg.user ? 'Replace the current house and join? (yes/no)' : 'Join now? (Y/n)', cfg.stated && cfg.user ? 'no' : 'Y')).trim().toLowerCase();
|
|
3451
|
+
if (!(a === 'y' || a === 'yes' || (a === '' && !(cfg.stated && cfg.user)))) { console.log(' nothing done. Later: memhouse install --env ' + path.basename(pick.file)); return 0; }
|
|
3452
|
+
flags.env = pick.file; if (cfg.stated && cfg.user) flags.force = true;
|
|
3453
|
+
return cmdInstall({ interactive: false });
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3338
3456
|
async function cmdInvite() {
|
|
3339
3457
|
const name = positional[0];
|
|
3340
3458
|
if (!name) { console.log('usage: memhouse invite <name> --url <house-url> [--admin-user … [--admin-password …]] [--db <house>] [--out <file>] [--adopt] [--print-sql]'); return 2; }
|
|
@@ -3806,13 +3924,21 @@ async function cmdUninstall() {
|
|
|
3806
3924
|
const cfg = resolveConfig();
|
|
3807
3925
|
|
|
3808
3926
|
switch (cmd) {
|
|
3809
|
-
case null:
|
|
3927
|
+
case null: {
|
|
3928
|
+
// Bare `memhouse`. If an invite file is where the user is standing — or in their
|
|
3929
|
+
// downloads — the likeliest reason they typed this is that they were handed it. Ask.
|
|
3930
|
+
// Otherwise, the help screen as before. Nothing happens without a yes.
|
|
3931
|
+
const code = await offerInvite();
|
|
3932
|
+
if (code === null) console.log(HELP); else process.exitCode = code;
|
|
3933
|
+
break;
|
|
3934
|
+
}
|
|
3935
|
+
case 'help': console.log(HELP); break;
|
|
3810
3936
|
// `--version` and `-v` land here as flags, not as the `version` verb, so they used to
|
|
3811
3937
|
// fall through to HELP: forty lines of help and exit 0 is not what --version means.
|
|
3812
3938
|
case 'version': console.log(PKG.version); break;
|
|
3813
3939
|
case 'discover': await cmdDiscover(); break;
|
|
3814
|
-
case 'onboard': process.exitCode = await cmdOnboard(); break;
|
|
3815
|
-
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;
|
|
3816
3942
|
case 'setup': {
|
|
3817
3943
|
// Dual-mode like install: --yes (with any --url/--user/--password/--db/
|
|
3818
3944
|
// --port overrides, already resolved into cfg) skips every prompt so
|
|
@@ -3943,6 +4069,57 @@ async function cmdUninstall() {
|
|
|
3943
4069
|
if (!au) console.log(' (run with --admin-user to be sure you are seeing every account)');
|
|
3944
4070
|
break;
|
|
3945
4071
|
}
|
|
4072
|
+
case 'instance': {
|
|
4073
|
+
// Which memhouse am I talking to? One screen: the instance, its binary, its house,
|
|
4074
|
+
// its rooms, what it ships, its host, its daemons, and which playbooks are bound to
|
|
4075
|
+
// it. A machine can run several instances; this is how they are told apart.
|
|
4076
|
+
const cfg = resolveConfig();
|
|
4077
|
+
const binding = require(path.join(REPO_ROOT, 'memhouse', 'binding'));
|
|
4078
|
+
const scope = require(path.join(REPO_ROOT, 'editors', 'scope'));
|
|
4079
|
+
const hostmod = require(path.join(REPO_ROOT, 'memhouse', 'host.js'));
|
|
4080
|
+
const thisBin = (() => { try { return fs.realpathSync(process.argv[1]); } catch { return process.argv[1]; } })();
|
|
4081
|
+
const name = binding.instanceName(HOME_DIR, readEnvFile().MEMHOUSE_NAME);
|
|
4082
|
+
const host = (() => { try { return hostmod.read(HOME_DIR); } catch { return null; } })();
|
|
4083
|
+
const daemons = { shipper: pidOf('shipper') || null, dashboard: pidOf('dashboard') || null };
|
|
4084
|
+
const realHome = (() => { try { return fs.realpathSync(HOME_DIR); } catch { return HOME_DIR; } })();
|
|
4085
|
+
const cands = new Map();
|
|
4086
|
+
for (const d of scope.parseList(cfg.claudeRoots).map(scope.expandHome)) cands.set(d, d);
|
|
4087
|
+
for (const t of claudeTargets()) cands.set(t.dir, t.dir);
|
|
4088
|
+
const bound = [...cands.keys()].filter((d) => {
|
|
4089
|
+
const b = binding.boundTo(binding.readSettings(d)); if (!b) return false;
|
|
4090
|
+
try { return fs.realpathSync(b.home) === realHome; } catch { return b.home === HOME_DIR; }
|
|
4091
|
+
});
|
|
4092
|
+
let rooms = null; let counts = {};
|
|
4093
|
+
if (cfg.stated && cfg.user) {
|
|
4094
|
+
rooms = roomNames(cfg.user);
|
|
4095
|
+
try {
|
|
4096
|
+
// system.tables, not system.parts: a member holds no grant on the latter, and the
|
|
4097
|
+
// former is filtered to what they may see. total_rows is approximate under merges
|
|
4098
|
+
// and exact enough for "is anything in there".
|
|
4099
|
+
for (const r of await chRows(cfg, `SELECT name AS table, total_rows AS n FROM system.tables WHERE database = ${sqlStr(cfg.db)} AND startsWith(name, ${sqlStr(`${cfg.user}_`)})`, { database: '' })) counts[r.table] = Number(r.n);
|
|
4100
|
+
} catch { /* unreachable house: rooms still listed, counts absent */ }
|
|
4101
|
+
}
|
|
4102
|
+
const out = {
|
|
4103
|
+
instance: name, home: HOME_DIR, envFile: ENV_FILE, binary: thisBin, version: PKG.version, channel: cfg.channel || null,
|
|
4104
|
+
house: cfg.stated ? { url: cfg.url, db: cfg.db, member: cfg.user, adminCredential: !!cfg.adminUser, adminUser: cfg.adminUser || null } : null,
|
|
4105
|
+
rooms: rooms ? [...ROOM_TYPES, ...META_TYPES].map((t) => ({ table: `${cfg.db}.${rooms.physical[t]}`, rows: counts[rooms.physical[t]] ?? null })) : [],
|
|
4106
|
+
ships: scope.describe({ MEMHOUSE_EDITORS: cfg.editors, MEMHOUSE_CLAUDE_ROOTS: cfg.claudeRoots }),
|
|
4107
|
+
host: host ? host.id : null, daemons: { ...daemons, dashboardUrl: daemons.dashboard ? `http://localhost:${cfg.port}` : null },
|
|
4108
|
+
boundPlaybooks: bound,
|
|
4109
|
+
};
|
|
4110
|
+
if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); break; }
|
|
4111
|
+
const line = (k, v) => console.log(` ${k.padEnd(9)} ${v}`);
|
|
4112
|
+
line('instance', `${name} (${short(HOME_DIR)})`);
|
|
4113
|
+
line('binary', `${short(thisBin)} ${PKG.version}${cfg.channel ? ` channel ${cfg.channel}` : ''}`);
|
|
4114
|
+
if (!out.house) { line('house', 'none configured — memhouse install'); break; }
|
|
4115
|
+
line('house', `${cfg.url} db ${cfg.db} member ${cfg.user} admin credential: ${cfg.adminUser ? `yes (${cfg.adminUser})` : 'no'}`);
|
|
4116
|
+
line('rooms', out.rooms.map((r) => `${r.table}${r.rows === null ? '' : ` ${r.rows}`}`).join(' · '));
|
|
4117
|
+
line('ships', out.ships);
|
|
4118
|
+
line('host', host ? host.id : 'not minted yet (first ship mints it)');
|
|
4119
|
+
line('daemons', `shipper ${daemons.shipper ? `running (pid ${daemons.shipper})` : 'stopped'} dashboard ${daemons.dashboard ? `running (pid ${daemons.dashboard}) → http://localhost:${cfg.port}` : 'stopped'}`);
|
|
4120
|
+
line('bound', bound.length ? bound.map(short).join(', ') : 'no playbook is bound to this instance — memhouse plugins install claude');
|
|
4121
|
+
break;
|
|
4122
|
+
}
|
|
3946
4123
|
case 'rooms': {
|
|
3947
4124
|
// What are my rooms called? One answer, in one place: named for the member, inside the
|
|
3948
4125
|
// configured house. It resolves through roomNames(), so it cannot drift from what the
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// An instance's plugin is bound to the instance that installed it.
|
|
2
|
+
//
|
|
3
|
+
// A memhouse INSTANCE is a home: an env file naming a house, the daemons that ship to it,
|
|
4
|
+
// and the playbooks (Claude Code config dirs) whose sessions it ships. Its /mem:* skills
|
|
5
|
+
// belong in exactly those playbooks, and inside them they must read THAT house and run
|
|
6
|
+
// THAT binary — a machine can carry two instances on two channels, and a bare `memhouse`
|
|
7
|
+
// with a session-inherited MEMHOUSE_HOME would pick whichever the shell happened to have.
|
|
8
|
+
//
|
|
9
|
+
// Claude Code applies a settings.json `env` block to every session under a config dir.
|
|
10
|
+
// So `plugins install` stamps two variables there, beside the skills it copies:
|
|
11
|
+
//
|
|
12
|
+
// MEMHOUSE_HOME the instance's home — which house the skills read
|
|
13
|
+
// MEMHOUSE_BIN the instance's binary — which memhouse the skills run
|
|
14
|
+
//
|
|
15
|
+
// Nothing else in the file is touched; other env keys survive; `plugins remove` takes the
|
|
16
|
+
// two back out. Pure functions here, file I/O below them, both unit-tested.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
const KEYS = ['MEMHOUSE_HOME', 'MEMHOUSE_BIN'];
|
|
22
|
+
|
|
23
|
+
function bindSettings(json, { home, bin }) {
|
|
24
|
+
const out = { ...(json || {}) };
|
|
25
|
+
out.env = { ...(out.env || {}), MEMHOUSE_HOME: home, MEMHOUSE_BIN: bin };
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function unbindSettings(json) {
|
|
30
|
+
const out = { ...(json || {}) };
|
|
31
|
+
if (out.env) {
|
|
32
|
+
out.env = { ...out.env };
|
|
33
|
+
for (const k of KEYS) delete out.env[k];
|
|
34
|
+
if (!Object.keys(out.env).length) delete out.env;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function boundTo(json) {
|
|
40
|
+
const env = (json && json.env) || {};
|
|
41
|
+
return env.MEMHOUSE_HOME ? { home: env.MEMHOUSE_HOME, bin: env.MEMHOUSE_BIN || null } : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function settingsPath(dir) { return path.join(dir, 'settings.json'); }
|
|
45
|
+
|
|
46
|
+
function readSettings(dir) {
|
|
47
|
+
try { return JSON.parse(fs.readFileSync(settingsPath(dir), 'utf-8')); } catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function writeSettings(dir, json) {
|
|
51
|
+
fs.writeFileSync(settingsPath(dir), `${JSON.stringify(json, null, 2)}\n`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Stamp the binding into a config dir's settings.json; returns what was written. */
|
|
55
|
+
function bind(dir, { home, bin }) {
|
|
56
|
+
const cur = readSettings(dir);
|
|
57
|
+
if (cur === null && fs.existsSync(settingsPath(dir))) throw new Error(`${settingsPath(dir)} is not valid JSON — not touching it`);
|
|
58
|
+
writeSettings(dir, bindSettings(cur || {}, { home, bin }));
|
|
59
|
+
return { home, bin };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function unbind(dir) {
|
|
63
|
+
const cur = readSettings(dir);
|
|
64
|
+
if (!cur || !boundTo(cur)) return false;
|
|
65
|
+
writeSettings(dir, unbindSettings(cur));
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What an instance is called: MEMHOUSE_NAME if the env file sets one, else its home
|
|
71
|
+
* directory with the conventional `.memhouse-` prefix removed — `~/.memhouse-stage` is
|
|
72
|
+
* "stage", `~/.memhouse` is "default", `~/alice-sandbox/memhouse` is "memhouse".
|
|
73
|
+
*/
|
|
74
|
+
function instanceName(home, envName = '') {
|
|
75
|
+
if (envName) return envName;
|
|
76
|
+
const base = path.basename(String(home || '').replace(/[\/]+$/, ''));
|
|
77
|
+
if (base === '.memhouse') return 'default';
|
|
78
|
+
return base.replace(/^\.memhouse-/, '') || 'default';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { bindSettings, unbindSettings, boundTo, bind, unbind, readSettings, KEYS, instanceName };
|
|
@@ -8,7 +8,7 @@ You received two things:
|
|
|
8
8
|
|
|
9
9
|
| file | what it is |
|
|
10
10
|
|---|---|
|
|
11
|
-
| `{{FILE}}` | **your credential
|
|
11
|
+
| `{{FILE}}` | **your credential**, in this same folder. One-time: install consumes it and deletes it. Treat it like a password until then. |
|
|
12
12
|
| this page | the steps. Nothing secret in here. |
|
|
13
13
|
|
|
14
14
|
Do it yourself, or hand this page to your coding agent. From the directory holding both
|
|
@@ -21,21 +21,39 @@ claude "Read MEMHOUSE-INVITATION.md and follow it. Ask me before each step that
|
|
|
21
21
|
Any agent that can run shell commands works the same way; the rules it must keep are at
|
|
22
22
|
the end of this page.
|
|
23
23
|
|
|
24
|
+
## 0. Find your credential
|
|
25
|
+
|
|
26
|
+
The install needs `{{FILE}}`, which was sent alongside this page. Confirm it is here first —
|
|
27
|
+
run this from the same directory, and if it prints the filename you are ready:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
ls invite-*.env
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
If it is missing, it did not travel with this page; get it from whoever invited you before
|
|
34
|
+
going on. Do not print its contents — the last line of it is a password.
|
|
35
|
+
|
|
24
36
|
## 1. Install memhouse
|
|
25
37
|
|
|
26
38
|
{{INSTALL}}
|
|
27
39
|
|
|
28
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.
|
|
29
45
|
`memhouse --version` should print `{{VERSION}}`.
|
|
30
46
|
|
|
31
47
|
## 2. Join the house
|
|
32
48
|
|
|
33
|
-
From the directory holding `{{FILE}}
|
|
49
|
+
From the directory holding `{{FILE}}`, just run memhouse — it finds the invitation and asks:
|
|
34
50
|
|
|
35
51
|
```
|
|
36
|
-
memhouse
|
|
52
|
+
memhouse
|
|
37
53
|
```
|
|
38
54
|
|
|
55
|
+
(or, without the question: `memhouse install --env {{FILE}}`)
|
|
56
|
+
|
|
39
57
|
What that does, in order: connects to `{{URL}}` as `{{NAME}}`, **rotates your password to
|
|
40
58
|
one only your machine knows**, creates your rooms, writes your config to `~/.memhouse/env`,
|
|
41
59
|
deletes `{{FILE}}`, and starts shipping in the background. The password in the file stops
|
|
@@ -115,6 +133,9 @@ narrower than the one memhouse issues — ask {{INVITER}} to run `memhouse insta
|
|
|
115
133
|
You are installing memhouse for the person who handed you this file. Five rules; three of
|
|
116
134
|
them exist because an agent broke them:
|
|
117
135
|
|
|
136
|
+
0. **Your credential is `{{FILE}}` in the directory this page is in.** If `Read
|
|
137
|
+
MEMHOUSE-INVITATION.md` is all you were given, `ls invite-*.env` beside it is the file
|
|
138
|
+
the steps refer to; confirm it is there before step 1.
|
|
118
139
|
1. **Never print `{{FILE}}` or any `MEMHOUSE_*` value.** No `cat`, no `echo $MEMHOUSE_PASSWORD`,
|
|
119
140
|
no `set -x`. This very session will be shipped into the house; a password printed here is
|
|
120
141
|
a password archived.
|
|
@@ -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
|
},
|
|
@@ -36,6 +36,13 @@ rows, and *you* read them.
|
|
|
36
36
|
memhouse ships back into the house — the archive is insert-only, so a leaked credential
|
|
37
37
|
cannot be withdrawn.
|
|
38
38
|
|
|
39
|
+
**Which memhouse.** `plugins install` stamps two variables into this config directory's
|
|
40
|
+
`settings.json`, so every session here carries them: `MEMHOUSE_HOME` is the instance whose
|
|
41
|
+
house these skills read, and `MEMHOUSE_BIN` is that instance's binary. **Every `memhouse …`
|
|
42
|
+
command in these skills means `"${MEMHOUSE_BIN:-memhouse}"`.** A machine can run two
|
|
43
|
+
instances on two channels; the bare name would be whichever the shell found first. Do not
|
|
44
|
+
override either variable.
|
|
45
|
+
|
|
39
46
|
Credentials resolve **flags > exported `MEMHOUSE_*` > `$MEMHOUSE_HOME/env`** (default
|
|
40
47
|
`~/.memhouse/env`) — the same order the CLI uses. The dance below snapshots the
|
|
41
48
|
environment first, because sourcing the file would otherwise clobber an already-exported
|
|
@@ -26,7 +26,7 @@ This runs the `memhouse invite` CLI and nothing else. **Do not hand-run `CREATE
|
|
|
26
26
|
`GRANT` with an admin password substituted into a command** — that burns the credential
|
|
27
27
|
into a transcript on disk. The CLI passes it to one process and never echoes it.
|
|
28
28
|
|
|
29
|
-
1. **Check the binary:** `command -v memhouse`. Absent → tell the user to
|
|
29
|
+
1. **Check the binary:** `command -v "${MEMHOUSE_BIN:-memhouse}"`. Absent → tell the user to
|
|
30
30
|
`npm install -g memhouse`. There is no in-skill fallback, on purpose.
|
|
31
31
|
|
|
32
32
|
2. **Gather only two things:** the invitee's **name** (validate `[A-Za-z][A-Za-z0-9_]*`;
|
|
@@ -32,6 +32,22 @@ stop:
|
|
|
32
32
|
> `memhouse deploy --local` stands one up bound to loopback. Someone invited you?
|
|
33
33
|
> `memhouse install --env invite-<you>.env`.
|
|
34
34
|
|
|
35
|
+
## First: which memhouse this is
|
|
36
|
+
|
|
37
|
+
Before anything else, run and show verbatim:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
"${MEMHOUSE_BIN:-memhouse}" instance
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
One screen: the instance's name and home, the binary and channel, the house (server,
|
|
44
|
+
database, member, whether an admin credential is present), the five rooms with row counts,
|
|
45
|
+
what this machine ships, the host identity, the daemons, and which playbooks are bound to
|
|
46
|
+
this instance. A machine can run several instances on different channels; this is how the
|
|
47
|
+
user tells them apart, and how you know which house every answer below comes from. If the
|
|
48
|
+
instance shown is not the one the user meant, stop and say so — the fix is launching
|
|
49
|
+
under the right playbook, not overriding variables.
|
|
50
|
+
|
|
35
51
|
## The report
|
|
36
52
|
|
|
37
53
|
Lead with the one-line verdict — *working and current*, *working but stale*, or *not
|
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/flags.js
CHANGED
|
@@ -16,7 +16,7 @@ const COMMAND_FLAGS = {
|
|
|
16
16
|
null: [], help: [], version: [],
|
|
17
17
|
whoami: ['admin'],
|
|
18
18
|
share: ['only', 'revoke', 'list'],
|
|
19
|
-
discover: [], stats: [], stop: [], doctor: [], 'sessions-query': [], rooms: [], members: ['db','admin-user','admin-password'],
|
|
19
|
+
discover: [], stats: [], stop: [], doctor: [], 'sessions-query': [], rooms: [], instance: [], members: ['db','admin-user','admin-password'],
|
|
20
20
|
status: [], 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',
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// An invite file found where the invitee is standing.
|
|
2
|
+
//
|
|
3
|
+
// The guide says "from the directory holding the file, run memhouse install --env <file>".
|
|
4
|
+
// A person who just downloaded `invite-alice.env` and typed `memhouse` should not be shown
|
|
5
|
+
// the help screen; they should be asked whether to join. This module finds candidate files
|
|
6
|
+
// and reads what they promise; the CLI decides whether to ask, and install does the work.
|
|
7
|
+
// Nothing here prints a value from the file — the URL, user and database are safe to show,
|
|
8
|
+
// the password never is.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const envfile = require('./envfile');
|
|
13
|
+
|
|
14
|
+
const REQUIRED = ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB'];
|
|
15
|
+
|
|
16
|
+
/** `invite-<name>.env` files in the given directories, first directory first. */
|
|
17
|
+
function findInvites(dirs) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const dir of dirs) {
|
|
20
|
+
let names = [];
|
|
21
|
+
try { names = fs.readdirSync(dir); } catch { continue; }
|
|
22
|
+
for (const n of names.sort()) if (/^invite-[A-Za-z0-9_]+\.env$/.test(n)) out.push({ file: path.join(dir, n), dir });
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What the file promises, without the secret. */
|
|
28
|
+
function describeInvite(text) {
|
|
29
|
+
const parsed = envfile.parse(String(text || ''));
|
|
30
|
+
const missing = REQUIRED.filter((k) => !parsed[k]);
|
|
31
|
+
return {
|
|
32
|
+
user: parsed.MEMHOUSE_USER || null, url: parsed.MEMHOUSE_URL || null, db: parsed.MEMHOUSE_DB || null,
|
|
33
|
+
channel: parsed.MEMHOUSE_CHANNEL || null, isInvite: parsed.MEMHOUSE_INVITE === '1',
|
|
34
|
+
complete: missing.length === 0, missing,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { findInvites, describeInvite, REQUIRED };
|
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": {
|