memhouse 0.18.1 → 0.18.3
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/bin/memhouse.js +55 -9
- package/editors/claude.js +90 -10
- package/memhouse/delivery/INVITATION.md.tpl +4 -0
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/memhouse/delivery/plugin/reference/HOUSE.md +17 -0
- package/memhouse/delivery/plugin/skills/recall/SKILL.md +5 -0
- package/memhouse/envfile.js +16 -1
- package/memhouse/house/house.js +173 -1
- package/memhouse/provision.js +4 -1
- package/memhouse/server/queries.js +271 -137
- package/memhouse/shipper/passlog.js +37 -0
- package/memhouse/shipper/ship.js +81 -7
- package/package.json +3 -2
package/bin/memhouse.js
CHANGED
|
@@ -273,14 +273,24 @@ function portOf(u) {
|
|
|
273
273
|
// "not running" for every correctly service-managed install, and `doctor` fails on a
|
|
274
274
|
// healthy machine. Returns { running, via }.
|
|
275
275
|
function shipperHealth() {
|
|
276
|
+
// "running" is the pid; whether it is DOING anything is the log. Both, always.
|
|
277
|
+
const lastPass = (() => {
|
|
278
|
+
try {
|
|
279
|
+
const passlog = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog'));
|
|
280
|
+
const f = path.join(LOG_DIR, 'shipper.log'); const st = fs.statSync(f);
|
|
281
|
+
const fd = fs.openSync(f, 'r'); const len = Math.min(st.size, 65536); const buf = Buffer.alloc(len);
|
|
282
|
+
fs.readSync(fd, buf, 0, len, st.size - len); fs.closeSync(fd);
|
|
283
|
+
return passlog.lastPass(buf.toString('utf-8'));
|
|
284
|
+
} catch { return { outcome: null }; }
|
|
285
|
+
})();
|
|
276
286
|
const pid = pidOf('shipper');
|
|
277
|
-
if (pid) return { running: true, via: `daemon (pid ${pid})
|
|
287
|
+
if (pid) return { running: true, via: `daemon (pid ${pid})`, lastPass };
|
|
278
288
|
try {
|
|
279
289
|
const svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
|
|
280
290
|
const st = svc.status();
|
|
281
|
-
if (st.installed) return { running: st.running, via: `service (${st.kind})
|
|
291
|
+
if (st.installed) return { running: st.running, via: `service (${st.kind})`, lastPass };
|
|
282
292
|
} catch { /* no service integration on this platform */ }
|
|
283
|
-
return { running: false, via: null };
|
|
293
|
+
return { running: false, via: null, lastPass };
|
|
284
294
|
}
|
|
285
295
|
|
|
286
296
|
// Room routing for the CLI's own queries. The shipper and dashboard resolve rooms through
|
|
@@ -405,7 +415,9 @@ Setup onboard interactive wizard: discover → configure →
|
|
|
405
415
|
--print-sql print the SQL, run it yourself
|
|
406
416
|
with admin: --admin-user --admin-password [--member NAME]
|
|
407
417
|
builds house + user + rooms + grants, then verifies as
|
|
408
|
-
the member. The admin credential
|
|
418
|
+
the member. The admin credential IS written to the env
|
|
419
|
+
file (mode 600) so invite/members need no flags; delete
|
|
420
|
+
those two lines if you would rather pass it each time.
|
|
409
421
|
--env FILE installs from an invite file (see: invite)
|
|
410
422
|
invite <name> mint a member and write the env file their install needs
|
|
411
423
|
(--url [--db NAME] [--out FILE]; --admin-user and
|
|
@@ -969,6 +981,16 @@ function startShipperBackground(cfg) {
|
|
|
969
981
|
console.log(' dashboard (browse / search / analyze): memhouse start');
|
|
970
982
|
}
|
|
971
983
|
|
|
984
|
+
// The store is node:sqlite, stable from Node 24. On 22 everything green-lights and the
|
|
985
|
+
// SQLite-backed adapters silently ship nothing — 36 messages went out before anyone noticed.
|
|
986
|
+
// doctor said so; install and onboard did not. Now they refuse, with the same sentence.
|
|
987
|
+
function engineOk() {
|
|
988
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
989
|
+
if (major >= 24) return true;
|
|
990
|
+
console.log(bad(`node ${process.versions.node} — need >= 24 — node:sqlite is stable there; the SQLite-backed adapters need it`));
|
|
991
|
+
process.exitCode = 1; return false;
|
|
992
|
+
}
|
|
993
|
+
|
|
972
994
|
async function cmdInstall({ interactive }) {
|
|
973
995
|
// --env <file>: an INVITE intake. The file carries MEMHOUSE_URL/USER/PASSWORD/DB from
|
|
974
996
|
// `memhouse invite` on the admin's machine; loading it into the environment BEFORE
|
|
@@ -985,6 +1007,10 @@ async function cmdInstall({ interactive }) {
|
|
|
985
1007
|
// this machine with a file that cannot authenticate on the invitee's.
|
|
986
1008
|
const missing = wanted.filter((k) => !parsed[k]);
|
|
987
1009
|
if (missing.length) { console.log(bad(`--env ${flags.env} is missing ${missing.join(', ')} — not a complete invite file?`)); return 1; }
|
|
1010
|
+
try {
|
|
1011
|
+
const h = new URL(String(parsed.MEMHOUSE_URL || '')).hostname;
|
|
1012
|
+
if (/^(localhost|127\.0\.0\.1|\[?::1\]?)$/.test(h)) console.log(warn(`${parsed.MEMHOUSE_URL} is loopback — it points at THIS machine; only right if a tunnel or port-forward puts the house here`));
|
|
1013
|
+
} catch { /* not a URL; the missing-fields check above already spoke */ }
|
|
988
1014
|
// Clear ambient MEMHOUSE_* so ONLY the file speaks (exported vars normally win over the
|
|
989
1015
|
// file; an invite intake is the one place they must not).
|
|
990
1016
|
for (const k of ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB', 'MEMHOUSE_PORT']) delete process.env[k];
|
|
@@ -1285,6 +1311,18 @@ async function finishInvite(cfg) {
|
|
|
1285
1311
|
}
|
|
1286
1312
|
if (go) {
|
|
1287
1313
|
const code = await cmdPasswd({ quiet: true });
|
|
1314
|
+
if (code === 0) {
|
|
1315
|
+
// The rotation rewrote the env FILE. But --env hoisted the invite's values into
|
|
1316
|
+
// process.env, and resolveConfig prefers process.env over the file — so the
|
|
1317
|
+
// shipper spawned a few lines later got the INVITE password, which the rotation
|
|
1318
|
+
// had just killed, and failed every pass with "Authentication failed" while every
|
|
1319
|
+
// fresh CLI call (reading the file) worked. A drill member watched an empty house
|
|
1320
|
+
// for twelve minutes. Adopt what the file now says before anything else is spawned.
|
|
1321
|
+
const drift = envfile.credentialDrift(fs.readFileSync(ENV_FILE, 'utf-8'), process.env);
|
|
1322
|
+
for (const k of drift.changed) process.env[k] = drift.values[k];
|
|
1323
|
+
if (drift.values.MEMHOUSE_PASSWORD !== undefined && flags.password !== undefined) flags.password = drift.values.MEMHOUSE_PASSWORD;
|
|
1324
|
+
if (cfg) cfg.password = process.env.MEMHOUSE_PASSWORD;
|
|
1325
|
+
}
|
|
1288
1326
|
if (code !== 0) {
|
|
1289
1327
|
// Rotation failed and its own state may be uncertain — KEEP the invite file (it
|
|
1290
1328
|
// still carries the password the config was just written from) so nothing is
|
|
@@ -1323,7 +1361,8 @@ function printGettingStarted(cfg) {
|
|
|
1323
1361
|
console.log(' /mem:sql, /mem:access, /mem:admin');
|
|
1324
1362
|
console.log(` ${mh} search <terms> find a past conversation right now`);
|
|
1325
1363
|
console.log(` ${mh} doctor every line a check mark = healthy`);
|
|
1326
|
-
console.log('
|
|
1364
|
+
if (flags['no-ship'] === true) console.log(' Shipping is OFF (--no-ship). Start it when ready: memhouse start');
|
|
1365
|
+
else console.log(' The house keeps shipping as you work; nothing else to do.');
|
|
1327
1366
|
}
|
|
1328
1367
|
|
|
1329
1368
|
async function cmdOnboard() {
|
|
@@ -1605,7 +1644,12 @@ async function cmdStatus() {
|
|
|
1605
1644
|
console.log(` ${f.writer.padEnd(28)} ${String(f.version || '?').padEnd(8)} last ship ${fleetAge(f.ageMs)}${mark}`);
|
|
1606
1645
|
}
|
|
1607
1646
|
}
|
|
1608
|
-
|
|
1647
|
+
{
|
|
1648
|
+
const fail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(out.shipper.lastPass);
|
|
1649
|
+
console.log(!out.shipper.running ? warn('shipper: not running')
|
|
1650
|
+
: fail ? warn(`shipper: running — ${out.shipper.via} — but ${fail}`)
|
|
1651
|
+
: ok(`shipper: running — ${out.shipper.via}`));
|
|
1652
|
+
}
|
|
1609
1653
|
console.log(out.daemons.dashboard ? ok(`dashboard: running (pid ${out.daemons.dashboard})`) : warn('dashboard: not running'));
|
|
1610
1654
|
if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${runningPort(cfg)}`);
|
|
1611
1655
|
// Exit non-zero when the house is unreachable. `status` is what a health check, a
|
|
@@ -1952,7 +1996,9 @@ async function cmdDoctor() {
|
|
|
1952
1996
|
'that adapter does not extract usage; the messages are stored, the numbers are not');
|
|
1953
1997
|
} catch { /* a house that cannot be read is already reported above */ }
|
|
1954
1998
|
const sh = shipperHealth();
|
|
1955
|
-
|
|
1999
|
+
const shFail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(sh.lastPass);
|
|
2000
|
+
add(sh.running && !shFail, `shipper${sh.via ? ` — ${sh.via}` : ''}${shFail ? ` — ${shFail}` : ''}`,
|
|
2001
|
+
shFail ? (sh.lastPass.auth ? 'memhouse stop && memhouse start' : 'read: ~/.memhouse/logs/shipper.log') : 'memhouse start (or: memhouse service install)');
|
|
1956
2002
|
add(!!pidOf('dashboard'), 'dashboard daemon', 'memhouse start');
|
|
1957
2003
|
add(fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html')), 'dashboard UI built', 'built automatically by memhouse start');
|
|
1958
2004
|
|
|
@@ -3891,8 +3937,8 @@ async function cmdUninstall() {
|
|
|
3891
3937
|
// fall through to HELP: forty lines of help and exit 0 is not what --version means.
|
|
3892
3938
|
case 'version': console.log(PKG.version); break;
|
|
3893
3939
|
case 'discover': await cmdDiscover(); break;
|
|
3894
|
-
case 'onboard': process.exitCode = await cmdOnboard(); break;
|
|
3895
|
-
case 'install': process.exitCode = await cmdInstall({ interactive: false }); break;
|
|
3940
|
+
case 'onboard': if (!engineOk()) break; process.exitCode = await cmdOnboard(); break;
|
|
3941
|
+
case 'install': if (!engineOk()) break; process.exitCode = await cmdInstall({ interactive: false }); break;
|
|
3896
3942
|
case 'setup': {
|
|
3897
3943
|
// Dual-mode like install: --yes (with any --url/--user/--password/--db/
|
|
3898
3944
|
// --port overrides, already resolved into cfg) skips every prompt so
|
package/editors/claude.js
CHANGED
|
@@ -195,11 +195,19 @@ function cleanPrompt(prompt) {
|
|
|
195
195
|
// Parse one Claude session .jsonl into the adapter's message shape. When
|
|
196
196
|
// `isSubagent` is set, each message is tagged `[subagent]` so folded subagent turns
|
|
197
197
|
// are clearly attributed in the transcript.
|
|
198
|
-
function parseSessionFile(filePath, isSubagent) {
|
|
198
|
+
function parseSessionFile(filePath, isSubagent, agent = null) {
|
|
199
199
|
const messages = [];
|
|
200
200
|
let lines;
|
|
201
201
|
try { lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean); } catch { return messages; }
|
|
202
202
|
const tag = isSubagent ? '[subagent] ' : '';
|
|
203
|
+
// A folded subagent turn used to carry only the `[subagent]` tag: the house held the
|
|
204
|
+
// turns but not WHICH subagent said them, so one fork's transcript could not be isolated,
|
|
205
|
+
// cited, or told apart from a sibling's. An agent looking for the fork as its own session
|
|
206
|
+
// concluded it was never shipped. Every turn now carries the agent's id, the description
|
|
207
|
+
// the parent gave it, and its position within that subagent — in `extra`, which is not
|
|
208
|
+
// part of the line hash, so nothing already shipped is re-shipped for this.
|
|
209
|
+
let turn = 0;
|
|
210
|
+
const stamp = (m) => { if (agent) { m._agent = { ...agent, turn: turn++ }; } return m; };
|
|
203
211
|
|
|
204
212
|
for (const line of lines) {
|
|
205
213
|
let obj;
|
|
@@ -223,19 +231,19 @@ function parseSessionFile(filePath, isSubagent) {
|
|
|
223
231
|
|
|
224
232
|
if (obj.type === 'user' && obj.message) {
|
|
225
233
|
const content = extractContent(obj.message.content);
|
|
226
|
-
if (content) messages.push({ role: 'user', content: tag + content, _ts: at });
|
|
234
|
+
if (content) messages.push(stamp({ role: 'user', content: tag + content, _ts: at }));
|
|
227
235
|
} else if (obj.type === 'assistant' && obj.message) {
|
|
228
236
|
const { text, toolCalls } = extractAssistantContent(obj.message.content);
|
|
229
237
|
const usage = obj.message.usage;
|
|
230
|
-
if (text) messages.push({
|
|
238
|
+
if (text) messages.push(stamp({
|
|
231
239
|
role: 'assistant', content: tag + text, _model: obj.message.model,
|
|
232
240
|
_inputTokens: usage?.input_tokens, _outputTokens: usage?.output_tokens,
|
|
233
241
|
_cacheRead: usage?.cache_read_input_tokens, _cacheWrite: usage?.cache_creation_input_tokens,
|
|
234
242
|
_toolCalls: toolCalls, _ts: at,
|
|
235
|
-
});
|
|
243
|
+
}));
|
|
236
244
|
} else if (obj.type === 'system') {
|
|
237
245
|
const text = typeof obj.message?.content === 'string' ? obj.message.content : '';
|
|
238
|
-
if (text) messages.push({ role: 'system', content: tag + text, _ts: at });
|
|
246
|
+
if (text) messages.push(stamp({ role: 'system', content: tag + text, _ts: at }));
|
|
239
247
|
}
|
|
240
248
|
}
|
|
241
249
|
return messages;
|
|
@@ -252,14 +260,86 @@ function getMessages(chat) {
|
|
|
252
260
|
// after the parent's turns, so nothing is lost and subagents are never
|
|
253
261
|
// double-counted as standalone sessions.
|
|
254
262
|
const subagentsDir = path.join(filePath.replace(/\.jsonl$/, ''), 'subagents');
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
263
|
+
const subFiles = subagentFiles(subagentsDir);
|
|
264
|
+
const named = subFiles.length ? subagentNames(filePath) : new Map();
|
|
265
|
+
for (const { file, workflow } of subFiles) {
|
|
266
|
+
const id = path.basename(file).replace(/^agent-/, '').replace(/\.jsonl$/, '');
|
|
267
|
+
const meta = named.get(id) || {};
|
|
268
|
+
const agent = { id, description: meta.description || '', type: meta.type || (workflow ? 'workflow-subagent' : ''), file: path.basename(file) };
|
|
269
|
+
if (workflow) agent.workflow = workflow;
|
|
270
|
+
const turns = parseSessionFile(file, true, agent);
|
|
271
|
+
// A fork the parent never named (a Workflow-run agent, or a parent from before the
|
|
272
|
+
// ids were reported) is described by its own first prompt — what it was told to do.
|
|
273
|
+
if (!agent.description) {
|
|
274
|
+
const first = turns.find((m) => m.role === 'user');
|
|
275
|
+
if (first) { const d = String(first.content).replace(/^\[subagent\] /, '').replace(/\s+/g, ' ').trim().slice(0, 160); for (const m of turns) m._agent.description = d; }
|
|
276
|
+
}
|
|
277
|
+
messages.push(...turns);
|
|
278
|
+
}
|
|
259
279
|
|
|
260
280
|
return messages;
|
|
261
281
|
}
|
|
262
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Every subagent transcript under a session, wherever Claude Code put it:
|
|
285
|
+
* <session>/subagents/agent-<id>.jsonl — Agent tool forks
|
|
286
|
+
* <session>/subagents/workflows/wf_<id>/agent-<id>.jsonl — Workflow-run agents
|
|
287
|
+
* journal.jsonl and *.meta.json are bookkeeping, not transcripts. Sorted for a stable
|
|
288
|
+
* fold order. On real data the workflow layer held 100 of 165 forks and shipped none.
|
|
289
|
+
*/
|
|
290
|
+
function subagentFiles(subagentsDir) {
|
|
291
|
+
const out = [];
|
|
292
|
+
let names = [];
|
|
293
|
+
try { names = fs.readdirSync(subagentsDir); } catch { return out; }
|
|
294
|
+
for (const f of names.sort()) if (f.startsWith('agent-') && f.endsWith('.jsonl')) out.push({ file: path.join(subagentsDir, f), workflow: null });
|
|
295
|
+
const wfDir = path.join(subagentsDir, 'workflows');
|
|
296
|
+
let wfs = [];
|
|
297
|
+
try { wfs = fs.readdirSync(wfDir).filter((d) => d.startsWith('wf_')).sort(); } catch { return out; }
|
|
298
|
+
for (const wf of wfs) {
|
|
299
|
+
let files = [];
|
|
300
|
+
try { files = fs.readdirSync(path.join(wfDir, wf)); } catch { continue; }
|
|
301
|
+
for (const f of files.sort()) if (f.startsWith('agent-') && f.endsWith('.jsonl')) out.push({ file: path.join(wfDir, wf, f), workflow: wf });
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* What the parent called each subagent. The parent's `Agent` tool_use carries the
|
|
308
|
+
* description and subagent_type; the tool_result that answers it names the agentId
|
|
309
|
+
* ("agentId: <id>"). Join the two through tool_use_id → a map agentId → {description, type}.
|
|
310
|
+
* Best effort: a parent that predates the ids, or a truncated line, just yields no name.
|
|
311
|
+
*/
|
|
312
|
+
function subagentNames(parentFile) {
|
|
313
|
+
const byToolUse = new Map(); const out = new Map();
|
|
314
|
+
let lines = [];
|
|
315
|
+
try { lines = fs.readFileSync(parentFile, 'utf-8').split('\n').filter(Boolean); } catch { return out; }
|
|
316
|
+
for (const line of lines) {
|
|
317
|
+
let obj; try { obj = JSON.parse(line); } catch { continue; }
|
|
318
|
+
const content = obj.message && Array.isArray(obj.message.content) ? obj.message.content : [];
|
|
319
|
+
for (const c of content) {
|
|
320
|
+
if (c && c.type === 'tool_use' && c.name === 'Agent' && c.id) {
|
|
321
|
+
byToolUse.set(c.id, { description: String((c.input && c.input.description) || ''), type: String((c.input && c.input.subagent_type) || '') });
|
|
322
|
+
} else if (c && c.type === 'tool_result' && c.tool_use_id && byToolUse.has(c.tool_use_id)) {
|
|
323
|
+
const text = typeof c.content === 'string' ? c.content : (Array.isArray(c.content) ? c.content.map((x) => (x && x.text) || '').join('\n') : '');
|
|
324
|
+
const m = /agentId:\s*([0-9a-f]{6,})/i.exec(text);
|
|
325
|
+
if (m) out.set(m[1], byToolUse.get(c.tool_use_id));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// A background subagent reports through a task notification instead — a user-role
|
|
329
|
+
// line whose text carries <task-id>agentId</task-id> and <tool-use-id>toolu_…</tool-use-id>.
|
|
330
|
+
// On real data 30 of 65 forks were named only this way.
|
|
331
|
+
const flat = typeof (obj.message && obj.message.content) === 'string' ? obj.message.content
|
|
332
|
+
: content.map((x) => (x && typeof x.text === 'string') ? x.text : '').join('\n');
|
|
333
|
+
if (flat.includes('<task-notification>')) {
|
|
334
|
+
for (const block of flat.split('<task-notification>').slice(1)) {
|
|
335
|
+
const t = /<task-id>([0-9a-f]{6,})<\/task-id>/i.exec(block); const u = /<tool-use-id>(toolu_[A-Za-z0-9]+)<\/tool-use-id>/.exec(block);
|
|
336
|
+
if (t && u && byToolUse.has(u[1]) && !out.has(t[1])) out.set(t[1], byToolUse.get(u[1]));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
263
343
|
function extractContent(content) {
|
|
264
344
|
if (typeof content === 'string') return content;
|
|
265
345
|
if (!Array.isArray(content)) return '';
|
|
@@ -420,4 +500,4 @@ function getMCPServers() {
|
|
|
420
500
|
// answer to a different question: where this pilot's Claude Code instances live. Two
|
|
421
501
|
// implementations of "find every CLAUDE_CONFIG_DIR" would drift the first time a playbook
|
|
422
502
|
// layout changes, and this is the copy exercised on every single ship.
|
|
423
|
-
module.exports = { name, labels, getChats, getMessages, getUsage, getArtifacts, getMCPServers, discoverClaudeRoots };
|
|
503
|
+
module.exports = { name, labels, getChats, getMessages, getUsage, getArtifacts, getMCPServers, discoverClaudeRoots, subagentNames, subagentFiles };
|
|
@@ -38,6 +38,10 @@ going on. Do not print its contents — the last line of it is a password.
|
|
|
38
38
|
{{INSTALL}}
|
|
39
39
|
|
|
40
40
|
On most Linux distros the global install needs `sudo`; if `npm` says `EACCES`, that is why.
|
|
41
|
+
If you handed this page to an agent and it reports that a permission classifier blocked the
|
|
42
|
+
install (a global `npm install` is exactly what such classifiers stop), run this one line
|
|
43
|
+
yourself in a terminal and tell the agent to continue from step 2 — that is the intended
|
|
44
|
+
split: the person installs software, the agent does the rest.
|
|
41
45
|
`memhouse --version` should print `{{VERSION}}`.
|
|
42
46
|
|
|
43
47
|
## 2. Join the house
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mem",
|
|
3
3
|
"description": "Conversation memory for AI agents over the memhouse typed ClickHouse store: recall past sessions and answer from them, run analytics, check the house, bring people in, and administer the server. Skills install as /mem:recall, /mem:sql, /mem:house, /mem:access, and /mem:admin.",
|
|
4
|
-
"version": "0.18.
|
|
4
|
+
"version": "0.18.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ramazan Polat"
|
|
7
7
|
},
|
|
@@ -92,6 +92,21 @@ wrong rooms. Say whose memory an answer came from.
|
|
|
92
92
|
|
|
93
93
|
## Schema
|
|
94
94
|
|
|
95
|
+
**Subagents (forks) live inside their parent session.** Claude Code writes them to
|
|
96
|
+
`<session>/subagents/agent-<id>.jsonl` (Agent tool) and
|
|
97
|
+
`<session>/subagents/workflows/wf_<id>/agent-<id>.jsonl` (Workflow runs); the shipper folds them into the parent with
|
|
98
|
+
`is_subagent = 1` and stamps *which* one in `extra.agent`. Looking for a fork as its own
|
|
99
|
+
session finds nothing — look inside the parent:
|
|
100
|
+
|
|
101
|
+
```sql
|
|
102
|
+
-- the subagents a session spawned
|
|
103
|
+
SELECT toString(extra.agent.id) AS agent, any(toString(extra.agent.description)) AS asked_to, count() AS turns
|
|
104
|
+
FROM messages WHERE session_id = '<sid>' AND is_subagent GROUP BY agent -- toString: a JSON path cannot be a GROUP BY key
|
|
105
|
+
-- one subagent's transcript, in order
|
|
106
|
+
SELECT seq, role, substring(text, 1, 2000) FROM messages
|
|
107
|
+
WHERE session_id = '<sid>' AND extra.agent.id = '<agent>' ORDER BY seq
|
|
108
|
+
```
|
|
109
|
+
|
|
95
110
|
**`messages`** — one row per turn.
|
|
96
111
|
|
|
97
112
|
| column | meaning |
|
|
@@ -102,6 +117,8 @@ wrong rooms. Say whose memory an answer came from.
|
|
|
102
117
|
| `host` | the machine, `<hostname>-<8 hex>` — random per install, not derived |
|
|
103
118
|
| `ts` | when the turn happened (`DateTime64(3,'UTC')`) |
|
|
104
119
|
| `role` | `user`, `assistant`, `system` |
|
|
120
|
+
| `is_subagent` | 1 for a turn that a Claude Code **subagent** (a fork) produced; folded into the parent session after the parent's own turns. Subagents are never separate sessions. |
|
|
121
|
+
| `extra` | JSON. For a subagent turn: `extra.agent.id`, `extra.agent.description` (what the parent asked it to do, else the fork's own first prompt), `extra.agent.type` (`general-purpose`, `workflow-subagent`, …), `extra.agent.turn` (position within that subagent), and `extra.agent.workflow` (`wf_…`) when a Workflow run spawned it. Empty `{}` otherwise, and on rows shipped before 0.18.3. |
|
|
105
122
|
| `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens` | usage; **empty for several adapters** — see below |
|
|
106
123
|
| `text` | the turn, **truncated at 50,000 chars** |
|
|
107
124
|
| `text_ngram`, `text_word` | `MATERIALIZED lower(text)`, carrying the text indexes |
|
|
@@ -112,6 +112,11 @@ Beyond the three in the reference:
|
|
|
112
112
|
|
|
113
113
|
- **Ranking is yours.** There is no relevance score in the store. `hits` plus recency is
|
|
114
114
|
the whole of it — say so rather than implying the top row is "the best match".
|
|
115
|
+
- **A subagent (fork) is not a session.** Its turns are inside the parent session with
|
|
116
|
+
`is_subagent = 1` and `extra.agent.id` / `extra.agent.description` saying which one; the
|
|
117
|
+
reference has the two queries. "Find the fork that did X" → search `messages` as usual,
|
|
118
|
+
then group the hits by `toString(extra.agent.id)` — never conclude it was not shipped because no
|
|
119
|
+
session carries its name.
|
|
115
120
|
- **Tool *results* are not stored.** "What did that command print" is unanswerable from
|
|
116
121
|
the house; only the invocation and its arguments survive.
|
|
117
122
|
- **`memhouse search`** exists as a CLI, but it joins your terms into ONE literal phrase
|
package/memhouse/envfile.js
CHANGED
|
@@ -46,4 +46,19 @@ function assertSingleLine(env) {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Which connection values in an env file's text differ from what a process currently holds.
|
|
51
|
+
* A running shipper was spawned with one credential; `install --env` rotates the password
|
|
52
|
+
* and rewrites the file, and a member can `memhouse passwd` under a running daemon. In
|
|
53
|
+
* both cases the FILE is the truth and the process copy is stale. Pure; used by the
|
|
54
|
+
* shipper (adopt on auth failure) and by install (adopt before spawning).
|
|
55
|
+
*/
|
|
56
|
+
function credentialDrift(text, current = {}) {
|
|
57
|
+
const file = parse(String(text || ''));
|
|
58
|
+
const keys = ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB'];
|
|
59
|
+
const changed = keys.filter((k) => file[k] !== undefined && file[k] !== current[k]);
|
|
60
|
+
const values = Object.fromEntries(changed.map((k) => [k, file[k]]));
|
|
61
|
+
return { changed, values };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { credentialDrift, parse, quoteShell, quoteSystemd, assertSingleLine };
|
package/memhouse/house/house.js
CHANGED
|
@@ -306,6 +306,117 @@ function sessionsRollup({ sessions, messages }) {
|
|
|
306
306
|
)`;
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
// The precomputed rollups' table names. One place, because the shipper that fills them,
|
|
310
|
+
// the runtime probe and the read layer must all agree.
|
|
311
|
+
const SESSION_STATS = 'session_stats';
|
|
312
|
+
const MODEL_STATS = 'session_model_stats';
|
|
313
|
+
const TOOL_STATS = 'session_tool_stats';
|
|
314
|
+
// One-layout: like every room, a stat table is named for its member — `<member>_session_stats`
|
|
315
|
+
// — so it sits under the same `<db>.<member>_*` grant the member already holds. No
|
|
316
|
+
// database-level privilege exists in this layout, and none is needed.
|
|
317
|
+
function statTableNames(member) {
|
|
318
|
+
return {
|
|
319
|
+
session_stats: physicalRoom(SESSION_STATS, member),
|
|
320
|
+
model_stats: physicalRoom(MODEL_STATS, member),
|
|
321
|
+
tool_stats: physicalRoom(TOOL_STATS, member),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The precomputed rollups — the tables the dashboard reads instead of rebuilding the
|
|
327
|
+
* session rollup on every query. Measured on a 714k-message house: 1,413,624 rows read
|
|
328
|
+
* per query became ~1,250, and a page load went from 4–10 seconds to ~250 ms.
|
|
329
|
+
*
|
|
330
|
+
* REFRESHED BY THE SHIPPER, NOT BY A MATERIALIZED VIEW — and that is forced, not chosen.
|
|
331
|
+
* The obvious tool is a refreshable materialized view (`REFRESH EVERY 5 MINUTE TO t`),
|
|
332
|
+
* which is exactly what the 0.17 line uses. It cannot exist in this layout: a refreshable
|
|
333
|
+
* view swaps its target atomically through a temporary table with a generated name, so
|
|
334
|
+
* ClickHouse demands `SELECT, INSERT, CREATE TABLE, DROP TABLE ON <db>.*` — the whole
|
|
335
|
+
* database — and a one-layout member holds `<db>.<member>_*` and nothing else, on
|
|
336
|
+
* purpose. Measured on 25.8: the CREATE is refused with exactly that message, and the
|
|
337
|
+
* grant that would satisfy it is the one this layout exists to withhold.
|
|
338
|
+
*
|
|
339
|
+
* (An ordinary insert-trigger MV is wrong for a different reason: it counts every
|
|
340
|
+
* ReplacingMergeTree version the merge has not collapsed yet, cannot see the epoch
|
|
341
|
+
* filter, and cannot join.)
|
|
342
|
+
*
|
|
343
|
+
* So the shipper runs the rollup itself, once at the end of every pass — the same
|
|
344
|
+
* cadence a view would have had — and INSERTs the result stamped with one
|
|
345
|
+
* `refreshed_at`. Every insert is a complete snapshot, a GENERATION; the read layer
|
|
346
|
+
* takes only the newest generation. Nothing is deleted, nothing is swapped, nothing
|
|
347
|
+
* needs a privilege beyond INSERT and SELECT on the member's own rooms. A TTL of one day
|
|
348
|
+
* keeps old generations from accumulating (288 a day at the default cadence), and the
|
|
349
|
+
* sorting key leads with `refreshed_at` so reading the newest one is one index range,
|
|
350
|
+
* not a scan.
|
|
351
|
+
*
|
|
352
|
+
* Built from the SAME rollup text the read layer would otherwise run, so the two
|
|
353
|
+
* cannot drift; column types are inferred with EMPTY AS for the same reason.
|
|
354
|
+
*/
|
|
355
|
+
const STAT_TTL = 'TTL toDateTime(refreshed_at) + INTERVAL 1 DAY';
|
|
356
|
+
|
|
357
|
+
function statBodies(rooms) {
|
|
358
|
+
// sessionsRollup wraps itself in parentheses for the FROM position; strip them.
|
|
359
|
+
const rollup = sessionsRollup(rooms).replace(/^\s*\(/, '').replace(/\)\s*$/, '');
|
|
360
|
+
return {
|
|
361
|
+
session_stats: {
|
|
362
|
+
key: '(refreshed_at, session_id, user_id)',
|
|
363
|
+
body: rollup,
|
|
364
|
+
},
|
|
365
|
+
// One row per (session, user, model) with the message count and the four token sums.
|
|
366
|
+
// Every model/cost aggregate the dashboard runs is a sum or argMax over these rows —
|
|
367
|
+
// a few thousand — instead of a scan of the full messages room. The read layer's
|
|
368
|
+
// any-token predicates survive unchanged: a zero-token row contributes zero to every
|
|
369
|
+
// sum, so filtering before summing and summing over everything agree.
|
|
370
|
+
model_stats: {
|
|
371
|
+
key: '(refreshed_at, session_id, user_id, model)',
|
|
372
|
+
body: `SELECT session_id, user_id, any(source) AS source, any(folder) AS folder, model,
|
|
373
|
+
count() AS msgs,
|
|
374
|
+
sum(input_tokens) AS input_tokens, sum(output_tokens) AS output_tokens,
|
|
375
|
+
sum(cache_read_tokens) AS cache_read_tokens, sum(cache_write_tokens) AS cache_write_tokens
|
|
376
|
+
FROM ${rooms.messages} AS m
|
|
377
|
+
GROUP BY session_id, user_id, model`,
|
|
378
|
+
},
|
|
379
|
+
// One row per (session, user, tool_name) with the call count. NOTE the epoch source:
|
|
380
|
+
// rooms.tool_calls is currentParse(tool_calls, 'messages') — the tool room takes its
|
|
381
|
+
// epoch from MESSAGES, because a parse that keeps its messages but emits no tool calls
|
|
382
|
+
// writes nothing at the new epoch, and this room's own max(epoch) would resurrect the
|
|
383
|
+
// superseded parse's calls.
|
|
384
|
+
tool_stats: {
|
|
385
|
+
key: '(refreshed_at, session_id, user_id, tool_name)',
|
|
386
|
+
body: `SELECT session_id, user_id, any(source) AS source, any(folder) AS folder, tool_name,
|
|
387
|
+
count() AS calls
|
|
388
|
+
FROM ${rooms.tool_calls} AS tc
|
|
389
|
+
GROUP BY session_id, user_id, tool_name`,
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The CREATE statements — additive, idempotent, run by ensureSchema. */
|
|
395
|
+
function statCreateStatements(rooms) {
|
|
396
|
+
const names = statTableNames(rooms.member);
|
|
397
|
+
const bodies = statBodies(rooms);
|
|
398
|
+
return Object.keys(bodies).map((k) =>
|
|
399
|
+
`CREATE TABLE IF NOT EXISTS ${names[k]} ENGINE = MergeTree ORDER BY ${bodies[k].key} ${STAT_TTL} `
|
|
400
|
+
+ `EMPTY AS SELECT now64(3) AS refreshed_at, * FROM (${bodies[k].body})`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The refresh — one INSERT per table, one generation per call. All three carry the same
|
|
405
|
+
* `refreshed_at` so a reader never mixes a session_stats generation with a model_stats
|
|
406
|
+
* one. Run by the shipper after every pass and by --ensure-schema.
|
|
407
|
+
*/
|
|
408
|
+
function statRefreshStatements(rooms, at = 'now64(3)') {
|
|
409
|
+
const names = statTableNames(rooms.member);
|
|
410
|
+
const bodies = statBodies(rooms);
|
|
411
|
+
return Object.keys(bodies).map((k) =>
|
|
412
|
+
`INSERT INTO ${names[k]} SELECT ${at} AS refreshed_at, * FROM (${bodies[k].body})`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The read-side source for one stat table: its newest generation, and nothing else. */
|
|
416
|
+
function latestGeneration(table) {
|
|
417
|
+
return `(SELECT * FROM ${table} WHERE refreshed_at = (SELECT max(refreshed_at) FROM ${table}))`;
|
|
418
|
+
}
|
|
419
|
+
|
|
309
420
|
/**
|
|
310
421
|
* The settings every read needs. `final` collapses ReplacingMergeTree versions;
|
|
311
422
|
* `join_use_nulls` is what the rollup's coalesce depends on.
|
|
@@ -381,8 +492,67 @@ async function currentUser(client) {
|
|
|
381
492
|
}
|
|
382
493
|
|
|
383
494
|
/** Resolve the rooms for whoever this client is connected as. */
|
|
495
|
+
/**
|
|
496
|
+
* The precomputed rollup, when the house has one.
|
|
497
|
+
*
|
|
498
|
+
* `sessions_v` is a SAVED QUERY that rebuilds the whole rollup — sessions FINAL joined
|
|
499
|
+
* to the current parse of messages, grouped over the entire house — on every call. A
|
|
500
|
+
* house that has run the session_stats migration carries the same rows as a flat table,
|
|
501
|
+
* refreshed on a timer, and the read layer can name that instead. Measured on a 714k
|
|
502
|
+
* message house: 1,413,624 rows read per query becomes 1,230, and p50 query time 68ms
|
|
503
|
+
* becomes 4ms.
|
|
504
|
+
*
|
|
505
|
+
* Resolved at RUNTIME rather than by version, and that is the point: one binary has to
|
|
506
|
+
* serve a migrated house, a house whose migration has not run, and a friend's house
|
|
507
|
+
* shared read-only that was never migrated at all. Asking the server what exists is the
|
|
508
|
+
* only answer that is correct in all three.
|
|
509
|
+
*/
|
|
510
|
+
async function statTables(client, member) {
|
|
511
|
+
const names = statTableNames(member);
|
|
512
|
+
const wanted = Object.values(names);
|
|
513
|
+
try {
|
|
514
|
+
const rs = await client.query({
|
|
515
|
+
query: `SELECT name FROM system.tables
|
|
516
|
+
WHERE database = currentDatabase()
|
|
517
|
+
AND name IN (${wanted.map((n) => `'${n}'`).join(', ')})`,
|
|
518
|
+
format: 'JSONEachRow',
|
|
519
|
+
});
|
|
520
|
+
const present = (await rs.json()).map((r) => r.name);
|
|
521
|
+
// Present is not enough: a table exists from the moment ensureSchema creates it and
|
|
522
|
+
// holds nothing until the first refresh lands. A dashboard reading it then would show
|
|
523
|
+
// a house with no sessions at all (caught by house-test the first time). An empty
|
|
524
|
+
// stat table reads as absent, so the inline rollup answers until the table can.
|
|
525
|
+
const usable = new Set();
|
|
526
|
+
for (const name of present) {
|
|
527
|
+
try {
|
|
528
|
+
const c = await client.query({ query: `SELECT count() AS n FROM ${name}`, format: 'JSONEachRow' });
|
|
529
|
+
const rows = await c.json();
|
|
530
|
+
if (Number(rows[0] && rows[0].n) > 0) usable.add(name);
|
|
531
|
+
} catch { /* unreadable: treat as absent */ }
|
|
532
|
+
}
|
|
533
|
+
return usable;
|
|
534
|
+
} catch {
|
|
535
|
+
// No grant on system.tables is not an error — it means "assume none", and the
|
|
536
|
+
// subquery path is always correct.
|
|
537
|
+
return new Set();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function hasSessionStats(client, member) {
|
|
542
|
+
return (await statTables(client, member)).has(statTableNames(member).session_stats);
|
|
543
|
+
}
|
|
544
|
+
|
|
384
545
|
async function resolveRooms(client) {
|
|
385
|
-
|
|
546
|
+
const rooms = roomNames(await currentUser(client));
|
|
547
|
+
const names = statTableNames(rooms.member);
|
|
548
|
+
const have = await statTables(client, rooms.member);
|
|
549
|
+
if (have.has(names.session_stats)) rooms.sessions_v = latestGeneration(names.session_stats);
|
|
550
|
+
// Null when absent: the read layer branches to the legacy scan-the-room SQL. Never a
|
|
551
|
+
// token substitution, because the legacy SQL has a different shape, not just a
|
|
552
|
+
// different table name.
|
|
553
|
+
rooms.model_stats = have.has(names.model_stats) ? latestGeneration(names.model_stats) : null;
|
|
554
|
+
rooms.tool_stats = have.has(names.tool_stats) ? latestGeneration(names.tool_stats) : null;
|
|
555
|
+
return rooms;
|
|
386
556
|
}
|
|
387
557
|
|
|
388
558
|
/**
|
|
@@ -426,4 +596,6 @@ module.exports = {
|
|
|
426
596
|
READ_SETTINGS, MEMBER_PIN,
|
|
427
597
|
installCommand, assertUsableName,
|
|
428
598
|
sessionsRollup, currentParse, createStatement, roomNames, currentUser, resolveRooms,
|
|
599
|
+
SESSION_STATS, MODEL_STATS, TOOL_STATS, statTableNames, hasSessionStats, statTables,
|
|
600
|
+
statCreateStatements, statRefreshStatements, latestGeneration,
|
|
429
601
|
};
|
package/memhouse/provision.js
CHANGED
|
@@ -61,7 +61,10 @@ function plan({ db, member, password = null }) {
|
|
|
61
61
|
{ sql: `CREATE DATABASE IF NOT EXISTS ${db}`, why: 'the house', optional: false },
|
|
62
62
|
];
|
|
63
63
|
if (password !== null) {
|
|
64
|
-
|
|
64
|
+
// No IF NOT EXISTS. The live path checks first and refuses an existing user; the printed
|
|
65
|
+
// plan used to no-op here, apply the grants, and hand out a password the server never saw —
|
|
66
|
+
// found on a realm that mints its own users. Now it fails loudly on line one instead.
|
|
67
|
+
steps.push({ sql: `CREATE USER ${member} IDENTIFIED BY ${sqlString(password)}`, why: 'the member — fails if it already exists; then this password is NOT theirs', optional: false });
|
|
65
68
|
}
|
|
66
69
|
steps.push(
|
|
67
70
|
{ sql: roomGrant(db, member), why: `their rooms — ${db}.${roomPattern(member)} — and nothing else in the house`, optional: false },
|