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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// What the shipper's log says about its LAST pass.
|
|
2
|
+
//
|
|
3
|
+
// A daemon whose every pass fails is "running" by pid. A drill member's shipper failed
|
|
4
|
+
// auth for twelve minutes while `status` said "shipper: running" and `doctor` was green,
|
|
5
|
+
// and she — and her agent — read an empty house as "still catching up". The log beside
|
|
6
|
+
// it had the answer on every pass. This reads it. Pure; the CLI reads the file.
|
|
7
|
+
//
|
|
8
|
+
// Lines have no timestamps, so "last" is textual order: the later of the last success
|
|
9
|
+
// line and the last failure line wins.
|
|
10
|
+
|
|
11
|
+
const OK = /\[memhouse\] shipped (\d+) sessions[^\n]*?→ (\d+) msg rows/;
|
|
12
|
+
const FAIL = /\[memhouse\] pass failed: ([^\n]*)/;
|
|
13
|
+
|
|
14
|
+
/** @returns {{outcome:'ok'|'failed'|null, sessions?:number, rows?:number, why?:string, auth?:boolean}} */
|
|
15
|
+
function lastPass(logText) {
|
|
16
|
+
const text = String(logText || '');
|
|
17
|
+
let lastOk = -1; let lastFail = -1; let okM = null; let failM = null;
|
|
18
|
+
for (const m of text.matchAll(new RegExp(OK.source, 'g'))) { lastOk = m.index; okM = m; }
|
|
19
|
+
for (const m of text.matchAll(new RegExp(FAIL.source, 'g'))) { lastFail = m.index; failM = m; }
|
|
20
|
+
if (lastOk < 0 && lastFail < 0) return { outcome: null };
|
|
21
|
+
if (lastFail > lastOk) {
|
|
22
|
+
const why = failM[1].trim();
|
|
23
|
+
return { outcome: 'failed', why, auth: /Authentication failed|ACCESS_DENIED|Not enough privileges/i.test(why) };
|
|
24
|
+
}
|
|
25
|
+
return { outcome: 'ok', sessions: Number(okM[1]), rows: Number(okM[2]) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One line for a person: null when there is nothing to say (no passes yet, or the last one was fine). */
|
|
29
|
+
function describeFailure(lp) {
|
|
30
|
+
if (!lp || lp.outcome !== 'failed') return null;
|
|
31
|
+
const why = lp.why.length > 90 ? `${lp.why.slice(0, 87)}…` : lp.why;
|
|
32
|
+
return lp.auth
|
|
33
|
+
? `the last pass FAILED — ${why} — restart it: memhouse stop && memhouse start`
|
|
34
|
+
: `the last pass FAILED — ${why} — see: memhouse doctor`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { lastPass, describeFailure };
|
package/memhouse/shipper/ship.js
CHANGED
|
@@ -62,7 +62,9 @@ const selfUpdate = require('../self-update');
|
|
|
62
62
|
const selfSnap = selfUpdate.snapshot(__filename);
|
|
63
63
|
const {
|
|
64
64
|
resolveRooms, READ_SETTINGS, ROOM_TYPES, META_TYPES, SCHEMA_VERSION, MEMBER_PIN, createStatement,
|
|
65
|
-
SUPPORTED_SCHEMAS, keyProblem, legacyTextIndexDialect, isTextIndexGrammarRefusal, textIndexDialectFor
|
|
65
|
+
SUPPORTED_SCHEMAS, keyProblem, legacyTextIndexDialect, isTextIndexGrammarRefusal, textIndexDialectFor,
|
|
66
|
+
statCreateStatements, statRefreshStatements, statTableNames,
|
|
67
|
+
} = require('../house/house');
|
|
66
68
|
// Rooms plus the house's own record of itself. Every table the template declares, which
|
|
67
69
|
// is what the column healer and the drift warning have to cover — a column added to
|
|
68
70
|
// events would otherwise roll out to nobody.
|
|
@@ -463,6 +465,24 @@ async function ensureSchema(client) {
|
|
|
463
465
|
throw e;
|
|
464
466
|
}
|
|
465
467
|
}
|
|
468
|
+
// The precomputed rollups — additive, so they belong here and not in a migration (see
|
|
469
|
+
// migrate.js's rule). Three plain tables the shipper fills after every pass; the read
|
|
470
|
+
// layer probes for them at startup and falls back to the inline rollup when they are
|
|
471
|
+
// absent or empty, so a denied CREATE here degrades to slow, never to wrong. Why they
|
|
472
|
+
// are shipper-filled and not materialized views: house.js, above statBodies.
|
|
473
|
+
for (const q of statCreateStatements(rooms)) {
|
|
474
|
+
try {
|
|
475
|
+
await client.command({ query: q, clickhouse_settings: { async_insert: 0 } });
|
|
476
|
+
} catch (e) {
|
|
477
|
+
const m = e && e.message ? e.message : String(e);
|
|
478
|
+
if (/Not enough privileges|ACCESS_DENIED/i.test(m)) { denied++; continue; }
|
|
479
|
+
if (/ALREADY_EXISTS|already exists/i.test(m)) { continue; }
|
|
480
|
+
throw e;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// First generation now, so install and update are fast at once instead of after the
|
|
484
|
+
// next pass. Best effort: a denied CREATE above means these fail too, quietly.
|
|
485
|
+
await refreshStats(client, rooms);
|
|
466
486
|
if (denied) {
|
|
467
487
|
// In a SHARED house this is the designed state, not a shortfall: the operator creates
|
|
468
488
|
// each member's rooms during `invite` precisely so the member cannot — a member who
|
|
@@ -903,8 +923,11 @@ function rowsForChat(chat, host) {
|
|
|
903
923
|
text,
|
|
904
924
|
project,
|
|
905
925
|
folder,
|
|
906
|
-
is_subagent: text.startsWith('[subagent]'),
|
|
907
|
-
|
|
926
|
+
is_subagent: text.startsWith('[subagent]') || Boolean(m._agent),
|
|
927
|
+
// Which subagent (Claude Code fork) a folded turn belongs to — id, the description
|
|
928
|
+
// the parent gave it, its type, and the turn's position within that subagent — so
|
|
929
|
+
// one fork's transcript is `WHERE extra.agent.id = '…' ORDER BY seq`. Not hashed.
|
|
930
|
+
extra: m._agent ? { agent: { id: String(m._agent.id || ''), description: String(m._agent.description || ''), type: String(m._agent.type || ''), turn: Number(m._agent.turn) || 0, ...(m._agent.workflow ? { workflow: String(m._agent.workflow) } : {}) } } : {},
|
|
908
931
|
};
|
|
909
932
|
// Hash the content fields only (not ts — it is synthesized and may drift).
|
|
910
933
|
row.line_hash = lineHash({
|
|
@@ -1051,6 +1074,38 @@ async function loadStoredParse(client, rooms, id, uid, epoch) {
|
|
|
1051
1074
|
return { hashes, tools };
|
|
1052
1075
|
}
|
|
1053
1076
|
|
|
1077
|
+
/**
|
|
1078
|
+
* Recompute the three stat tables — one generation, one INSERT each, all stamped with the
|
|
1079
|
+
* same refreshed_at. Runs at the end of every ship pass, which is exactly the cadence a
|
|
1080
|
+
* refreshable view would have had, and after --ensure-schema. Best effort: a house whose
|
|
1081
|
+
* tables were never created (a denied CREATE) fails every INSERT here, and the read layer
|
|
1082
|
+
* is already on the inline rollup.
|
|
1083
|
+
*/
|
|
1084
|
+
async function refreshStats(client, rooms) {
|
|
1085
|
+
// One timestamp for all three, taken once — three now64() calls would be three
|
|
1086
|
+
// generations, and the read layer would pair session_stats from one with model_stats
|
|
1087
|
+
// from another.
|
|
1088
|
+
let at;
|
|
1089
|
+
try {
|
|
1090
|
+
const rs = await client.query({ query: 'SELECT toString(now64(3)) AS t', format: 'JSONEachRow' });
|
|
1091
|
+
at = `toDateTime64('${(await rs.json())[0].t}', 3, 'UTC')`;
|
|
1092
|
+
} catch { return 0; }
|
|
1093
|
+
let done = 0;
|
|
1094
|
+
for (const q of statRefreshStatements(rooms, at)) {
|
|
1095
|
+
try {
|
|
1096
|
+
await client.command({ query: q, clickhouse_settings: { ...READ_SETTINGS, async_insert: 0 } });
|
|
1097
|
+
done++;
|
|
1098
|
+
} catch (e) {
|
|
1099
|
+
const m = e && e.message ? e.message : String(e);
|
|
1100
|
+
if (!/Not enough privileges|ACCESS_DENIED|UNKNOWN_TABLE|doesn't exist|does not exist/i.test(m)) {
|
|
1101
|
+
console.error(`[memhouse] stats refresh: ${m.split('\n')[0]}`);
|
|
1102
|
+
}
|
|
1103
|
+
return done;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return done;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1054
1109
|
// One shipping pass. Incremental unless opts.full: a chat is skipped when the house
|
|
1055
1110
|
// already has it at least as fresh (last_updated_at) and at least as large
|
|
1056
1111
|
// (extra.bubbleCount) — both readable without parsing the chat.
|
|
@@ -1198,6 +1253,9 @@ async function runShip(client, opts = {}) {
|
|
|
1198
1253
|
for (const r of rows.toolRows) { r.epoch = epoch; await push('tool_calls', scrub(r)); toolRows++; }
|
|
1199
1254
|
}
|
|
1200
1255
|
for (const table of Object.keys(batches)) await flush(table);
|
|
1256
|
+
// The dashboard's tables, recomputed from what this pass just wrote. Skipped on a pass
|
|
1257
|
+
// that shipped nothing: the previous generation is still exact.
|
|
1258
|
+
if (sessions > 0) await refreshStats(client, rooms);
|
|
1201
1259
|
// Anything that only failed while reading messages — the sink is reset by the
|
|
1202
1260
|
// next getAllChats(), so unreported here means never reported at all.
|
|
1203
1261
|
reportAdapterErrors(warned);
|
|
@@ -1251,7 +1309,7 @@ async function printStats(client) {
|
|
|
1251
1309
|
|
|
1252
1310
|
async function main() {
|
|
1253
1311
|
const argv = process.argv.slice(2);
|
|
1254
|
-
|
|
1312
|
+
let client = makeClient();
|
|
1255
1313
|
try {
|
|
1256
1314
|
if (argv.includes('--ensure-schema')) {
|
|
1257
1315
|
const n = await ensureSchema(client);
|
|
@@ -1289,6 +1347,7 @@ async function main() {
|
|
|
1289
1347
|
`${r.msgRows} msg rows, ${r.toolRows} tool rows in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
1290
1348
|
} catch (e) {
|
|
1291
1349
|
failed = true;
|
|
1350
|
+
var adoptedCredential = false;
|
|
1292
1351
|
// A connection failure arrives as an AggregateError whose own `message` is empty,
|
|
1293
1352
|
// so this printed "[memhouse] pass failed:" and nothing else, after 35 lines of
|
|
1294
1353
|
// driver internals. Dig out a cause and name the fix; `status` and `doctor`
|
|
@@ -1299,7 +1358,22 @@ async function main() {
|
|
|
1299
1358
|
if (/ECONNREFUSED|fetch failed|ENOTFOUND|EAI_AGAIN|socket hang up/i.test(why + causes.join(' '))) {
|
|
1300
1359
|
console.error(`[memhouse] ${process.env.MEMHOUSE_URL} did not answer — is the house running?`);
|
|
1301
1360
|
} else if (/Authentication failed|ACCESS_DENIED|Not enough privileges/i.test(why)) {
|
|
1302
|
-
|
|
1361
|
+
// This process was spawned with one credential; the env FILE may hold a newer one
|
|
1362
|
+
// — `install --env` rotates the invite password and rewrites the file, and
|
|
1363
|
+
// `memhouse passwd` can run under a live daemon. Retrying the dead copy for
|
|
1364
|
+
// 300s a pass while the file beside it is right is what a drill member watched
|
|
1365
|
+
// for twelve minutes. Adopt the file, rebuild the client, retry now.
|
|
1366
|
+
const envPath = require('path').join(process.env.MEMHOUSE_HOME || require('path').join(require('os').homedir(), '.memhouse'), 'env');
|
|
1367
|
+
let drift = { changed: [] };
|
|
1368
|
+
try { drift = require('../envfile').credentialDrift(require('fs').readFileSync(envPath, 'utf-8'), process.env); } catch { /* no file: nothing to adopt */ }
|
|
1369
|
+
if (drift.changed.length) {
|
|
1370
|
+
for (const k of drift.changed) process.env[k] = drift.values[k];
|
|
1371
|
+
client = makeClient();
|
|
1372
|
+
adoptedCredential = true;
|
|
1373
|
+
console.error(`[memhouse] ${envPath} holds a newer ${drift.changed.map((k) => k.replace('MEMHOUSE_', '').toLowerCase()).join('/')} — adopting it and retrying now`);
|
|
1374
|
+
} else {
|
|
1375
|
+
console.error(`[memhouse] the credential this shipper holds was rejected by ${process.env.MEMHOUSE_URL} (and ${envPath} says the same)`);
|
|
1376
|
+
}
|
|
1303
1377
|
} else if (/does not exist|UNKNOWN_TABLE|UNKNOWN_DATABASE/i.test(why)) {
|
|
1304
1378
|
console.error('[memhouse] the house is missing a room this pass needed');
|
|
1305
1379
|
}
|
|
@@ -1314,7 +1388,7 @@ async function main() {
|
|
|
1314
1388
|
}
|
|
1315
1389
|
full = false; // --full applies to the first pass only; loop passes stay incremental
|
|
1316
1390
|
if (loop) {
|
|
1317
|
-
const waitMs = failed ? Math.min(retryMs, intervalSec * 1000) : intervalSec * 1000;
|
|
1391
|
+
const waitMs = failed ? (adoptedCredential ? 1000 : Math.min(retryMs, intervalSec * 1000)) : intervalSec * 1000;
|
|
1318
1392
|
if (failed) {
|
|
1319
1393
|
console.error(`[memhouse] retrying in ${Math.round(waitMs / 1000)}s`);
|
|
1320
1394
|
retryMs = Math.min(retryMs * 2, intervalSec * 1000);
|
|
@@ -1336,7 +1410,7 @@ async function main() {
|
|
|
1336
1410
|
}
|
|
1337
1411
|
}
|
|
1338
1412
|
|
|
1339
|
-
module.exports = { runShip, ensureSchema, templateColumns, decideEpoch };
|
|
1413
|
+
module.exports = { runShip, ensureSchema, refreshStats, templateColumns, decideEpoch };
|
|
1340
1414
|
|
|
1341
1415
|
if (require.main === module) {
|
|
1342
1416
|
main().catch((e) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memhouse",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.3",
|
|
4
4
|
"description": "memhouse \u2014 agent conversation memory across 17 editors: parse-on-client shippers, a typed ClickHouse store with one set of rooms per member, kernel-agency install, and the agentlytics dashboard",
|
|
5
5
|
"main": "bin/memhouse.js",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"dev": "cd ui && npm run dev",
|
|
29
29
|
"test": "node misc/syntax-check.js && node misc/unit-test.js",
|
|
30
30
|
"test:house": "node misc/house-test.js",
|
|
31
|
-
"prepack": "cd ui && npm install --no-audit --no-fund && npm run build"
|
|
31
|
+
"prepack": "cd ui && npm install --no-audit --no-fund && npm run build",
|
|
32
|
+
"prepublishOnly": "node misc/publish-guard.js"
|
|
32
33
|
},
|
|
33
34
|
"keywords": [
|
|
34
35
|
"memory",
|