memhouse 0.18.4 → 0.18.6

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
@@ -453,6 +453,8 @@ Data ship one incremental pass (--full | --loop [sec])
453
453
  instance which memhouse this is: home, binary, house, rooms, scope, daemons, bound playbooks
454
454
  rooms what your rooms are actually called (--json)
455
455
  members who is in a house and what each can reach (--db, admin)
456
+ convert operator: move a pre-one-layout house (a database per member) to the one layout
457
+ (--admin-user/--admin-password, --member, --print-sql, --dry-run, --guides --out DIR)
456
458
  start | stop | shipper loop + dashboard as background daemons
457
459
  share <user> let someone read this house (--only project=… |
458
460
  session=… | host=… | source=… | folder=… | since=… |
@@ -1640,13 +1642,29 @@ async function cmdStatus() {
1640
1642
  if (out.connected && !out.messages) console.log(ok(`house: empty — ${out.sessions} sessions, 0 messages (nothing shipped yet)`));
1641
1643
  else if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${out.freshest} UTC)`));
1642
1644
  if (out.fleet && out.fleet.length) {
1643
- const badOnes = out.fleet.filter((f) => f.verdict !== 'ok');
1644
- console.log((badOnes.length ? warn : ok)(`fleet: ${out.fleet.length} writer(s) known to this house`));
1645
- for (const f of out.fleet) {
1645
+ // A writer appears in this list because it has rows — the list is GROUP BY over the
1646
+ // messages, not a registry — so a machine that was re-identified (before host ids were
1647
+ // derived from the machine) stays here forever, and cannot be tidied away without
1648
+ // deleting its sessions. Retired writers are therefore folded into one line instead of
1649
+ // shouting a permanent warning: they are history, not a fleet to maintain. `--all`
1650
+ // prints every one. A writer is RETIRED when it has been silent past the cutoff; a
1651
+ // pre-0.10 writer that is still active keeps its warning, because that one can still bite.
1652
+ const RETIRED_MS = 14 * 24 * 3600 * 1000;
1653
+ const retired = out.fleet.filter((f) => f.ageMs !== null && f.ageMs > RETIRED_MS);
1654
+ const shown = flags.all === true ? out.fleet : out.fleet.filter((f) => !retired.includes(f));
1655
+ const live = out.fleet.filter((f) => !retired.includes(f));
1656
+ const badOnes = live.filter((f) => f.verdict !== 'ok');
1657
+ console.log((badOnes.length ? warn : ok)(`fleet: ${live.length} active writer(s)${retired.length ? `, ${retired.length} retired` : ''}`));
1658
+ for (const f of shown) {
1646
1659
  const mark = f.verdict === 'legacy' ? ' ⚠ pre-0.10 — upgrade it (or revoke its mutation grants); its re-ships delete retained parses'
1647
1660
  : f.verdict === 'outdated' ? ' ⚠ supports an older schema — refusing every pass until updated'
1648
1661
  : f.verdict === 'stale' ? ' • stale' : '';
1649
- console.log(` ${f.writer.padEnd(28)} ${String(f.version || '?').padEnd(8)} last ship ${fleetAge(f.ageMs)}${mark}`);
1662
+ const tag = retired.includes(f) ? ' · retired' : mark;
1663
+ console.log(` ${f.writer.padEnd(28)} ${String(f.version || '?').padEnd(8)} last ship ${fleetAge(f.ageMs)}${tag}`);
1664
+ }
1665
+ if (retired.length && flags.all !== true) {
1666
+ const oldest = fleetAge(Math.max(...retired.map((f) => f.ageMs)));
1667
+ console.log(` ${String(`${retired.length} retired writer(s)`).padEnd(28)} ${' '.padEnd(8)} silent ${oldest} and longer — their sessions are still here: memhouse status --all`);
1650
1668
  }
1651
1669
  }
1652
1670
  {
@@ -2000,6 +2018,10 @@ async function cmdDoctor() {
2000
2018
  : `token capture: ${zero.map((r) => `${r.source} (${r.n} messages)`).join(', ')} ship ZERO tokens — their cost shows as $0, not as unknown`,
2001
2019
  'that adapter does not extract usage; the messages are stored, the numbers are not');
2002
2020
  } catch { /* a house that cannot be read is already reported above */ }
2021
+ try {
2022
+ const moved = await adoptMovedHouse(cfg, { fix: false });
2023
+ 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)');
2024
+ } catch { /* connection problems are reported above */ }
2003
2025
  const sh = shipperHealth();
2004
2026
  const shFail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(sh.lastPass);
2005
2027
  add(sh.running && !shFail, `shipper${sh.via ? ` — ${sh.via}` : ''}${shFail ? ` — ${shFail}` : ''}`,
@@ -2221,6 +2243,25 @@ async function cmdUpdate() {
2221
2243
  }
2222
2244
  }
2223
2245
  console.log(ok('files updated'));
2246
+ // The house may have been converted to the one layout since this machine last ran:
2247
+ // the member's rooms moved from <name>.* to mem.<name>_*. Fix the env here, before the
2248
+ // shipper restarts, or its first pass refuses the old address forever.
2249
+ try { await adoptMovedHouse(resolveConfig()); } catch { /* doctor will say */ }
2250
+
2251
+ // Bring the house to the schema the NEW code expects, before any daemon restarts.
2252
+ // A release that adds rooms (0.18.3's stat tables) used to land after the restart, so
2253
+ // the freshly started shipper failed its first passes with "has no <member>_sessions…",
2254
+ // `status` quoted that failure, and the dashboard served a page whose every request
2255
+ // 500'd — all of it self-healing eventually, all of it alarming meanwhile. Two real
2256
+ // members hit this within an hour of each other. The new binary owns the new schema, so
2257
+ // it runs it here, in the one moment the daemons are already down.
2258
+ {
2259
+ const r = spawnSync(process.execPath, [path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js'), '--ensure-schema'],
2260
+ { env: childEnv(resolveConfig()), encoding: 'utf-8', stdio: 'pipe', timeout: 120000 });
2261
+ const said = `${r.stdout || ''}${r.stderr || ''}`.trim().split('\n').filter((l) => l.includes('[memhouse]')).pop();
2262
+ if (r.status === 0) console.log(ok(`house schema current${said ? ` — ${said.replace('[memhouse] ', '')}` : ''}`));
2263
+ else console.log(warn(`could not bring the house to this version's schema — the daemons start anyway; run: memhouse doctor${said ? `\n ${said}` : ''}`));
2264
+ }
2224
2265
 
