memhouse 0.8.0 → 0.11.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 (36) hide show
  1. package/README.md +84 -47
  2. package/bin/memhouse.js +993 -89
  3. package/editors/antigravity.js +7 -15
  4. package/editors/cursor.js +18 -24
  5. package/editors/goose.js +9 -21
  6. package/editors/index.js +19 -70
  7. package/editors/opencode.js +6 -15
  8. package/editors/sqlite.js +74 -0
  9. package/editors/windsurf.js +3 -3
  10. package/editors/zed.js +10 -15
  11. package/memhouse/COMPETITION.md +9 -5
  12. package/memhouse/DESIGN.md +37 -12
  13. package/memhouse/RESUME-AND-UPDATE.md +7 -10
  14. package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +10 -2
  15. package/memhouse/delivery/AGENT-INSTALL.md +56 -32
  16. package/memhouse/delivery/PROMPT.md +37 -13
  17. package/memhouse/delivery/kernel-install.md +4 -4
  18. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +14 -5
  19. package/memhouse/delivery/plugin/skills/ask/SKILL.md +112 -0
  20. package/memhouse/delivery/plugin/skills/hello/SKILL.md +76 -0
  21. package/memhouse/delivery/plugin/skills/invite/SKILL.md +65 -0
  22. package/memhouse/delivery/plugin/skills/search/SKILL.md +1 -1
  23. package/memhouse/delivery/plugin/skills/sessions/SKILL.md +3 -3
  24. package/memhouse/delivery/plugin/skills/share/SKILL.md +130 -0
  25. package/memhouse/delivery/plugin/skills/sql/SKILL.md +34 -11
  26. package/memhouse/delivery/plugin/skills/status/SKILL.md +114 -0
  27. package/memhouse/delivery/plugin/skills/users/SKILL.md +97 -0
  28. package/memhouse/house/HOUSE.md +40 -5
  29. package/memhouse/house/house.js +191 -10
  30. package/memhouse/house/migrate.js +277 -0
  31. package/memhouse/house/migrations/0100-epoch-key.js +68 -0
  32. package/memhouse/house/schema.sql.tpl +96 -25
  33. package/memhouse/self-update.js +4 -4
  34. package/memhouse/service.js +7 -2
  35. package/memhouse/shipper/ship.js +471 -131
  36. package/package.json +3 -6
@@ -14,11 +14,18 @@
14
14
  // - DateTime64 values travel as 'YYYY-MM-DD HH:MM:SS.mmm' UTC strings (plain
15
15
  // format; ISO 'T'/'Z' forms parse unreliably under JSONEachRow). Nullable → null.
16
16
  // - Int64-bound values are integer-coerced (some adapters emit fractional ms).
17
+ // - INSERT-ONLY. The shipper never deletes and never mutates. A re-parse that shrinks
18
+ // or diverges from what is stored is written under a NEW epoch, so the superseded
19
+ // parse survives intact — Claude Code compacts transcripts and deletes them after
20
+ // cleanupPeriodDays (30 by default), which makes the house the only remaining copy.
21
+ // memhouse is an accumulator, not a mirror: absence of a session is never a signal,
22
+ // and no "sync" or "prune" feature may ever be built here.
17
23
  // - 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.
24
+ // latest-wins at FINAL. Keyed (session_id, user_id, origin, epoch, seq) on messages
25
+ // and (…, epoch, idx) on tool_calls, so an imported row and a shipped one at the same
26
+ // seq are two rows, and so are two parses of the same session. sessions is
27
+ // (session_id, user_id) with NO origin and NO epoch — one metadata row per session is
28
+ // what every read path assumes.
22
29
  //
23
30
  // CLI: node ship.js one incremental pass
24
31
  // node ship.js --loop [sec] repeat every sec seconds (default 300)
@@ -52,7 +59,14 @@ const selfUpdate = require('../self-update');
52
59
  // Captured at require time, which is as close to "what this process booted with" as it
53
60
  // gets. Taking it later would record whatever an upgrade had already replaced.
54
61
  const selfSnap = selfUpdate.snapshot(__filename);
55
- const { resolveRooms, READ_SETTINGS, ROOM_TYPES } = require('../house/house');
62
+ const {
63
+ resolveRooms, READ_SETTINGS, ROOM_TYPES, META_TYPES, SCHEMA_VERSION, MEMBER_PIN,
64
+ SUPPORTED_SCHEMAS, keyProblem,
65
+ } = require('../house/house');
66
+ // Rooms plus the house's own record of itself. Every table the template declares, which
67
+ // is what the column healer and the drift warning have to cover — a column added to
68
+ // house_events would otherwise roll out to nobody.
69
+ const ALL_TABLES = [...ROOM_TYPES, ...META_TYPES];
56
70
 
57
71
  const BATCH_ROWS = 2000; // insert batch ceiling (binding)
58
72
  const TEXT_MAX = 50000; // messages.text truncation
