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
@@ -1,504 +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 } = 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
- async function ensureSchema(client) {
116
- const { member } = await resolveRooms(client);
117
- const tpl = fs.readFileSync(path.join(__dirname, '..', 'per-member', 'schema-member.sql.tpl'), 'utf-8');
118
- const sql = tpl.replaceAll('{{MEMBER}}', member);
119
- const stripped = sql.split('\n').map((l) => l.replace(/--.*$/, '')).join('\n');
120
- const stmts = stripped.split(';').map((s) => s.trim()).filter(Boolean);
121
- for (const q of stmts) {
122
- await client.command({
123
- query: q,
124
- // allow_experimental_full_text_index: on 25.x the messages text indexes
125
- // are gated behind this flag (SUPPORT_IS_DISABLED without it); 26.x+
126
- // accepts it as a no-op. Query-scoped, so no server config or admin
127
- // rights are needed. Verified on 25.11 and 26.7.
128
- clickhouse_settings: { async_insert: 0, allow_experimental_full_text_index: 1 },
129
- });
130
- }
131
- return stmts.length;
132
- }
133
-
134
- // Incremental state: what the house already holds, keyed by session_id. We compare
135
- // against extra.bubbleCount (the adapter's own cheap count, stored at ship time) —
136
- // NOT message_count — because parsed-message count and bubbleCount are different
137
- // units in several adapters (claude folds subagents, codex reports 0), and skipping
138
- // must be decidable WITHOUT calling getMessages on every chat.
139
- async function loadExisting(client, rooms) {
140
- const rs = await client.query({
141
- query: `SELECT session_id, last_updated_at, message_count, extra FROM ${rooms.sessions} FINAL WHERE user_id = currentUser()`,
142
- format: 'JSONEachRow',
143
- });
144
- // Actual message rows per session: an interrupted re-ship (crash between the
145
- // clear-DELETE and the inserts, or mid-flush on a large transcript) leaves a
146
- // fresh-looking session row with a missing or PARTIAL transcript — the skip
147
- // must compare the real row count against the recorded message_count, not
148
- // merely check that some row exists.
149
- const mr = await client.query({
150
- query: `SELECT session_id, count() AS n FROM ${rooms.messages} FINAL WHERE user_id = currentUser() GROUP BY session_id`,
151
- format: 'JSONEachRow',
152
- });
153
- const msgCounts = new Map();
154
- for (const r of await mr.json()) msgCounts.set(r.session_id, toInt(r.n));
155
- const map = new Map();
156
- for (const r of await rs.json()) {
157
- // DateTime64 comes back as 'YYYY-MM-DD HH:MM:SS.mmm' — re-parse as UTC.
158
- const ms = r.last_updated_at ? Date.parse(r.last_updated_at.replace(' ', 'T') + 'Z') : null;
159
- const bc = toInt(r.extra && r.extra.bubbleCount);
160
- const count = toInt(r.message_count);
161
- map.set(r.session_id, { ms, count, bc, intact: (msgCounts.get(r.session_id) || 0) === count });
162
- }
163
- return map;
164
- }
165
-
166
- // Regex fallback for adapters that render tool calls into text but don't expose
167
- // m._toolCalls: matches '[tool-call: Name(' and '[tool-call: Name]' forms.
168
- const TOOL_CALL_RE = /\[tool-call: ([^(\]]+)/g;
169
-
170
- // Build the typed rows for one chat. Returns null when the chat is unreadable
171
- // (adapter threw — e.g. a partially-written or locked session file): the caller
172
- // must write NOTHING for it, so the next incremental pass retries. A chat that
173
- // parses to zero messages still gets its session row (deliberate: the stored row
174
- // absorbs the incremental skip for stable-empty chats).
175
- function rowsForChat(chat, host) {
176
- let messages;
177
- try { messages = getMessages(chat) || []; }
178
- catch (e) {
179
- // Some readers throw instead of reporting through adapter-errors — Cursor's
180
- // agent-store path on a bad blobs table, for one. Converting that to a bare
181
- // `unreadable` count would leave the end-of-pass report with nothing to print,
182
- // so operators would see a number rising every pass and no cause anywhere.
183
- adapterErrorSink.record(chat.source, e, chat.composerId);
184
- return null;
185
- }
186
-
187
- const source = chat.source;
188
- // Canonical globally-unique session id: '<source>:<adapter-local id>'.
189
- // composerId is only unique WITHIN one editor; two editors emitting the same
190
- // id must never collide in keys, incremental state, deletes, or the API.
191
- // Sources are colon-free, so the prefix parses back unambiguously.
192
- const id = `${source}:${String(chat.composerId)}`;
193
- const folder = chat.folder || '';
194
- const project = folder ? path.basename(folder) : '';
195
- const total = messages.length;
196
-
197
- const session = {
198
- session_id: id,
199
- source,
200
- host,
201
- name: chat.name || '',
202
- mode: chat.mode || '',
203
- folder,
204
- project,
205
- git_branch: chat._gitBranch || '',
206
- created_at: chat.createdAt ? chTs(chat.createdAt) : null,
207
- last_updated_at: chat.lastUpdatedAt ? chTs(chat.lastUpdatedAt) : null,
208
- message_count: total,
209
- path: chat._fullPath || chat._dbPath || id,
210
- extra: { bubbleCount: chat.bubbleCount || 0 },
211
- };
212
-
213
- const msgRows = [];
214
- const toolRows = [];
215
- let idx = 0; // session-wide tool-call index
216
- for (let seq = 0; seq < total; seq++) {
217
- const m = messages[seq];
218
- const raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content) ?? '';
219
- const text = raw.length > TEXT_MAX ? raw.slice(0, TEXT_MAX) : raw;
220
- const ts = messageTs(chat, seq, total);
221
- const row = {
222
- session_id: id,
223
- seq,
224
- source,
225
- host,
226
- ts,
227
- role: m.role || '',
228
- model: m._model || '',
229
- input_tokens: toInt(m._inputTokens),
230
- output_tokens: toInt(m._outputTokens),
231
- cache_read_tokens: toInt(m._cacheRead),
232
- cache_write_tokens: toInt(m._cacheWrite),
233
- text,
234
- project,
235
- folder,
236
- is_subagent: text.startsWith('[subagent]'),
237
- extra: {},
238
- };
239
- // Hash the content fields only (not ts — it is synthesized and may drift).
240
- row.line_hash = lineHash({
241
- session_id: id, seq, role: row.role, model: row.model,
242
- input_tokens: row.input_tokens, output_tokens: row.output_tokens,
243
- cache_read_tokens: row.cache_read_tokens, cache_write_tokens: row.cache_write_tokens,
244
- text,
245
- });
246
- msgRows.push(row);
247
-
248
- if (m.role !== 'assistant') continue;
249
- let calls;
250
- if (Array.isArray(m._toolCalls) && m._toolCalls.length) {
251
- calls = m._toolCalls.map((tc) => ({ name: String(tc.name || 'unknown'), args: tc.args || {} }));
252
- } else {
253
- calls = [];
254
- for (const match of text.matchAll(TOOL_CALL_RE)) {
255
- calls.push({ name: match[1].trim(), args: {} });
256
- }
257
- }
258
- for (const tc of calls) {
259
- const args = JSON.stringify(tc.args);
260
- toolRows.push({
261
- session_id: id,
262
- seq,
263
- idx: idx++,
264
- source,
265
- host,
266
- tool_name: tc.name,
267
- args: args.length > ARGS_MAX ? args.slice(0, ARGS_MAX) : args,
268
- ts,
269
- project,
270
- folder,
271
- });
272
- }
273
- }
274
- return { session, msgRows, toolRows };
275
- }
276
-
277
- // One shipping pass. Incremental unless opts.full: a chat is skipped when the house
278
- // already has it at least as fresh (last_updated_at) and at least as large
279
- // (extra.bubbleCount) — both readable without parsing the chat.
280
- async function runShip(client, opts = {}) {
281
- const { full = false } = opts;
282
- const host = hostId();
283
- // Always load what the house holds — even with --full. The skip decision uses it
284
- // only in incremental mode, but re-shipping a KNOWN session must clear its old
285
- // message/tool rows first (a shrunken re-parse leaves stale higher-seq rows
286
- // otherwise: ReplacingMergeTree collapses same-key rows only).
287
- const rooms = await resolveRooms(client);
288
- const existing = await loadExisting(client, rooms);
289
- const chats = getAllChats();
290
-
291
- // An adapter that cannot load contributes zero sessions, which is indistinguishable
292
- // from an editor the user does not have — so the shipper would silently ship a
293
- // partial history forever. Say so on every pass.
294
- // Reported once here for what the scan already knows (a dead binding is visible
295
- // before any work happens, and this pass may take minutes), and again after the
296
- // loop for failures that only surface while reading messages.
297
- const warned = new Set();
298
- reportAdapterErrors(warned);
299
-
300
- const batches = { sessions: [], messages: [], tool_calls: [] };
301
- const flush = async (table) => {
302
- if (!batches[table].length) return;
303
- await client.insert({
304
- table: rooms[table],
305
- values: batches[table],
306
- format: 'JSONEachRow',
307
- clickhouse_settings: { async_insert: 0 }, // binding: user_id stamping breaks otherwise
308
- });
309
- batches[table] = [];
310
- };
311
- const push = async (table, row) => {
312
- batches[table].push(row);
313
- if (batches[table].length >= BATCH_ROWS) await flush(table);
314
- };
315
-
316
- const seen = new Set(); // adapters must not double-ship a session id within a pass
317
- let sessions = 0, skipped = 0, msgRows = 0, toolRows = 0, unreadable = 0;
318
- for (const chat of chats) {
319
- if (chat.encrypted) continue;
320
- // Same canonical '<source>:<adapter-local id>' as rowsForChat: dedup and
321
- // incremental state are per (source, id) — two editors sharing an id must
322
- // not skip or overwrite each other.
323
- const id = `${chat.source}:${String(chat.composerId)}`;
324
- if (seen.has(id)) continue;
325
- seen.add(id);
326
-
327
- const prev = existing.get(id);
328
- if (!full && prev) {
329
- // floor: some adapters (codex) emit fractional-ms timestamps, but DateTime64(3)
330
- // stores whole ms — without it every such session loses by <1ms and re-ships.
331
- const chatLast = Math.floor(chat.lastUpdatedAt || chat.createdAt || 0);
332
- const notNewer = chatLast === 0 || (prev.ms !== null && prev.ms >= chatLast);
333
- // Freshness alone is not enough: the transcript must be COMPLETE (stored
334
- // row count == recorded message_count) so an interrupted re-ship — even one
335
- // that died mid-flush leaving a partial transcript — repairs itself on the
336
- // next pass instead of being skipped forever.
337
- // _countUnknown means the adapter could not determine this chat's message
338
- // count, so bubbleCount is a placeholder rather than a measurement. Skipping on
339
- // it would compare against a number that means nothing — `prev.bc >= 0` is
340
- // trivially true — and the session would stay stale forever. Re-read instead.
341
- if (notNewer && !chat._countUnknown && prev.bc >= (chat.bubbleCount || 0) && prev.intact) { skipped++; continue; }
342
- }
343
-
344
- // rowsForChat returns null only when getMessages() *throws*. An adapter that
345
- // swallows its own failure returns [] instead, which would look like a session
346
- // that genuinely has no messages — and for a known session the re-ship below
347
- // deletes the old transcript before inserting that emptiness. So treat a
348
- // failure recorded while reading THIS chat as unreadable too: write nothing,
349
- // delete nothing, retry next pass.
350
- const errsBefore = adapterErrorSink.recorded().length;
351
- const rows = rowsForChat(chat, host);
352
- const failedHere = adapterErrorSink.recorded().slice(errsBefore).some((e) => e.source === chat.source);
353
- // Write nothing on a failed read, whether or not the session is already stored.
354
- //
355
- // Shipping a partial first read looks tempting — nothing is there to destroy —
356
- // but it is a trap: message_count would be written from the partial rows, so
357
- // prev.intact becomes true, prev.bc already matches the source, the timestamp is
358
- // unchanged, and the skip predicate above then withholds the session forever.
359
- // The truncation would become permanent AND the warning would stop, because the
360
- // adapter is never asked to read it again. Withholding keeps the session out of
361
- // the skip predicate entirely, so every pass retries it and re-reports it until
362
- // the underlying store is readable.
363
- if (!rows || failedHere) { unreadable++; continue; }
364
- if (prev) {
365
- // Known session being re-shipped: clear its old rows BEFORE inserting so a
366
- // shorter re-parse can't leave stale seq/idx tails. A crash between the
367
- // delete and the inserts is repaired by the next pass: the skip predicate
368
- // refuses to skip a non-empty session whose message rows are missing.
369
- // user_id is BOUND, not `= currentUser()`. A DELETE is a mutation, and a mutation
370
- // does not necessarily evaluate currentUser() in the caller's context — it matches
371
- // nothing at all, so the delete silently removes zero rows and the stale tail this
372
- // code exists to clear survives forever. Measured on ClickHouse 25.11 —
373
- // the identical predicate with the literal value deleted 2000 rows where
374
- // currentUser() deleted 0. The value is the same identity either way: it is read
375
- // from the server over this very connection.
376
- for (const t of ['messages', 'tool_calls']) {
377
- await client.command({
378
- query: `DELETE FROM ${rooms[t]} WHERE session_id = {id:String} AND user_id = {uid:String}`,
379
- query_params: { id, uid: rooms.user },
380
- clickhouse_settings: { async_insert: 0 },
381
- });
382
- }
383
- }
384
- await push('sessions', rows.session);
385
- sessions++;
386
- for (const r of rows.msgRows) { await push('messages', r); msgRows++; }
387
- for (const r of rows.toolRows) { await push('tool_calls', r); toolRows++; }
388
- }
389
- for (const table of Object.keys(batches)) await flush(table);
390
- // Anything that only failed while reading messages — the sink is reset by the
391
- // next getAllChats(), so unreported here means never reported at all.
392
- reportAdapterErrors(warned);
393
- return { sessions, skipped, msgRows, toolRows, unreadable };
394
- }
395
-
396
- // Warn once per adapter per pass. `warned` carries across the two call sites so a
397
- // dead binding is not reported twice in the same run.
398
- function reportAdapterErrors(warned) {
399
- const errors = getAdapterErrors().filter((e) => !warned.has(e.source));
400
- if (!errors.length) return;
401
- for (const e of errors) warned.add(e.source);
402
- const noBinding = errors.filter((e) => e.missingBinding).map((e) => e.source);
403
- if (noBinding.length) {
404
- console.log(`[mem-house] WARNING: ${noBinding.length} adapter(s) skipped, sessions NOT shipped — better-sqlite3 has no native binding: ${noBinding.join(', ')}`);
405
- console.log('[mem-house] fix: npm install -g memhouse --allow-scripts=better-sqlite3');
406
- }
407
- for (const e of errors.filter((x) => !x.missingBinding)) {
408
- console.log(`[mem-house] WARNING: ${e.source} skipped — ${e.message}`);
409
- }
410
- }
411
-
412
- // Per-source rollup straight from sessions_v (final=1 so ReplacingMergeTree collapses).
413
- // The rollup resolves like the rooms do — it is a subquery over the caller's own rooms.
414
- async function printStats(client) {
415
- const rooms = await resolveRooms(client);
416
- const rs = await client.query({
417
- query: `
418
- SELECT source,
419
- count() AS sessions,
420
- sum(total_msgs) AS messages,
421
- sum(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) AS tokens
422
- FROM ${rooms.sessions_v}
423
- GROUP BY source
424
- ORDER BY sessions DESC`,
425
- format: 'JSONEachRow',
426
- clickhouse_settings: READ_SETTINGS,
427
- });
428
- const rows = await rs.json();
429
- if (!rows.length) { console.log('[mem-house] house is empty'); return; }
430
- const w = [Math.max(6, ...rows.map((r) => r.source.length)), 8, 8, 14];
431
- const line = (a, b, c, d) =>
432
- console.log(`${String(a).padEnd(w[0])} ${String(b).padStart(w[1])} ${String(c).padStart(w[2])} ${String(d).padStart(w[3])}`);
433
- line('source', 'sessions', 'messages', 'tokens');
434
- let ts = 0, tm = 0, tt = 0;
435
- for (const r of rows) {
436
- line(r.source, r.sessions, r.messages, r.tokens);
437
- ts += Number(r.sessions); tm += Number(r.messages); tt += Number(r.tokens);
438
- }
439
- line('total', ts, tm, tt);
440
- }
441
-
442
- async function main() {
443
- const argv = process.argv.slice(2);
444
- const client = makeClient();
445
- try {
446
- if (argv.includes('--ensure-schema')) {
447
- const n = await ensureSchema(client);
448
- console.log(`[mem-house] schema ensured (${n} statements)`);
449
- return;
450
- }
451
- if (argv.includes('--stats')) { await printStats(client); return; }
452
-
453
- const loopIdx = argv.indexOf('--loop');
454
- const loop = loopIdx !== -1;
455
- let intervalSec = 300;
456
- if (loop) {
457
- const n = Number(argv[loopIdx + 1]);
458
- if (Number.isFinite(n) && n > 0) intervalSec = n;
459
- }
460
- let full = argv.includes('--full');
461
- // A failed pass does NOT wait the full interval. The common failure at startup is
462
- // that the house is not up yet — the container is still booting — and sleeping 300s
463
- // there means the first ship is
464
- // five minutes late for a condition that clears in under a second. systemd's
465
- // After= orders process start, not readiness, and launchd has no ordering at all, so
466
- // this is the only place the race can be closed for every path at once.
467
- // Backs off to the normal interval so a genuinely unreachable house is not hammered.
468
- const RETRY_START_MS = 2000;
469
- let retryMs = RETRY_START_MS;
470
- do {
471
- const t0 = Date.now();
472
- let failed = false;
473
- try {
474
- const r = await runShip(client, { full });
475
- console.log(`[mem-house] shipped ${r.sessions} sessions (${r.skipped} skipped${r.unreadable ? `, ${r.unreadable} unreadable-will-retry` : ''}) → ` +
476
- `${r.msgRows} msg rows, ${r.toolRows} tool rows in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
477
- } catch (e) {
478
- failed = true;
479
- console.error(`[mem-house] pass failed: ${e.message}`);
480
- if (!loop) process.exitCode = 1;
481
- }
482
- full = false; // --full applies to the first pass only; loop passes stay incremental
483
- if (loop) {
484
- const waitMs = failed ? Math.min(retryMs, intervalSec * 1000) : intervalSec * 1000;
485
- if (failed) {
486
- console.error(`[mem-house] retrying in ${Math.round(waitMs / 1000)}s`);
487
- retryMs = Math.min(retryMs * 2, intervalSec * 1000);
488
- } else {
489
- retryMs = RETRY_START_MS;
490
- }
491
- await new Promise((r) => setTimeout(r, waitMs));
492
- resetCaches(); // adapters cache chat lists; drop them so new sessions surface
493
- }
494
- } while (loop);
495
- } finally {
496
- await client.close();
497
- }
498
- }
499
-
500
- module.exports = { runShip, ensureSchema };
501
-
502
- if (require.main === module) {
503
- main().catch((e) => { console.error(`[mem-house] fatal: ${e.message}`); process.exit(1); });
504
- }