2225
2266
  // Refresh the Claude plugin wherever it is ALREADY installed, so `update` keeps the
2226
2267
  // skills in lockstep with the package instead of leaving a stale `/mem:*` behind. Only
@@ -3417,6 +3458,30 @@ async function cmdWhoami() {
3417
3458
  * declined. Never touches the file's password: what it shows is the user, the house and
3418
3459
  * the server, which the guide beside the file shows too.
3419
3460
  */
3461
+ /**
3462
+ * After the operator converted the house to the one layout, a member's env still says
3463
+ * MEMHOUSE_DB=<their name>. Their rooms now live in `mem` as <name>_*. Detect that and
3464
+ * rewrite the env: the member's `memhouse update` is the moment it happens, and doctor
3465
+ * names it. Returns 'moved' (env rewritten), 'current', or 'unknown' (could not tell).
3466
+ */
3467
+ async function adoptMovedHouse(cfg, { fix = true, quiet = false } = {}) {
3468
+ if (!cfg.stated || !cfg.user) return 'unknown';
3469
+ const mine = physicalRoom('messages', cfg.user);
3470
+ let rows;
3471
+ try {
3472
+ rows = await chRows(cfg, `SELECT database, name FROM system.tables WHERE (database = ${sqlStr(cfg.db)} AND name IN ('messages', ${sqlStr(mine)})) OR (database = 'mem' AND name = ${sqlStr(mine)})`, { database: '' });
3473
+ } catch { return 'unknown'; }
3474
+ const has = (d, n) => rows.some((r) => r.database === d && r.name === n);
3475
+ if (has(cfg.db, mine)) return 'current';
3476
+ if (cfg.db !== 'mem' && has('mem', mine) && !has(cfg.db, 'messages')) {
3477
+ if (!fix) return 'moved';
3478
+ writeEnvFile({ ...cfg, db: 'mem' });
3479
+ if (!quiet) console.log(ok(`your rooms moved: ${cfg.db}.messages is now mem.${mine} — ${short(ENV_FILE)} now says MEMHOUSE_DB=mem`));
3480
+ return 'moved';
3481
+ }
3482
+ return 'current';
3483
+ }
3484
+
3420
3485
  async function offerInvite() {
3421
3486
  const inv = require(path.join(REPO_ROOT, 'memhouse', 'invitefile'));
3422
3487
  // The current directory only. The guide says "from the directory holding the file", and
@@ -4014,6 +4079,93 @@ async function cmdUninstall() {
4014
4079
  console.log(r.sessions_v);
4015
4080
  break;
4016
4081
  }
4082
+ case 'convert': {
4083
+ // Operator: move a pre-one-layout house (a database per member) to the one layout.
4084
+ // Discovers members by their databases, plans per member, executes as admin, records
4085
+ // an event in each member's new events room. --print-sql shows the plan; --dry-run
4086
+ // discovers and plans without touching anything; --guides <dir> renders
4087
+ // MEMHOUSE-UPGRADE.md per member for the operator to hand out.
4088
+ const convert = require(path.join(REPO_ROOT, 'memhouse', 'convert'));
4089
+ const db = flags.db && flags.db !== true ? String(flags.db) : 'mem';
4090
+ const au = flags['admin-user'] && flags['admin-user'] !== true ? String(flags['admin-user']) : (cfg.adminUser || null);
4091
+ const ap = flags['admin-password'] && flags['admin-password'] !== true ? String(flags['admin-password']) : (cfg.adminPassword || '');
4092
+ if (!au) { console.log(bad('convert needs an admin credential: --admin-user/--admin-password (or the one this instance keeps)')); process.exitCode = 1; break; }
4093
+ const who = { ...cfg, user: au, password: ap };
4094
+ const only = flags.member && flags.member !== true ? String(flags.member) : null;
4095
+ // Candidates: a user whose own database holds `messages` and who has no rooms in `db` yet.
4096
+ const cands = await chRows(who, `SELECT u.name AS member FROM system.users u WHERE u.name IN (SELECT database FROM system.tables WHERE name = 'messages') AND u.name NOT IN (SELECT replaceRegexpOne(name, '_messages$', '') FROM system.tables WHERE database = ${sqlStr(db)} AND name LIKE '%\\_messages') ORDER BY u.name`, { database: '' });
4097
+ const members = cands.map((r) => r.member).filter((m) => !only || m === only);
4098
+ if (!members.length) { console.log(ok(only ? `'${only}' has nothing to convert` : 'nothing to convert — no member still owns a database of their own')); break; }
4099
+ console.log(ok(`${members.length} member(s) to convert into '${db}': ${members.join(', ')}`));
4100
+ const plans = [];
4101
+ for (const m of members) {
4102
+ const shares = (await chRows(who, `SELECT DISTINCT user_name FROM system.grants WHERE access_type = 'SELECT' AND database = ${sqlStr(m)} AND (table = '' OR table IS NULL) AND user_name != ${sqlStr(m)}`, { database: '' })).map((r) => ({ reader: r.user_name }));
4103
+ const policies = (await chRows(who, `SELECT name, table, select_filter AS filter, apply_to_list AS readers FROM system.row_policies WHERE database = ${sqlStr(m)}`, { database: '' })).map((r) => ({ name: r.name, table: r.table, filter: r.filter, readers: Array.isArray(r.readers) ? r.readers : [] }));
4104
+ plans.push({ member: m, shares, policies, steps: convert.planMember({ member: m, db, shares, policies }) });
4105
+ }
4106
+ if (flags['print-sql'] === true || flags['dry-run'] === true) {
4107
+ console.log(`CREATE DATABASE IF NOT EXISTS ${db};`);
4108
+ for (const p of plans) console.log(`\n-- ${p.member}: ${p.shares.length} share(s), ${p.policies.length} row polic${p.policies.length === 1 ? 'y' : 'ies'}\n${convert.render(p.steps)}`);
4109
+ if (flags['dry-run'] === true) console.log(`\n${warn('dry run — nothing executed')}`);
4110
+ break;
4111
+ }
4112
+ if (flags.yes !== true && process.stdin.isTTY) {
4113
+ const a = (await ask(`Convert ${members.length} member(s) now? Renames are instant and reversible only by renaming back. (yes/no)`, 'no')).toLowerCase();
4114
+ if (a !== 'yes' && a !== 'y') { console.log(' nothing done.'); break; }
4115
+ }
4116
+ await ch(who, `CREATE DATABASE IF NOT EXISTS ${db}`, { database: '' });
4117
+ let converted = 0;
4118
+ for (const p of plans) {
4119
+ let failed = null;
4120
+ for (const st of p.steps) {
4121
+ if (st.when === 'empty') {
4122
+ const left = await chRows(who, `SELECT count() AS n FROM system.tables WHERE database = ${sqlStr(p.member)}`, { database: '' });
4123
+ if (Number(left[0] && left[0].n) !== 0) { console.log(warn(` ${p.member}: database kept — ${left[0].n} unexpected table(s) remain in it`)); continue; }
4124
+ }
4125
+ try { await ch(who, st.sql, { database: '' }); }
4126
+ catch (e) {
4127
+ const m = (e && e.message ? e.message : String(e)).split('\n')[0];
4128
+ if (st.optional) { console.log(warn(` ${p.member}: skipped — ${st.why}: ${m}`)); continue; }
4129
+ failed = `${st.why}: ${m}`; break;
4130
+ }
4131
+ }
4132
+ if (failed) { console.log(bad(`${p.member}: stopped — ${failed}`)); console.log(' what already ran stays (renames are visible in system.tables); fix and re-run: memhouse convert --member ' + p.member); process.exitCode = 1; continue; }
4133
+ try {
4134
+ const ev = physicalRoom('events', p.member);
4135
+ const cols = new Set((await chRows(who, `SELECT name FROM system.columns WHERE database = ${sqlStr(db)} AND table = ${sqlStr(ev)} AND default_kind != 'MATERIALIZED'`, { database: '' })).map((r) => r.name)); // actor is MATERIALIZED currentUser()
4136
+ const want = { event_at: 'now64(3)', kind: "'layout'", id: "'one-layout'", status: "'converted'", from_version: "'db-per-member'", to_version: "'one-layout'", actor: sqlStr(au), host: sqlStr(os.hostname()), rows_before: '0', rows_after: '0', detail: sqlStr(`renamed ${p.member}.* → ${db}.${p.member}_*; ${p.shares.length} share(s), ${p.policies.length} row polic(y/ies) carried`) };
4137
+ const use = Object.keys(want).filter((c) => cols.has(c));
4138
+ if (use.length) await ch(who, `INSERT INTO ${db}.${ev} (${use.join(', ')}) VALUES (${use.map((c) => want[c]).join(', ')})`, { database: '' });
4139
+ else console.log(warn(` ${p.member}: events room has none of the expected columns — no event recorded`));
4140
+ } catch (e) { console.log(warn(` ${p.member}: event not recorded — ${(e && e.message ? e.message : String(e)).split('\n')[0].slice(0, 120)}`)); }
4141
+ // The rooms moved, but the house also needs what THIS version adds — the
4142
+ // per-member stat tables the dashboard reads. `ensureSchema` makes them, and a
4143
+ // plain `ship` never calls it, so leaving them to the member meant a dashboard
4144
+ // that scanned millions of rows (or 500'd) until someone happened to run
4145
+ // `ship --ensure-schema`. Create them here, as the admin, while we are already
4146
+ // holding the member's rooms in mind.
4147
+ try {
4148
+ const st = convert.statTables(db, p.member);
4149
+ for (const sql of st) await ch(who, sql, { database: '' });
4150
+ if (st.length) console.log(ok(` ${p.member}: dashboard tables created (${st.length})`));
4151
+ } 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)}`)); }
4152
+ converted++;
4153
+ 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'}` : ''}`));
4154
+ }
4155
+ console.log(ok(`${converted}/${members.length} converted`));
4156
+ if (flags.guides) {
4157
+ const outDir = flags.out && flags.out !== true ? String(flags.out) : process.cwd();
4158
+ fs.mkdirSync(outDir, { recursive: true });
4159
+ const tpl = fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'delivery', 'UPGRADE.md.tpl'), 'utf-8');
4160
+ for (const p of plans) {
4161
+ const rooms = [...ROOM_TYPES, ...META_TYPES].map((t) => `${db}.${physicalRoom(t, p.member)}`).join('\n');
4162
+ const md = tpl.replaceAll('{{NAME}}', p.member).replaceAll('{{URL}}', cfg.url).replaceAll('{{DB}}', db).replaceAll('{{ROOMS}}', rooms)
4163
+ .replaceAll('{{CHANNEL}}', cfg.channel || 'latest').replaceAll('{{OPERATOR}}', os.userInfo().username).replaceAll('{{VERSION}}', PKG.version).replaceAll('{{DATE}}', new Date().toISOString().slice(0, 10));
4164
+ const f = path.join(outDir, `MEMHOUSE-UPGRADE-${p.member}.md`); fs.writeFileSync(f, md); console.log(ok(`guide written: ${short(f)}`));
4165
+ }
4166
+ }
4167
+ break;
4168
+ }
4017
4169
  case 'members': {
4018
4170
  // "Who is in this database, and what can each of them reach?" Before shared houses a
4019
4171
  // database had exactly one member and the question did not exist; this layout creates
@@ -0,0 +1,75 @@
1
+ // Convert a pre-one-layout house to the one layout — the plan, as SQL, pure.
2
+ //
3
+ // Before 0.18 every member owned a DATABASE named for them: `polat.messages`,
4
+ // `polat.house_meta`, granted ALL ON polat.*. The one layout puts everyone in one database
5
+ // as `mem.polat_messages`, `mem.polat_meta`, with one wildcard grant and nothing on the
6
+ // database itself. Moving is a RENAME per table — metadata only, nothing copied — plus the
7
+ // grant swap, and shares re-expressed on the new names. The 0.17.1 dashboard views are
8
+ // dropped: in the one layout the shipper fills the stat tables (a refreshable view needs
9
+ // CREATE TABLE on the whole database, the grant this layout withholds).
10
+ //
11
+ // The operator runs it once, as an admin. Members then `memhouse update`; the new build
12
+ // finds the moved rooms and repairs its own env (see adoptMovedHouse in the CLI and shipper).
13
+
14
+ const { roomPattern, physicalRoom, ROOM_TYPES, META_TYPES, MEMBER_PIN } = require('./house/house');
15
+ const { roomGrant } = require('./provision');
16
+
17
+ const LEGACY = { messages: 'messages', sessions: 'sessions', tool_calls: 'tool_calls', meta: 'house_meta', events: 'house_events' };
18
+ const STAT_VIEWS = ['session_stats_mv', 'session_model_stats_mv', 'session_tool_stats_mv'];
19
+ const STAT_TABLES = ['session_stats', 'session_model_stats', 'session_tool_stats'];
20
+
21
+ /**
22
+ * @param {object} p
23
+ * @param {string} p.member the member (= the old database name)
24
+ * @param {string} [p.db='mem'] the one-layout house
25
+ * @param {Array<{reader:string}>} [p.shares] whole-house reads granted to others (SELECT ON member.*)
26
+ * @param {Array<{name:string, table:string, filter:string, readers:string[]}>} [p.policies] row policies on the old tables
27
+ * @returns {Array<{sql:string, why:string, optional?:boolean, when?:string}>}
28
+ */
29
+ function planMember({ member, db = 'mem', shares = [], policies = [] }) {
30
+ const steps = [];
31
+ for (const v of STAT_VIEWS) steps.push({ sql: `DROP TABLE IF EXISTS ${member}.${v}`, why: '0.17.1 refreshable view — the shipper fills stats in the one layout' });
32
+ for (const t of STAT_TABLES) steps.push({ sql: `DROP TABLE IF EXISTS ${member}.${t}`, why: '0.17.1 stat table — recreated by the member\'s next ship in the new shape' });
33
+ const pairs = [...ROOM_TYPES, ...META_TYPES].map((t) => `${member}.${LEGACY[t]} TO ${db}.${physicalRoom(t, member)}`);
34
+ steps.push({ sql: `RENAME TABLE ${pairs.join(', ')}`, why: 'the five rooms move into the house under the member\'s name — one atomic rename, nothing copied' });
35
+ steps.push({ sql: `REVOKE ALL ON ${member}.* FROM ${member}`, why: 'the database-wide grant the old layout needed' });
36
+ steps.push({ sql: roomGrant(db, member), why: `their rooms — ${db}.${roomPattern(member)} — and nothing else` });
37
+ steps.push({ sql: `ALTER USER ${member} ADD SETTING ${MEMBER_PIN}`, why: 'user_id is stamped per row; async inserts would skip it', optional: true });
38
+ steps.push({ sql: `GRANT ALTER USER ON ${member} TO ${member}`, why: '`memhouse passwd` without an admin', optional: true });
39
+ steps.push({ sql: `GRANT SHOW USERS ON *.* TO ${member}`, why: 'names only, to know who to share with', optional: true });
40
+ steps.push({ sql: `GRANT REMOTE ON *.* TO ${member}`, why: '`memhouse relocate`', optional: true });
41
+ for (const s of shares) {
42
+ steps.push({ sql: `REVOKE SELECT ON ${member}.* FROM ${s.reader}`, why: `${s.reader}'s read of the old database`, optional: true });
43
+ steps.push({ sql: `GRANT SELECT ON ${db}.${roomPattern(member)} TO ${s.reader}`, why: `${s.reader} keeps reading ${member}'s rooms`, optional: true });
44
+ }
45
+ for (const p of policies) {
46
+ const type = Object.keys(LEGACY).find((k) => LEGACY[k] === p.table) || null;
47
+ if (!type) continue;
48
+ const readers = p.readers.length ? p.readers.join(', ') : 'ALL';
49
+ steps.push({ sql: `CREATE ROW POLICY OR REPLACE ${p.name} ON ${db}.${physicalRoom(type, member)} FOR SELECT USING ${p.filter} TO ${readers}`, why: `row policy '${p.name}' follows the room`, optional: true });
50
+ steps.push({ sql: `DROP ROW POLICY IF EXISTS ${p.name} ON ${member}.${p.table}`, why: 'its copy on the old name', optional: true });
51
+ }
52
+ steps.push({ sql: `DROP DATABASE IF EXISTS ${member}`, why: 'the emptied database', when: 'empty' });
53
+ return steps;
54
+ }
55
+
56
+ function render(steps) {
57
+ return steps.map((s) => `-- ${s.why}${s.optional ? ' (optional)' : ''}${s.when ? ` (only when ${s.when})` : ''}\n${s.sql};`).join('\n');
58
+ }
59
+
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 };
@@ -0,0 +1,67 @@
1
+ # Your memhouse moved — one command brings you along
2
+
3
+ {{OPERATOR}} upgraded the house at `{{URL}}` to memhouse's **one layout**. Your memory is
4
+ all still there; only its address changed. Where you had a database of your own
5
+ (`{{NAME}}.messages`), you now have rooms named for you inside the shared house:
6
+
7
+ ```
8
+ {{ROOMS}}
9
+ ```
10
+
11
+ Nothing was copied or deleted — the tables were renamed in place. Your shares were carried
12
+ over by the operator. Until you upgrade, your shipper stops adding new sessions (it refuses
13
+ the old address rather than writing somewhere wrong); nothing is lost meanwhile.
14
+
15
+ Do it yourself, or hand this page to your coding agent, from any directory:
16
+
17
+ ```
18
+ claude "Read MEMHOUSE-UPGRADE.md and follow it. Ask me before each step that changes this machine."
19
+ ```
20
+
21
+ ## 1. Upgrade
22
+
23
+ ```
24
+ memhouse update
25
+ ```
26
+
27
+ That installs the new build from the channel you are on ({{CHANNEL}}), notices that your
28
+ rooms moved, rewrites `~/.memhouse/env` to point at house `{{DB}}`, refreshes the `/mem`
29
+ skills in your Claude config, and restarts the shipper. If `memhouse` is not installed on
30
+ this machine at all: `npm install -g memhouse@{{CHANNEL}}`, then `memhouse status`.
31
+
32
+ ## 2. Check
33
+
34
+ ```
35
+ memhouse instance
36
+ memhouse status
37
+ ```
38
+
39
+ `instance` should say house `{{DB}}`, member `{{NAME}}`, rooms `{{DB}}.{{NAME}}_*`, shipper
40
+ running. `status` shows your session count — the same as before — and, after a few
41
+ minutes, a fresh pass.
42
+
43
+ ## 3. What is different
44
+
45
+ - **Reading a housemate.** In `/mem:recall`, a friend's memory is `{{DB}}.<friend>_messages`
46
+ — no database switch. `/mem:house` lists who can read yours.
47
+ - **The dashboard** (`memhouse start`, then the printed URL) is faster: the tables it reads
48
+ are now filled by your own shipper at the end of each pass.
49
+ - **Nothing to re-share.** Grants you gave or received were re-expressed on the new names.
50
+
51
+ ## If something looks wrong
52
+
53
+ - `memhouse status` says *"last pass FAILED"* → `memhouse stop && memhouse start`.
54
+ - `memhouse update` says it is *already current* but `instance` still shows house `{{NAME}}`
55
+ → run `memhouse doctor`; it names the fix.
56
+ - Anything else: send {{OPERATOR}} the output of `memhouse doctor` — it prints no secrets.
57
+
58
+ ## For an agent following this page
59
+
60
+ - Never print `~/.memhouse/env` or any `MEMHOUSE_*` value; the transcript you are in is
61
+ itself shipped into this house.
62
+ - Run exactly the commands above. Do not create or drop tables, do not edit the env by
63
+ hand — `memhouse update` does the rewrite, and `memhouse doctor` names any remaining fix.
64
+ - If `memhouse update` needs `sudo` (an `EACCES` from npm), that is the one place it is fine.
65
+ - Stop and show the person the output when anything says ✗.
66
+
67
+ memhouse {{VERSION}} · generated {{DATE}}
@@ -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",
4
+ "version": "0.18.6",
5
5
  "author": {
6
6
  "name": "Ramazan Polat"
7
7
  },
