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.
package/memhouse/DESIGN.md
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "0.18.8",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ramazan Polat"
|
|
7
7
|
},
|
package/memhouse/shipper/ship.js
CHANGED
|
@@ -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.
|
|
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 (
|
|
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
|
|
1253
|
-
for (const r of
|
|
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
|
|
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.
|
|
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": {
|