memhouse 0.18.3 → 0.18.5

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=… |
@@ -1013,8 +1015,13 @@ async function cmdInstall({ interactive }) {
1013
1015
  } catch { /* not a URL; the missing-fields check above already spoke */ }
1014
1016
  // Clear ambient MEMHOUSE_* so ONLY the file speaks (exported vars normally win over the
1015
1017
  // file; an invite intake is the one place they must not).
1016
- for (const k of ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB', 'MEMHOUSE_PORT']) delete process.env[k];
1018
+ for (const k of ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB', 'MEMHOUSE_PORT', 'MEMHOUSE_CHANNEL', 'MEMHOUSE_EDITORS', 'MEMHOUSE_CLAUDE_ROOTS']) delete process.env[k];
1017
1019
  for (const k of wanted) process.env[k] = parsed[k];
1020
+ // The invite also carries what the inviter's house is ON: its channel (a `team` house
1021
+ // is 0.18.x, published under `team`). Dropping it wrote an env with no pin, and the
1022
+ // member's first `memhouse update` followed `latest` — zeo's 0.17 line — and
1023
+ // downgraded a working team install onto the wrong layout. A flag still wins.
1024
+ for (const k of ['MEMHOUSE_CHANNEL', 'MEMHOUSE_EDITORS', 'MEMHOUSE_CLAUDE_ROOTS']) if (parsed[k]) process.env[k] = parsed[k];
1018
1025
  console.log(ok(`using the invite file ${String(flags.env)} (nothing persisted until the install proves out)`));
1019
1026
  _inviteFileToShred = path.resolve(String(flags.env));
1020
1027
  _inviteWantsRotate = parsed.MEMHOUSE_INVITE === '1';
@@ -1995,6 +2002,10 @@ async function cmdDoctor() {
1995
2002
  : `token capture: ${zero.map((r) => `${r.source} (${r.n} messages)`).join(', ')} ship ZERO tokens — their cost shows as $0, not as unknown`,
1996
2003
  'that adapter does not extract usage; the messages are stored, the numbers are not');
1997
2004
  } catch { /* a house that cannot be read is already reported above */ }
2005
+ try {
2006
+ const moved = await adoptMovedHouse(cfg, { fix: false });
2007
+ if (moved === 'moved') add(false, `rooms moved to mem.${physicalRoom('messages', cfg.user).replace(/_messages$/, '')}_* — this env still says MEMHOUSE_DB=${cfg.db}`, 'memhouse update (rewrites the env and restarts the shipper)');
2008
+ } catch { /* connection problems are reported above */ }
1998
2009
  const sh = shipperHealth();
1999
2010
  const shFail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(sh.lastPass);
2000
2011
  add(sh.running && !shFail, `shipper${sh.via ? ` — ${sh.via}` : ''}${shFail ? ` — ${shFail}` : ''}`,
@@ -2216,6 +2227,10 @@ async function cmdUpdate() {
2216
2227
  }
2217
2228
  }
2218
2229
  console.log(ok('files updated'));
2230
+ // The house may have been converted to the one layout since this machine last ran:
2231
+ // the member's rooms moved from <name>.* to mem.<name>_*. Fix the env here, before the
2232
+ // shipper restarts, or its first pass refuses the old address forever.
2233
+ try { await adoptMovedHouse(resolveConfig()); } catch { /* doctor will say */ }
2219
2234
 
2220
2235
  // Refresh the Claude plugin wherever it is ALREADY installed, so `update` keeps the
2221
2236
  // skills in lockstep with the package instead of leaving a stale `/mem:*` behind. Only
@@ -3412,6 +3427,30 @@ async function cmdWhoami() {
3412
3427
  * declined. Never touches the file's password: what it shows is the user, the house and
3413
3428
  * the server, which the guide beside the file shows too.
3414
3429
  */
3430
+ /**
3431
+ * After the operator converted the house to the one layout, a member's env still says
3432
+ * MEMHOUSE_DB=<their name>. Their rooms now live in `mem` as <name>_*. Detect that and
3433
+ * rewrite the env: the member's `memhouse update` is the moment it happens, and doctor
3434
+ * names it. Returns 'moved' (env rewritten), 'current', or 'unknown' (could not tell).
3435
+ */
3436
+ async function adoptMovedHouse(cfg, { fix = true, quiet = false } = {}) {
3437
+ if (!cfg.stated || !cfg.user) return 'unknown';
3438
+ const mine = physicalRoom('messages', cfg.user);
3439
+ let rows;
3440
+ try {
3441
+ 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: '' });
3442
+ } catch { return 'unknown'; }
3443
+ const has = (d, n) => rows.some((r) => r.database === d && r.name === n);
3444
+ if (has(cfg.db, mine)) return 'current';
3445
+ if (cfg.db !== 'mem' && has('mem', mine) && !has(cfg.db, 'messages')) {
3446
+ if (!fix) return 'moved';
3447
+ writeEnvFile({ ...cfg, db: 'mem' });
3448
+ if (!quiet) console.log(ok(`your rooms moved: ${cfg.db}.messages is now mem.${mine} — ${short(ENV_FILE)} now says MEMHOUSE_DB=mem`));
3449
+ return 'moved';
3450
+ }
3451
+ return 'current';
3452
+ }
3453
+
3415
3454
  async function offerInvite() {
3416
3455
  const inv = require(path.join(REPO_ROOT, 'memhouse', 'invitefile'));
3417
3456
  // The current directory only. The guide says "from the directory holding the file", and
@@ -4009,6 +4048,82 @@ async function cmdUninstall() {
4009
4048
  console.log(r.sessions_v);
4010
4049
  break;
4011
4050
  }
4051
+ case 'convert': {
4052
+ // Operator: move a pre-one-layout house (a database per member) to the one layout.
4053
+ // Discovers members by their databases, plans per member, executes as admin, records
4054
+ // an event in each member's new events room. --print-sql shows the plan; --dry-run
4055
+ // discovers and plans without touching anything; --guides <dir> renders
4056
+ // MEMHOUSE-UPGRADE.md per member for the operator to hand out.
4057
+ const convert = require(path.join(REPO_ROOT, 'memhouse', 'convert'));
4058
+ const db = flags.db && flags.db !== true ? String(flags.db) : 'mem';
4059
+ const au = flags['admin-user'] && flags['admin-user'] !== true ? String(flags['admin-user']) : (cfg.adminUser || null);
4060
+ const ap = flags['admin-password'] && flags['admin-password'] !== true ? String(flags['admin-password']) : (cfg.adminPassword || '');
4061
+ if (!au) { console.log(bad('convert needs an admin credential: --admin-user/--admin-password (or the one this instance keeps)')); process.exitCode = 1; break; }
4062
+ const who = { ...cfg, user: au, password: ap };
4063
+ const only = flags.member && flags.member !== true ? String(flags.member) : null;
4064
+ // Candidates: a user whose own database holds `messages` and who has no rooms in `db` yet.
4065
+ 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: '' });
4066
+ const members = cands.map((r) => r.member).filter((m) => !only || m === only);
4067
+ 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; }
4068
+ console.log(ok(`${members.length} member(s) to convert into '${db}': ${members.join(', ')}`));
4069
+ const plans = [];
4070
+ for (const m of members) {
4071
+ 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 }));
4072
+ 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 : [] }));
4073
+ plans.push({ member: m, shares, policies, steps: convert.planMember({ member: m, db, shares, policies }) });
4074
+ }
4075
+ if (flags['print-sql'] === true || flags['dry-run'] === true) {
4076
+ console.log(`CREATE DATABASE IF NOT EXISTS ${db};`);
4077
+ 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)}`);
4078
+ if (flags['dry-run'] === true) console.log(`\n${warn('dry run — nothing executed')}`);
4079
+ break;
4080
+ }
4081
+ if (flags.yes !== true && process.stdin.isTTY) {
4082
+ const a = (await ask(`Convert ${members.length} member(s) now? Renames are instant and reversible only by renaming back. (yes/no)`, 'no')).toLowerCase();
4083
+ if (a !== 'yes' && a !== 'y') { console.log(' nothing done.'); break; }
4084
+ }
4085
+ await ch(who, `CREATE DATABASE IF NOT EXISTS ${db}`, { database: '' });
4086
+ let converted = 0;
4087
+ for (const p of plans) {
4088
+ let failed = null;
4089
+ for (const st of p.steps) {
4090
+ if (st.when === 'empty') {
4091
+ const left = await chRows(who, `SELECT count() AS n FROM system.tables WHERE database = ${sqlStr(p.member)}`, { database: '' });
4092
+ 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; }
4093
+ }
4094
+ try { await ch(who, st.sql, { database: '' }); }
4095
+ catch (e) {
4096
+ const m = (e && e.message ? e.message : String(e)).split('\n')[0];
4097
+ if (st.optional) { console.log(warn(` ${p.member}: skipped — ${st.why}: ${m}`)); continue; }
4098
+ failed = `${st.why}: ${m}`; break;
4099
+ }
4100
+ }
4101
+ 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; }
4102
+ try {
4103
+ const ev = physicalRoom('events', p.member);
4104
+ 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()
4105
+ 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`) };
4106
+ const use = Object.keys(want).filter((c) => cols.has(c));
4107
+ if (use.length) await ch(who, `INSERT INTO ${db}.${ev} (${use.join(', ')}) VALUES (${use.map((c) => want[c]).join(', ')})`, { database: '' });
4108
+ else console.log(warn(` ${p.member}: events room has none of the expected columns — no event recorded`));
4109
+ } catch (e) { console.log(warn(` ${p.member}: event not recorded — ${(e && e.message ? e.message : String(e)).split('\n')[0].slice(0, 120)}`)); }
4110
+ converted++;
4111
+ console.log(ok(`${p.member}: rooms now ${db}.${p.member}_* — ${p.shares.length} share(s) carried${p.policies.length ? `, ${p.policies.length} row polic${p.policies.length === 1 ? 'y' : 'ies'}` : ''}`));
4112
+ }
4113
+ console.log(ok(`${converted}/${members.length} converted`));
4114
+ if (flags.guides) {
4115
+ const outDir = flags.out && flags.out !== true ? String(flags.out) : process.cwd();
4116
+ fs.mkdirSync(outDir, { recursive: true });
4117
+ const tpl = fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'delivery', 'UPGRADE.md.tpl'), 'utf-8');
4118
+ for (const p of plans) {
4119
+ const rooms = [...ROOM_TYPES, ...META_TYPES].map((t) => `${db}.${physicalRoom(t, p.member)}`).join('\n');
4120
+ const md = tpl.replaceAll('{{NAME}}', p.member).replaceAll('{{URL}}', cfg.url).replaceAll('{{DB}}', db).replaceAll('{{ROOMS}}', rooms)
4121
+ .replaceAll('{{CHANNEL}}', cfg.channel || 'latest').replaceAll('{{OPERATOR}}', os.userInfo().username).replaceAll('{{VERSION}}', PKG.version).replaceAll('{{DATE}}', new Date().toISOString().slice(0, 10));
4122
+ const f = path.join(outDir, `MEMHOUSE-UPGRADE-${p.member}.md`); fs.writeFileSync(f, md); console.log(ok(`guide written: ${short(f)}`));
4123
+ }
4124
+ }
4125
+ break;
4126
+ }
4012
4127
  case 'members': {
4013
4128
  // "Who is in this database, and what can each of them reach?" Before shared houses a
4014
4129
  // database had exactly one member and the question did not exist; this layout creates
@@ -32,9 +32,22 @@ function pickChannel({ version, pinned = null, tags = null }) {
32
32
  if (isPrerelease(version)) {
33
33
  return { channel: null, target: null, reason: `${version} is on no registry tag — a tarball or checkout build; update it by installing a newer tarball, or pin a channel: memhouse update --channel <tag>` };
34
34
  }
35
- return { channel: 'latest', target: t.latest || null, reason: tags ? 'following latest' : 'following latest (registry did not answer)' };
35
+ // A release that matches no tag any more (the tag moved on) stays on ITS LINE: the tag
36
+ // whose target shares this version's major.minor. Falling back to `latest` moved a
37
+ // 0.18.2 team member to 0.17.1 — a different house layout — on her first update.
38
+ const line = lineOf(version);
39
+ const sameLine = Object.entries(t).filter(([, v]) => lineOf(v) === line).map(([k]) => k);
40
+ if (sameLine.length) {
41
+ const k = sameLine.includes('latest') ? 'latest' : sameLine[0];
42
+ return { channel: k, target: t[k], reason: `following '${k}' — the tag on the ${line}.x line this install is on` };
43
+ }
44
+ if (!tags) return { channel: null, target: null, reason: 'the registry did not answer — cannot tell which line to follow; retry, or: memhouse update --channel <tag>' };
45
+ return { channel: null, target: null, reason: `${version} is on the ${line}.x line and no registry tag points at ${line}.x — crossing lines is a decision: memhouse update --channel <tag>` };
36
46
  }
37
47
 
48
+ /** "0.18" of "0.18.2" — the line a version belongs to. */
49
+ function lineOf(v) { const m = /^(\d+)\.(\d+)/.exec(String(v || '')); return m ? `${m[1]}.${m[2]}` : ''; }
50
+
38
51
  /** The registry's dist-tags, or null when it cannot be reached. Abbreviated document, small. */
39
52
  async function fetchTags(fetchImpl = globalThis.fetch) {
40
53
  try {
@@ -47,4 +60,4 @@ async function fetchTags(fetchImpl = globalThis.fetch) {
47
60
  } catch { return null; }
48
61
  }
49
62
 
50
- module.exports = { pickChannel, fetchTags, isPrerelease };
63
+ module.exports = { pickChannel, fetchTags, isPrerelease, lineOf };
@@ -0,0 +1,60 @@
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
+ module.exports = { planMember, render, 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.3",
4
+ "version": "0.18.5",
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,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: [], instance: [], members: ['db','admin-user','admin-password'],
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
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',
@@ -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.3",
3
+ "version": "0.18.5",
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": {