memhouse 0.17.0 → 0.18.1

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 (42) hide show
  1. package/README.md +109 -30
  2. package/bin/memhouse.js +555 -202
  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/binding.js +81 -0
  22. package/memhouse/channel.js +50 -0
  23. package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
  24. package/memhouse/delivery/AGENT-INSTALL.md +1 -1
  25. package/memhouse/delivery/INVITATION.md.tpl +147 -0
  26. package/memhouse/delivery/PROMPT.md +6 -5
  27. package/memhouse/delivery/kernel-install.md +4 -2
  28. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
  29. package/memhouse/delivery/plugin/reference/HOUSE.md +38 -12
  30. package/memhouse/delivery/plugin/skills/access/SKILL.md +9 -9
  31. package/memhouse/delivery/plugin/skills/admin/SKILL.md +1 -1
  32. package/memhouse/delivery/plugin/skills/house/SKILL.md +18 -2
  33. package/memhouse/flags.js +5 -4
  34. package/memhouse/house/HOUSE.md +25 -11
  35. package/memhouse/house/house.js +110 -27
  36. package/memhouse/house/migrate.js +7 -6
  37. package/memhouse/house/relocate.js +2 -2
  38. package/memhouse/house/schema.sql.tpl +3 -3
  39. package/memhouse/invitefile.js +38 -0
  40. package/memhouse/provision.js +100 -0
  41. package/memhouse/shipper/ship.js +87 -28
  42. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  const { execSync, execFileSync } = require('child_process');
2
2
  const fs = require('fs');
3
+ const scope = require('./scope');
3
4
  const path = require('path');
4
5
  const os = require('os');
5
6
  const { openReadOnly, textOf } = require('./sqlite');
@@ -10,6 +11,7 @@ const HOME = os.homedir();
10
11
  const ANTIGRAVITY_USER_DIR = path.join(getAppDataPath('Antigravity'), 'User');
11
12
  const ANTIGRAVITY_GLOBAL_STORAGE_DB = path.join(ANTIGRAVITY_USER_DIR, 'globalStorage', 'state.vscdb');
12
13
  const ANTIGRAVITY_BRAIN_DIR = path.join(HOME, '.gemini', 'antigravity', 'brain');
14
+ scope.fixed('antigravity', [ANTIGRAVITY_USER_DIR, ANTIGRAVITY_BRAIN_DIR]);
13
15
  const OFFLINE_TRAJECTORY_SUMMARIES_KEYS = [
14
16
  'antigravityUnifiedStateSync.trajectorySummaries',
15
17
  'unifiedStateSync.trajectorySummaries',
package/editors/claude.js CHANGED
@@ -36,7 +36,9 @@ function discoverClaudeRoots() {
36
36
  seen.add(rp);
37
37
  roots.push(d);
38
38
  }
39
- return roots;
39
+ // MEMHOUSE_CLAUDE_ROOTS replaces discovery with an explicit list — one Claude Code
40
+ // instance out of several on a machine. Each path is checked the same way as above.
41
+ return require('./scope').roots('claude', roots, process.env.MEMHOUSE_CLAUDE_ROOTS);
40
42
  }
41
43
 
42
44
  // ============================================================
@@ -1,4 +1,5 @@
1
1
  const path = require('path');
2
+ const scope = require('./scope');
2
3
  const fs = require('fs');
3
4
  const os = require('os');
4
5
 
@@ -29,6 +30,7 @@ function getProjectRoots() {
29
30
  }
30
31
  return roots;
31
32
  }
33
+ scope.fixed('codebuff', getProjectRoots().map((r) => r.projectsDir));
32
34
 
