memhouse 0.17.0 → 0.18.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 (40) hide show
  1. package/README.md +96 -30
  2. package/bin/memhouse.js +418 -196
  3. package/editors/antigravity.js +2 -0
  4. package/editors/claude.js +3 -1
  5. package/editors/codebuff.js +2 -0
  6. package/editors/codex.js +4 -1
  7. package/editors/commandcode.js +2 -1
  8. package/editors/copilot-jetbrains.js +2 -1
  9. package/editors/copilot.js +2 -1
  10. package/editors/cursor-agent.js +2 -1
  11. package/editors/cursor.js +2 -0
  12. package/editors/gemini.js +2 -1
  13. package/editors/goose.js +2 -1
  14. package/editors/index.js +8 -1
  15. package/editors/kiro.js +3 -2
  16. package/editors/opencode.js +2 -1
  17. package/editors/scope.js +126 -0
  18. package/editors/vscode.js +2 -0
  19. package/editors/zed.js +2 -1
  20. package/memhouse/DESIGN.md +31 -8
  21. package/memhouse/channel.js +50 -0
  22. package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
  23. package/memhouse/delivery/AGENT-INSTALL.md +1 -1
  24. package/memhouse/delivery/INVITATION.md.tpl +130 -0
  25. package/memhouse/delivery/PROMPT.md +6 -5
  26. package/memhouse/delivery/kernel-install.md +4 -2
  27. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
  28. package/memhouse/delivery/plugin/reference/HOUSE.md +31 -12
  29. package/memhouse/delivery/plugin/skills/access/SKILL.md +8 -8
  30. package/memhouse/delivery/plugin/skills/admin/SKILL.md +1 -1
  31. package/memhouse/delivery/plugin/skills/house/SKILL.md +2 -2
  32. package/memhouse/flags.js +5 -4
  33. package/memhouse/house/HOUSE.md +25 -11
  34. package/memhouse/house/house.js +110 -27
  35. package/memhouse/house/migrate.js +7 -6
  36. package/memhouse/house/relocate.js +2 -2
  37. package/memhouse/house/schema.sql.tpl +3 -3
  38. package/memhouse/provision.js +100 -0
  39. package/memhouse/shipper/ship.js +87 -28
  40. package/package.json +1 -1
