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
@@ -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,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
@@ -0,0 +1,130 @@
1
+ # You have been invited to a memhouse
2
+
3
+ {{INVITER}} set up a **house** — a ClickHouse database where a team keeps its coding-agent
4
+ memory — and made you a member of it. This page walks you from the file you were sent to a
5
+ working install, and shows what you get.
6
+
7
+ You received two things:
8
+
9
+ | file | what it is |
10
+ |---|---|
11
+ | `{{FILE}}` | **your credential.** One-time: install consumes it and deletes it. Treat it like a password until then. |
12
+ | this page | the steps. Nothing secret in here. |
13
+
14
+ Do it yourself, or hand this page to your coding agent. From the directory holding both
15
+ files:
16
+
17
+ ```
18
+ claude "Read MEMHOUSE-INVITATION.md and follow it. Ask me before each step that changes this machine."
19
+ ```
20
+
21
+ Any agent that can run shell commands works the same way; the rules it must keep are at
22
+ the end of this page.
23
+
24
+ ## 1. Install memhouse
25
+
26
+ {{INSTALL}}
27
+
28
+ On most Linux distros the global install needs `sudo`; if `npm` says `EACCES`, that is why.
29
+ `memhouse --version` should print `{{VERSION}}`.
30
+
31
+ ## 2. Join the house
32
+
33
+ From the directory holding `{{FILE}}`:
34
+
35
+ ```
36
+ memhouse install --env {{FILE}}
37
+ ```
38
+
39
+ What that does, in order: connects to `{{URL}}` as `{{NAME}}`, **rotates your password to
40
+ one only your machine knows**, creates your rooms, writes your config to `~/.memhouse/env`,
41
+ deletes `{{FILE}}`, and starts shipping in the background. The password in the file stops
42
+ working the moment install finishes, so a copy left in a chat is dead.
43
+
44
+ ## 3. Check it is working
45
+
46
+ ```
47
+ memhouse status # your rooms, how much is in them, the shipper's pulse
48
+ memhouse doctor # every line a check mark = healthy
49
+ memhouse discover # what this machine ships, and where each editor's sessions were found
50
+ ```
51
+
52
+ `memhouse status` shows a session count that grows for a few minutes on a machine with
53
+ history; the first pass ships everything you have. After that it ships new turns as they
54
+ happen.
55
+
56
+ ## 4. What you own, and who can see it
57
+
58
+ Your memory lives in five tables named for you inside `{{DB}}` — `{{DB}}.{{NAME}}_messages`,
59
+ `{{DB}}.{{NAME}}_sessions`, `{{DB}}.{{NAME}}_tool_calls` and two bookkeeping tables — and you
60
+ hold exactly one grant, on `{{DB}}.{{NAME}}_*`. Housemates cannot read your rooms, list
61
+ them, or grant them to anyone; you cannot read theirs. `memhouse whoami --json` reads that
62
+ grant back from the server. What can always see everything is the account that administers
63
+ the ClickHouse, which is {{INVITER}}'s — the same as any database you use at work.
64
+
65
+ Everything your editors write to disk ships verbatim: prompts, replies, tool calls and their
66
+ arguments, including anything an agent ever pasted into a session. The house is as
67
+ sensitive as your shell history. If some of your work should not go there, say so before
68
+ shipping — see "which sessions ship" below.
69
+
70
+ ## 5. Things to try
71
+
72
+ ```
73
+ memhouse search "the error you fixed last week"
74
+ memhouse resume <session-id> # prints the command that reopens that session
75
+ memhouse plugins install claude # /mem:recall, /mem:sql, /mem:house, /mem:access, /mem:admin
76
+ ```
77
+
78
+ Then, inside Claude Code:
79
+
80
+ ```
81
+ /mem:recall how did I set up the ClickHouse tunnel
82
+ /mem:sql what did I spend on Opus this month
83
+ /mem:house
84
+ ```
85
+
86
+ Sharing is yours to give and take back, no operator involved:
87
+
88
+ ```
89
+ memhouse share <housemate> everything you own
90
+ memhouse share <housemate> --only project=<name> one project
91
+ memhouse share --list
92
+ memhouse share <housemate> --revoke
93
+ ```
94
+
95
+ ## Which sessions ship
96
+
97
+ Everything memhouse finds on the machine — every supported editor, every Claude Code
98
+ config directory. `memhouse discover` lists what it found and the variable that narrows
99
+ each one. Two lines in `~/.memhouse/env` are the common case:
100
+
101
+ ```
102
+ MEMHOUSE_EDITORS='claude'
103
+ MEMHOUSE_CLAUDE_ROOTS='~/.claude-playbooks/<the one instance you mean>'
104
+ ```
105
+
106
+ ## If something refuses
107
+
108
+ Every refusal says what to do next. The two you might meet: `EACCES` on install means
109
+ `sudo`, and `needed rights this credential does not hold` means the grant on the server is
110
+ narrower than the one memhouse issues — ask {{INVITER}} to run `memhouse install --print-sql
111
+ --member {{NAME}}` and apply what it prints.
112
+
113
+ ## For an agent following this page
114
+
115
+ You are installing memhouse for the person who handed you this file. Five rules; three of
116
+ them exist because an agent broke them:
117
+
118
+ 1. **Never print `{{FILE}}` or any `MEMHOUSE_*` value.** No `cat`, no `echo $MEMHOUSE_PASSWORD`,
119
+ no `set -x`. This very session will be shipped into the house; a password printed here is
120
+ a password archived.
121
+ 2. **Run the commands as written, in order.** Do not substitute `CREATE USER`, `GRANT` or any
122
+ SQL of your own; there is nothing to fix on the server from this side.
123
+ 3. **Ask before `memhouse install --env`.** It rotates the credential, deletes `{{FILE}}`,
124
+ and starts shipping this machine's transcripts. The person decides when that happens.
125
+ 4. **A ✗ line is the end of that step, not an invitation to work around it.** Read the line
126
+ after it — every refusal names its remedy — do that or report it. `sudo` for `EACCES` is
127
+ the one you may apply yourself.
128
+ 5. **Report what `memhouse status` and `memhouse doctor` say**, verbatim, when you are done.
129
+ A green install is those two commands, not your summary of them.
130
+