memhouse 0.0.1 → 0.3.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 (97) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +72 -3
  3. package/bin/memhouse.js +559 -0
  4. package/cache.js +850 -0
  5. package/ch-ingest.js +329 -0
  6. package/ch-schema.js +152 -0
  7. package/clickhouse.js +87 -0
  8. package/editors/adapter-errors.js +52 -0
  9. package/editors/antigravity.js +1048 -0
  10. package/editors/base.js +321 -0
  11. package/editors/claude.js +386 -0
  12. package/editors/codebuff.js +338 -0
  13. package/editors/codex.js +537 -0
  14. package/editors/commandcode.js +166 -0
  15. package/editors/copilot-jetbrains.js +207 -0
  16. package/editors/copilot.js +260 -0
  17. package/editors/cursor-agent.js +202 -0
  18. package/editors/cursor.js +498 -0
  19. package/editors/gemini.js +231 -0
  20. package/editors/goose.js +386 -0
  21. package/editors/gsd.js +366 -0
  22. package/editors/index.js +274 -0
  23. package/editors/kiro.js +315 -0
  24. package/editors/opencode.js +432 -0
  25. package/editors/vscode.js +413 -0
  26. package/editors/windsurf.js +634 -0
  27. package/editors/zed.js +229 -0
  28. package/index.js +424 -2
  29. package/mcp-server.js +294 -0
  30. package/mem-house/DESIGN.md +119 -0
  31. package/mem-house/delivery/AGENT-INSTALL.md +87 -0
  32. package/mem-house/delivery/PROMPT.md +42 -0
  33. package/mem-house/delivery/install.sh +181 -0
  34. package/mem-house/delivery/kernel-install.md +83 -0
  35. package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
  36. package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
  37. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
  38. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
  39. package/mem-house/rls.sql +17 -0
  40. package/mem-house/schema.sql +145 -0
  41. package/mem-house/server/queries.js +878 -0
  42. package/mem-house/server/server.js +228 -0
  43. package/mem-house/shipper/ship.js +461 -0
  44. package/misc/syntax-check.js +38 -0
  45. package/package.json +59 -7
  46. package/pricing.js +88 -0
  47. package/pricing.json +992 -0
  48. package/public/assets/index-CCPh7a2Z.js +75 -0
  49. package/public/assets/index-DV6ONi_F.css +2 -0
  50. package/public/index.html +16 -0
  51. package/relay-client.js +306 -0
  52. package/relay-server.js +577 -0
  53. package/server.js +860 -0
  54. package/share-image.js +367 -0
  55. package/ui/eslint.config.js +29 -0
  56. package/ui/index.html +15 -0
  57. package/ui/package-lock.json +4229 -0
  58. package/ui/package.json +38 -0
  59. package/ui/src/App.jsx +364 -0
  60. package/ui/src/components/ActivityHeatmap.jsx +206 -0
  61. package/ui/src/components/AiAuditCard.jsx +254 -0
  62. package/ui/src/components/AnimatedLoader.jsx +14 -0
  63. package/ui/src/components/AnimatedLogo.jsx +96 -0
  64. package/ui/src/components/ChatSidebar.jsx +226 -0
  65. package/ui/src/components/DateRangePicker.jsx +104 -0
  66. package/ui/src/components/EditorDot.jsx +11 -0
  67. package/ui/src/components/EditorIcon.jsx +117 -0
  68. package/ui/src/components/KpiCard.jsx +9 -0
  69. package/ui/src/components/LiveFeed.jsx +128 -0
  70. package/ui/src/components/LoginScreen.jsx +81 -0
  71. package/ui/src/components/MessageRenderer.jsx +148 -0
  72. package/ui/src/components/PageHeader.jsx +11 -0
  73. package/ui/src/components/SectionTitle.jsx +3 -0
  74. package/ui/src/components/ShareModal.jsx +305 -0
  75. package/ui/src/components/TokenTimeline.jsx +258 -0
  76. package/ui/src/hooks/useLive.jsx +15 -0
  77. package/ui/src/index.css +83 -0
  78. package/ui/src/lib/api.js +343 -0
  79. package/ui/src/lib/constants.js +88 -0
  80. package/ui/src/lib/theme.jsx +23 -0
  81. package/ui/src/main.jsx +16 -0
  82. package/ui/src/pages/Artifacts.jsx +575 -0
  83. package/ui/src/pages/Compare.jsx +232 -0
  84. package/ui/src/pages/CostAnalysis.jsx +354 -0
  85. package/ui/src/pages/Dashboard.jsx +529 -0
  86. package/ui/src/pages/DeepAnalysis.jsx +495 -0
  87. package/ui/src/pages/GSD.jsx +726 -0
  88. package/ui/src/pages/MCPs.jsx +744 -0
  89. package/ui/src/pages/ProjectDetail.jsx +325 -0
  90. package/ui/src/pages/Projects.jsx +249 -0
  91. package/ui/src/pages/RelayDashboard.jsx +590 -0
  92. package/ui/src/pages/RelayUserDetail.jsx +466 -0
  93. package/ui/src/pages/Sessions.jsx +535 -0
  94. package/ui/src/pages/Settings.jsx +241 -0
  95. package/ui/src/pages/SqlViewer.jsx +370 -0
  96. package/ui/src/pages/Subscriptions.jsx +411 -0
  97. package/ui/vite.config.js +18 -0
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fatih Kadir Akın (agentlytics — original work)
4
+ Copyright (c) 2026 Ramazan Polat (memhouse — derivative work)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,75 @@
1
1
  # memhouse