@@ -0,0 +1,100 @@
1
+ // Provisioning a member — THE plan, whether memhouse executes it or a DBA does.
2
+ //
3
+ // There used to be three copies of this: the live path (adminBootstrap), the printed one
4
+ // (--print-sql), and install --admin-user. They drifted, and the printed copy handed a
5
+ // non-admin the one configuration the live path refuses — found in a drill, not by a
6
+ // test. A plan that is data cannot drift from itself: the live path executes it,
7
+ // --print-sql prints it, and the unit tests assert on it.
8
+ //
9
+ // The plan is a member and a database, and its centre is one statement:
10
+ //
11
+ // GRANT <room privileges> ON <db>.<member>_* TO <member> WITH GRANT OPTION
12
+ //
13
+ // A wildcard on the member's own name. It covers rooms that do not exist yet, so the
14
+ // member's shipper creates their rooms itself, a schema rebuild can CREATE and EXCHANGE
15
+ // inside the pattern, and a share can name the whole set or one table. It reaches nothing
16
+ // outside the pattern — measured on 26.7: a member cannot create, read, list, drop or
17
+ // re-grant a housemate's rooms — and a colleague can read it back with SHOW GRANTS in one
18
+ // line, which is the property the whole product rests on.
19
+ //
20
+ // Nobody is ever granted ALL ON <db>.*. That grant is dynamic (it covers rooms created
21
+ // later), and it is what turned two members in one database into a leak.
22
+
23
+ const { MEMBER_PIN, assertUsableName, roomPattern } = require('./house/house');
24
+
25
+ /**
26
+ * What a member may do inside their own pattern. Each entry earns its place:
27
+ * SELECT, INSERT the shipper writes, everything else reads
28
+ * ALTER, OPTIMIZE ensureSchema adds columns in place; doctor compacts
29
+ * CREATE TABLE, DROP TABLE their own rooms on first ship; a rebuild on migrate
30
+ * ROW POLICY * `share --only` scopes a grantee to a project or a session
31
+ */
32
+ const ROOM_PRIVILEGES = [
33
+ 'SELECT', 'INSERT', 'ALTER', 'OPTIMIZE',
34
+ 'CREATE TABLE', 'DROP TABLE',
35
+ 'CREATE ROW POLICY', 'ALTER ROW POLICY', 'DROP ROW POLICY', 'SHOW ROW POLICIES',
36
+ ];
37
+
38
+ function roomGrant(db, member) {
39
+ return `GRANT ${ROOM_PRIVILEGES.join(', ')} ON ${db}.${roomPattern(member)} TO ${member} WITH GRANT OPTION`;
40
+ }
41
+
42
+ function sqlString(v) {
43
+ return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
44
+ }
45
+
46
+ /**
47
+ * The statements that make `member` a member of house `db`, in order.
48
+ *
49
+ * `optional` marks steps that need ACCESS MANAGEMENT the admin may not hold. The live
50
+ * path skips those on refusal and says what the member loses; a refused required step
51
+ * aborts. `password` may be null for a member that already exists (an invite re-run, an
52
+ * install against an account the DBA made) — then the CREATE USER step is omitted and the
53
+ * rest still applies.
54
+ *
55
+ * @returns {Array<{sql:string, why:string, optional:boolean}>}
56
+ */
57
+ function plan({ db, member, password = null }) {
58
+ assertUsableName(db, 'house');
59
+ assertUsableName(member, 'member');
60
+ const steps = [
61
+ { sql: `CREATE DATABASE IF NOT EXISTS ${db}`, why: 'the house', optional: false },
62
+ ];
63
+ if (password !== null) {
64
+ steps.push({ sql: `CREATE USER IF NOT EXISTS ${member} IDENTIFIED BY ${sqlString(password)}`, why: 'the member', optional: false });
65
+ }
66
+ steps.push(
67
+ { sql: roomGrant(db, member), why: `their rooms — ${db}.${roomPattern(member)} — and nothing else in the house`, optional: false },
68
+ { sql: `ALTER USER ${member} ADD SETTING ${MEMBER_PIN}`, why: 'user_id is stamped on every row; an async insert would skip the stamp', optional: false },
69
+ { sql: `GRANT ALTER USER ON ${member} TO ${member}`, why: '`memhouse passwd` without an admin (their own account only)', optional: true },
70
+ { sql: `GRANT SHOW USERS ON *.* TO ${member}`, why: 'see who to share with — names only, never data', optional: true },
71
+ { sql: `GRANT REMOTE ON *.* TO ${member}`, why: '`memhouse relocate` pulls the old house over remoteSecure()', optional: true },
72
+ );
73
+ return steps;
74
+ }
75
+
76
+ /**
77
+ * The plan as SQL a person runs — clickhouse-client, the play UI, curl. One statement per
78
+ * line so it can be split on ';' by anything, comments above each saying why.
79
+ */
80
+ function render(steps, { db, member }) {
81
+ const out = [
82
+ `-- memhouse: make '${member}' a member of house '${db}'.`,
83
+ '-- Run as a user with ACCESS MANAGEMENT (a stock \'default\' with access_management=1 will do).',
84
+ '--',
85
+ `-- One grant, on ${db}.${roomPattern(member)}: their rooms and nothing else in the database.`,
86
+ '-- Housemates keep their own rooms beside these and cannot read them. Do NOT widen it to',
87
+ `-- ${db}.* — that reaches every housemate's rooms, present and future.`,
88
+ '--',
89
+ `-- Their rooms are created by their own first \`memhouse ship\`; nothing to pre-build.`,
90
+ '',
91
+ ];
92
+ for (const s of steps) {
93
+ out.push(`-- ${s.why}${s.optional ? ' (optional: needs ACCESS MANAGEMENT; skip if refused)' : ''}`);
94
+ out.push(`${s.sql};`);
95
+ out.push('');
96
+ }
97
+ return out.join('\n');
98
+ }
99
+
100
+ module.exports = { ROOM_PRIVILEGES, roomGrant, plan, render };
@@ -61,12 +61,11 @@ const selfUpdate = require('../self-update');
61
61
  // gets. Taking it later would record whatever an upgrade had already replaced.
