memhouse 0.4.3 → 0.7.0

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 (64) hide show
  1. package/README.md +244 -114
  2. package/bin/memhouse.js +1169 -233
  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/memhouse/per-member/provision.js +265 -0
  23. package/memhouse/per-member/rooms.js +235 -0
  24. package/memhouse/per-member/schema-member.sql.tpl +125 -0
  25. package/{mem-house → memhouse}/per-member/schema-merge.sql.tpl +3 -3
  26. package/memhouse/resume.js +150 -0
  27. package/memhouse/self-update.js +150 -0
  28. package/{mem-house → memhouse}/server/queries.js +80 -34
  29. package/memhouse/server/server.js +451 -0
  30. package/{mem-house → memhouse}/service.js +48 -29
  31. package/memhouse/shipper/ship.js +949 -0
  32. package/package.json +5 -20
  33. package/public/assets/{index-CtH-5i95.js → index-CnWzrjF3.js} +10 -7
  34. package/public/index.html +2 -2
  35. package/ui/index.html +1 -1
  36. package/ui/src/App.jsx +1 -1
  37. package/ui/src/lib/api.js +29 -13
  38. package/ui/src/pages/Dashboard.jsx +8 -5
  39. package/ui/src/pages/Sessions.jsx +3 -1
  40. package/ui/src/pages/SqlViewer.jsx +6 -23
  41. package/cache.js +0 -850
  42. package/ch-ingest.js +0 -329
  43. package/ch-schema.js +0 -152
  44. package/clickhouse.js +0 -87
  45. package/index.js +0 -425
  46. package/mcp-server.js +0 -294
  47. package/mem-house/delivery/install.sh +0 -183
  48. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +0 -125
  49. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +0 -146
  50. package/mem-house/per-member/provision.js +0 -142
  51. package/mem-house/per-member/rooms.js +0 -132
  52. package/mem-house/per-member/schema-member.sql.tpl +0 -85
  53. package/mem-house/server/server.js +0 -228
  54. package/mem-house/shipper/ship.js +0 -504
  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
  64. /package/{mem-house → memhouse}/per-member/SHARING.md +0 -0
