memhouse 0.7.1 → 0.9.0

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.
Files changed (37) hide show
  1. package/README.md +79 -43
  2. package/bin/memhouse.js +153 -147
  3. package/editors/antigravity.js +7 -15
  4. package/editors/cursor.js +18 -24
  5. package/editors/goose.js +9 -21
  6. package/editors/index.js +19 -70
  7. package/editors/opencode.js +6 -15
  8. package/editors/sqlite.js +74 -0
  9. package/editors/windsurf.js +3 -3
  10. package/editors/zed.js +10 -15
  11. package/memhouse/COMPETITION.md +9 -5
  12. package/memhouse/DESIGN.md +29 -30
  13. package/memhouse/RESUME-AND-UPDATE.md +7 -10
  14. package/memhouse/delivery/AGENT-INSTALL.md +15 -13
  15. package/memhouse/delivery/PROMPT.md +7 -9
  16. package/memhouse/delivery/kernel-install.md +18 -33
  17. package/memhouse/delivery/plugin/skills/search/SKILL.md +25 -38
  18. package/memhouse/delivery/plugin/skills/sessions/SKILL.md +15 -28
  19. package/memhouse/delivery/plugin/skills/sql/SKILL.md +23 -36
  20. package/memhouse/host.js +1 -1
  21. package/memhouse/house/HOUSE.md +74 -0
  22. package/memhouse/house/house.js +170 -0
  23. package/memhouse/{per-member/schema-member.sql.tpl → house/schema.sql.tpl} +8 -11
  24. package/memhouse/resume.js +1 -1
  25. package/memhouse/self-update.js +4 -4
  26. package/memhouse/server/queries.js +8 -2
  27. package/memhouse/shipper/ship.js +30 -37
  28. package/package.json +2 -6
  29. package/memhouse/per-member/INSTALL.md +0 -228
  30. package/memhouse/per-member/PLAN.md +0 -174
  31. package/memhouse/per-member/PROVISIONING.md +0 -177
  32. package/memhouse/per-member/README.md +0 -48
  33. package/memhouse/per-member/SCHEMA.md +0 -120
  34. package/memhouse/per-member/SHARING.md +0 -149
  35. package/memhouse/per-member/provision.js +0 -297
  36. package/memhouse/per-member/rooms.js +0 -252
  37. package/memhouse/per-member/schema-merge.sql.tpl +0 -24
package/bin/memhouse.js CHANGED
@@ -23,7 +23,7 @@ const SHIP_JS = path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js');
23
23
  const SERVER_JS = path.join(REPO_ROOT, 'memhouse', 'server', 'server.js');
24
24
  const DELIVERY = path.join(REPO_ROOT, 'memhouse', 'delivery');
25
25
  const PKG = require(path.join(REPO_ROOT, 'package.json'));
26
- const { roomNames, ROOM_TYPES, mergeRooms, MEMBER_PRIVS, MEMBER_PROFILE, MEMBER_PROFILE_SETTINGS, installCommand } = require(path.join(REPO_ROOT, 'memhouse', 'per-member', 'rooms'));
26
+ const { roomNames, ROOM_TYPES, MEMBER_PIN, installCommand } = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
27
27
  const envfile = require(path.join(REPO_ROOT, 'memhouse', 'envfile'));
28
28
 
29
29
  const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