62
62
  const selfSnap = selfUpdate.snapshot(__filename);
63
63
  const {
64
- resolveRooms, READ_SETTINGS, ROOM_TYPES, META_TYPES, SCHEMA_VERSION, MEMBER_PIN,
65
- SUPPORTED_SCHEMAS, keyProblem,
66
- } = require('../house/house');
64
+ resolveRooms, READ_SETTINGS, ROOM_TYPES, META_TYPES, SCHEMA_VERSION, MEMBER_PIN, createStatement,
65
+ SUPPORTED_SCHEMAS, keyProblem, legacyTextIndexDialect, isTextIndexGrammarRefusal, textIndexDialectFor } = require('../house/house');
67
66
  // Rooms plus the house's own record of itself. Every table the template declares, which
68
67
  // is what the column healer and the drift warning have to cover — a column added to
69
- // house_events would otherwise roll out to nobody.
68
+ // events would otherwise roll out to nobody.
70
69
  const ALL_TABLES = [...ROOM_TYPES, ...META_TYPES];
71
70
 
72
71
  const BATCH_ROWS = 2000; // insert batch ceiling by ROW COUNT
@@ -410,17 +409,25 @@ async function assertRoomKeys(client, rooms) {
410
409
  }
411
410
  }
412
411
 
412
+ // The shipper reads the prefix straight from its environment: it is spawned with the
413
+ // resolved MEMHOUSE_* env by the CLI, and every room name it touches comes from
414
+ // resolveRooms, so this is the only line that needs to know.
415
+
413
416
  async function ensureSchema(client) {
414
417
  const rooms = await resolveRooms(client);
415
418
  // BEFORE the CREATEs and ALTERs, not beside the end-of-function asserts. A house whose
416
419
  // record says a newer release moved it forward may have columns this template does not
417
420
  // know; running this template's DDL first could add back what that release removed —
418
421
  // the exact write the guard exists to prevent.
422
+ await assertNotLegacyLayout(client, rooms);
419
423
  await assertWriterSupported(client, rooms);
420
424
  const tpl = fs.readFileSync(path.join(__dirname, '..', 'house', 'schema.sql.tpl'), 'utf-8');
421
- const sql = tpl; // plain shared tables — nothing to render
422
- const stripped = sql.split('\n').map((l) => l.replace(/--.*$/, '')).join('\n');
423
- const stmts = stripped.split(';').map((s) => s.trim()).filter(Boolean);
425
+ // Rendered per room rather than taken as one blob, because a prefixed house names its
426
+ // rooms `<prefix>_messages` and the template says `messages`. With no prefix the two
427
+ // are identical, so this is the same statements in the same order as before.
428
+ const stmts = [...ROOM_TYPES, ...META_TYPES]
429
+ .map((t) => createStatement(tpl, t, rooms.physical ? rooms.physical[t] : t)
430
+ .replace('CREATE TABLE ', 'CREATE TABLE IF NOT EXISTS '));
424
431
  // Two failure shapes are survivable here and both are skipped rather than fatal:
425
432
  //
426
433
  // - a permission denial. A read-only credential can still run --ensure-schema for
@@ -431,16 +438,24 @@ async function ensureSchema(client) {
431
438
  // the loser can get TABLE_ALREADY_EXISTS or a metadata-file collision. The table
432
439
  // exists either way, which is the outcome this function wants.
433
440
  let denied = 0;
434
- for (const q of stmts) {
441
+ // The text-index grammar changed at 25.10 and neither side parses the other (measured:
442
+ // 25.8/25.9 take the quoted form, 25.10+ the function form). Pick by the server's own
443
+ // version, then keep one fallback: if the chosen form is refused on grammar alone —
444
+ // never on privilege — send the other. A wrong guess costs a round-trip, not the install.
445
+ const version = await serverVersion(client);
446
+ const dialect = textIndexDialectFor(version);
447
+ if (dialect === 'legacy') console.error(`[memhouse] ClickHouse ${version}: text indexes in the pre-25.10 grammar`);
448
+ const create = (q) => client.command({ query: q, clickhouse_settings: { async_insert: 0, allow_experimental_full_text_index: 1 } });
449
+ for (const modern of stmts) {
450
+ const legacy = legacyTextIndexDialect(modern);
451
+ const [first, second] = dialect === 'legacy' ? [legacy, modern] : [modern, legacy];
435
452
  try {
436
- await client.command({
437
- query: q,
438
- // allow_experimental_full_text_index: on 25.x the messages text indexes
439
- // are gated behind this flag (SUPPORT_IS_DISABLED without it); 26.x+
440
- // accepts it as a no-op. Query-scoped, so no server config or admin
441
- // rights are needed. Verified on 25.11 and 26.7.
442
- clickhouse_settings: { async_insert: 0, allow_experimental_full_text_index: 1 },
443
- });
453
+ try { await create(first); }
454
+ catch (e) {
455
+ const m = e && e.message ? e.message : String(e);
456
+ if (!(first !== second && isTextIndexGrammarRefusal(m))) throw e;
457
+ await create(second);
458
+ }
444
459
  } catch (e) {
445
460
  const m = e && e.message ? e.message : String(e);
446
461
  if (/Not enough privileges|ACCESS_DENIED/i.test(m)) { denied++; continue; }
@@ -449,8 +464,16 @@ async function ensureSchema(client) {
449
464
  }
450
465
  }
451
466
  if (denied) {
452
- console.error(`[memhouse] ${denied} schema statement(s) needed rights you do not hold — continuing with what you can do.`);
453
- console.error('[memhouse] creating or replacing a ROOM is the house owner\'s job; adding a missing COLUMN is not.');
467
+ // In a SHARED house this is the designed state, not a shortfall: the operator creates
468
+ // each member's rooms during `invite` precisely so the member cannot — a member who
469
+ // could CREATE TABLE here could add tables beside every housemate's. Saying "you do
470
+ // not hold these rights" and then "schema ensured" about the same statements reads as
471
+ // a contradiction on every routine ship, which is how often a member sees it.
472
+ // A member holds CREATE TABLE on their own name pattern, so this is no longer the
473
+ // ordinary path — it is a credential provisioned outside memhouse with less than the
474
+ // plan gives. Say what to ask for rather than whose job it is.
475
+ console.error(`[memhouse] ${denied} schema statement(s) needed rights this credential does not hold — continuing with what it can do.`);
476
+ console.error('[memhouse] the memhouse grant is one line — `memhouse install --print-sql` shows it; ask whoever administers the house to run it.');
454
477
  }
455
478
  // A house created before origin existed has no such column, and every read and write
456
479
  // scopes by it. Add it in place; ReplacingMergeTree backfills the DEFAULT, so every
@@ -503,7 +526,7 @@ async function ensureSchema(client) {
503
526
  // There used to be a second, hardcoded `ADD COLUMN … origin` here, from when the healer
504
527
  // above could only add that one column. It is redundant now that the generic loop reads
505
528
  // the template correctly — and it would have been actively wrong once this loop covered
506
- // house_meta and house_events, which have no origin and want none.
529
+ // meta and events, which have no origin and want none.
507
530
 
508
531
  await assertRoomsExist(client, rooms);
509
532
  await assertRoomKeys(client, rooms);
@@ -534,7 +557,7 @@ async function ensureSchema(client) {
534
557
  * Keep the house's record of itself current: which schema generation it is at, and which
535
558
  * memhouse version is writing into it.
536
559
  *
537
- * Written only when something CHANGED. A row per pass would turn house_events into a
560
+ * Written only when something CHANGED. A row per pass would turn events into a
538
561
  * heartbeat log — under `--loop` at the default interval that is 288 rows a day per
539
562
  * machine, and the one question the table exists to answer ("when did this house move,
540
563
  * and who moved it") would be buried in noise.
@@ -548,7 +571,7 @@ async function recordHouseState(client, rooms, host) {
548
571
  const writer = `${rooms.user}@${host}`;
549
572
  try {
550
573
  const rs = await client.query({
551
- query: `SELECT key, value FROM ${rooms.house_meta} FINAL WHERE key IN ('schema_version', {ck:String}, {cs:String})`,
574
+ query: `SELECT key, value FROM ${rooms.meta} FINAL WHERE key IN ('schema_version', {ck:String}, {cs:String})`,
552
575
  // Keyed per member AND host. Keyed by member alone, two machines of one member
553
576
  // overwrote each other's entry on every pass, so the fleet view could only ever
554
577
  // show the machine that shipped last — the exact machine that needs no attention.
@@ -588,15 +611,15 @@ async function recordHouseState(client, rooms, host) {
588
611
  }
589
612
  // The heartbeat, EVERY pass — the fleet view's health column. Latest-wins on the key,
590
613
  // so it is one live row per writer however often it fires; version/schema above stay
591
- // change-only so house_events remains a record of moves, not a pulse trace.
614
+ // change-only so events remains a record of moves, not a pulse trace.
592
615
  metas.push({ key: `last_ship:${writer}`, value: new Date().toISOString(), host });
593
616
  await client.insert({
594
- table: rooms.house_meta_raw, values: metas, format: 'JSONEachRow',
617
+ table: rooms.meta_raw, values: metas, format: 'JSONEachRow',
595
618
  clickhouse_settings: { async_insert: 0 },
596
619
  });
597
620
  if (events.length) {
598
621
  await client.insert({
599
- table: rooms.house_events_raw, values: events, format: 'JSONEachRow',
622
+ table: rooms.events_raw, values: events, format: 'JSONEachRow',
600
623
  clickhouse_settings: { async_insert: 0 },
601
624
  });
602
625
  }
@@ -609,8 +632,8 @@ async function recordHouseState(client, rooms, host) {
609
632
  *
610
633
  * Distinct from assertRoomKeys, which inspects key SHAPES — a future generation could be
611
634
  * a data transform the keys do not show. This reads what the house says about itself:
612
- * house_meta['schema_version'] (what generation the rooms are at) and
613
- * house_meta['min_writer_schema'] (the floor `memhouse migrate` sets under writers).
635
+ * meta['schema_version'] (what generation the rooms are at) and
636
+ * meta['min_writer_schema'] (the floor `memhouse migrate` sets under writers).
614
637
  * Too new -> the fix is on THIS machine: memhouse update. Below the floor -> same.
615
638
  * (A house OLDER than this release is not an error here — the room checks catch it and
616
639
  * name `memhouse migrate`; this guard must not fire on a pre-0.10 house that has no
@@ -619,17 +642,52 @@ async function recordHouseState(client, rooms, host) {
619
642
  * Releases before 0.10.0 never read this — for them the floor is enforced by the
620
643
  * pilot's GRANTs, not by code.
621
644
  */
645
+ /**
646
+ * A house from before the one-layout holds plain rooms — `messages`, not `<member>_messages`.
647
+ * Shipping into it would quietly create a second, empty set of rooms beside the full ones
648
+ * and write there: every past session invisible to the member's own reads, the dashboard
649
+ * and the skills, with nothing anywhere saying why. Refuse, and say exactly what moves the
650
+ * old rooms across — a RENAME, instant, nothing copied.
651
+ *
652
+ * Trips only when the member's rooms are ABSENT and the plain ones PRESENT; a house holding
653
+ * both is mid-conversion and is left alone.
654
+ */
655
+ async function serverVersion(client) {
656
+ try {
657
+ const rs = await client.query({ query: 'SELECT version() AS v', format: 'JSONEachRow' });
658
+ return String(((await rs.json())[0] || {}).v || '');
659
+ } catch { return ''; }
660
+ }
661
+
662
+ async function assertNotLegacyLayout(client, rooms) {
663
+ const rs = await client.query({
664
+ query: `SELECT name FROM system.tables WHERE database = currentDatabase() AND name IN ('messages', '${rooms.physical.messages}')`,
665
+ format: 'JSONEachRow',
666
+ });
667
+ const names = new Set((await rs.json()).map((r) => r.name));
668
+ if (names.has(rooms.physical.messages) || !names.has('messages')) return;
669
+ // The plain rooms of a pre-one-layout house: the transcript rooms by type name, and the
670
+ // two bookkeeping rooms under the names they had then.
671
+ const legacyName = { meta: 'house_meta', events: 'house_events' };
672
+ const moves = [...ROOM_TYPES, ...META_TYPES].map((t) => `RENAME TABLE ${legacyName[t] || t} TO ${rooms.physical[t]};`).join('\n ');
673
+ throw new Error(
674
+ 'this house holds plain rooms (messages, sessions, tool_calls) — the layout before rooms were named for their member.\n'
675
+ + ` Shipping now would create empty ${rooms.pattern} rooms beside them and write there, hiding every past session.\n`
676
+ + ` Move the old rooms across (a rename — instant, nothing copied), then ship again:\n ${moves}\n`
677
+ + ` Then replace the database-wide grant with the one-layout grant: memhouse install --print-sql --member ${rooms.member}`);
678
+ }
679
+
622
680
  async function assertWriterSupported(client, rooms) {
623
681
  let have;
624
682
  try {
625
683
  const rs = await client.query({
626
- query: `SELECT key, value FROM ${rooms.house_meta} FINAL WHERE key IN ('schema_version', 'min_writer_schema')`,
684
+ query: `SELECT key, value FROM ${rooms.meta} FINAL WHERE key IN ('schema_version', 'min_writer_schema')`,
627
685
  format: 'JSONEachRow',
628
686
  });
629
687
  have = new Map((await rs.json()).map((r) => [r.key, toInt(r.value)]));
630
688
  } catch (e) {
631
689
  // ONLY a missing table means a pre-0.10 house (the room checks own that case). Any
632
- // other failure — an ACCESS_DENIED on house_meta, a timeout — used to fall through
690
+ // other failure — an ACCESS_DENIED on meta, a timeout — used to fall through
633
691
  // here too, and a writer that merely could not READ the record was treated as if the
634
692
  // record did not exist: on a newer-schema house whose key shapes happen to match,
635
693
  // that bypassed the whole compatibility guard. Not being able to check is a reason
@@ -1004,6 +1062,7 @@ async function runShip(client, opts = {}) {
1004
1062
  // shape: writing at the wrong epoch either forks a session that did not change, or
1005
1063
  // overwrites a stored parse that did.
1006
1064
  const rooms = await resolveRooms(client);
1065
+ await assertNotLegacyLayout(client, rooms);
1007
1066
  await assertWriterSupported(client, rooms);
1008
1067
  await assertRoomsExist(client, rooms);
1009
1068
  await assertRoomKeys(client, rooms);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memhouse",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
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": {