memhouse 0.18.7 → 0.18.8

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.
@@ -140,6 +140,20 @@ Config file: `~/.memhouse/config.json` (`hiddenProjects`, future prefs).
140
140
  - Incremental shipping: read existing `(session_id, last_updated_at, message_count)`
141
141
  (FINAL) and skip chats that haven't grown/changed; a full re-ship must remain
142
142
  safe (ReplacingMergeTree collapses).
143
+ - **A session that is re-shipped sends only its tail.** Collapsing at FINAL makes a
144
+ re-ship *safe*, not *free*: a growing session that re-sent its whole transcript every
145
+ pass accumulated one full copy of itself per pass, and because every read carries
146
+ `final: 1`, readers pay to collapse all of them. Measured on a real 1,230-session
147
+ house: 1,226,770 stored message rows for 685,649 real ones. So once `decideEpoch` has
148
+ reused the epoch — which already proves nothing in the overlap changed — `tailRows`
149
+ drops every row whose stored `line_hash` matches the incoming one, and every tool call
150
+ matching on `(tool_name, args)`. A seq the house is missing has no stored hash, so a
151
+ pass that died mid-flush still repairs itself.
152
+ `tailSafe` withholds the tail path for the three shipped columns `line_hash` does not
153
+ cover and cannot derive: `ts` when the adapter supplies none (it is interpolated as
154
+ `seq / (total - 1)`, so every stored row's ts moves as the session grows), `host`, and
155
+ `folder`/`project`. Those sessions re-ship whole, as does `--full`, which stays the
156
+ repair hammer.
143
157
  - **The shipper is insert-only, and nothing may reintroduce a destructive verb.**
144
158
  `ReplacingMergeTree` collapses same-key rows but cannot remove a row the new parse no
145
159
  longer produces, so a shrunken re-parse leaves a stale `seq` tail. The shipper used to
@@ -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.7",
4
+ "version": "0.18.8",
5
5
  "author": {
6
6
  "name": "Ramazan Polat"
7
7
  },
@@ -22,7 +22,10 @@
22
22
  // memhouse is an accumulator, not a mirror: absence of a session is never a signal,
23
23
  // and no "sync" or "prune" feature may ever be built here.
24
24
  // - Re-shipping is always safe: ReplacingMergeTree(ingested_at) collapses to
25
- // latest-wins at FINAL. Keyed (session_id, user_id, origin, epoch, seq) on messages
25
+ // latest-wins at FINAL. Safe is not free, though — see tailRows: a re-ship sends
26
+ // only the rows the house does not already hold byte for byte, because collapsing
27
+ // duplicates is work every reader pays for on every request.
28
+ // Keyed (session_id, user_id, origin, epoch, seq) on messages
26
29
  // and (…, epoch, idx) on tool_calls, so an imported row and a shipped one at the same
27
30
  // seq are two rows, and so are two parses of the same session. sessions is
28
31
  // (session_id, user_id) with NO origin and NO epoch — one metadata row per session is
@@ -104,6 +107,16 @@ function chTs(ms) {
104
107
  `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
105
108
  }
106
109
 
110
+ // Whether the adapter gave this message its own timestamp, or the shipper has to
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
113
+ // session grows. That is what forces a session with no per-message timestamps to
114
+ // re-ship whole rather than tail-only. See rowsForChat's `tsInterpolated` and tailSafe.
115
+ function hasOwnTs(msg) {
116
+ const at = Number(msg && msg._ts);
117
+ return Number.isFinite(at) && at > 0;
118
+ }
119
+
107
120
  // When a message was actually sent.
108
121
  //
109
122
  // Prefer the adapter's own `_ts` (epoch ms). It is optional by contract — `getMessages`
@@ -124,7 +137,7 @@ function messageTs(chat, seq, total, msg) {
124
137
  // Seconds or milliseconds, depending on the format: goose stores seconds, Claude Code
125
138
  // and opencode milliseconds. Real epoch-ms is > 1e12 and real epoch-seconds ~1.7e9, so
126
139
  // the split point is unambiguous for any date this side of 1973.
127
- if (Number.isFinite(at) && at > 0) return chTs(at < 1e11 ? at * 1000 : at);
140
+ if (hasOwnTs(msg)) return chTs(at < 1e11 ? at * 1000 : at);
128
141
  const start = chat.createdAt || chat.lastUpdatedAt || Date.now();
129
142
  const end = chat.lastUpdatedAt || chat.createdAt || start;
130
143
  if (total <= 1) return chTs(start);
@@ -747,7 +760,7 @@ async function assertWriterSupported(client, rooms) {
747
760
  // must be decidable WITHOUT calling getMessages on every chat.
748
761
  async function loadExisting(client, rooms) {
749
762
  const rs = await client.query({
750
- query: `SELECT session_id, last_updated_at, message_count, extra FROM ${rooms.sessions_raw} FINAL WHERE user_id = currentUser()`,
763
+ query: `SELECT session_id, last_updated_at, message_count, extra, host, folder FROM ${rooms.sessions_raw} FINAL WHERE user_id = currentUser()`,
751
764
  format: 'JSONEachRow',
752
765
  });
753
766
  // Actual message rows per session: an interrupted re-ship (a crash mid-flush on a
@@ -848,6 +861,11 @@ async function loadExisting(client, rooms) {
848
861
  maxSeq: msgs.maxSeq,
849
862
  maxIdx: tools.maxIdx,
850
863
  hasRows: msgEpoch.has(r.session_id) || toolEpoch.has(r.session_id),
864
+ // Denormalized onto every message and tool_call row, and NOT covered by
865
+ // line_hash. A change in either means the stored rows are stale in a way the
866
+ // hash cannot see, so the session must re-ship whole. See tailRows.
867
+ host: String(r.host || ''),
868
+ folder: String(r.folder || ''),
851
869
  });
852
870
  }
853
871
  return map;
@@ -903,8 +921,13 @@ function rowsForChat(chat, host) {
903
921
  const msgRows = [];
904
922
  const toolRows = [];
905
923
  let idx = 0; // session-wide tool-call index
924
+ // Set as soon as one message has no timestamp of its own. One is enough: the
925
+ // interpolation denominator is the SESSION's length, so a single interpolated row
926
+ // means this session's stored timestamps drift the next time it grows.
927
+ let tsInterpolated = false;
906
928
  for (let seq = 0; seq < total; seq++) {
907
929
  const m = messages[seq];
930
+ if (!hasOwnTs(m)) tsInterpolated = true;
908
931
  const raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content) ?? '';
909
932
  const text = truncate(raw, TEXT_MAX);
910
933
  const ts = messageTs(chat, seq, total, m);
@@ -969,7 +992,7 @@ function rowsForChat(chat, host) {
969
992
  // message half; without the tool half a pass that dies between the messages insert and
970
993
  // the tool_calls insert leaves a session that looks finished forever. See loadExisting.
971
994
  session.extra.toolCallCount = toolRows.length;
972
- return { session, msgRows, toolRows };
995
+ return { session, msgRows, toolRows, tsInterpolated };
973
996
  }
974
997
 
975
998
  /**
@@ -1042,6 +1065,78 @@ function decideEpoch(stored, incoming) {
1042
1065
  return { epoch, reason: null };
1043
1066
  }
1044
1067
 
1068
+ /**
1069
+ * Whether this session's already-stored rows can be left alone, or have to be rewritten.
1070
+ *
1071
+ * `line_hash` deliberately covers CONTENT only — session_id, seq, role, model, the four
1072
+ * token counts, text. Three shipped columns sit outside it and are not derived from
1073
+ * anything inside it, so a row can be stale in a way no hash comparison can see:
1074
+ *
1075
+ * ts. Interpolated across [createdAt, lastUpdatedAt] as `seq / (total - 1)` for the
1076
+ * adapters that record no per-message time. The denominator is the session's length,
1077
+ * so every stored row's ts moves the moment the session grows. Adapters that DO supply
1078
+ * `_ts` (Claude Code, opencode, goose, …) are exempt — their timestamps are facts, not
1079
+ * functions of the parse.
1080
+ *
1081
+ * host. The machine that shipped the row. Two machines shipping one session (a synced
1082
+ * home directory) must keep overwriting each other, as they do today.
1083
+ *
1084
+ * folder / project. Denormalized onto every message and tool_call row; a moved or
1085
+ * renamed project has to reach the rows that carry it.
1086
+ *
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.
1089
+ *
1090
+ * When this returns false the session ships whole, exactly as it always has. Pure.
1091
+ */
1092
+ function tailSafe(prev, rows, host) {
1093
+ if (rows.tsInterpolated) return false;
1094
+ if (String(prev.host || '') !== String(host || '')) return false;
1095
+ return String(prev.folder || '') === String((rows.session && rows.session.folder) || '');
1096
+ }
1097
+
1098
+ /**
1099
+ * Drop the rows the house already holds, byte for byte, and keep only what is new.
1100
+ *
1101
+ * A session that is still being written grows a few messages at a time, but the shipper
1102
+ * re-parses and re-sends the WHOLE transcript every pass. ReplacingMergeTree accepts all
1103
+ * of it — every already-stored row lands again under its own key as another version — so
1104
+ * an actively used session accumulates one full copy of itself per pass. Measured on a
1105
+ * real 1,230-session house: 1,226,770 stored rows for 685,649 real ones, 1.79 copies of
1106
+ * the average row and seven copies of the worst. Every read carries `final=1`, so the
1107
+ * dashboard pays for all of them on every request.
1108
+ *
1109
+ * The versions are not merely redundant, they are IDENTICAL: same key, same content.
1110
+ * decideEpoch has already established that (it forks the session to a new epoch the
1111
+ * moment anything in the overlap differs), so by the time this runs, a row whose stored
1112
+ * line_hash equals the incoming one cannot differ in any column line_hash covers.
1113
+ *
1114
+ * Pure, and exported, for the same reason decideEpoch is: it decides what does and does
1115
+ * not reach the house, and it is unit tested without a server.
1116
+ *
1117
+ * PRECONDITION, enforced by the caller: only valid when decideEpoch reused the epoch AND
1118
+ * the columns line_hash does NOT cover are stable for this session — see
1119
+ * `tsInterpolated`, `host` and `folder` in runShip. A gap in the overlap (a seq the house
1120
+ * is missing, from a pass that died mid-flush) has no stored hash, so it is kept and the
1121
+ * next pass repairs itself.
1122
+ *
1123
+ * @param {object} stored { hashes: Map<seq,string>, tools: Map<idx,{tool_name,args}> }
1124
+ * @param {object} incoming { msgRows, toolRows } as built by rowsForChat.
1125
+ * @returns {{ msgRows: object[], toolRows: object[] }} the subset worth sending.
1126
+ */
1127
+ function tailRows(stored, incoming) {
1128
+ const hashes = stored.hashes || new Map();
1129
+ const tools = stored.tools || new Map();
1130
+ return {
1131
+ msgRows: incoming.msgRows.filter((r) => hashes.get(r.seq) !== String(r.line_hash)),
1132
+ // Tool calls carry no hash — same comparison decideEpoch makes.
1133
+ toolRows: incoming.toolRows.filter((r) => {
1134
+ const was = tools.get(r.idx);
1135
+ return !was || was.tool_name !== r.tool_name || was.args !== r.args;
1136
+ }),
1137
+ };
1138
+ }
1139
+
1045
1140
  /**
1046
1141
  * What the house currently holds for ONE session, at its current epoch — the input
1047
1142
  * decideEpoch needs to tell an append from a rewrite.
@@ -1159,6 +1254,7 @@ async function runShip(client, opts = {}) {
1159
1254
 
1160
1255
  const seen = new Set(); // adapters must not double-ship a session id within a pass
1161
1256
  let sessions = 0, skipped = 0, msgRows = 0, toolRows = 0, unreadable = 0, bumped = 0, withheld = 0;
1257
+ let msgRowsDeduped = 0, toolRowsDeduped = 0;
1162
1258
  for (const chat of chats) {
1163
1259
  if (chat.encrypted) continue;
1164
1260
  // Same canonical '<source>:<adapter-local id>' as rowsForChat: dedup and
@@ -1235,6 +1331,9 @@ async function runShip(client, opts = {}) {
1235
1331
  // default), so a shorter re-parse is at least as likely to mean "the source lost it"
1236
1332
  // as "the adapter was fixed". The house is supposed to outlive the source.
1237
1333
  let epoch = 0;
1334
+ // What actually gets sent. Defaults to the whole parse; narrowed to the tail below
1335
+ // when — and only when — every row it drops is already in the house unchanged.
1336
+ let send = rows;
1238
1337
  if (prev && prev.hasRows) {
1239
1338
  const stored = await loadStoredParse(client, rooms, id, rooms.user, prev.epoch);
1240
1339
  const d = decideEpoch({ ...prev, ...stored }, rows);
@@ -1242,6 +1341,12 @@ async function runShip(client, opts = {}) {
1242
1341
  if (d.reason) {
1243
1342
  bumped++;
1244
1343
  console.log(`[memhouse] ${id} → epoch ${epoch} (${d.reason}); the previous parse is kept`);
1344
+ } else if (!full && tailSafe(prev, rows, host)) {
1345
+ // Same epoch, and nothing outside line_hash's reach has moved: every row the
1346
+ // house already holds would land again byte-identical. Send the tail only.
1347
+ send = tailRows(stored, rows);
1348
+ msgRowsDeduped += rows.msgRows.length - send.msgRows.length;
1349
+ toolRowsDeduped += rows.toolRows.length - send.toolRows.length;
1245
1350
  }
1246
1351
  } else if (prev) {
1247
1352
  epoch = toInt(prev.epoch);
@@ -1249,8 +1354,8 @@ async function runShip(client, opts = {}) {
1249
1354
  rows.session.epoch = epoch;
1250
1355
  await push('sessions', scrub(rows.session));
1251
1356
  sessions++;
1252
- for (const r of rows.msgRows) { r.epoch = epoch; await push('messages', scrub(r)); msgRows++; }
1253
- for (const r of rows.toolRows) { r.epoch = epoch; await push('tool_calls', scrub(r)); toolRows++; }
1357
+ for (const r of send.msgRows) { r.epoch = epoch; await push('messages', scrub(r)); msgRows++; }
1358
+ for (const r of send.toolRows) { r.epoch = epoch; await push('tool_calls', scrub(r)); toolRows++; }
1254
1359
  }
1255
1360
  for (const table of Object.keys(batches)) await flush(table);
1256
1361
  // The dashboard's tables, recomputed from what this pass just wrote. Skipped on a pass
@@ -1259,7 +1364,7 @@ async function runShip(client, opts = {}) {
1259
1364
  // Anything that only failed while reading messages — the sink is reset by the
1260
1365
  // next getAllChats(), so unreported here means never reported at all.
1261
1366
  reportAdapterErrors(warned);
1262
- return { sessions, skipped, msgRows, toolRows, unreadable, bumped, withheld };
1367
+ return { sessions, skipped, msgRows, toolRows, unreadable, bumped, withheld, msgRowsDeduped, toolRowsDeduped };
1263
1368
  }
1264
1369
 
1265
1370
  // Warn once per adapter per pass. `warned` carries across the two call sites so one
@@ -1365,7 +1470,12 @@ async function main() {
1365
1470
  + `${r.unreadable ? `, ${r.unreadable} unreadable-will-retry` : ''}`
1366
1471
  + `${r.withheld ? `, ${r.withheld} withheld-empty` : ''}`
1367
1472
  + `${r.bumped ? `, ${r.bumped} kept an earlier parse` : ''}) → ` +
1368
- `${r.msgRows} msg rows, ${r.toolRows} tool rows in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
1473
+ `${r.msgRows} msg rows, ${r.toolRows} tool rows` +
1474
+ // What the tail-only path saved. Worth printing: it is the difference between a
1475
+ // house that grows with the conversation and one that grows with the passes.
1476
+ `${r.msgRowsDeduped || r.toolRowsDeduped
1477
+ ? ` (${r.msgRowsDeduped + r.toolRowsDeduped} unchanged rows not re-sent)` : ''}` +
1478
+ ` in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
1369
1479
  } catch (e) {
1370
1480
  failed = true;
1371
1481
  var adoptedCredential = false;
@@ -1431,7 +1541,7 @@ async function main() {
1431
1541
  }
1432
1542
  }
1433
1543
 
1434
- module.exports = { runShip, ensureSchema, refreshStats, templateColumns, decideEpoch };
1544
+ module.exports = { runShip, ensureSchema, refreshStats, templateColumns, decideEpoch, tailRows, tailSafe };
1435
1545
 
1436
1546
  if (require.main === module) {
1437
1547
  main().catch((e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memhouse",
3
- "version": "0.18.7",
3
+ "version": "0.18.8",
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": {