33
35
  function safeReadJson(filePath) {
34
36
  try { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } catch { return null; }
package/editors/codex.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const path = require('path');
2
+ const scope = require('./scope');
2
3
  const fs = require('fs');
3
4
  const os = require('os');
4
5
 
@@ -33,11 +34,13 @@ function getMessages(chat) {
33
34
  return parseSessionMessages(filePath);
34
35
  }
35
36
 
36
- function getCodexHome() {
37
+ function getCodexHomeDefault() {
37
38
  return process.env.CODEX_HOME && process.env.CODEX_HOME.trim()
38
39
  ? path.resolve(process.env.CODEX_HOME.trim())
39
40
  : DEFAULT_CODEX_HOME;
40
41
  }
42
+ // CODEX_HOME is Codex's own variable; MEMHOUSE_CODEX_ROOTS is memhouse's, and wins.
43
+ function getCodexHome() { return scope.root('codex', getCodexHomeDefault()); }
41
44
 
42
45
  function getSessionsDir() {
43
46
  return path.join(getCodexHome(), SESSION_SUBDIR);
@@ -1,8 +1,9 @@
1
1
  const path = require('path');
2
+ const scope = require('./scope');
2
3
  const fs = require('fs');
3
4
  const os = require('os');
4
5
 
5
- const COMMANDCODE_DIR = path.join(os.homedir(), '.commandcode');
6
+ const COMMANDCODE_DIR = scope.root('commandcode', path.join(os.homedir(), '.commandcode'));
6
7
  const PROJECTS_DIR = path.join(COMMANDCODE_DIR, 'projects');
7
8
 
8
9
  // ============================================================
@@ -1,8 +1,9 @@
1
1
  const path = require('path');
2
+ const scope = require('./scope');
2
3
  const fs = require('fs');
3
4
  const os = require('os');
4
5
 
5
- const JB_DIR = path.join(os.homedir(), '.copilot', 'jb');
6
+ const JB_DIR = scope.root('copilot-jetbrains', path.join(os.homedir(), '.copilot', 'jb'));
6
7
 
7
8
  // ============================================================
8
9
  // Adapter interface
@@ -1,8 +1,9 @@
1
1
  const path = require('path');
2
+ const scope = require('./scope');
2
3
  const fs = require('fs');
3
4
  const os = require('os');
4
5
 
5
- const COPILOT_DIR = path.join(os.homedir(), '.copilot');
6
+ const COPILOT_DIR = scope.root('copilot-cli', path.join(os.homedir(), '.copilot'));
6
7
  const SESSION_STATE_DIR = path.join(COPILOT_DIR, 'session-state');
7
8
 
8
9
  // ============================================================
@@ -1,8 +1,9 @@
1
1
  const path = require('path');
2
+ const scope = require('./scope');
2
3
  const fs = require('fs');
3
4
  const os = require('os');
4
5
 
5
- const CURSOR_PROJECTS_DIR = path.join(os.homedir(), '.cursor', 'projects');
6
+ const CURSOR_PROJECTS_DIR = scope.root('cursor-agent', path.join(os.homedir(), '.cursor', 'projects'));
6
7
 
7
8
  // ============================================================
8
9
  // Adapter interface
package/editors/cursor.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const { openReadOnly, textOf, bytesOf } = require('./sqlite');
2
2
  const path = require('path');
3
+ const scope = require('./scope');
3
4
  const fs = require('fs');
4
5
  const os = require('os');
5
6
  const { getAppDataPath } = require('./base');
@@ -10,6 +11,7 @@ const CURSOR_CHATS_DIR = path.join(HOME, '.cursor', 'chats');
10
11
  const CURSOR_USER_DIR = path.join(getAppDataPath('Cursor'), 'User');
11
12
  const WORKSPACE_STORAGE_DIR = path.join(CURSOR_USER_DIR, 'workspaceStorage');
12
13
  const GLOBAL_STORAGE_DB = path.join(CURSOR_USER_DIR, 'globalStorage', 'state.vscdb');
14
+ scope.fixed('cursor', [CURSOR_USER_DIR, CURSOR_CHATS_DIR]);
13
15
 
14
16
  // ============================================================
15
17
  // Source 1: ~/.cursor/chats/<hash>/<chatId>/store.db (agent KV)
package/editors/gemini.js CHANGED
@@ -1,8 +1,9 @@
1
1
  const path = require('path');
2
+ const scope = require('./scope');
2
3
  const fs = require('fs');
3
4
  const os = require('os');
4
5
 
5
- const GEMINI_DIR = path.join(os.homedir(), '.gemini');
6
+ const GEMINI_DIR = scope.root('gemini-cli', path.join(os.homedir(), '.gemini'));
6
7
  const TMP_DIR = path.join(GEMINI_DIR, 'tmp');
7
8
  const PROJECTS_JSON = path.join(GEMINI_DIR, 'projects.json');
8
9
 
package/editors/goose.js CHANGED
@@ -1,11 +1,12 @@
1
1
  const path = require('path');
2
+ const scope = require('./scope');
2
3
  const fs = require('fs');
3
4
  const os = require('os');
4
5
 
5
6
  const { openReadOnly, textOf } = require('./sqlite');
6
7
  const adapterErrors = require('./adapter-errors');
7
8
 
8
- const GOOSE_DIR = path.join(os.homedir(), '.local', 'share', 'goose', 'sessions');
9
+ const GOOSE_DIR = scope.root('goose', path.join(os.homedir(), '.local', 'share', 'goose', 'sessions'));
9
10
  const DB_PATH = path.join(GOOSE_DIR, 'sessions.db');
10
11
  const CONFIG_PATH = path.join(os.homedir(), '.config', 'goose', 'config.yaml');
11
12
 
package/editors/index.js CHANGED
@@ -15,6 +15,7 @@ const goose = require('./goose');
15
15
  const kiro = require('./kiro');
16
16
  const codebuff = require('./codebuff');
17
17
  const adapterErrorSink = require('./adapter-errors');
18
+ const scope = require('./scope');
18
19
 
19
20
  const editors = [cursor, devin, antigravity, claude, vscode, zed, opencode, codex, gemini, copilot, copilotJetbrains, cursorAgent, commandcode, goose, kiro, codebuff];
20
21
 
@@ -47,7 +48,13 @@ function getAllChats() {
47
48
  const chats = [];
48
49
  adapterErrors = [];
49
50
  adapterErrorSink.reset();
50
- for (const editor of editors) {
51
+ // MEMHOUSE_EDITORS narrows which adapters run; an unknown name throws rather than
52
+ // shipping nothing (or everything) behind a typo. See scope.js.
53
+ for (const editor of scope.selectEditors(editors, process.env.MEMHOUSE_EDITORS)) {
54
+ // A refused MEMHOUSE_<EDITOR>_ROOTS is recorded at load; surface it here as this
55
+ // adapter's error and skip it, rather than letting it scan nowhere and report zero.
56
+ const w = scope.watching().find((x) => x.name === editor.name);
57
+ if (w && w.error) { adapterErrors.push({ source: editor.name, message: w.error }); continue; }
51
58
  try {
52
59
  const editorChats = editor.getChats();
53
60
  chats.push(...editorChats);
package/editors/kiro.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const path = require('path');
2
+ const scope = require('./scope');
2
3
  const fs = require('fs');
3
4
  const os = require('os');
4
5
  const { getAppDataPath } = require('./base');
@@ -9,9 +10,9 @@ const { getAppDataPath } = require('./base');
9
10
 
10
11
  const name = 'kiro';
11
12
 
12
- const KIRO_AGENT_DIR = path.join(
13
+ const KIRO_AGENT_DIR = scope.root('kiro', path.join(
13
14
  getAppDataPath('Kiro'), 'User', 'globalStorage', 'kiro.kiroagent'
14
- );
15
+ ));
15
16
  const WORKSPACE_SESSIONS_DIR = path.join(KIRO_AGENT_DIR, 'workspace-sessions');
16
17
 
17
18
  function getChats() {
@@ -1,4 +1,5 @@
1
1
  const path = require('path');
2
+ const scope = require('./scope');
2
3
  const fs = require('fs');
3
4
  const os = require('os');
4
5
  const { openReadOnly, textOf } = require('./sqlite');
@@ -6,7 +7,7 @@ const adapterErrors = require('./adapter-errors');
6
7
 
7
8
  // OpenCode stores data in XDG-style paths across all platforms
8
9
  function getOpenCodeStoragePath() {
9
- return path.join(os.homedir(), '.local', 'share', 'opencode', 'storage');
10
+ return scope.root('opencode', path.join(os.homedir(), '.local', 'share', 'opencode', 'storage'));
10
11
  }
11
12
 
12
13
  const STORAGE_DIR = getOpenCodeStoragePath();
@@ -0,0 +1,126 @@
1
+ // Which of this machine's sessions ship — the scope.
2
+ //
3
+ // By default every adapter runs and the Claude adapter reads every config directory it
4
+ // can find (~/.claude and each ~/.claude-playbooks/<name>). A machine that keeps several
5
+ // Claude Code instances, or an operator who wants one instance's memory in a team house
6
+ // and the rest of their work elsewhere, needs to say which. Two knobs, both plain text in
7
+ // the env file so a person can read what a house will receive:
8
+ //
9
+ // MEMHOUSE_EDITORS comma-separated adapter names: `claude`, `codex`, … Empty = all.
10
+ // MEMHOUSE_<EDITOR>_ROOTS the directory that adapter reads instead of its default —
11
+ // MEMHOUSE_CODEX_ROOTS='~/work/.codex'. The name is the adapter's,
12
+ // upper-cased, dashes to underscores: MEMHOUSE_GEMINI_CLI_ROOTS.
13
+ // Claude is the one adapter with MANY roots (each Claude Code
14
+ // config dir); every other adapter has one store, so one path.
15
+ //
16
+ // `memhouse discover` prints, per adapter, what it is watching and the variable that
17
+ // changes it. Adapters register here at load, so the list is what actually ran.
18
+ //
19
+ // Both FAIL LOUDLY on a name or path that does not exist. A typo that silently shipped
20
+ // nothing would look like a working install with an empty house; one that silently
21
+ // shipped everything would defeat the point of setting it.
22
+
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const path = require('path');
26
+
27
+ function parseList(value) {
28
+ return String(value || '').split(',').map((s) => s.trim()).filter(Boolean);
29
+ }
30
+
31
+ /** The adapters that should run, in the order given. */
32
+ function selectEditors(editors, value) {
33
+ const wanted = parseList(value);
34
+ if (!wanted.length) return editors;
35
+ const byName = new Map(editors.map((e) => [e.name, e]));
36
+ const unknown = wanted.filter((n) => !byName.has(n));
37
+ if (unknown.length) {
38
+ throw new Error(`MEMHOUSE_EDITORS names an adapter that does not exist: ${unknown.join(', ')}. `
39
+ + `Known: ${editors.map((e) => e.name).join(', ')}`);
40
+ }
41
+ return wanted.map((n) => byName.get(n));
42
+ }
43
+
44
+ function expandHome(p) {
45
+ return p === '~' ? os.homedir() : p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p;
46
+ }
47
+
48
+ /**
49
+ * The Claude roots to read. With nothing set, the discovered list as-is. With a list set,
50
+ * exactly those directories — each must exist and hold a `projects/` dir or a
51
+ * `history.jsonl`, the same test discovery applies, so a path that is real but not a
52
+ * Claude config dir is refused rather than silently contributing nothing.
53
+ */
54
+ function selectClaudeRoots(discovered, value, { exists = fs.existsSync, isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } } } = {}) {
55
+ const wanted = parseList(value).map(expandHome);
56
+ if (!wanted.length) return discovered;
57
+ const bad = [];
58
+ for (const r of wanted) {
59
+ if (!isDir(r)) { bad.push(`${r} (not a directory)`); continue; }
60
+ if (!isDir(path.join(r, 'projects')) && !exists(path.join(r, 'history.jsonl'))) bad.push(`${r} (no projects/ and no history.jsonl — not a Claude Code config dir)`);
61
+ }
62
+ if (bad.length) throw new Error(`MEMHOUSE_CLAUDE_ROOTS: ${bad.join('; ')}`);
63
+ return wanted;
64
+ }
65
+
66
+ /** One line for status/discover: what this install ships, or 'everything'. */
67
+ function describe(env = process.env) {
68
+ const parts = [];
69
+ if (parseList(env.MEMHOUSE_EDITORS).length) parts.push(`editors: ${parseList(env.MEMHOUSE_EDITORS).join(', ')}`);
70
+ for (const k of Object.keys(env).filter((k) => /^MEMHOUSE_[A-Z_]+_ROOTS$/.test(k)).sort()) {
71
+ if (parseList(env[k]).length) parts.push(`${k.slice(9, -6).toLowerCase().replace(/_/g, '-')} roots: ${parseList(env[k]).join(', ')}`);
72
+ }
73
+ return parts.length ? parts.join('; ') : 'everything this machine has';
74
+ }
75
+
76
+ /** The env variable that overrides an adapter's root(s). */
77
+ function keyFor(name) { return `MEMHOUSE_${String(name).toUpperCase().replace(/-/g, '_')}_ROOTS`; }
78
+
79
+ const isDirFs = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
80
+
81
+ /**
82
+ * Pure core for a single-store adapter: the override if one is set, else the default.
83
+ * An override must be exactly one existing directory. Errors are RETURNED, not thrown, so
84
+ * a bad value for one adapter skips that adapter with its reason and never takes the other
85
+ * sixteen down with it at module load.
86
+ */
87
+ function selectRoot(dflt, value, { isDir = isDirFs } = {}) {
88
+ const wanted = parseList(value).map(expandHome);
89
+ if (!wanted.length) return { root: dflt, error: null };
90
+ if (wanted.length > 1) return { root: null, error: `reads one directory, ${wanted.length} given` };
91
+ if (!isDir(wanted[0])) return { root: null, error: `${wanted[0]} is not a directory` };
92
+ return { root: wanted[0], error: null };
93
+ }
94
+
95
+ // What each adapter is watching, recorded as adapters load. `key` is null for an adapter
96
+ // whose location is not overridable yet; `error` is why an override was refused.
97
+ const watched = new Map();
98
+ const NOWHERE = path.join(os.tmpdir(), 'memhouse-scope-refused-' + process.pid);
99
+
100
+ function root(name, dflt, env = process.env) {
101
+ const key = keyFor(name);
102
+ const { root: r, error } = selectRoot(dflt, env[key]);
103
+ watched.set(name, { roots: [error ? null : r].filter(Boolean), key, error: error ? `${key}: ${error}` : null });
104
+ return error ? NOWHERE : r;
105
+ }
106
+
107
+ function roots(name, defaults, value, opts) {
108
+ const key = keyFor(name);
109
+ try {
110
+ const r = selectClaudeRoots(defaults, value, opts);
111
+ watched.set(name, { roots: r, key, error: null });
112
+ return r;
113
+ } catch (e) {
114
+ watched.set(name, { roots: [], key, error: String(e.message || e) });
115
+ throw e;
116
+ }
117
+ }
118
+
119
+ /** Register an adapter whose location is built in and not (yet) overridable. */
120
+ function fixed(name, dirs) { watched.set(name, { roots: dirs.filter(Boolean), key: null, error: null }); }
121
+
122
+ function watching() {
123
+ return [...watched.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, w]) => ({ name, ...w }));
124
+ }
125
+
126
+ module.exports = { parseList, selectEditors, selectClaudeRoots, selectRoot, expandHome, describe, keyFor, root, roots, fixed, watching };
package/editors/vscode.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const path = require('path');
2
+ const scope = require('./scope');
2
3
  const fs = require('fs');
3
4
  const os = require('os');
4
5
  const { getAppDataPath } = require('./base');
@@ -14,6 +15,7 @@ const VARIANTS = [
14
15
  appSupport: getAppDataPath('Code - Insiders'),
15
16
  },
16
17
  ];
18
+ scope.fixed('vscode', VARIANTS.map((v) => v.appSupport));
17
19
 
18
20
  // ============================================================
19
21
  // JSONL reconstruction: kind:0 = init, kind:1 = patch at key path
package/editors/zed.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const path = require('path');
2
+ const scope = require('./scope');
2
3
  const fs = require('fs');
3
4
  const os = require('os');
4
5
 
@@ -21,7 +22,7 @@ function getZedDataPath() {
21
22
  }
22
23
  }
23
24
 
24
- const THREADS_DB = path.join(getZedDataPath(), 'threads', 'threads.db');
25
+ const THREADS_DB = path.join(scope.root('zed', getZedDataPath()), 'threads', 'threads.db');
25
26
 
26
27
  // ============================================================
27
28
  // Decompress zstd blob via CLI (with cross-platform support)
@@ -5,12 +5,35 @@ on your machines — across **all 17 editors** agentlytics supports — parsed l
5
5
  shipped to a typed ClickHouse store, shareable with a team, installable on the
6
6
  ultimagent kernel as an **agency**, and visible through the agentlytics dashboard.
7
7
 
8
- **A house is a database; its rooms are three shared tables** — `sessions`, `messages`,
9
- `tool_calls`. Everyone in the house writes into the same tables with their own
10
- credential; the server-stamped `user_id` says who, the install fingerprint `host` says
11
- which machine. Sharing IS the house; the boundary between groups is the database. (The
12
- layout has moved twice: shared-tables-with-row-policies fell in 0.4.0, per-member
13
- suffixed rooms fell in 0.8.0 — see `house/HOUSE.md` for why.)
8
+ **A house is a database; its rooms are tables** — `sessions`, `messages`, `tool_calls`.
9
+ The server-stamped `user_id` says who wrote a row, the install fingerprint `host` says
10
+ which machine.
11
+
12
+ **What a member owns inside a house is the layout's whole question, and it has three
13
+ answers, two of them retired.** Shared tables with row policies fell in 0.4.0 (policies
14
+ are permissive and OR'd — a catch-all fails OPEN). Per-member suffixed rooms fell in
15
+ 0.8.0, on the argument that isolation between housemates solved an adversarial problem
16
+ the product did not have. That argument was wrong, and the counter-example is measured:
17
+ with everyone holding `ALL ON db.* WITH GRANT OPTION`, one member ran
18
+ `GRANT SELECT ON db.* TO <outsider>` and handed over a housemate's transcripts — no
19
+ admin, no notification.
20
+
21
+ So the layout today is per-member rooms again, prefixed rather than suffixed, and this
22
+ time there is only one of it: **every member's rooms are named for them, and one wildcard
23
+ grant — `ON mem.<name>_*` — is the whole of what they hold.** Nobody is granted the
24
+ database. A house with one member is a house of one, not a different layout; standalone,
25
+ team and kernel are the same statements. `roomNames()` in `house/house.js` is the only
26
+ place a table name is produced and `provision.js` the only description of the grant.
27
+
28
+ The two objections that retired the 0.8.0 layout were real and are paid again here:
29
+ clients must resolve names before querying (`memhouse rooms`, and the `q()` helper the
30
+ skills route through), and reading across members needs a `UNION ALL` rather than one
31
+ table. Both are cheaper than the leak.
32
+
33
+ **Why grants and not policies.** A grant is something a colleague can verify for
34
+ themselves — `SHOW GRANTS` lists their rooms, and `SHOW TABLES` does not show them what
35
+ they were not granted. "Trust the row policy protecting your rows" is a sentence you
36
+ cannot ask a teammate to accept when "here are your grants, check them" is available.
14
37
 
15
38
  **Agency, precisely.** In constellation terms (`../TERMINOLOGY.md`) memhouse is a
16
39
  **house** — the `mem` database — **plus a resident**: the shipper. That pairing
@@ -60,7 +83,7 @@ them free and further along; **bet 3 is the one nobody else attempts.**
60
83
  write axis this bet is precisely *moving work from routine to resident*:
61
84
  memory-house parses when you query, memhouse parses before anyone asks and
62
85
  writes the result down.
63
- 2. **Typed common schema** (`house/schema.sql.tpl` — three shared tables per house).
86
+ 2. **Typed common schema** (`house/schema.sql.tpl` — the rooms, rendered per member).
64
87
  Physical typed columns (what memory-house derives in views) + `tool_calls`
65
88
  (memory-house has no tool table) + one `extra JSON` escape hatch per room so
66
89
  unnormalized adapter fields are never lost. `ReplacingMergeTree(ingested_at)`;
@@ -84,7 +107,7 @@ them free and further along; **bet 3 is the one nobody else attempts.**
84
107
  | Path | What | Notes |
85
108
  |---|---|---|
86
109
  | `house/house.js` | room names + the session rollup + the user pin | one rule for both transports |
87
- | `house/schema.sql.tpl` | the house's three shared tables (typed) | applied by each member's own shipper |
110
+ | `house/schema.sql.tpl` | the rooms (typed) | applied by the member's shipper, or by the operator at invite in a shared house |
88
111
  | `house/HOUSE.md` | the model, and what it replaced | |
89
112
  | `shipper/ship.js` | parse-on-client shipper CLI — the resident (`worker`) | reuses `../../editors`; incremental; idempotent |
90
113
  | `server/server.js` | REST API + dashboard | same API contract as agentlytics; serves `../../public` |
@@ -0,0 +1,81 @@
1
+ // An instance's plugin is bound to the instance that installed it.
2
+ //
3
+ // A memhouse INSTANCE is a home: an env file naming a house, the daemons that ship to it,
4
+ // and the playbooks (Claude Code config dirs) whose sessions it ships. Its /mem:* skills
5
+ // belong in exactly those playbooks, and inside them they must read THAT house and run
6
+ // THAT binary — a machine can carry two instances on two channels, and a bare `memhouse`
7
+ // with a session-inherited MEMHOUSE_HOME would pick whichever the shell happened to have.
8
+ //
9
+ // Claude Code applies a settings.json `env` block to every session under a config dir.
10
+ // So `plugins install` stamps two variables there, beside the skills it copies:
11
+ //
12
+ // MEMHOUSE_HOME the instance's home — which house the skills read
13
+ // MEMHOUSE_BIN the instance's binary — which memhouse the skills run
14
+ //
15
+ // Nothing else in the file is touched; other env keys survive; `plugins remove` takes the
16
+ // two back out. Pure functions here, file I/O below them, both unit-tested.
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ const KEYS = ['MEMHOUSE_HOME', 'MEMHOUSE_BIN'];
22
+
23
+ function bindSettings(json, { home, bin }) {
24
+ const out = { ...(json || {}) };
25
+ out.env = { ...(out.env || {}), MEMHOUSE_HOME: home, MEMHOUSE_BIN: bin };
26
+ return out;
27
+ }
28
+
29
+ function unbindSettings(json) {
30
+ const out = { ...(json || {}) };
31
+ if (out.env) {
32
+ out.env = { ...out.env };
33
+ for (const k of KEYS) delete out.env[k];
34
+ if (!Object.keys(out.env).length) delete out.env;
35
+ }
36
+ return out;
37
+ }
38
+
39
+ function boundTo(json) {
40
+ const env = (json && json.env) || {};
41
+ return env.MEMHOUSE_HOME ? { home: env.MEMHOUSE_HOME, bin: env.MEMHOUSE_BIN || null } : null;
42
+ }
43
+
44
+ function settingsPath(dir) { return path.join(dir, 'settings.json'); }
45
+
46
+ function readSettings(dir) {
47
+ try { return JSON.parse(fs.readFileSync(settingsPath(dir), 'utf-8')); } catch { return null; }
48
+ }
49
+
50
+ function writeSettings(dir, json) {
51
+ fs.writeFileSync(settingsPath(dir), `${JSON.stringify(json, null, 2)}\n`);
52
+ }
53
+
54
+ /** Stamp the binding into a config dir's settings.json; returns what was written. */
55
+ function bind(dir, { home, bin }) {
56
+ const cur = readSettings(dir);
57
+ if (cur === null && fs.existsSync(settingsPath(dir))) throw new Error(`${settingsPath(dir)} is not valid JSON — not touching it`);
58
+ writeSettings(dir, bindSettings(cur || {}, { home, bin }));
59
+ return { home, bin };
60
+ }
61
+
62
+ function unbind(dir) {
63
+ const cur = readSettings(dir);
64
+ if (!cur || !boundTo(cur)) return false;
65
+ writeSettings(dir, unbindSettings(cur));
66
+ return true;
67
+ }
68
+
69
+ /**
70
+ * What an instance is called: MEMHOUSE_NAME if the env file sets one, else its home
71
+ * directory with the conventional `.memhouse-` prefix removed — `~/.memhouse-stage` is
72
+ * "stage", `~/.memhouse` is "default", `~/alice-sandbox/memhouse` is "memhouse".
73
+ */
74
+ function instanceName(home, envName = '') {
75
+ if (envName) return envName;
76
+ const base = path.basename(String(home || '').replace(/[\/]+$/, ''));
77
+ if (base === '.memhouse') return 'default';
78
+ return base.replace(/^\.memhouse-/, '') || 'default';
79
+ }
80
+
81
+ module.exports = { bindSettings, unbindSettings, boundTo, bind, unbind, readSettings, KEYS, instanceName };
@@ -0,0 +1,50 @@
1
+ // Which npm channel this install follows, and what `memhouse update` may move it to.
2
+ //
3
+ // `update` used to install `memhouse@latest`, full stop. That is right for a house that
4
+ // tracks the published release and wrong for every other kind of install: a build
5
+ // published under another dist-tag (`memhouse@team`, so a team on a newer layout does not
6
+ // drag every other house along) would be DOWNGRADED to latest on its next update, and a
7
+ // tarball install has no tag at all and would be replaced by whatever latest is.
8
+ //
9
+ // So the channel is resolved, in this order:
10
+ // 1. pinned — MEMHOUSE_CHANNEL in the env file, or --channel on the command line
11
+ // 2. inferred — the dist-tag whose version equals the one installed (latest preferred)
12
+ // 3. a pre-release version matching no tag is a tarball: no automatic update at all
13
+ // 4. otherwise latest
14
+ // Pure, so the decision is unit-tested against every shape of registry answer.
15
+
16
+ function isPrerelease(version) { return /-/.test(String(version || '')); }
17
+
18
+ /**
19
+ * @param {{version:string, pinned?:string|null, tags:Record<string,string>|null}} p
20
+ * @returns {{channel:string|null, target:string|null, reason:string}}
21
+ */
22
+ function pickChannel({ version, pinned = null, tags = null }) {
23
+ const t = tags || {};
24
+ if (pinned) {
25
+ return t[pinned]
26
+ ? { channel: pinned, target: t[pinned], reason: `pinned by MEMHOUSE_CHANNEL` }
27
+ : { channel: pinned, target: null, reason: `pinned to '${pinned}', which is not a tag on the registry${tags ? '' : ' (or the registry did not answer)'}` };
28
+ }
29
+ const matching = Object.entries(t).filter(([, v]) => v === version).map(([k]) => k);
30
+ if (matching.includes('latest')) return { channel: 'latest', target: t.latest, reason: 'installed version is latest' };
31
+ if (matching.length) return { channel: matching[0], target: t[matching[0]], reason: `installed version is published as '${matching[0]}'` };
32
+ if (isPrerelease(version)) {
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
+ }
35
+ return { channel: 'latest', target: t.latest || null, reason: tags ? 'following latest' : 'following latest (registry did not answer)' };
36
+ }
37
+
38
+ /** The registry's dist-tags, or null when it cannot be reached. Abbreviated document, small. */
39
+ async function fetchTags(fetchImpl = globalThis.fetch) {
40
+ try {
41
+ const res = await fetchImpl('https://registry.npmjs.org/memhouse', {
42
+ headers: { accept: 'application/vnd.npm.install-v1+json' }, signal: AbortSignal.timeout(8000),
43
+ });
44
+ if (!res.ok) return null;
45
+ const j = await res.json();
46
+ return j && j['dist-tags'] ? j['dist-tags'] : null;
47
+ } catch { return null; }
48
+ }
49
+
50
+ module.exports = { pickChannel, fetchTags, isPrerelease };
@@ -43,7 +43,8 @@ already exist.
43
43
  - If a command fails, read what it printed before retrying. memhouse's failures name the
44
44
  cause and usually name the fix — it is not a generic tool that fails generically.
45
45
  - The house's rooms are three PLAIN, SHARED tables — `sessions`, `messages`,
46
- `tool_calls` — in the connection's database. No suffixes, no per-member tables.
46
+ `tool_calls` — in the connection's database, carrying your own prefix if this is a
47
+ shared house (`alice_messages`). `memhouse rooms` prints what yours are called.
47
48
  Direct reads of `messages`/`tool_calls` must filter to the current parse (the shipper
48
49
  retains superseded parses under an `epoch` column); the installed skills carry the
49
50
  filter, so prefer them for queries.
@@ -87,7 +87,7 @@ no longer exists, so anything named here is a single editor's own store (locked
87
87
  running editor, corrupt, or on a schema this parser does not know).
88
88
 
89
89
  `stats` should show one row per source (claude-code, codex, gemini-cli, …) with
90
- non-zero sessions/messages. Or verify by SQL — the rooms are plain shared tables
90
+ non-zero sessions/messages. Or verify by SQL — the rooms are the ones `memhouse rooms` names
91
91
  (`sessions`, `messages`, `tool_calls`) and the session rollup is a saved query rather
92
92
  than an object, so `memhouse sessions-query` prints it (it also applies the
93
93
  current-parse filter that any hand-written read of `messages` needs — the shipper