@@ -263,11 +277,19 @@ function makeClient() {
263
277
  *
264
278
  * MATERIALIZED and DEFAULT clauses are kept: `user_id String MATERIALIZED currentUser()`
265
279
  * has to be added exactly that way or the identity stamp does not happen.
280
+ *
281
+ * The room name in this pattern is the LITERAL table name. It used to be
282
+ * `<type>_{{MEMBER}}`, from the per-member layout, and nobody updated it when the rooms
283
+ * became plain shared tables — so it matched nothing, and every consumer silently got an
284
+ * empty column list. That took out three surfaces at once, all of them reporting success:
285
+ * the column healer in ensureSchema had nothing to add, warnMissingColumns had nothing to
286
+ * warn about, and doctor's column check printed "✓ columns: every room matches the schema
287
+ * template" having compared zero columns. A regression test now pins it (misc/unit-test.js).
266
288
  */
267
289
  function templateColumns(tpl) {
268
290
  const out = {};
269
- for (const t of ROOM_TYPES) {
270
- const re = new RegExp(`CREATE TABLE IF NOT EXISTS ${t}_\\{\\{MEMBER\\}\\}\\s*\\(([\\s\\S]*?)\\n\\)`, 'm');
291
+ for (const t of ALL_TABLES) {
292
+ const re = new RegExp(`CREATE TABLE IF NOT EXISTS ${t}\\s*\\(([\\s\\S]*?)\\n\\)`, 'm');
271
293
  const m = tpl.match(re);
272
294
  if (!m) continue;
273
295
  const cols = [];
@@ -308,15 +330,15 @@ async function warnMissingColumns(client, rooms) {
308
330
  catch { return; }
309
331
  const want = templateColumns(tpl);
310
332
  const missing = [];
311
- for (const t of ROOM_TYPES) {
333
+ for (const t of ALL_TABLES) {
312
334
  try {
313
335
  const rs = await client.query({
314
336
  query: 'SELECT name FROM system.columns WHERE database = currentDatabase() AND table = {n:String}',
315
- query_params: { n: rooms[t] }, format: 'JSONEachRow',
337
+ query_params: { n: rooms[`${t}_raw`] }, format: 'JSONEachRow',
316
338
  });
317
339
  const have = new Set((await rs.json()).map((r) => r.name));
318
340
  if (!have.size) continue;
319
- for (const c of (want[t] || [])) if (!have.has(c.name)) missing.push(`${rooms[t]}.${c.name}`);
341
+ for (const c of (want[t] || [])) if (!have.has(c.name)) missing.push(`${rooms[`${t}_raw`]}.${c.name}`);
320
342
  } catch { /* unreadable rooms are assertRoomsExist's problem */ }
321
343
  }
322
344
  if (missing.length) {
@@ -329,11 +351,11 @@ async function warnMissingColumns(client, rooms) {
329
351
  async function assertRoomsExist(client, rooms) {
330
352
  const rs = await client.query({
331
353
  query: `SELECT name FROM system.tables WHERE database = currentDatabase() AND name IN ({n:Array(String)})`,
332
- query_params: { n: ROOM_TYPES.map((t) => rooms[t]) },
354
+ query_params: { n: ROOM_TYPES.map((t) => rooms[`${t}_raw`]) },
333
355
  format: 'JSONEachRow',
334
356
  });
335
357
  const have = new Set((await rs.json()).map((r) => r.name));
336
- const missing = ROOM_TYPES.map((t) => rooms[t]).filter((n) => !have.has(n));
358
+ const missing = ROOM_TYPES.map((t) => rooms[`${t}_raw`]).filter((n) => !have.has(n));
337
359
  if (!missing.length) return;
338
360
  throw new Error(
339
361
  `'${rooms.member}' has no ${missing.join(', ')} in this house.\n`
@@ -343,46 +365,45 @@ async function assertRoomsExist(client, rooms) {
343
365
  + ' A house from before 0.4 reaches this too — its rooms have different names.');
344
366
  }
345
367
 
346
- async function assertOriginKeyed(client, rooms) {
368
+ /**
369
+ * Refuse to write into rooms whose sorting key is not the one the shipper's safety
370
+ * depends on. What each column buys is written out on ROOM_KEYS in ../house/house.js;
371
+ * this is the enforcement, run at the top of EVERY pass rather than only in
372
+ * `--ensure-schema`, because a plain `memhouse ship` never calls ensureSchema and a guard
373
+ * living only there is a guard that never runs on the path that does the damage.
374
+ */
375
+ async function assertRoomKeys(client, rooms) {
347
376
  const wrong = [];
348
377
  for (const t of ROOM_TYPES) {
349
378
  const rs = await client.query({
350
379
  query: `SELECT sorting_key FROM system.tables WHERE database = currentDatabase() AND name = {n:String}`,
351
- query_params: { n: rooms[t] }, format: 'JSONEachRow',
380
+ query_params: { n: rooms[`${t}_raw`] }, format: 'JSONEachRow',
352
381
  });
353
382
  const row = (await rs.json())[0];
354
- const key = (row || {}).sorting_key || '';
355
- // A room that EXISTS but has no sorting key is not a MergeTree — a Merge, a View, a Log
356
- // engine standing where the shipper expects to DELETE and INSERT. Skipping it here let
357
- // the pass sail past its own guard and die later on
358
- // `DELETE query is not supported for table …`, which is exactly what this refusal is
359
- // meant to prevent. (A room that does not exist at all is assertRoomsExist's job and is
360
- // reported there.)
361
- if (row && !key) { wrong.push(`${rooms[t]} — not a MergeTree, so it cannot be shipped to`); continue; }
362
- if (!key) continue;
363
- const keyed = /\borigin\b/.test(key);
364
- // sessions is one row per session and must NOT key on origin; the transcript rooms
365
- // hold many rows per session and must.
366
- const want = t !== 'sessions';
367
- if (keyed !== want) {
368
- wrong.push(`${rooms[t]} (${key}) — origin ${want ? 'missing from' : 'must not be in'} the key`);
369
- }
383
+ // A room that does not exist at all is assertRoomsExist's job and is reported there.
384
+ if (!row) continue;
385
+ const problem = keyProblem(t, row.sorting_key);
386
+ if (problem) wrong.push(`${rooms[`${t}_raw`]} ${problem}`);
370
387
  }
371
388
  if (wrong.length) {
372
389
  throw new Error(
373
390
  'these rooms have the wrong sorting key, so a ship pass would corrupt them:\n'
374
391
  + wrong.map((s) => ` ${s}`).join('\n')
375
- + '\n ORDER BY cannot be altered in place. Rebuild each room, then re-ship:'
376
- + '\n RENAME TABLE <room> TO <room>_old;'
377
- + `\n -- recreate from ${path.join(__dirname, '..', 'house', 'schema.sql.tpl')}`
378
- + '\n INSERT INTO <room> SELECT * FROM <room>_old;'
379
- + '\n (add `, \'ship\' AS origin` to the SELECT only if <room>_old has no origin column)'
392
+ + '\n ORDER BY cannot be altered in place — the rooms have to be rebuilt:'
393
+ + '\n memhouse migrate-rooms'
394
+ + '\n It copies each room into one with the current key, swaps them atomically, and'
395
+ + '\n keeps the old one as <room>_pre_epoch for you to drop. Nothing is deleted.'
380
396
  + '\n A house with no imported rows can also just be re-shipped from scratch.');
381
397
  }
382
398
  }
383
399
 
384
400
  async function ensureSchema(client) {
385
401
  const rooms = await resolveRooms(client);
402
+ // BEFORE the CREATEs and ALTERs, not beside the end-of-function asserts. A house whose
403
+ // record says a newer release moved it forward may have columns this template does not
404
+ // know; running this template's DDL first could add back what that release removed —
405
+ // the exact write the guard exists to prevent.
406
+ await assertWriterSupported(client, rooms);
386
407
  const tpl = fs.readFileSync(path.join(__dirname, '..', 'house', 'schema.sql.tpl'), 'utf-8');
387
408
  const sql = tpl; // plain shared tables — nothing to render
388
409
  const stripped = sql.split('\n').map((l) => l.replace(/--.*$/, '')).join('\n');
@@ -418,10 +439,11 @@ async function ensureSchema(client) {
418
439
  console.error(`[memhouse] ${denied} schema statement(s) needed rights you do not hold — continuing with what you can do.`);
419
440
  console.error('[memhouse] creating or replacing a ROOM is the house owner\'s job; adding a missing COLUMN is not.');
420
441
  }
421
- // A house created before origin existed has no such column, and the clear binds it —
422
- // an unguarded DELETE there would be the old destructive behaviour, and a guarded one
423
- // would error. Add it in place; ReplacingMergeTree backfills the DEFAULT, so every
424
- // pre-existing row reads as 'ship', which is what it was.
442
+ // A house created before origin existed has no such column, and every read and write
443
+ // scopes by it. Add it in place; ReplacingMergeTree backfills the DEFAULT, so every
444
+ // pre-existing row reads as 'ship', which is what it was. (Having the COLUMN is not
445
+ // having it in the sorting KEY — that is assertRoomKeys' refusal and migrate-rooms'
446
+ // job, because ORDER BY cannot be altered.)
425
447
  //
426
448
  // EVERY column the template declares, not just `origin`.
427
449
  //
@@ -435,60 +457,198 @@ async function ensureSchema(client) {
435
457
  // It also made the grant a lie: rooms.js and INSTALL.md both say ALTER ADD COLUMN is
436
458
  // granted "for ensureSchema's rollout", and no rollout of anything but `origin` existed.
437
459
  const wantCols = templateColumns(tpl);
438
- for (const t of ROOM_TYPES) {
460
+ for (const t of ALL_TABLES) {
439
461
  let have = new Set();
440
462
  try {
441
463
  const rs = await client.query({
442
464
  query: 'SELECT name FROM system.columns WHERE database = currentDatabase() AND table = {n:String}',
443
- query_params: { n: rooms[t] }, format: 'JSONEachRow',
465
+ query_params: { n: rooms[`${t}_raw`] }, format: 'JSONEachRow',
444
466
  });
445
467
  have = new Set((await rs.json()).map((r) => r.name));
446
468
  } catch { /* unreadable: the checks above already reported why */ }
469
+ // No columns means the table is not there — the CREATEs above were denied, or this is
470
+ // a house where the meta tables were never created. ALTERing it would throw
471
+ // UNKNOWN_TABLE, which is not a permissions error and so prints, once per column, on
472
+ // every `--ensure-schema`. Nothing to heal on a table that does not exist.
473
+ if (!have.size) continue;
447
474
  for (const col of (wantCols[t] || [])) {
448
- if (have.size && have.has(col.name)) continue;
475
+ if (have.has(col.name)) continue;
449
476
  try {
450
477
  await client.command({
451
- query: `ALTER TABLE ${rooms[t]} ADD COLUMN IF NOT EXISTS ${col.name} ${col.type}`,
478
+ query: `ALTER TABLE ${rooms[`${t}_raw`]} ADD COLUMN IF NOT EXISTS ${col.name} ${col.type}`,
452
479
  clickhouse_settings: { allow_experimental_full_text_index: 1 },
453
480
  });
454
- if (have.size) console.log(`[memhouse] added missing column ${rooms[t]}.${col.name}`);
481
+ console.log(`[memhouse] added missing column ${rooms[`${t}_raw`]}.${col.name}`);
455
482
  } catch (e) {
456
483
  const m = e && e.message ? e.message : String(e);
457
484
  if (!/Not enough privileges|ACCESS_DENIED/i.test(m)) {
458
- console.error(`[memhouse] could not add ${rooms[t]}.${col.name}: ${m}`);
485
+ console.error(`[memhouse] could not add ${rooms[`${t}_raw`]}.${col.name}: ${m}`);
459
486
  }
460
487
  }
461
488
  }
462
- try {
463
- await client.command({
464
- query: `ALTER TABLE ${rooms[t]} ADD COLUMN IF NOT EXISTS origin LowCardinality(String) DEFAULT 'ship'`,
465
- // 25.11 refuses ANY alter on a table carrying the messages text indexes unless
466
- // this is set — including an ADD COLUMN that has nothing to do with them
467
- // (Code: 344, SUPPORT_IS_DISABLED). Without it the backfill fails silently and
468
- // the guard ends up missing on precisely the houses that need upgrading. A no-op
469
- // on 26.x, and query-scoped, so it needs no server config.
470
- clickhouse_settings: { allow_experimental_full_text_index: 1 },
471
- });
472
- } catch (e) {
473
- // No rights to alter is genuinely not fatal — a member on someone else's house
474
- // cannot roll a column out and does not need to. But this used to swallow
475
- // EVERYTHING, including the 25.11 Code 344 this call carries a setting to avoid, and
476
- // `schema ensured` printed regardless. Name anything that is not a permissions
477
- // problem, and keep going.
478
- const m = e && e.message ? e.message : String(e);
479
- if (!/Not enough privileges|ACCESS_DENIED/i.test(m)) {
480
- console.error(`[memhouse] could not add the 'origin' column to ${rooms[t]}: ${m}`);
481
- console.error('[memhouse] the room still works; a house that predates 0.4.4 needs rebuilding — memhouse doctor');
482
- }
483
- }
484
489
  }
490
+ // There used to be a second, hardcoded `ADD COLUMN … origin` here, from when the healer
491
+ // above could only add that one column. It is redundant now that the generic loop reads
492
+ // the template correctly — and it would have been actively wrong once this loop covered
493
+ // house_meta and house_events, which have no origin and want none.
485
494
 
486
495
  await assertRoomsExist(client, rooms);
487
- await assertOriginKeyed(client, rooms);
496
+ await assertRoomKeys(client, rooms);
488
497
  await warnMissingColumns(client, rooms);
498
+ // Re-assert the async-insert pin. A 0.11 member holds ALTER USER on themselves (so
499
+ // `memhouse passwd` needs no admin) — and that same grant lets them drop the pin
500
+ // (`ALTER USER <self> SETTINGS NONE`), which on some ClickHouse versions makes their
501
+ // OWN async inserts land with an empty user_id. It cannot forge another identity
502
+ // (currentUser() is server-side) and doctor flags any blank user_id, but re-adding the
503
+ // pin here means an accidental or transient drop self-heals on the next --ensure-schema
504
+ // (install, update, migrate). Best-effort: a member who cannot ADD SETTING just stays
505
+ // as they were.
506
+ try { await client.command({ query: `ALTER USER \`${rooms.user}\` ADD SETTING ${MEMBER_PIN}`, clickhouse_settings: { async_insert: 0 } }); }
507
+ catch { /* no self-alter grant (pre-0.11), or not permitted — leave it */ }
508
+ // The rooms are at this schema generation — the assertion above is what makes that a
509
+ // fact rather than a claim. Recording it here means a house built by `install` is
510
+ // already stamped, instead of looking un-migrated until its first ship pass.
511
+ //
512
+ // MEMHOUSE_NO_RECORD is the invite path: `memhouse invite` runs this only to PROVE the
513
+ // new member can build the rooms, on a throwaway home. Recording would stamp a phantom
514
+ // <member>@<ephemeral-host> writer into a house the inviter never ships to — the
515
+ // invitee mints their real identity on their own first pass.
516
+ if (process.env.MEMHOUSE_NO_RECORD !== '1') await recordHouseState(client, rooms, hostId());
489
517
  return stmts.length;
490
518
  }
491
519
 
520
+ /**
521
+ * Keep the house's record of itself current: which schema generation it is at, and which
522
+ * memhouse version is writing into it.
523
+ *
524
+ * Written only when something CHANGED. A row per pass would turn house_events into a
525
+ * heartbeat log — under `--loop` at the default interval that is 288 rows a day per
526
+ * machine, and the one question the table exists to answer ("when did this house move,
527
+ * and who moved it") would be buried in noise.
528
+ *
529
+ * Entirely best-effort. A member on someone else's house may hold no rights on these
530
+ * tables, or the house may predate them; none of that is a reason to stop shipping, so
531
+ * every failure here is swallowed. The rooms are the product, this is the paperwork.
532
+ */
533
+ async function recordHouseState(client, rooms, host) {
534
+ const version = require('../../package.json').version;
535
+ const writer = `${rooms.user}@${host}`;
536
+ try {
537
+ const rs = await client.query({
538
+ query: `SELECT key, value FROM ${rooms.house_meta} FINAL WHERE key IN ('schema_version', {ck:String}, {cs:String})`,
539
+ // Keyed per member AND host. Keyed by member alone, two machines of one member
540
+ // overwrote each other's entry on every pass, so the fleet view could only ever
541
+ // show the machine that shipped last — the exact machine that needs no attention.
542
+ query_params: { ck: `client_version:${writer}`, cs: `client_schema:${writer}` }, format: 'JSONEachRow',
543
+ });
544
+ const have = new Map((await rs.json()).map((r) => [r.key, String(r.value)]));
545
+ const events = [];
546
+ const metas = [];
547
+ if (have.get('schema_version') !== String(SCHEMA_VERSION)) {
548
+ // The rooms are at this generation — assertRoomKeys ran before this and refuses to
549
+ // let a pass reach here otherwise, so recording it is a statement of fact, not a
550
+ // claim about what someone intends to do.
551
+ metas.push({ key: 'schema_version', value: String(SCHEMA_VERSION), host });
552
+ events.push({
553
+ kind: 'schema', status: 'observed', host,
554
+ from_version: have.get('schema_version') || '', to_version: String(SCHEMA_VERSION),
555
+ detail: 'rooms carry the current sorting keys',
556
+ });
557
+ }
558
+ const clientKey = `client_version:${writer}`;
559
+ // The schema this writer SUPPORTS, beside the marketing version — the fleet view
560
+ // judges on this, because a version string cannot be compared against a schema
561
+ // requirement (and a pre-release checkout may not have bumped package.json at all).
562
+ // Tracked on its own change, not the version's: tying it to a version bump left every
563
+ // already-recorded writer without one forever.
564
+ const mySchema = String(Math.max(...SUPPORTED_SCHEMAS));
565
+ if (have.get(`client_schema:${writer}`) !== mySchema) {
566
+ metas.push({ key: `client_schema:${writer}`, value: mySchema, host });
567
+ }
568
+ if (have.get(clientKey) !== version) {
569
+ metas.push({ key: clientKey, value: version, host });
570
+ events.push({
571
+ kind: 'version', status: 'observed', host,
572
+ from_version: have.get(clientKey) || '', to_version: version,
573
+ detail: `${rooms.user} shipping from ${host}`,
574
+ });
575
+ }
576
+ // The heartbeat, EVERY pass — the fleet view's health column. Latest-wins on the key,
577
+ // so it is one live row per writer however often it fires; version/schema above stay
578
+ // change-only so house_events remains a record of moves, not a pulse trace.
579
+ metas.push({ key: `last_ship:${writer}`, value: new Date().toISOString(), host });
580
+ await client.insert({
581
+ table: rooms.house_meta_raw, values: metas, format: 'JSONEachRow',
582
+ clickhouse_settings: { async_insert: 0 },
583
+ });
584
+ if (events.length) {
585
+ await client.insert({
586
+ table: rooms.house_events_raw, values: events, format: 'JSONEachRow',
587
+ clickhouse_settings: { async_insert: 0 },
588
+ });
589
+ }
590
+ } catch { /* the house's paperwork is never worth failing a pass over */ }
591
+ }
592
+
593
+ /**
594
+ * The forward half of the compatibility story: refuse a house whose RECORDED schema this
595
+ * release does not support, before any room is touched.
596
+ *
597
+ * Distinct from assertRoomKeys, which inspects key SHAPES — a future generation could be
598
+ * a data transform the keys do not show. This reads what the house says about itself:
599
+ * house_meta['schema_version'] (what generation the rooms are at) and
600
+ * house_meta['min_writer_schema'] (the floor `memhouse migrate` sets under writers).
601
+ * Too new -> the fix is on THIS machine: memhouse update. Below the floor -> same.
602
+ * (A house OLDER than this release is not an error here — the room checks catch it and
603
+ * name `memhouse migrate`; this guard must not fire on a pre-0.10 house that has no
604
+ * record at all.)
605
+ *
606
+ * Releases before 0.10.0 never read this — for them the floor is enforced by the
607
+ * pilot's GRANTs, not by code.
608
+ */
609
+ async function assertWriterSupported(client, rooms) {
610
+ let have;
611
+ try {
612
+ const rs = await client.query({
613
+ query: `SELECT key, value FROM ${rooms.house_meta} FINAL WHERE key IN ('schema_version', 'min_writer_schema')`,
614
+ format: 'JSONEachRow',
615
+ });
616
+ have = new Map((await rs.json()).map((r) => [r.key, toInt(r.value)]));
617
+ } catch (e) {
618
+ // ONLY a missing table means a pre-0.10 house (the room checks own that case). Any
619
+ // other failure — an ACCESS_DENIED on house_meta, a timeout — used to fall through
620
+ // here too, and a writer that merely could not READ the record was treated as if the
621
+ // record did not exist: on a newer-schema house whose key shapes happen to match,
622
+ // that bypassed the whole compatibility guard. Not being able to check is a reason
623
+ // to refuse, never a reason to proceed.
624
+ const m = e && e.message ? e.message : String(e);
625
+ if (/UNKNOWN_TABLE|Unknown table expression|doesn't exist|does not exist/i.test(m)) return;
626
+ throw new Error(`could not read this house's compatibility record (${m.split('\n')[0]})\n`
627
+ + ' Refusing to write until it is readable — a writer that cannot check the schema\n'
628
+ + ' generation must not assume it matches.');
629
+ }
630
+ const houseSchema = have.get('schema_version') || 0;
631
+ const floor = have.get('min_writer_schema') || 0;
632
+ const mine = Math.max(...SUPPORTED_SCHEMAS);
633
+ // MEMBERSHIP, not a ceiling. `houseSchema <= max` would let a [4]-only writer into a
634
+ // schema-3 house — and if generation 4 was a data transform, the sorting keys match and
635
+ // nothing else refuses. A recorded generation this release does not list is unwritable
636
+ // in either direction; only the remedy differs.
637
+ if (houseSchema > 0 && !SUPPORTED_SCHEMAS.includes(houseSchema)) {
638
+ throw new Error(houseSchema > mine
639
+ ? `this house is at schema ${houseSchema}; this memhouse (${require('../../package.json').version}) supports ${SUPPORTED_SCHEMAS.join(', ')}.\n`
640
+ + ' A newer release moved the house forward. Writing with this one could corrupt it, so it will not.\n'
641
+ + ' Update THIS machine: memhouse update'
642
+ : `this house is at schema ${houseSchema}; this memhouse supports ${SUPPORTED_SCHEMAS.join(', ')}.\n`
643
+ + ' Bring the house forward: memhouse migrate');
644
+ }
645
+ if (floor > mine) {
646
+ throw new Error(
647
+ `this house requires writers at schema ${floor}+; this memhouse supports ${SUPPORTED_SCHEMAS.join(', ')}.\n`
648
+ + ' Update THIS machine: memhouse update');
649
+ }
650
+ }
651
+
492
652
  // Incremental state: what the house already holds, keyed by session_id. We compare
493
653
  // against extra.bubbleCount (the adapter's own cheap count, stored at ship time) —
494
654
  // NOT message_count — because parsed-message count and bubbleCount are different
@@ -496,11 +656,11 @@ async function ensureSchema(client) {
496
656
  // must be decidable WITHOUT calling getMessages on every chat.
497
657
  async function loadExisting(client, rooms) {
498
658
  const rs = await client.query({
499
- query: `SELECT session_id, last_updated_at, message_count, extra FROM ${rooms.sessions} FINAL WHERE user_id = currentUser()`,
659
+ query: `SELECT session_id, last_updated_at, message_count, extra FROM ${rooms.sessions_raw} FINAL WHERE user_id = currentUser()`,
500
660
  format: 'JSONEachRow',
501
661
  });
502
- // Actual message rows per session: an interrupted re-ship (crash between the
503
- // clear-DELETE and the inserts, or mid-flush on a large transcript) leaves a
662
+ // Actual message rows per session: an interrupted re-ship (a crash mid-flush on a
663
+ // large transcript, or between the sessions insert and the messages one) leaves a
504
664
  // fresh-looking session row with a missing or PARTIAL transcript — the skip
505
665
  // must compare the real row count against the recorded message_count, not
506
666
  // merely check that some row exists.
@@ -511,12 +671,40 @@ async function loadExisting(client, rooms) {
511
671
  // on every pass — no data lost, but the incremental skip silently stops existing.
512
672
  // Measured: adding one origin='import' row to a settled session made it re-ship
513
673
  // every pass; removing it froze the session again.
674
+ //
675
+ // GROUPED BY EPOCH, and only the newest epoch's rows count. A session whose parse was
676
+ // superseded still holds every older parse — that is the point of the epoch — so a
677
+ // plain count would exceed message_count forever, `intact` would never be true, and the
678
+ // incremental skip would quietly stop existing for exactly the sessions that have been
679
+ // through a compaction. One row per (session, epoch); epochs are rare, so this stays
680
+ // the same size as the per-session grouping it replaces.
514
681
  const mr = await client.query({
515
- query: `SELECT session_id, countIf(origin = 'ship') AS n FROM ${rooms.messages} FINAL WHERE user_id = currentUser() GROUP BY session_id`,
682
+ query: `SELECT session_id, epoch, count() AS n, max(seq) AS max_seq FROM ${rooms.messages_raw} FINAL
683
+ WHERE user_id = currentUser() AND origin = 'ship' GROUP BY session_id, epoch`,
516
684
  format: 'JSONEachRow',
517
685
  });
518
- const msgCounts = new Map();
519
- for (const r of await mr.json()) msgCounts.set(r.session_id, toInt(r.n));
686
+ // Kept PER EPOCH, not collapsed to each room's own newest — a room can legitimately
687
+ // have no rows at the session's current epoch, and reading its highest epoch instead
688
+ // would answer with the superseded parse. See the tool_calls note below, which is where
689
+ // that actually bites.
690
+ //
691
+ // U+0000 as the key separator, and written as an ESCAPE, deliberately. A session_id is
692
+ // '<source>:<adapter-local id>' and an adapter-local id can legally contain any
693
+ // printable character, so only a byte that cannot appear in one is collision-proof.
694
+ // The escape matters twice over: a literal NUL byte typed here once made grep treat
695
+ // this file as binary — and one of the three key sites was typed with a plain space
696
+ // instead, so the tool lookups missed on every call, `tools.n` read 0, every session
697
+ // with tool calls failed `intact`, and a real 437-session history re-shipped 324
698
+ // sessions on EVERY pass, forever, with every surface green. All three sites must
699
+ // build this key identically; the unit test pins them to each other.
700
+ const at = (m, id, epoch) => m.get(`${id}\u0000${epoch}`) || null;
701
+ const msgState = new Map(); // (session, epoch) → { n, maxSeq }
702
+ const msgEpoch = new Map(); // session → newest epoch present
703
+ for (const r of await mr.json()) {
704
+ const epoch = toInt(r.epoch);
705
+ msgState.set(`${r.session_id}\u0000${epoch}`, { n: toInt(r.n), maxSeq: toInt(r.max_seq) });
706
+ msgEpoch.set(r.session_id, Math.max(epoch, msgEpoch.get(r.session_id) || 0));
707
+ }
520
708
  // The tool_calls room needs the same check, and used to have none. shipSession writes
521
709
  // sessions, then messages, then tool_calls — so a pass that fails during the LAST of
522
710
  // those three leaves a session whose message count matches perfectly. The next pass
@@ -525,11 +713,17 @@ async function loadExisting(client, rooms) {
525
713
  // Reproduced by dropping the room mid-pass; any transient — a ClickHouse restart, a
526
714
  // quota rejection, a network blip — reaches the same state.
527
715
  const tr = await client.query({
528
- query: `SELECT session_id, countIf(origin = 'ship') AS n FROM ${rooms.tool_calls} FINAL WHERE user_id = currentUser() GROUP BY session_id`,
716
+ query: `SELECT session_id, epoch, count() AS n, max(idx) AS max_idx FROM ${rooms.tool_calls_raw} FINAL
717
+ WHERE user_id = currentUser() AND origin = 'ship' GROUP BY session_id, epoch`,
529
718
  format: 'JSONEachRow',
530
719
  });
531
- const toolCounts = new Map();
532
- for (const r of await tr.json()) toolCounts.set(r.session_id, toInt(r.n));
720
+ const toolState = new Map(); // (session, epoch) → { n, maxIdx }
721
+ const toolEpoch = new Map(); // session → newest epoch present
722
+ for (const r of await tr.json()) {
723
+ const epoch = toInt(r.epoch);
724
+ toolState.set(`${r.session_id}\u0000${epoch}`, { n: toInt(r.n), maxIdx: toInt(r.max_idx) });
725
+ toolEpoch.set(r.session_id, Math.max(epoch, toolEpoch.get(r.session_id) || 0));
726
+ }
533
727
  const map = new Map();
534
728
  for (const r of await rs.json()) {
535
729
  // DateTime64 comes back as 'YYYY-MM-DD HH:MM:SS.mmm' — re-parse as UTC.
@@ -540,9 +734,30 @@ async function loadExisting(client, rooms) {
540
734
  // have tool calls re-ship exactly once and then record it. Self-correcting, and
541
735
  // cheaper than a branch that has to be remembered forever.
542
736
  const toolCount = toInt(r.extra && r.extra.toolCallCount);
543
- const intact = (msgCounts.get(r.session_id) || 0) === count
544
- && (toolCounts.get(r.session_id) || 0) === toolCount;
545
- map.set(r.session_id, { ms, count, bc, intact });
737
+ // The session's current epoch is whatever its ROWS say, never what the sessions row
738
+ // says: sessions is latest-wins with no origin in its key, so an import writing that
739
+ // row after a bump would hand the next pass a 0 and make it overwrite the current
740
+ // parse. The column there is for people, not for this.
741
+ const epoch = Math.max(msgEpoch.get(r.session_id) || 0, toolEpoch.get(r.session_id) || 0);
742
+ // Everything else is read AT that epoch, and "no rows there" means zero — not "look
743
+ // at the newest epoch this room happens to have".
744
+ //
745
+ // Getting this wrong does not lose data, it makes the shipper churn forever. A parse
746
+ // that keeps its messages but drops every tool call writes nothing into tool_calls at
747
+ // the new epoch. Reading that room's own newest epoch then returned the SUPERSEDED
748
+ // count (say 2) and its maxIdx — so `intact` compared 2 against the recorded 0 and was
749
+ // false on every pass, and decideEpoch compared `0 - 1 < oldMaxIdx` and bumped again
750
+ // on every pass. The session re-shipped and gained an epoch forever, growing the house
751
+ // without end, while every surface reported success.
752
+ const msgs = at(msgState, r.session_id, epoch) || { n: 0, maxSeq: -1 };
753
+ const tools = at(toolState, r.session_id, epoch) || { n: 0, maxIdx: -1 };
754
+ const intact = msgs.n === count && tools.n === toolCount;
755
+ map.set(r.session_id, {
756
+ ms, count, bc, intact, epoch,
757
+ maxSeq: msgs.maxSeq,
758
+ maxIdx: tools.maxIdx,
759
+ hasRows: msgEpoch.has(r.session_id) || toolEpoch.has(r.session_id),
760
+ });
546
761
  }
547
762
  return map;
548
763
  }
@@ -663,20 +878,124 @@ function rowsForChat(chat, host) {
663
878
  return { session, msgRows, toolRows };
664
879
  }
665
880
 
881
+ /**
882
+ * Which parse epoch this re-ship belongs to, and why.
883
+ *
884
+ * THE RULE: the shipper never writes over stored content that differs from what it is
885
+ * writing. If the new parse can be laid on top of the old one without changing or
886
+ * orphaning anything, it reuses the epoch and ReplacingMergeTree dedupes it exactly as
887
+ * before — the common case, free. Otherwise it moves to a new epoch, where the new rows
888
+ * cannot collide with the old ones, and the old parse stays whole.
889
+ *
890
+ * Two things make a parse un-overwritable, and both are ordinary:
891
+ *
892
+ * SHRINK. A re-parse with fewer messages leaves the old higher-seq rows with nothing
893
+ * written over them. That stale tail is what the DELETE this replaces was written to
894
+ * remove — and the delete could not tell a fixed adapter bug (junk, worth removing)
895
+ * from a compacted or user-deleted transcript (irreplaceable), because from outside
896
+ * they look identical.
897
+ *
898
+ * DIVERGENCE. Claude Code COMPACTS a session in place: same session, rewritten
899
+ * shorter, with different content at low seq numbers. Bumping on shrink alone would
900
+ * preserve the tail and still overwrite the rewritten head — the same loss, reached
901
+ * through the merge instead of the delete. line_hash is already stored per row, so
902
+ * comparing is a lookup, not a re-read of the source.
903
+ *
904
+ * Tool calls carry no hash, so they are compared on (tool_name, args) directly. They are
905
+ * derived from `m._toolCalls`, which can change while the assistant text does not.
906
+ *
907
+ * Pure, and exported: this is the decision the whole redesign rests on, and it is unit
908
+ * tested without a server.
909
+ *
910
+ * @param {object|null} stored { epoch, maxSeq, maxIdx, hashes: Map<seq,string>,
911
+ * tools: Map<idx,{tool_name,args}> } — null when the
912
+ * session is not in the house yet.
913
+ * @param {object} incoming { msgRows, toolRows } as built by rowsForChat.
914
+ * @returns {{ epoch: number, reason: string|null }} reason is null when nothing moved.
915
+ */
916
+ function decideEpoch(stored, incoming) {
917
+ if (!stored) return { epoch: 0, reason: null };
918
+ const epoch = toInt(stored.epoch);
919
+ const bump = (reason) => ({ epoch: epoch + 1, reason });
920
+
921
+ const maxSeq = Number.isFinite(stored.maxSeq) ? stored.maxSeq : -1;
922
+ const maxIdx = Number.isFinite(stored.maxIdx) ? stored.maxIdx : -1;
923
+ if (incoming.msgRows.length - 1 < maxSeq) {
924
+ return bump(`${maxSeq + 1} messages stored, ${incoming.msgRows.length} parsed`);
925
+ }
926
+ if (incoming.toolRows.length - 1 < maxIdx) {
927
+ return bump(`${maxIdx + 1} tool calls stored, ${incoming.toolRows.length} parsed`);
928
+ }
929
+
930
+ // A seq the house does not hold is a gap, not a disagreement — there is nothing there
931
+ // to destroy, so it is not a reason to fork the session.
932
+ const hashes = stored.hashes || new Map();
933
+ for (const row of incoming.msgRows) {
934
+ if (row.seq > maxSeq) break;
935
+ const was = hashes.get(row.seq);
936
+ if (was !== undefined && was !== String(row.line_hash)) {
937
+ return bump(`message ${row.seq} was rewritten`);
938
+ }
939
+ }
940
+ const tools = stored.tools || new Map();
941
+ for (const row of incoming.toolRows) {
942
+ if (row.idx > maxIdx) break;
943
+ const was = tools.get(row.idx);
944
+ if (was !== undefined && (was.tool_name !== row.tool_name || was.args !== row.args)) {
945
+ return bump(`tool call ${row.idx} was rewritten`);
946
+ }
947
+ }
948
+ return { epoch, reason: null };
949
+ }
950
+
951
+ /**
952
+ * What the house currently holds for ONE session, at its current epoch — the input
953
+ * decideEpoch needs to tell an append from a rewrite.
954
+ *
955
+ * Read only for sessions actually being re-shipped, which the incremental skip has
956
+ * already narrowed to a handful; it replaces the two DELETE mutations that used to run
957
+ * per re-shipped session, so a normal pass does strictly less work than before.
958
+ *
959
+ * `toString(line_hash)`: the client is configured with
960
+ * output_format_json_quote_64bit_integers = 0, so a UInt64 arrives as a JSON number and
961
+ * everything above 2^53 comes back rounded. Comparing rounded hashes would report
962
+ * divergence on identical rows and fork a session on every single pass.
963
+ */
964
+ async function loadStoredParse(client, rooms, id, uid, epoch) {
965
+ const params = { id, uid, e: epoch };
966
+ const mr = await client.query({
967
+ query: `SELECT seq, toString(line_hash) AS line_hash FROM ${rooms.messages_raw} FINAL
968
+ WHERE session_id = {id:String} AND user_id = {uid:String} AND origin = 'ship' AND epoch = {e:UInt32}`,
969
+ query_params: params, format: 'JSONEachRow',
970
+ });
971
+ const hashes = new Map();
972
+ for (const r of await mr.json()) hashes.set(toInt(r.seq), String(r.line_hash));
973
+ const tr = await client.query({
974
+ query: `SELECT idx, tool_name, args FROM ${rooms.tool_calls_raw} FINAL
975
+ WHERE session_id = {id:String} AND user_id = {uid:String} AND origin = 'ship' AND epoch = {e:UInt32}`,
976
+ query_params: params, format: 'JSONEachRow',
977
+ });
978
+ const tools = new Map();
979
+ for (const r of await tr.json()) tools.set(toInt(r.idx), { tool_name: r.tool_name, args: r.args });
980
+ return { hashes, tools };
981
+ }
982
+
666
983
  // One shipping pass. Incremental unless opts.full: a chat is skipped when the house
667
984
  // already has it at least as fresh (last_updated_at) and at least as large
668
985
  // (extra.bubbleCount) — both readable without parsing the chat.
669
986
  async function runShip(client, opts = {}) {
670
987
  const { full = false } = opts;
671
988
  const host = hostId();
672
- // Always load what the house holds — even with --full. The skip decision uses it
673
- // only in incremental mode, but re-shipping a KNOWN session must clear its old
674
- // message/tool rows first (a shrunken re-parse leaves stale higher-seq rows
675
- // otherwise: ReplacingMergeTree collapses same-key rows only).
989
+ // Always load what the house holds — even with --full. The skip decision uses it only
990
+ // in incremental mode, but re-shipping a KNOWN session needs its current epoch and row
991
+ // shape: writing at the wrong epoch either forks a session that did not change, or
992
+ // overwrites a stored parse that did.
676
993
  const rooms = await resolveRooms(client);
994
+ await assertWriterSupported(client, rooms);
677
995
  await assertRoomsExist(client, rooms);
678
- await assertOriginKeyed(client, rooms);
996
+ await assertRoomKeys(client, rooms);
679
997
  await warnMissingColumns(client, rooms);
998
+ await recordHouseState(client, rooms, host);
680
999
  const existing = await loadExisting(client, rooms);
681
1000
  const chats = getAllChats();
682
1001
 
@@ -693,7 +1012,7 @@ async function runShip(client, opts = {}) {
693
1012
  const flush = async (table) => {
694
1013
  if (!batches[table].length) return;
695
1014
  await client.insert({
696
- table: rooms[table],
1015
+ table: rooms[`${table}_raw`],
697
1016
  values: batches[table],
698
1017
  format: 'JSONEachRow',
699
1018
  clickhouse_settings: { async_insert: 0 }, // binding: user_id stamping breaks otherwise
@@ -706,7 +1025,7 @@ async function runShip(client, opts = {}) {
706
1025
  };
707
1026
 
708
1027
  const seen = new Set(); // adapters must not double-ship a session id within a pass
709
- let sessions = 0, skipped = 0, msgRows = 0, toolRows = 0, unreadable = 0;
1028
+ let sessions = 0, skipped = 0, msgRows = 0, toolRows = 0, unreadable = 0, bumped = 0, withheld = 0;
710
1029
  for (const chat of chats) {
711
1030
  if (chat.encrypted) continue;
712
1031
  // Same canonical '<source>:<adapter-local id>' as rowsForChat: dedup and
@@ -734,11 +1053,13 @@ async function runShip(client, opts = {}) {
734
1053
  }
735
1054
 
736
1055
  // rowsForChat returns null only when getMessages() *throws*. An adapter that
737
- // swallows its own failure returns [] instead, which would look like a session
738
- // that genuinely has no messages — and for a known session the re-ship below
739
- // deletes the old transcript before inserting that emptiness. So treat a
740
- // failure recorded while reading THIS chat as unreadable too: write nothing,
741
- // delete nothing, retry next pass.
1056
+ // swallows its own failure returns [] instead, which would look like a session that
1057
+ // genuinely has no messages — and for a known session that is a maximal shrink, so
1058
+ // the pass would fork it to a new, EMPTY epoch and every read would show nothing
1059
+ // where a transcript used to be. Nothing is destroyed any more, but a house that
1060
+ // reports empty sessions after a locked SQLite file is still wrong. So treat a
1061
+ // failure recorded while reading THIS chat as unreadable: write nothing, retry next
1062
+ // pass.
742
1063
  const errsBefore = adapterErrorSink.recorded().length;
743
1064
  const rows = rowsForChat(chat, host);
744
1065
  const failedHere = adapterErrorSink.recorded().slice(errsBefore).some((e) => e.source === chat.source);
@@ -753,57 +1074,69 @@ async function runShip(client, opts = {}) {
753
1074
  // the skip predicate entirely, so every pass retries it and re-reports it until
754
1075
  // the underlying store is readable.
755
1076
  if (!rows || failedHere) { unreadable++; continue; }
756
- if (prev) {
757
- // Known session being re-shipped: clear its old rows BEFORE inserting so a
758
- // shorter re-parse can't leave stale seq/idx tails. A crash between the
759
- // delete and the inserts is repaired by the next pass: the skip predicate
760
- // refuses to skip a non-empty session whose message rows are missing.
761
- // user_id is BOUND, not `= currentUser()`. A DELETE is a mutation, and a mutation
762
- // does not necessarily evaluate currentUser() in the caller's context — it matches
763
- // nothing at all, so the delete silently removes zero rows and the stale tail this
764
- // code exists to clear survives forever. Measured on ClickHouse 25.11 —
765
- // the identical predicate with the literal value deleted 2000 rows where
766
- // currentUser() deleted 0. The value is the same identity either way: it is read
767
- // from the server over this very connection.
768
- // origin='ship' is the third bind, and it is not cosmetic. This clear exists so a
769
- // shorter re-parse cannot leave a stale seq tail behind — but scoped to
770
- // (session_id, user_id) alone it deletes EVERY row for the session, including rows
771
- // the adapters did not write and cannot rewrite. Measured, on a real house: an
772
- // import of 135,307 messages lost 27,948 of them to one ship pass, because
773
- // memory-house had captured more per session than the adapters emit. The rows the
774
- // shipper owns are the only rows it may remove.
775
- for (const t of ['messages', 'tool_calls']) {
776
- await client.command({
777
- query: `DELETE FROM ${rooms[t]} WHERE session_id = {id:String} AND user_id = {uid:String} AND origin = 'ship'`,
778
- query_params: { id, uid: rooms.user },
779
- clickhouse_settings: { async_insert: 0 },
780
- });
1077
+
1078
+ // A KNOWN session that now parses to nothing is withheld, not written.
1079
+ //
1080
+ // Two reasons, and the second is the one that bites. An adapter that swallows its own
1081
+ // failure returns [] rather than throwing, which is indistinguishable from a chat
1082
+ // whose content really did vanish — and the house is the party that still has the
1083
+ // content either way. Worse, writing it would not even be honest about itself: with
1084
+ // zero rows to insert, the new epoch exists nowhere in the room, so the read filter's
1085
+ // max(epoch) would keep answering with the SUPERSEDED epoch and serve the old parse as
1086
+ // if it were current. Withholding leaves the session out of the skip predicate, so
1087
+ // every pass retries it and says so.
1088
+ if (prev && prev.hasRows && !rows.msgRows.length) {
1089
+ withheld++;
1090
+ console.log(`[memhouse] WARNING: ${id} parsed to 0 messages but the house holds ${prev.count} — withholding`);
1091
+ continue;
1092
+ }
1093
+
1094
+ // Which parse this belongs to. A known session whose stored rows the new parse
1095
+ // cannot be laid over — shorter, or rewritten at an overlapping position — moves to
1096
+ // a new epoch instead of overwriting anything. See decideEpoch.
1097
+ //
1098
+ // This is where a DELETE used to be. It removed the session's shipped rows before
1099
+ // re-inserting them, which is the only way to clear a stale seq tail in a
1100
+ // ReplacingMergeTree — and it destroyed content that existed nowhere else, because
1101
+ // Claude Code compacts transcripts and deletes them after cleanupPeriodDays (30 by
1102
+ // default), so a shorter re-parse is at least as likely to mean "the source lost it"
1103
+ // as "the adapter was fixed". The house is supposed to outlive the source.
1104
+ let epoch = 0;
1105
+ if (prev && prev.hasRows) {
1106
+ const stored = await loadStoredParse(client, rooms, id, rooms.user, prev.epoch);
1107
+ const d = decideEpoch({ ...prev, ...stored }, rows);
1108
+ epoch = d.epoch;
1109
+ if (d.reason) {
1110
+ bumped++;
1111
+ console.log(`[memhouse] ${id} → epoch ${epoch} (${d.reason}); the previous parse is kept`);
781
1112
  }
1113
+ } else if (prev) {
1114
+ epoch = toInt(prev.epoch);
782
1115
  }
1116
+ rows.session.epoch = epoch;
783
1117
  await push('sessions', scrub(rows.session));
784
1118
  sessions++;
785
- for (const r of rows.msgRows) { await push('messages', scrub(r)); msgRows++; }
786
- for (const r of rows.toolRows) { await push('tool_calls', scrub(r)); toolRows++; }
1119
+ for (const r of rows.msgRows) { r.epoch = epoch; await push('messages', scrub(r)); msgRows++; }
1120
+ for (const r of rows.toolRows) { r.epoch = epoch; await push('tool_calls', scrub(r)); toolRows++; }
787
1121
  }
788
1122
  for (const table of Object.keys(batches)) await flush(table);
789
1123
  // Anything that only failed while reading messages — the sink is reset by the
790
1124
  // next getAllChats(), so unreported here means never reported at all.
791
1125
  reportAdapterErrors(warned);
792
- return { sessions, skipped, msgRows, toolRows, unreadable };
1126
+ return { sessions, skipped, msgRows, toolRows, unreadable, bumped, withheld };
793
1127
  }
794
1128
 
795
- // Warn once per adapter per pass. `warned` carries across the two call sites so a
796
- // dead binding is not reported twice in the same run.
1129
+ // Warn once per adapter per pass. `warned` carries across the two call sites so one
1130
+ // unreadable store is not reported twice in the same run.
1131
+ //
1132
+ // The loud case used to be a missing better-sqlite3 binding, which took five adapters
1133
+ // out together and came with a reinstall command attached. SQLite ships with Node now;
1134
+ // what is left is per-store, and each error already says what happened.
797
1135
  function reportAdapterErrors(warned) {
798
1136
  const errors = getAdapterErrors().filter((e) => !warned.has(e.source));
799
1137
  if (!errors.length) return;
800
1138
  for (const e of errors) warned.add(e.source);
801
- const noBinding = errors.filter((e) => e.missingBinding).map((e) => e.source);
802
- if (noBinding.length) {
803
- console.log(`[memhouse] WARNING: ${noBinding.length} adapter(s) skipped, sessions NOT shipped — better-sqlite3 has no native binding: ${noBinding.join(', ')}`);
804
- console.log(`[memhouse] fix: ${require('../house/house').installCommand()}`);
805
- }
806
- for (const e of errors.filter((x) => !x.missingBinding)) {
1139
+ for (const e of errors) {
807
1140
  console.log(`[memhouse] WARNING: ${e.source} skipped — ${e.message}`);
808
1141
  }
809
1142
  }
@@ -871,7 +1204,10 @@ async function main() {
871
1204
  let failed = false;
872
1205
  try {
873
1206
  const r = await runShip(client, { full });
874
- console.log(`[memhouse] shipped ${r.sessions} sessions (${r.skipped} skipped${r.unreadable ? `, ${r.unreadable} unreadable-will-retry` : ''}) → ` +
1207
+ console.log(`[memhouse] shipped ${r.sessions} sessions (${r.skipped} skipped`
1208
+ + `${r.unreadable ? `, ${r.unreadable} unreadable-will-retry` : ''}`
1209
+ + `${r.withheld ? `, ${r.withheld} withheld-empty` : ''}`
1210
+ + `${r.bumped ? `, ${r.bumped} kept an earlier parse` : ''}) → ` +
875
1211
  `${r.msgRows} msg rows, ${r.toolRows} tool rows in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
876
1212
  } catch (e) {
877
1213
  failed = true;
@@ -922,7 +1258,7 @@ async function main() {
922
1258
  }
923
1259
  }
924
1260
 
925
- module.exports = { runShip, ensureSchema, templateColumns };
1261
+ module.exports = { runShip, ensureSchema, templateColumns, decideEpoch };
926
1262
 
927
1263
  if (require.main === module) {
928
1264
  main().catch((e) => {
@@ -937,7 +1273,11 @@ if (require.main === module) {
937
1273
  // advice to run `ship --ensure-schema`.
938
1274
  const m = e && e.message ? e.message : String(e);
939
1275
  if (/Not enough privileges|ACCESS_DENIED/i.test(m) && process.env.MEMHOUSE_QUIET_DENIED === '1') process.exit(1);
940
- console.error(`[memhouse] ${/Not enough privileges|ACCESS_DENIED/i.test(m) ? 'refused' : 'fatal'}: ${m}`);
1276
+ // The sorting-key refusal joins the permissions one: both are the shipper DECLINING
1277
+ // to act, with the remediation already in the message. "fatal:" ahead of it made the
1278
+ // most-seen line of the upgrade path — every `--ensure-schema` on a pre-epoch house —
1279
+ // read like a crash.
1280
+ console.error(`[memhouse] ${/Not enough privileges|ACCESS_DENIED|wrong sorting key/i.test(m) ? 'refused' : 'fatal'}: ${m}`);
941
1281
  process.exit(1);
942
1282
  });
943
1283
  }