@@ -65,7 +65,10 @@ function resolveConfig() {
65
65
  url: pick('url', 'MEMHOUSE_URL', 'http://localhost:8123'),
66
66
  user: pick('user', 'MEMHOUSE_USER', 'memhouse_root'),
67
67
  password: pick('password', 'MEMHOUSE_PASSWORD', ''),
68
- db: pick('db', 'MEMHOUSE_DB', 'mem'),
68
+ // The house defaults to the USER'S OWN NAME (`polat` ships into `polat.messages`),
69
+ // because a house is a database and the natural first house is your own. Any name
70
+ // works — user 'alice' with house 'default' is a supported pairing.
71
+ db: pick('db', 'MEMHOUSE_DB', pick('user', 'MEMHOUSE_USER', 'memhouse_root')),
69
72
  port: pick('port', 'MEMHOUSE_PORT', '4640'),
70
73
  // Did anything actually SAY which house this is, or are the values above just the
71
74
  // defaults? The defaults are not neutral — localhost:8123 as memhouse_root is a real
@@ -353,18 +356,15 @@ Engine: MEMHOUSE_ENGINE pins docker or podman when both are installed and one ca
353
356
  `;
354
357
 
355
358
  // A skipped adapter and an editor the user does not have look identical — both
356
- // contribute zero sessions. Say which happened, and how to fix the one that is fixable.
359
+ // contribute zero sessions. Say which happened.
360
+ //
361
+ // There was a second branch here for the one cause that hit five adapters at once: a
362
+ // missing better-sqlite3 native binding, fixable only by reinstalling with a flag. With
363
+ // SQLite now inside Node there is nothing to install and nothing to advise — every
364
+ // remaining failure is one adapter's own store, and its own message is the whole story.
357
365
  function printAdapterErrors(errors) {
358
366
  if (!errors || errors.length === 0) return;
359
- const blocked = errors.filter((e) => e.missingBinding);
360
- const other = errors.filter((e) => !e.missingBinding);
361
- if (blocked.length) {
362
- console.log(warn(`${blocked.length} adapter${blocked.length > 1 ? 's' : ''} skipped — better-sqlite3 has no native binding: ${blocked.map((e) => e.source).join(', ')}`));
363
- console.log(' their sessions are NOT being shipped. npm >= 12 blocks install scripts by default; rebuild with:');
364
- console.log(' ${installCommand()}');
365
- console.log(' (from a checkout: npm install --no-audit --no-fund, which package.json already allows)');
366
- }
367
- for (const e of other) console.log(warn(`${e.source.padEnd(16)} skipped: ${e.message}`));
367
+ for (const e of errors) console.log(warn(`${e.source.padEnd(16)} skipped: ${e.message}`));
368
368
  }
369
369
 
370
370
  async function cmdDiscover() {
@@ -600,38 +600,20 @@ function generatePassword() {
600
600
  * same templates provision.js applies, so the two cannot drift into different houses.
601
601
  */
602
602
  function memberSql(db, member, password) {
603
- const here = path.join(REPO_ROOT, 'memhouse', 'per-member');
604
- // The templates are written unqualified because provision.js applies them with the
605
- // house already selected. A human pastes this somewhere unknown — clickhouse-client,
606
- // the play UI, curl — so every name is qualified here and there is no `USE`.
607
- //
608
- // `currentDatabase()` in the Merge engine is the trap, and it is silent:
609
- // it is evaluated at CREATE time, not at read time, so a block run without the house
610
- // selected builds Merge rooms pointing at `default` that return zero rows forever. The
611
- // literal name goes in instead.
603
+ // The template is written unqualified because the shipper applies it with the house
604
+ // already selected. A human pastes this somewhere unknown — clickhouse-client, the
605
+ // play UI, curl — so every name is qualified here and there is no `USE`.
612
606
  const qualify = (sql) => sql
613
- .replace(/CREATE TABLE IF NOT EXISTS (\w+)/g, `CREATE TABLE IF NOT EXISTS ${db}.$1`)
614
- .replace(/ AS (\w+)\nENGINE = Merge/g, ` AS ${db}.$1\nENGINE = Merge`)
615
- .replace(/Merge\(currentDatabase\(\)/g, `Merge('${db}'`);
616
- const rooms = qualify(fs.readFileSync(path.join(here, 'schema-member.sql.tpl'), 'utf-8')
617
- .replaceAll('{{MEMBER}}', member));
618
- const merge = qualify(fs.readFileSync(path.join(here, 'schema-merge.sql.tpl'), 'utf-8')
619
- .replaceAll('{{TEMPLATE_MEMBER}}', member));
620
- // The SAME grant set provision.js issues. These two paths build the same house, and when
621
- // only one of them was narrowed, `install --print-sql` — the path INSTALL.md calls the
622
- // common case — still emitted GRANT ALL, which includes CREATE TABLE on the member's own
623
- // room name. A member provisioned that way could drop their room, recreate it as
624
- // Merge('<db>','^messages_'), and double every other member's rows in the team room:
625
- // exactly the attack the narrowing was for, fully reachable, one release later.
626
- const grants = ROOM_TYPES.flatMap((t) => [
627
- `GRANT ${MEMBER_PRIVS} ON ${db}.${t}_${member} TO ${member};`,
628
- `GRANT SELECT ON ${db}.${t}_${member} TO ${member} WITH GRANT OPTION;`,
629
- ]).join('\n');
630
- const mergeGrants = Object.values(mergeRooms())
631
- .map((n) => `GRANT SELECT ON ${db}.${n} TO ${member};`).join('\n');
607
+ .replace(/CREATE TABLE IF NOT EXISTS (\w+)/g, `CREATE TABLE IF NOT EXISTS ${db}.$1`);
608
+ const rooms = qualify(fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl'), 'utf-8'));
632
609
  return `-- memhouse: everything '${member}' needs in house '${db}'. Run as a user with
633
610
  -- ACCESS MANAGEMENT (a stock 'default' with access_management=1 will do).
634
611
  --
612
+ -- A house is a database; its rooms are three shared tables. Everyone granted on the
613
+ -- database writes into the same tables, and every row carries who (user_id, stamped by
614
+ -- the server) and where from (host, the install's fingerprint). Adding a housemate later
615
+ -- is the same two statements: CREATE USER, GRANT ALL ON the house.
616
+ --
635
617
  -- Every name is qualified and there is no USE, so this runs anywhere: clickhouse-client,
636
618
  -- the play UI, curl, a GUI. Order matters only in that the database comes first.
637
619
  --
@@ -655,31 +637,16 @@ CREATE USER IF NOT EXISTS ${member} IDENTIFIED BY '${password.replace(/'/g, "\\'
655
637
 
656
638
  ${rooms.trim()}
657
639
 
658
- -- Exactly what the shipper uses, and nothing that defines an object: no CREATE TABLE, no
659
- -- DROP, no TRUNCATE. A member who owned the room NAME rather than its data could replace
660
- -- it with a Merge over every member's rooms and double their rows in the team room.
661
- -- Only SELECT is re-grantable, so a share can only ever be read-only.
662
- ${grants}
663
-
664
- -- Team-wide reads. A Merge room reduces to the rooms the CALLER holds grants for, so
665
- -- this narrows rather than denies, and picks up members added later with no DDL.
666
- ${merge.trim()}
667
-
668
- ${mergeGrants}
669
-
670
- -- Resource ceilings. Confidentiality isolation is a grant that is absent; this is the
671
- -- availability half, and it did not exist until 0.4.7 — a member could raise
672
- -- max_memory_usage, zero max_execution_time and take a shared server down for everyone.
673
- -- CONST, not MAX: a plain default is advisory, and MAX is not enough either because 0 means
674
- -- UNLIMITED in ClickHouse and 0 satisfies any MAX. Measured — with a MAX ceiling in force,
675
- -- SETTINGS max_memory_usage = 0 was accepted and the ceiling was gone.
676
- -- CREATE ... OR REPLACE, which this used, DETACHES every user already on the profile, so
677
- -- provisioning a second member silently stripped the first member's ceilings. IF NOT
678
- -- EXISTS + ALTER edits the same object in place and keeps every assignment.
679
- CREATE SETTINGS PROFILE IF NOT EXISTS ${MEMBER_PROFILE};
680
- ALTER SETTINGS PROFILE ${MEMBER_PROFILE} SETTINGS NONE;
681
- ALTER SETTINGS PROFILE ${MEMBER_PROFILE} SETTINGS ${MEMBER_PROFILE_SETTINGS};
682
- ALTER USER ${member} SETTINGS PROFILE '${MEMBER_PROFILE}';
640
+ -- The whole database. ALL on your own house reaches nothing outside it — the database is
641
+ -- the boundary — and it is what lets the shipper create and evolve its own tables.
642
+ GRANT ALL ON ${db}.* TO ${member};
643
+
644
+ -- Pin async_insert on the user (ADD SETTING merges; a bare SETTINGS clause would replace
645
+ -- the user's whole list). The pin keeps the user_id stamp honest: a MATERIALIZED
646
+ -- currentUser() is computed during the INSERT, and an async flush stores it as the empty
647
+ -- string. CONST refuses the override; a client that never mentions the setting is held
648
+ -- at 0 and its insert lands stamped.
649
+ ALTER USER ${member} ADD SETTING ${MEMBER_PIN};
683
650
  `;
684
651
  }
685
652
 
@@ -707,22 +674,19 @@ async function adminBootstrap(cfg, admin) {
707
674
  catch { console.log(bad(`house '${cfg.db}' does not exist and '${admin.user}' cannot create it: ${e.message}`)); return null; }
708
675
  }
709
676
 
710
- // 2. the user. An EXISTING user is refused: adopting one silently hands the second
711
- // human the first's identity and rooms, and user_id MATERIALIZED currentUser() would
712
- // stamp them identically, so nothing downstream would ever notice.
713
- // A member who passes --admin-user gets ACCESS_DENIED here, because no member holds
714
- // SELECT ON system.users. That is the isolation working — but the raw 497 names neither
715
- // side, while INSTALL.md promises install "refuses, names both sides, and creates
716
- // nothing". Say who you are not, and what the owner has to run instead.
677
+ // 2. the user. A shared house makes an existing user unremarkable — rows are told apart
678
+ // by the server-stamped user_id, so "the handle is taken" hands nobody anything. What
679
+ // still matters is proof: connecting AS the member below is what validates whichever
680
+ // password is in play, existing user or fresh one.
717
681
  let exists;
718
682
  try {
719
683
  exists = (await chRows(adminCfg, `SELECT name FROM system.users WHERE name = '${admin.member}'`, { database: '' })).length > 0;
720
684
  } catch (e) {
721
685
  if (/Not enough privileges|ACCESS_DENIED|system\.users/i.test(e.message || '')) {
722
686
  console.log(bad(`'${admin.user}' is not an admin of this house — it cannot read system.users, so it cannot provision a member`));
723
- console.log(' Adding a member is the house owner\'s job. Ask them for:');
724
- console.log(` node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member ${admin.member} --merge`);
725
- console.log(' If you already HAVE a member credential on this house, you do not need admin:');
687
+ console.log(' Joining a house is two statements for whoever owns it:');
688
+ console.log(` CREATE USER ${admin.member} IDENTIFIED BY '…'; GRANT ALL ON ${cfg.db}.* TO ${admin.member};`);
689
+ console.log(' If you already HAVE a credential on this house, you do not need admin:');
726
690
  console.log(` memhouse install --url ${adminCfg.url} --user ${admin.member} --password …`);
727
691
  return null;
728
692
  }
@@ -730,19 +694,14 @@ async function adminBootstrap(cfg, admin) {
730
694
  }
731
695
  let password = flags['member-password'] || null;
732
696
  if (exists) {
733
- if (flags['adopt-user'] !== true) {
734
- console.log(bad(`ClickHouse user '${admin.member}' already exists in this house.`));
735
- console.log(' Creating rooms for them would hand you their identity — user_id is stamped from');
736
- console.log(' currentUser(), so their rows and yours would be indistinguishable.');
737
- console.log(' If this is you on a new machine, prove it:');
738
- console.log(` memhouse install --adopt-user --member ${admin.member} --member-password '…' …`);
739
- console.log(` If it is someone else, pick another handle: --member <name>`);
697
+ if (!password) {
698
+ console.log(bad(`ClickHouse user '${admin.member}' already exists — pass --member-password to install as them.`));
699
+ console.log(` Or pick another handle: --member <name>`);
740
700
  return null;
741
701
  }
742
- if (!password) { console.log(bad('--adopt-user requires --member-password — the password is the proof')); return null; }
743
702
  try { await ch({ ...cfg, user: admin.member, password }, 'SELECT 1', { database: '' }); }
744
- catch { console.log(bad(`--adopt-user: '${admin.member}' did not authenticate with that password`)); return null; }
745
- console.log(ok(`adopted existing user '${admin.member}' (password verified)`));
703
+ catch { console.log(bad(`'${admin.member}' did not authenticate with that password`)); return null; }
704
+ console.log(ok(`using the existing ClickHouse user '${admin.member}' (password verified)`));
746
705
  } else {
747
706
  if (!password) {
748
707
  password = generatePassword();
@@ -758,33 +717,18 @@ async function adminBootstrap(cfg, admin) {
758
717
  createdUser = admin.member;
759
718
  }
760
719
 
761
- // 3. rooms, 4. grants, 5. Merge rooms + their grant — provision.js owns all of it, so
762
- // there is one implementation of the grant set rather than two that drift.
763
- const provision = path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js');
764
- // Tell it who made the user. Without this the child finds a user that exists — because
765
- // THIS command created it two lines ago — and says so, producing two consecutive lines
766
- // of one command's output that contradict each other:
767
- // ✓ created ClickHouse user 'henry'
768
- // [mem] ClickHouse user 'henry' already exists — provisioning rooms only
769
- // and "already exists" is the exact phrase memhouse uses for the identity-takeover
770
- // REFUSAL, so a first-time installer reads a success as a warning about someone else.
771
- const provisionArgs = [provision, '--member', admin.member, '--merge'];
772
- if (createdUser) provisionArgs.push('--user-just-created');
773
- const rc = spawnSync(process.execPath, provisionArgs, {
774
- stdio: 'inherit',
775
- env: { ...process.env, MEM_URL: cfg.url, MEM_USER: admin.user, MEM_PASSWORD: admin.password, MEM_DB: cfg.db },
776
- });
777
- if (rc.status !== 0) {
778
- // "nothing was written" was a lie whenever the CREATE USER above had already run —
779
- // and it runs before this. Observed: provision.js died on a missing dependency, this
780
- // printed the all-clear, and the next install refused with the identity-takeover
781
- // message because the user it said it had not created was there. Say what exists, and
782
- // name the two ways forward.
783
- console.log(bad('provisioning failed'));
720
+ // 3. the grant and the pin. GRANT ALL on the house is the whole tenancy model: it
721
+ // reaches nothing outside the database, and it is what lets the member's own shipper
722
+ // create and evolve the rooms (--ensure-schema below).
723
+ try {
724
+ await q(`GRANT ALL ON ${cfg.db}.* TO ${admin.member}`, { database: '' });
725
+ await q(`ALTER USER ${admin.member} ADD SETTING ${MEMBER_PIN}`, { database: '' });
726
+ console.log(ok(`granted the house: ALL ON ${cfg.db}.* to '${admin.member}', async_insert pinned`));
727
+ } catch (e) {
728
+ console.log(bad(`could not grant the house to '${admin.member}': ${e.message}`));
784
729
  if (createdUser) {
785
- console.log(` ClickHouse user '${createdUser}' WAS created before this failed; its rooms were not.`);
786
- console.log(' Nothing was written to disk. To continue once the cause is fixed:');
787
- console.log(` memhouse install --adopt-user --member ${createdUser} --member-password '<the password above>' …`);
730
+ console.log(` ClickHouse user '${createdUser}' WAS created before this failed.`);
731
+ console.log(` Continue once fixed: memhouse install --member ${createdUser} --member-password '<the password above>' …`);
788
732
  console.log(` Or undo it as the admin: DROP USER ${createdUser}`);
789
733
  } else {
790
734
  console.log(' nothing was written');
@@ -793,11 +737,15 @@ async function adminBootstrap(cfg, admin) {
793
737
  }
794
738
 
795
739
  // The step that makes this trustworthy: stop being admin, and prove the credential we
796
- // are about to persist actually reaches the rooms.
740
+ // are about to persist can build and reach the rooms itself.
797
741
  const memberCfg = { ...cfg, user: admin.member, password };
742
+ if (run(SHIP_JS, ['--ensure-schema'], memberCfg) !== 0) {
743
+ console.log(bad(`'${admin.member}' could not create the rooms in '${cfg.db}' — nothing written to disk`));
744
+ return null;
745
+ }
798
746
  try {
799
- const seen = await chRows(memberCfg, `SELECT count() AS n FROM system.tables WHERE database = '${cfg.db}' AND name IN ('sessions_${admin.member}','messages_${admin.member}','tool_calls_${admin.member}')`, { database: '' });
800
- if (Number(seen[0]?.n) !== 3) { console.log(bad(`'${admin.member}' cannot see all three of their rooms — nothing written`)); return null; }
747
+ const seen = await chRows(memberCfg, `SELECT count() AS n FROM system.tables WHERE database = '${cfg.db}' AND name IN ('sessions','messages','tool_calls')`, { database: '' });
748
+ if (Number(seen[0]?.n) !== 3) { console.log(bad(`'${admin.member}' cannot see the three rooms — nothing written`)); return null; }
801
749
  } catch (e) { console.log(bad(`'${admin.member}' could not connect after provisioning: ${e.message}`)); return null; }
802
750
  console.log(ok(`verified as '${admin.member}' — admin credential discarded, not stored`));
803
751
  return memberCfg;
@@ -855,6 +803,16 @@ async function cmdInstall({ interactive }) {
855
803
  let cfg = resolveConfig();
856
804
  const adminUser = flags['admin-user'];
857
805
 
806
+ // The house name is spliced unquoted into CREATE DATABASE and GRANT ALL ON <db>.* —
807
+ // validate it HERE, on every install mode, not only where deploy happens to choose it.
808
+ // Found by the acceptance run, driven end to end: `install --db system` sailed through,
809
+ // granted a member CHECK/SHOW/SELECT/INSERT/ALTER/DROP/TRUNCATE/… ON system.* and
810
+ // created sessions/messages/tool_calls INSIDE the server's system database, exit 0.
811
+ // On a shared or kernel ClickHouse that is a granted-everything-on-system user minted
812
+ // by a memhouse one-liner.
813
+ try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(cfg.db, 'house'); }
814
+ catch (e) { console.log(bad(e.message)); return 1; }
815
+
858
816
  // Option 1 — print the SQL and stop. For the common case: you have admin on this
859
817
  // ClickHouse and would rather run four statements yourself than hand a credential to an
860
818
  // installer. Nothing is written and nothing is contacted.
@@ -893,7 +851,7 @@ async function cmdInstall({ interactive }) {
893
851
  if (keyProblem) {
894
852
  console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
895
853
  console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
896
- console.log(` rebuild them from ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl')}, then: memhouse ship --full`);
854
+ console.log(` rebuild them from ${path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl')}, then: memhouse ship --full`);
897
855
  console.log(' no config was written — nothing here reads as installed.');
898
856
  return 1;
899
857
  }
@@ -907,8 +865,8 @@ async function cmdInstall({ interactive }) {
907
865
  }
908
866
  if (flags['no-ship'] !== true && run(SHIP_JS, [], cfg) !== 0) return 1;
909
867
  console.log(ok('installed — next: memhouse start (dashboard + shipper loop)'));
910
- console.log(' admin is needed again only for: a second member, or an ADD COLUMN rollout.');
911
- console.log(` node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member <name> --merge`);
868
+ console.log(' adding a housemate later is two statements for the admin:');
869
+ console.log(` CREATE USER <name> IDENTIFIED BY '…'; GRANT ALL ON ${cfg.db}.* TO <name>;`);
912
870
  return 0;
913
871
  }
914
872
 
@@ -917,7 +875,7 @@ async function cmdInstall({ interactive }) {
917
875
  // authenticating as memhouse_root instead: the three flags did nothing, --yes satisfied
918
876
  // haveAll so no prompt asked who you were, and resolveConfig's default was used AS A
919
877
  // CREDENTIAL. Refuse rather than do something else silently.
920
- const adminOnly = ['adopt-user', 'member', 'member-password'].filter((k) => flags[k] !== undefined);
878
+ const adminOnly = ['member', 'member-password'].filter((k) => flags[k] !== undefined);
921
879
  if (!adminUser && adminOnly.length) {
922
880
  console.log(bad(`--${adminOnly.join(', --')} ${adminOnly.length > 1 ? 'are' : 'is'} only read with --admin-user`));
923
881
  console.log(' Those flags provision a member, which needs house admin. Without them this');
@@ -1030,7 +988,7 @@ async function cmdInstall({ interactive }) {
1030
988
  if (keyProblem) {
1031
989
  console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
1032
990
  console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
1033
- console.log(` rebuild them from ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl')}, then: memhouse ship --full`);
991
+ console.log(` rebuild them from ${path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl')}, then: memhouse ship --full`);
1034
992
  console.log(' no config was written — nothing here reads as installed.');
1035
993
  return 1;
1036
994
  }
@@ -1268,8 +1226,11 @@ async function cmdDoctor() {
1268
1226
  const checks = [];
1269
1227
  const add = (okFlag, label, hint) => checks.push({ ok: okFlag, label, hint });
1270
1228
 
1271
- const [major, minor] = process.versions.node.split('.').map(Number);
1272
- add(major > 20 || (major === 20 && minor >= 19), `node ${process.versions.node}`, 'need >= 20.19');
1229
+ // 24 because SQLite comes from `node:sqlite`, which is stable there. It works on 22.13+
1230
+ // but prints an ExperimentalWarning on every command, and 20 is past EOL. Below the
1231
+ // floor, the five SQLite-backed adapters are the part that actually breaks.
1232
+ const [major] = process.versions.node.split('.').map(Number);
1233
+ add(major >= 24, `node ${process.versions.node}`, 'need >= 24 — node:sqlite is stable there; the SQLite-backed adapters need it');
1273
1234
  add(fs.existsSync(ENV_FILE), `config ${ENV_FILE}`, 'run: memhouse install');
1274
1235
  // doctor is exempt from requireConfig — diagnosing an unconfigured machine is its job —
1275
1236
  // but "exempt from refusing" is not "licensed to log in somewhere". With no config it
@@ -1320,7 +1281,7 @@ async function cmdDoctor() {
1320
1281
  add(t === objects.length, t === objects.length
1321
1282
  ? `schema: 3/3 rooms in '${cfg.db}' (${objects.join(', ')})`
1322
1283
  : `schema: ${t}/${objects.length} rooms in '${cfg.db}' — missing ${absent.join(', ')}`,
1323
- `run: memhouse install, or as the owner: node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member ${rooms.member}`);
1284
+ `run: memhouse install — with ALL on the house, ship --ensure-schema builds them`);
1324
1285
  } catch (e) { add(false, 'schema check', `${e.message} — run: memhouse install`); }
1325
1286
  // Columns, not just rooms. A room with a column missing accepts every insert and
1326
1287
  // discards that field — measured, 85 rows shipped with the value thrown away while
@@ -1328,7 +1289,7 @@ async function cmdDoctor() {
1328
1289
  // counted rooms.
1329
1290
  try {
1330
1291
  const { templateColumns } = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js'));
1331
- const tpl = fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl'), 'utf-8');
1292
+ const tpl = fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl'), 'utf-8');
1332
1293
  const want = templateColumns(tpl);
1333
1294
  // Names AND types AND the MATERIALIZED kind. Comparing names alone left every other
1334
1295
  // kind of drift invisible, with measured consequences: a `UInt64` column narrowed to
@@ -1393,7 +1354,7 @@ async function cmdDoctor() {
1393
1354
  add(false, `sorting keys: only ${checked}/${ROOM_TYPES.length} rooms are MergeTree — the rest have no sorting key at all`,
1394
1355
  'a room was replaced by a Merge/View/Log engine; rebuild it from the schema template as the house owner');
1395
1356
  } else add(wrongKeys.length === 0, `sorting keys${wrongKeys.length ? `: wrong on ${wrongKeys.join(', ')}` : ` carry origin correctly (${checked}/${ROOM_TYPES.length} rooms)`}`,
1396
- `rebuild those rooms from the schema template, then: memhouse ship --full\n template: ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl')}`);
1357
+ `rebuild those rooms from the schema template, then: memhouse ship --full\n template: ${path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl')}`);
1397
1358
  } catch (e) { add(false, 'sorting keys', e.message); }
1398
1359
  try {
1399
1360
  // countIf, not any(). `any()` returns an arbitrary row's value, so on a house with
@@ -1484,11 +1445,11 @@ async function cmdDoctor() {
1484
1445
  }
1485
1446
  adapterErrors = getAdapterErrors();
1486
1447
  for (const t of thrown) {
1487
- if (!adapterErrors.some((e) => e.source === t.source)) adapterErrors.push({ ...t, missingBinding: false });
1448
+ if (!adapterErrors.some((e) => e.source === t.source)) adapterErrors.push(t);
1488
1449
  }
1489
- // Any adapter that could not be read is a failure, whatever the cause — a
1490
- // locked or corrupt store loses just as many sessions as a missing binding.
1491
- const blocked = adapterErrors.filter((e) => e.missingBinding).map((e) => e.source);
1450
+ // Any adapter that could not be read is a failure, whatever the cause — a locked or
1451
+ // corrupt store loses sessions just as surely as the missing native binding this
1452
+ // used to have a separate remedy for.
1492
1453
  const failed = adapterErrors.map((e) => e.source);
1493
1454
  add(adapterErrors.length === 0,
1494
1455
  // "visible locally" is what the ADAPTERS see; `stats` reports what the house HOLDS,
@@ -1497,9 +1458,7 @@ async function cmdDoctor() {
1497
1458
  // find the other number elsewhere (161 here, 156 there, on a real machine) gives
1498
1459
  // them no way to tell "correctly skipped" from "silently dropped".
1499
1460
  `adapters: ${seen} sessions visible locally${failed.length ? ` (${failed.length} skipped: ${failed.join(', ')})` : ''} — compare with what the house holds: memhouse stats`,
1500
- blocked.length ? installCommand()
1501
- : adapterErrors.length ? adapterErrors.map((e) => `${e.source}: ${e.message}`).join('; ')
1502
- : undefined);
1461
+ adapterErrors.length ? adapterErrors.map((e) => `${e.source}: ${e.message}`).join('; ') : undefined);
1503
1462
  } catch (e) { add(false, 'adapters', e.message); }
1504
1463
  // Which models in this house have no price. An unpriced model is not an error — a new
1505
1464
  // release always lands before pricing.json catches up — but it IS silent: calculateCost
@@ -1696,12 +1655,12 @@ async function cmdUpdate() {
1696
1655
 
1697
1656
  if (kind === 'npx') {
1698
1657
  console.log(warn('nothing to update — npx resolves the registry on every run'));
1699
- console.log(' to keep a version around: npm install -g memhouse --allow-scripts=better-sqlite3');
1658
+ console.log(' to keep a version around: npm install -g memhouse');
1700
1659
  return 1;
1701
1660
  }
1702
1661
  if (kind === 'local-dep') {
1703
1662
  console.log(warn('this is a project dependency, not a global install'));
1704
- console.log(` upgrade it where it lives: npm install memhouse@latest --allow-scripts=better-sqlite3`);
1663
+ console.log(` upgrade it where it lives: npm install memhouse@latest`);
1705
1664
  return 1;
1706
1665
  }
1707
1666
 
@@ -1729,10 +1688,11 @@ async function cmdUpdate() {
1729
1688
  }
1730
1689
  }
1731
1690
  } else {
1732
- // --allow-scripts is not optional and not remembered: npm >= 12 blocks install scripts
1733
- // by default, so an upgrade without it leaves better-sqlite3 with no binding and the
1734
- // five SQLite-backed adapters read zero sessions on every pass afterwards.
1735
- const r = spawnSync('npm', ['install', '-g', 'memhouse@latest', '--allow-scripts=better-sqlite3'], { stdio: 'inherit' });
1691
+ // Plain, with no flag to forget. This carried `--allow-scripts=better-sqlite3` for as
1692
+ // long as SQLite was a native module: npm never remembered it, and an upgrade without
1693
+ // it left the binding unbuilt and the five SQLite-backed adapters reading zero
1694
+ // sessions on every pass afterwards. There is no binding to build now.
1695
+ const r = spawnSync('npm', ['install', '-g', 'memhouse@latest'], { stdio: 'inherit' });
1736
1696
  if (r.status !== 0) {
1737
1697
  console.log(bad('npm install failed — nothing was restarted, the running version is unchanged'));
1738
1698
  console.log(' if it was EACCES: ls -ld "$(npm prefix -g)" — root-owned needs sudo, yours needs a chown');
@@ -1931,8 +1891,8 @@ async function cmdReset() {
1931
1891
  } catch { /* pre-origin house: nothing to protect, ensureSchema will add the column */ }
1932
1892
 
1933
1893
  if (flags.yes !== true) {
1934
- // Name the rooms. Under the per-member layout this only ever empties the caller's own,
1935
- // and "ALL rows in 'mem'" would misdescribe that in the alarming direction.
1894
+ // Name the rooms, and the scope honestly: the tables are shared, but reset only ever
1895
+ // deletes rows whose user_id is the CALLER's — a housemate's rows are untouched.
1936
1896
  const scope = allOrigins
1937
1897
  ? `EVERY row, including ${imported} imported one(s) that re-shipping CANNOT restore,`
1938
1898
  : 'the rows the shipper wrote';
@@ -2271,10 +2231,43 @@ async function cmdUninstall() {
2271
2231
  : process.env.MEMHOUSE_PASSWORD ? 'MEMHOUSE_PASSWORD' : null;
2272
2232
  if (initialised && rotateAsk) {
2273
2233
  console.log(bad(`${rotateAsk} cannot change the credential of an existing house — the image only applies it when it initialises the data directory.`));
2274
- console.log(' to rotate: ALTER USER memhouse_root IDENTIFIED BY \'…\' inside the house, then: memhouse setup --password …');
2234
+ console.log(` to rotate: ALTER USER ${priorCfg.user || 'memhouse_root'} IDENTIFIED BY '…' inside the house, then: memhouse setup --password …`);
2275
2235
  console.log(' to start over (DESTROYS the memory): memhouse deploy --down');
2276
2236
  process.exitCode = 2; break;
2277
2237
  }
2238
+ // Who owns this house. A local deploy is the pilot's own machine and their own
2239
+ // memory, so the house user is THEM — suggested from the OS username (`polat`, not
2240
+ // `memhouse_root`) — created by the container itself at first init with full
2241
+ // rights. No admin/member split, no provisioning dance: a URI and a credential is
2242
+ // the whole install. The name is fixed at volume init exactly like the password,
2243
+ // so an existing house keeps the user it was born with.
2244
+ const osName = (os.userInfo().username || '').replace(/[^A-Za-z0-9_]/g, '_').replace(/^[0-9_]+/, '');
2245
+ let houseUser;
2246
+ if (initialised) {
2247
+ // The env FILE, not resolveConfig(): flags outrank the file there, so comparing
2248
+ // `--user other` against priorCfg.user compares the flag against itself and the
2249
+ // refusal below can never fire — while the volume stays initialised with the
2250
+ // original name and every later connection fails auth.
2251
+ const storedUser = readEnvFile().MEMHOUSE_USER;
2252
+ // No stored user means the env file is gone or emptied — the stored PASSWORD is
2253
+ // gone with it, so the `initialised && !reusable` refusal below already owns this
2254
+ // case. Refusing `--user` here on a guessed 'memhouse_root' would tell the pilot
2255
+ // their house "was initialised with memhouse_root" on no evidence at all.
2256
+ if (storedUser && flags.user && flags.user !== true && flags.user !== storedUser) {
2257
+ console.log(bad(`--user cannot rename the user of an existing house — the image creates it only when it initialises the data directory.`));
2258
+ console.log(` this house was initialised with '${storedUser}'. To start over (DESTROYS the memory): memhouse deploy --down`);
2259
+ process.exitCode = 2; break;
2260
+ }
2261
+ houseUser = storedUser || 'memhouse_root';
2262
+ } else if (flags.user && flags.user !== true) {
2263
+ houseUser = String(flags.user);
2264
+ } else if (flags.yes !== true && process.stdin.isTTY) {
2265
+ houseUser = (await ask(`House user (this becomes your member name)`, osName || 'memhouse_root')).trim() || osName || 'memhouse_root';
2266
+ } else {
2267
+ houseUser = osName || 'memhouse_root';
2268
+ }
2269
+ try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(houseUser, 'house user'); }
2270
+ catch (e) { console.log(bad(`'${houseUser}' cannot be the house user — ${e.message}`)); process.exitCode = 2; break; }
2278
2271
  // An initialised volume with no credential to reuse — env file deleted, emptied, or
2279
2272
  // never written — is the same lockout by another route: a generated password would
2280
2273
  // be ignored by the server and then written over the config as if it worked.
@@ -2301,7 +2294,17 @@ async function cmdUninstall() {
2301
2294
  const tag = flags.tag || process.env.MEMHOUSE_CH_TAG || dep.managedTag() || dep.DEFAULT_TAG;
2302
2295
  // The database is part of the destination, not a detail of it — see the service
2303
2296
  // check below, which must compare it.
2304
- const targetDb = flags.db || process.env.MEMHOUSE_DB || priorCfg.db || 'mem';
2297
+ // STATED sources only, then the house user. priorCfg.db is unusable here: resolveConfig
2298
+ // computes a DEFAULT for it (the user's name, itself defaulted), so going through it
2299
+ // handed a fresh deploy `memhouse_root` as the house name while the superuser was
2300
+ // 'polat' — the memory would live in a database named after a role account nobody
2301
+ // chose.
2302
+ const targetDb = flags.db || process.env.MEMHOUSE_DB || readEnvFile().MEMHOUSE_DB || houseUser;
2303
+ // Same check install runs, but EARLY — deploy initialises the container volume and
2304
+ // writes the env file before its install step, so a refusal that waits for install
2305
+ // arrives after the damage.
2306
+ try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(targetDb, 'house'); }
2307
+ catch (e) { console.log(bad(e.message)); process.exitCode = 2; break; }
2305
2308
  {
2306
2309
  // A service-managed shipper keeps the environment it was installed with, so ANY
2307
2310
  // switch that repoints the config leaves it shipping somewhere else — not only an
@@ -2355,7 +2358,7 @@ async function cmdUninstall() {
2355
2358
  }
2356
2359
  const pw = reusable || crypto.randomBytes(16).toString('hex');
2357
2360
  if (reusable) console.log(ok('reusing the existing house credential (its data volume is already initialised)'));
2358
- const r = dep.up({ password: pw, port, tag });
2361
+ const r = dep.up({ password: pw, port, tag, user: houseUser });
2359
2362
  if (!r.ok) { console.log(bad(r.msg)); process.exitCode = 1; break; }
2360
2363
  console.log(ok(`ClickHouse starting via ${r.engine} on ${r.url} (loopback only)`));
2361
2364
 
@@ -2367,7 +2370,10 @@ async function cmdUninstall() {
2367
2370
  // house that eventually came up is unreachable forever. The image applies the
2368
2371
  // password only at first init, so there is no way back from that.
2369
2372
  if (!reusable) {
2370
- writeEnvFile({ ...priorCfg, url: r.url, user: 'memhouse_root', password: pw });
2373
+ // db INCLUDED: this file is read back by the install below, and writing
2374
+ // priorCfg's computed default here made that install refuse over a mismatch
2375
+ // with a file this same command had just written.
2376
+ writeEnvFile({ ...priorCfg, url: r.url, user: houseUser, password: pw, db: targetDb });
2371
2377
  console.log(ok(`credential saved to ${ENV_FILE} before waiting — the volume is initialised with it`));
2372
2378
  }
2373
2379
 
@@ -2417,10 +2423,10 @@ async function cmdUninstall() {
2417
2423
  // anticipates it) and it is NOT the narrow set INSTALL.md's grant-set argument
2418
2424
  // describes. A user who later adds a second member should know which house they
2419
2425
  // have.
2420
- console.log(warn(`this house is yours alone: '${'memhouse_root'}' is its superuser, and that is the credential being saved`));
2421
- console.log(' Adding a second person later? Give them their own member instead:');
2422
- console.log(` node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member <name> --merge`);
2423
- flags.url = r.url; flags.user = 'memhouse_root'; flags.password = pw;
2426
+ console.log(warn(`this house is yours alone: '${houseUser}' is its superuser, and that is the credential being saved`));
2427
+ console.log(' Adding a housemate later is two statements:');
2428
+ console.log(` CREATE USER <name> IDENTIFIED BY '…'; GRANT ALL ON ${targetDb}.* TO <name>;`);
2429
+ flags.url = r.url; flags.user = houseUser; flags.password = pw;
2424
2430
  flags.db = targetDb;
2425
2431
  flags.yes = true;
2426
2432
  process.exitCode = await cmdInstall({ interactive: false });
@@ -2,16 +2,7 @@ const { execSync, execFileSync } = require('child_process');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const os = require('os');
5
- // Required lazily. A top-level require makes this native module a load-time dependency
6
- // of the whole adapter set: editors/index.js imports every adapter, so one missing
7
- // binding threw before any adapter ran and `discover` reported ZERO editors — losing the
8
- // eleven that need no native code at all. The adapterErrors path exists to degrade one
9
- // adapter at a time, and a top-level throw walks straight past it.
10
- let Database = null;
11
- function openDb(...a) {
12
- if (!Database) Database = require('better-sqlite3');
13
- return new Database(...a);
14
- }
5
+ const { openReadOnly, textOf } = require('./sqlite');
15
6
  const { getAppDataPath } = require('./base');
16
7
  const adapterErrors = require('./adapter-errors');
17
8
 
@@ -244,13 +235,14 @@ function readGlobalStateValue(key) {
244
235
 
245
236
  let db = null;
246
237
  try {
247
- db = openDb(ANTIGRAVITY_GLOBAL_STORAGE_DB, { readonly: true, fileMustExist: true });
238
+ // fileMustExist is gone with better-sqlite3, not lost: node:sqlite refuses to
239
+ // create a database it is opening read-only, and the existsSync above still stands.
240
+ db = openReadOnly(ANTIGRAVITY_GLOBAL_STORAGE_DB);
248
241
  const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(key);
249
242
  if (!row) return null;
250
- const v = row.value;
251
- if (typeof v === 'string') return v;
252
- if (Buffer.isBuffer(v) || v instanceof Uint8Array) return Buffer.from(v).toString('utf-8');
253
- return v == null ? null : String(v);
243
+ // ItemTable.value is declared BLOB and usually holds TEXT — textOf covers both,
244
+ // including the Uint8Array node:sqlite returns for the byte case.
245
+ return row.value == null ? null : textOf(row.value);
254
246
  } catch (e) {
255
247
  // Only the offline half of getChats() reads this store; live cascades come over
256
248
  // RPC. So a failure here costs some sessions, not all of them.