2
2
 
3
- Agent conversation memory across 17 editors — parse-on-client shippers, a typed
4
- ClickHouse store, team sharing, and an analytics dashboard.
3
+ **memhouse** — agent conversation memory as a product. Every coding-agent session
4
+ on your machines — across the **17 editors** the agentlytics adapters support —
5
+ parsed locally, shipped to one typed ClickHouse store, shareable with a team,
6
+ installable on the ultimagent kernel as an **agency**, and visible through the
7
+ agentlytics dashboard unchanged.
5
8
 
6
- Name reserved; first public release coming.
9
+ An **agency** in the constellation sense (`TERMINOLOGY.md`): a **house** — the
10
+ `memhouse` database — plus a **resident** working in it, the shipper. The test is
11
+ what writes. The shipper fires on its own loop and puts rows in the house that
12
+ outlive any query; everything else here is only ever read. The house alone would
13
+ hold; the shipper is what makes it act.
14
+
15
+ An alternative agency **competing with memory-house**; if it wins, it becomes
16
+ memory-house v4. Start with `mem-house/DESIGN.md` for the four bets
17
+ (parse-on-client, typed common schema, kernel-agency, borrowed UI).
18
+
19
+ ## Layout
20
+
21
+ | Path | What |
22
+ |---|---|
23
+ | `mem-house/` | the product: `DESIGN.md`, `schema.sql`, `rls.sql`, `shipper/`, `server/`, `delivery/` |
24
+ | `editors/` | the 17 editor adapters (inherited from agentlytics; the crown jewels) |
25
+ | `pricing.js` + `pricing.json` | the cost engine |
26
+ | `ui/` | the dashboard SPA (built to `public/`, served unchanged by the memhouse server) |
27
+ | `agency/` | the earlier agentlytics-agency wrap (raw canonical shape) — kept as prior art |
28
+ | `TERMINOLOGY.md` | the constellation terminology canon + how it applies here |
29
+ | everything else at root | upstream agentlytics (see `AGENTLYTICS-README.md`), still runnable |
30
+
31
+ ## Quickstart — the `memhouse` CLI
32
+
33
+ ```bash
34
+ npm install -g memhouse --allow-scripts=better-sqlite3
35
+ memhouse onboard # wizard: discover → configure → ship → start
36
+ ```
37
+
38
+ **Do not drop `--allow-scripts=better-sqlite3`.** Five adapters — cursor, goose,
39
+ opencode, zed, and antigravity — read sessions out of SQLite files, and
40
+ `better-sqlite3` builds its native binding from an install script. npm 12 blocks
41
+ install scripts by default, so without the flag those five read nothing and you
42
+ silently ship a partial history. `memhouse discover` and `memhouse doctor` both
43
+ say so when the binding is missing.
44
+
45
+ To try it without installing, npx takes the same flag — it has to come before the
46
+ package name:
47
+
48
+ ```bash
49
+ npx --allow-scripts=better-sqlite3 -y memhouse discover
50
+ ```
51
+
52
+ Working from a checkout instead: `npm install` (the repo's `allowScripts` field
53
+ covers the binding), then `node bin/memhouse.js …`.
54
+
55
+ ```text
56
+ memhouse onboard | install | setup | discover | uninstall | reset
57
+ memhouse ship [--full|--loop N] | stats | search <terms> | start | stop | status | doctor
58
+ memhouse plugins install claude | prompt
59
+ ```
60
+
61
+ Every command is dual-mode: interactive for humans, `--yes`/flags/`--json` for
62
+ agents — so an agent can self-install its own memory (`memhouse install --yes …`,
63
+ `memhouse plugins install claude`). Config: flags > `MEMHOUSE_*` env >
64
+ `~/.memhouse/env` > defaults.
65
+
66
+ Deeper docs: `mem-house/delivery/AGENT-INSTALL.md`, kernel install (agency
67
+ `memhouse`, members, own-only RLS): `mem-house/delivery/kernel-install.md`,
68
+ skills/plugin payloads: `mem-house/delivery/`.
69
+
70
+ ## Heritage & license
71
+
72
+ Built on [agentlytics](https://github.com/f/agentlytics) by Fatih Kadir Akın (MIT)
73
+ — the adapters, dashboard, and cost engine come from there (this repo's history
74
+ carries the full lineage). The `agentlytics` remote tracks the private working
75
+ mirror for syncing adapter improvements both ways.
@@ -0,0 +1,559 @@
1
+ #!/usr/bin/env node
2
+ // memhouse — the mem-house CLI. One command to install, ship, serve, and query
3
+ // agent conversation memory (see mem-house/DESIGN.md).
4
+ //
5
+ // Thin orchestrator: heavy operations run the existing entrypoints
6
+ // (mem-house/shipper/ship.js, mem-house/server/server.js) as children with the
7
+ // resolved MEMHOUSE_* env, so there is a single source of truth for shipping and
8
+ // serving. The CLI owns: config (~/.memhouse/env), daemons (pidfiles), probes,
9
+ // and the small read-only queries (status/search) done over ClickHouse HTTP.
10
+ //
11
+ // Every command is dual-mode: interactive for humans, flag/env-driven (--yes,
12
+ // --json where relevant) for agents. Config precedence: flags > process env >
13
+ // $MEMHOUSE_HOME/env file > defaults.
14
+
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+ const { spawn, spawnSync } = require('child_process');
19
+
20
+ const REPO_ROOT = path.join(__dirname, '..');
21
+ const SHIP_JS = path.join(REPO_ROOT, 'mem-house', 'shipper', 'ship.js');
22
+ const SERVER_JS = path.join(REPO_ROOT, 'mem-house', 'server', 'server.js');
23
+ const DELIVERY = path.join(REPO_ROOT, 'mem-house', 'delivery');
24
+ const PKG = require(path.join(REPO_ROOT, 'package.json'));
25
+
26
+ const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
27
+ const ENV_FILE = path.join(HOME_DIR, 'env');
28
+ const RUN_DIR = path.join(HOME_DIR, 'run');
29
+ const LOG_DIR = path.join(HOME_DIR, 'logs');
30
+
31
+ // ── args ────────────────────────────────────────────────────────────────────────
32
+ const argv = process.argv.slice(2);
33
+ const cmd = argv[0] && !argv[0].startsWith('-') ? argv[0] : null;
34
+ const rest = cmd ? argv.slice(1) : argv;
35
+ const flags = {};
36
+ const positional = [];
37
+ for (let i = 0; i < rest.length; i++) {
38
+ const a = rest[i];
39
+ if (a.startsWith('--')) {
40
+ const key = a.slice(2);
41
+ const next = rest[i + 1];
42
+ if (next !== undefined && !next.startsWith('--')) { flags[key] = next; i++; }
43
+ else flags[key] = true;
44
+ } else positional.push(a);
45
+ }
46
+ const JSON_OUT = flags.json === true;
47
+
48
+ // ── config ──────────────────────────────────────────────────────────────────────
49
+ function readEnvFile() {
50
+ const out = {};
51
+ try {
52
+ for (const line of fs.readFileSync(ENV_FILE, 'utf-8').split('\n')) {
53
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
54
+ if (m && !line.trim().startsWith('#')) {
55
+ let v = m[2].trim();
56
+ // Unwrap shell quoting (we write single-quoted; tolerate double too).
57
+ if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) {
58
+ v = v.slice(1, -1).replace(/'\\''/g, "'");
59
+ } else if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) {
60
+ v = v.slice(1, -1);
61
+ }
62
+ out[m[1]] = v;
63
+ }
64
+ }
65
+ } catch { /* no env file yet */ }
66
+ return out;
67
+ }
68
+
69
+ function resolveConfig() {
70
+ const file = readEnvFile();
71
+ const pick = (flag, env, dflt) => flags[flag] !== undefined && flags[flag] !== true
72
+ ? flags[flag] : (process.env[env] ?? file[env] ?? dflt);
73
+ return {
74
+ url: pick('url', 'MEMHOUSE_URL', 'http://localhost:8123'),
75
+ user: pick('user', 'MEMHOUSE_USER', 'memhouse_root'),
76
+ password: pick('password', 'MEMHOUSE_PASSWORD', ''),
77
+ db: pick('db', 'MEMHOUSE_DB', 'memhouse'),
78
+ port: pick('port', 'MEMHOUSE_PORT', '4640'),
79
+ };
80
+ }
81
+
82
+ function childEnv(cfg) {
83
+ return {
84
+ ...process.env,
85
+ MEMHOUSE_URL: cfg.url, MEMHOUSE_USER: cfg.user, MEMHOUSE_PASSWORD: cfg.password,
86
+ MEMHOUSE_DB: cfg.db, MEMHOUSE_PORT: String(cfg.port), MEMHOUSE_HOME: HOME_DIR,
87
+ };
88
+ }
89
+
90
+ function writeEnvFile(cfg) {
91
+ fs.mkdirSync(HOME_DIR, { recursive: true });
92
+ // Single-quoted values: this file is also sourced by shells (skills/docs use
93
+ // `. ~/.memhouse/env`), so metacharacters in a password must never be bare.
94
+ const sq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
95
+ const body = [
96
+ '# mem-house connection — written by `memhouse install/setup`',
97
+ `MEMHOUSE_URL=${sq(cfg.url)}`,
98
+ `MEMHOUSE_USER=${sq(cfg.user)}`,
99
+ `MEMHOUSE_PASSWORD=${sq(cfg.password)}`,
100
+ `MEMHOUSE_DB=${sq(cfg.db)}`,
101
+ `MEMHOUSE_PORT=${sq(cfg.port)}`,
102
+ '',
103
+ ].join('\n');
104
+ fs.writeFileSync(ENV_FILE, body, { mode: 0o600 });
105
+ }
106
+
107
+ // ── ClickHouse over HTTP (small read-only queries; heavy ops go via ship.js) ───
108
+ async function ch(cfg, sql, { database = cfg.db } = {}) {
109
+ const params = new URLSearchParams({ final: '1' });
110
+ if (database) params.set('database', database);
111
+ const res = await fetch(`${cfg.url.replace(/\/$/, '')}/?${params}`, {
112
+ method: 'POST',
113
+ body: sql,
114
+ headers: { Authorization: 'Basic ' + Buffer.from(`${cfg.user}:${cfg.password}`).toString('base64') },
115
+ signal: AbortSignal.timeout(30000),
116
+ });
117
+ const text = await res.text();
118
+ if (!res.ok) throw new Error(text.trim().split('\n')[0]);
119
+ return text.trim();
120
+ }
121
+ async function chRows(cfg, sql, opts) {
122
+ const text = await ch(cfg, sql + ' FORMAT JSONEachRow', opts);
123
+ return text ? text.split('\n').map((l) => JSON.parse(l)) : [];
124
+ }
125
+
126
+ // ── small helpers ───────────────────────────────────────────────────────────────
127
+ const ok = (s) => ` \x1b[32m✓\x1b[0m ${s}`;
128
+ const bad = (s) => ` \x1b[31m✗\x1b[0m ${s}`;
129
+ const warn = (s) => ` \x1b[33m•\x1b[0m ${s}`;
130
+
131
+ function run(script, args, cfg, { inherit = true } = {}) {
132
+ const r = spawnSync(process.execPath, [script, ...args], {
133
+ env: childEnv(cfg), stdio: inherit ? 'inherit' : 'pipe', encoding: 'utf-8',
134
+ });
135
+ return r.status ?? 1;
136
+ }
137
+
138
+ async function ask(question, dflt) {
139
+ const rl = require('node:readline/promises').createInterface({ input: process.stdin, output: process.stdout });
140
+ const a = (await rl.question(dflt !== undefined ? `${question} [${dflt}]: ` : `${question}: `)).trim();
141
+ rl.close();
142
+ return a || dflt || '';
143
+ }
144
+
145
+ function pidOf(name) {
146
+ try {
147
+ const pid = parseInt(fs.readFileSync(path.join(RUN_DIR, name + '.pid'), 'utf-8'), 10);
148
+ process.kill(pid, 0);
149
+ return pid;
150
+ } catch { return null; }
151
+ }
152
+
153
+ function ensureUiBuilt() {
154
+ if (fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html'))) return true;
155
+ const uiDir = path.join(REPO_ROOT, 'ui');
156
+ if (!fs.existsSync(uiDir)) return false;
157
+ console.log(' building dashboard UI (first run, ~1-2 min)…');
158
+ for (const args of [['install', '--no-audit', '--no-fund'], ['run', 'build']]) {
159
+ const r = spawnSync('npm', args, { cwd: uiDir, stdio: 'pipe' });
160
+ if (r.status !== 0) { console.log(bad('UI build failed — dashboard will 503; run: cd ui && npm install && npm run build')); return false; }
161
+ }
162
+ return true;
163
+ }
164
+
165
+ // ── commands ────────────────────────────────────────────────────────────────────
166
+ const HELP = `
167
+ memhouse ${PKG.version} — agent conversation memory across 17 editors
168
+
169
+ Setup onboard interactive wizard: discover → configure → ship → start
170
+ install scriptable setup (--url --user --password --db [--yes] [--no-ship])
171
+ setup (re)write the connection config only (--yes = no prompts)
172
+ discover read-only preflight: editors, sessions, reachable ClickHouses
173
+ uninstall stop daemons + remove ${HOME_DIR.replace(os.homedir(), '~')} (house data untouched)
174
+ reset truncate the house tables and re-ship everything (--yes to skip confirm)
175
+
176
+ Data ship one incremental pass (--full | --loop [sec])
177
+ stats per-source session/message/token counts
178
+ search <terms…> full-text search across all sessions
179
+ start | stop | shipper loop + dashboard as background daemons
180
+ status daemons, connection, counts, freshness (--json)
181
+ doctor diagnose the whole pipeline
182
+
183
+ Agents plugins list | install claude [--target DIR] | remove claude
184
+ prompt print the memory system-prompt snippet
185
+
186
+ Config: flags > MEMHOUSE_* env > ${ENV_FILE.replace(os.homedir(), '~')} > defaults.
187
+ `;
188
+
189
+ // A skipped adapter and an editor the user does not have look identical — both
190
+ // contribute zero sessions. Say which happened, and how to fix the one that is fixable.
191
+ function printAdapterErrors(errors) {
192
+ if (!errors || errors.length === 0) return;
193
+ const blocked = errors.filter((e) => e.missingBinding);
194
+ const other = errors.filter((e) => !e.missingBinding);
195
+ if (blocked.length) {
196
+ console.log(warn(`${blocked.length} adapter${blocked.length > 1 ? 's' : ''} skipped — better-sqlite3 has no native binding: ${blocked.map((e) => e.source).join(', ')}`));
197
+ console.log(' their sessions are NOT being shipped. npm >= 12 blocks install scripts by default; rebuild with:');
198
+ console.log(' npm install -g memhouse --allow-scripts=better-sqlite3');
199
+ console.log(' (from a checkout: npm install --no-audit --no-fund, which package.json already allows)');
200
+ }
201
+ for (const e of other) console.log(warn(`${e.source.padEnd(16)} skipped: ${e.message}`));
202
+ }
203
+
204
+ async function cmdDiscover() {
205
+ const out = { editors: [], clickhouse: [], config: null, memoryHouse: false };
206
+ process.stdout.write(JSON_OUT ? '' : 'Scanning editors (reading local session stores)…\n');
207
+ try {
208
+ const { getAllChats, getAdapterErrors } = require(path.join(REPO_ROOT, 'editors'));
209
+ const counts = {};
210
+ for (const c of getAllChats()) counts[c.source] = (counts[c.source] || 0) + 1;
211
+ out.editors = Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([source, sessions]) => ({ source, sessions }));
212
+ out.adapterErrors = getAdapterErrors();
213
+ } catch (e) { out.editorsError = e.message; }
214
+
215
+ for (const url of [...new Set([resolveConfig().url, 'http://localhost:8123'])]) {
216
+ const probe = { url, reachable: false };
217
+ try {
218
+ const res = await fetch(url.replace(/\/$/, '') + '/ping', { signal: AbortSignal.timeout(3000) });
219
+ probe.reachable = res.ok;
220
+ if (res.ok) {
221
+ try {
222
+ const cfg = { ...resolveConfig(), url };
223
+ probe.version = await ch(cfg, 'SELECT version()', { database: '' });
224
+ const dbs = (await chRows(cfg, 'SELECT name FROM system.databases', { database: '' })).map((r) => r.name);
225
+ probe.hasHouse = dbs.includes(cfg.db);
226
+ probe.kernel = dbs.includes('sys');
227
+ } catch { probe.auth = 'needed'; }
228
+ }
229
+ } catch { /* unreachable */ }
230
+ out.clickhouse.push(probe);
231
+ }
232
+ out.config = fs.existsSync(ENV_FILE) ? ENV_FILE : null;
233
+ out.memoryHouse = fs.existsSync(path.join(os.homedir(), '.config', 'memory-house'));
234
+
235
+ if (JSON_OUT) return console.log(JSON.stringify(out, null, 2));
236
+ console.log('\nEditors with sessions on this machine:');
237
+ if (out.editors.length === 0) console.log(warn('none found' + (out.editorsError ? ` (${out.editorsError})` : '')));
238
+ for (const e of out.editors) console.log(ok(`${e.source.padEnd(16)} ${e.sessions} sessions`));
239
+ printAdapterErrors(out.adapterErrors);
240
+ console.log('\nClickHouse endpoints:');
241
+ for (const p of out.clickhouse) {
242
+ if (!p.reachable) console.log(bad(`${p.url} — unreachable`));
243
+ else if (p.auth) console.log(warn(`${p.url} — reachable, credentials needed`));
244
+ else console.log(ok(`${p.url} — v${p.version}${p.hasHouse ? ', house present' : ''}${p.kernel ? ', KERNEL detected' : ''}`));
245
+ }
246
+ console.log('');
247
+ console.log(out.config ? ok(`config: ${out.config}`) : warn('no config yet — run: memhouse install (or onboard)'));
248
+ if (out.memoryHouse) console.log(warn('memory-house detected on this machine (they coexist fine)'));
249
+ }
250
+
251
+ async function cmdInstall({ interactive }) {
252
+ let cfg = resolveConfig();
253
+ const haveAll = flags.yes === true || (flags.url && flags.user !== undefined);
254
+ if (interactive || !haveAll) {
255
+ console.log('mem-house connection (Enter keeps the default):');
256
+ cfg.url = await ask(' ClickHouse URL', cfg.url);
257
+ cfg.user = await ask(' user', cfg.user);
258
+ cfg.password = await ask(' password', cfg.password);
259
+ cfg.db = await ask(' database (the house)', cfg.db);
260
+ cfg.port = await ask(' dashboard port', cfg.port);
261
+ }
262
+ if (!/^[A-Za-z0-9_]{1,64}$/.test(cfg.db)) {
263
+ console.log(bad(`invalid database name '${cfg.db}' — use letters, digits, underscore`));
264
+ return 1;
265
+ }
266
+ writeEnvFile(cfg);
267
+ console.log(ok(`config written: ${ENV_FILE}`));
268
+ // Preflight WITHOUT selecting the house — on a fresh standalone ClickHouse the
269
+ // database doesn't exist yet, and selecting it would fail before we can create it.
270
+ try { await ch(cfg, 'SELECT 1', { database: '' }); }
271
+ catch (e) { console.log(bad(`connection failed: ${e.message}`)); console.log(' fix the connection, then re-run: memhouse install'); return 1; }
272
+ console.log(ok('connection verified'));
273
+ // Ensure the house exists (standalone path). On a kernel realm the house is
274
+ // provisioned by install-agency and this is a no-op; if the user lacks CREATE
275
+ // DATABASE but the house is already reachable, that's fine too.
276
+ try { await ch(cfg, `CREATE DATABASE IF NOT EXISTS ${cfg.db}`, { database: '' }); }
277
+ catch (e) {
278
+ try { await ch(cfg, 'SELECT 1'); }
279
+ catch { console.log(bad(`house '${cfg.db}' does not exist and cannot be created: ${e.message}`)); return 1; }
280
+ }
281
+ console.log(ok(`house '${cfg.db}' ready`));
282
+ if (run(SHIP_JS, ['--ensure-schema'], cfg) !== 0) return 1;
283
+ if (flags['no-ship'] !== true) {
284
+ if (run(SHIP_JS, [], cfg) !== 0) return 1;
285
+ }
286
+ console.log(ok('installed — next: memhouse start (dashboard + shipper loop)'));
287
+ return 0;
288
+ }
289
+
290
+ async function cmdOnboard() {
291
+ console.log(`memhouse ${PKG.version} — onboarding\n`);
292
+ await cmdDiscover();
293
+ console.log('');
294
+ const code = await cmdInstall({ interactive: true });
295
+ if (code !== 0) return code;
296
+ const yn = (await ask('Start the daemons now? (Y/n)', 'Y')).toLowerCase();
297
+ if (yn !== 'n' && yn !== 'no') await cmdStart();
298
+ return 0;
299
+ }
300
+
301
+ async function cmdStart() {
302
+ const cfg = resolveConfig();
303
+ fs.mkdirSync(RUN_DIR, { recursive: true });
304
+ fs.mkdirSync(LOG_DIR, { recursive: true });
305
+ ensureUiBuilt();
306
+ const daemons = [
307
+ { name: 'shipper', script: SHIP_JS, args: ['--loop', String(flags.interval || 300)] },
308
+ { name: 'dashboard', script: SERVER_JS, args: [] },
309
+ ];
310
+ for (const d of daemons) {
311
+ if (pidOf(d.name)) { console.log(warn(`${d.name} already running (pid ${pidOf(d.name)})`)); continue; }
312
+ const log = fs.openSync(path.join(LOG_DIR, d.name + '.log'), 'a');
313
+ const child = spawn(process.execPath, [d.script, ...d.args], {
314
+ env: childEnv(cfg), detached: true, stdio: ['ignore', log, log],
315
+ });
316
+ fs.writeFileSync(path.join(RUN_DIR, d.name + '.pid'), String(child.pid));
317
+ child.unref();
318
+ console.log(ok(`${d.name} started (pid ${child.pid})`));
319
+ }
320
+ console.log(` dashboard → http://localhost:${cfg.port}`);
321
+ }
322
+
323
+ function cmdStop() {
324
+ let stopped = 0;
325
+ for (const name of ['shipper', 'dashboard']) {
326
+ const pid = pidOf(name);
327
+ if (pid) { try { process.kill(pid, 'SIGTERM'); console.log(ok(`${name} stopped (pid ${pid})`)); stopped++; } catch { /* raced */ } }
328
+ try { fs.unlinkSync(path.join(RUN_DIR, name + '.pid')); } catch { /* absent */ }
329
+ }
330
+ if (!stopped) console.log(warn('nothing was running'));
331
+ }
332
+
333
+ async function cmdStatus() {
334
+ const cfg = resolveConfig();
335
+ const out = {
336
+ config: fs.existsSync(ENV_FILE) ? ENV_FILE : null,
337
+ url: cfg.url, db: cfg.db, user: cfg.user,
338
+ daemons: { shipper: pidOf('shipper'), dashboard: pidOf('dashboard') },
339
+ connected: false,
340
+ };
341
+ try {
342
+ out.connected = true && !!(await ch(cfg, 'SELECT 1'));
343
+ const s = await chRows(cfg, 'SELECT count() AS sessions FROM sessions_v');
344
+ const m = await chRows(cfg, "SELECT count() AS msgs, formatDateTime(max(ingested_at), '%Y-%m-%d %H:%i:%S') AS freshest FROM messages");
345
+ out.sessions = Number(s[0]?.sessions || 0);
346
+ out.messages = Number(m[0]?.msgs || 0);
347
+ out.freshest = m[0]?.freshest || null;
348
+ } catch (e) { out.connected = false; out.error = e.message; }
349
+
350
+ if (JSON_OUT) return console.log(JSON.stringify(out, null, 2));
351
+ console.log(out.config ? ok(`config: ${out.config}`) : warn('no config (memhouse install)'));
352
+ console.log(out.connected ? ok(`connected: ${cfg.url} / ${cfg.db} as ${cfg.user}`) : bad(`not connected: ${out.error || cfg.url}`));
353
+ if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${out.freshest} UTC)`));
354
+ for (const [name, pid] of Object.entries(out.daemons)) {
355
+ console.log(pid ? ok(`${name}: running (pid ${pid})`) : warn(`${name}: not running`));
356
+ }
357
+ if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${cfg.port}`);
358
+ }
359
+
360
+ async function cmdDoctor() {
361
+ const cfg = resolveConfig();
362
+ const checks = [];
363
+ const add = (okFlag, label, hint) => checks.push({ ok: okFlag, label, hint });
364
+
365
+ const [major, minor] = process.versions.node.split('.').map(Number);
366
+ add(major > 20 || (major === 20 && minor >= 19), `node ${process.versions.node}`, 'need >= 20.19');
367
+ add(fs.existsSync(ENV_FILE), `config ${ENV_FILE}`, 'run: memhouse install');
368
+ try { await ch(cfg, 'SELECT 1', { database: '' }); add(true, `clickhouse reachable (${cfg.url})`); }
369
+ catch (e) { add(false, `clickhouse reachable (${cfg.url})`, e.message); }
370
+ try {
371
+ const t = (await chRows(cfg, "SELECT name FROM system.tables WHERE database = {db:String} AND name IN ('sessions','messages','tool_calls')".replace('{db:String}', `'${cfg.db}'`), { database: '' })).length;
372
+ add(t === 3, `schema: ${t}/3 tables in '${cfg.db}'`, 'run: memhouse install (ensure-schema)');
373
+ } catch (e) { add(false, 'schema check', e.message); }
374
+ try {
375
+ const u = await chRows(cfg, 'SELECT any(user_id) AS u FROM sessions');
376
+ add((u[0]?.u ?? '') !== '' || (await chRows(cfg, 'SELECT count() AS c FROM sessions'))[0].c === 0,
377
+ `identity stamping (user_id='${u[0]?.u ?? ''}')`, 'writers must use async_insert=0');
378
+ } catch { add(false, 'identity stamping', 'schema missing?'); }
379
+ let adapterErrors = [];
380
+ try {
381
+ const { getAllChats, getAdapterErrors, getMessages } = require(path.join(REPO_ROOT, 'editors'));
382
+ const chats = getAllChats();
383
+ const seen = chats.length;
384
+ // Listing sessions is not the same as being able to read them. Goose, for one,
385
+ // can query `sessions` while its `messages` rows use a schema this parser cannot
386
+ // decode — the shipper would then withhold every one of those sessions while
387
+ // doctor reported a clean bill of health. Read the newest session per source so
388
+ // the message readers actually run; that is one parse per editor, not a full scan.
389
+ // One chat per source is not enough coverage: several adapters mix storage
390
+ // paths, and a probe only exercises the one the sampled chat happens to use.
391
+ // Each such adapter declares which store a chat came from — Cursor as _type
392
+ // (agent-store vs workspace), Goose as _storage (sqlite vs jsonl), OpenCode as
393
+ // _storageType (file vs sqlite) — so probing one chat per declared variant
394
+ // covers every path deterministically.
395
+ //
396
+ // The newest/middle/oldest spread is kept on top of that as a cheap hedge for
397
+ // any split an adapter does NOT declare.
398
+ const picks = new Map();
399
+ const bySource = new Map();
400
+ for (const chat of chats) {
401
+ if (!bySource.has(chat.source)) bySource.set(chat.source, []);
402
+ bySource.get(chat.source).push(chat);
403
+ }
404
+ for (const [source, list] of bySource) {
405
+ for (const i of new Set([0, Math.floor(list.length / 2), list.length - 1])) {
406
+ picks.set(`${source}#${i}`, list[i]);
407
+ }
408
+ for (const chat of list) {
409
+ const variant = `${source}|${chat._type || ''}|${chat._storage || ''}|${chat._storageType || ''}`;
410
+ if (!picks.has(variant)) picks.set(variant, chat);
411
+ }
412
+ }
413
+
414
+ const thrown = [];
415
+ for (const chat of picks.values()) {
416
+ // Not every reader reports through the sink — some throw instead, e.g.
417
+ // Cursor's agent-store path, where discovery reads `meta` fine but
418
+ // collectStoreMessages() can throw on a corrupt `blobs` table. Swallowing
419
+ // that here would discard the only signal and leave doctor green while the
420
+ // shipper withholds every one of those sessions.
421
+ try { getMessages(chat); }
422
+ catch (e) { thrown.push({ source: chat.source, message: ((e && e.message) || String(e)).split('\n')[0] }); }
423
+ }
424
+ adapterErrors = getAdapterErrors();
425
+ for (const t of thrown) {
426
+ if (!adapterErrors.some((e) => e.source === t.source)) adapterErrors.push({ ...t, missingBinding: false });
427
+ }
428
+ // Any adapter that could not be read is a failure, whatever the cause — a
429
+ // locked or corrupt store loses just as many sessions as a missing binding.
430
+ const blocked = adapterErrors.filter((e) => e.missingBinding).map((e) => e.source);
431
+ const failed = adapterErrors.map((e) => e.source);
432
+ add(adapterErrors.length === 0,
433
+ `adapters: ${seen} sessions visible locally${failed.length ? ` (${failed.length} skipped: ${failed.join(', ')})` : ''}`,
434
+ blocked.length ? 'npm install -g memhouse --allow-scripts=better-sqlite3'
435
+ : adapterErrors.length ? adapterErrors.map((e) => `${e.source}: ${e.message}`).join('; ')
436
+ : undefined);
437
+ } catch (e) { add(false, 'adapters', e.message); }
438
+ add(!!pidOf('shipper'), 'shipper daemon', 'memhouse start');
439
+ add(!!pidOf('dashboard'), 'dashboard daemon', 'memhouse start');
440
+ add(fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html')), 'dashboard UI built', 'built automatically by memhouse start');
441
+
442
+ for (const c of checks) console.log(c.ok ? ok(c.label) : bad(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`));
443
+ return checks.every((c) => c.ok) ? 0 : 1;
444
+ }
445
+
446
+ async function cmdSearch() {
447
+ if (!positional.length) { console.log('usage: memhouse search <terms…>'); return 2; }
448
+ const cfg = resolveConfig();
449
+ const needle = positional.join(' ').toLowerCase().replace(/[%_\\]/g, '\\$&').replace(/'/g, "\\'");
450
+ const rows = await chRows(cfg, `
451
+ SELECT session_id, any(source) AS source, any(project) AS project,
452
+ formatDateTime(max(ts), '%Y-%m-%d %H:%i') AS at, count() AS hits,
453
+ substring(any(text), 1, 150) AS snippet
454
+ FROM messages
455
+ WHERE text_ngram LIKE '%${needle}%'
456
+ GROUP BY session_id ORDER BY max(ts) DESC LIMIT ${Number(flags.limit) || 10}`);
457
+ if (JSON_OUT) return console.log(JSON.stringify(rows, null, 2));
458
+ if (!rows.length) return console.log(warn('no matches'));
459
+ for (const r of rows) {
460
+ console.log(`\x1b[1m${r.session_id.slice(0, 8)}\x1b[0m ${r.source} ${r.project || '-'} ${r.at} (${r.hits} hits)`);
461
+ console.log(` ${r.snippet.replace(/\s+/g, ' ')}`);
462
+ }
463
+ }
464
+
465
+ function cmdPlugins() {
466
+ const sub = positional[0] || 'list';
467
+ const target = flags.target || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
468
+ const skillsSrc = path.join(DELIVERY, 'skills');
469
+ const names = fs.readdirSync(skillsSrc);
470
+ const dstSkills = path.join(target, 'skills');
471
+ if (sub === 'list') {
472
+ console.log(`skills available: ${names.join(', ')}`);
473
+ for (const n of names) {
474
+ console.log(fs.existsSync(path.join(dstSkills, n)) ? ok(`${n} installed in ${dstSkills}`) : warn(`${n} not installed (memhouse plugins install claude)`));
475
+ }
476
+ } else if (sub === 'install' && positional[1] === 'claude') {
477
+ fs.mkdirSync(dstSkills, { recursive: true });
478
+ for (const n of names) fs.cpSync(path.join(skillsSrc, n), path.join(dstSkills, n), { recursive: true });
479
+ console.log(ok(`installed ${names.length} skills into ${dstSkills} (${names.join(', ')})`));
480
+ } else if (sub === 'remove' && positional[1] === 'claude') {
481
+ let removed = 0;
482
+ for (const n of names) { const p = path.join(dstSkills, n); if (fs.existsSync(p)) { fs.rmSync(p, { recursive: true }); removed++; } }
483
+ console.log(ok(`removed ${removed} skills from ${dstSkills}`));
484
+ } else {
485
+ console.log('usage: memhouse plugins [list | install claude | remove claude] [--target DIR]');
486
+ return 2;
487
+ }
488
+ return 0;
489
+ }
490
+
491
+ async function cmdReset() {
492
+ const cfg = resolveConfig();
493
+ if (flags.yes !== true) {
494
+ const a = (await ask(`This truncates ALL rows in '${cfg.db}' and re-ships. Continue? (yes/no)`, 'no')).toLowerCase();
495
+ if (a !== 'yes' && a !== 'y') return console.log('aborted'), 1;
496
+ }
497
+ for (const t of ['sessions', 'messages', 'tool_calls']) await ch(cfg, `TRUNCATE TABLE IF EXISTS ${t}`);
498
+ console.log(ok('house truncated'));
499
+ return run(SHIP_JS, ['--full'], cfg);
500
+ }
501
+
502
+ function cmdUninstall() {
503
+ cmdStop();
504
+ if (fs.existsSync(HOME_DIR)) fs.rmSync(HOME_DIR, { recursive: true });
505
+ console.log(ok(`removed ${HOME_DIR} (the house data in ClickHouse is untouched)`));
506
+ }
507
+
508
+ // ── dispatch ────────────────────────────────────────────────────────────────────
509
+ (async () => {
510
+ const cfg = resolveConfig();
511
+ switch (cmd) {
512
+ case null: case 'help': console.log(HELP); break;
513
+ case 'version': console.log(PKG.version); break;
514
+ case 'discover': await cmdDiscover(); break;
515
+ case 'onboard': process.exitCode = await cmdOnboard(); break;
516
+ case 'install': process.exitCode = await cmdInstall({ interactive: false }); break;
517
+ case 'setup': {
518
+ // Dual-mode like install: --yes (with any --url/--user/--password/--db/
519
+ // --port overrides, already resolved into cfg) skips every prompt so
520
+ // agents/CI can rewrite the config non-interactively.
521
+ const c = { ...cfg };
522
+ if (flags.yes !== true) {
523
+ c.url = await ask(' ClickHouse URL', c.url);
524
+ c.user = await ask(' user', c.user);
525
+ c.password = await ask(' password', c.password);
526
+ c.db = await ask(' database (the house)', c.db);
527
+ c.port = await ask(' dashboard port', c.port);
528
+ }
529
+ if (!/^[A-Za-z0-9_]{1,64}$/.test(c.db)) {
530
+ console.log(bad(`invalid database name '${c.db}' — use letters, digits, underscore`));
531
+ process.exitCode = 1;
532
+ break;
533
+ }
534
+ writeEnvFile(c);
535
+ console.log(ok(`config written: ${ENV_FILE}`));
536
+ break;
537
+ }
538
+ case 'ship': {
539
+ const args = [];
540
+ if (flags.full === true) args.push('--full');
541
+ if (flags.loop !== undefined) { args.push('--loop'); if (flags.loop !== true) args.push(String(flags.loop)); }
542
+ process.exitCode = run(SHIP_JS, args, cfg);
543
+ break;
544
+ }
545
+ case 'stats': process.exitCode = run(SHIP_JS, ['--stats'], cfg); break;
546
+ case 'start': await cmdStart(); break;
547
+ case 'stop': cmdStop(); break;
548
+ case 'status': await cmdStatus(); break;
549
+ case 'doctor': process.exitCode = await cmdDoctor(); break;
550
+ case 'search': process.exitCode = await cmdSearch(); break;
551
+ case 'plugins': process.exitCode = cmdPlugins(); break;
552
+ case 'prompt': process.stdout.write(fs.readFileSync(path.join(DELIVERY, 'PROMPT.md'), 'utf-8')); break;
553
+ case 'reset': process.exitCode = await cmdReset(); break;
554
+ case 'uninstall': cmdUninstall(); break;
555
+ default:
556
+ console.error(`unknown command: ${cmd}\n${HELP}`);
557
+ process.exitCode = 2;
558
+ }
559
+ })().catch((e) => { console.error(bad(e.message)); process.exit(1); });