memhouse 0.4.4 → 0.7.1

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.
Files changed (63) hide show
  1. package/README.md +244 -114
  2. package/bin/memhouse.js +1177 -231
  3. package/editors/base.js +6 -0
  4. package/editors/claude.js +44 -9
  5. package/editors/goose.js +4 -0
  6. package/editors/index.js +0 -0
  7. package/editors/opencode.js +3 -0
  8. package/{mem-house → memhouse}/COMPETITION.md +5 -5
  9. package/{mem-house → memhouse}/DESIGN.md +18 -12
  10. package/memhouse/RESUME-AND-UPDATE.md +185 -0
  11. package/{mem-house → memhouse}/delivery/AGENT-INSTALL-PROMPT.md +1 -1
  12. package/{mem-house → memhouse}/delivery/AGENT-INSTALL.md +9 -7
  13. package/{mem-house → memhouse}/delivery/PROMPT.md +8 -8
  14. package/{mem-house → memhouse}/delivery/kernel-install.md +3 -3
  15. package/{mem-house → memhouse}/delivery/plugin/.claude-plugin/plugin.json +1 -1
  16. package/{mem-house/delivery/skills/memhouse-search → memhouse/delivery/plugin/skills/search}/SKILL.md +60 -18
  17. package/memhouse/delivery/plugin/skills/sessions/SKILL.md +177 -0
  18. package/memhouse/delivery/plugin/skills/sql/SKILL.md +206 -0
  19. package/memhouse/host.js +101 -0
  20. package/{mem-house → memhouse}/per-member/INSTALL.md +59 -14
  21. package/{mem-house → memhouse}/per-member/SCHEMA.md +47 -0
  22. package/{mem-house → memhouse}/per-member/SHARING.md +47 -0
  23. package/memhouse/per-member/provision.js +297 -0
  24. package/memhouse/per-member/rooms.js +252 -0
  25. package/{mem-house → memhouse}/per-member/schema-member.sql.tpl +18 -14
  26. package/{mem-house → memhouse}/per-member/schema-merge.sql.tpl +3 -3
  27. package/memhouse/resume.js +150 -0
  28. package/memhouse/self-update.js +150 -0
  29. package/{mem-house → memhouse}/server/queries.js +80 -34
  30. package/memhouse/server/server.js +451 -0
  31. package/{mem-house → memhouse}/service.js +48 -29
  32. package/memhouse/shipper/ship.js +949 -0
  33. package/package.json +5 -20
  34. package/public/assets/{index-CtH-5i95.js → index-CnWzrjF3.js} +10 -7
  35. package/public/index.html +2 -2
  36. package/ui/index.html +1 -1
  37. package/ui/src/App.jsx +1 -1
  38. package/ui/src/lib/api.js +29 -13
  39. package/ui/src/pages/Dashboard.jsx +8 -5
  40. package/ui/src/pages/Sessions.jsx +3 -1
  41. package/ui/src/pages/SqlViewer.jsx +6 -23
  42. package/cache.js +0 -850
  43. package/ch-ingest.js +0 -329
  44. package/ch-schema.js +0 -152
  45. package/clickhouse.js +0 -87
  46. package/index.js +0 -425
  47. package/mcp-server.js +0 -294
  48. package/mem-house/delivery/install.sh +0 -183
  49. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +0 -125
  50. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +0 -146
  51. package/mem-house/per-member/provision.js +0 -142
  52. package/mem-house/per-member/rooms.js +0 -132
  53. package/mem-house/server/server.js +0 -228
  54. package/mem-house/shipper/ship.js +0 -566
  55. package/relay-client.js +0 -306
  56. package/relay-server.js +0 -577
  57. package/server.js +0 -860
  58. package/share-image.js +0 -367
  59. /package/{mem-house → memhouse}/deploy.js +0 -0
  60. /package/{mem-house → memhouse}/envfile.js +0 -0
  61. /package/{mem-house → memhouse}/per-member/PLAN.md +0 -0
  62. /package/{mem-house → memhouse}/per-member/PROVISIONING.md +0 -0
  63. /package/{mem-house → memhouse}/per-member/README.md +0 -0
