memhouse 0.4.4 → 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.
- package/README.md +244 -114
- package/bin/memhouse.js +1162 -230
- package/editors/base.js +6 -0
- package/editors/claude.js +44 -9
- package/editors/goose.js +4 -0
- package/editors/index.js +0 -0
- package/editors/opencode.js +3 -0
- package/{mem-house → memhouse}/COMPETITION.md +5 -5
- package/{mem-house → memhouse}/DESIGN.md +18 -12
- package/memhouse/RESUME-AND-UPDATE.md +185 -0
- package/{mem-house → memhouse}/delivery/AGENT-INSTALL-PROMPT.md +1 -1
- package/{mem-house → memhouse}/delivery/AGENT-INSTALL.md +9 -7
- package/{mem-house → memhouse}/delivery/PROMPT.md +8 -8
- package/{mem-house → memhouse}/delivery/kernel-install.md +3 -3
- package/{mem-house → memhouse}/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/{mem-house/delivery/skills/memhouse-search → memhouse/delivery/plugin/skills/search}/SKILL.md +60 -18
- package/memhouse/delivery/plugin/skills/sessions/SKILL.md +177 -0
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +206 -0
- package/memhouse/host.js +101 -0
- package/{mem-house → memhouse}/per-member/INSTALL.md +59 -14
- package/{mem-house → memhouse}/per-member/SCHEMA.md +47 -0
- package/memhouse/per-member/provision.js +265 -0
- package/memhouse/per-member/rooms.js +235 -0
- package/{mem-house → memhouse}/per-member/schema-member.sql.tpl +18 -14
- package/{mem-house → memhouse}/per-member/schema-merge.sql.tpl +3 -3
- package/memhouse/resume.js +150 -0
- package/memhouse/self-update.js +150 -0
- package/{mem-house → memhouse}/server/queries.js +80 -34
- package/memhouse/server/server.js +451 -0
- package/{mem-house → memhouse}/service.js +48 -29
- package/memhouse/shipper/ship.js +949 -0
- package/package.json +5 -20
- package/public/assets/{index-CtH-5i95.js → index-CnWzrjF3.js} +10 -7
- package/public/index.html +2 -2
- package/ui/index.html +1 -1
- package/ui/src/App.jsx +1 -1
- package/ui/src/lib/api.js +29 -13
- package/ui/src/pages/Dashboard.jsx +8 -5
- package/ui/src/pages/Sessions.jsx +3 -1
- package/ui/src/pages/SqlViewer.jsx +6 -23
- package/cache.js +0 -850
- package/ch-ingest.js +0 -329
- package/ch-schema.js +0 -152
- package/clickhouse.js +0 -87
- package/index.js +0 -425
- package/mcp-server.js +0 -294
- package/mem-house/delivery/install.sh +0 -183
- package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +0 -125
- package/mem-house/delivery/skills/memhouse-sql/SKILL.md +0 -146
- package/mem-house/per-member/provision.js +0 -142
- package/mem-house/per-member/rooms.js +0 -132
- package/mem-house/server/server.js +0 -228
- package/mem-house/shipper/ship.js +0 -566
- package/relay-client.js +0 -306
- package/relay-server.js +0 -577
- package/server.js +0 -860
- package/share-image.js +0 -367
- /package/{mem-house → memhouse}/deploy.js +0 -0
- /package/{mem-house → memhouse}/envfile.js +0 -0
- /package/{mem-house → memhouse}/per-member/PLAN.md +0 -0
- /package/{mem-house → memhouse}/per-member/PROVISIONING.md +0 -0
- /package/{mem-house → memhouse}/per-member/README.md +0 -0
- /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
|
-
|
|
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
|
-
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|