memhouse 0.4.3 → 0.7.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 (64) hide show
  1. package/README.md +244 -114
  2. package/bin/memhouse.js +1169 -233
  3. package/editors/base.js +6 -0
  4. package/editors/claude.js +44 -9
  5. package/editors/goose.js +4 -0
  6. package/editors/index.js +0 -0
  7. package/editors/opencode.js +3 -0
  8. package/{mem-house → memhouse}/COMPETITION.md +5 -5
  9. package/{mem-house → memhouse}/DESIGN.md +18 -12
  10. package/memhouse/RESUME-AND-UPDATE.md +185 -0
  11. package/{mem-house → memhouse}/delivery/AGENT-INSTALL-PROMPT.md +1 -1
  12. package/{mem-house → memhouse}/delivery/AGENT-INSTALL.md +9 -7
  13. package/{mem-house → memhouse}/delivery/PROMPT.md +8 -8
  14. package/{mem-house → memhouse}/delivery/kernel-install.md +3 -3
  15. package/{mem-house → memhouse}/delivery/plugin/.claude-plugin/plugin.json +1 -1
  16. package/{mem-house/delivery/skills/memhouse-search → memhouse/delivery/plugin/skills/search}/SKILL.md +60 -18
  17. package/memhouse/delivery/plugin/skills/sessions/SKILL.md +177 -0
  18. package/memhouse/delivery/plugin/skills/sql/SKILL.md +206 -0
  19. package/memhouse/host.js +101 -0
  20. package/{mem-house → memhouse}/per-member/INSTALL.md +59 -14
  21. package/{mem-house → memhouse}/per-member/SCHEMA.md +47 -0
  22. package/memhouse/per-member/provision.js +265 -0
  23. package/memhouse/per-member/rooms.js +235 -0
  24. package/memhouse/per-member/schema-member.sql.tpl +125 -0
  25. package/{mem-house → memhouse}/per-member/schema-merge.sql.tpl +3 -3
  26. package/memhouse/resume.js +150 -0
  27. package/memhouse/self-update.js +150 -0
  28. package/{mem-house → memhouse}/server/queries.js +80 -34
  29. package/memhouse/server/server.js +451 -0
  30. package/{mem-house → memhouse}/service.js +48 -29
  31. package/memhouse/shipper/ship.js +949 -0
  32. package/package.json +5 -20
  33. package/public/assets/{index-CtH-5i95.js → index-CnWzrjF3.js} +10 -7
  34. package/public/index.html +2 -2
  35. package/ui/index.html +1 -1
  36. package/ui/src/App.jsx +1 -1
  37. package/ui/src/lib/api.js +29 -13
  38. package/ui/src/pages/Dashboard.jsx +8 -5
  39. package/ui/src/pages/Sessions.jsx +3 -1
  40. package/ui/src/pages/SqlViewer.jsx +6 -23
  41. package/cache.js +0 -850
  42. package/ch-ingest.js +0 -329
  43. package/ch-schema.js +0 -152
  44. package/clickhouse.js +0 -87
  45. package/index.js +0 -425
  46. package/mcp-server.js +0 -294
  47. package/mem-house/delivery/install.sh +0 -183
  48. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +0 -125
  49. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +0 -146
  50. package/mem-house/per-member/provision.js +0 -142
  51. package/mem-house/per-member/rooms.js +0 -132
  52. package/mem-house/per-member/schema-member.sql.tpl +0 -85
  53. package/mem-house/server/server.js +0 -228
  54. package/mem-house/shipper/ship.js +0 -504
  55. package/relay-client.js +0 -306
  56. package/relay-server.js +0 -577
  57. package/server.js +0 -860
  58. package/share-image.js +0 -367
  59. /package/{mem-house → memhouse}/deploy.js +0 -0
  60. /package/{mem-house → memhouse}/envfile.js +0 -0
  61. /package/{mem-house → memhouse}/per-member/PLAN.md +0 -0
  62. /package/{mem-house → memhouse}/per-member/PROVISIONING.md +0 -0
  63. /package/{mem-house → memhouse}/per-member/README.md +0 -0
  64. /package/{mem-house → memhouse}/per-member/SHARING.md +0 -0
