memhouse 0.18.10 → 0.18.11

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.
@@ -66,7 +66,7 @@ const selfSnap = selfUpdate.snapshot(__filename);
66
66
  const {
67
67
  resolveRooms, READ_SETTINGS, ROOM_TYPES, META_TYPES, SCHEMA_VERSION, MEMBER_PIN, createStatement,
68
68
  SUPPORTED_SCHEMAS, keyProblem, legacyTextIndexDialect, isTextIndexGrammarRefusal, textIndexDialectFor,
69
- statCreateStatements, statRefreshStatements, statTableNames,
69
+ statCreateStatements, statRefreshStatements, statTableNames, statsNeedRefresh,
70
70
  } = require('../house/house');
71
71
  // Rooms plus the house's own record of itself. Every table the template declares, which
72
72
  // is what the column healer and the drift warning have to cover — a column added to
@@ -109,7 +109,7 @@ function chTs(ms) {
109
109
 
110
110
  // Whether the adapter gave this message its own timestamp, or the shipper has to
111
111
  // interpolate one. The distinction matters beyond accuracy: an interpolated ts is a
112
- // function of `seq / (total - 1)`, so EVERY already-stored row's ts moves when the
112
+ // function of `pos / (total - 1)` (position in the parse), so EVERY already-stored row's ts moves when the
113
113
  // session grows. That is what forces a session with no per-message timestamps to
114
114
  // re-ship whole rather than tail-only. See rowsForChat's `tsInterpolated` and tailSafe.
115
115
  function hasOwnTs(msg) {
@@ -127,12 +127,12 @@ function hasOwnTs(msg) {
127
127
  // and the search skill reported those to the user as fact.
128
128
  //
129
129
  // Fall back to interpolating across [createdAt, lastUpdatedAt] for the adapters that
130
- // genuinely have nothing per message — monotonic-by-seq, so sessions_v started/ended
130
+ // genuinely have nothing per message — monotonic by position, so sessions_v started/ended
131
131
  // still line up with the session bounds (same approximation as agency/ingest.js;
132
132
  // documented in DESIGN.md). Real timestamps are NOT necessarily monotonic by seq — a
133
133
  // folded subagent transcript is appended after its parent's turns but ran during them.
134
134
  // Nothing depends on that: ordering is by `seq`, and started/ended are min/max.
135
- function messageTs(chat, seq, total, msg) {
135
+ function messageTs(chat, pos, total, msg) {
136
136
  const at = Number(msg && msg._ts);
137
137
  // Seconds or milliseconds, depending on the format: goose stores seconds, Claude Code
138
138
  // and opencode milliseconds. Real epoch-ms is > 1e12 and real epoch-seconds ~1.7e9, so
@@ -141,7 +141,9 @@ function messageTs(chat, seq, total, msg) {
141
141
  const start = chat.createdAt || chat.lastUpdatedAt || Date.now();
142
142
  const end = chat.lastUpdatedAt || chat.createdAt || start;
143
143
  if (total <= 1) return chTs(start);
144
- return chTs(start + Math.round((end - start) * (seq / (total - 1))));
144
+ // `pos` is the message's position in the parse, NOT its seq: a folded subagent's seq
145
+ // is sparse (see subagentSeq) and would put its interpolated time far past the session.
146
+ return chTs(start + Math.round((end - start) * (pos / (total - 1))));
145
147
  }
146
148
 
147
149
  /**
@@ -875,6 +877,42 @@ async function loadExisting(client, rooms) {
875
877
  // m._toolCalls: matches '[tool-call: Name(' and '[tool-call: Name]' forms.
876
878
  const TOOL_CALL_RE = /\[tool-call: ([^(\]]+)/g;
877
879
 
880
+ // Where a folded subagent's rows are numbered. A session's own turns keep the dense
881
+ // 0, 1, 2, … they always had; subagent number `slot` (see getMessages in
882
+ // editors/claude.js) owns the block
883
+ //
884
+ // seq = SUBAGENT_SEQ_BASE + slot * SUBAGENT_SEQ_STRIDE + turn
885
+ //
886
+ // and its tool calls the same block of `idx`, counted within that subagent. So a row's
887
+ // seq depends only on the subagent it belongs to and its position inside it — never on
888
+ // how long the parent is, how long any other subagent is, or how many there are.
889
+ //
890
+ // It used to be the row's position in the whole parse, with subagents folded in after the
891
+ // parent. One new parent turn then moved every subagent row down by one; decideEpoch saw
892
+ // stored rows whose line_hash (which covers seq) no longer matched, forked the session to
893
+ // a new epoch, and re-sent it whole — on every pass, for every session that had ever
894
+ // spawned a subagent. Measured on a real house: 1.07 M message rows in 24 h against 10.2 M
895
+ // in total, most of them full copies of five sessions, one stored 60 times in a day.
896
+ //
897
+ // Decimal so a seq reads at a glance (1,003,000,042 = subagent 3, turn 42), and sized for
898
+ // UInt32: 1e9 parent turns, 32,949 subagents of 100,000 turns each (the largest sessions
899
+ // today hold ~33 k parent lines and 35 subagents). A parse that does not fit falls back to
900
+ // positional numbering, which is correct and merely re-ships whole, as before.
901
+ const SUBAGENT_SEQ_BASE = 1000000000;
902
+ const SUBAGENT_SEQ_STRIDE = 100000;
903
+ const SUBAGENT_SLOTS = Math.floor((2 ** 32 - SUBAGENT_SEQ_BASE) / SUBAGENT_SEQ_STRIDE);
904
+
905
+ // The block a folded subagent turn belongs to, or null for the session's own turns and for
906
+ // adapters that fold nothing.
907
+ function subagentSlot(m) {
908
+ const a = m && m._agent;
909
+ return a && Number.isInteger(a.slot) && Number.isInteger(a.turn) ? a.slot : null;
910
+ }
911
+ function subagentSeq(slot, n) {
912
+ if (slot < 0 || slot >= SUBAGENT_SLOTS || n < 0 || n >= SUBAGENT_SEQ_STRIDE) return null;
913
+ return SUBAGENT_SEQ_BASE + slot * SUBAGENT_SEQ_STRIDE + n;
914
+ }
915
+
878
916
  // Build the typed rows for one chat. Returns null when the chat is unreadable
879
917
  // (adapter threw — e.g. a partially-written or locked session file): the caller
880
918
  // must write NOTHING for it, so the next incremental pass retries. A chat that
@@ -891,6 +929,13 @@ function rowsForChat(chat, host) {
891
929
  adapterErrorSink.record(chat.source, e, chat.composerId);
892
930
  return null;
893
931
  }
932
+ // The adapter asked to wait a pass: what it read is not yet the session's settled shape
933
+ // (a subagent with no timestamped line — see editors/claude.js). Not an error; the next
934
+ // pass reads it again.
935
+ if (messages._defer) {
936
+ console.log(`[memhouse] ${chat.source}:${chat.composerId} held for one pass — ${messages._defer}`);
937
+ return null;
938
+ }
894
939
 
895
940
  const source = chat.source;
896
941
  // Canonical globally-unique session id: '<source>:<adapter-local id>'.
@@ -918,19 +963,47 @@ function rowsForChat(chat, host) {
918
963
  extra: { bubbleCount: chat.bubbleCount || 0 },
919
964
  };
920
965
 
966
+ // Stable numbering first; positional if the parse does not fit it (see subagentSeq).
967
+ const built = buildRows(chat, messages, { id, source, host, folder, project }, true)
968
+ || buildRows(chat, messages, { id, source, host, folder, project }, false);
969
+ // What a complete ship of this session looks like, recorded ON the session row so the
970
+ // next pass can tell "finished" from "got part way". message_count already carries the
971
+ // message half; without the tool half a pass that dies between the messages insert and
972
+ // the tool_calls insert leaves a session that looks finished forever. See loadExisting.
973
+ session.extra.toolCallCount = built.toolRows.length;
974
+ return { session, ...built };
975
+ }
976
+
977
+ // The message and tool_call rows for one parse. `stable` numbers folded subagent rows by
978
+ // their slot (subagentSeq) and returns null if any of them does not fit; otherwise every
979
+ // row is numbered by its position, the pre-0.18.10 scheme. For a session with no subagents
980
+ // the two are identical.
981
+ function buildRows(chat, messages, { id, source, host, folder, project }, stable) {
982
+ const total = messages.length;
921
983
  const msgRows = [];
922
984
  const toolRows = [];
923
- let idx = 0; // session-wide tool-call index
985
+ let seqNext = 0; // the session's own turns, dense from 0
986
+ let idx = 0; // the session's own tool calls, dense from 0
987
+ const subIdx = new Map(); // slot → tool calls seen so far in that subagent
924
988
  // Set as soon as one message has no timestamp of its own. One is enough: the
925
989
  // interpolation denominator is the SESSION's length, so a single interpolated row
926
990
  // means this session's stored timestamps drift the next time it grows.
927
991
  let tsInterpolated = false;
928
- for (let seq = 0; seq < total; seq++) {
929
- const m = messages[seq];
992
+ for (let pos = 0; pos < total; pos++) {
993
+ const m = messages[pos];
994
+ const slot = stable ? subagentSlot(m) : null;
995
+ let seq;
996
+ if (slot === null) {
997
+ if (stable && seqNext >= SUBAGENT_SEQ_BASE) return null;
998
+ seq = seqNext++;
999
+ } else {
1000
+ seq = subagentSeq(slot, m._agent.turn);
1001
+ if (seq === null) return null;
1002
+ }
930
1003
  if (!hasOwnTs(m)) tsInterpolated = true;
931
1004
  const raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content) ?? '';
932
1005
  const text = truncate(raw, TEXT_MAX);
933
- const ts = messageTs(chat, seq, total, m);
1006
+ const ts = messageTs(chat, pos, total, m);
934
1007
  const row = {
935
1008
  session_id: id,
936
1009
  seq,
@@ -973,10 +1046,18 @@ function rowsForChat(chat, host) {
973
1046
  }
974
1047
  for (const tc of calls) {
975
1048
  const args = JSON.stringify(tc.args);
1049
+ let at;
1050
+ if (slot === null) at = idx++;
1051
+ else {
1052
+ const n = subIdx.get(slot) || 0;
1053
+ subIdx.set(slot, n + 1);
1054
+ at = subagentSeq(slot, n);
1055
+ if (at === null) return null;
1056
+ }
976
1057
  toolRows.push({
977
1058
  session_id: id,
978
1059
  seq,
979
- idx: idx++,
1060
+ idx: at,
980
1061
  source,
981
1062
  host,
982
1063
  tool_name: tc.name,
@@ -987,12 +1068,7 @@ function rowsForChat(chat, host) {
987
1068
  });
988
1069
  }
989
1070
  }
990
- // What a complete ship of this session looks like, recorded ON the session row so the
991
- // next pass can tell "finished" from "got part way". message_count already carries the
992
- // message half; without the tool half a pass that dies between the messages insert and
993
- // the tool_calls insert leaves a session that looks finished forever. See loadExisting.
994
- session.extra.toolCallCount = toolRows.length;
995
- return { session, msgRows, toolRows, tsInterpolated };
1071
+ return { msgRows, toolRows, tsInterpolated };
996
1072
  }
997
1073
 
998
1074
  /**
@@ -1037,26 +1113,43 @@ function decideEpoch(stored, incoming) {
1037
1113
 
1038
1114
  const maxSeq = Number.isFinite(stored.maxSeq) ? stored.maxSeq : -1;
1039
1115
  const maxIdx = Number.isFinite(stored.maxIdx) ? stored.maxIdx : -1;
1040
- if (incoming.msgRows.length - 1 < maxSeq) {
1041
- return bump(`${maxSeq + 1} messages stored, ${incoming.msgRows.length} parsed`);
1116
+ const hashes = stored.hashes || new Map();
1117
+ const tools = stored.tools || new Map();
1118
+
1119
+ // SHRINK is "the house holds a row this parse does not produce", checked per key. It
1120
+ // used to be `parsed - 1 < maxSeq`, which is the same question only while seq is dense
1121
+ // 0..N-1; a folded subagent's rows are numbered from a sparse block (subagentSeq), so a
1122
+ // vanished subagent or a stale pre-0.18.10 numbering leaves stored keys inside the range
1123
+ // that nothing overwrites. The max comparison stays for a caller holding no hashes.
1124
+ const orphans = (have, rows, key, max) => {
1125
+ const want = new Set(rows.map((r) => r[key]));
1126
+ let missing = 0;
1127
+ for (const k of have.keys()) if (!want.has(k)) missing++;
1128
+ const inMax = rows.reduce((a, r) => Math.max(a, r[key]), -1);
1129
+ // How many the house holds, for the log line. `max + 1` is a count only while keys are
1130
+ // dense; with subagent blocks max is ~1e9, so it said "1000000123 messages stored".
1131
+ return { missing, beyond: max > inMax, stored: have.size || max + 1 };
1132
+ };
1133
+ const mo = orphans(hashes, incoming.msgRows, 'seq', maxSeq);
1134
+ if (mo.missing || mo.beyond) {
1135
+ return bump(`${mo.stored} messages stored, ${incoming.msgRows.length} parsed${mo.missing ? `, ${mo.missing} of them not in the new parse` : ''}`);
1042
1136
  }
1043
- if (incoming.toolRows.length - 1 < maxIdx) {
1044
- return bump(`${maxIdx + 1} tool calls stored, ${incoming.toolRows.length} parsed`);
1137
+ const to = orphans(tools, incoming.toolRows, 'idx', maxIdx);
1138
+ if (to.missing || to.beyond) {
1139
+ return bump(`${to.stored} tool calls stored, ${incoming.toolRows.length} parsed${to.missing ? `, ${to.missing} of them not in the new parse` : ''}`);
1045
1140
  }
1046
1141
 
1047
1142
  // A seq the house does not hold is a gap, not a disagreement — there is nothing there
1048
1143
  // to destroy, so it is not a reason to fork the session.
1049
- const hashes = stored.hashes || new Map();
1050
1144
  for (const row of incoming.msgRows) {
1051
- if (row.seq > maxSeq) break;
1145
+ if (row.seq > maxSeq) continue;
1052
1146
  const was = hashes.get(row.seq);
1053
1147
  if (was !== undefined && was !== String(row.line_hash)) {
1054
1148
  return bump(`message ${row.seq} was rewritten`);
1055
1149
  }
1056
1150
  }
1057
- const tools = stored.tools || new Map();
1058
1151
  for (const row of incoming.toolRows) {
1059
- if (row.idx > maxIdx) break;
1152
+ if (row.idx > maxIdx) continue;
1060
1153
  const was = tools.get(row.idx);
1061
1154
  if (was !== undefined && (was.tool_name !== row.tool_name || was.args !== row.args)) {
1062
1155
  return bump(`tool call ${row.idx} was rewritten`);
@@ -1072,7 +1165,7 @@ function decideEpoch(stored, incoming) {
1072
1165
  * token counts, text. Three shipped columns sit outside it and are not derived from
1073
1166
  * anything inside it, so a row can be stale in a way no hash comparison can see:
1074
1167
  *
1075
- * ts. Interpolated across [createdAt, lastUpdatedAt] as `seq / (total - 1)` for the
1168
+ * ts. Interpolated across [createdAt, lastUpdatedAt] as `pos / (total - 1)` (position in the parse, not seq) for the
1076
1169
  * adapters that record no per-message time. The denominator is the session's length,
1077
1170
  * so every stored row's ts moves the moment the session grows. Adapters that DO supply
1078
1171
  * `_ts` (Claude Code, opencode, goose, …) are exempt — their timestamps are facts, not
@@ -1084,8 +1177,11 @@ function decideEpoch(stored, incoming) {
1084
1177
  * folder / project. Denormalized onto every message and tool_call row; a moved or
1085
1178
  * renamed project has to reach the rows that carry it.
1086
1179
  *
1087
- * `source` cannot change (it is the session_id prefix), `is_subagent` is derived from
1088
- * `text`, and messages' `extra` is always empty — so those three need no check.
1180
+ * `source` cannot change (it is the session_id prefix) and `is_subagent` is derived from
1181
+ * `text`, so neither needs a check. Messages' `extra` carries a folded subagent's
1182
+ * `agent` stamp; its id and turn are fixed by the row's seq, and a description the parent
1183
+ * supplies only later (a background fork named by its task notification) is not worth
1184
+ * re-shipping a session for.
1089
1185
  *
1090
1186
  * When this returns false the session ships whole, exactly as it always has. Pure.
1091
1187
  */
@@ -1169,6 +1265,24 @@ async function loadStoredParse(client, rooms, id, uid, epoch) {
1169
1265
  return { hashes, tools };
1170
1266
  }
1171
1267
 
1268
+ /**
1269
+ * How old the newest stats generation is, in ms. Infinity when the table is empty (a TTL
1270
+ * emptied it, or no refresh ever landed); null when it cannot be read at all, so a house
1271
+ * without the tables is not refreshed on every idle pass. Cheap: count() is metadata and
1272
+ * refreshed_at leads the sorting key.
1273
+ */
1274
+ async function statsAgeMs(client, rooms) {
1275
+ try {
1276
+ const t = statTableNames(rooms.member).session_stats;
1277
+ const rs = await client.query({
1278
+ query: `SELECT count() AS n, dateDiff('second', max(refreshed_at), now64(3)) AS age FROM ${t}`,
1279
+ format: 'JSONEachRow',
1280
+ });
1281
+ const [r] = await rs.json();
1282
+ return Number(r.n) > 0 ? Number(r.age) * 1000 : Infinity;
1283
+ } catch { return null; }
1284
+ }
1285
+
1172
1286
  /**
1173
1287
  * Recompute the three stat tables — one generation, one INSERT each, all stamped with the
1174
1288
  * same refreshed_at. Runs at the end of every ship pass, which is exactly the cadence a
@@ -1358,9 +1472,12 @@ async function runShip(client, opts = {}) {
1358
1472
  for (const r of send.toolRows) { r.epoch = epoch; await push('tool_calls', scrub(r)); toolRows++; }
1359
1473
  }
1360
1474
  for (const table of Object.keys(batches)) await flush(table);
1361
- // The dashboard's tables, recomputed from what this pass just wrote. Skipped on a pass
1362
- // that shipped nothing: the previous generation is still exact.
1363
- if (sessions > 0) await refreshStats(client, rooms);
1475
+ // The dashboard's tables, recomputed from what this pass just wrote. A pass that shipped
1476
+ // nothing leaves an exact generation alone — unless it is getting old: the tables carry a
1477
+ // one-day TTL, and a quiet house used to age out of its dashboard entirely (G11).
1478
+ if (statsNeedRefresh({ shipped: sessions, ageMs: sessions > 0 ? 0 : await statsAgeMs(client, rooms) })) {
1479
+ await refreshStats(client, rooms);
1480
+ }
1364
1481
  // Anything that only failed while reading messages — the sink is reset by the
1365
1482
  // next getAllChats(), so unreported here means never reported at all.
1366
1483
  reportAdapterErrors(warned);
@@ -1541,7 +1658,7 @@ async function main() {
1541
1658
  }
1542
1659
  }
1543
1660
 
1544
- module.exports = { runShip, ensureSchema, refreshStats, templateColumns, decideEpoch, tailRows, tailSafe };
1661
+ module.exports = { runShip, ensureSchema, refreshStats, templateColumns, decideEpoch, tailRows, tailSafe, rowsForChat, buildRows, SUBAGENT_SEQ_BASE, SUBAGENT_SEQ_STRIDE };
1545
1662
 
1546
1663
  if (require.main === module) {
1547
1664
  main().catch((e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memhouse",
3
- "version": "0.18.10",
3
+ "version": "0.18.11",
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": {