@@ -0,0 +1,949 @@
1
+ #!/usr/bin/env node
2
+ // memhouse 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) collapses to
18
+ // latest-wins at FINAL. Keyed (session_id, user_id, origin, seq) on messages and
19
+ // (session_id, user_id, origin, idx) on tool_calls, so an imported row and a shipped
20
+ // one at the same seq are two rows, not one. sessions is (session_id, user_id) with
21
+ // NO origin — one metadata row per session is what every read path assumes.
22
+ //
23
+ // CLI: node ship.js one incremental pass
24
+ // node ship.js --loop [sec] repeat every sec seconds (default 300)
25
+ // node ship.js --full ignore the incremental skip (re-ship everything)
26
+ // node ship.js --ensure-schema create the caller's own rooms and exit
27
+ // node ship.js --stats per-source counts from sessions_v and exit
28
+ //
29
+ // Env (DESIGN.md contract): MEMHOUSE_URL / MEMHOUSE_USER / MEMHOUSE_PASSWORD /
30
+ // MEMHOUSE_DB. URL and USER are REQUIRED — there is no default house, because the old
31
+ // one (http://localhost:8123 as memhouse_root) is a real house on many machines. DB
32
+ // defaults to 'mem'.
33
+
34
+ const os = require('os');
35
+ const fs = require('fs');
36
+ const path = require('path');
37
+ const crypto = require('crypto');
38
+ // Named, not a raw MODULE_NOT_FOUND with a require stack. This runs as a SUBPROCESS of
39
+ // `memhouse ship`/`install`, so its stack trace lands in the middle of the parent's
40
+ // output and reads as a crash in the parent. Same preflight provision.js carries.
41
+ let createClient, ClickHouseLogLevel;
42
+ try { ({ createClient, ClickHouseLogLevel } = require('@clickhouse/client')); }
43
+ catch {
44
+ console.error("[memhouse] dependency '@clickhouse/client' is not installed.");
45
+ console.error(`[memhouse] from a checkout: npm install --prefix ${require('path').join(__dirname, '..', '..')}`);
46
+ console.error(`[memhouse] from an npm install: ${require('../per-member/rooms').installCommand()}`);
47
+ process.exit(2);
48
+ }
49
+ const { getAllChats, getAdapterErrors, getMessages, resetCaches } = require('../../editors');
50
+ const adapterErrorSink = require('../../editors/adapter-errors');
51
+ const selfUpdate = require('../self-update');
52
+ // Captured at require time, which is as close to "what this process booted with" as it
53
+ // gets. Taking it later would record whatever an upgrade had already replaced.
54
+ const selfSnap = selfUpdate.snapshot(__filename);
55
+ const { resolveRooms, READ_SETTINGS, ROOM_TYPES } = require('../per-member/rooms');
56
+
57
+ const BATCH_ROWS = 2000; // insert batch ceiling (binding)
58
+ const TEXT_MAX = 50000; // messages.text truncation
59
+ const ARGS_MAX = 20000; // tool_calls.args truncation
60
+
61
+ // Stable host id: <shorthostname>-<8hex>, where the hex half is a fingerprint written
62
+ // ONCE into MEMHOUSE_HOME rather than hashed out of hostname/platform/arch.
63
+ //
64
+ // The derived version failed in both directions, and both matter here because every
65
+ // machine belonging to one member ships into the SAME rooms — `host` is the only column
66
+ // separating them. Two laptops answering to the same default hostname produced one id
67
+ // between them; renaming a machine moved its id and split its own history. See ../host.js.
68
+ function hostId() {
69
+ return require('../host').identity().id;
70
+ }
71
+
72
+ // UInt64 (as decimal string) = first 8 bytes big-endian of sha256 over the row's
73
+ // content fields. Decimal string survives JSONEachRow → UInt64 without precision loss.
74
+ function lineHash(obj) {
75
+ return crypto.createHash('sha256').update(JSON.stringify(obj)).digest().readBigUInt64BE(0).toString();
76
+ }
77
+
78
+ // DateTime64(3) wire format: 'YYYY-MM-DD HH:MM:SS.mmm' in UTC (see header note).
79
+ function chTs(ms) {
80
+ const d = new Date(ms);
81
+ const p = (n, w = 2) => String(n).padStart(w, '0');
82
+ return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ` +
83
+ `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
84
+ }
85
+
86
+ // When a message was actually sent.
87
+ //
88
+ // Prefer the adapter's own `_ts` (epoch ms). It is optional by contract — `getMessages`
89
+ // returns `{ role, content }` and nothing more is required — but a transcript format that
90
+ // records a time per line should not have it thrown away. Claude Code's JSONL does, and
91
+ // discarding it produced timestamps wrong by up to the whole span of a session: four
92
+ // messages sent within three minutes came back spread over 08:00, 18:43, 03:27 and 12:11,
93
+ // and the search skill reported those to the user as fact.
94
+ //
95
+ // Fall back to interpolating across [createdAt, lastUpdatedAt] for the adapters that
96
+ // genuinely have nothing per message — monotonic-by-seq, so sessions_v started/ended
97
+ // still line up with the session bounds (same approximation as agency/ingest.js;
98
+ // documented in DESIGN.md). Real timestamps are NOT necessarily monotonic by seq — a
99
+ // folded subagent transcript is appended after its parent's turns but ran during them.
100
+ // Nothing depends on that: ordering is by `seq`, and started/ended are min/max.
101
+ function messageTs(chat, seq, total, msg) {
102
+ const at = Number(msg && msg._ts);
103
+ // Seconds or milliseconds, depending on the format: goose stores seconds, Claude Code
104
+ // and opencode milliseconds. Real epoch-ms is > 1e12 and real epoch-seconds ~1.7e9, so
105
+ // the split point is unambiguous for any date this side of 1973.
106
+ if (Number.isFinite(at) && at > 0) return chTs(at < 1e11 ? at * 1000 : at);
107
+ const start = chat.createdAt || chat.lastUpdatedAt || Date.now();
108
+ const end = chat.lastUpdatedAt || chat.createdAt || start;
109
+ if (total <= 1) return chTs(start);
110
+ return chTs(start + Math.round((end - start) * (seq / (total - 1))));
111
+ }
112
+
113
+ /**
114
+ * Truncate without splitting a character.
115
+ *
116
+ * `String.prototype.slice` counts UTF-16 code units, so a boundary that lands inside an
117
+ * astral character (emoji, and everything else above U+FFFF) leaves a lone high surrogate
118
+ * at the end. `JSON.stringify` then emits "\ud83d", and ClickHouse rejects THE WHOLE
119
+ * INSERT:
120
+ *
121
+ * Cannot parse escape sequence: missing second part of surrogate pair
122
+ * (while reading the value of key text) … CANNOT_PARSE_ESCAPE_SEQUENCE
123
+ *
124
+ * The blast radius is not the one message. One poisoned session among ten made every pass
125
+ * fail and the house stayed completely empty across four passes; under `--loop` it retries
126
+ * forever. In another ordering the failure landed after the sessions insert, leaving
127
+ * session rows whose transcripts do not exist and which accumulated on every pass.
128
+ *
129
+ * Reachable on ordinary data: four messages in a real 398-session history already sit at
130
+ * the 50,000 ceiling, and codex folds a whole turn into one assistant message (mean 9,762
131
+ * chars), so it is the most exposed source. Dropping the orphaned half of a pair costs one
132
+ * character out of 50,000.
133
+ */
134
+ function truncate(s, max) {
135
+ if (s.length <= max) return s;
136
+ const cut = s.slice(0, max);
137
+ const last = cut.charCodeAt(cut.length - 1);
138
+ return (last >= 0xD800 && last <= 0xDBFF) ? cut.slice(0, -1) : cut;
139
+ }
140
+
141
+ /**
142
+ * Drop unpaired surrogates from every string on its way into an insert.
143
+ *
144
+ * truncate() above fixes the two places THIS file cuts a string, and that was not enough:
145
+ * the same failure arrives by two other routes.
146
+ *
147
+ * 1. Titles. `cleanPrompt` in editors/claude.js cuts the first prompt to 120 characters,
148
+ * and 120 is a far more reachable boundary than 50,000. An emoji there produced
149
+ * `(while reading the value of key name)` — same total outage, different column.
150
+ * 2. Source data. `{"content":"before \ud83d after"}` is valid JSON that JSON.parse
151
+ * accepts, so a lone surrogate can arrive already in a transcript with no truncation
152
+ * involved at all.
153
+ *
154
+ * Guarding cut sites one at a time is the wrong shape — every future string field is
155
+ * another instance. This runs over the finished row instead, so nothing reaches ClickHouse
156
+ * carrying an escape it will reject. The cost is one regex over fields that almost never
157
+ * match; the alternative is a member whose house stops accepting rows entirely, exit 1
158
+ * forever under --loop, while `doctor` reports everything green.
159
+ */
160
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
161
+ function scrub(row) {
162
+ for (const k of Object.keys(row)) {
163
+ const v = row[k];
164
+ if (typeof v === 'string') {
165
+ if (LONE_SURROGATE.test(v)) { LONE_SURROGATE.lastIndex = 0; row[k] = v.replace(LONE_SURROGATE, ''); }
166
+ LONE_SURROGATE.lastIndex = 0;
167
+ } else if (v && typeof v === 'object') {
168
+ // `extra` is a JSON column; its values travel as strings too.
169
+ row[k] = JSON.parse(JSON.stringify(v).replace(LONE_SURROGATE, ''));
170
+ LONE_SURROGATE.lastIndex = 0;
171
+ }
172
+ }
173
+ return row;
174
+ }
175
+
176
+ // UInt64-bound coercion: integers only, never negative, garbage → 0.
177
+ function toInt(v) {
178
+ const n = Math.trunc(Number(v));
179
+ return Number.isFinite(n) && n > 0 ? n : 0;
180
+ }
181
+
182
+ function makeClient() {
183
+ // No house-shaped default. `memhouse` spawns this with the resolved config in the
184
+ // environment, so a missing MEMHOUSE_URL here means the shipper was started some other
185
+ // way — a stale service unit, a hand-rolled cron, a copied command — and the old
186
+ // defaults sent it at http://localhost:8123 as memhouse_root, which on a lot of machines
187
+ // is a real house belonging to someone else. Writing memory into it is worse than
188
+ // reading from it.
189
+ if (!process.env.MEMHOUSE_URL || !process.env.MEMHOUSE_USER) {
190
+ console.error('[memhouse] no house configured: MEMHOUSE_URL and MEMHOUSE_USER are unset.');
191
+ console.error('[memhouse] run `memhouse install`, or set them for this process. Refusing to');
192
+ console.error('[memhouse] guess http://localhost:8123 as memhouse_root.');
193
+ process.exit(2);
194
+ }
195
+ return createClient({
196
+ // The driver logs a full connection-object dump and a node stack to stderr for every
197
+ // failed request, at ERROR level, BEFORE we get the exception. A member without
198
+ // CREATE TABLE then sees 23 lines of @clickhouse/client internals and the word "fatal"
199
+ // ahead of the handled, actionable refusal — it reads like a crash that recovered.
200
+ // Silence the driver; every call site already catches the throw and prints something
201
+ // a person can act on. MEMHOUSE_DEBUG=1 puts it back.
202
+ log: { level: process.env.MEMHOUSE_DEBUG ? ClickHouseLogLevel.DEBUG : ClickHouseLogLevel.OFF },
203
+ url: process.env.MEMHOUSE_URL,
204
+ username: process.env.MEMHOUSE_USER,
205
+ password: process.env.MEMHOUSE_PASSWORD || '',
206
+ database: process.env.MEMHOUSE_DB || 'mem',
207
+ request_timeout: 300000, // full re-ships move tens of MB; don't cut inserts short
208
+ clickhouse_settings: {
209
+ // Int64/UInt64 back as JSON numbers — our values (counts, tokens) are < 2^53.
210
+ output_format_json_quote_64bit_integers: 0,
211
+ // With a 300s request_timeout the client warns unless progress headers
212
+ // keep long requests alive through proxies/load balancers — enable them
213
+ // (also silences the startup WARN on every ship/install run).
214
+ // The interval MUST sit below the smallest idle timeout in the path:
215
+ // 60s is the default for AWS ALB and nginx proxy_read_timeout, so a
216
+ // longer interval emits nothing before the socket is dropped and the
217
+ // setting only silences the warning. 30s leaves headroom for both.
218
+ send_progress_in_http_headers: 1,
219
+ http_headers_progress_interval_ms: '30000',
220
+ },
221
+ });
222
+ }
223
+
224
+ // Create the CALLER'S OWN rooms, from ../per-member/schema-member.sql.tpl. Comments are
225
+ // stripped BEFORE the ';' split — schema comments legitimately contain semicolons.
226
+ //
227
+ // This is the solo path: on a house you own, `memhouse install` mints your three rooms and
228
+ // you are done. It creates nobody else's — the template is rendered for currentUser(), the
229
+ // same identity the rooms' user_id is stamped with, so a client cannot name its way into
230
+ // someone else's rooms.
231
+ //
232
+ // It does NOT issue grants or create the Merge rooms. A solo owner needs neither. Adding a
233
+ // SECOND member — grants, Merge rooms, sharing — is owner work and lives in
234
+ // ../per-member/provision.js.
235
+ /**
236
+ * Refuse to write into rooms whose sorting key is wrong for `origin` — in EITHER
237
+ * direction. Two different houses are broken in two opposite ways:
238
+ *
239
+ * - pre-0.4.4: no `origin` in the messages/tool_calls key, so ReplacingMergeTree
240
+ * collapses an imported row against a shipped one and the import is lost.
241
+ * - 0.4.4 exactly: `origin` was also put in the SESSIONS key, which gives a session
242
+ * two metadata rows. sessions_v then joins messages twice and over-reports, and the
243
+ * incremental skip cannot tell which row is current.
244
+ *
245
+ * Called at the top of EVERY ship pass, not just --ensure-schema: a plain `memhouse ship`
246
+ * never touches ensureSchema, so a guard living only there is a guard that never runs on
247
+ * the path that does the damage. Found exactly that way — the refusal was in place and
248
+ * 137 sessions shipped straight past it into a stale-key house.
249
+ */
250
+ /**
251
+ * Refuse, legibly, when the caller's rooms are not there.
252
+ *
253
+ * Without this the first thing to touch a roomless house is loadExisting's SELECT, and the
254
+ * user gets `Unknown table expression identifier 'sessions_x' in scope SELECT session_id,
255
+ * last_updated_at, message_count, extra FROM ...` — a raw ClickHouse identifier error with
256
+ * no next step, from a command that may be running inside a service loop. `install` and
257
+ * `doctor` both handle the identical situation properly; ship was the one that did not.
258
+ * It is also the documented 0.3.x-upgrade symptom, which INSTALL.md claims is named.
259
+ */
260
+ /**
261
+ * The columns each room type declares in the template, as {name, type} — the source of
262
+ * truth for what a room must have. Parsed from the same file that creates them, so a
263
+ * column added there is rolled out without anyone remembering to write a migration.
264
+ *
265
+ * MATERIALIZED and DEFAULT clauses are kept: `user_id String MATERIALIZED currentUser()`
266
+ * has to be added exactly that way or the identity stamp does not happen.
267
+ */
268
+ function templateColumns(tpl) {
269
+ const out = {};
270
+ for (const t of ROOM_TYPES) {
271
+ const re = new RegExp(`CREATE TABLE IF NOT EXISTS ${t}_\\{\\{MEMBER\\}\\}\\s*\\(([\\s\\S]*?)\\n\\)`, 'm');
272
+ const m = tpl.match(re);
273
+ if (!m) continue;
274
+ const cols = [];
275
+ let depth = 0, buf = '';
276
+ for (const raw of m[1].split('\n')) {
277
+ const line = raw.replace(/--.*$/, '').trim();
278
+ if (!line) continue;
279
+ buf += (buf ? ' ' : '') + line;
280
+ depth += (line.match(/\(/g) || []).length - (line.match(/\)/g) || []).length;
281
+ if (depth > 0 || !buf.endsWith(',')) { if (depth > 0) continue; }
282
+ const decl = buf.replace(/,$/, '').trim();
283
+ buf = '';
284
+ // Skip index/constraint declarations — only column definitions here.
285
+ if (/^(INDEX|CONSTRAINT|PROJECTION|PRIMARY\s+KEY)\b/i.test(decl)) continue;
286
+ const sp = decl.indexOf(' ');
287
+ if (sp < 1) continue;
288
+ cols.push({ name: decl.slice(0, sp), type: decl.slice(sp + 1).trim() });
289
+ }
290
+ out[t] = cols;
291
+ }
292
+ return out;
293
+ }
294
+
295
+ /**
296
+ * Say so, every pass, when a room is missing a column.
297
+ *
298
+ * `ship` does not call ensureSchema — only `--ensure-schema` and `install` do — so a room
299
+ * that has drifted keeps accepting inserts and keeps discarding that field, silently, for
300
+ * as long as nobody thinks to run the healer. Measured: 85 rows shipped with the value
301
+ * thrown away and four surfaces reporting success.
302
+ *
303
+ * A WARNING rather than a refusal: the rows that do fit are still worth having, and
304
+ * refusing would stop all shipping over one column. One extra query per room per pass.
305
+ */
306
+ async function warnMissingColumns(client, rooms) {
307
+ let tpl;
308
+ try { tpl = fs.readFileSync(path.join(__dirname, '..', 'per-member', 'schema-member.sql.tpl'), 'utf-8'); }
309
+ catch { return; }
310
+ const want = templateColumns(tpl);
311
+ const missing = [];
312
+ for (const t of ROOM_TYPES) {
313
+ try {
314
+ const rs = await client.query({
315
+ query: 'SELECT name FROM system.columns WHERE database = currentDatabase() AND table = {n:String}',
316
+ query_params: { n: rooms[t] }, format: 'JSONEachRow',
317
+ });
318
+ const have = new Set((await rs.json()).map((r) => r.name));
319
+ if (!have.size) continue;
320
+ for (const c of (want[t] || [])) if (!have.has(c.name)) missing.push(`${rooms[t]}.${c.name}`);
321
+ } catch { /* unreadable rooms are assertRoomsExist's problem */ }
322
+ }
323
+ if (missing.length) {
324
+ console.error(`[memhouse] WARNING: ${missing.length} column(s) missing from your rooms — those fields are being DISCARDED on every pass:`);
325
+ console.error(`[memhouse] ${missing.slice(0, 8).join(', ')}${missing.length > 8 ? ` … and ${missing.length - 8} more` : ''}`);
326
+ console.error('[memhouse] heal them with: memhouse ship --ensure-schema');
327
+ }
328
+ }
329
+
330
+ async function assertRoomsExist(client, rooms) {
331
+ const rs = await client.query({
332
+ query: `SELECT name FROM system.tables WHERE database = currentDatabase() AND name IN ({n:Array(String)})`,
333
+ query_params: { n: ROOM_TYPES.map((t) => rooms[t]) },
334
+ format: 'JSONEachRow',
335
+ });
336
+ const have = new Set((await rs.json()).map((r) => r.name));
337
+ const missing = ROOM_TYPES.map((t) => rooms[t]).filter((n) => !have.has(n));
338
+ if (!missing.length) return;
339
+ throw new Error(
340
+ `'${rooms.member}' has no ${missing.join(', ')} in this house.\n`
341
+ + ' Create them: memhouse install\n'
342
+ + ' If you hold no CREATE TABLE here, the owner runs:\n'
343
+ + ` memhouse install --print-sql --member ${rooms.member}\n`
344
+ + ' A house from before 0.4 reaches this too — its rooms have different names.');
345
+ }
346
+
347
+ async function assertOriginKeyed(client, rooms) {
348
+ const wrong = [];
349
+ for (const t of ROOM_TYPES) {
350
+ const rs = await client.query({
351
+ query: `SELECT sorting_key FROM system.tables WHERE database = currentDatabase() AND name = {n:String}`,
352
+ query_params: { n: rooms[t] }, format: 'JSONEachRow',
353
+ });
354
+ const row = (await rs.json())[0];
355
+ const key = (row || {}).sorting_key || '';
356
+ // A room that EXISTS but has no sorting key is not a MergeTree — a Merge, a View, a Log
357
+ // engine standing where the shipper expects to DELETE and INSERT. Skipping it here let
358
+ // the pass sail past its own guard and die later on
359
+ // `DELETE query is not supported for table …`, which is exactly what this refusal is
360
+ // meant to prevent. (A room that does not exist at all is assertRoomsExist's job and is
361
+ // reported there.)
362
+ if (row && !key) { wrong.push(`${rooms[t]} — not a MergeTree, so it cannot be shipped to`); continue; }
363
+ if (!key) continue;
364
+ const keyed = /\borigin\b/.test(key);
365
+ // sessions is one row per session and must NOT key on origin; the transcript rooms
366
+ // hold many rows per session and must.
367
+ const want = t !== 'sessions';
368
+ if (keyed !== want) {
369
+ wrong.push(`${rooms[t]} (${key}) — origin ${want ? 'missing from' : 'must not be in'} the key`);
370
+ }
371
+ }
372
+ if (wrong.length) {
373
+ throw new Error(
374
+ 'these rooms have the wrong sorting key, so a ship pass would corrupt them:\n'
375
+ + wrong.map((s) => ` ${s}`).join('\n')
376
+ + '\n ORDER BY cannot be altered in place. Rebuild each room, then re-ship:'
377
+ + '\n RENAME TABLE <room> TO <room>_old;'
378
+ + `\n -- recreate from ${path.join(__dirname, '..', 'per-member', 'schema-member.sql.tpl')}`
379
+ + '\n INSERT INTO <room> SELECT * FROM <room>_old;'
380
+ + '\n (add `, \'ship\' AS origin` to the SELECT only if <room>_old has no origin column)'
381
+ + '\n A house with no imported rows can also just be re-shipped from scratch.');
382
+ }
383
+ }
384
+
385
+ async function ensureSchema(client) {
386
+ const rooms = await resolveRooms(client);
387
+ const { member } = rooms;
388
+ const tpl = fs.readFileSync(path.join(__dirname, '..', 'per-member', 'schema-member.sql.tpl'), 'utf-8');
389
+ const sql = tpl.replaceAll('{{MEMBER}}', member);
390
+ const stripped = sql.split('\n').map((l) => l.replace(/--.*$/, '')).join('\n');
391
+ const stmts = stripped.split(';').map((s) => s.trim()).filter(Boolean);
392
+ // A MEMBER holds no CREATE TABLE — that is the point of the narrowed grant set — and
393
+ // ClickHouse checks the grant BEFORE it checks existence, so `IF NOT EXISTS` does not
394
+ // save these. Running the CREATEs in a bare loop meant `--ensure-schema` died on the
395
+ // FIRST statement for the very user who is told to run it, and never reached the ADD
396
+ // COLUMN rollout that is the whole reason to run it. Skip what we may not do and carry
397
+ // on; the rooms either already exist (assertRoomsExist says so) or an owner has to
398
+ // create them, which install already explains.
399
+ let denied = 0;
400
+ for (const q of stmts) {
401
+ try {
402
+ await client.command({
403
+ query: q,
404
+ // allow_experimental_full_text_index: on 25.x the messages text indexes
405
+ // are gated behind this flag (SUPPORT_IS_DISABLED without it); 26.x+
406
+ // accepts it as a no-op. Query-scoped, so no server config or admin
407
+ // rights are needed. Verified on 25.11 and 26.7.
408
+ clickhouse_settings: { async_insert: 0, allow_experimental_full_text_index: 1 },
409
+ });
410
+ } catch (e) {
411
+ const m = e && e.message ? e.message : String(e);
412
+ if (/Not enough privileges|ACCESS_DENIED/i.test(m)) { denied++; continue; }
413
+ throw e;
414
+ }
415
+ }
416
+ if (denied) {
417
+ console.error(`[memhouse] ${denied} schema statement(s) needed rights you do not hold — continuing with what you can do.`);
418
+ console.error('[memhouse] creating or replacing a ROOM is the house owner\'s job; adding a missing COLUMN is not.');
419
+ }
420
+ // A house created before origin existed has no such column, and the clear binds it —
421
+ // an unguarded DELETE there would be the old destructive behaviour, and a guarded one
422
+ // would error. Add it in place; ReplacingMergeTree backfills the DEFAULT, so every
423
+ // pre-existing row reads as 'ship', which is what it was.
424
+ //
425
+ // Deliberately NOT applied to the Merge rooms — but not for the reason this comment used
426
+ // to give. It claimed they "reject ALTER"; they do not, on either 26.7 or 25.11, where
427
+ // `ALTER TABLE all_messages ADD COLUMN …` succeeds. The real reason is that a Merge room
428
+ // is a VIEW over whatever `^messages_` matches: a column added to it is cosmetic, is not
429
+ // backed by the underlying rooms, and drifts from them the moment a member is added. It
430
+ // takes its structure from a member room at CREATE time, so recreating it is the only
431
+ // correct way to change it — and nothing reads `origin` through it anyway.
432
+ // EVERY column the template declares, not just `origin`.
433
+ //
434
+ // This used to add exactly one column, which meant any OTHER column missing from a room
435
+ // was invisible and lossy: `ship` writes the row, ClickHouse discards the field it has
436
+ // no column for, and the pass reports success. Measured — a room with `is_subagent`
437
+ // dropped shipped 85 rows with the value silently thrown away, while `ship`, `install`,
438
+ // `--ensure-schema` AND `doctor` all reported green. doctor's schema check counts ROOMS,
439
+ // not columns, so nothing anywhere noticed.
440
+ //
441
+ // It also made the grant a lie: rooms.js and INSTALL.md both say ALTER ADD COLUMN is
442
+ // granted "for ensureSchema's rollout", and no rollout of anything but `origin` existed.
443
+ const wantCols = templateColumns(tpl);
444
+ for (const t of ROOM_TYPES) {
445
+ let have = new Set();
446
+ try {
447
+ const rs = await client.query({
448
+ query: 'SELECT name FROM system.columns WHERE database = currentDatabase() AND table = {n:String}',
449
+ query_params: { n: rooms[t] }, format: 'JSONEachRow',
450
+ });
451
+ have = new Set((await rs.json()).map((r) => r.name));
452
+ } catch { /* unreadable: the checks above already reported why */ }
453
+ for (const col of (wantCols[t] || [])) {
454
+ if (have.size && have.has(col.name)) continue;
455
+ try {
456
+ await client.command({
457
+ query: `ALTER TABLE ${rooms[t]} ADD COLUMN IF NOT EXISTS ${col.name} ${col.type}`,
458
+ clickhouse_settings: { allow_experimental_full_text_index: 1 },
459
+ });
460
+ if (have.size) console.log(`[memhouse] added missing column ${rooms[t]}.${col.name}`);
461
+ } catch (e) {
462
+ const m = e && e.message ? e.message : String(e);
463
+ if (!/Not enough privileges|ACCESS_DENIED/i.test(m)) {
464
+ console.error(`[memhouse] could not add ${rooms[t]}.${col.name}: ${m}`);
465
+ }
466
+ }
467
+ }
468
+ try {
469
+ await client.command({
470
+ query: `ALTER TABLE ${rooms[t]} ADD COLUMN IF NOT EXISTS origin LowCardinality(String) DEFAULT 'ship'`,
471
+ // 25.11 refuses ANY alter on a table carrying the messages text indexes unless
472
+ // this is set — including an ADD COLUMN that has nothing to do with them
473
+ // (Code: 344, SUPPORT_IS_DISABLED). Without it the backfill fails silently and
474
+ // the guard ends up missing on precisely the houses that need upgrading. A no-op
475
+ // on 26.x, and query-scoped, so it needs no server config.
476
+ clickhouse_settings: { allow_experimental_full_text_index: 1 },
477
+ });
478
+ } catch (e) {
479
+ // No rights to alter is genuinely not fatal — a member on someone else's house
480
+ // cannot roll a column out and does not need to. But this used to swallow
481
+ // EVERYTHING, including the 25.11 Code 344 this call carries a setting to avoid, and
482
+ // `schema ensured` printed regardless. Name anything that is not a permissions
483
+ // problem, and keep going.
484
+ const m = e && e.message ? e.message : String(e);
485
+ if (!/Not enough privileges|ACCESS_DENIED/i.test(m)) {
486
+ console.error(`[memhouse] could not add the 'origin' column to ${rooms[t]}: ${m}`);
487
+ console.error('[memhouse] the room still works; a house that predates 0.4.4 needs rebuilding — memhouse doctor');
488
+ }
489
+ }
490
+ }
491
+
492
+ await assertRoomsExist(client, rooms);
493
+ await assertOriginKeyed(client, rooms);
494
+ await warnMissingColumns(client, rooms);
495
+ return stmts.length;
496
+ }
497
+
498
+ // Incremental state: what the house already holds, keyed by session_id. We compare
499
+ // against extra.bubbleCount (the adapter's own cheap count, stored at ship time) —
500
+ // NOT message_count — because parsed-message count and bubbleCount are different
501
+ // units in several adapters (claude folds subagents, codex reports 0), and skipping
502
+ // must be decidable WITHOUT calling getMessages on every chat.
503
+ async function loadExisting(client, rooms) {
504
+ const rs = await client.query({
505
+ query: `SELECT session_id, last_updated_at, message_count, extra FROM ${rooms.sessions} FINAL WHERE user_id = currentUser()`,
506
+ format: 'JSONEachRow',
507
+ });
508
+ // Actual message rows per session: an interrupted re-ship (crash between the
509
+ // clear-DELETE and the inserts, or mid-flush on a large transcript) leaves a
510
+ // fresh-looking session row with a missing or PARTIAL transcript — the skip
511
+ // must compare the real row count against the recorded message_count, not
512
+ // merely check that some row exists.
513
+ //
514
+ // countIf(origin='ship'), not count(). message_count records how many rows the
515
+ // SHIPPER wrote; a session that also carries imported rows would never match a
516
+ // plain count(), so `intact` would be false forever and the session would re-ship
517
+ // on every pass — no data lost, but the incremental skip silently stops existing.
518
+ // Measured: adding one origin='import' row to a settled session made it re-ship
519
+ // every pass; removing it froze the session again.
520
+ const mr = await client.query({
521
+ query: `SELECT session_id, countIf(origin = 'ship') AS n FROM ${rooms.messages} FINAL WHERE user_id = currentUser() GROUP BY session_id`,
522
+ format: 'JSONEachRow',
523
+ });
524
+ const msgCounts = new Map();
525
+ for (const r of await mr.json()) msgCounts.set(r.session_id, toInt(r.n));
526
+ // The tool_calls room needs the same check, and used to have none. shipSession writes
527
+ // sessions, then messages, then tool_calls — so a pass that fails during the LAST of
528
+ // those three leaves a session whose message count matches perfectly. The next pass
529
+ // reads that as intact, skips it, and the tool calls are never written. Not a crash:
530
+ // exit 0, `status` and `stats` both green, and only `ship --full` ever recovers them.
531
+ // Reproduced by dropping the room mid-pass; any transient — a ClickHouse restart, a
532
+ // quota rejection, a network blip — reaches the same state.
533
+ const tr = await client.query({
534
+ query: `SELECT session_id, countIf(origin = 'ship') AS n FROM ${rooms.tool_calls} FINAL WHERE user_id = currentUser() GROUP BY session_id`,
535
+ format: 'JSONEachRow',
536
+ });
537
+ const toolCounts = new Map();
538
+ for (const r of await tr.json()) toolCounts.set(r.session_id, toInt(r.n));
539
+ const map = new Map();
540
+ for (const r of await rs.json()) {
541
+ // DateTime64 comes back as 'YYYY-MM-DD HH:MM:SS.mmm' — re-parse as UTC.
542
+ const ms = r.last_updated_at ? Date.parse(r.last_updated_at.replace(' ', 'T') + 'Z') : null;
543
+ const bc = toInt(r.extra && r.extra.bubbleCount);
544
+ const count = toInt(r.message_count);
545
+ // Sessions shipped before toolCallCount existed read 0 here, so any of them that do
546
+ // have tool calls re-ship exactly once and then record it. Self-correcting, and
547
+ // cheaper than a branch that has to be remembered forever.
548
+ const toolCount = toInt(r.extra && r.extra.toolCallCount);
549
+ const intact = (msgCounts.get(r.session_id) || 0) === count
550
+ && (toolCounts.get(r.session_id) || 0) === toolCount;
551
+ map.set(r.session_id, { ms, count, bc, intact });
552
+ }
553
+ return map;
554
+ }
555
+
556
+ // Regex fallback for adapters that render tool calls into text but don't expose
557
+ // m._toolCalls: matches '[tool-call: Name(' and '[tool-call: Name]' forms.
558
+ const TOOL_CALL_RE = /\[tool-call: ([^(\]]+)/g;
559
+
560
+ // Build the typed rows for one chat. Returns null when the chat is unreadable
561
+ // (adapter threw — e.g. a partially-written or locked session file): the caller
562
+ // must write NOTHING for it, so the next incremental pass retries. A chat that
563
+ // parses to zero messages still gets its session row (deliberate: the stored row
564
+ // absorbs the incremental skip for stable-empty chats).
565
+ function rowsForChat(chat, host) {
566
+ let messages;
567
+ try { messages = getMessages(chat) || []; }
568
+ catch (e) {
569
+ // Some readers throw instead of reporting through adapter-errors — Cursor's
570
+ // agent-store path on a bad blobs table, for one. Converting that to a bare
571
+ // `unreadable` count would leave the end-of-pass report with nothing to print,
572
+ // so operators would see a number rising every pass and no cause anywhere.
573
+ adapterErrorSink.record(chat.source, e, chat.composerId);
574
+ return null;
575
+ }
576
+
577
+ const source = chat.source;
578
+ // Canonical globally-unique session id: '<source>:<adapter-local id>'.
579
+ // composerId is only unique WITHIN one editor; two editors emitting the same
580
+ // id must never collide in keys, incremental state, deletes, or the API.
581
+ // Sources are colon-free, so the prefix parses back unambiguously.
582
+ const id = `${source}:${String(chat.composerId)}`;
583
+ const folder = chat.folder || '';
584
+ const project = folder ? path.basename(folder) : '';
585
+ const total = messages.length;
586
+
587
+ const session = {
588
+ session_id: id,
589
+ source,
590
+ host,
591
+ name: chat.name || '',
592
+ mode: chat.mode || '',
593
+ folder,
594
+ project,
595
+ git_branch: chat._gitBranch || '',
596
+ created_at: chat.createdAt ? chTs(chat.createdAt) : null,
597
+ last_updated_at: chat.lastUpdatedAt ? chTs(chat.lastUpdatedAt) : null,
598
+ message_count: total,
599
+ path: chat._fullPath || chat._dbPath || id,
600
+ extra: { bubbleCount: chat.bubbleCount || 0 },
601
+ };
602
+
603
+ const msgRows = [];
604
+ const toolRows = [];
605
+ let idx = 0; // session-wide tool-call index
606
+ for (let seq = 0; seq < total; seq++) {
607
+ const m = messages[seq];
608
+ const raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content) ?? '';
609
+ const text = truncate(raw, TEXT_MAX);
610
+ const ts = messageTs(chat, seq, total, m);
611
+ const row = {
612
+ session_id: id,
613
+ seq,
614
+ source,
615
+ host,
616
+ ts,
617
+ role: m.role || '',
618
+ model: m._model || '',
619
+ input_tokens: toInt(m._inputTokens),
620
+ output_tokens: toInt(m._outputTokens),
621
+ cache_read_tokens: toInt(m._cacheRead),
622
+ cache_write_tokens: toInt(m._cacheWrite),
623
+ text,
624
+ project,
625
+ folder,
626
+ is_subagent: text.startsWith('[subagent]'),
627
+ extra: {},
628
+ };
629
+ // Hash the content fields only (not ts — it is synthesized and may drift).
630
+ row.line_hash = lineHash({
631
+ session_id: id, seq, role: row.role, model: row.model,
632
+ input_tokens: row.input_tokens, output_tokens: row.output_tokens,
633
+ cache_read_tokens: row.cache_read_tokens, cache_write_tokens: row.cache_write_tokens,
634
+ text,
635
+ });
636
+ msgRows.push(row);
637
+
638
+ if (m.role !== 'assistant') continue;
639
+ let calls;
640
+ if (Array.isArray(m._toolCalls) && m._toolCalls.length) {
641
+ calls = m._toolCalls.map((tc) => ({ name: String(tc.name || 'unknown'), args: tc.args || {} }));
642
+ } else {
643
+ calls = [];
644
+ for (const match of text.matchAll(TOOL_CALL_RE)) {
645
+ calls.push({ name: match[1].trim(), args: {} });
646
+ }
647
+ }
648
+ for (const tc of calls) {
649
+ const args = JSON.stringify(tc.args);
650
+ toolRows.push({
651
+ session_id: id,
652
+ seq,
653
+ idx: idx++,
654
+ source,
655
+ host,
656
+ tool_name: tc.name,
657
+ args: truncate(args, ARGS_MAX),
658
+ ts,
659
+ project,
660
+ folder,
661
+ });
662
+ }
663
+ }
664
+ // What a complete ship of this session looks like, recorded ON the session row so the
665
+ // next pass can tell "finished" from "got part way". message_count already carries the
666
+ // message half; without the tool half a pass that dies between the messages insert and
667
+ // the tool_calls insert leaves a session that looks finished forever. See loadExisting.
668
+ session.extra.toolCallCount = toolRows.length;
669
+ return { session, msgRows, toolRows };
670
+ }
671
+
672
+ // One shipping pass. Incremental unless opts.full: a chat is skipped when the house
673
+ // already has it at least as fresh (last_updated_at) and at least as large
674
+ // (extra.bubbleCount) — both readable without parsing the chat.
675
+ async function runShip(client, opts = {}) {
676
+ const { full = false } = opts;
677
+ const host = hostId();
678
+ // Always load what the house holds — even with --full. The skip decision uses it
679
+ // only in incremental mode, but re-shipping a KNOWN session must clear its old
680
+ // message/tool rows first (a shrunken re-parse leaves stale higher-seq rows
681
+ // otherwise: ReplacingMergeTree collapses same-key rows only).
682
+ const rooms = await resolveRooms(client);
683
+ await assertRoomsExist(client, rooms);
684
+ await assertOriginKeyed(client, rooms);
685
+ await warnMissingColumns(client, rooms);
686
+ const existing = await loadExisting(client, rooms);
687
+ const chats = getAllChats();
688
+
689
+ // An adapter that cannot load contributes zero sessions, which is indistinguishable
690
+ // from an editor the user does not have — so the shipper would silently ship a
691
+ // partial history forever. Say so on every pass.
692
+ // Reported once here for what the scan already knows (a dead binding is visible
693
+ // before any work happens, and this pass may take minutes), and again after the
694
+ // loop for failures that only surface while reading messages.
695
+ const warned = new Set();
696
+ reportAdapterErrors(warned);
697
+
698
+ const batches = { sessions: [], messages: [], tool_calls: [] };
699
+ const flush = async (table) => {
700
+ if (!batches[table].length) return;
701
+ await client.insert({
702
+ table: rooms[table],
703
+ values: batches[table],
704
+ format: 'JSONEachRow',
705
+ clickhouse_settings: { async_insert: 0 }, // binding: user_id stamping breaks otherwise
706
+ });
707
+ batches[table] = [];
708
+ };
709
+ const push = async (table, row) => {
710
+ batches[table].push(row);
711
+ if (batches[table].length >= BATCH_ROWS) await flush(table);
712
+ };
713
+
714
+ const seen = new Set(); // adapters must not double-ship a session id within a pass
715
+ let sessions = 0, skipped = 0, msgRows = 0, toolRows = 0, unreadable = 0;
716
+ for (const chat of chats) {
717
+ if (chat.encrypted) continue;
718
+ // Same canonical '<source>:<adapter-local id>' as rowsForChat: dedup and
719
+ // incremental state are per (source, id) — two editors sharing an id must
720
+ // not skip or overwrite each other.
721
+ const id = `${chat.source}:${String(chat.composerId)}`;
722
+ if (seen.has(id)) continue;
723
+ seen.add(id);
724
+
725
+ const prev = existing.get(id);
726
+ if (!full && prev) {
727
+ // floor: some adapters (codex) emit fractional-ms timestamps, but DateTime64(3)
728
+ // stores whole ms — without it every such session loses by <1ms and re-ships.
729
+ const chatLast = Math.floor(chat.lastUpdatedAt || chat.createdAt || 0);
730
+ const notNewer = chatLast === 0 || (prev.ms !== null && prev.ms >= chatLast);
731
+ // Freshness alone is not enough: the transcript must be COMPLETE (stored
732
+ // row count == recorded message_count) so an interrupted re-ship — even one
733
+ // that died mid-flush leaving a partial transcript — repairs itself on the
734
+ // next pass instead of being skipped forever.
735
+ // _countUnknown means the adapter could not determine this chat's message
736
+ // count, so bubbleCount is a placeholder rather than a measurement. Skipping on
737
+ // it would compare against a number that means nothing — `prev.bc >= 0` is
738
+ // trivially true — and the session would stay stale forever. Re-read instead.
739
+ if (notNewer && !chat._countUnknown && prev.bc >= (chat.bubbleCount || 0) && prev.intact) { skipped++; continue; }
740
+ }
741
+
742
+ // rowsForChat returns null only when getMessages() *throws*. An adapter that
743
+ // swallows its own failure returns [] instead, which would look like a session
744
+ // that genuinely has no messages — and for a known session the re-ship below
745
+ // deletes the old transcript before inserting that emptiness. So treat a
746
+ // failure recorded while reading THIS chat as unreadable too: write nothing,
747
+ // delete nothing, retry next pass.
748
+ const errsBefore = adapterErrorSink.recorded().length;
749
+ const rows = rowsForChat(chat, host);
750
+ const failedHere = adapterErrorSink.recorded().slice(errsBefore).some((e) => e.source === chat.source);
751
+ // Write nothing on a failed read, whether or not the session is already stored.
752
+ //
753
+ // Shipping a partial first read looks tempting — nothing is there to destroy —
754
+ // but it is a trap: message_count would be written from the partial rows, so
755
+ // prev.intact becomes true, prev.bc already matches the source, the timestamp is
756
+ // unchanged, and the skip predicate above then withholds the session forever.
757
+ // The truncation would become permanent AND the warning would stop, because the
758
+ // adapter is never asked to read it again. Withholding keeps the session out of
759
+ // the skip predicate entirely, so every pass retries it and re-reports it until
760
+ // the underlying store is readable.
761
+ if (!rows || failedHere) { unreadable++; continue; }
762
+ if (prev) {
763
+ // Known session being re-shipped: clear its old rows BEFORE inserting so a
764
+ // shorter re-parse can't leave stale seq/idx tails. A crash between the
765
+ // delete and the inserts is repaired by the next pass: the skip predicate
766
+ // refuses to skip a non-empty session whose message rows are missing.
767
+ // user_id is BOUND, not `= currentUser()`. A DELETE is a mutation, and a mutation
768
+ // does not necessarily evaluate currentUser() in the caller's context — it matches
769
+ // nothing at all, so the delete silently removes zero rows and the stale tail this
770
+ // code exists to clear survives forever. Measured on ClickHouse 25.11 —
771
+ // the identical predicate with the literal value deleted 2000 rows where
772
+ // currentUser() deleted 0. The value is the same identity either way: it is read
773
+ // from the server over this very connection.
774
+ // origin='ship' is the third bind, and it is not cosmetic. This clear exists so a
775
+ // shorter re-parse cannot leave a stale seq tail behind — but scoped to
776
+ // (session_id, user_id) alone it deletes EVERY row for the session, including rows
777
+ // the adapters did not write and cannot rewrite. Measured, on a real house: an
778
+ // import of 135,307 messages lost 27,948 of them to one ship pass, because
779
+ // memory-house had captured more per session than the adapters emit. The rows the
780
+ // shipper owns are the only rows it may remove.
781
+ for (const t of ['messages', 'tool_calls']) {
782
+ await client.command({
783
+ query: `DELETE FROM ${rooms[t]} WHERE session_id = {id:String} AND user_id = {uid:String} AND origin = 'ship'`,
784
+ query_params: { id, uid: rooms.user },
785
+ clickhouse_settings: { async_insert: 0 },
786
+ });
787
+ }
788
+ }
789
+ await push('sessions', scrub(rows.session));
790
+ sessions++;
791
+ for (const r of rows.msgRows) { await push('messages', scrub(r)); msgRows++; }
792
+ for (const r of rows.toolRows) { await push('tool_calls', scrub(r)); toolRows++; }
793
+ }
794
+ for (const table of Object.keys(batches)) await flush(table);
795
+ // Anything that only failed while reading messages — the sink is reset by the
796
+ // next getAllChats(), so unreported here means never reported at all.
797
+ reportAdapterErrors(warned);
798
+ return { sessions, skipped, msgRows, toolRows, unreadable };
799
+ }
800
+
801
+ // Warn once per adapter per pass. `warned` carries across the two call sites so a
802
+ // dead binding is not reported twice in the same run.
803
+ function reportAdapterErrors(warned) {
804
+ const errors = getAdapterErrors().filter((e) => !warned.has(e.source));
805
+ if (!errors.length) return;
806
+ for (const e of errors) warned.add(e.source);
807
+ const noBinding = errors.filter((e) => e.missingBinding).map((e) => e.source);
808
+ if (noBinding.length) {
809
+ console.log(`[memhouse] WARNING: ${noBinding.length} adapter(s) skipped, sessions NOT shipped — better-sqlite3 has no native binding: ${noBinding.join(', ')}`);
810
+ console.log(`[memhouse] fix: ${require('../per-member/rooms').installCommand()}`);
811
+ }
812
+ for (const e of errors.filter((x) => !x.missingBinding)) {
813
+ console.log(`[memhouse] WARNING: ${e.source} skipped — ${e.message}`);
814
+ }
815
+ }
816
+
817
+ // Per-source rollup straight from sessions_v (final=1 so ReplacingMergeTree collapses).
818
+ // The rollup resolves like the rooms do — it is a subquery over the caller's own rooms.
819
+ async function printStats(client) {
820
+ const rooms = await resolveRooms(client);
821
+ const rs = await client.query({
822
+ query: `
823
+ SELECT source,
824
+ count() AS sessions,
825
+ sum(total_msgs) AS messages,
826
+ sum(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) AS tokens
827
+ FROM ${rooms.sessions_v}
828
+ GROUP BY source
829
+ ORDER BY sessions DESC`,
830
+ format: 'JSONEachRow',
831
+ clickhouse_settings: READ_SETTINGS,
832
+ });
833
+ const rows = await rs.json();
834
+ if (!rows.length) { console.log('[memhouse] house is empty'); return; }
835
+ const w = [Math.max(6, ...rows.map((r) => r.source.length)), 8, 8, 14];
836
+ const line = (a, b, c, d) =>
837
+ console.log(`${String(a).padEnd(w[0])} ${String(b).padStart(w[1])} ${String(c).padStart(w[2])} ${String(d).padStart(w[3])}`);
838
+ line('source', 'sessions', 'messages', 'tokens');
839
+ let ts = 0, tm = 0, tt = 0;
840
+ for (const r of rows) {
841
+ line(r.source, r.sessions, r.messages, r.tokens);
842
+ ts += Number(r.sessions); tm += Number(r.messages); tt += Number(r.tokens);
843
+ }
844
+ line('total', ts, tm, tt);
845
+ }
846
+
847
+ async function main() {
848
+ const argv = process.argv.slice(2);
849
+ const client = makeClient();
850
+ try {
851
+ if (argv.includes('--ensure-schema')) {
852
+ const n = await ensureSchema(client);
853
+ console.log(`[memhouse] schema ensured (${n} statements)`);
854
+ return;
855
+ }
856
+ if (argv.includes('--stats')) { await printStats(client); return; }
857
+
858
+ const loopIdx = argv.indexOf('--loop');
859
+ const loop = loopIdx !== -1;
860
+ let intervalSec = 300;
861
+ if (loop) {
862
+ const n = Number(argv[loopIdx + 1]);
863
+ if (Number.isFinite(n) && n > 0) intervalSec = n;
864
+ }
865
+ let full = argv.includes('--full');
866
+ // A failed pass does NOT wait the full interval. The common failure at startup is
867
+ // that the house is not up yet — the container is still booting — and sleeping 300s
868
+ // there means the first ship is
869
+ // five minutes late for a condition that clears in under a second. systemd's
870
+ // After= orders process start, not readiness, and launchd has no ordering at all, so
871
+ // this is the only place the race can be closed for every path at once.
872
+ // Backs off to the normal interval so a genuinely unreachable house is not hammered.
873
+ const RETRY_START_MS = 2000;
874
+ let retryMs = RETRY_START_MS;
875
+ do {
876
+ const t0 = Date.now();
877
+ let failed = false;
878
+ try {
879
+ const r = await runShip(client, { full });
880
+ console.log(`[memhouse] shipped ${r.sessions} sessions (${r.skipped} skipped${r.unreadable ? `, ${r.unreadable} unreadable-will-retry` : ''}) → ` +
881
+ `${r.msgRows} msg rows, ${r.toolRows} tool rows in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
882
+ } catch (e) {
883
+ failed = true;
884
+ // A connection failure arrives as an AggregateError whose own `message` is empty,
885
+ // so this printed "[memhouse] pass failed:" and nothing else, after 35 lines of
886
+ // driver internals. Dig out a cause and name the fix; `status` and `doctor`
887
+ // already handle the same condition cleanly and `ship` was the odd one out.
888
+ const causes = (e && e.errors) ? e.errors.map((x) => x && x.message).filter(Boolean) : [];
889
+ const why = e.message || causes[0] || e.code || String(e);
890
+ console.error(`[memhouse] pass failed: ${why}`);
891
+ if (/ECONNREFUSED|fetch failed|ENOTFOUND|EAI_AGAIN|socket hang up/i.test(why + causes.join(' '))) {
892
+ console.error(`[memhouse] ${process.env.MEMHOUSE_URL} did not answer — is the house running?`);
893
+ } else if (/Authentication failed|ACCESS_DENIED|Not enough privileges/i.test(why)) {
894
+ console.error(`[memhouse] the credential in ${process.env.MEMHOUSE_HOME || '~/.memhouse'}/env was rejected by ${process.env.MEMHOUSE_URL}`);
895
+ } else if (/does not exist|UNKNOWN_TABLE|UNKNOWN_DATABASE/i.test(why)) {
896
+ console.error('[memhouse] the house is missing a room this pass needed');
897
+ }
898
+ // doctor diagnoses every one of these and prints the fix, so say so ALWAYS rather
899
+ // than only for the one failure mode that happened to be special-cased. The line
900
+ // this used to end on — "MEMHOUSE_DEBUG=1 for the full error" — is a next step for
901
+ // filing a bug, not for fixing the house.
902
+ console.error('[memhouse] diagnose it with: memhouse doctor');
903
+ if (process.env.MEMHOUSE_DEBUG) console.error(e);
904
+ else console.error('[memhouse] (MEMHOUSE_DEBUG=1 for the full error)');
905
+ if (!loop) process.exitCode = 1;
906
+ }
907
+ full = false; // --full applies to the first pass only; loop passes stay incremental
908
+ if (loop) {
909
+ const waitMs = failed ? Math.min(retryMs, intervalSec * 1000) : intervalSec * 1000;
910
+ if (failed) {
911
+ console.error(`[memhouse] retrying in ${Math.round(waitMs / 1000)}s`);
912
+ retryMs = Math.min(retryMs * 2, intervalSec * 1000);
913
+ } else {
914
+ retryMs = RETRY_START_MS;
915
+ }
916
+ await new Promise((r) => setTimeout(r, waitMs));
917
+ resetCaches(); // adapters cache chat lists; drop them so new sessions surface
918
+ // Then, before the next pass, notice if the installation underneath us changed.
919
+ // Checked AFTER the wait rather than before it so an upgrade landing mid-sleep is
920
+ // acted on at the top of the next pass instead of one whole interval later — and
921
+ // never mid-pass, where handing over would abandon a half-shipped session.
922
+ // Does not return when it hands over.
923
+ selfUpdate.maybeRestart({ snap: selfSnap, name: 'shipper' });
924
+ }
925
+ } while (loop);
926
+ } finally {
927
+ await client.close();
928
+ }
929
+ }
930
+
931
+ module.exports = { runShip, ensureSchema, templateColumns };
932
+
933
+ if (require.main === module) {
934
+ main().catch((e) => {
935
+ // A permissions failure here is not a crash — the CALLER (install, doctor) catches the
936
+ // same condition and prints a refusal naming both routes. Printing "[memhouse] fatal:"
937
+ // first put a raw driver sentence above that refusal and made a handled case read like
938
+ // an unhandled one.
939
+ // Exit quietly ONLY where a caller is known to print the refusal itself: install and
940
+ // doctor both catch this condition and name both routes. Everywhere else — and
941
+ // `memhouse ship` is everywhere else — silence plus exit 1 is the worst possible
942
+ // output, and this suppression produced exactly that for a member following the
943
+ // advice to run `ship --ensure-schema`.
944
+ const m = e && e.message ? e.message : String(e);
945
+ if (/Not enough privileges|ACCESS_DENIED/i.test(m) && process.env.MEMHOUSE_QUIET_DENIED === '1') process.exit(1);
946
+ console.error(`[memhouse] ${/Not enough privileges|ACCESS_DENIED/i.test(m) ? 'refused' : 'fatal'}: ${m}`);
947
+ process.exit(1);
948
+ });
949
+ }