@@ -0,0 +1,451 @@
1
+ // memhouse server — the dashboard/API entrypoint.
2
+ //
3
+ // Speaks the agentlytics REST contract (same routes, same response shapes) over the
4
+ // memhouse typed schema, so the UNMODIFIED agentlytics React SPA is the dashboard:
5
+ // queries.js computes every response; this file is routing + config + stubs. Serves
6
+ // the built SPA from the repo root's public/.
7
+ //
8
+ // node memhouse/server/server.js (env: MEMHOUSE_* per DESIGN.md; port 4640)
9
+
10
+ const express = require('express');
11
+ const path = require('path');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const qy = require('./queries');
15
+
16
+ const PORT = parseInt(process.env.MEMHOUSE_PORT || '4640', 10);
17
+ // Localhost by default: the dashboard exposes full transcripts and a SQL console
18
+ // with no auth. Remote access is an explicit opt-in (MEMHOUSE_HOST=0.0.0.0).
19
+ const HOST = process.env.MEMHOUSE_HOST || '127.0.0.1';
20
+ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
21
+ // MEMHOUSE_HOME, like every other component. childEnv() already passes it to this child;
22
+ // this was the one place that ignored it and wrote to the real home instead. Anyone
23
+ // running a second house under MEMHOUSE_HOME — the documented way to test, and what every
24
+ // acceptance run is told to do — hid a project in their throwaway dashboard and had it
25
+ // written into the PILOT'S ~/.memhouse/config.json.
26
+ const CONFIG_PATH = path.join(process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse'), 'config.json');
27
+
28
+ const app = express();
29
+ app.use(express.json());
30
+ if (fs.existsSync(PUBLIC_DIR)) app.use(express.static(PUBLIC_DIR));
31
+
32
+ function readConfig() {
33
+ try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return {}; }
34
+ }
35
+ function writeConfig(config) {
36
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
37
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
38
+ }
39
+ // Both keys. `hiddenFolders` was accepted by PUT /api/config, validated, written to disk
40
+ // — and never read, so setting it changed nothing and said nothing.
41
+ const hiddenFolders = () => [...(readConfig().hiddenProjects || []), ...(readConfig().hiddenFolders || [])] || [];
42
+
43
+ function parseDateOpts(query) {
44
+ const opts = {};
45
+ if (query.dateFrom) opts.dateFrom = parseInt(query.dateFrom) || null;
46
+ if (query.dateTo) opts.dateTo = parseInt(query.dateTo) || null;
47
+ return opts;
48
+ }
49
+
50
+ // Route helper: async handler with the uniform 500 shape.
51
+ const route = (fn) => async (req, res) => {
52
+ try { await fn(req, res); } catch (err) { res.status(500).json({ error: err.message }); }
53
+ };
54
+
55
+ app.get('/api/ping', (req, res) => res.json({ app: 'agentlytics', pid: process.pid }));
56
+ app.get('/api/mode', (req, res) => res.json({ mode: 'local' }));
57
+
58
+ app.get('/api/overview', route(async (req, res) => {
59
+ res.json(await qy.getOverview({ editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
60
+ }));
61
+
62
+ app.get('/api/daily-activity', route(async (req, res) => {
63
+ res.json(await qy.getDailyActivity({ editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
64
+ }));
65
+
66
+ app.get('/api/dashboard-stats', route(async (req, res) => {
67
+ res.json(await qy.getDashboardStats({ editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
68
+ }));
69
+
70
+ app.get('/api/chats', route(async (req, res) => {
71
+ const opts = {
72
+ editor: req.query.editor || null,
73
+ folder: req.query.folder || null,
74
+ named: req.query.named !== 'false',
75
+ // Clamped, not passed through. `offset=-5` reached ClickHouse as a UInt64 parameter
76
+ // and came back as HTTP 500 carrying "Value -5 cannot be parsed as UInt64 … only 0 of
77
+ // 2 bytes was parsed" — a server error and a database internal for a client mistake.
78
+ // `limit=abc` was silently ignored and `limit=99999999999999999999` returned everything.
79
+ limit: clampInt(req.query.limit, 200, 1, 5000),
80
+ offset: clampInt(req.query.offset, 0, 0, Number.MAX_SAFE_INTEGER),
81
+ ...parseDateOpts(req.query),
82
+ hiddenFolders: hiddenFolders(),
83
+ };
84
+ const [total, chats] = await Promise.all([qy.countChats(opts), qy.getChats(opts)]);
85
+ res.json({ total, chats });
86
+ }));
87
+
88
+ app.get('/api/chats/:id', route(async (req, res) => {
89
+ const result = await qy.getChat(req.params.id);
90
+ if (!result) return res.status(404).json({ error: 'Chat not found' });
91
+ res.json(result);
92
+ }));
93
+
94
+ app.get('/api/chats/:id/markdown', route(async (req, res) => {
95
+ const result = await qy.getChat(req.params.id);
96
+ if (!result) return res.status(404).json({ error: 'Chat not found' });
97
+ const lines = [];
98
+ const title = result.name || 'Untitled Session';
99
+ lines.push(`# ${title}\n`);
100
+ const meta = [];
101
+ if (result.source) meta.push(`**Editor:** ${result.source}`);
102
+ if (result.mode) meta.push(`**Mode:** ${result.mode}`);
103
+ if (result.folder) meta.push(`**Project:** ${result.folder}`);
104
+ if (result.createdAt) meta.push(`**Created:** ${new Date(result.createdAt).toISOString()}`);
105
+ if (result.lastUpdatedAt) meta.push(`**Updated:** ${new Date(result.lastUpdatedAt).toISOString()}`);
106
+ if (result.stats) {
107
+ meta.push(`**Messages:** ${result.stats.totalMessages}`);
108
+ if (result.stats.totalInputTokens) meta.push(`**Input Tokens:** ${result.stats.totalInputTokens}`);
109
+ if (result.stats.totalOutputTokens) meta.push(`**Output Tokens:** ${result.stats.totalOutputTokens}`);
110
+ const models = [...new Set(result.stats.models || [])];
111
+ if (models.length > 0) meta.push(`**Models:** ${models.join(', ')}`);
112
+ }
113
+ if (meta.length > 0) lines.push(meta.join(' \n') + '\n');
114
+ lines.push('---\n');
115
+ for (const msg of result.messages) {
116
+ const label = msg.role === 'user' ? '## User' : msg.role === 'assistant' ? '## Assistant' : `## ${msg.role.charAt(0).toUpperCase() + msg.role.slice(1)}`;
117
+ const modelTag = msg.model ? ` *(${msg.model})*` : '';
118
+ lines.push(`${label}${modelTag}\n`);
119
+ lines.push(msg.content + '\n');
120
+ }
121
+ const filename = title.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 80) + '.md';
122
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
123
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
124
+ res.send(lines.join('\n'));
125
+ }));
126
+
127
+ app.get('/api/projects', route(async (req, res) => {
128
+ res.json(await qy.getProjects({ ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
129
+ }));
130
+
131
+ app.get('/api/all-projects', route(async (req, res) => {
132
+ res.json(await qy.getProjects({ ...parseDateOpts(req.query), includeHidden: true }));
133
+ }));
134
+
135
+ app.get('/api/deep-analytics', route(async (req, res) => {
136
+ res.json(await qy.getDeepAnalytics({
137
+ editor: req.query.editor || null,
138
+ folder: req.query.folder || null,
139
+ limit: clampInt(req.query.limit, 500, 1, 5000),
140
+ ...parseDateOpts(req.query),
141
+ hiddenFolders: hiddenFolders(),
142
+ }));
143
+ }));
144
+
145
+ app.get('/api/costs', route(async (req, res) => {
146
+ res.json(await qy.estimateCosts({
147
+ editor: req.query.editor || null,
148
+ folder: req.query.folder || null,
149
+ chatId: req.query.chatId || null,
150
+ ...parseDateOpts(req.query),
151
+ hiddenFolders: hiddenFolders(),
152
+ }));
153
+ }));
154
+
155
+ app.get('/api/cost-analytics', route(async (req, res) => {
156
+ res.json(await qy.getCostAnalytics({ editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
157
+ }));
158
+
159
+ app.get('/api/tool-calls', route(async (req, res) => {
160
+ const name = req.query.name;
161
+ if (!name) return res.status(400).json({ error: 'name query param required' });
162
+ // Same filter set as the analytics the drill-down is opened from.
163
+ res.json(await qy.getToolCalls(name, {
164
+ limit: clampInt(req.query.limit, 200, 1, 1000),
165
+ folder: req.query.folder || null,
166
+ editor: req.query.editor || null,
167
+ ...parseDateOpts(req.query),
168
+ hiddenFolders: hiddenFolders(),
169
+ }));
170
+ }));
171
+
172
+ function clampInt(v, dflt, min, max) {
173
+ if (v === undefined || v === null || v === '') return dflt;
174
+ const n = Number(v);
175
+ if (!Number.isFinite(n)) return dflt;
176
+ return Math.min(Math.max(Math.trunc(n), min), max);
177
+ }
178
+
179
+ // The databases that actually exist on this server, refreshed lazily. Comparing against
180
+ // real names keeps `alias.column` — the common case — from being refused, while still
181
+ // catching a genuine cross-database read.
182
+ let KNOWN_DBS = new Set(['system']);
183
+ let knownDbsAt = 0;
184
+ async function refreshKnownDbs() {
185
+ if (Date.now() - knownDbsAt < 60000) return;
186
+ try {
187
+ const rows = await qy.rawQueryUnguarded('SELECT name FROM system.databases');
188
+ KNOWN_DBS = new Set(rows.map((r) => String(r.name).toLowerCase()));
189
+ KNOWN_DBS.add('system');
190
+ knownDbsAt = Date.now();
191
+ } catch { /* keep whatever we had */ }
192
+ }
193
+
194
+ app.post('/api/query', route(async (req, res) => {
195
+ await refreshKnownDbs();
196
+ const { sql } = req.body;
197
+ if (!sql || typeof sql !== 'string') return res.status(400).json({ error: 'sql string required' });
198
+ // ONE pass, and it has to know every construct ClickHouse does, because each one can
199
+ // contain another's opening marker:
200
+ //
201
+ // SELECT * FROM /* ' */ url('http://…') a quote inside a comment
202
+ // SELECT '--', url('http://…') a comment marker inside a string
203
+ // SELECT $d$'$d$ AS x, * FROM url('http://…') a quote inside a heredoc literal
204
+ // SELECT * FROM # '⏎ url('http://…') --' a quote inside a # comment
205
+ //
206
+ // Every one of those desyncs a scanner that does not model the construct it appears in.
207
+ // ClickHouse supports `$tag$…$tag$` heredocs and treats `#` and `#!` as line comments as
208
+ // well as `--`; the first two forms above were bypasses of the previous version.
209
+ const CLICKHOUSE_SQL = /* for the reader: single quotes are literals, double quotes and
210
+ backticks are IDENTIFIERS — the identifier text must survive, or `FROM "url"(…)` hides
211
+ the function name. */ null;
212
+ let bare = '';
213
+ for (let i = 0; i < sql.length;) {
214
+ const c = sql[i];
215
+ const two = sql.slice(i, i + 2);
216
+ const heredoc = /^\$[A-Za-z0-9_]*\$/.exec(sql.slice(i));
217
+ if (c === "'" || c === '"' || c === '`') {
218
+ const quote = c;
219
+ i++;
220
+ let inner = '';
221
+ while (i < sql.length) {
222
+ if (sql[i] === '\\') { inner += sql.slice(i, i + 2); i += 2; continue; }
223
+ if (sql[i] === quote) {
224
+ if (quote === "'" && sql[i + 1] === "'") { inner += "''"; i += 2; continue; }
225
+ i++; break;
226
+ }
227
+ inner += sql[i];
228
+ i++;
229
+ }
230
+ bare += quote === "'" ? "''" : inner;
231
+ } else if (heredoc) {
232
+ const tag = heredoc[0];
233
+ const end = sql.indexOf(tag, i + tag.length);
234
+ i = end === -1 ? sql.length : end + tag.length;
235
+ bare += "''";
236
+ } else if (two === '/*') {
237
+ const end = sql.indexOf('*/', i + 2);
238
+ i = end === -1 ? sql.length : end + 2;
239
+ bare += ' ';
240
+ } else if (two === '--' || c === '#') {
241
+ const nl = sql.indexOf('\n', i);
242
+ i = nl === -1 ? sql.length : nl;
243
+ bare += ' ';
244
+ } else {
245
+ bare += c;
246
+ i++;
247
+ }
248
+ }
249
+
250
+ const first = bare.trim().split(/\s+/)[0].toUpperCase();
251
+ if (!['SELECT', 'WITH', 'EXPLAIN', 'DESCRIBE', 'DESC', 'SHOW'].includes(first)) {
252
+ return res.status(403).json({ error: 'Only SELECT queries are allowed' });
253
+ }
254
+
255
+ // Which identifiers are in TABLE position — the only place a table function can appear.
256
+ //
257
+ // Checking every call inside a FROM clause refused ordinary read SQL: `count()` and
258
+ // `any()` in a derived table, `USING (…)`, `toString()` in an ON condition,
259
+ // `splitByChar()` in an ARRAY JOIN. That is the shape of this endpoint's own session
260
+ // rollup, on the endpoint whose job is querying transcripts. A table expression follows
261
+ // FROM, JOIN or a comma DIRECTLY, at the same paren depth — anything nested inside
262
+ // parentheses is a subquery or an argument, not a table function introduced here.
263
+ //
264
+ // DESCRIBE takes a table expression too, with no FROM at all: `DESCRIBE url('http://…')`
265
+ // performs schema inference, which dials out.
266
+ // `view` and `merge` are gone from this list. Both take a table expression or a database
267
+ // name as an ARGUMENT, so allowing them re-opened everything the list is for:
268
+ // `view(SELECT count() FROM system.tables)` returned 190, and
269
+ // `merge('other_db','^messages_')` returned 5,000 rows from a database this house does
270
+ // not own. The house's own team rooms are Merge TABLES, not calls to merge(), so nothing
271
+ // legitimate needs them here.
272
+ const ALLOWED_FN = new Set(['numbers', 'values', 'null', 'generateseries', 'format']);
273
+
274
+ // A table function was never the only way out. `SELECT count() FROM system.users`
275
+ // returned 2, and a database-qualified name reads any database the credential can see —
276
+ // which after `deploy --local` is all of them, because that path makes the member the
277
+ // superuser. The error text said "this endpoint reads only this house"; make that true.
278
+ const OWN_DB = (process.env.MEMHOUSE_DB || 'mem').toLowerCase();
279
+ for (const m of bare.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*[A-Za-z_][A-Za-z0-9_]*/g)) {
280
+ const db = m[1].toLowerCase();
281
+ // A bare `alias.column` is the overwhelmingly common case and is not a database
282
+ // reference; only refuse names that actually resolve to another DATABASE.
283
+ if (db === OWN_DB) continue;
284
+ if (db === 'system' || KNOWN_DBS.has(db)) {
285
+ return res.status(403).json({ error: `'${m[1]}' is another database — this endpoint reads only '${process.env.MEMHOUSE_DB || 'mem'}'` });
286
+ }
287
+ }
288
+
289
+ // Walk the normalised text once, tracking paren depth and whether the current depth is
290
+ // inside a FROM clause. A table function is an identifier-call that appears where a
291
+ // TABLE EXPRESSION goes: directly after FROM, after JOIN, or after a comma that
292
+ // separates table expressions in a FROM clause AT THE SAME DEPTH.
293
+ //
294
+ // Both qualifiers are load-bearing. Without the depth rule, `FROM (SELECT session_id,
295
+ // count() AS c FROM …)` reads the comma in the SELECT list as a table separator and
296
+ // refuses `count()`. Without excluding ARRAY JOIN, `ARRAY JOIN splitByChar(',', text)`
297
+ // matches on JOIN and refuses `splitByChar()`. Both are ordinary read SQL on the
298
+ // endpoint whose purpose is reading transcripts — and `any()` in a derived table is the
299
+ // shape of this product's own session rollup.
300
+ // ON and USING do NOT end the FROM clause — they are part of a JOIN, and a comma join
301
+ // can follow them: `FROM a JOIN b ON 1=1, file('/etc/hostname')` is ordinary SQL and
302
+ // walked straight through when they were listed here. Neither does a quoted alias that
303
+ // merely SPELLS one of these words: `FROM numbers(1) AS "WHERE", file(…)` was the same
304
+ // bypass with two characters of disguise. Only a keyword in KEYWORD POSITION ends it —
305
+ // that is, one that was not just introduced by AS.
306
+ const CLAUSE_END = /^(WHERE|PREWHERE|GROUP|ORDER|LIMIT|HAVING|SETTINGS|UNION|INTO|FORMAT|WINDOW|QUALIFY)$/i;
307
+ const tableFns = [];
308
+ const inFrom = [];
309
+ let depth = 0;
310
+ // Three tokens of history: at the '(' we need the identifier (prev), what introduced it
311
+ // (prevPrev), and what preceded THAT (prev3) — because distinguishing `JOIN f(` from
312
+ // `ARRAY JOIN f(` needs the token before the JOIN.
313
+ let prev = ''; // previous significant token, upper-cased
314
+ let prevPrev = '';
315
+ let prev3 = '';
316
+ const tok = /[A-Za-z_][A-Za-z0-9_]*|[(),]|[^\s(),]+/g;
317
+ let m;
318
+ while ((m = tok.exec(bare)) !== null) {
319
+ const raw = m[0];
320
+ const up = raw.toUpperCase();
321
+ if (raw === '(') {
322
+ // An identifier immediately before '(' is a call; decide it here, where we still
323
+ // know what preceded the identifier.
324
+ const isCall = /^[A-Za-z_][A-Za-z0-9_]*$/.test(prev === '' ? '' : bare.slice(0, m.index).match(/[A-Za-z_][A-Za-z0-9_]*\s*$/)?.[0]?.trim() || '');
325
+ if (isCall) {
326
+ const name = bare.slice(0, m.index).match(/([A-Za-z_][A-Za-z0-9_]*)\s*$/)[1];
327
+ const introducer = prevPrev;
328
+ const tablePos = (introducer === 'FROM')
329
+ || (introducer === 'JOIN' && prev3 !== 'ARRAY')
330
+ || (introducer === ',' && inFrom[depth]);
331
+ if (tablePos) tableFns.push(name);
332
+ }
333
+ depth++;
334
+ prev3 = prevPrev; prevPrev = prev; prev = raw;
335
+ continue;
336
+ }
337
+ if (raw === ')') { depth = Math.max(0, depth - 1); prev3 = prevPrev; prevPrev = prev; prev = raw; continue; }
338
+ // `prev === 'AS'` means this token is an ALIAS, whatever it spells. A quoted alias
339
+ // arrives here as its bare text (the tokenizer keeps identifier content so that
340
+ // `FROM "url"(…)` is still visible), so `AS "WHERE"` would otherwise close the clause.
341
+ const isAlias = prev === 'AS';
342
+ if (up === 'FROM' && !isAlias) inFrom[depth] = true;
343
+ else if (CLAUSE_END.test(up) && !isAlias) inFrom[depth] = false;
344
+ prev3 = prevPrev; prevPrev = prev; prev = (raw === ',') ? ',' : up;
345
+ }
346
+
347
+ // DESCRIBE takes a table expression with no FROM at all, and schema inference on
348
+ // `DESCRIBE url('http://…')` dials out.
349
+ if (['DESCRIBE', 'DESC'].includes(first)) {
350
+ const after = bare.trim().replace(/^\w+\s+/, '').replace(/^TABLE\s+/i, '');
351
+ const d = /^([A-Za-z_][A-Za-z0-9_]*)\s*\(/.exec(after);
352
+ if (d) tableFns.push(d[1]);
353
+ }
354
+
355
+ for (const name of tableFns) {
356
+ if (!ALLOWED_FN.has(name.toLowerCase())) {
357
+ return res.status(403).json({ error: `table function ${name}() is not allowed here — this endpoint reads only this house` });
358
+ }
359
+ }
360
+
361
+ try { res.json(await qy.rawQuery(sql)); }
362
+ catch (err) { res.status(400).json({ error: err.message }); }
363
+ }));
364
+
365
+ app.get('/api/schema', route(async (req, res) => res.json(await qy.schema())));
366
+
367
+ app.get('/api/config', (req, res) => res.json(readConfig()));
368
+ // Only the keys the dashboard actually owns. `Object.assign(config, req.body)` persisted
369
+ // whatever was sent — `{"pwned":"yes"}` came back 200 and stayed in the file.
370
+ const CONFIG_KEYS = { hiddenProjects: 'array', hiddenFolders: 'array' };
371
+ app.put('/api/config', route(async (req, res) => {
372
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
373
+ // Own keys only. `{"__proto__":{…}}` has no OWN key, so `Object.keys` saw an empty body,
374
+ // the unknown-key check passed, and the config was rewritten to {} with a 200.
375
+ const unknown = Object.keys(body).filter((k) => !Object.prototype.hasOwnProperty.call(CONFIG_KEYS, k));
376
+ if (Object.getPrototypeOf(body) !== Object.prototype && Object.getPrototypeOf(body) !== null) {
377
+ return res.status(400).json({ error: 'unexpected body' });
378
+ }
379
+ if (!Object.keys(body).length) return res.status(400).json({ error: 'no config keys given' });
380
+ if (unknown.length) return res.status(400).json({ error: `unknown config keys: ${unknown.join(', ')}` });
381
+ const config = readConfig();
382
+ for (const [k, kind] of Object.entries(CONFIG_KEYS)) {
383
+ if (!(k in body)) continue;
384
+ if (kind === 'array') {
385
+ if (!Array.isArray(body[k]) || body[k].some((v) => typeof v !== 'string')) {
386
+ return res.status(400).json({ error: `${k} must be an array of strings` });
387
+ }
388
+ config[k] = body[k];
389
+ }
390
+ }
391
+ writeConfig(config);
392
+ res.json(config);
393
+ }));
394
+
395
+ // Real refetch: run one full shipper pass, streaming SSE like the root contract.
396
+ // POST, not GET. A full re-ship behind a GET is the easiest request on a machine to make
397
+ // by accident — a prefetch, a link checker, a curl in a log. The dashboard calls it
398
+ // explicitly, so nothing legitimate depended on the verb.
399
+ app.post('/api/refetch', async (req, res) => {
400
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
401
+ try {
402
+ const { runShip } = require('../shipper/ship');
403
+ res.write(`data: ${JSON.stringify({ scanned: 0, analyzed: 0, skipped: 0, total: 0 })}\n\n`);
404
+ const r = await runShip(qy.getClient(), { full: true });
405
+ res.write(`data: ${JSON.stringify({ done: true, total: r.sessions + r.skipped, analyzed: r.sessions })}\n\n`);
406
+ } catch (err) {
407
+ res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
408
+ }
409
+ res.end();
410
+ });
411
+
412
+ // Not applicable to memhouse (dashboard renders these pages empty/absent).
413
+ app.get('/api/usage', (req, res) => res.json([]));
414
+ app.get('/api/artifacts', (req, res) => res.json([]));
415
+ app.get('/api/artifact-content', (req, res) => res.status(404).json({ error: 'not available in memhouse' }));
416
+ app.get('/api/mcps', (req, res) => res.json({
417
+ servers: [], toolCalls: [], matchedTools: {}, topSessions: [], projectMcps: [],
418
+ summary: { totalServers: 0, totalToolCalls: 0, uniqueTools: 0, sessionsWithTools: 0, editorsWithServers: [] },
419
+ }));
420
+ app.get('/api/gsd/projects', (req, res) => res.json([]));
421
+ app.get('/api/gsd/phases', (req, res) => res.json([]));
422
+ app.get('/api/gsd/plan', (req, res) => res.status(404).json({ error: 'not available in memhouse' }));
423
+ app.get('/api/gsd/overview', (req, res) => res.json({ totalProjects: 0, totalPhases: 0, completedPhases: 0, activePhases: [], executingPhases: 0, plannedPhases: 0 }));
424
+ app.get('/api/gsd/config', (req, res) => res.json(null));
425
+ app.get('/api/gsd/phase-tokens', (req, res) => res.json([]));
426
+ app.get('/api/gsd/file', (req, res) => res.json({ content: null }));
427
+ app.get('/api/share-image', (req, res) => res.status(501).json({ error: 'not available in memhouse' }));
428
+ app.get('/api/check-ai', (req, res) => res.status(501).json({ error: 'not available in memhouse' }));
429
+
430
+ // SPA fallback
431
+ app.get('*', (req, res) => {
432
+ const index = path.join(PUBLIC_DIR, 'index.html');
433
+ if (fs.existsSync(index)) res.sendFile(index);
434
+ else res.status(503).json({ error: 'UI not built — run: cd ui && npm install && npm run build' });
435
+ });
436
+
437
+ if (require.main === module) {
438
+ app.listen(PORT, HOST, () => {
439
+ console.log(`[memhouse] dashboard → http://localhost:${PORT} (house '${qy.config.database}' @ ${qy.config.url.replace(/\/\/.*@/, '//')})`
440
+ + (HOST !== '127.0.0.1' ? ` [bound to ${HOST} — remotely reachable]` : ''));
441
+ });
442
+ // An upgrade replaces public/ as well as the code, and this process holds neither: the
443
+ // static middleware bound to the old directory at boot, so the SPA it serves stays the
444
+ // old bundle however many times the pilot reloads. The shipper checks once per pass; a
445
+ // server has no pass, so it polls. unref() so the timer never holds the process open.
446
+ const selfUpdate = require('../self-update');
447
+ const snap = selfUpdate.snapshot(__filename);
448
+ setInterval(() => selfUpdate.maybeRestart({ snap, name: 'dashboard', log: console.log }), 60_000).unref();
449
+ }
450
+
451
+ module.exports = app;
@@ -26,13 +26,6 @@ const { execFileSync, spawnSync } = require('child_process');
26
26
  const envfile = require('./envfile');
27
27
 
28
28
  const LABEL = 'memhouse-shipper';
29
- // Units this build never writes, but earlier ones did. A machine that ran the solo tier
30
- // still has these enabled and possibly executing a script that no longer exists, so
31
- // uninstall has to reap them or it reports success over a live service. Cleanup outlives
32
- // the feature; nothing here ever installs them.
33
- const LEGACY_UNITS = [
34
- { unit: 'memhouse-solo.service', label: 'com.memhouse.solo', file: 'memhouse-solo.service', plist: 'com.memhouse.solo.plist' },
35
- ];
36
29
  // Environment an ADAPTER reads to find its sessions, as opposed to the connection
37
30
  // settings that live in the env file. Keep this in step with `editors/` — today
38
31
  // `editors/codex.js` is the only adapter with an override.
@@ -165,7 +158,21 @@ function preflight({ envFile }) {
165
158
  }
166
159
 
167
160
  /** Install the shipper as a user service. */
168
- function install({ shipJs, envFile, logDir, interval = 300, home = null }) {
161
+ // What MEMHOUSE_* environment is baked into an already-installed unit? Both formats inline
162
+ // it (see the note at the top), so this reads the file rather than asking the init system.
163
+ function readInstalledEnv(unitPath) {
164
+ const out = {};
165
+ let text = '';
166
+ try { text = fs.readFileSync(unitPath, 'utf-8'); } catch { return out; }
167
+ for (const m of text.matchAll(/^Environment=([A-Z0-9_]+)=(.*)$/gm)) out[m[1]] = m[2].replace(/^"|"$/g, '');
168
+ // launchd: <key>NAME</key><string>VALUE</string> inside EnvironmentVariables
169
+ for (const m of text.matchAll(/<key>([A-Z0-9_]+)<\/key>\s*<string>([^<]*)<\/string>/g)) {
170
+ if (!(m[1] in out)) out[m[1]] = m[2];
171
+ }
172
+ return out;
173
+ }
174
+
175
+ function install({ shipJs, envFile, logDir, interval = 300, home = null, force = false }) {
169
176
  const kind = platform();
170
177
  if (!kind) return { ok: false, msg: `no service integration for platform '${process.platform}'` };
171
178
  const p = unitPaths()[kind];
@@ -182,11 +189,36 @@ function install({ shipJs, envFile, logDir, interval = 300, home = null }) {
182
189
  // service installed under a custom home must keep using it, or it reads a different
183
190
  // config after a reboot than the one just written.
184
191
  if (home) env.MEMHOUSE_HOME = home;
192
+
193
+ // One label per user, so a SECOND install under a different MEMHOUSE_HOME would
194
+ // `bootout` the first and take the name — silently, reporting success, leaving the
195
+ // original house with no shipper and nothing to say so. Refuse instead, and name both
196
+ // sides. (Namespacing the label per home would fix it more thoroughly but orphans every
197
+ // service already installed under the current one; a refusal loses nothing.)
198
+ const existingHome = fs.existsSync(p) ? (readInstalledEnv(p).MEMHOUSE_HOME || '') : null;
199
+ const wantHome = env.MEMHOUSE_HOME || '';
200
+ if (existingHome !== null && existingHome !== wantHome && !force) {
201
+ return {
202
+ ok: false,
203
+ msg: `a memhouse service is already installed for a different config\n`
204
+ + ` installed: MEMHOUSE_HOME=${existingHome || '(default)'}\n`
205
+ + ` this run: MEMHOUSE_HOME=${wantHome || '(default)'}\n`
206
+ + ` Both would use the same service name, so installing would silently replace the\n`
207
+ + ` other one. Uninstall it first (memhouse service uninstall), or pass --force.`,
208
+ path: p,
209
+ };
210
+ }
185
211
  // Adapter location overrides travel too. A detached shipper inherits them from the
186
212
  // invoking shell through childEnv(); a service inherits nothing, so an override that
187
213
  // was working before `service install` silently stops applying — the adapter falls back
188
214
  // to its default path and just stops finding sessions, with no error anywhere.
189
215
  for (const k of ADAPTER_ENV) if (process.env[k]) env[k] = process.env[k];
216
+ // Tells the shipper it has a supervisor. On an upgrade a supervised daemon EXITS and lets
217
+ // systemd/launchd start the new version; an unsupervised one re-execs itself, because
218
+ // nothing else would. Getting this backwards is the expensive direction: a process that
219
+ // re-execs under a supervisor races the unit's own restart, and two shippers on one house
220
+ // clear each other's rows. See memhouse/self-update.js.
221
+ env.MEMHOUSE_SUPERVISED = '1';
190
222
  try { envfile.assertSingleLine(env); } catch (e) { return { ok: false, msg: e.message }; }
191
223
 
192
224
  if (kind === 'systemd') {
@@ -205,7 +237,11 @@ function install({ shipJs, envFile, logDir, interval = 300, home = null }) {
205
237
  execFileSync('systemctl', ['--user', 'restart', unit]);
206
238
  }
207
239
  const warn = lingerEnabled() ? null
208
- : `systemd --user services stop at logout. Run: loginctl enable-linger ${os.userInfo().username}`;
240
+ // Hedged, like the deploy --local warning, and for the same measured reason: whether
241
+ // a --user unit survives logout depends on logind's KillUserProcesses, which Ubuntu
242
+ // 24.04 sets to no. Stating it as a certainty on a distro where it is false costs
243
+ // credibility on everything else the command says.
244
+ : `systemd --user services MAY stop at logout — it depends on your distro (Ubuntu 24.04 keeps them). To make it moot: loginctl enable-linger ${os.userInfo().username}`;
209
245
  return { ok: true, kind, path: p, warn };
210
246
  }
211
247
 
@@ -245,16 +281,9 @@ function uninstall() {
245
281
  const stillRunning = [];
246
282
  const indeterminate = [];
247
283
 
248
- const home = os.homedir();
249
- const legacy = LEGACY_UNITS.map((l) => (kind === 'systemd'
250
- ? [l.unit, path.join(home, '.config', 'systemd', 'user', l.file), l.label]
251
- : [l.label, path.join(home, 'Library', 'LaunchAgents', l.plist), l.label]));
252
- const units = [
253
- ...(kind === 'systemd'
254
- ? [[`${LABEL}.service`, p, 'com.memhouse.shipper']]
255
- : [['com.memhouse.shipper', p, 'com.memhouse.shipper']]),
256
- ...legacy,
257
- ];
284
+ const units = kind === 'systemd'
285
+ ? [[`${LABEL}.service`, p, 'com.memhouse.shipper']]
286
+ : [['com.memhouse.shipper', p, 'com.memhouse.shipper']];
258
287
 
259
288
  for (const [unit, file, label] of units) {
260
289
  if (!fs.existsSync(file)) continue;
@@ -373,21 +402,11 @@ function status() {
373
402
  const kind = platform();
374
403
  if (!kind) return { kind: null, installed: false, running: false };
375
404
  const p = unitPaths()[kind];
376
- const home = os.homedir();
377
- // Legacy units are reported separately: `installed` must stay "this build's unit is
378
- // here", or every caller that asks it starts meaning something else. But uninstall has
379
- // to know, or a machine with ONLY a stale solo unit skips the service step entirely.
380
- const legacy = LEGACY_UNITS
381
- .map((l) => (kind === 'systemd'
382
- ? path.join(home, '.config', 'systemd', 'user', l.file)
383
- : path.join(home, 'Library', 'LaunchAgents', l.plist)))
384
- .filter((f) => fs.existsSync(f));
385
405
  return {
386
406
  kind,
387
407
  installed: fs.existsSync(p),
388
408
  running: isRunning(kind, `${LABEL}.service`, 'com.memhouse.shipper'),
389
409
  path: p,
390
- legacy,
391
410
  };
392
411
  }
393
412