@@ -1,566 +0,0 @@
1
- #!/usr/bin/env node
2
- // mem-house SHIPPER — parse-on-client (bet #1 in ../DESIGN.md). Runs the 17 editor
3
- // adapters locally and ships TYPED rows into THE CALLER'S OWN rooms — sessions_<m> /
4
- // messages_<m> / tool_calls_<m>, where <m> is `SELECT currentUser()` and never config
5
- // (../per-member/schema-member.sql.tpl, ../per-member/rooms.js). All of one member's
6
- // machines land in that member's rooms, told apart by the `host` column; no member
7
- // writes into another's. The typed evolution of agency/ingest.js: same host id,
8
- // same ts interpolation, same batching discipline — but rows land in physical
9
- // columns instead of a raw JSON blob.
10
- //
11
- // Data-plane rules (binding, see DESIGN.md):
12
- // - JSONEachRow, batches <= 2000 rows, async_insert=0 on EVERY insert — the house
13
- // stamps user_id MATERIALIZED currentUser(), which is empty during async flushes.
14
- // - DateTime64 values travel as 'YYYY-MM-DD HH:MM:SS.mmm' UTC strings (plain
15
- // format; ISO 'T'/'Z' forms parse unreliably under JSONEachRow). Nullable → null.
16
- // - Int64-bound values are integer-coerced (some adapters emit fractional ms).
17
- // - Re-shipping is always safe: ReplacingMergeTree(ingested_at) keyed
18
- // (session_id, user_id, seq) collapses to latest-wins at FINAL.
19
- //
20
- // CLI: node ship.js one incremental pass
21
- // node ship.js --loop [sec] repeat every sec seconds (default 300)
22
- // node ship.js --full ignore the incremental skip (re-ship everything)
23
- // node ship.js --ensure-schema create the caller's own rooms and exit
24
- // node ship.js --stats per-source counts from sessions_v and exit
25
- //
26
- // Env (DESIGN.md contract): MEMHOUSE_URL / MEMHOUSE_USER / MEMHOUSE_PASSWORD /
27
- // MEMHOUSE_DB — defaults http://localhost:8123 / memhouse_root / '' / mem.
28
-
29
- const os = require('os');
30
- const fs = require('fs');
31
- const path = require('path');
32
- const crypto = require('crypto');
33
- const { createClient } = require('@clickhouse/client');
34
- const { getAllChats, getAdapterErrors, getMessages, resetCaches } = require('../../editors');
35
- const adapterErrorSink = require('../../editors/adapter-errors');
36
- const { resolveRooms, READ_SETTINGS, ROOM_TYPES } = require('../per-member/rooms');
37
-
38
- const BATCH_ROWS = 2000; // insert batch ceiling (binding)
39
- const TEXT_MAX = 50000; // messages.text truncation
40
- const ARGS_MAX = 20000; // tool_calls.args truncation
41
-
42
- // Stable host id: <shorthostname>-<8hex sha256(machine seed)> (memory-house convention).
43
- function hostId() {
44
- const short = (os.hostname() || 'host').split('.')[0];
45
- const seed = `${os.hostname()}|${os.platform()}|${os.arch()}`;
46
- const h = crypto.createHash('sha256').update(seed).digest('hex').slice(0, 8);
47
- return `${short}-${h}`;
48
- }
49
-
50
- // UInt64 (as decimal string) = first 8 bytes big-endian of sha256 over the row's
51
- // content fields. Decimal string survives JSONEachRow → UInt64 without precision loss.
52
- function lineHash(obj) {
53
- return crypto.createHash('sha256').update(JSON.stringify(obj)).digest().readBigUInt64BE(0).toString();
54
- }
55
-
56
- // DateTime64(3) wire format: 'YYYY-MM-DD HH:MM:SS.mmm' in UTC (see header note).
57
- function chTs(ms) {
58
- const d = new Date(ms);
59
- const p = (n, w = 2) => String(n).padStart(w, '0');
60
- return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ` +
61
- `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
62
- }
63
-
64
- // Per-message timestamp interpolated across [createdAt, lastUpdatedAt] — adapters
65
- // don't expose per-message times, so we synthesize monotonic-by-seq ones that make
66
- // sessions_v started/ended line up with the session bounds (same approximation as
67
- // agency/ingest.js; documented in DESIGN.md).
68
- function messageTs(chat, seq, total) {
69
- const start = chat.createdAt || chat.lastUpdatedAt || Date.now();
70
- const end = chat.lastUpdatedAt || chat.createdAt || start;
71
- if (total <= 1) return chTs(start);
72
- return chTs(start + Math.round((end - start) * (seq / (total - 1))));
73
- }
74
-
75
- // UInt64-bound coercion: integers only, never negative, garbage → 0.
76
- function toInt(v) {
77
- const n = Math.trunc(Number(v));
78
- return Number.isFinite(n) && n > 0 ? n : 0;
79
- }
80
-
81
- function makeClient() {
82
- return createClient({
83
- url: process.env.MEMHOUSE_URL || 'http://localhost:8123',
84
- username: process.env.MEMHOUSE_USER || 'memhouse_root',
85
- password: process.env.MEMHOUSE_PASSWORD || '',
86
- database: process.env.MEMHOUSE_DB || 'mem',
87
- request_timeout: 300000, // full re-ships move tens of MB; don't cut inserts short
88
- clickhouse_settings: {
89
- // Int64/UInt64 back as JSON numbers — our values (counts, tokens) are < 2^53.
90
- output_format_json_quote_64bit_integers: 0,
91
- // With a 300s request_timeout the client warns unless progress headers
92
- // keep long requests alive through proxies/load balancers — enable them
93
- // (also silences the startup WARN on every ship/install run).
94
- // The interval MUST sit below the smallest idle timeout in the path:
95
- // 60s is the default for AWS ALB and nginx proxy_read_timeout, so a
96
- // longer interval emits nothing before the socket is dropped and the
97
- // setting only silences the warning. 30s leaves headroom for both.
98
- send_progress_in_http_headers: 1,
99
- http_headers_progress_interval_ms: '30000',
100
- },
101
- });
102
- }
103
-
104
- // Create the CALLER'S OWN rooms, from ../per-member/schema-member.sql.tpl. Comments are
105
- // stripped BEFORE the ';' split — schema comments legitimately contain semicolons.
106
- //
107
- // This is the solo path: on a house you own, `memhouse install` mints your three rooms and
108
- // you are done. It creates nobody else's — the template is rendered for currentUser(), the
109
- // same identity the rooms' user_id is stamped with, so a client cannot name its way into
110
- // someone else's rooms.
111
- //
112
- // It does NOT issue grants or create the Merge rooms. A solo owner needs neither. Adding a
113
- // SECOND member — grants, Merge rooms, sharing — is owner work and lives in
114
- // ../per-member/provision.js.
115
- /**
116
- * Refuse to write into rooms whose sorting key predates `origin`.
117
- *
118
- * Called at the top of EVERY ship pass, not just --ensure-schema: a plain `memhouse ship`
119
- * never touches ensureSchema, so a guard living only there is a guard that never runs on
120
- * the path that does the damage. Found exactly that way — the refusal was in place and
121
- * 137 sessions shipped straight past it into a stale-key house.
122
- */
123
- async function assertOriginKeyed(client, rooms) {
124
- const stale = [];
125
- for (const t of ROOM_TYPES) {
126
- const rs = await client.query({
127
- query: `SELECT sorting_key FROM system.tables WHERE database = currentDatabase() AND name = {n:String}`,
128
- query_params: { n: rooms[t] }, format: 'JSONEachRow',
129
- });
130
- const key = ((await rs.json())[0] || {}).sorting_key || '';
131
- if (key && !/\borigin\b/.test(key)) stale.push(`${rooms[t]} (${key})`);
132
- }
133
- if (stale.length) {
134
- throw new Error(
135
- 'these rooms predate the origin sorting key, so imported rows would still be lost:\n'
136
- + stale.map((s) => ` ${s}`).join('\n')
137
- + '\n ORDER BY cannot be altered in place. Rebuild each room, then re-ship:'
138
- + '\n RENAME TABLE <room> TO <room>_old;'
139
- + '\n -- recreate from mem-house/per-member/schema-member.sql.tpl'
140
- + '\n INSERT INTO <room> SELECT *, \'ship\' AS origin FROM <room>_old;'
141
- + '\n A house with no imported rows can also just be re-shipped from scratch.');
142
- }
143
- }
144
-
145
- async function ensureSchema(client) {
146
- const rooms = await resolveRooms(client);
147
- const { member } = rooms;
148
- const tpl = fs.readFileSync(path.join(__dirname, '..', 'per-member', 'schema-member.sql.tpl'), 'utf-8');
149
- const sql = tpl.replaceAll('{{MEMBER}}', member);
150
- const stripped = sql.split('\n').map((l) => l.replace(/--.*$/, '')).join('\n');
151
- const stmts = stripped.split(';').map((s) => s.trim()).filter(Boolean);
152
- for (const q of stmts) {
153
- await client.command({
154
- query: q,
155
- // allow_experimental_full_text_index: on 25.x the messages text indexes
156
- // are gated behind this flag (SUPPORT_IS_DISABLED without it); 26.x+
157
- // accepts it as a no-op. Query-scoped, so no server config or admin
158
- // rights are needed. Verified on 25.11 and 26.7.
159
- clickhouse_settings: { async_insert: 0, allow_experimental_full_text_index: 1 },
160
- });
161
- }
162
- // A house created before origin existed has no such column, and the clear binds it —
163
- // an unguarded DELETE there would be the old destructive behaviour, and a guarded one
164
- // would error. Add it in place; ReplacingMergeTree backfills the DEFAULT, so every
165
- // pre-existing row reads as 'ship', which is what it was.
166
- //
167
- // Deliberately NOT applied to the Merge rooms: they take their structure from a member
168
- // room at CREATE time and reject ALTER. A Merge room simply will not expose `origin`
169
- // until it is recreated, which costs nothing — nothing reads origin through it.
170
- for (const t of ROOM_TYPES) {
171
- try {
172
- await client.command({
173
- query: `ALTER TABLE ${rooms[t]} ADD COLUMN IF NOT EXISTS origin LowCardinality(String) DEFAULT 'ship'`,
174
- // 25.11 refuses ANY alter on a table carrying the messages text indexes unless
175
- // this is set — including an ADD COLUMN that has nothing to do with them
176
- // (Code: 344, SUPPORT_IS_DISABLED). Without it the backfill fails silently and
177
- // the guard ends up missing on precisely the houses that need upgrading. A no-op
178
- // on 26.x, and query-scoped, so it needs no server config.
179
- clickhouse_settings: { allow_experimental_full_text_index: 1 },
180
- });
181
- } catch { /* no rights to alter is not fatal: a member on someone else's house */ }
182
- }
183
-
184
- await assertOriginKeyed(client, rooms);
185
- return stmts.length;
186
- }
187
-
188
- // Incremental state: what the house already holds, keyed by session_id. We compare
189
- // against extra.bubbleCount (the adapter's own cheap count, stored at ship time) —
190
- // NOT message_count — because parsed-message count and bubbleCount are different
191
- // units in several adapters (claude folds subagents, codex reports 0), and skipping
192
- // must be decidable WITHOUT calling getMessages on every chat.
193
- async function loadExisting(client, rooms) {
194
- const rs = await client.query({
195
- query: `SELECT session_id, last_updated_at, message_count, extra FROM ${rooms.sessions} FINAL WHERE user_id = currentUser()`,
196
- format: 'JSONEachRow',
197
- });
198
- // Actual message rows per session: an interrupted re-ship (crash between the
199
- // clear-DELETE and the inserts, or mid-flush on a large transcript) leaves a
200
- // fresh-looking session row with a missing or PARTIAL transcript — the skip
201
- // must compare the real row count against the recorded message_count, not
202
- // merely check that some row exists.
203
- const mr = await client.query({
204
- query: `SELECT session_id, count() AS n FROM ${rooms.messages} FINAL WHERE user_id = currentUser() GROUP BY session_id`,
205
- format: 'JSONEachRow',
206
- });
207
- const msgCounts = new Map();
208
- for (const r of await mr.json()) msgCounts.set(r.session_id, toInt(r.n));
209
- const map = new Map();
210
- for (const r of await rs.json()) {
211
- // DateTime64 comes back as 'YYYY-MM-DD HH:MM:SS.mmm' — re-parse as UTC.
212
- const ms = r.last_updated_at ? Date.parse(r.last_updated_at.replace(' ', 'T') + 'Z') : null;
213
- const bc = toInt(r.extra && r.extra.bubbleCount);
214
- const count = toInt(r.message_count);
215
- map.set(r.session_id, { ms, count, bc, intact: (msgCounts.get(r.session_id) || 0) === count });
216
- }
217
- return map;
218
- }
219
-
220
- // Regex fallback for adapters that render tool calls into text but don't expose
221
- // m._toolCalls: matches '[tool-call: Name(' and '[tool-call: Name]' forms.
222
- const TOOL_CALL_RE = /\[tool-call: ([^(\]]+)/g;
223
-
224
- // Build the typed rows for one chat. Returns null when the chat is unreadable
225
- // (adapter threw — e.g. a partially-written or locked session file): the caller
226
- // must write NOTHING for it, so the next incremental pass retries. A chat that
227
- // parses to zero messages still gets its session row (deliberate: the stored row
228
- // absorbs the incremental skip for stable-empty chats).
229
- function rowsForChat(chat, host) {
230
- let messages;
231
- try { messages = getMessages(chat) || []; }
232
- catch (e) {
233
- // Some readers throw instead of reporting through adapter-errors — Cursor's
234
- // agent-store path on a bad blobs table, for one. Converting that to a bare
235
- // `unreadable` count would leave the end-of-pass report with nothing to print,
236
- // so operators would see a number rising every pass and no cause anywhere.
237
- adapterErrorSink.record(chat.source, e, chat.composerId);
238
- return null;
239
- }
240
-
241
- const source = chat.source;
242
- // Canonical globally-unique session id: '<source>:<adapter-local id>'.
243
- // composerId is only unique WITHIN one editor; two editors emitting the same
244
- // id must never collide in keys, incremental state, deletes, or the API.
245
- // Sources are colon-free, so the prefix parses back unambiguously.
246
- const id = `${source}:${String(chat.composerId)}`;
247
- const folder = chat.folder || '';
248
- const project = folder ? path.basename(folder) : '';
249
- const total = messages.length;
250
-
251
- const session = {
252
- session_id: id,
253
- source,
254
- host,
255
- name: chat.name || '',
256
- mode: chat.mode || '',
257
- folder,
258
- project,
259
- git_branch: chat._gitBranch || '',
260
- created_at: chat.createdAt ? chTs(chat.createdAt) : null,
261
- last_updated_at: chat.lastUpdatedAt ? chTs(chat.lastUpdatedAt) : null,
262
- message_count: total,
263
- path: chat._fullPath || chat._dbPath || id,
264
- extra: { bubbleCount: chat.bubbleCount || 0 },
265
- };
266
-
267
- const msgRows = [];
268
- const toolRows = [];
269
- let idx = 0; // session-wide tool-call index
270
- for (let seq = 0; seq < total; seq++) {
271
- const m = messages[seq];
272
- const raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content) ?? '';
273
- const text = raw.length > TEXT_MAX ? raw.slice(0, TEXT_MAX) : raw;
274
- const ts = messageTs(chat, seq, total);
275
- const row = {
276
- session_id: id,
277
- seq,
278
- source,
279
- host,
280
- ts,
281
- role: m.role || '',
282
- model: m._model || '',
283
- input_tokens: toInt(m._inputTokens),
284
- output_tokens: toInt(m._outputTokens),
285
- cache_read_tokens: toInt(m._cacheRead),
286
- cache_write_tokens: toInt(m._cacheWrite),
287
- text,
288
- project,
289
- folder,
290
- is_subagent: text.startsWith('[subagent]'),
291
- extra: {},
292
- };
293
- // Hash the content fields only (not ts — it is synthesized and may drift).
294
- row.line_hash = lineHash({
295
- session_id: id, seq, role: row.role, model: row.model,
296
- input_tokens: row.input_tokens, output_tokens: row.output_tokens,
297
- cache_read_tokens: row.cache_read_tokens, cache_write_tokens: row.cache_write_tokens,
298
- text,
299
- });
300
- msgRows.push(row);
301
-
302
- if (m.role !== 'assistant') continue;
303
- let calls;
304
- if (Array.isArray(m._toolCalls) && m._toolCalls.length) {
305
- calls = m._toolCalls.map((tc) => ({ name: String(tc.name || 'unknown'), args: tc.args || {} }));
306
- } else {
307
- calls = [];
308
- for (const match of text.matchAll(TOOL_CALL_RE)) {
309
- calls.push({ name: match[1].trim(), args: {} });
310
- }
311
- }
312
- for (const tc of calls) {
313
- const args = JSON.stringify(tc.args);
314
- toolRows.push({
315
- session_id: id,
316
- seq,
317
- idx: idx++,
318
- source,
319
- host,
320
- tool_name: tc.name,
321
- args: args.length > ARGS_MAX ? args.slice(0, ARGS_MAX) : args,
322
- ts,
323
- project,
324
- folder,
325
- });
326
- }
327
- }
328
- return { session, msgRows, toolRows };
329
- }
330
-
331
- // One shipping pass. Incremental unless opts.full: a chat is skipped when the house
332
- // already has it at least as fresh (last_updated_at) and at least as large
333
- // (extra.bubbleCount) — both readable without parsing the chat.
334
- async function runShip(client, opts = {}) {
335
- const { full = false } = opts;
336
- const host = hostId();
337
- // Always load what the house holds — even with --full. The skip decision uses it
338
- // only in incremental mode, but re-shipping a KNOWN session must clear its old
339
- // message/tool rows first (a shrunken re-parse leaves stale higher-seq rows
340
- // otherwise: ReplacingMergeTree collapses same-key rows only).
341
- const rooms = await resolveRooms(client);
342
- await assertOriginKeyed(client, rooms);
343
- const existing = await loadExisting(client, rooms);
344
- const chats = getAllChats();
345
-
346
- // An adapter that cannot load contributes zero sessions, which is indistinguishable
347
- // from an editor the user does not have — so the shipper would silently ship a
348
- // partial history forever. Say so on every pass.
349
- // Reported once here for what the scan already knows (a dead binding is visible
350
- // before any work happens, and this pass may take minutes), and again after the
351
- // loop for failures that only surface while reading messages.
352
- const warned = new Set();
353
- reportAdapterErrors(warned);
354
-
355
- const batches = { sessions: [], messages: [], tool_calls: [] };
356
- const flush = async (table) => {
357
- if (!batches[table].length) return;
358
- await client.insert({
359
- table: rooms[table],
360
- values: batches[table],
361
- format: 'JSONEachRow',
362
- clickhouse_settings: { async_insert: 0 }, // binding: user_id stamping breaks otherwise
363
- });
364
- batches[table] = [];
365
- };
366
- const push = async (table, row) => {
367
- batches[table].push(row);
368
- if (batches[table].length >= BATCH_ROWS) await flush(table);
369
- };
370
-
371
- const seen = new Set(); // adapters must not double-ship a session id within a pass
372
- let sessions = 0, skipped = 0, msgRows = 0, toolRows = 0, unreadable = 0;
373
- for (const chat of chats) {
374
- if (chat.encrypted) continue;
375
- // Same canonical '<source>:<adapter-local id>' as rowsForChat: dedup and
376
- // incremental state are per (source, id) — two editors sharing an id must
377
- // not skip or overwrite each other.
378
- const id = `${chat.source}:${String(chat.composerId)}`;
379
- if (seen.has(id)) continue;
380
- seen.add(id);
381
-
382
- const prev = existing.get(id);
383
- if (!full && prev) {
384
- // floor: some adapters (codex) emit fractional-ms timestamps, but DateTime64(3)
385
- // stores whole ms — without it every such session loses by <1ms and re-ships.
386
- const chatLast = Math.floor(chat.lastUpdatedAt || chat.createdAt || 0);
387
- const notNewer = chatLast === 0 || (prev.ms !== null && prev.ms >= chatLast);
388
- // Freshness alone is not enough: the transcript must be COMPLETE (stored
389
- // row count == recorded message_count) so an interrupted re-ship — even one
390
- // that died mid-flush leaving a partial transcript — repairs itself on the
391
- // next pass instead of being skipped forever.
392
- // _countUnknown means the adapter could not determine this chat's message
393
- // count, so bubbleCount is a placeholder rather than a measurement. Skipping on
394
- // it would compare against a number that means nothing — `prev.bc >= 0` is
395
- // trivially true — and the session would stay stale forever. Re-read instead.
396
- if (notNewer && !chat._countUnknown && prev.bc >= (chat.bubbleCount || 0) && prev.intact) { skipped++; continue; }
397
- }
398
-
399
- // rowsForChat returns null only when getMessages() *throws*. An adapter that
400
- // swallows its own failure returns [] instead, which would look like a session
401
- // that genuinely has no messages — and for a known session the re-ship below
402
- // deletes the old transcript before inserting that emptiness. So treat a
403
- // failure recorded while reading THIS chat as unreadable too: write nothing,
404
- // delete nothing, retry next pass.
405
- const errsBefore = adapterErrorSink.recorded().length;
406
- const rows = rowsForChat(chat, host);
407
- const failedHere = adapterErrorSink.recorded().slice(errsBefore).some((e) => e.source === chat.source);
408
- // Write nothing on a failed read, whether or not the session is already stored.
409
- //
410
- // Shipping a partial first read looks tempting — nothing is there to destroy —
411
- // but it is a trap: message_count would be written from the partial rows, so
412
- // prev.intact becomes true, prev.bc already matches the source, the timestamp is
413
- // unchanged, and the skip predicate above then withholds the session forever.
414
- // The truncation would become permanent AND the warning would stop, because the
415
- // adapter is never asked to read it again. Withholding keeps the session out of
416
- // the skip predicate entirely, so every pass retries it and re-reports it until
417
- // the underlying store is readable.
418
- if (!rows || failedHere) { unreadable++; continue; }
419
- if (prev) {
420
- // Known session being re-shipped: clear its old rows BEFORE inserting so a
421
- // shorter re-parse can't leave stale seq/idx tails. A crash between the
422
- // delete and the inserts is repaired by the next pass: the skip predicate
423
- // refuses to skip a non-empty session whose message rows are missing.
424
- // user_id is BOUND, not `= currentUser()`. A DELETE is a mutation, and a mutation
425
- // does not necessarily evaluate currentUser() in the caller's context — it matches
426
- // nothing at all, so the delete silently removes zero rows and the stale tail this
427
- // code exists to clear survives forever. Measured on ClickHouse 25.11 —
428
- // the identical predicate with the literal value deleted 2000 rows where
429
- // currentUser() deleted 0. The value is the same identity either way: it is read
430
- // from the server over this very connection.
431
- // origin='ship' is the third bind, and it is not cosmetic. This clear exists so a
432
- // shorter re-parse cannot leave a stale seq tail behind — but scoped to
433
- // (session_id, user_id) alone it deletes EVERY row for the session, including rows
434
- // the adapters did not write and cannot rewrite. Measured, on a real house: an
435
- // import of 135,307 messages lost 27,948 of them to one ship pass, because
436
- // memory-house had captured more per session than the adapters emit. The rows the
437
- // shipper owns are the only rows it may remove.
438
- for (const t of ['messages', 'tool_calls']) {
439
- await client.command({
440
- query: `DELETE FROM ${rooms[t]} WHERE session_id = {id:String} AND user_id = {uid:String} AND origin = 'ship'`,
441
- query_params: { id, uid: rooms.user },
442
- clickhouse_settings: { async_insert: 0 },
443
- });
444
- }
445
- }
446
- await push('sessions', rows.session);
447
- sessions++;
448
- for (const r of rows.msgRows) { await push('messages', r); msgRows++; }
449
- for (const r of rows.toolRows) { await push('tool_calls', r); toolRows++; }
450
- }
451
- for (const table of Object.keys(batches)) await flush(table);
452
- // Anything that only failed while reading messages — the sink is reset by the
453
- // next getAllChats(), so unreported here means never reported at all.
454
- reportAdapterErrors(warned);
455
- return { sessions, skipped, msgRows, toolRows, unreadable };
456
- }
457
-
458
- // Warn once per adapter per pass. `warned` carries across the two call sites so a
459
- // dead binding is not reported twice in the same run.
460
- function reportAdapterErrors(warned) {
461
- const errors = getAdapterErrors().filter((e) => !warned.has(e.source));
462
- if (!errors.length) return;
463
- for (const e of errors) warned.add(e.source);
464
- const noBinding = errors.filter((e) => e.missingBinding).map((e) => e.source);
465
- if (noBinding.length) {
466
- console.log(`[mem-house] WARNING: ${noBinding.length} adapter(s) skipped, sessions NOT shipped — better-sqlite3 has no native binding: ${noBinding.join(', ')}`);
467
- console.log('[mem-house] fix: npm install -g memhouse --allow-scripts=better-sqlite3');
468
- }
469
- for (const e of errors.filter((x) => !x.missingBinding)) {
470
- console.log(`[mem-house] WARNING: ${e.source} skipped — ${e.message}`);
471
- }
472
- }
473
-
474
- // Per-source rollup straight from sessions_v (final=1 so ReplacingMergeTree collapses).
475
- // The rollup resolves like the rooms do — it is a subquery over the caller's own rooms.
476
- async function printStats(client) {
477
- const rooms = await resolveRooms(client);
478
- const rs = await client.query({
479
- query: `
480
- SELECT source,
481
- count() AS sessions,
482
- sum(total_msgs) AS messages,
483
- sum(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) AS tokens
484
- FROM ${rooms.sessions_v}
485
- GROUP BY source
486
- ORDER BY sessions DESC`,
487
- format: 'JSONEachRow',
488
- clickhouse_settings: READ_SETTINGS,
489
- });
490
- const rows = await rs.json();
491
- if (!rows.length) { console.log('[mem-house] house is empty'); return; }
492
- const w = [Math.max(6, ...rows.map((r) => r.source.length)), 8, 8, 14];
493
- const line = (a, b, c, d) =>
494
- console.log(`${String(a).padEnd(w[0])} ${String(b).padStart(w[1])} ${String(c).padStart(w[2])} ${String(d).padStart(w[3])}`);
495
- line('source', 'sessions', 'messages', 'tokens');
496
- let ts = 0, tm = 0, tt = 0;
497
- for (const r of rows) {
498
- line(r.source, r.sessions, r.messages, r.tokens);
499
- ts += Number(r.sessions); tm += Number(r.messages); tt += Number(r.tokens);
500
- }
501
- line('total', ts, tm, tt);
502
- }
503
-
504
- async function main() {
505
- const argv = process.argv.slice(2);
506
- const client = makeClient();
507
- try {
508
- if (argv.includes('--ensure-schema')) {
509
- const n = await ensureSchema(client);
510
- console.log(`[mem-house] schema ensured (${n} statements)`);
511
- return;
512
- }
513
- if (argv.includes('--stats')) { await printStats(client); return; }
514
-
515
- const loopIdx = argv.indexOf('--loop');
516
- const loop = loopIdx !== -1;
517
- let intervalSec = 300;
518
- if (loop) {
519
- const n = Number(argv[loopIdx + 1]);
520
- if (Number.isFinite(n) && n > 0) intervalSec = n;
521
- }
522
- let full = argv.includes('--full');
523
- // A failed pass does NOT wait the full interval. The common failure at startup is
524
- // that the house is not up yet — the container is still booting — and sleeping 300s
525
- // there means the first ship is
526
- // five minutes late for a condition that clears in under a second. systemd's
527
- // After= orders process start, not readiness, and launchd has no ordering at all, so
528
- // this is the only place the race can be closed for every path at once.
529
- // Backs off to the normal interval so a genuinely unreachable house is not hammered.
530
- const RETRY_START_MS = 2000;
531
- let retryMs = RETRY_START_MS;
532
- do {
533
- const t0 = Date.now();
534
- let failed = false;
535
- try {
536
- const r = await runShip(client, { full });
537
- console.log(`[mem-house] shipped ${r.sessions} sessions (${r.skipped} skipped${r.unreadable ? `, ${r.unreadable} unreadable-will-retry` : ''}) → ` +
538
- `${r.msgRows} msg rows, ${r.toolRows} tool rows in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
539
- } catch (e) {
540
- failed = true;
541
- console.error(`[mem-house] pass failed: ${e.message}`);
542
- if (!loop) process.exitCode = 1;
543
- }
544
- full = false; // --full applies to the first pass only; loop passes stay incremental
545
- if (loop) {
546
- const waitMs = failed ? Math.min(retryMs, intervalSec * 1000) : intervalSec * 1000;
547
- if (failed) {
548
- console.error(`[mem-house] retrying in ${Math.round(waitMs / 1000)}s`);
549
- retryMs = Math.min(retryMs * 2, intervalSec * 1000);
550
- } else {
551
- retryMs = RETRY_START_MS;
552
- }
553
- await new Promise((r) => setTimeout(r, waitMs));
554
- resetCaches(); // adapters cache chat lists; drop them so new sessions surface
555
- }
556
- } while (loop);
557
- } finally {
558
- await client.close();
559
- }
560
- }
561
-
562
- module.exports = { runShip, ensureSchema };
563
-
564
- if (require.main === module) {
565
- main().catch((e) => { console.error(`[mem-house] fatal: ${e.message}`); process.exit(1); });
566
- }