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.
@@ -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 };
@@ -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 } = require('../house/house');
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
- extra: {},
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
- const client = makeClient();
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
- console.error(`[memhouse] the credential in ${process.env.MEMHOUSE_HOME || '~/.memhouse'}/env was rejected by ${process.env.MEMHOUSE_URL}`);
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.1",
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",