@@ -61,4 +61,11 @@ function credentialDrift(text, current = {}) {
61
61
  return { changed, values };
62
62
  }
63
63
 
64
- module.exports = { credentialDrift, parse, quoteShell, quoteSystemd, assertSingleLine };
64
+ /** The env file text with one key set (replaced in place, or appended). Pure. */
65
+ function setKey(text, key, value) {
66
+ const line = `${key}='${String(value).replace(/'/g, "'\\''")}'`;
67
+ const re = new RegExp(`^${key}=.*$`, 'm');
68
+ return re.test(text) ? text.replace(re, line) : `${text.replace(/\n*$/, '')}\n${line}\n`;
69
+ }
70
+
71
+ module.exports = { setKey, credentialDrift, parse, quoteShell, quoteSystemd, assertSingleLine };
package/memhouse/flags.js CHANGED
@@ -16,8 +16,8 @@ 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: [], instance: [], members: ['db','admin-user','admin-password'],
20
- status: [], start: [],
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: ['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: crypto.randomBytes(16).toString('hex'),
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 getClient() {
32
- if (_client) return _client;
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
- ...READ_SETTINGS,
44
- output_format_json_quote_64bit_integers: 0,
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]` : ''));
@@ -200,7 +200,7 @@ function toInt(v) {
200
200
  return Number.isFinite(n) && n > 0 ? n : 0;
201
201
  }
202
202
 
203
- function makeClient() {
203
+ function makeClient(override = {}) {
204
204
  // No house-shaped default. `memhouse` spawns this with the resolved config in the
205
205
  // environment, so a missing MEMHOUSE_URL here means the shipper was started some other
206
206
  // way — a stale service unit, a hand-rolled cron, a copied command — and the old
@@ -224,7 +224,7 @@ function makeClient() {
224
224
  url: process.env.MEMHOUSE_URL,
225
225
  username: process.env.MEMHOUSE_USER,
226
226
  password: process.env.MEMHOUSE_PASSWORD || '',
227
- database: process.env.MEMHOUSE_DB || process.env.MEMHOUSE_USER, // house defaults to the user's own name
227
+ database: override.database || process.env.MEMHOUSE_DB || process.env.MEMHOUSE_USER, // house defaults to the user's own name
228
228
  // gzip the INSERT body. Measured on a real house behind a Cloudflare tunnel: a
229
229
  // 2000-row batch was ~27 MB of JSON and the insert spent 76s in NetworkReceive alone
230
230
  // (index build was 0.8s, CPU 1.1s) — the upload, not ClickHouse, was the whole cost.
@@ -1310,6 +1310,27 @@ async function printStats(client) {
1310
1310
  async function main() {
1311
1311
  const argv = process.argv.slice(2);
1312
1312
  let client = makeClient();
1313
+ // The operator may have converted the house to the one layout since this env was
1314
+ // written: the member's rooms moved from <name>.* to mem.<name>_*. Adopt that here —
1315
+ // rewrite the env, point this process at `mem` — instead of refusing the old address on
1316
+ // every pass until someone runs `memhouse update`.
1317
+ try {
1318
+ const me = process.env.MEMHOUSE_USER; const db = process.env.MEMHOUSE_DB || me;
1319
+ if (me && db !== 'mem') {
1320
+ const mine = `${me}_messages`;
1321
+ // Probe through `system`: after a conversion the env's database no longer exists, and a
1322
+ // session defaulting to it is refused before any query runs.
1323
+ const probe = makeClient({ database: 'system' });
1324
+ const rs = await probe.query({ query: `SELECT database, name FROM system.tables WHERE (database = '${db.replace(/'/g, "\\'")}' AND name IN ('messages', '${mine}')) OR (database = 'mem' AND name = '${mine}')`, format: 'JSONEachRow' });
1325
+ const rows = await rs.json(); const has = (d, n) => rows.some((r) => r.database === d && r.name === n);
1326
+ if (!has(db, mine) && !has(db, 'messages') && has('mem', mine)) {
1327
+ const envPath = require('path').join(process.env.MEMHOUSE_HOME || require('path').join(require('os').homedir(), '.memhouse'), 'env');
1328
+ try { const fs = require('fs'); fs.writeFileSync(envPath, require('../envfile').setKey(fs.readFileSync(envPath, 'utf-8'), 'MEMHOUSE_DB', 'mem')); } catch { /* env not writable: this process still adopts */ }
1329
+ process.env.MEMHOUSE_DB = 'mem'; client = makeClient();
1330
+ console.error(`[memhouse] your rooms moved: ${db}.messages is now mem.${mine} — env now says MEMHOUSE_DB=mem`);
1331
+ }
1332
+ }
1333
+ } catch { /* unreachable or unreadable: the pass will say */ }
1313
1334
  try {
1314
1335
  if (argv.includes('--ensure-schema')) {
1315
1336
  const n = await ensureSchema(client);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memhouse",
3
- "version": "0.18.4",
3
+ "version": "0.18.6",
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": {