memhouse 0.18.12 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/AGENTS.md +22 -151
  2. package/CHANGELOG.md +263 -29
  3. package/LICENSE +202 -22
  4. package/LICENSES/agentlytics-MIT.txt +21 -0
  5. package/LICENSES/memhouse-MIT-until-0.18.11.txt +22 -0
  6. package/LICENSES/models.dev-MIT.txt +22 -0
  7. package/NOTICE +28 -0
  8. package/README.md +42 -20
  9. package/SECURITY.md +21 -8
  10. package/bin/memhouse.js +2223 -311
  11. package/docs/README.md +11 -9
  12. package/docs/guides/admin-password.md +37 -10
  13. package/docs/guides/configuration.md +13 -6
  14. package/docs/guides/daemons.md +64 -3
  15. package/docs/guides/import-claude-ai.md +204 -0
  16. package/docs/guides/install.md +11 -6
  17. package/docs/guides/invite.md +21 -7
  18. package/docs/guides/operate.md +114 -7
  19. package/docs/guides/search-and-resume.md +72 -3
  20. package/docs/guides/share.md +91 -52
  21. package/docs/guides/storage.md +44 -4
  22. package/docs/guides/troubleshooting.md +121 -14
  23. package/docs/guides/uninstall.md +4 -3
  24. package/docs/guides/update.md +16 -0
  25. package/docs/guides/upgrade-to-0.20.md +188 -0
  26. package/docs/releases/README.md +2 -1
  27. package/docs/releases/v0.18.10.md +5 -5
  28. package/docs/releases/v0.18.11.md +3 -3
  29. package/docs/releases/v0.20.0.md +627 -0
  30. package/docs/releases/v0.20.1.md +114 -0
  31. package/docs/tutorials/01-first-house.md +10 -7
  32. package/docs/tutorials/02-join-a-house.md +24 -15
  33. package/docs/tutorials/03-share-your-memory.md +40 -31
  34. package/editors/antigravity.js +1 -80
  35. package/editors/base.js +463 -9
  36. package/editors/bridge.js +119 -0
  37. package/editors/claude.js +273 -105
  38. package/editors/codex.js +278 -52
  39. package/editors/copilot.js +1 -70
  40. package/editors/cursor.js +1 -75
  41. package/editors/derive-fingerprints.json +18 -0
  42. package/editors/harness-paths.js +69 -0
  43. package/editors/index.js +1 -20
  44. package/editors/normalise/agy.js +910 -0
  45. package/editors/normalise/copilot-cli.js +321 -0
  46. package/editors/normalise/cursor-agent.js +852 -0
  47. package/editors/normalise/cursor.js +474 -0
  48. package/editors/normalise/devin.js +575 -0
  49. package/editors/normalise/gemini-cli.js +602 -0
  50. package/editors/normalise/goose.js +673 -0
  51. package/editors/normalise/opencode.js +633 -0
  52. package/editors/normalise/vscode.js +549 -0
  53. package/editors/normalise/zed.js +473 -0
  54. package/editors/opencode-v4.js +119 -0
  55. package/editors/scope.js +14 -3
  56. package/editors/sessions-log.js +349 -0
  57. package/editors/sqlite.js +28 -1
  58. package/editors/vscode.js +1 -72
  59. package/editors/windsurf.js +1 -143
  60. package/examples/01-local-one-machine/README.md +24 -19
  61. package/examples/01-local-one-machine/run.sh +1 -1
  62. package/examples/02-existing-clickhouse/README.md +30 -22
  63. package/examples/02-existing-clickhouse/run.sh +5 -4
  64. package/examples/03-team-house/README.md +44 -37
  65. package/examples/03-team-house/run.sh +35 -18
  66. package/examples/04-self-hosted-team/README.md +29 -14
  67. package/examples/04-self-hosted-team/compose.yml +5 -2
  68. package/examples/04-self-hosted-team/smoke.sh +26 -12
  69. package/examples/README.md +8 -4
  70. package/examples/fixtures/README.md +4 -1
  71. package/examples/fixtures/make-sessions.js +8 -5
  72. package/memhouse/admin-secret.js +89 -24
  73. package/memhouse/argv-secrets.js +64 -0
  74. package/memhouse/channel.js +49 -9
  75. package/memhouse/codex-plugin.js +194 -0
  76. package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
  77. package/memhouse/delivery/AGENT-INSTALL.md +22 -11
  78. package/memhouse/delivery/INVITATION.md.tpl +44 -45
  79. package/memhouse/delivery/PROMPT.md +53 -5
  80. package/memhouse/delivery/UPGRADE.md.tpl +1 -1
  81. package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
  82. package/memhouse/delivery/codex/SKILL.md +50 -0
  83. package/memhouse/delivery/kernel-install.md +17 -9
  84. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
  85. package/memhouse/delivery/plugin/reference/HOUSE.md +168 -41
  86. package/memhouse/delivery/plugin/skills/access/SKILL.md +13 -5
  87. package/memhouse/delivery/plugin/skills/admin/SKILL.md +14 -6
  88. package/memhouse/delivery/plugin/skills/house/SKILL.md +25 -8
  89. package/memhouse/delivery/plugin/skills/recall/SKILL.md +66 -8
  90. package/memhouse/delivery/plugin/skills/sql/SKILL.md +66 -7
  91. package/memhouse/deploy.js +1 -1
  92. package/memhouse/flags.js +35 -18
  93. package/memhouse/help.js +37 -0
  94. package/memhouse/host.js +82 -2
  95. package/memhouse/house/HOUSE.md +2 -2
  96. package/memhouse/house/convert-profile.js +638 -0
  97. package/memhouse/house/convert-v3.js +742 -0
  98. package/memhouse/house/doctor-v3.js +324 -0
  99. package/memhouse/house/host-alias.js +85 -0
  100. package/memhouse/house/house.js +3 -3
  101. package/memhouse/house/migrate.js +2 -2
  102. package/memhouse/house/profile-dedupe.js +183 -0
  103. package/memhouse/house/profile-edges.js +554 -0
  104. package/memhouse/house/profile-house.js +430 -0
  105. package/memhouse/house/profile-rollup.js +190 -0
  106. package/memhouse/house/q-cli.js +863 -0
  107. package/memhouse/house/queries-v3.js +1013 -0
  108. package/memhouse/house/reconcile-v3.js +192 -0
  109. package/memhouse/house/records-to-profile.js +174 -0
  110. package/memhouse/house/records-to-rows.js +169 -0
  111. package/memhouse/house/relocate.js +2 -1
  112. package/memhouse/house/schema-choice.js +121 -0
  113. package/memhouse/house/schema-profile.js +290 -0
  114. package/memhouse/house/schema-profile.sql.tpl +398 -0
  115. package/memhouse/house/schema-v3.js +267 -0
  116. package/memhouse/house/schema-v3.sql.tpl +213 -0
  117. package/memhouse/import/claude-ai.js +420 -0
  118. package/memhouse/import/import.js +372 -0
  119. package/memhouse/import/pyjson.js +206 -0
  120. package/memhouse/import/records.js +64 -0
  121. package/memhouse/import/writer-profile.js +192 -0
  122. package/memhouse/import/writer-v3.js +240 -0
  123. package/memhouse/import/zip.js +112 -0
  124. package/memhouse/invitation.js +102 -0
  125. package/memhouse/invitefile.js +5 -2
  126. package/memhouse/plugin-copies.js +176 -0
  127. package/memhouse/provision.js +7 -2
  128. package/memhouse/release-hint.js +24 -0
  129. package/memhouse/resume.js +4 -4
  130. package/memhouse/server/api-v3.js +632 -0
  131. package/memhouse/server/dashboard-auth.js +181 -0
  132. package/memhouse/server/dashboard-port.js +91 -0
  133. package/memhouse/server/queries.js +30 -0
  134. package/memhouse/server/server.js +118 -20
  135. package/memhouse/service.js +1 -1
  136. package/memhouse/share.js +687 -1
  137. package/memhouse/shipper/adapter-capture.js +756 -0
  138. package/memhouse/shipper/capture.js +2571 -0
  139. package/memhouse/shipper/dedupe-profile.js +237 -0
  140. package/memhouse/shipper/dedupe-v3.js +1027 -0
  141. package/memhouse/shipper/forks.js +233 -0
  142. package/memhouse/shipper/log-adapter.js +219 -0
  143. package/memhouse/shipper/maintenance.js +49 -0
  144. package/memhouse/shipper/normalise-run.js +148 -0
  145. package/memhouse/shipper/passlog.js +68 -5
  146. package/memhouse/shipper/profile-capture.js +903 -0
  147. package/memhouse/shipper/ship.js +91 -8
  148. package/memhouse/shipper/snapshots.js +193 -0
  149. package/memhouse/shipper/source-roots.js +111 -0
  150. package/memhouse/version-notice.js +66 -0
  151. package/package.json +14 -7
  152. package/public/THIRD_PARTY_NOTICES.txt +2 -2
  153. package/public/assets/index-CcNnhfM3.js +78 -0
  154. package/public/index.html +1 -1
  155. package/ui/package-lock.json +56 -37
  156. package/ui/package.json +1 -0
  157. package/ui/src/App.jsx +29 -14
  158. package/ui/src/components/ChatSidebar.jsx +69 -12
  159. package/ui/src/lib/api.js +76 -0
  160. package/ui/src/lib/dashboard-token.js +41 -0
  161. package/ui/src/main.jsx +22 -5
  162. package/ui/src/pages/Dashboard.jsx +22 -36
  163. package/ui/third-party-notices.js +119 -0
  164. package/ui/vite.config.js +5 -1
  165. package/docs/releases/v0.18.12.md +0 -59
  166. package/memhouse/COMPETITION.md +0 -221
  167. package/memhouse/DESIGN.md +0 -222
  168. package/memhouse/RESUME-AND-UPDATE.md +0 -182
  169. package/public/assets/index-CnWzrjF3.js +0 -78
@@ -0,0 +1,2571 @@
1
+ #!/usr/bin/env node
2
+ // memhouse CAPTURE — the schema-v3 shipper (docs/design/source-identity.md, draft 5, §4–§5;
3
+ // the build contract is docs/design/source-identity-build.md).
4
+ //
5
+ // One pass:
6
+ // 1. stat every source file under MEMHOUSE_SOURCE_ROOTS against the local cache; an
7
+ // unchanged file costs a stat and nothing else.
8
+ // 2. for a changed file, read its `files` row from the house FIRST — the house is the truth,
9
+ // the cache is only an accelerator — and validate the bytes it describes: the inode and
10
+ // the 64 KiB before `upto` on every pass; the whole prefix (a SHA-256 digest of [0, upto))
11
+ // the first time this process touches the file, after an inode change, when the cache was
12
+ // rebuilt, and once a day. A copy shorter than `upto` written by another host is a lagging
13
+ // synced copy and waits; anything else that fails is a non-append change: `fgen` is bumped,
14
+ // the file re-archived and re-derived, and the keys that vanished are tombstoned.
15
+ // 3. read only from `reparse_from` (the start of the earliest open unit), archive the bytes
16
+ // past `upto` exactly (UTF-8, or base64 when not UTF-8), derive, and write in this order:
17
+ // archive → turns/calls → files → sessions; the local cache advances last. A crash re-sends,
18
+ // and every key is idempotent.
19
+ // A trailing line without its newline is held; once unchanged for 10 minutes it is archived
20
+ // as kind = 'fragment' (and parsed only when complete). A pass reads no conversation rows —
21
+ // except once per session that holds converted legacy rows:
22
+ // 4. DEDUPE (dedupe-v3.js): when a session's capture has caught up with its files for the first
23
+ // time, its converted `legacy:` rows are matched row by row against what was captured;
24
+ // every matched legacy ship row is tombstoned, every unmatched one stays live (and is
25
+ // reported), and their share of the totals is kept in a `files` row of role 'legacy' that
26
+ // also marks the session deduped. A sweep finds sessions captured before that marker existed.
27
+ //
28
+ // CLI: node memhouse/shipper/capture.js [--loop [sec]] [--backfill] [--no-dedupe] [--duplicates] [--redupe] [--dry-run]
29
+ // --redupe: a one-shot repair after the pass — re-plans deduped sessions that still have live
30
+ // legacy ship rows (dedupeSweep before 0.20.0-dev.2 deduped against partial captures)
31
+ // (also `memhouse ship --v3`, or `ship.js` with MEMHOUSE_SCHEMA=3)
32
+ // Env: MEMHOUSE_URL, MEMHOUSE_USER, MEMHOUSE_PASSWORD (or MEMHOUSE_PASSWORD_FILE), MEMHOUSE_DB
33
+ // (default mem; the archive is <db>_archive), MEMHOUSE_SCHEMA=3, MEMHOUSE_HOME (state),
34
+ // MEMHOUSE_SOURCE_ROOTS — comma-separated `[claude:|codex:][name=]<dir>`, each a Claude
35
+ // Code config dir or its projects/ dir, or a Codex home or its sessions/ dir. Unset, it
36
+ // defaults to what the 0.18 shipper reads on this machine (source-roots.js: every Claude
37
+ // Code config dir the legacy adapter finds, honouring MEMHOUSE_CLAUDE_ROOTS and
38
+ // MEMHOUSE_EDITORS, plus the Codex sessions dirs) and is re-resolved every pass, so a new
39
+ // playbook install is picked up without a restart. Set it to read only a copy (the dev CT).
40
+
41
+ 'use strict';
42
+
43
+ const fs = require('fs');
44
+ const os = require('os');
45
+ const path = require('path');
46
+ const crypto = require('crypto');
47
+ const zlib = require('zlib');
48
+ const { isUtf8 } = require('buffer');
49
+
50
+ const base = require('../../editors/base').capture;
51
+ const dedupe = require('./dedupe-v3');
52
+ const forks = require('./forks');
53
+
54
+ const BLOCK = 256 * 1024; // prefix digest block: a steady pass re-hashes at most one
55
+ const ANCHOR = 64 * 1024; // the window validated on every pass
56
+ const CHUNK = 1 << 20; // one archive row carries at most this many raw bytes
57
+ const BATCH_ROWS = 2000;
58
+ const BATCH_BYTES = 16 * 1024 * 1024;
59
+ const PASS_BUDGET = Number(process.env.MEMHOUSE_PASS_BYTES) || 512 * 1024 * 1024;
60
+ const GROUP_BYTES = 64 * 1024 * 1024; // what one write cycle holds in memory
61
+ const FRAGMENT_MS = 10 * 60 * 1000;
62
+ const PREFIX_EVERY_MS = 24 * 3600 * 1000;
63
+ const LIVE_MS = 15 * 60 * 1000;
64
+ const EPOCH_TS = '1970-01-01 00:00:00.000';
65
+
66
+ function defaultAdapters() {
67
+ return { 'claude-code': require('../../editors/claude').capture, codex: require('../../editors/codex').capture };
68
+ }
69
+
70
+ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
71
+ // A session's key within this machine: harness, the harness's data folder, session id, and the
72
+ // incarnation when the harness reuses ids inside one store (layout 3.2).
73
+ const skey = (harness, sid, hp = '', inc = '') => `${harness}\t${hp}\t${sid}${inc ? `\t${inc}` : ''}`;
74
+ // A source file's key within this machine: its session's key and its file key.
75
+ const fkey = (x) => `${skey(x.harness, x.session_id, x.harness_path || '', x.incarnation || '')}\t${x.file_key}`;
76
+ /** The harness's data folder for a capture root, symlink-resolved: ~/.claude for <dir>/projects, ~/.codex for <dir>/sessions. */
77
+ function harnessPathOf(r) {
78
+ if (r.harness_path) return r.harness_path;
79
+ let d = r.dir;
80
+ const b = path.basename(d);
81
+ if ((r.harness === 'claude-code' && b === 'projects') || (r.harness === 'codex' && (b === 'sessions' || b === 'archived_sessions'))) d = path.dirname(d);
82
+ // The contract's rule: absolute, symlink-resolved, the on-disk case (native resolver), no trailing slash.
83
+ try { return fs.realpathSync.native(d); } catch { return path.resolve(d); }
84
+ }
85
+ /**
86
+ * The adapter of a store. A pass that reads the bridge and the normalised logs sets one per store
87
+ * (ctx.storeAdapters): one harness may have a bridged store beside a logged one (Devin Desktop,
88
+ * Devin Local), and each must be derived by its own adapter. Else the harness's (ctx.adapters).
89
+ */
90
+ function adapterOf(ctx, x) {
91
+ const s = ctx.storeAdapters && ctx.storeAdapters.get(`${x.harness}\t${x.harness_path || ''}`);
92
+ return s || ctx.adapters[x.harness];
93
+ }
94
+ function parseJson(s, dflt) { try { return s ? JSON.parse(s) : dflt; } catch { return dflt; } }
95
+ function hexOrEmpty(v) { const s = String(v || '').replace(/\u0000/g, ''); return /^[0-9a-f]{64}$/i.test(s) ? s.toLowerCase() : ''; }
96
+
97
+ // ── source roots ────────────────────────────────────────────────────────────────────────────
98
+ function claudeRootName(configDir) {
99
+ const b = path.basename(configDir);
100
+ // ~/.claude is the 'default' install; a copy of it may be named for it ('claude').
101
+ if (b === '.claude' || b === 'claude') return 'default';
102
+ if (b === 'playbook') return path.basename(path.dirname(configDir));
103
+ return b.replace(/^\./, '') || 'default';
104
+ }
105
+ function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
106
+ function classifyRoot(dir, harness, name) {
107
+ if (!isDir(dir)) throw new Error(`source root ${dir} is not a directory`);
108
+ const b = path.basename(dir);
109
+ const claudeOk = harness !== 'codex';
110
+ const codexOk = harness !== 'claude-code';
111
+ if (claudeOk && isDir(path.join(dir, 'projects'))) return [{ harness: 'claude-code', dir: path.join(dir, 'projects'), name: name || claudeRootName(dir) }];
112
+ if (claudeOk && b === 'projects') return [{ harness: 'claude-code', dir, name: name || claudeRootName(path.dirname(dir)) }];
113
+ if (codexOk) {
114
+ const subs = ['sessions', 'archived_sessions'].filter((x) => isDir(path.join(dir, x)));
115
+ if (subs.length) return subs.map((x) => ({ harness: 'codex', dir: path.join(dir, x), name: name || 'default' }));
116
+ if (b === 'sessions' || b === 'archived_sessions') return [{ harness: 'codex', dir, name: name || 'default' }];
117
+ }
118
+ // A directory of Claude Code config dirs (a transcript copy: <dir>/<root>/projects): each one a root.
119
+ if (claudeOk && !name) {
120
+ let subs = [];
121
+ try { subs = fs.readdirSync(dir).filter((x) => isDir(path.join(dir, x, 'projects'))).sort(); } catch { /* unreadable */ }
122
+ if (subs.length) return subs.map((x) => ({ harness: 'claude-code', dir: path.join(dir, x, 'projects'), name: claudeRootName(path.join(dir, x)) }));
123
+ }
124
+ if (harness) return [{ harness, dir, name: name || (harness === 'codex' ? 'default' : claudeRootName(dir)) }];
125
+ throw new Error(`source root ${dir}: neither a Claude Code projects dir nor a Codex sessions dir — prefix it with claude: or codex:`);
126
+ }
127
+ /** MEMHOUSE_SOURCE_ROOTS → [{ harness, dir, name }]. Entries: `[claude:|codex:][name=]<dir>`, comma-separated. */
128
+ function parseSourceRoots(spec) {
129
+ const out = [];
130
+ for (const raw of String(spec || '').split(/[,\n;]/)) {
131
+ let s = raw.trim();
132
+ if (!s) continue;
133
+ let harness = null; let name = null;
134
+ const m = /^(claude-code|claude|codex):(.*)$/.exec(s);
135
+ if (m) { harness = m[1] === 'codex' ? 'codex' : 'claude-code'; s = m[2]; }
136
+ const eq = s.indexOf('=');
137
+ if (eq > 0 && !s.slice(0, eq).includes('/')) { name = s.slice(0, eq); s = s.slice(eq + 1); }
138
+ out.push(...classifyRoot(path.resolve(s.replace(/^~(?=$|\/)/, os.homedir())), harness, name));
139
+ }
140
+ return out;
141
+ }
142
+
143
+ // ── discovery ───────────────────────────────────────────────────────────────────────────────
144
+ function statSafe(p) { try { const s = fs.statSync(p); return s.isFile() ? { size: s.size, mtimeMs: Math.floor(s.mtimeMs), ino: Number(s.ino) } : null; } catch { return null; } }
145
+
146
+ /** Every session under the roots, each with its source files (one entry per file_key). */
147
+ function discover(roots, adapters, report = {}) {
148
+ const sessions = new Map();
149
+ report.duplicates = 0;
150
+ for (const r of roots) {
151
+ const ad = r.adapter || adapters[r.harness];
152
+ if (!ad) continue;
153
+ const hp = harnessPathOf(r);
154
+ for (const s of ad.sessions(r)) {
155
+ const k = skey(r.harness, s.session_id, hp, s.incarnation || '');
156
+ let S = sessions.get(k);
157
+ if (!S) sessions.set(k, S = { harness: r.harness, session_id: String(s.session_id), harness_path: hp, incarnation: String(s.incarnation || ''), root: s.root || r.name, files: new Map() });
158
+ for (const f of ad.sourceFiles(s)) {
159
+ const st = statSafe(f.path);
160
+ if (!st) continue;
161
+ const prev = S.files.get(f.file_key);
162
+ if (prev) {
163
+ // The same file under two roots (a copied config dir, an archived copy): keep the
164
+ // longer, then the newer. Two writers must never drive one files row.
165
+ report.duplicates++;
166
+ if (st.size < prev.stat.size || (st.size === prev.stat.size && st.mtimeMs <= prev.stat.mtimeMs)) continue;
167
+ }
168
+ S.files.set(f.file_key, { ...f, harness: r.harness, session_id: S.session_id, harness_path: hp, incarnation: S.incarnation, root: s.root || r.name, stat: st });
169
+ if (f.role === 'main') S.root = s.root || r.name;
170
+ }
171
+ }
172
+ }
173
+ for (const S of sessions.values()) for (const f of S.files.values()) f.root = S.root;
174
+ return sessions;
175
+ }
176
+
177
+ // ── local cache: $MEMHOUSE_HOME/state/capture-v3-<house>.json ───────────────────────────────
178
+ // v2: layout 3.2 keys (the store in every key); a v1 cache is rebuilt from the house.
179
+ const CACHE_V = 2;
180
+ class Cache {
181
+ constructor(file) { this.file = file; this.data = { v: CACHE_V, files: {}, deduped: {} }; this.rebuilt = false; this.dirty = false; }
182
+ load() {
183
+ try {
184
+ const d = JSON.parse(fs.readFileSync(this.file, 'utf-8'));
185
+ if (d && d.v === CACHE_V && d.files) { this.data = { ...d, deduped: d.deduped || {} }; delete this.data.reconciled; } else this.rebuilt = true;
186
+ } catch { this.rebuilt = true; }
187
+ return this;
188
+ }
189
+ get(k) { return this.data.files[k] || null; }
190
+ set(k, v) { this.data.files[k] = v; this.dirty = true; }
191
+ save() {
192
+ // A pass that lost its lock (reloadCache sets the guard) never writes the file: it is the holder's.
193
+ if (!this.dirty || !this.file || (this.guard && this.guard())) return;
194
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
195
+ const tmp = `${this.file}.${process.pid}.tmp`;
196
+ fs.writeFileSync(tmp, JSON.stringify(this.data), { mode: 0o600 });
197
+ fs.renameSync(tmp, this.file);
198
+ this.dirty = false;
199
+ }
200
+ }
201
+ function cachePath(home, ident) {
202
+ return path.join(home, 'state', `capture-v3-${sha256(ident).slice(0, 12)}.json`);
203
+ }
204
+
205
+ // ── bytes ───────────────────────────────────────────────────────────────────────────────────
206
+ function readRange(fd, from, to) {
207
+ const len = Math.max(0, to - from);
208
+ const buf = Buffer.allocUnsafe(len);
209
+ let got = 0;
210
+ while (got < len) {
211
+ const n = fs.readSync(fd, buf, got, len - got, from + got);
212
+ if (!n) break;
213
+ got += n;
214
+ }
215
+ return got === len ? buf : buf.subarray(0, got);
216
+ }
217
+ /** SHA-256 over the SHA-256s of the 256 KiB blocks of [0, upto): computable from the file alone, and extendable. */
218
+ function digestOf(blocks, partialHex) {
219
+ const h = crypto.createHash('sha256');
220
+ for (const b of blocks) h.update(Buffer.from(b, 'hex'));
221
+ if (partialHex) h.update(Buffer.from(partialHex, 'hex'));
222
+ return h.digest('hex');
223
+ }
224
+ function prefixDigest(fd, upto) {
225
+ const blocks = [];
226
+ let partial = null;
227
+ for (let off = 0; off < upto; off += BLOCK) {
228
+ const end = Math.min(off + BLOCK, upto);
229
+ const hx = sha256(readRange(fd, off, end));
230
+ if (end - off === BLOCK) blocks.push(hx); else partial = hx;
231
+ }
232
+ return { hex: digestOf(blocks, partial), blocks };
233
+ }
234
+ /** Extend full-block hashes over [0, newUpto) given `buf` that starts at `bufFrom` <= blocks.length·BLOCK. */
235
+ function extendDigest(blocks, buf, bufFrom, newUpto) {
236
+ const out = blocks.slice();
237
+ let off = out.length * BLOCK;
238
+ let partial = null;
239
+ while (off < newUpto) {
240
+ const end = Math.min(off + BLOCK, newUpto);
241
+ const hx = sha256(buf.subarray(off - bufFrom, end - bufFrom));
242
+ if (end - off === BLOCK) out.push(hx); else partial = hx;
243
+ off = end;
244
+ }
245
+ return { hex: digestOf(out, partial), blocks: out };
246
+ }
247
+ function digestBuffer(buf) {
248
+ const blocks = [];
249
+ let partial = null;
250
+ for (let off = 0; off < buf.length; off += BLOCK) {
251
+ const end = Math.min(off + BLOCK, buf.length);
252
+ const hx = sha256(buf.subarray(off, end));
253
+ if (end - off === BLOCK) blocks.push(hx); else partial = hx;
254
+ }
255
+ return digestOf(blocks, partial);
256
+ }
257
+ function anchorOf(fd, upto) { return sha256(readRange(fd, Math.max(0, upto - ANCHOR), upto)); }
258
+
259
+ /** Archive rows for one record's bytes: UTF-8 travels as text, anything else as base64; chunked at CHUNK. */
260
+ function archiveRows(f, fgen, pos, bytes, kind, host, capturedAt) {
261
+ const sha = sha256(bytes);
262
+ const utf8 = isUtf8(bytes);
263
+ const rows = [];
264
+ let off = 0; let chunk = 0;
265
+ do {
266
+ let end = Math.min(bytes.length, off + CHUNK);
267
+ if (utf8) while (end < bytes.length && end > off && (bytes[end] & 0xC0) === 0x80) end--;
268
+ const part = bytes.subarray(off, end);
269
+ rows.push({
270
+ harness: f.harness, harness_path: String(f.harness_path || ''), session_id: f.session_id, incarnation: String(f.incarnation || ''),
271
+ file_key: f.file_key, fgen, pos, chunk, sha, kind,
272
+ enc: utf8 ? '' : 'b64', raw: utf8 ? part.toString('utf-8') : part.toString('base64'), host, captured_at: capturedAt,
273
+ });
274
+ off = end; chunk++;
275
+ } while (off < bytes.length);
276
+ return rows;
277
+ }
278
+ /**
279
+ * A read manifest (harness-sessions-sql §4.1): one archive row per read, so the archive alone shows what
280
+ * went away too — a source reset to empty, a source that vanished. pos 0, chunk 0, kind and enc
281
+ * 'manifest', sha the sha256 of raw (a record at pos 0 has another sha, so the two never collide).
282
+ * raw: { progress, continuity: 'appended' | 'reset' | 'snapshot', units: [[pos, sha], …], gone,
283
+ * source_gone, policy? } — policy ('kept' | 'removed') only on a source_gone manifest.
284
+ */
285
+ function manifestRow(f, fgen, m, host, capturedAt) {
286
+ const raw = JSON.stringify({
287
+ progress: m.progress, continuity: m.continuity, units: m.units || [], gone: [], source_gone: !!m.source_gone,
288
+ ...(m.source_gone ? { policy: m.policy } : {}),
289
+ });
290
+ return {
291
+ harness: f.harness, harness_path: String(f.harness_path || ''), session_id: f.session_id, incarnation: String(f.incarnation || ''),
292
+ file_key: f.file_key, fgen, pos: 0, chunk: 0, sha: sha256(Buffer.from(raw, 'utf-8')), kind: 'manifest', enc: 'manifest', raw, host, captured_at: capturedAt,
293
+ };
294
+ }
295
+ /** The units a read delivered, as its archive rows name them: [pos, the whole record's sha], in order. */
296
+ const unitsOf = (rows) => rows.filter((r) => r.kind !== 'manifest' && r.kind !== 'fragment' && Number(r.chunk) === 0).map((r) => [String(r.pos), r.sha]);
297
+
298
+ /**
299
+ * The records a source holds now, from its archive rows alone (every generation, any order): the
300
+ * harness-sessions-sql §4.1 replay. The highest generation is current; a source_gone one replays as its
301
+ * policy says ('kept': the generation before it; 'removed', or no policy: empty). Within the
302
+ * generation, manifests fold in progress order (units added, gone dropped); a generation archived
303
+ * before manifests existed has no opening manifest and starts from all its records.
304
+ * Returns { fgen, records, empty, source_gone, policy } (fgen null when nothing was archived).
305
+ */
306
+ function replayArchive(rows) {
307
+ const gens = new Map();
308
+ for (const r of rows) {
309
+ const g = Number(r.fgen);
310
+ if (!gens.has(g)) gens.set(g, { records: [], manifests: [] });
311
+ if (r.kind === 'manifest') gens.get(g).manifests.push({ ...JSON.parse(r.raw), at: String(r.captured_at || '') });
312
+ else if (r.kind === 'record') gens.get(g).records.push(r);
313
+ }
314
+ const order = [...gens.keys()].sort((a, b) => b - a);
315
+ let i = 0; let gone = null;
316
+ while (i < order.length) {
317
+ // Two source_gone events in one generation should not happen (each is allocated past the
318
+ // archive's highest); if they do, the later one is what was done last, and "removed" wins a tie.
319
+ const m = gens.get(order[i]).manifests.filter((x) => x.source_gone)
320
+ .sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : (a.policy === 'kept' ? -1 : 1) - (b.policy === 'kept' ? -1 : 1))).pop();
321
+ if (!m) break;
322
+ gone = gone || { policy: m.policy === 'kept' ? 'kept' : 'removed' };
323
+ if (gone.policy !== 'kept') return { fgen: order[i], records: [], empty: true, source_gone: true, policy: gone.policy };
324
+ i++;
325
+ }
326
+ if (i >= order.length) return { fgen: null, records: [], empty: true, source_gone: !!gone, policy: gone ? gone.policy : null };
327
+ const fgen = order[i]; const G = gens.get(fgen);
328
+ const ms = G.manifests.slice().sort((a, b) => a.progress - b.progress);
329
+ const opened = ms.some((x) => x.continuity === 'reset' || x.continuity === 'snapshot');
330
+ const units = new Map();
331
+ if (!opened) for (const r of G.records) if (Number(r.chunk) === 0 && !units.has(String(r.pos))) units.set(String(r.pos), null);
332
+ for (const x of ms) {
333
+ if (x.continuity === 'reset' || x.continuity === 'snapshot') units.clear();
334
+ for (const [k, sha] of x.units) { units.delete(k); units.set(k, sha); }
335
+ for (const k of x.gone || []) units.delete(String(k));
336
+ }
337
+ const want = new Set(units.keys());
338
+ const picked = G.records.filter((r) => want.has(String(r.pos)) && (units.get(String(r.pos)) == null || units.get(String(r.pos)) === r.sha));
339
+ const recs = recordsFromArchive(picked);
340
+ const at = new Map(recs.map((r) => [String(r.pos), r]));
341
+ const records = [...units.keys()].map((k) => at.get(k)).filter(Boolean);
342
+ return { fgen, records, empty: !records.length, source_gone: !!gone, policy: gone ? gone.policy : null };
343
+ }
344
+
345
+ /** Archive rows of one generation → derive records (kind = 'record' only; a fragment is never parsed). */
346
+ function recordsFromArchive(rows) {
347
+ const byPos = new Map();
348
+ for (const r of rows) {
349
+ if (r.kind !== 'record') continue;
350
+ const pos = Number(r.pos);
351
+ let g = byPos.get(pos);
352
+ if (!g) byPos.set(pos, g = new Map());
353
+ const v = g.get(r.sha) || { chunks: [], at: r.captured_at };
354
+ v.chunks[Number(r.chunk)] = r.enc === 'b64' ? Buffer.from(r.raw, 'base64') : Buffer.from(r.raw, 'utf-8');
355
+ if (r.captured_at < v.at) v.at = r.captured_at;
356
+ g.set(r.sha, v);
357
+ }
358
+ const out = [];
359
+ for (const pos of [...byPos.keys()].sort((a, b) => a - b)) {
360
+ // Byte history: two versions at one position within a generation should not happen (a
361
+ // change bumps fgen); if it does, the newest capture is the one the file holds.
362
+ const versions = [...byPos.get(pos).values()].sort((a, b) => (a.at < b.at ? -1 : 1));
363
+ const v = versions[versions.length - 1];
364
+ const bytes = Buffer.concat(v.chunks.filter(Boolean));
365
+ out.push(toRecord(pos, bytes, Date.parse(String(v.at).replace(' ', 'T') + 'Z')));
366
+ }
367
+ return out;
368
+ }
369
+ /** The file a generation's archive rows describe, byte for byte (records, each with its newline). */
370
+ function rebuildFromArchive(rows) {
371
+ const parts = [];
372
+ const byPos = new Map();
373
+ for (const r of rows) {
374
+ if (r.kind !== 'record') continue;
375
+ const g = byPos.get(Number(r.pos)) || [];
376
+ g[Number(r.chunk)] = r.enc === 'b64' ? Buffer.from(r.raw, 'base64') : Buffer.from(r.raw, 'utf-8');
377
+ byPos.set(Number(r.pos), g);
378
+ }
379
+ for (const pos of [...byPos.keys()].sort((a, b) => a - b)) { parts.push(Buffer.concat(byPos.get(pos).filter(Boolean)), Buffer.from('\n')); }
380
+ return Buffer.concat(parts);
381
+ }
382
+ function toRecord(pos, bytes, capturedAt) {
383
+ let obj = null;
384
+ if (bytes.length && isUtf8(bytes)) { try { obj = JSON.parse(bytes.toString('utf-8')); } catch { obj = null; } }
385
+ if (obj !== null && (typeof obj !== 'object' || Array.isArray(obj))) obj = null;
386
+ return { pos, end: pos + bytes.length + 1, obj, capturedAt, empty: bytes.length === 0 };
387
+ }
388
+ /** Complete lines of `buf` (which starts at `bufFrom`) from `start`; the bytes after the last newline. */
389
+ function splitLines(buf, bufFrom, start) {
390
+ const lines = [];
391
+ let off = start - bufFrom;
392
+ for (;;) {
393
+ const nl = buf.indexOf(0x0A, off);
394
+ if (nl === -1) break;
395
+ lines.push({ pos: bufFrom + off, bytes: buf.subarray(off, nl) });
396
+ off = nl + 1;
397
+ }
398
+ return { lines, tailPos: bufFrom + off, tail: buf.subarray(off) };
399
+ }
400
+
401
+ // ── one source file ─────────────────────────────────────────────────────────────────────────
402
+ function fileCtx(f, host, fgen, upto, reparseFrom, attr) {
403
+ return {
404
+ harness: f.harness, session_id: f.session_id, harness_path: String(f.harness_path || ''), incarnation: String(f.incarnation || ''), file_key: f.file_key, root: f.root, host,
405
+ agent_id: f.agent_id || '', agent_type: attr.agent_type || '', agent_desc: attr.agent_desc || '',
406
+ workflow: f.workflow || '', fgen, upto, reparse_from: reparseFrom,
407
+ };
408
+ }
409
+ function filesRow(f, ctx, o) {
410
+ const c = o.contrib || {};
411
+ return {
412
+ harness: f.harness, harness_path: String(f.harness_path || ''), session_id: f.session_id, incarnation: String(f.incarnation || ''),
413
+ file_key: f.file_key, agent_id: f.agent_id || '', role: f.role,
414
+ agent_type: o.attr.agent_type || '', agent_desc: o.attr.agent_desc || '',
415
+ fgen: o.fgen, upto: o.upto, reparse_from: o.reparse_from, derive_state: JSON.stringify(o.state || {}),
416
+ adapter_ver: o.adapter_ver, size: o.size, inode: o.ino, anchor_sha: o.anchor, prefix_sha: o.prefix,
417
+ is_deleted: 0, contrib: JSON.stringify(c), first_ts: c.first_ts || EPOCH_TS, last_ts: c.last_ts || EPOCH_TS,
418
+ host: ctx.host,
419
+ };
420
+ }
421
+ const verOf = (r) => base.revOf(r.fgen, r.upto);
422
+ /**
423
+ * Layout 3.2: a session belongs to its harness store. The reference every house query takes:
424
+ * the harness, this machine's host, the harness root's label, the session id, the incarnation.
425
+ */
426
+ function refOf(x, host) {
427
+ return {
428
+ harness: String(x.harness), host: String(host ?? x.host ?? ''), harness_path: String(x.harness_path || ''),
429
+ session_id: String(x.session_id), incarnation: String(x.incarnation || ''),
430
+ };
431
+ }
432
+
433
+ /** Tombstones for vanished keys: the exact old rows read back from the house, copied whole. */
434
+ async function tombstonesFor(ctx, fc, vanished, rev, { removed = false } = {}) {
435
+ // `removed`: the keys left the source (a rewrite) — kept as removed versions. Otherwise they are
436
+ // duplicates of a re-derivation (an adapter upgrade, re-keyed open units) — physical tombstones.
437
+ const mark = removed ? base.removedOf : base.tombstoneOf;
438
+ const ref = refOf(fc, fc.host);
439
+ const out = { turns: [], calls: [] };
440
+ if (vanished.turns.length) {
441
+ for (const r of await ctx.house.rowsByKey('turns', ref, vanished.turns)) out.turns.push(mark('turns', r, rev));
442
+ }
443
+ if (vanished.calls.length) {
444
+ for (const r of await ctx.house.rowsByKey('calls', ref, vanished.calls)) out.calls.push(mark('calls', r, rev));
445
+ }
446
+ return out;
447
+ }
448
+ /** Keys ({ t: [[message_id, part_id, ts]], c: [[call_id, ts]] }) the new derivation no longer produces. */
449
+ function vanishedFrom(old, res) {
450
+ const have = { t: new Set(res.turns.map(base.turnKey)), c: new Set(res.calls.map(base.callKey)) };
451
+ return {
452
+ turns: (old.t || []).filter(([message_id, part_id, ts]) => !have.t.has(base.turnKey({ ts, message_id, part_id })))
453
+ .map(([message_id, part_id, ts]) => ({ message_id, part_id: String(part_id), ts })),
454
+ calls: (old.c || []).filter(([call_id, ts]) => !have.c.has(base.callKey({ ts, call_id })))
455
+ .map(([call_id, ts]) => ({ call_id, ts })),
456
+ };
457
+ }
458
+ function liveKeys(res) {
459
+ return {
460
+ t: res.turns.map((r) => [r.message_id, r.part_id, r.ts, r.role, r.kind]),
461
+ c: res.calls.map((r) => [r.call_id, r.ts, r.message_id]),
462
+ };
463
+ }
464
+
465
+ async function captureSide(ctx, f, H, C) {
466
+ const out = { archive: [], turns: [], calls: [], files: null, cache: null, bytes: 0, more: false, event: null };
467
+ const ad = adapterOf(ctx, f);
468
+ let bytes;
469
+ try { bytes = fs.readFileSync(f.path); } catch { return out; }
470
+ const size = bytes.length;
471
+ const sha = sha256(bytes);
472
+ const digest = digestBuffer(bytes);
473
+ // A side file back after it vanished opens a generation past the source_gone one, whatever its bytes.
474
+ const goneGen = goneGenOf(C, H, ctx.archGen && ctx.archGen.get(fkey(f)));
475
+ const unchanged = goneGen < 0 && H && hexOrEmpty(H.prefix_sha) === digest && Number(H.upto) === size;
476
+ const fgen = H ? (unchanged ? Number(H.fgen) : Math.max(Number(H.fgen), goneGen) + 1) : (C ? Math.max(Number(C.fgen), goneGen + 1) : 0);
477
+ const attr = ad.attribution ? ad.attribution(f) : {};
478
+ if (!unchanged) {
479
+ out.archive = archiveRows(f, fgen, 0, bytes, 'side-file', ctx.host, base.chTs(ctx.now()));
480
+ out.archive.push(manifestRow(f, fgen, { progress: size, continuity: 'snapshot', units: [['0', sha]] }, ctx.host, base.chTs(ctx.now())));
481
+ out.files = filesRow(f, ctx, {
482
+ attr, fgen, upto: size, reparse_from: size, state: {}, adapter_ver: ad.deriveVersion, size, ino: f.stat.ino,
483
+ anchor: sha256(bytes.subarray(Math.max(0, size - ANCHOR))), prefix: digest,
484
+ contrib: base.serializeContrib(base.newContrib()),
485
+ });
486
+ out.bytes = size;
487
+ out.event = H ? 'side-changed' : 'side-new';
488
+ }
489
+ out.cache = { size, mtimeMs: f.stat.mtimeMs, ino: f.stat.ino, fgen, upto: size, adapter_ver: ad.deriveVersion, prefixAt: ctx.now(), path: f.path, archAt: archAtOf(ctx, fkey(f), C, ctx.now()) };
490
+ return out;
491
+ }
492
+
493
+ /**
494
+ * Plan the capture of one changed file: what to archive, which rows to write, its new `files`
495
+ * row and cache entry. Reads the file; writes nothing (the caller writes, in order).
496
+ */
497
+ async function captureFile(ctx, f, H, C, budget) {
498
+ if (f.role === 'side') return captureSide(ctx, f, H, C);
499
+ const ad = adapterOf(ctx, f);
500
+ const now = ctx.now();
501
+ const out = { archive: [], turns: [], calls: [], files: null, cache: null, bytes: 0, more: false, event: null };
502
+ const k = fkey(f);
503
+ let fd;
504
+ try { fd = fs.openSync(f.path, 'r'); } catch { out.event = 'unreadable'; return out; }
505
+ try {
506
+ const st = fs.fstatSync(fd);
507
+ const size = st.size; const ino = Number(st.ino);
508
+ let fgen = H ? Number(H.fgen) : (C ? Number(C.fgen) : 0);
509
+ let upto = H ? Number(H.upto) : 0;
510
+ let reparseFrom = H ? Number(H.reparse_from) : 0;
511
+ let state = H ? parseJson(H.derive_state, null) : null;
512
+ // A replay starts from the snapshot taken at reparse_from. Without a usable one (unreadable,
513
+ // or an older state format) the only safe start is the beginning of the file.
514
+ if (state && state.v !== base.CAPTURE_STATE_V) state = null;
515
+ if (!state) reparseFrom = 0;
516
+ let blocks = [];
517
+ let prefixAt = C && C.prefixAt ? C.prefixAt : 0;
518
+ let rewrite = null;
519
+
520
+ // A source back after it vanished (a source_gone manifest recorded it) is read again from the
521
+ // start, as a new generation past the source_gone one: the archive's current generation is then
522
+ // the file as it is, not the event that it was gone.
523
+ const goneGen = goneGenOf(C, H, ctx.archGen && ctx.archGen.get(k));
524
+ // A gone mark without a generation (recordVanished found the files row deleted: a superseded
525
+ // bridge source) still forces the re-read: appended into the deleted row's generation, the
526
+ // source would share it with that generation's "removed" manifest and replay as empty.
527
+ if (H && (goneGen >= 0 || (C && C.gone))) rewrite = 'reappeared';
528
+ // Validate what the house row describes against the bytes here.
529
+ else if (H && upto > 0) {
530
+ if (size < upto) {
531
+ rewrite = 'shrunk';
532
+ } else if (anchorOf(fd, upto) !== hexOrEmpty(H.anchor_sha)) {
533
+ rewrite = 'anchor';
534
+ } else {
535
+ const cacheMatches = C && Number(C.fgen) === fgen && Number(C.upto) === upto && Array.isArray(C.blocks);
536
+ const full = !cacheMatches || C.ino !== ino || !ctx.validated.has(k) || now - prefixAt > PREFIX_EVERY_MS;
537
+ if (full) {
538
+ const p = prefixDigest(fd, upto);
539
+ if (p.hex !== hexOrEmpty(H.prefix_sha)) rewrite = 'prefix';
540
+ else { blocks = p.blocks; prefixAt = now; ctx.validated.add(k); }
541
+ } else blocks = C.blocks;
542
+ }
543
+ }
544
+ // A row derived by a NEWER adapter (another host already upgraded) is never re-derived
545
+ // backwards: two versions would rewrite each other's rows on every pass.
546
+ if (!rewrite && H && Number(H.adapter_ver) > ad.deriveVersion) { out.event = 'newer-adapter'; out.wait = true; return out; }
547
+ // Derived by an older adapter: an upgrade — or, with different bytes, a harness switching from
548
+ // the bridge to its normaliser (log derive versions sit above every bridge version). Either way
549
+ // the old keys are the house's, and the ones the new derivation no longer produces are
550
+ // duplicates of it: physical tombstones, never removed versions. With a rewrite in the same
551
+ // pass, only those the upgrade dropped are (retireBoth).
552
+ const reAdapter = !!H && Number(H.adapter_ver) < ad.deriveVersion;
553
+ const upgrade = !rewrite && reAdapter;
554
+ const attr = ad.attribution ? ad.attribution(f) : {};
555
+
556
+ if (rewrite || upgrade) {
557
+ // Re-derive the whole file: a new generation (rewrite) or a new adapter version (upgrade).
558
+ // The keys before: re-derived from the archive (rewrite), or what the house holds for this
559
+ // file (upgrade — the old version's derivation cannot be reproduced).
560
+ const oldKeys = rewrite && !reAdapter ? await oldGenerationKeys(ctx, f, H, state) : await ctx.house.fileKeys(refOf(f, ctx.host), f.agent_id || '', ctx.agentFileIds(f));
561
+ // A new generation never reuses one the archive already holds (a source_gone manifest sits one
562
+ // past the cursor's).
563
+ const archived = ctx.archChecked && ctx.archChecked.has(k) ? (ctx.archGen.has(k) ? ctx.archGen.get(k) : -1) : await maxGeneration(ctx, f);
564
+ const newFgen = rewrite ? Math.max(fgen, goneGen, archived) + 1 : fgen;
565
+ const buf = readRange(fd, 0, size);
566
+ const { lines, tailPos, tail } = splitLines(buf, 0, 0);
567
+ const newUpto = tailPos;
568
+ const records = lines.map((l) => toRecord(l.pos, l.bytes, now));
569
+ const fc = fileCtx(f, ctx.host, newFgen, newUpto, 0, attr);
570
+ // A rewrite moves records: positional first-seen times go, those kept by a stable id stay (tsOf).
571
+ const fbIds = (fb) => Object.fromEntries(Object.entries(fb || {}).filter(([k]) => k.startsWith('id:')));
572
+ const freshState = { ...base.initialState(ad.adapter), fb: rewrite ? fbIds(state && state.fb) : ((state && state.fb) || {}) };
573
+ const res = ad.derive({ file: fc, records, state: freshState });
574
+ const rev = String(base.revOf(newFgen, newUpto));
575
+ const tomb = rewrite && reAdapter ? await retireBoth(ctx, f, H, state, fc, oldKeys, res, rev)
576
+ : await tombstonesFor(ctx, fc, vanishedFrom(oldKeys, res), rev, { removed: !!rewrite });
577
+ // A new generation is archived whole; an upgrade archives only what lies past `upto`.
578
+ for (const l of lines) if (rewrite || l.pos >= upto) out.archive.push(...archiveRows(f, newFgen, l.pos, l.bytes, 'record', ctx.host, base.chTs(now)));
579
+ // The read's manifest: a rewrite opens its generation (written even when the file is now empty);
580
+ // an upgrade that read past the cursor appended to the one it had.
581
+ if (rewrite) out.archive.push(manifestRow(f, newFgen, { progress: newUpto, continuity: 'reset', units: unitsOf(out.archive) }, ctx.host, base.chTs(now)));
582
+ else if (newUpto > upto) out.archive.push(manifestRow(f, newFgen, { progress: newUpto, continuity: 'appended', units: unitsOf(out.archive) }, ctx.host, base.chTs(now)));
583
+ out.turns = [...res.turns, ...tomb.turns];
584
+ out.calls = [...res.calls, ...tomb.calls];
585
+ const digest = prefixDigest(fd, newUpto);
586
+ out.files = filesRow(f, ctx, {
587
+ attr, fgen: newFgen, upto: newUpto, reparse_from: res.reparse_from, state: res.state, adapter_ver: ad.deriveVersion,
588
+ size, ino, anchor: anchorOf(fd, newUpto), prefix: digest.hex, contrib: res.contrib,
589
+ });
590
+ out.bytes = size;
591
+ out.event = rewrite ? `rewrite:${rewrite}` : 'upgrade';
592
+ out.tombstones = [...tomb.turns, ...tomb.calls].filter((r) => Number(r.is_deleted) || Number(r.removed)).length;
593
+ ctx.validated.add(k);
594
+ out.cache = cacheEntry(f, { size, ino, fgen: newFgen, upto: newUpto, blocks: digest.blocks, prefixAt: now, adapter_ver: ad.deriveVersion, frag: fragState(C, newFgen, tailPos, tail, now, size, out, f, ctx), archAt: archAtOf(ctx, k, C, now) });
595
+ return out;
596
+ }
597
+
598
+ // Append: read from the earliest open unit; archive and derive only complete lines.
599
+ const blockStart = blocks.length * BLOCK;
600
+ const readFrom = Math.min(reparseFrom, blockStart, upto);
601
+ const limit = Math.min(size, upto + Math.max(1, budget));
602
+ let readTo = limit;
603
+ let buf = readRange(fd, readFrom, readTo);
604
+ // A single line longer than the budget is read whole: a pass must make progress.
605
+ while (readTo < size && buf.lastIndexOf(0x0A) < upto - readFrom) {
606
+ readTo = Math.min(size, readTo + Math.max(readTo - upto, 65536));
607
+ buf = readRange(fd, readFrom, readTo);
608
+ }
609
+ // Consume the complete lines inside the budget — or, when none fits, the one that crosses it.
610
+ if (readTo > limit || readTo < size) {
611
+ const inBudget = limit > readFrom ? buf.lastIndexOf(0x0A, limit - readFrom - 1) : -1;
612
+ const cut = inBudget >= upto - readFrom ? inBudget + 1 : buf.indexOf(0x0A, upto - readFrom) + 1;
613
+ if (cut > 0 && readFrom + cut < readTo) { buf = buf.subarray(0, cut); readTo = readFrom + cut; }
614
+ }
615
+ const { lines, tailPos, tail } = splitLines(buf, readFrom, reparseFrom);
616
+ const newUpto = Math.max(upto, tailPos);
617
+ out.more = readTo < size;
618
+ const fresh = lines.filter((l) => l.pos >= upto);
619
+ for (const l of fresh) out.archive.push(...archiveRows(f, fgen, l.pos, l.bytes, 'record', ctx.host, base.chTs(now)));
620
+ // The read's manifest: the first read of a source opens its generation (written even when it
621
+ // delivered nothing); a later read only when it moved the cursor — one that delivered nothing
622
+ // would add nothing to the fold.
623
+ if (!H || newUpto > upto) out.archive.push(manifestRow(f, fgen, { progress: newUpto, continuity: H ? 'appended' : 'reset', units: unitsOf(out.archive) }, ctx.host, base.chTs(now)));
624
+ out.bytes = newUpto - upto;
625
+ const frag = fragState(C, fgen, tailPos, readTo === size ? tail : null, now, size, out, f, ctx);
626
+
627
+ if (newUpto > upto || !H) {
628
+ const records = lines.map((l) => toRecord(l.pos, l.bytes, now));
629
+ if (!state) await restoreFirstSeen(ctx, f, fgen, records);
630
+ const fc = fileCtx(f, ctx.host, fgen, newUpto, reparseFrom, attr);
631
+ const res = ad.derive({ file: fc, records, state });
632
+ const rev = String(base.revOf(fgen, newUpto));
633
+ const tomb = await tombstonesFor(ctx, fc, base.vanishedKeys(state, res), rev);
634
+ out.turns = [...res.turns.filter((r) => r._touch >= upto), ...tomb.turns];
635
+ out.calls = [...res.calls.filter((r) => r._touch >= upto), ...tomb.calls];
636
+ out.tombstones = tomb.turns.length + tomb.calls.length;
637
+ const digest = extendDigest(blocks, buf, readFrom, newUpto);
638
+ blocks = digest.blocks;
639
+ out.files = filesRow(f, ctx, {
640
+ attr, fgen, upto: newUpto, reparse_from: res.reparse_from, state: res.state, adapter_ver: ad.deriveVersion,
641
+ size, ino, anchor: anchorOf(fd, newUpto), prefix: digest.hex, contrib: res.contrib,
642
+ });
643
+ out.event = H ? 'append' : 'new';
644
+ ctx.validated.add(k);
645
+ }
646
+ out.cache = cacheEntry(f, { size, ino, fgen, upto: newUpto, blocks, prefixAt: prefixAt || now, adapter_ver: ad.deriveVersion, frag, more: out.more, archAt: archAtOf(ctx, k, C, now) });
647
+ return out;
648
+ } finally {
649
+ fs.closeSync(fd);
650
+ }
651
+ }
652
+ /**
653
+ * A record with no timestamp of its own is dated by the captured_at of its FIRST archive row
654
+ * (§2.1). With the files row (and its derive_state) gone, that first sighting is still in the
655
+ * archive: read it back, so a re-derive keeps the key instead of minting a new one.
656
+ * Known limit: the lookup is by byte position within `fgen`, the generation this read starts at.
657
+ * With the local cache gone as well, that is generation 0, so for a source rewritten since (a
658
+ * normalised log that changed its history) the positions name other records, or none, and the
659
+ * records are re-dated under new keys. Not fixed in 0.20 (release notes, Known issues).
660
+ */
661
+ async function restoreFirstSeen(ctx, f, fgen, records) {
662
+ const ad = adapterOf(ctx, f);
663
+ const tsless = ad.tsless || ((o) => typeof o.timestamp !== 'string');
664
+ const need = records.filter((r) => r.obj && tsless(r.obj)).map((r) => r.pos);
665
+ if (!need.length || typeof ctx.house.firstCaptured !== 'function') return;
666
+ const seen = await ctx.house.firstCaptured(refOf(f, ctx.host), f.file_key, fgen, need);
667
+ for (const r of records) if (seen.has(r.pos)) r.capturedAt = seen.get(r.pos);
668
+ }
669
+ function cacheEntry(f, o) {
670
+ return { size: o.size, mtimeMs: f.stat.mtimeMs, ino: o.ino, fgen: o.fgen, upto: o.upto, blocks: o.blocks, prefixAt: o.prefixAt, adapter_ver: o.adapter_ver, frag: o.frag || null, more: !!o.more, path: f.path, archAt: o.archAt || 0 };
671
+ }
672
+ /** The highest generation the archive holds for each source ({ fkey: g }; absent: none). */
673
+ async function maxGenerations(ctx, files) {
674
+ if (!files.length || typeof ctx.house.maxGenerations !== 'function') return new Map();
675
+ return ctx.house.maxGenerations(files.map((f) => ({ ref: refOf(f, ctx.host), file_key: f.file_key })));
676
+ }
677
+ async function maxGeneration(ctx, f) {
678
+ const g = (await maxGenerations(ctx, [f])).get(fkey(f));
679
+ return g === undefined ? -1 : g;
680
+ }
681
+ /**
682
+ * The source_gone generation of a source that is back (-1: none). The cache says so (C.gone). An
683
+ * entry without the mark — no entry at all (a lost or rebuilt cache), or one from an older save —
684
+ * asks the archive (archGen, see runPass): only a source_gone manifest is ever written past the files
685
+ * row's generation, so an archived generation above it means the source was recorded gone (or a
686
+ * pass died after its archive insert — a re-read from the start is right for that too).
687
+ */
688
+ function goneGenOf(C, H, archGen) {
689
+ if (C && C.gone && Number.isFinite(C.gone.fgen)) return C.gone.fgen;
690
+ if (H && Number.isFinite(archGen) && archGen > Number(H.fgen)) return archGen;
691
+ return -1;
692
+ }
693
+ // How often a cache entry without a gone mark is checked against the archive. An older cache
694
+ // restored over a newer one cannot be told from the house's files rows (a vanish leaves them as they
695
+ // were), so the check runs on a missing entry at once and on every other entry at most daily. Until
696
+ // it runs, a returning source appends into its old generation; the archive still replays it right
697
+ // (the "kept" generation replays the one before it, appends included), and the check then moves it.
698
+ const ARCH_CHECK_MS = 24 * 3600 * 1000;
699
+ /** When this source was last checked against the archive: now, if it was this pass. */
700
+ const archAtOf = (ctx, k, C, now) => (ctx.archChecked && ctx.archChecked.has(k) ? now : (C && C.archAt) || 0);
701
+ /** The trailing fragment: held while it changes; archived once (kind = 'fragment') after 10 minutes unchanged. */
702
+ function fragState(C, fgen, pos, tail, now, size, out, f, ctx) {
703
+ if (!tail || !tail.length) return null;
704
+ const sha = sha256(tail);
705
+ const prev = C && C.frag && C.frag.fgen === fgen && C.frag.pos === pos && C.frag.sha === sha ? C.frag : null;
706
+ const frag = prev ? { ...prev } : { fgen, pos, len: tail.length, sha, since: now, archived: false };
707
+ if (!frag.archived && now - frag.since >= FRAGMENT_MS) {
708
+ out.archive.push(...archiveRows(f, fgen, pos, tail, 'fragment', ctx.host, base.chTs(now)));
709
+ frag.archived = true;
710
+ out.event = out.event || 'fragment';
711
+ }
712
+ return frag;
713
+ }
714
+ /** The previous generation, re-derived from the archive by the current adapter; null when nothing is archived. */
715
+ async function oldGeneration(ctx, f, H, state) {
716
+ const ad = adapterOf(ctx, f);
717
+ const rows = await ctx.house.archiveRecords(refOf(f, ctx.host), f.file_key, Number(H.fgen));
718
+ if (!rows.length) return null;
719
+ const records = recordsFromArchive(rows);
720
+ const fc = fileCtx(f, ctx.host, Number(H.fgen), Number(H.upto), 0, {});
721
+ return ad.derive({ file: fc, records, state: { ...base.initialState(ad.adapter), fb: (state && state.fb) || {} } });
722
+ }
723
+ /** The keys the previous generation produced, re-derived from the archive (draft 5 §5). */
724
+ async function oldGenerationKeys(ctx, f, H, state) {
725
+ const res = await oldGeneration(ctx, f, H, state);
726
+ return res ? liveKeys(res) : (state && state.keys) || { t: [], c: [] };
727
+ }
728
+
729
+ /**
730
+ * A rewrite and an adapter upgrade in one pass, retired as the two passes they stand for: the
731
+ * upgrade over the previous generation, then the rewrite (sessions §4). The previous generation,
732
+ * re-derived by the new adapter, tells the causes apart:
733
+ * - a held key that neither it nor the new generation produces is one the upgrade dropped: a
734
+ * tombstone of the house row;
735
+ * - a key it produces that the new generation does not left the source: a removed version of its
736
+ * re-derived row, at the new rev. (The upgrade alone would first have written that row live at
737
+ * the previous generation's rev, where the house already holds the old adapter's row: a second,
738
+ * different row at one rev, which harness-sessions-sql's principle 4 forbids. It is skipped.)
739
+ * With no previous generation archived the causes cannot be told apart, and nothing is tombstoned.
740
+ * `held`: the house's live keys for the file (fileKeys).
741
+ */
742
+ async function retireBoth(ctx, f, H, state, fc, held, res, rev) {
743
+ const prev = await oldGeneration(ctx, f, H, state);
744
+ if (!prev) return tombstonesFor(ctx, fc, vanishedFrom(held, res), rev, { removed: true });
745
+ const out = await tombstonesFor(ctx, fc, vanishedFrom(held, { turns: [...prev.turns, ...res.turns], calls: [...prev.calls, ...res.calls] }), rev);
746
+ const now = { t: new Set(res.turns.map(base.turnKey)), c: new Set(res.calls.map(base.callKey)) };
747
+ for (const r of prev.turns) if (!now.t.has(base.turnKey(r))) out.turns.push(base.removedOf('turns', r, rev));
748
+ for (const r of prev.calls) if (!now.c.has(base.callKey(r))) out.calls.push(base.removedOf('calls', r, rev));
749
+ return out;
750
+ }
751
+
752
+ // ── sessions ────────────────────────────────────────────────────────────────────────────────
753
+ function cleanTitle(prompt) {
754
+ if (!prompt) return '';
755
+ const t = String(prompt)
756
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
757
+ .replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, '')
758
+ .replace(/<command-(?:name|message|args|contents|stdout|stderr)>[\s\S]*?<\/command-\1>/g, '')
759
+ .replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
760
+ return t.slice(0, 120);
761
+ }
762
+
763
+ /**
764
+ * One `sessions` row from every live files row of the session: metadata + exact totals; rev = Σ files.ver.
765
+ * The 'legacy' row (dedupe-v3.js) is summed like a file: the share of the legacy rows still live.
766
+ */
767
+ function buildSession(harness, sessionId, rows, { host, hostLabel = '', harnessPath = '', incarnation = '' }) {
768
+ const live = rows.filter((r) => !Number(r.is_deleted));
769
+ const n = {}; const usage = new Map(); const tools = new Map(); const days = new Set(); const models = {};
770
+ const agents = new Map();
771
+ let rev = 0n; let started = null; let ended = null;
772
+ const mains = [];
773
+ const attr = new Map();
774
+ let legacyLive = 0; let legacyMeta = {}; let inherits = null;
775
+ for (const r of live) {
776
+ rev += BigInt(r.ver !== undefined ? r.ver : verOf(r));
777
+ const c = typeof r.contrib === 'string' ? parseJson(r.contrib, {}) : (r.contrib || {});
778
+ if (r.role === dedupe.LEGACY_ROLE) {
779
+ legacyLive += (Number(c.legacy && c.legacy.turns) || 0) + (Number(c.legacy && c.legacy.calls) || 0);
780
+ legacyMeta = c.meta || {};
781
+ }
782
+ if (r.role === forks.INHERITED_ROLE && c.inherits) inherits = c.inherits;
783
+ if (r.agent_id && (r.agent_type || r.agent_desc)) {
784
+ const prev = attr.get(r.agent_id) || {};
785
+ // The side file (.meta.json) is the authority on what an agent is; the transcript row second.
786
+ if (r.role === 'side' || !prev.type) attr.set(r.agent_id, { type: r.agent_type || prev.type || '', description: r.agent_desc || prev.description || '' });
787
+ }
788
+ if (r.role === 'side') continue;
789
+ for (const f of ['messages', 'user_messages', 'assistant_messages', 'agent_messages', 'tool_calls', 'input_tokens', 'output_tokens',
790
+ 'cache_read_tokens', 'cache_write_tokens', 'reasoning_tokens', 'user_chars', 'assistant_chars']) n[f] = (n[f] || 0) + (Number(c[f]) || 0);
791
+ for (const u of c.usage || []) {
792
+ const k = `${u.hour}\u0000${u.model}`;
793
+ const v = usage.get(k) || { hour: u.hour, model: u.model, messages: 0, input: 0, output: 0, cache_read: 0, cache_write: 0, reasoning: 0 };
794
+ for (const x of ['messages', 'input', 'output', 'cache_read', 'cache_write', 'reasoning']) v[x] += Number(u[x]) || 0;
795
+ usage.set(k, v);
796
+ }
797
+ for (const t of c.tools || []) {
798
+ const v = tools.get(t.name) || { name: t.name, calls: 0, errors: 0 };
799
+ v.calls += Number(t.calls) || 0; v.errors += Number(t.errors) || 0; tools.set(t.name, v);
800
+ }
801
+ for (const d of c.days || []) days.add(d);
802
+ for (const [m, cnt] of Object.entries(c.models || {})) models[m] = (models[m] || 0) + Number(cnt);
803
+ for (const a of c.agents || []) {
804
+ const v = agents.get(a.agent_id) || { agent_id: a.agent_id, type: '', description: '', workflow: '', messages: 0, first_ts: null };
805
+ v.messages += Number(a.messages) || 0;
806
+ if (a.type && !v.type) v.type = a.type;
807
+ if (a.description && !v.description) v.description = a.description;
808
+ if (a.workflow && !v.workflow) v.workflow = a.workflow;
809
+ if (a.first_ts && (!v.first_ts || a.first_ts < v.first_ts)) v.first_ts = a.first_ts;
810
+ if (!v.prompt && c.meta && c.meta.agent_prompt) v.prompt = c.meta.agent_prompt;
811
+ agents.set(a.agent_id, v);
812
+ }
813
+ if (c.first_ts && (!started || c.first_ts < started)) started = c.first_ts;
814
+ if (c.last_ts && (!ended || c.last_ts > ended)) ended = c.last_ts;
815
+ if (r.role === 'main') mains.push({ r, c });
816
+ }
817
+ mains.sort((a, b) => String(a.c.first_ts || '9').localeCompare(String(b.c.first_ts || '9')));
818
+ const meta = {};
819
+ let links = [];
820
+ for (const { c } of mains) {
821
+ const m = c.meta || {};
822
+ if (Array.isArray(m.links)) links = m.links;
823
+ for (const k of ['folder', 'first_prompt', 'session_id']) if (!meta[k] && m[k]) meta[k] = m[k];
824
+ for (const k of ['git_branch', 'version', 'title', 'ai_title', 'agent_name']) if (m[k]) meta[k] = m[k];
825
+ if (m.agent_names) meta.agent_names = { ...(meta.agent_names || {}), ...m.agent_names };
826
+ }
827
+ // A fork the engine verified by content (forks.js) is a link the store never stated: shown as inferred.
828
+ if (inherits && inherits.evidence === 'inferred' && !links.some((l) => l.origin === inherits.session_id)) {
829
+ links = [...links, { kind: inherits.kind, origin: inherits.session_id, evidence: 'inferred', at: inherits.at || '', through: inherits.through || '' }];
830
+ }
831
+ // What only the live legacy rows know (a transcript that lost its start): a fallback, never an override.
832
+ for (const k of ['folder', 'first_prompt']) if (!meta[k] && legacyMeta[k]) meta[k] = legacyMeta[k];
833
+ for (const v of agents.values()) {
834
+ const a = attr.get(v.agent_id) || {};
835
+ const named = (meta.agent_names || {})[v.agent_id] || {};
836
+ v.type = a.type || v.type || named.type || '';
837
+ v.description = a.description || v.description || named.description || v.prompt || '';
838
+ }
839
+ const agentList = [...agents.values()].sort((a, b) => String(a.first_ts || '').localeCompare(String(b.first_ts || '')) || a.agent_id.localeCompare(b.agent_id));
840
+ // An hour whose usage a fork's copies netted to nothing (forks.js) is no usage at all.
841
+ const usageList = [...usage.values()].filter((u) => u.messages || u.input || u.output || u.cache_read || u.cache_write || u.reasoning)
842
+ .sort((a, b) => a.hour.localeCompare(b.hour) || a.model.localeCompare(b.model));
843
+ const toolList = [...tools.values()].sort((a, b) => a.name.localeCompare(b.name));
844
+ const dominant = Object.entries(models).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
845
+ const folder = meta.folder || '';
846
+ const row = {
847
+ harness, session_id: sessionId, incarnation: String(incarnation || ''), harness_version: meta.version || '', harness_path: String(harnessPath || ''), host, host_label: String(hostLabel || ''),
848
+ name: meta.title || meta.ai_title || cleanTitle(meta.first_prompt) || '', mode: harness === 'claude-code' ? 'claude' : harness,
849
+ folder, project: folder ? folder.replace(/\/+$/, '').split('/').pop() : '', git_branch: meta.git_branch || '',
850
+ created_at: started, updated_at: ended, started: started || EPOCH_TS, ended: ended || started || EPOCH_TS,
851
+ messages: n.messages || 0, user_messages: n.user_messages || 0, assistant_messages: n.assistant_messages || 0,
852
+ agent_messages: n.agent_messages || 0, tool_calls: n.tool_calls || 0,
853
+ input_tokens: n.input_tokens || 0, output_tokens: n.output_tokens || 0, cache_read_tokens: n.cache_read_tokens || 0,
854
+ cache_write_tokens: n.cache_write_tokens || 0, reasoning_tokens: n.reasoning_tokens || 0,
855
+ user_chars: n.user_chars || 0, assistant_chars: n.assistant_chars || 0,
856
+ first_prompt: meta.first_prompt || '',
857
+ models: Object.keys(models).filter((m) => models[m] > 0).sort(),
858
+ dominant_model: dominant && dominant[1] > 0 ? dominant[0] : '',
859
+ days: [...days].sort(),
860
+ // Nested columns travel as dotted keys: the nested-object form is silently dropped.
861
+ 'usage.hour': usageList.map((u) => u.hour), 'usage.model': usageList.map((u) => u.model),
862
+ 'usage.messages': usageList.map((u) => u.messages), 'usage.input': usageList.map((u) => u.input),
863
+ 'usage.output': usageList.map((u) => u.output), 'usage.cache_read': usageList.map((u) => u.cache_read),
864
+ 'usage.cache_write': usageList.map((u) => u.cache_write), 'usage.reasoning': usageList.map((u) => u.reasoning),
865
+ 'tools.name': toolList.map((t) => t.name), 'tools.calls': toolList.map((t) => t.calls), 'tools.errors': toolList.map((t) => t.errors),
866
+ 'agents.agent_id': agentList.map((a) => a.agent_id), 'agents.type': agentList.map((a) => a.type),
867
+ 'agents.description': agentList.map((a) => a.description), 'agents.workflow': agentList.map((a) => a.workflow),
868
+ 'agents.messages': agentList.map((a) => a.messages), 'agents.first_ts': agentList.map((a) => a.first_ts || EPOCH_TS),
869
+ // 'mixed': captured rows (tokens per response) next to legacy rows still live (tokens per record).
870
+ 'links.kind': links.map((l) => String(l.kind || '')), 'links.origin': links.map((l) => String(l.origin || '')),
871
+ 'links.origin_store': links.map((l) => String(l.origin_store || '')), 'links.evidence': links.map((l) => String(l.evidence || '')),
872
+ 'links.at': links.map((l) => String(l.at || '')), 'links.through': links.map((l) => String(l.through || '')),
873
+ usage_basis: legacyLive ? 'mixed' : 'per_response', origin: 'ship', rev: String(rev), is_deleted: 0, removed: 0,
874
+ };
875
+ return row;
876
+ }
877
+
878
+ /**
879
+ * A source this machine captured and no longer has — the harness deleted the session's file. The
880
+ * house keeps what it captured (harness-sessions-sql §4.1 policy "kept"; the archive outlives the store),
881
+ * and the event is recorded once, as a manifest opening a new generation with source_gone. Only a
882
+ * file under a root this pass read, whose path is really gone (ENOENT), counts: a missing or
883
+ * unreadable root, or an adapter that no longer lists a file still on disk, says nothing.
884
+ */
885
+ async function recordVanished(ctx, sessions, stats) {
886
+ const seen = new Set();
887
+ for (const S of sessions.values()) {
888
+ for (const f of S.files.values()) {
889
+ const k = fkey(f); seen.add(k);
890
+ // Older cache entries learn their path from the file that is still here.
891
+ const C = ctx.cache.get(k);
892
+ if (C && C.path !== f.path) { C.path = f.path; ctx.cache.dirty = true; }
893
+ }
894
+ }
895
+ // The root directories read this pass. Two roots can share a store (Codex sessions/ and
896
+ // archived_sessions/), so a file counts only under a directory that was itself read.
897
+ const dirs = (ctx.roots || []).filter((r) => r.dir && isDir(r.dir)).map((r) => ({ harness: r.harness, dir: path.resolve(r.dir) + path.sep }));
898
+ const cands = [];
899
+ for (const [k, C] of Object.entries(ctx.cache.data.files)) {
900
+ if (seen.has(k) || !C || C.gone || !C.path) continue;
901
+ const [harness, harness_path, session_id] = k.split('\t');
902
+ const p = path.resolve(C.path);
903
+ if (!dirs.some((d) => d.harness === harness && p.startsWith(d.dir)) || !enoent(C.path)) continue;
904
+ const rest = k.split('\t').slice(3);
905
+ // skey omits an empty incarnation: three fields, then the file key (which has no tab).
906
+ const incarnation = rest.length > 1 ? rest[0] : ''; const file_key = rest[rest.length - 1];
907
+ cands.push({ k, C, f: { harness, harness_path, session_id, incarnation, file_key } });
908
+ }
909
+ // The paths learned above are saved now, whatever a later step of the pass does.
910
+ ctx.cache.save();
911
+ if (!cands.length) return;
912
+ const H = new Map();
913
+ for (const r of await ctx.house.filesRows(cands.map((c) => refOf(c.f, ctx.host)))) H.set(fkey(r), r);
914
+ const gens = await maxGenerations(ctx, cands.map((c) => c.f));
915
+ const now = ctx.now(); const rows = []; const marks = [];
916
+ for (const c of cands) {
917
+ const h = H.get(c.k);
918
+ // Nothing captured (or already deleted): no history to keep, nothing to record.
919
+ if (!h || Number(h.is_deleted) || h.role === dedupe.LEGACY_ROLE || h.role === forks.INHERITED_ROLE) { marks.push([c.C, { fgen: null, at: now }]); continue; }
920
+ const g = gens.get(c.k);
921
+ const fgen = Math.max(Number(h.fgen), g === undefined ? -1 : g) + 1;
922
+ rows.push(manifestRow(c.f, fgen, { progress: 0, continuity: 'reset', units: [], source_gone: true, policy: 'kept' }, ctx.host, base.chTs(now)));
923
+ marks.push([c.C, { fgen, at: now }]);
924
+ }
925
+ await ctx.house.insert('records', rows);
926
+ // Marked only once the manifests are in the house: a failed insert leaves them to the next pass.
927
+ for (const [C, gone] of marks) C.gone = gone;
928
+ ctx.cache.dirty = true;
929
+ ctx.cache.save();
930
+ stats.vanished = (stats.vanished || 0) + rows.length;
931
+ stats.archiveRows += rows.length;
932
+ }
933
+ function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
934
+ function enoent(p) { try { fs.lstatSync(p); return false; } catch (e) { return e && e.code === 'ENOENT'; } }
935
+
936
+ // ── the pass ────────────────────────────────────────────────────────────────────────────────
937
+ function isChanged(f, C, ad, now) {
938
+ if (!C || C.gone) return true;
939
+ const st = f.stat;
940
+ if (C.size !== st.size || C.mtimeMs !== st.mtimeMs || C.ino !== st.ino) return true;
941
+ if (f.role !== 'side' && C.adapter_ver !== ad.deriveVersion) return true;
942
+ if (C.more) return true;
943
+ if (C.frag && !C.frag.archived && now - C.frag.since >= FRAGMENT_MS) return true;
944
+ return false;
945
+ }
946
+
947
+ /**
948
+ * One capture pass. `ctx`: { house, cache, host, roots, adapters, now, log, budget, dedupe (default on), sweep }.
949
+ * Returns counts. Never deletes, never mutates: every change is an insert at a higher rev.
950
+ */
951
+ async function runPass(ctx) {
952
+ if (ctx.layout === 'profile') return profilePass(ctx);
953
+ ctx.adapters = ctx.adapters || defaultAdapters();
954
+ ctx.now = ctx.now || Date.now;
955
+ ctx.validated = ctx.validated || new Set();
956
+ const log = ctx.log || (() => {});
957
+ const now = ctx.now();
958
+ const report = {};
959
+ const sessions = ctx.sessions || discover(ctx.roots, ctx.adapters, report);
960
+ if (ctx.adapterHarnesses && ctx.adapterHarnesses.length && ctx.adapterStores) await routeAdapterSessions(ctx, sessions);
961
+ const stats = { sessions: sessions.size, files: 0, changed: 0, written: 0, bytes: 0, archiveRows: 0, turns: 0, calls: 0,
962
+ tombstones: 0, sessionRows: 0, waits: 0, rewrites: 0, upgrades: 0, more: 0, duplicates: report.duplicates || 0, events: {},
963
+ deduped: 0, legacyTombstoned: 0, legacyKept: 0, legacyImports: 0 };
964
+ ctx.agentFileIds = (f) => {
965
+ const S = sessions.get(skey(f.harness, f.session_id, f.harness_path, f.incarnation));
966
+ return S ? [...S.files.values()].filter((x) => x.role === 'agent').map((x) => x.agent_id) : [];
967
+ };
968
+
969
+ let queue = [];
970
+ for (const S of sessions.values()) {
971
+ const files = [...S.files.values()];
972
+ stats.files += files.length;
973
+ const changed = files.filter((f) => isChanged(f, ctx.cache.get(fkey(f)), adapterOf(ctx, f), now));
974
+ if (!changed.length) continue;
975
+ const newest = Math.max(...files.map((f) => f.stat.mtimeMs));
976
+ const oldest = Math.min(...files.map((f) => f.stat.mtimeMs));
977
+ queue.push({ S, changed, live: now - newest < LIVE_MS, newest, oldest });
978
+ }
979
+ // Backfill order: live sessions first, then oldest first.
980
+ queue.sort((a, b) => (b.live - a.live) || (a.live ? b.newest - a.newest : a.oldest - b.oldest));
981
+ stats.changed = queue.reduce((s, q) => s + q.changed.length, 0);
982
+
983
+ let budget = ctx.budget || PASS_BUDGET;
984
+ const forkItems = []; // sessions whose rows moved this pass: fork billing looks at them after the groups
985
+ while (queue.length && budget > 0) {
986
+ if (ctx.stopped && ctx.stopped()) { stats.stopped = true; break; }
987
+ const group = [];
988
+ let est = 0;
989
+ while (queue.length && (est < GROUP_BYTES || !group.length)) {
990
+ const q = queue.shift();
991
+ group.push(q);
992
+ for (const f of q.changed) { const C = ctx.cache.get(fkey(f)); est += Math.max(0, f.stat.size - (C ? C.upto : 0)); }
993
+ }
994
+ const H = new Map();
995
+ const bySession = new Map();
996
+ for (const r of await ctx.house.filesRows(group.map((q) => refOf(q.S, ctx.host)))) {
997
+ H.set(fkey(r), r);
998
+ const sk = skey(r.harness, r.session_id, r.harness_path, r.incarnation);
999
+ if (!bySession.has(sk)) bySession.set(sk, new Map());
1000
+ bySession.get(sk).set(r.file_key, r);
1001
+ }
1002
+ // Whether a source was recorded gone, from the archive, for an entry that cannot say (goneGenOf):
1003
+ // one query per group, only for sources the house has a files row for.
1004
+ const ask = group.flatMap((q) => q.changed.filter((f) => {
1005
+ const C = ctx.cache.get(fkey(f));
1006
+ return H.has(fkey(f)) && (!C || (!C.gone && now - (C.archAt || 0) >= ARCH_CHECK_MS));
1007
+ }));
1008
+ ctx.archChecked = new Set(ask.map(fkey));
1009
+ ctx.archGen = await maxGenerations(ctx, ask);
1010
+ const plans = [];
1011
+ const again = [];
1012
+ for (const q of group) {
1013
+ const more = [];
1014
+ for (const f of q.changed) {
1015
+ const k = fkey(f);
1016
+ const plan = await captureFile(ctx, f, H.get(k) || null, ctx.cache.get(k), Math.min(Math.max(budget, 1), GROUP_BYTES));
1017
+ budget -= plan.bytes;
1018
+ plans.push({ q, f, k, plan });
1019
+ if (plan.event) stats.events[plan.event.split(':')[0]] = (stats.events[plan.event.split(':')[0]] || 0) + 1;
1020
+ if (plan.wait) stats.waits++;
1021
+ if (plan.more) { more.push(f); stats.more++; }
1022
+ }
1023
+ if (more.length) again.push({ ...q, changed: more });
1024
+ }
1025
+ // archive → turns/calls → files → sessions → cache
1026
+ const all = (key) => plans.flatMap((p) => p.plan[key] || []);
1027
+ const archive = all('archive'); const turns = all('turns'); const calls = all('calls');
1028
+ const files = plans.map((p) => p.plan.files).filter(Boolean);
1029
+ await ctx.house.insert('records', archive);
1030
+ await ctx.house.insert('turns', turns);
1031
+ await ctx.house.insert('calls', calls);
1032
+ await ctx.house.insert('files', files);
1033
+ const touched = new Set();
1034
+ for (const p of plans) if (!p.plan.wait) touched.add(skey(p.f.harness, p.f.session_id, p.f.harness_path, p.f.incarnation));
1035
+ // The sessions whose row can have changed: a files row written, or a file cursor that moved
1036
+ // since the cache last recorded it. A file whose bytes changed without a complete new line (an
1037
+ // editor mid-way through writing a record) or whose mtime alone moved makes neither, and its
1038
+ // session row is left alone. The cursor test keeps a pass that died between the files insert
1039
+ // and the sessions insert recoverable: the cache was not saved, so the cursor still differs.
1040
+ const moved = new Set();
1041
+ for (const p of plans) {
1042
+ if (p.plan.wait) continue;
1043
+ const C = ctx.cache.get(p.k);
1044
+ const N = p.plan.cache;
1045
+ if (p.plan.files || !C || (N && (Number(N.upto) !== Number(C.upto) || Number(N.fgen) !== Number(C.fgen)))) moved.add(skey(p.f.harness, p.f.session_id, p.f.harness_path, p.f.incarnation));
1046
+ }
1047
+ for (const fr of files) {
1048
+ const sk = skey(fr.harness, fr.session_id, fr.harness_path, fr.incarnation);
1049
+ if (!bySession.has(sk)) bySession.set(sk, new Map());
1050
+ bySession.get(sk).set(fr.file_key, fr);
1051
+ }
1052
+ // Dedupe, once per session, when its capture has caught up: no file left for a later
1053
+ // group (`again`) and none waiting on a lagging copy. Before the sessions row, so the row
1054
+ // counts the legacy rows that stay live — and none that were just tombstoned.
1055
+ if (ctx.dedupe !== false) {
1056
+ for (const q of group) {
1057
+ const sk = skey(q.S.harness, q.S.session_id, q.S.harness_path, q.S.incarnation);
1058
+ const have = bySession.get(sk);
1059
+ if (!touched.has(sk) || !have || again.some((x) => x.S === q.S) || plans.some((p) => p.q === q && p.plan.wait)) continue;
1060
+ if ([...have.values()].some((r) => r.role === dedupe.LEGACY_ROLE) || ctx.cache.data.deduped[sk] === 'none') continue;
1061
+ // Only against a captured transcript: matched against nothing, every legacy row would be
1062
+ // kept under a marker, and the transcript captured later would duplicate them.
1063
+ if (![...have.values()].some((r) => r.role === 'main' || r.role === 'agent')) continue;
1064
+ const d = await dedupeSession(ctx, refOf(q.S, ctx.host), stats);
1065
+ if (d.filesRow) { have.set(d.filesRow.file_key, d.filesRow); moved.add(sk); }
1066
+ }
1067
+ }
1068
+ // A session's row is written only when what it says changed. A pass that saw a file change on
1069
+ // disk used to rewrite its session whatever it found: on the dev house ~1,000 session rows a day
1070
+ // against ~2,000 turns and 133 files rows (2026-10-15..18). Sessions without a moved file are
1071
+ // skipped; a moved cursor over records that make no rows (progress, snapshots) is caught
1072
+ // by the digest. `rev` (Σ files.ver) is left out of the comparison — it moves with every byte
1073
+ // — so the stored row keeps its older rev until its content changes, and the next real write
1074
+ // still carries a higher one.
1075
+ const sessionRows = [];
1076
+ const sessionDigests = [];
1077
+ const known = ctx.cache.data.sessions || (ctx.cache.data.sessions = {});
1078
+ for (const q of group) {
1079
+ const sk = skey(q.S.harness, q.S.session_id, q.S.harness_path, q.S.incarnation);
1080
+ if (!moved.has(sk) || !bySession.has(sk)) continue;
1081
+ const row = buildSession(q.S.harness, q.S.session_id, [...bySession.get(sk).values()], { host: ctx.host, hostLabel: ctx.hostLabel, harnessPath: q.S.harness_path, incarnation: q.S.incarnation });
1082
+ const dg = sessionDigest(row);
1083
+ if (known[sk] === dg) { stats.sessionsUnchanged = (stats.sessionsUnchanged || 0) + 1; continue; }
1084
+ sessionRows.push(row); sessionDigests.push([sk, dg]);
1085
+ }
1086
+ await ctx.house.insert('sessions', sessionRows);
1087
+ for (const [sk, dg] of sessionDigests) known[sk] = dg;
1088
+ // Fork billing waits for the end of the pass; its work is saved with this group's cursors, so a
1089
+ // pass that fails before it is done leaves the work for the next pass, not lost with the cursors moved.
1090
+ const pendingForks = ctx.cache.data.forkPending || (ctx.cache.data.forkPending = {});
1091
+ for (const q of group) {
1092
+ const sk = skey(q.S.harness, q.S.session_id, q.S.harness_path, q.S.incarnation);
1093
+ if (!moved.has(sk) || !bySession.has(sk) || forks.NATIVE.has(q.S.harness)) continue;
1094
+ forkItems.push({ ref: refOf(q.S, ctx.host), rows: [...bySession.get(sk).values()] });
1095
+ pendingForks[sk] = { ...(pendingForks[sk] || {}), ref: refOf(q.S, ctx.host) };
1096
+ }
1097
+ // The lock lost while this group ran: its rows are in, but the cache file is the new holder's now.
1098
+ // Left unsaved, the next pass reads these files' state from the house.
1099
+ // The group goes back whole (its continuations are the same sessions): the next pass redoes it from the house.
1100
+ if (ctx.stopped && ctx.stopped()) { stats.stopped = true; queue = [...group, ...queue]; break; }
1101
+ for (const p of plans) if (p.plan.cache) ctx.cache.set(p.k, p.plan.cache);
1102
+ ctx.cache.save();
1103
+ stats.written += files.length;
1104
+ stats.bytes += plans.reduce((s, p) => s + p.plan.bytes, 0);
1105
+ stats.archiveRows += archive.length; stats.turns += turns.length; stats.calls += calls.length;
1106
+ stats.tombstones += plans.reduce((s, p) => s + (p.plan.tombstones || 0), 0);
1107
+ stats.sessionRows += sessionRows.length;
1108
+ stats.rewrites += plans.filter((p) => String(p.plan.event || '').startsWith('rewrite')).length;
1109
+ stats.upgrades += plans.filter((p) => p.plan.event === 'upgrade').length;
1110
+ queue = [...again, ...queue];
1111
+ }
1112
+ stats.more = queue.length;
1113
+ // A pass whose lock was taken over leaves the fork billing and the maintenance, and the files they
1114
+ // save, to the pass that holds it now.
1115
+ if (stats.stopped) { log(stats); return stats; }
1116
+ // Fork billing once (forks.js): an 'inherited' row on each verified fork, and its sessions row rebuilt.
1117
+ // The work list is the saved one (this pass's sessions, and any a failed pass left), cleared only
1118
+ // once the sessions rows are written; a rebuild owed is saved before the insert that pays it.
1119
+ const pendingForks = ctx.cache.data.forkPending || {};
1120
+ if (ctx.forks !== false && Object.keys(pendingForks).length) {
1121
+ const skOf = (r) => skey(r.harness, r.session_id, r.harness_path, r.incarnation);
1122
+ const rowsOf = new Map(forkItems.map((x) => [skOf(x.ref), x.rows]));
1123
+ // Owed work (a row saved, a rebuild owed) is paid first, before anything is verified again, so a
1124
+ // verification that fails cannot hold back a rebuild already owed; then the rest is verified,
1125
+ // and what that owes is paid in the same pass.
1126
+ const payOwed = async () => {
1127
+ const owed = Object.entries(pendingForks).filter(([, p]) => p.rebuild);
1128
+ if (!owed.length) return;
1129
+ // Inserting a saved row again is harmless: the same key at the same ver.
1130
+ const rowsToInsert = owed.map(([, p]) => p.row).filter(Boolean);
1131
+ if (rowsToInsert.length) await ctx.house.insert('files', rowsToInsert);
1132
+ const rebuild = owed.map(([, p]) => p.ref);
1133
+ const by = new Map();
1134
+ for (const r of await ctx.house.filesRows(rebuild)) {
1135
+ const sk = skey(r.harness, r.session_id, r.harness_path, r.incarnation);
1136
+ if (!by.has(sk)) by.set(sk, []);
1137
+ by.get(sk).push(r);
1138
+ }
1139
+ const known = ctx.cache.data.sessions || (ctx.cache.data.sessions = {});
1140
+ const rows = [];
1141
+ for (const ref of rebuild) {
1142
+ const sk = skey(ref.harness, ref.session_id, ref.harness_path, ref.incarnation);
1143
+ if (!by.has(sk)) continue;
1144
+ const row = buildSession(ref.harness, ref.session_id, by.get(sk), { host: ctx.host, hostLabel: ctx.hostLabel, harnessPath: ref.harness_path, incarnation: ref.incarnation });
1145
+ rows.push(row); known[sk] = sessionDigest(row);
1146
+ }
1147
+ await ctx.house.insert('sessions', rows);
1148
+ stats.sessionRows += rows.length;
1149
+ for (const [sk] of owed) delete pendingForks[sk];
1150
+ ctx.cache.dirty = true; ctx.cache.save();
1151
+ };
1152
+ await payOwed();
1153
+ const work = Object.entries(pendingForks).filter(([, p]) => !p.rebuild);
1154
+ if (work.length) {
1155
+ const { writes, deferred } = await forks.forkPass(ctx, work.map(([sk, p]) => ({ ref: p.ref, rows: rowsOf.get(sk) })), stats, ctx.forkBudget ? { budget: ctx.forkBudget } : {});
1156
+ // Saved before the insert: the rows and the rebuild they owe outlive any failure from here on.
1157
+ const later = new Set(deferred.map(skOf));
1158
+ for (const [sk] of work) if (!later.has(sk)) delete pendingForks[sk];
1159
+ for (const w of writes) pendingForks[skOf(w.ref)] = { ref: w.ref, row: w.row, rebuild: true };
1160
+ for (const ref of deferred) pendingForks[skOf(ref)] = pendingForks[skOf(ref)] || { ref };
1161
+ ctx.cache.dirty = true; ctx.cache.save();
1162
+ await payOwed();
1163
+ }
1164
+ }
1165
+ if (ctx.vanished !== false) await recordVanished(ctx, sessions, stats);
1166
+ // Files no pass sees on disk any more are re-derived from the archive after an adapter upgrade.
1167
+ // One small query per harness; at start, then every six hours.
1168
+ if (ctx.sweep !== false && !(ctx.sweptAt && now - ctx.sweptAt < 6 * 3600 * 1000)) {
1169
+ await upgradeSweep(ctx, sessions, stats);
1170
+ ctx.sweptAt = now;
1171
+ }
1172
+ await supersedeBridge(ctx, sessions, stats);
1173
+ // Sessions captured before they could be deduped (an older build, a conversion that ran after
1174
+ // the capture): at start, then every six hours — sooner while some are still pending.
1175
+ if (ctx.dedupe !== false && ctx.sweep !== false && !(ctx.dedupeSweptAt && now - ctx.dedupeSweptAt < 6 * 3600 * 1000)) {
1176
+ if (await dedupeSweep(ctx, sessions, stats)) ctx.dedupeSweptAt = now;
1177
+ }
1178
+ if (ctx.redupe && ctx.dedupe !== false) stats.redupe = await redupe(ctx, sessions, stats);
1179
+ // Tombstone cleanup: daily once no session waits for its dedupe, or when asked (--cleanup).
1180
+ if (ctx.cleanup !== false && (ctx.cleanupNow || (ctx.dedupeSweptAt && !(ctx.cleanedAt && now - ctx.cleanedAt < 24 * 3600 * 1000)))) {
1181
+ try { stats.cleanup = await require('./maintenance').tombstoneCleanup(ctx.house, { force: !!ctx.cleanupNow }); } catch (e) { stats.cleanup = [{ error: e.message.split('\n')[0] }]; }
1182
+ ctx.cleanedAt = now; ctx.cleanupNow = false;
1183
+ }
1184
+ // The harnesses read through the harness-session-adapters package (MEMHOUSE_ADAPTERS): their roots left the
1185
+ // file capture in applyAdapters; one store at a time here.
1186
+ if (ctx.adapterStores && ctx.adapterStores.length) {
1187
+ try { stats.adapters = await adapterPass(ctx, stats); } catch (e) { stats.adapters = [{ error: e.message.split('\n')[0] }]; }
1188
+ }
1189
+ if (ctx.bridgeStats) { stats.bridge = ctx.bridgeStats; ctx.bridgeStats = null; }
1190
+ if (ctx.normaliseStats) { stats.normalise = ctx.normaliseStats; ctx.normaliseStats = null; }
1191
+ // What the maintenance learned (recordVanished's paths, supersedeBridge's marks) is saved before
1192
+ // the next pass reads the file again (reloadCache).
1193
+ if (ctx.cache) ctx.cache.save();
1194
+ log(stats);
1195
+ return stats;
1196
+ }
1197
+
1198
+ /**
1199
+ * Adapter upgrade (draft 5 §5): a file whose `files` row was derived by an older adapter_ver and
1200
+ * that this pass did not see on disk is re-derived FROM THE ARCHIVE, so a fix reaches sessions
1201
+ * whose transcripts were deleted long ago.
1202
+ */
1203
+ /**
1204
+ * What upgradeSweep looks at: each store with its own adapter (bridge, log), and each other harness
1205
+ * whole (the native ones). A log adapter re-derives only files a log adapter wrote (adapter_ver at
1206
+ * or above the floor): bridge-era files are a different format, and re-derived by the log derive
1207
+ * they would yield nothing — every row tombstoned, including sessions the user has since deleted.
1208
+ */
1209
+ function adapterScopes(ctx) {
1210
+ const floor = require('../../editors/sessions-log').LOG_VERSION_FLOOR;
1211
+ const scopes = []; const covered = new Set();
1212
+ for (const [k, ad] of ctx.storeAdapters || []) {
1213
+ const i = k.indexOf('\t');
1214
+ scopes.push({ harness: k.slice(0, i), harnessPath: k.slice(i + 1), ad, minVer: ad.deriveVersion >= floor ? floor : 0 });
1215
+ covered.add(k.slice(0, i));
1216
+ }
1217
+ for (const [harness, ad] of Object.entries(ctx.adapters)) if (!covered.has(harness)) scopes.push({ harness, harnessPath: null, ad, minVer: 0 });
1218
+ return scopes;
1219
+ }
1220
+ async function upgradeSweep(ctx, sessions, stats) {
1221
+ for (const { harness, harnessPath, ad, minVer } of adapterScopes(ctx)) {
1222
+ let stale = [];
1223
+ try { stale = await ctx.house.staleFiles(harness, ad.deriveVersion, 200, ctx.host, { harnessPath, minVer }); } catch { continue; }
1224
+ const pending = stale.filter((r) => {
1225
+ const S = sessions.get(skey(r.harness, r.session_id, r.harness_path, r.incarnation));
1226
+ // The legacy row is no source file: nothing to re-derive (an empty derive would tombstone the capture).
1227
+ return r.role !== 'side' && r.role !== dedupe.LEGACY_ROLE && r.role !== forks.INHERITED_ROLE && !(S && S.files.has(r.file_key));
1228
+ });
1229
+ if (!pending.length) continue;
1230
+ const sessionsTouched = new Map();
1231
+ for (const H of pending) {
1232
+ const f = { harness, session_id: H.session_id, harness_path: H.harness_path, incarnation: H.incarnation || '', file_key: H.file_key, agent_id: H.agent_id, role: H.role, root: '', workflow: '' };
1233
+ const ref = refOf(f, ctx.host);
1234
+ const recs = recordsFromArchive(await ctx.house.archiveRecords(ref, H.file_key, Number(H.fgen)))
1235
+ .filter((r) => r.end <= Number(H.upto));
1236
+ const state = parseJson(H.derive_state, null);
1237
+ const fc = fileCtx(f, ctx.host, Number(H.fgen), Number(H.upto), 0, { agent_type: H.agent_type, agent_desc: H.agent_desc });
1238
+ const res = ad.derive({ file: fc, records: recs, state: { ...base.initialState(ad.adapter), fb: (state && state.fb) || {} } });
1239
+ const S = sessions.get(skey(harness, H.session_id, H.harness_path, H.incarnation));
1240
+ const agentIds = S ? [...S.files.values()].filter((x) => x.role === 'agent').map((x) => x.agent_id) : [];
1241
+ const tomb = await tombstonesFor(ctx, fc, vanishedFrom(await ctx.house.fileKeys(ref, f.agent_id || '', agentIds), res), String(base.revOf(H.fgen, H.upto)));
1242
+ await ctx.house.insert('turns', [...res.turns, ...tomb.turns]);
1243
+ await ctx.house.insert('calls', [...res.calls, ...tomb.calls]);
1244
+ const row = { ...H, reparse_from: res.reparse_from, derive_state: JSON.stringify(res.state), adapter_ver: ad.deriveVersion, contrib: JSON.stringify(res.contrib), host: ctx.host };
1245
+ delete row.ver;
1246
+ await ctx.house.insert('files', [row]);
1247
+ sessionsTouched.set(skey(harness, H.session_id, H.harness_path, H.incarnation), ref);
1248
+ stats.upgrades++; stats.tombstones += tomb.turns.length + tomb.calls.length;
1249
+ }
1250
+ for (const ref of sessionsTouched.values()) {
1251
+ const rows = (await ctx.house.filesRows([ref]));
1252
+ await ctx.house.insert('sessions', [buildSession(harness, ref.session_id, rows, { host: ctx.host, hostLabel: ctx.hostLabel, harnessPath: ref.harness_path, incarnation: ref.incarnation })]);
1253
+ stats.sessionRows++;
1254
+ }
1255
+ }
1256
+ }
1257
+
1258
+ /**
1259
+ * A normaliser that keys a harness's sessions by incarnation (Goose: created_at, as id reuse cannot be
1260
+ * detected) replaces the bridge's `<id>` (incarnation '') with `<id>@<incarnation>`: another key, so
1261
+ * the switch alone would leave the bridge-era rows live beside the log's. Once a logged store holds
1262
+ * an id only under incarnations, the bridge-era session of that id in that store is a duplicate: its
1263
+ * files rows and its sessions row are deleted, and its rows retired (retireBridged): tombstoned where
1264
+ * the normaliser holds them again, kept as removed versions where it does not. Only what a bridge
1265
+ * wrote (adapter_ver below the log floor); a session the normaliser does not list is never touched.
1266
+ * Once per session (cache), after the normaliser's sessions of that id are captured to their end.
1267
+ */
1268
+ async function supersedeBridge(ctx, sessions, stats) {
1269
+ if (!ctx.storeAdapters || !ctx.storeAdapters.size) return;
1270
+ const floor = require('../../editors/sessions-log').LOG_VERSION_FLOOR;
1271
+ const done = ctx.cache.data.superseded || (ctx.cache.data.superseded = {});
1272
+ const want = new Map(); const logged = new Map();
1273
+ for (const S of sessions.values()) {
1274
+ if (!S.incarnation) continue;
1275
+ const ad = adapterOf(ctx, S);
1276
+ if (!ad || ad.deriveVersion < floor) continue;
1277
+ const k = skey(S.harness, S.session_id, S.harness_path, '');
1278
+ if (sessions.has(k) || done[k]) continue;
1279
+ want.set(k, { harness: S.harness, host: ctx.host, harness_path: S.harness_path, session_id: S.session_id, incarnation: '' });
1280
+ if (!logged.has(k)) logged.set(k, []);
1281
+ logged.get(k).push(S);
1282
+ }
1283
+ // Until then a bridge row could miss its twin and be kept as removed beside the copy that follows.
1284
+ for (const [k, list] of logged) if (!list.every((S) => caughtUp(ctx, S))) want.delete(k);
1285
+ if (!want.size) return;
1286
+ const byKey = new Map();
1287
+ for (const H of await ctx.house.filesRows([...want.values()])) {
1288
+ if (Number(H.is_deleted) || (H.incarnation || '') !== '' || H.role === dedupe.LEGACY_ROLE || H.role === forks.INHERITED_ROLE) continue;
1289
+ const k = skey(H.harness, H.session_id, H.harness_path, '');
1290
+ if (!byKey.has(k)) byKey.set(k, []);
1291
+ byKey.get(k).push(H);
1292
+ }
1293
+ for (const [k, ref] of want) {
1294
+ const files = byKey.get(k) || [];
1295
+ if (!files.length || files.some((H) => Number(H.adapter_ver) >= floor)) { done[k] = 'none'; ctx.cache.dirty = true; continue; }
1296
+ // Each source's new generation sits past the highest the archive holds (recordVanished may have
1297
+ // written a "kept" one past the files row in this same pass), never in it.
1298
+ const arch = await maxGenerations(ctx, files);
1299
+ const genOf = (H) => Math.max(Number(H.fgen), arch.has(fkey(H)) ? arch.get(fkey(H)) : -1) + 1;
1300
+ const rev = String(base.revOf(Math.max(...files.map(genOf)), 0));
1301
+ const tomb = await retireBridged(ctx, ref, logged.get(k), rev);
1302
+ // Calls first: a retry decides a call by its message, which must still be live then.
1303
+ await ctx.house.insert('calls', tomb.calls);
1304
+ await ctx.house.insert('turns', tomb.turns);
1305
+ // The bridge-era sources leave this key (their content is the normaliser's now, under incarnation
1306
+ // keys): recorded as source_gone with policy "removed" — the rows were tombstoned or removed live,
1307
+ // and a replay of the archive gives these sources nothing.
1308
+ await ctx.house.insert('records', files.map((H) => manifestRow(H, genOf(H), { progress: 0, continuity: 'reset', units: [], source_gone: true, policy: 'removed' }, ctx.host, base.chTs(ctx.now()))));
1309
+ // The sessions row before the files rows: until those are deleted, a stopped pass is done again.
1310
+ // A session whose rows were all duplicates goes; one that keeps removed rows stays, removed.
1311
+ // Kept rows from this run, or from before it (a retry after a stop, history removed earlier).
1312
+ const kept = [...tomb.turns, ...tomb.calls].some((r) => Number(r.removed)) || await ctx.house.hasRemoved(ref);
1313
+ const gone = buildSession(ref.harness, ref.session_id, kept ? files : [], { host: ctx.host, hostLabel: ctx.hostLabel, harnessPath: ref.harness_path, incarnation: '' });
1314
+ if (kept) gone.removed = 1; else gone.is_deleted = 1;
1315
+ gone.rev = String(files.reduce((a, H) => a + BigInt(base.revOf(genOf(H), 0)), 0n));
1316
+ await ctx.house.insert('sessions', [gone]);
1317
+ await ctx.house.insert('files', files.map((H) => { const r = { ...H, fgen: genOf(H), upto: 0, is_deleted: 1 }; delete r.ver; return r; }));
1318
+ done[k] = 'done'; ctx.cache.dirty = true;
1319
+ stats.superseded = (stats.superseded || 0) + 1;
1320
+ stats.tombstones += tomb.turns.length + tomb.calls.length;
1321
+ }
1322
+ }
1323
+
1324
+ /**
1325
+ * The live rows of a superseded bridge-era session, retired at `rev`. A bridge row is one message
1326
+ * (0.18's text: its text parts joined by '\n') or one call. A message the normaliser's sessions of
1327
+ * that id (`logged`) hold again — the same role and text, in a message no newer than the newest the
1328
+ * bridge holds — is a duplicate and tombstoned; so is a call of a tombstoned message that they hold
1329
+ * again (the same tool and arguments, no newer). Each of their rows is a twin once. Any other row
1330
+ * left the source before the switch (a cleared session typed again, a reused id), or is in a shape
1331
+ * the normaliser does not reproduce: kept as a removed version (harness-sessions-sql principle 3). A
1332
+ * normaliser row with no time is no twin.
1333
+ */
1334
+ async function retireBridged(ctx, ref, logged, rev) {
1335
+ const v = vanishedFrom(await ctx.house.fileKeys(ref, '', []), { turns: [], calls: [] });
1336
+ const turns = v.turns.length ? await ctx.house.rowsByKey('turns', ref, v.turns) : [];
1337
+ const calls = v.calls.length ? await ctx.house.rowsByKey('calls', ref, v.calls) : [];
1338
+ // Times as ClickHouse prints them or as ISO: the same fixed-width string compares in order.
1339
+ const tsKey = (t) => String(t || '').replace('T', ' ').replace('Z', '').slice(0, 23);
1340
+ const bound = [...turns, ...calls].reduce((a, r) => (tsKey(r.ts) > a ? tsKey(r.ts) : a), '');
1341
+ const seen = (t) => !!tsKey(t) && tsKey(t) <= bound;
1342
+ const bag = new Map();
1343
+ const add = (sig) => bag.set(sig, (bag.get(sig) || 0) + 1);
1344
+ const take = (sig) => { const n = bag.get(sig) || 0; if (n) bag.set(sig, n - 1); return n > 0; };
1345
+ for (const S of logged) {
1346
+ const cur = await ctx.house.capturedRows(refOf(S, ctx.host));
1347
+ const msgs = new Map();
1348
+ for (const r of [...cur.turns].sort((a, b) => a.part_ord - b.part_ord)) {
1349
+ if (r.kind !== 'text' || !r.text) continue;
1350
+ if (!msgs.has(r.message_id)) msgs.set(r.message_id, { role: r.role, texts: [], seen: true });
1351
+ const m = msgs.get(r.message_id);
1352
+ m.texts.push(r.text); m.seen = m.seen && seen(r.ts);
1353
+ }
1354
+ for (const m of msgs.values()) if (m.seen) add(`t\u0000${m.role}\u0000${m.texts.join('\n')}`);
1355
+ for (const c of cur.calls) if (seen(c.ts)) add(`c\u0000${c.tool_name}\u0000${c.args}`);
1356
+ }
1357
+ const twinned = new Set();
1358
+ const out = {
1359
+ turns: turns.map((r) => {
1360
+ if (!take(`t\u0000${r.role}\u0000${r.text}`)) return base.removedOf('turns', r, rev);
1361
+ twinned.add(r.message_id);
1362
+ return base.tombstoneOf('turns', r, rev);
1363
+ }),
1364
+ };
1365
+ out.calls = calls.map((r) => (twinned.has(r.message_id) && take(`c\u0000${r.tool_name}\u0000${r.args}`) ? base.tombstoneOf : base.removedOf)('calls', r, rev));
1366
+ return out;
1367
+ }
1368
+
1369
+ // ── dedupe of the converted legacy rows (draft 5 §7 step 5; dedupe-v3.js) ───────────────────
1370
+ /**
1371
+ * Match one session's legacy rows against its capture, tombstone the matched ship rows, and write
1372
+ * the 'legacy' files row (the share of the rows that stay live, and the marker). Reads the
1373
+ * session's legacy rows in every version (a tombstoned one keeps the run it matched) and its live
1374
+ * captured rows. Returns { none } when the session has no legacy rows at all, else { plan, filesRow }.
1375
+ * Order: tombstones before the marker, so a crash between them re-runs the match — which decides
1376
+ * the same way — instead of leaving a marker over live duplicates.
1377
+ */
1378
+ /** codexRepeats over the session's rollouts on disk (its main, agent and side files); [] when none is readable. */
1379
+ async function codexRepeatsOf(ctx, ref) {
1380
+ const files = (await ctx.house.filesRows([ref])).filter((f) => !Number(f.is_deleted) && ['main', 'agent', 'side'].includes(String(f.role)));
1381
+ return repeatsOfKeys(ref, files.map((f) => String(f.file_key)));
1382
+ }
1383
+ /** The same over the session's sources on the profile (session_sources). */
1384
+ async function profileRepeatsOf(ph, ref) {
1385
+ const srcs = (await ph.sources(ref)).filter((s) => !Number(s.is_deleted) && ['main', 'agent', 'side'].includes(String(s.role)));
1386
+ return repeatsOfKeys(ref, srcs.map((s) => String(s.source_key)));
1387
+ }
1388
+ async function repeatsOfKeys(ref, keys) {
1389
+ const out = [];
1390
+ for (const key of keys) {
1391
+ // The package keys a rollout by its path under the store; the 0.20 engine by its base name,
1392
+ // which dates it: sessions/YYYY/MM/DD/rollout-YYYY-MM-DDT…, or archived_sessions/.
1393
+ const day = /^rollout-(\d{4})-(\d{2})-(\d{2})T/.exec(key);
1394
+ const root = String(ref.harness_path || '/');
1395
+ const file = [path.resolve(root, key),
1396
+ ...(day && !key.includes('/') ? [path.join(root, 'sessions', day[1], day[2], day[3], key), path.join(root, 'archived_sessions', key)] : [])]
1397
+ .find((x) => fs.existsSync(x));
1398
+ if (!file) continue;
1399
+ // Unreadable is no proof: those rows stay live, and the pass goes on.
1400
+ try {
1401
+ const records = [];
1402
+ const rl = require('readline').createInterface({ input: fs.createReadStream(file, { encoding: 'utf8' }), crlfDelay: Infinity });
1403
+ for await (const line of rl) {
1404
+ if (!line.includes('"event_msg"') && !line.includes('"response_item"')) continue;
1405
+ let o;
1406
+ try { o = JSON.parse(line); } catch { continue; }
1407
+ const p = o && o.payload ? o.payload : {};
1408
+ records.push({ type: o.type, payload: { type: p.type, role: p.role, info: p.type === 'token_count' ? p.info : undefined } });
1409
+ }
1410
+ out.push(...dedupe.codexRepeats(records));
1411
+ } catch { /* an unreadable rollout proves nothing */ }
1412
+ }
1413
+ return out;
1414
+ }
1415
+
1416
+ async function dedupeSession(ctx, ref, stats = {}, { redo = false } = {}) {
1417
+ const harness = ref.harness; const sid = ref.session_id;
1418
+ const sk = skey(harness, sid, ref.harness_path, ref.incarnation);
1419
+ const legacy = await ctx.house.legacyRowsAll(ref, ctx.hostAliases || []);
1420
+ if (!legacy.turns.length && !legacy.calls.length) {
1421
+ ctx.cache.data.deduped[sk] = 'none'; ctx.cache.dirty = true;
1422
+ return { none: true };
1423
+ }
1424
+ const captured = await ctx.house.capturedRows(ref);
1425
+ // Matched against nothing, every legacy row would be kept under the marker for good and the
1426
+ // capture landing later would duplicate them: no marker until there is a capture to match.
1427
+ if (!captured.turns.length && !captured.calls.length) return { skipped: true };
1428
+ const input = {
1429
+ harness, session_id: sid, legacyTurns: legacy.turns, legacyCalls: legacy.calls,
1430
+ capturedTurns: captured.turns, capturedCalls: captured.calls,
1431
+ };
1432
+ let plan = dedupe.planDedupe(input);
1433
+ // A Codex '[assistant activity]' row in a turn aborted before any output is retired only on the
1434
+ // transcript's proof that its tokens are a re-emitted count (dedupe-v3 codexRepeats): read the
1435
+ // session's rollouts, once, only when the plan has such a row.
1436
+ if (plan.report.rereport_unproven > 0) {
1437
+ const repeats = await codexRepeatsOf(ctx, ref);
1438
+ if (repeats.length) plan = dedupe.planDedupe({ ...input, repeats });
1439
+ }
1440
+ // A repair re-plan that finds nothing new to tombstone changes nothing (and keeps the marker's ver),
1441
+ // unless the marker reports other kept rows than the house holds now: a marker that lost to an
1442
+ // older one (a repair after a tombstone cleanup, before the upto floor below) is rewritten.
1443
+ const prev = (await ctx.house.filesRows([ref])).find((f) => f.role === dedupe.LEGACY_ROLE && f.file_key === dedupe.LEGACY_FILE_KEY && !Number(f.is_deleted));
1444
+ const keptOf = (f) => { try { const k = JSON.parse(f.derive_state).dedupe.kept; return `${k.turns}/${k.calls}`; } catch { return null; } };
1445
+ if (redo && !plan.tombstones.turns.length && !plan.tombstones.calls.length
1446
+ && (!prev || keptOf(prev) === `${plan.report.kept.turns}/${plan.report.kept.calls}`)) return { plan, unchanged: true };
1447
+ await ctx.house.insert('turns', plan.tombstones.turns);
1448
+ await ctx.house.insert('calls', plan.tombstones.calls);
1449
+ const filesRow = dedupe.legacyFilesRow({ harness, session_id: sid, harness_path: ref.harness_path, incarnation: ref.incarnation }, plan, ctx.host);
1450
+ // The marker's ver is 1 + the legacy rows tombstoned, and a repair must outrank the marker it
1451
+ // replaces. A tombstone cleanup drops tombstoned rows with their originals, so a repair after it
1452
+ // counts fewer: its upto is at least the old marker's + 1.
1453
+ if (prev && Number(prev.upto) >= filesRow.upto) filesRow.upto = Number(prev.upto) + 1;
1454
+ await ctx.house.insert('files', [filesRow]);
1455
+ // The converted session under another key — this machine's pre-0.20 host id, or the unknown store
1456
+ // — is this one now: its rows are tombstoned, or counted by the 'legacy' row above. Its own
1457
+ // sessions row would list the session twice (found by misc/upgrade-drill-0.20.sh): retired, at a
1458
+ // higher rev.
1459
+ const foreign = new Map();
1460
+ for (const r of [...legacy.turns, ...legacy.calls]) {
1461
+ if (r.host === ref.host && String(r.harness_path || '') === String(ref.harness_path || '')) continue;
1462
+ foreign.set(`${r.host}\t${r.harness_path || ''}`, { harness, host: r.host, harness_path: String(r.harness_path || ''), session_id: sid, incarnation: String(ref.incarnation || '') });
1463
+ }
1464
+ if (foreign.size && typeof ctx.house.sessionRows === 'function') {
1465
+ const retired = (await ctx.house.sessionRows([...foreign.values()])).filter((r) => !Number(r.is_deleted))
1466
+ .map((r) => ({ ...r, is_deleted: 1, rev: String(BigInt(r.rev) + 1n) }));
1467
+ if (retired.length) { await ctx.house.insert('sessions', retired); stats.legacySessionsRetired = (stats.legacySessionsRetired || 0) + retired.length; }
1468
+ }
1469
+ ctx.cache.data.deduped[sk] = 'done'; ctx.cache.dirty = true;
1470
+ const r = plan.report;
1471
+ stats.deduped = (stats.deduped || 0) + 1;
1472
+ stats.legacyTombstoned = (stats.legacyTombstoned || 0) + r.tombstoned.turns + r.tombstoned.calls;
1473
+ stats.legacyKept = (stats.legacyKept || 0) + r.kept.turns + r.kept.calls;
1474
+ stats.legacyImports = (stats.legacyImports || 0) + r.imports.turns + r.imports.calls;
1475
+ stats.tombstones = (stats.tombstones || 0) + r.tombstoned.turns + r.tombstoned.calls;
1476
+ // Unmatched legacy rows are content the transcript no longer has: kept, and said so.
1477
+ if (r.kept.turns || r.kept.calls) (ctx.log || (() => {}))({ dedupe: sk, matched: r.matched, kept: r.kept, captured: r.captured });
1478
+ ctx.dedupeReports = ctx.dedupeReports || [];
1479
+ ctx.dedupeReports.push(r);
1480
+ if (ctx.dedupeReports.length > 1000) ctx.dedupeReports.shift();
1481
+ return { plan, filesRow };
1482
+ }
1483
+
1484
+ /**
1485
+ * Sessions with live legacy rows and captured files but no 'legacy' files row: captured by a build
1486
+ * without dedupe, or converted after their capture. Each is deduped and its sessions row rebuilt.
1487
+ * Returns true when nothing is left pending (the next sweep may wait six hours).
1488
+ */
1489
+ async function dedupeSweep(ctx, sessions, stats) {
1490
+ if (typeof ctx.house.pendingDedupe !== 'function') return true;
1491
+ const LIMIT = 5000;
1492
+ const pending = await ctx.house.pendingDedupe(LIMIT, ctx.host, ctx.hostAliases || []);
1493
+ let waiting = 0;
1494
+ for (const { harness, session_id: sid, harness_path: hp, incarnation: inc } of pending) {
1495
+ const S = sessions.get(skey(harness, sid, hp, inc));
1496
+ // Only a session whose transcript this machine has captured to the end. A files row exists
1497
+ // from the first partial group of a backfill on; matched then, the legacy rows whose twins
1498
+ // were not captured yet are kept for good and duplicated by the rest of the capture
1499
+ // (2026-10-14, the dev house: 82 sessions). A session not on this machine is its capturer's.
1500
+ if (!S) continue;
1501
+ if (!caughtUp(ctx, S)) { waiting++; continue; }
1502
+ const ref = refOf(S, ctx.host);
1503
+ const d = await dedupeSession(ctx, ref, stats);
1504
+ if (d.skipped) { waiting++; continue; }
1505
+ await ctx.house.insert('sessions', [buildSession(harness, sid, await ctx.house.filesRows([ref]), { host: ctx.host, hostLabel: ctx.hostLabel, harnessPath: S.harness_path, incarnation: S.incarnation })]);
1506
+ stats.sessionRows = (stats.sessionRows || 0) + 1;
1507
+ }
1508
+ return !waiting && pending.length < LIMIT;
1509
+ }
1510
+
1511
+ /** Every file of a session this machine has on disk is captured to its end: none missing from the cache, none partway. */
1512
+ function caughtUp(ctx, S) {
1513
+ return [...S.files.values()].every((f) => { const C = ctx.cache.get(fkey(f)); return !!C && !C.more; });
1514
+ }
1515
+
1516
+ /** What a sessions row says, without its `rev`: equal digests mean an identical row to readers. */
1517
+ function sessionDigest(row) {
1518
+ const { rev, ...rest } = row;
1519
+ return crypto.createHash('sha1').update(JSON.stringify(rest)).digest('base64').slice(0, 16);
1520
+ }
1521
+
1522
+ /**
1523
+ * Repair (--redupe): re-plan every deduped session that still has live legacy ship rows and that
1524
+ * this machine has captured to the end, and write where the new plan tombstones more — the
1525
+ * sessions a sweep deduped against a capture that had not caught up. A re-plan decides the same
1526
+ * way for rows already decided, so a session deduped correctly is left as it is.
1527
+ */
1528
+ async function redupe(ctx, sessions, stats) {
1529
+ const r = { candidates: 0, repaired: 0, unchanged: 0, waiting: 0, elsewhere: 0, tombstoned: 0 };
1530
+ // The package's stores are repaired in adapterPass: their sessions left the file capture.
1531
+ const pkg = new Set((ctx.adapterStores || []).map((s) => `${s.harness}\t${s.harness_path}`));
1532
+ const rows = (await ctx.house.dedupedWithLiveLegacy(ctx.host, ctx.hostAliases || [])).filter((x) => !pkg.has(`${x.harness}\t${x.harness_path || ''}`));
1533
+ r.candidates = rows.length;
1534
+ for (const { harness, session_id: sid, harness_path: hp, incarnation: inc } of rows) {
1535
+ const S = sessions.get(skey(harness, sid, hp, inc));
1536
+ if (!S) { r.elsewhere++; continue; }
1537
+ if (!caughtUp(ctx, S)) { r.waiting++; continue; }
1538
+ const before = stats.legacyTombstoned || 0;
1539
+ const ref = refOf(S, ctx.host);
1540
+ const d = await dedupeSession(ctx, ref, stats, { redo: true });
1541
+ if (d.skipped || d.unchanged) { r.unchanged++; continue; }
1542
+ r.repaired++; r.tombstoned += (stats.legacyTombstoned || 0) - before;
1543
+ await ctx.house.insert('sessions', [buildSession(harness, sid, await ctx.house.filesRows([ref]), { host: ctx.host, hostLabel: ctx.hostLabel, harnessPath: S.harness_path, incarnation: S.incarnation })]);
1544
+ stats.sessionRows = (stats.sessionRows || 0) + 1;
1545
+ }
1546
+ return r;
1547
+ }
1548
+
1549
+ // ── the house over HTTP ─────────────────────────────────────────────────────────────────────
1550
+ function sqlStr(s) { return `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; }
1551
+ /** WHERE clause for one session's store (refOf). */
1552
+ function storeWhere(ref) {
1553
+ return `harness = ${sqlStr(ref.harness)} AND host = ${sqlStr(ref.host)} AND harness_path = ${sqlStr(ref.harness_path)}`
1554
+ + ` AND session_id = ${sqlStr(ref.session_id)} AND incarnation = ${sqlStr(ref.incarnation || '')}`;
1555
+ }
1556
+ const STORE_TUPLE = '(harness, host, harness_path, session_id, incarnation)';
1557
+ const storeTuple = (ref) => `(${[ref.harness, ref.host, ref.harness_path, ref.session_id, ref.incarnation || ''].map(sqlStr).join(', ')})`;
1558
+ /** Split a column list at its top-level commas. */
1559
+ function splitTop(s) {
1560
+ const out = []; let depth = 0; let buf = '';
1561
+ for (const ch of `${s},`) {
1562
+ if (ch === '(') depth++;
1563
+ if (ch === ')') depth--;
1564
+ if (ch === ',' && depth === 0) { if (buf.trim()) out.push(buf.trim()); buf = ''; continue; }
1565
+ buf += ch;
1566
+ }
1567
+ return out;
1568
+ }
1569
+ /**
1570
+ * Each table's insertable columns, read from the schema template (memhouse/house/schema-v3.sql.tpl):
1571
+ * every declared column but the MATERIALIZED ones, a Nested column as its dotted subcolumns. A
1572
+ * column a row leaves out takes its DEFAULT. Hand-kept lists drifted from the template once
1573
+ * (layout 3.2: records, files and sessions still listed 3.1 columns) — only a real house saw it.
1574
+ */
1575
+ const TABLE_COLUMNS = (() => {
1576
+ const v3 = require('../house/schema-v3');
1577
+ const out = {};
1578
+ for (const [type, cols] of Object.entries(v3.templateColumns(v3.readTemplate()))) {
1579
+ out[type] = cols.filter((c) => !/\b(MATERIALIZED|ALIAS)\b/i.test(c.type)).flatMap((c) => {
1580
+ const m = /^Nested\s*\(([\s\S]*)\)\s*$/i.exec(c.type.replace(/--[^\n]*/g, ''));
1581
+ return m ? splitTop(m[1]).map((sub) => `${c.name}.${sub.split(/\s+/)[0]}`) : [c.name];
1582
+ });
1583
+ }
1584
+ return out;
1585
+ })();
1586
+ /** Split rows into insert batches of at most BATCH_ROWS rows and BATCH_BYTES of JSON. */
1587
+ function batches(rows, maxRows = BATCH_ROWS, maxBytes = BATCH_BYTES) {
1588
+ const out = [];
1589
+ let cur = []; let bytes = 0;
1590
+ for (const r of rows) {
1591
+ const line = JSON.stringify(r);
1592
+ const n = Buffer.byteLength(line) + 1;
1593
+ if (cur.length && (cur.length >= maxRows || bytes + n > maxBytes)) { out.push(cur); cur = []; bytes = 0; }
1594
+ cur.push(line); bytes += n;
1595
+ }
1596
+ if (cur.length) out.push(cur);
1597
+ return out;
1598
+ }
1599
+
1600
+ class HttpHouse {
1601
+ constructor({ url, user, password, db, member }) {
1602
+ this.url = String(url).replace(/\/+$/, '');
1603
+ this.user = user; this.password = password || '';
1604
+ this.db = db || 'mem'; this.adb = `${this.db}_archive`; this.member = member || null;
1605
+ this.inserted = {};
1606
+ }
1607
+ async init() {
1608
+ if (!this.member) this.member = (await this.query('SELECT currentUser() AS u'))[0].u;
1609
+ const m = this.member;
1610
+ this.t = {
1611
+ turns: `${this.db}.${m}_turns`, calls: `${this.db}.${m}_calls`, sessions: `${this.db}.${m}_sessions`,
1612
+ records: `${this.adb}.${m}_records`, files: `${this.adb}.${m}_files`,
1613
+ };
1614
+ return this;
1615
+ }
1616
+ async post(params, body, gzip) {
1617
+ const qs = new URLSearchParams({ database: this.db, ...params });
1618
+ const headers = { 'X-ClickHouse-User': this.user, 'X-ClickHouse-Key': this.password };
1619
+ let payload = body;
1620
+ if (gzip) { payload = zlib.gzipSync(Buffer.from(body)); headers['Content-Encoding'] = 'gzip'; }
1621
+ const res = await fetch(`${this.url}/?${qs}`, { method: 'POST', headers, body: payload });
1622
+ const text = await res.text();
1623
+ if (!res.ok) throw new Error(`ClickHouse ${res.status}: ${text.trim().split('\n')[0].slice(0, 400)}`);
1624
+ return text;
1625
+ }
1626
+ async query(sql, settings = {}) {
1627
+ const params = { default_format: 'JSONEachRow', output_format_json_quote_64bit_integers: '1' };
1628
+ for (const [k, v] of Object.entries(settings)) params[k] = String(v);
1629
+ const text = await this.post(params, sql);
1630
+ return text.split('\n').filter(Boolean).map((l) => JSON.parse(l));
1631
+ }
1632
+ /** The transport-free query interface memhouse/house/*-v3.js modules take (q.rows(sql, settings)). */
1633
+ async rows(sql, settings) { return this.query(sql, settings); }
1634
+ async insert(kind, rows) {
1635
+ if (!rows.length) return;
1636
+ const cols = TABLE_COLUMNS[kind];
1637
+ for (const lines of batches(rows)) {
1638
+ await this.post({
1639
+ query: `INSERT INTO ${this.t[kind]} (${cols.map((c) => (c.includes('.') ? `\`${c}\`` : c)).join(', ')}) FORMAT JSONEachRow`,
1640
+ async_insert: '0', input_format_skip_unknown_fields: '0',
1641
+ }, lines.join('\n'), true);
1642
+ }
1643
+ this.inserted[kind] = (this.inserted[kind] || 0) + rows.length;
1644
+ }
1645
+ /** The live sessions rows at these session keys (FINAL), every column — to retire one whole. */
1646
+ async sessionRows(refs) {
1647
+ if (!refs.length) return [];
1648
+ const cols = TABLE_COLUMNS.sessions.map((c) => (c.includes('.') ? `\`${c}\`` : c)).join(', ');
1649
+ return this.query(`SELECT ${cols} FROM ${this.t.sessions} FINAL WHERE ${STORE_TUPLE} IN (${refs.map(storeTuple).join(', ')}) AND is_deleted = 0`);
1650
+ }
1651
+ async filesRows(refs) {
1652
+ if (!refs.length) return [];
1653
+ const out = [];
1654
+ for (let i = 0; i < refs.length; i += 500) {
1655
+ const list = refs.slice(i, i + 500).map(storeTuple).join(', ');
1656
+ out.push(...await this.query(`SELECT harness, harness_path, session_id, incarnation, file_key, agent_id, role, agent_type, agent_desc, fgen, upto,
1657
+ reparse_from, derive_state, adapter_ver, size, inode, anchor_sha, prefix_sha, is_deleted, contrib,
1658
+ toString(first_ts) AS first_ts, toString(last_ts) AS last_ts, toString(ver) AS ver, host
1659
+ FROM ${this.t.files} FINAL WHERE ${STORE_TUPLE} IN (${list})`));
1660
+ }
1661
+ return out.map((r) => ({ ...r, fgen: Number(r.fgen), upto: Number(r.upto), reparse_from: Number(r.reparse_from), adapter_ver: Number(r.adapter_ver) }));
1662
+ }
1663
+ async staleFiles(harness, ver, limit, host, { harnessPath = null, minVer = 0 } = {}) {
1664
+ return (await this.query(`SELECT harness, harness_path, session_id, incarnation, file_key, agent_id, role, agent_type, agent_desc, fgen, upto,
1665
+ reparse_from, derive_state, adapter_ver, size, inode, anchor_sha, prefix_sha, is_deleted, contrib,
1666
+ toString(first_ts) AS first_ts, toString(last_ts) AS last_ts, host
1667
+ FROM ${this.t.files} FINAL WHERE harness = ${sqlStr(harness)} AND host = ${sqlStr(host)}${harnessPath == null ? '' : ` AND harness_path = ${sqlStr(harnessPath)}`}
1668
+ AND adapter_ver < ${Number(ver)} AND adapter_ver >= ${Number(minVer)} AND role NOT IN ('side', ${sqlStr(dedupe.LEGACY_ROLE)}, ${sqlStr(forks.INHERITED_ROLE)}) AND is_deleted = 0
1669
+ LIMIT ${Number(limit)}`)).map((r) => ({ ...r, fgen: Number(r.fgen), upto: Number(r.upto), adapter_ver: Number(r.adapter_ver) }));
1670
+ }
1671
+ async archiveRecords(ref, fileKey, fgen) {
1672
+ return this.query(`SELECT pos, chunk, sha, kind, enc, raw, toString(captured_at) AS captured_at
1673
+ FROM ${this.t.records} FINAL WHERE ${storeWhere(ref)}
1674
+ AND file_key = ${sqlStr(fileKey)} AND fgen = ${Number(fgen)} AND kind = 'record' ORDER BY pos, chunk`);
1675
+ }
1676
+ /** The archived session-adapter units of one source generation (adapter-capture.js). */
1677
+ async archiveUnits(ref, fileKey, fgen) {
1678
+ return (await this.query(`SELECT pos, chunk, sha, kind, raw
1679
+ FROM ${this.t.records} FINAL WHERE ${storeWhere(ref)}
1680
+ AND file_key = ${sqlStr(fileKey)} AND fgen = ${Number(fgen)} AND kind = 'unit' ORDER BY pos, chunk`))
1681
+ .map((r) => ({ ...r, pos: Number(r.pos), chunk: Number(r.chunk) }));
1682
+ }
1683
+ /**
1684
+ * A session's messages on its main line, in order, for fork verification (forks.js): per message,
1685
+ * the sha256 of its full parts (kind and text, by part_ord), whether any text is truncated
1686
+ * (text_len counts UTF-16 units), its first text, and its tokens with the hour and model they
1687
+ * were billed under.
1688
+ */
1689
+ async sessionMessages(ref) {
1690
+ const tok = 'input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + reasoning_tokens';
1691
+ const rows = await this.query(`SELECT message_id, any(role) AS role, min(ord) AS o,
1692
+ lower(hex(SHA256(arrayStringConcat(arrayMap(x -> concat(x.2, '\x01', x.3), arraySort(x -> x.1, groupArray((part_ord, toString(kind), text)))), '\x02')))) AS sha,
1693
+ max(text_len > lengthUTF8(text) + countMatches(text, '[\\\\x{10000}-\\\\x{10FFFF}]')) AS truncated,
1694
+ argMinIf(text, part_ord, kind = 'text') AS first_text,
1695
+ formatDateTime(toStartOfHour(if(countIf(${tok} > 0) > 0, minIf(ts, ${tok} > 0), min(ts))), '%Y-%m-%d %H:00:00') AS hour,
1696
+ if(countIf(${tok} > 0) > 0, anyIf(model, ${tok} > 0), any(model)) AS model,
1697
+ sum(input_tokens) AS input, sum(output_tokens) AS output, sum(cache_read_tokens) AS cache_read,
1698
+ sum(cache_write_tokens) AS cache_write, sum(reasoning_tokens) AS reasoning
1699
+ FROM ${this.t.turns} FINAL
1700
+ WHERE ${storeWhere(ref)} AND agent_id = '' AND kind != 'usage' AND is_deleted = 0 AND removed = 0
1701
+ GROUP BY message_id ORDER BY o, message_id`);
1702
+ return rows.map((r) => ({ ...r, truncated: Number(r.truncated) === 1 }));
1703
+ }
1704
+ /** Sessions of a store whose main line has an assistant text exactly `text` (fork nomination). */
1705
+ async sessionsWithText(store, text) {
1706
+ return this.query(`SELECT DISTINCT session_id, incarnation FROM ${this.t.turns} FINAL
1707
+ WHERE harness = ${sqlStr(store.harness)} AND host = ${sqlStr(store.host)} AND harness_path = ${sqlStr(store.harness_path)}
1708
+ AND agent_id = '' AND role = 'assistant' AND kind = 'text' AND is_deleted = 0 AND removed = 0 AND text = ${sqlStr(text)}
1709
+ LIMIT 50`);
1710
+ }
1711
+ /** The live sessions of a store with this id (one per incarnation). */
1712
+ async sessionsById(store, sessionId) {
1713
+ return this.query(`SELECT DISTINCT incarnation FROM ${this.t.files} FINAL
1714
+ WHERE harness = ${sqlStr(store.harness)} AND host = ${sqlStr(store.host)} AND harness_path = ${sqlStr(store.harness_path)}
1715
+ AND session_id = ${sqlStr(sessionId)} AND role = 'main' AND is_deleted = 0`);
1716
+ }
1717
+ /** Sessions of a store whose own record states a link to this one as their origin (child side). */
1718
+ async linkedChildren(store, sessionId) {
1719
+ return this.query(`SELECT session_id, incarnation FROM ${this.t.sessions} FINAL
1720
+ WHERE harness = ${sqlStr(store.harness)} AND host = ${sqlStr(store.host)} AND harness_path = ${sqlStr(store.harness_path)}
1721
+ AND is_deleted = 0 AND has(\`links.origin\`, ${sqlStr(sessionId)})`);
1722
+ }
1723
+ /** The forks of a session: live 'inherited' files rows of its store that name it as their origin. */
1724
+ async inheritedFrom(store, sessionId) {
1725
+ return this.query(`SELECT harness, harness_path, session_id, incarnation FROM ${this.t.files} FINAL
1726
+ WHERE harness = ${sqlStr(store.harness)} AND host = ${sqlStr(store.host)} AND harness_path = ${sqlStr(store.harness_path)}
1727
+ AND role = ${sqlStr(forks.INHERITED_ROLE)} AND is_deleted = 0 AND JSONExtractString(contrib, 'inherits', 'session_id') = ${sqlStr(sessionId)}`);
1728
+ }
1729
+ /** The highest generation archived per source ({ ref, file_key }[] → Map fkey → g; absent: none). */
1730
+ async maxGenerations(items) {
1731
+ const out = new Map();
1732
+ // A source belongs to its host; capture asks only about its own. The map is keyed like the
1733
+ // cache (fkey, no host), so one call names one host.
1734
+ if (new Set(items.map((x) => x.ref.host)).size > 1) throw new Error('maxGenerations: one host per call');
1735
+ for (let i = 0; i < items.length; i += 500) {
1736
+ const list = items.slice(i, i + 500).map((x) => `(${[x.ref.harness, x.ref.host, x.ref.harness_path, x.ref.session_id, x.ref.incarnation || '', x.file_key].map(sqlStr).join(', ')})`).join(', ');
1737
+ const rows = await this.query(`SELECT harness, host, harness_path, session_id, incarnation, file_key, max(fgen) AS g FROM ${this.t.records}
1738
+ WHERE (harness, host, harness_path, session_id, incarnation, file_key) IN (${list})
1739
+ GROUP BY harness, host, harness_path, session_id, incarnation, file_key`);
1740
+ for (const r of rows) out.set(fkey(r), Number(r.g));
1741
+ }
1742
+ return out;
1743
+ }
1744
+ async firstCaptured(ref, fileKey, fgen, positions) {
1745
+ const out = new Map();
1746
+ for (let i = 0; i < positions.length; i += 1000) {
1747
+ const rows = await this.query(`SELECT pos, toString(min(captured_at)) AS at FROM ${this.t.records}
1748
+ WHERE ${storeWhere(ref)} AND file_key = ${sqlStr(fileKey)}
1749
+ AND fgen = ${Number(fgen)} AND kind = 'record' AND pos IN (${positions.slice(i, i + 1000).map(Number).join(', ')}) GROUP BY pos`);
1750
+ for (const r of rows) out.set(Number(r.pos), Date.parse(`${r.at.replace(' ', 'T')}Z`));
1751
+ }
1752
+ return out;
1753
+ }
1754
+ /** Every insertable column, DateTime64 as its wire string — what a full-copy tombstone needs. */
1755
+ selectAll(kind) {
1756
+ const dt = { turns: ['ts'], calls: ['ts', 'result_ts'] }[kind];
1757
+ return TABLE_COLUMNS[kind].map((c) => (dt.includes(c) ? `toString(${c}) AS ${c}` : c)).join(', ');
1758
+ }
1759
+ /** The exact rows (FINAL, live) at these keys — read back before tombstoning them. */
1760
+ async rowsByKey(kind, ref, keys) {
1761
+ const out = [];
1762
+ for (let i = 0; i < keys.length; i += 200) {
1763
+ const ks = keys.slice(i, i + 200);
1764
+ const days = [...new Set(ks.map((k) => k.ts.slice(0, 10)))].map(sqlStr).join(', ');
1765
+ const tuples = kind === 'turns'
1766
+ ? ks.map((k) => `(${sqlStr(k.ts)}, ${sqlStr(k.message_id)}, ${sqlStr(k.part_id)})`).join(', ')
1767
+ : ks.map((k) => `(${sqlStr(k.ts)}, ${sqlStr(k.call_id)})`).join(', ');
1768
+ const key = kind === 'turns' ? '(toString(ts), message_id, part_id)' : '(toString(ts), call_id)';
1769
+ out.push(...await this.query(`SELECT ${this.selectAll(kind)} FROM ${this.t[kind]} FINAL
1770
+ WHERE toDate(ts) IN (${days}) AND ${storeWhere(ref)}
1771
+ AND ${key} IN (${tuples}) AND is_deleted = 0`));
1772
+ }
1773
+ return out.map((r) => (kind === 'turns' ? { ...r, part_ord: Number(r.part_ord) } : r));
1774
+ }
1775
+ /** The keys this file's shipped rows hold in the conversation layer (adapter upgrade only). */
1776
+ async fileKeys(ref, agentId, agentFileIds) {
1777
+ const who = agentId ? `agent_id = ${sqlStr(agentId)}`
1778
+ : (agentFileIds.length ? `agent_id NOT IN (${agentFileIds.map(sqlStr).join(', ')})` : '1');
1779
+ // Removed history (removed = 1) is never a candidate: it is what the source no longer has, not a
1780
+ // duplicate of the new derivation, and an upgrade must not erase it.
1781
+ const where = `${storeWhere(ref)} AND ${who} AND is_deleted = 0 AND removed = 0`;
1782
+ const t = await this.query(`SELECT message_id, part_id, toString(ts) AS ts FROM ${this.t.turns} FINAL
1783
+ WHERE ${where} AND origin = 'ship' AND NOT startsWith(message_id, 'legacy:')`);
1784
+ const c = await this.query(`SELECT call_id, toString(ts) AS ts FROM ${this.t.calls} FINAL
1785
+ WHERE ${where} AND NOT startsWith(call_id, 'legacy:')`);
1786
+ return { t: t.map((r) => [r.message_id, r.part_id, r.ts]), c: c.map((r) => [r.call_id, r.ts]) };
1787
+ }
1788
+ /**
1789
+ * Every `legacy:` row of one session in EVERY version — no FINAL, so tombstoned rows come back
1790
+ * too (dedupe-v3 takes the newest per key: a tombstone is a full copy, it still has its text).
1791
+ * Full rows, every insertable column: what a full-copy tombstone needs.
1792
+ */
1793
+ async legacyRowsAll(ref, hostAliases = []) {
1794
+ // Its own store, and the unknown store ('': a 0.18 row whose transcript path was empty) of the
1795
+ // same host and id — converted rows that could not name their store still dedupe. The host
1796
+ // also under its aliases (this machine's pre-0.20 memhouse id, which 0.18 shipped under).
1797
+ const refs = [];
1798
+ for (const h of [ref.host, ...hostAliases]) for (const p of [ref.harness_path, '']) refs.push(storeWhere({ ...ref, host: h, harness_path: p }));
1799
+ const where = `(${refs.join(' OR ')})`;
1800
+ const turns = (await this.query(`SELECT ${this.selectAll('turns')} FROM ${this.t.turns}
1801
+ WHERE ${where} AND startsWith(message_id, 'legacy:')`)).map((r) => ({ ...r, part_ord: Number(r.part_ord) }));
1802
+ const calls = await this.query(`SELECT ${this.selectAll('calls')} FROM ${this.t.calls}
1803
+ WHERE ${where} AND startsWith(call_id, 'legacy:')`);
1804
+ return { turns, calls };
1805
+ }
1806
+ /** Whether the session keeps any removed version (content that left its source), in turns or calls. */
1807
+ async hasRemoved(ref) {
1808
+ const where = `${storeWhere(ref)} AND removed = 1 AND is_deleted = 0`;
1809
+ const r = await this.query(`SELECT (SELECT count() FROM ${this.t.turns} FINAL WHERE ${where}) + (SELECT count() FROM ${this.t.calls} FINAL WHERE ${where}) AS n`);
1810
+ return Number((r[0] || {}).n) > 0;
1811
+ }
1812
+ /** The live captured rows of one session — what its legacy rows are matched against. */
1813
+ async capturedRows(ref) {
1814
+ const where = `${storeWhere(ref)} AND is_deleted = 0 AND removed = 0`;
1815
+ const turns = (await this.query(`SELECT message_id, part_id, part_ord, toString(ts) AS ts, ord, role, kind, text, text_len, agent_id, response_id, extra, toString(rev) AS rev
1816
+ FROM ${this.t.turns} FINAL WHERE ${where} AND NOT startsWith(message_id, 'legacy:')`))
1817
+ .map((r) => ({ ...r, part_ord: Number(r.part_ord), ord: Number(r.ord), text_len: Number(r.text_len) }));
1818
+ const calls = await this.query(`SELECT call_id, message_id, toString(ts) AS ts, tool_name, args, agent_id, toString(rev) AS rev
1819
+ FROM ${this.t.calls} FINAL WHERE ${where} AND NOT startsWith(call_id, 'legacy:')`);
1820
+ return { turns, calls };
1821
+ }
1822
+ /** The stores (h, p, s, i) of this host's live legacy rows; `prefix` 'legacy:' or 'legacy:ship:'. */
1823
+ legacyStoresSQL(host, prefix, hostAliases = []) {
1824
+ const hosts = [host, ...hostAliases].map(sqlStr).join(', ');
1825
+ const one = (t, id) => `SELECT DISTINCT toString(harness) AS h, toString(harness_path) AS p, session_id AS s, incarnation AS i FROM ${t} FINAL
1826
+ WHERE is_deleted = 0 AND host IN (${hosts}) AND startsWith(${id}, ${sqlStr(prefix)})`;
1827
+ return `${one(this.t.turns, 'message_id')} UNION DISTINCT ${one(this.t.calls, 'call_id')}`;
1828
+ }
1829
+ /**
1830
+ * Captured stores (files rows) of this host whose session has legacy rows in that store or in the
1831
+ * unknown store ('': a 0.18 row without a transcript path). `marked`: with a 'legacy' files row
1832
+ * (redupe) or without one (the sweep).
1833
+ */
1834
+ storesWithLegacySQL(host, prefix, { marked, hostAliases = [] }) {
1835
+ const L = sqlStr(dedupe.LEGACY_ROLE);
1836
+ const files = (role) => `SELECT toString(harness), toString(harness_path), session_id, incarnation FROM ${this.t.files} FINAL WHERE host = ${sqlStr(host)} AND ${role}`;
1837
+ // A marker reporting kept rows is a candidate too (marked): after a tombstone cleanup a repair could
1838
+ // write a marker that lost to the old one, which still counts rows that are gone.
1839
+ const kept = `JSONExtractUInt(derive_state, 'dedupe', 'kept', 'turns') + JSONExtractUInt(derive_state, 'dedupe', 'kept', 'calls')`;
1840
+ return `SELECT h AS harness, p AS harness_path, s AS session_id, i AS incarnation FROM (
1841
+ SELECT DISTINCT toString(harness) AS h, toString(harness_path) AS p, session_id AS s, incarnation AS i, ${kept} AS k FROM ${this.t.files} FINAL
1842
+ WHERE is_deleted = 0 AND host = ${sqlStr(host)} AND role ${marked ? `= ${L}` : `NOT IN ('side', ${L}, ${sqlStr(forks.INHERITED_ROLE)})`})
1843
+ WHERE ((h, p, s, i) IN (${this.legacyStoresSQL(host, prefix, hostAliases)}) OR (h, '', s, i) IN (${this.legacyStoresSQL(host, prefix, hostAliases)})${marked ? ' OR k > 0' : ''})
1844
+ ${marked ? '' : `AND (h, p, s, i) NOT IN (${files(`role = ${L}`)})`}
1845
+ ORDER BY h, p, s`;
1846
+ }
1847
+ /** Deduped sessions (a 'legacy' files row) that still have live legacy ship rows, or whose marker reports kept rows (redupe). */
1848
+ async dedupedWithLiveLegacy(host, hostAliases = []) {
1849
+ return this.query(this.storesWithLegacySQL(host, 'legacy:ship:', { marked: true, hostAliases }));
1850
+ }
1851
+ /** Sessions with live legacy rows and captured files rows but no 'legacy' files row (dedupeSweep). */
1852
+ async pendingDedupe(limit, host, hostAliases = []) {
1853
+ return this.query(`${this.storesWithLegacySQL(host, 'legacy:', { marked: false, hostAliases })} LIMIT ${Number(limit)}`);
1854
+ }
1855
+ }
1856
+
1857
+ /**
1858
+ * One capture pass at a time per memhouse home. A daemon and a `memhouse ship` (or two daemons) would
1859
+ * otherwise run passes over the same cache, local state and house rows at once. The lock is
1860
+ * state/capture.lock, { pid, at }, created whole (a hard link of a written file, so no reader sees it
1861
+ * half written). Its holder rewrites `at` every PASS_LOCK_REFRESH_MS (a rename over it), so however
1862
+ * long a pass runs, its lock stays fresh. A pass that finds it held by a live process with a fresh
1863
+ * `at` skips; one that finds it held by a dead process, by itself, unreadable, or not refreshed for
1864
+ * PASS_LOCK_STALE_MS takes it over.
1865
+ *
1866
+ * A takeover is one taker at a time: it first takes state/capture.lock.takeover (a hard link too), then
1867
+ * reads the lock again, moves it aside by a rename, and checks that what moved is what it judged stale.
1868
+ * When another process took the lock in between, what moved is that process's fresh lock: it is put
1869
+ * back, and this pass skips. A takeover guard older than a minute was left by a taker that died, and is
1870
+ * cleared for the next pass; a taker removes the guard only while it is its own. Temporary files a
1871
+ * pass that died left beside the lock (capture.lock.<pid>, capture.lock.stale.<pid>) are swept.
1872
+ *
1873
+ * A holder whose refresh finds the lock no longer its own (taken over while it stalled past the stale
1874
+ * limit: a machine asleep, a process stopped) marks it `lost`, and its pass stops at the next session.
1875
+ */
1876
+ const PASS_LOCK_STALE_MS = 12 * 3600 * 1000;
1877
+ const PASS_LOCK_REFRESH_MS = 10 * 60 * 1000;
1878
+ function passLock(home, { now = Date.now(), pid = process.pid, refreshMs = PASS_LOCK_REFRESH_MS, onTakeover = null } = {}) {
1879
+ if (!home) return { release() {} };
1880
+ const file = path.join(home, 'state', 'capture.lock');
1881
+ const guard = `${file}.takeover`;
1882
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1883
+ const alive = (p) => { try { process.kill(p, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
1884
+ const written = (at) => { const tmp = `${file}.${pid}`; fs.writeFileSync(tmp, JSON.stringify({ pid, at })); return tmp; };
1885
+ const linked = (target) => {
1886
+ const tmp = written(now);
1887
+ try { fs.linkSync(tmp, target); return true; } catch (e) { if (e.code !== 'EEXIST') throw e; return false; } finally { fs.rmSync(tmp, { force: true }); }
1888
+ };
1889
+ const ownedBy = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf-8')).pid === pid; } catch { return false; } };
1890
+ const mine = () => ownedBy(file);
1891
+ for (const name of fs.readdirSync(path.dirname(file))) {
1892
+ const m = /^capture\.lock\.(?:stale\.)?(\d+)$/.exec(name);
1893
+ if (m && Number(m[1]) !== pid && !alive(Number(m[1]))) fs.rmSync(path.join(path.dirname(file), name), { force: true });
1894
+ }
1895
+ const holding = () => {
1896
+ const h = { lost: false };
1897
+ // The holder's pid, null when the lock is gone; undefined when it cannot be read now (EMFILE, EIO):
1898
+ // that is no evidence of a takeover, so the next tick decides.
1899
+ const holder = () => { try { return JSON.parse(fs.readFileSync(file, 'utf-8')).pid; } catch (e) { return e.code === 'ENOENT' ? null : undefined; } };
1900
+ const timer = refreshMs > 0 ? setInterval(() => {
1901
+ try { const who = holder(); if (who === pid) fs.renameSync(written(Date.now()), file); else if (who !== undefined) h.lost = true; } catch { /* next tick */ }
1902
+ }, refreshMs) : null;
1903
+ if (timer && timer.unref) timer.unref();
1904
+ h.release = () => { if (timer) clearInterval(timer); try { if (mine()) fs.rmSync(file, { force: true }); } catch { /* gone already */ } };
1905
+ // Now, not at the next refresh: a pass resumed after a stall (a machine asleep) learns at its next
1906
+ // check that another pass took the lock meanwhile. One small read.
1907
+ // Lost only when another pid holds the lock: a missing file (another pass mid-takeover has moved it
1908
+ // aside, and puts it back when it was fresh) or one that cannot be read is no evidence.
1909
+ h.check = () => { if (!h.lost) { const who = holder(); if (Number.isInteger(who) && who !== pid) h.lost = true; } return h.lost; };
1910
+ return h;
1911
+ };
1912
+ const skip = (o) => ({ held: o || { pid: null } });
1913
+ const fresh = (o) => o && Number.isInteger(o.pid) && o.pid !== pid && alive(o.pid) && now - Number(o.at || 0) < PASS_LOCK_STALE_MS;
1914
+ const read = () => { try { return fs.readFileSync(file, 'utf-8'); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } };
1915
+ const parse = (raw) => { try { return JSON.parse(raw); } catch { return null; } };
1916
+
1917
+ if (linked(file)) return holding();
1918
+ const seen = parse(read());
1919
+ if (fresh(seen)) return skip(seen);
1920
+ if (!linked(guard)) {
1921
+ try { if (now - fs.statSync(guard).mtimeMs > 60 * 1000) fs.rmSync(guard, { force: true }); } catch { /* taken over and gone */ }
1922
+ return skip(seen);
1923
+ }
1924
+ try {
1925
+ const raw = read();
1926
+ if (raw === null) return linked(file) ? holding() : skip();
1927
+ const o = parse(raw);
1928
+ if (fresh(o)) return skip(o);
1929
+ if (onTakeover) onTakeover();
1930
+ const moved = `${file}.stale.${pid}`;
1931
+ try { fs.renameSync(file, moved); } catch (e) { if (e.code === 'ENOENT') return linked(file) ? holding() : skip(); throw e; }
1932
+ const was = fs.readFileSync(moved, 'utf-8');
1933
+ if (was !== raw) {
1934
+ try { fs.linkSync(moved, file); } catch { /* a third process holds it now */ }
1935
+ fs.rmSync(moved, { force: true });
1936
+ return skip(parse(was));
1937
+ }
1938
+ fs.rmSync(moved, { force: true });
1939
+ return linked(file) ? holding() : skip();
1940
+ } finally { if (ownedBy(guard)) fs.rmSync(guard, { force: true }); }
1941
+ }
1942
+
1943
+ /** --dry-run: discovers, reads and derives, writes nothing (every files row reads as absent). */
1944
+ class DryHouse {
1945
+ constructor() { this.inserted = {}; this.member = '(dry-run)'; }
1946
+ async init() { return this; }
1947
+ async insert(kind, rows) { this.inserted[kind] = (this.inserted[kind] || 0) + rows.length; }
1948
+ async filesRows() { return []; }
1949
+ async staleFiles() { return []; }
1950
+ async archiveRecords() { return []; }
1951
+ async maxGenerations() { return new Map(); }
1952
+ async sessionMessages() { return []; }
1953
+ async sessionsWithText() { return []; }
1954
+ async sessionsById() { return []; }
1955
+ async inheritedFrom() { return []; }
1956
+ async linkedChildren() { return []; }
1957
+ async firstCaptured() { return new Map(); }
1958
+ async rowsByKey() { return []; }
1959
+ async fileKeys() { return { t: [], c: [] }; }
1960
+ async hasRemoved() { return false; }
1961
+ async legacyRowsAll() { return { turns: [], calls: [] }; }
1962
+ async capturedRows() { return { turns: [], calls: [] }; }
1963
+ async pendingDedupe() { return []; }
1964
+ async dedupedWithLiveLegacy() { return []; }
1965
+ }
1966
+
1967
+ // ── CLI ─────────────────────────────────────────────────────────────────────────────────────
1968
+ function summary(s) {
1969
+ const ev = Object.entries(s.events).map(([k, v]) => `${k} ${v}`).join(', ');
1970
+ // The profile layout writes through the package and the normalised logs only: its line leads with
1971
+ // what those wrote, never the file engine's counts, which do not run there (profilePass).
1972
+ const lead = s.layout === 'profile'
1973
+ ? `[memhouse] v3 capture: profile layout → ${s.sessionRows} sessions, ${s.profileRows || 0} rows${s.profileRemoved ? ` (${s.profileRemoved} kept as removed versions)` : ''}, `
1974
+ + `${s.archiveRows} archive rows`
1975
+ : `[memhouse] v3 capture: ${s.changed}/${s.files} files changed in ${s.sessions} sessions → ${s.written} files rows, `
1976
+ + `${s.archiveRows} archive rows (${(s.bytes / 1048576).toFixed(1)} MiB), ${s.turns} turns, ${s.calls} calls, `
1977
+ + `${s.tombstones} tombstones, ${s.sessionRows} sessions${s.sessionsUnchanged ? ` (${s.sessionsUnchanged} unchanged, not rewritten)` : ''}`;
1978
+ return lead
1979
+ + `${ev ? ` [${ev}]` : ''}${s.waits ? `, ${s.waits} waiting on a lagging copy` : ''}${s.more ? `, ${s.more} sessions continue next pass` : ''}`
1980
+ + `${s.duplicates ? `, ${s.duplicates} duplicate files ignored` : ''}`
1981
+ + `${s.stopped ? ', STOPPED EARLY: another pass took the lock over' : ''}`
1982
+ + `${s.deduped ? `, deduped ${s.deduped} sessions: ${s.legacyTombstoned} legacy rows tombstoned, ${s.legacyKept} kept (not in the transcript)`
1983
+ + `${s.legacyImports ? `, ${s.legacyImports} imports` : ''}` : ''}`
1984
+ + `${s.cleanup ? `; cleanup: ${s.cleanup.map((c) => c.error ? `${c.kind || ''} failed: ${c.error}` : c.cleaned ? `${c.kind} ${c.tombstones} tombstones dropped (${c.rows} → ${c.after_rows} rows, ${c.ms} ms)` : `${c.kind} ${c.tombstones} tombstones kept`).join(', ')}` : ''}`
1985
+ + `${s.bridge ? `; bridge: ${s.bridge.chats} sessions in ${s.bridge.editors} editors (${s.bridge.read} read: ${s.bridge.new} new, ${s.bridge.append} appended, ${s.bridge.rewrite} rewritten) in ${s.bridge.ms} ms` : ''}`
1986
+ + `${s.adapters && s.adapters.length ? `; adapters: ${s.adapters.reduce((a, x) => a + (x.captured || 0), 0)} sessions captured in ${s.adapters.reduce((a, x) => a + (x.steps || 0), 0)} steps`
1987
+ + `${s.adapters.reduce((a, x) => a + (x.kept || 0), 0) ? `, ${s.adapters.reduce((a, x) => a + (x.kept || 0), 0)} kept on the 0.20 engine (too big at first sight)` : ''}`
1988
+ + `${s.adapters.reduce((a, x) => a + (x.heldBack || 0), 0) ? `, ${s.adapters.reduce((a, x) => a + (x.heldBack || 0), 0)} held back from the profile (too big at first sight)` : ''}`
1989
+ + `${((v, w) => (v || w ? `, ${v} vanished from their store${w ? ` (${w} more wait for the next pass)` : ''}` : ''))(s.adapters.reduce((a, x) => a + (x.vanished || 0), 0), s.adapters.reduce((a, x) => a + (x.vanishWaiting || 0), 0))}`
1990
+ + `${s.adapters.some(refusesVanish) ? `, VANISH REFUSED in ${storesN(s.adapters.filter(refusesVanish).length)} (below)` : ''}`
1991
+ + `${s.adapters.some((x) => x.unconfirmed) ? `, vanish warnings in ${storesN(s.adapters.filter((x) => x.unconfirmed).length)} (below)` : ''}`
1992
+ + `${s.adapters.some((x) => x.errors && x.errors.length) ? `, ${s.adapters.reduce((a, x) => a + ((x.errors && x.errors.length) || 0), 0)} errors` : ''}`
1993
+ + `${s.adapters.some((x) => x.error) ? `, ${s.adapters.filter((x) => x.error).length} failed (${s.adapters.filter((x) => x.error).map((x) => `${x.harness ? `${x.harness}: ` : ''}${x.error}`).join('; ')})` : ''}` : ''}`
1994
+ + `${s.normalise && (s.normalise.sessions || s.normalise.errors.length) ? `; normalise: ${s.normalise.sessions} sessions in ${s.normalise.harnesses} editors (${s.normalise.normalised} read: ${s.normalise.written.new} new, ${s.normalise.written.append} appended, ${s.normalise.written.rewrite} rewritten${s.normalise.deferred ? `, ${s.normalise.deferred} next pass` : ''}${s.normalise.errors.length ? `, ${s.normalise.errors.length} errors` : ''}) in ${s.normalise.ms} ms` : ''}`
1995
+ + `${s.redupe ? `; redupe: ${s.redupe.candidates} candidates, ${s.redupe.repaired} repaired (${s.redupe.tombstoned} legacy rows tombstoned), `
1996
+ + `${s.redupe.unchanged} unchanged, ${s.redupe.waiting} not caught up, ${s.redupe.elsewhere} not on this machine` : ''}`;
1997
+ }
1998
+
1999
+ /**
2000
+ * A pass's refused vanish sweeps (adapter-capture.js captureStore: THE VANISH SWEEP), one line per
2001
+ * store and cause, printed after the pass line, and its warnings (a listed session's source the list
2002
+ * left out that still reads). passlog.js reads them back for doctor: a refusal fails it, a warning
2003
+ * is shown, so neither is a quiet pass.
2004
+ */
2005
+ const refusesVanish = (x) => !!(x.vanishRefused || x.stillThere || x.candidateErrors);
2006
+ const storesN = (n) => `${n} store${n === 1 ? '' : 's'}`;
2007
+ /** One shell word: as is when it is plain, else single-quoted (a store path may hold spaces). */
2008
+ const shellWord = (w) => (/^[A-Za-z0-9_./:@%+=,-]+$/.test(w) ? w : `'${String(w).replace(/'/g, "'\\''")}'`);
2009
+ function vanishLines(s) {
2010
+ const out = [];
2011
+ for (const x of s.adapters || []) {
2012
+ if (x.stillThere) out.push(`[memhouse] vanish refused: ${x.harness} ${x.harness_path}: its list left out ${x.stillThere} session${x.stillThere === 1 ? '' : 's'} the store still has; read as listed, not recorded as vanished`);
2013
+ if (x.candidateErrors) out.push(`[memhouse] vanish refused: ${x.harness} ${x.harness_path}: ${x.candidateErrors} session${x.candidateErrors === 1 ? '' : 's'} its list left out could not be read (see the errors); not recorded as vanished`);
2014
+ if (x.vanishRefused) {
2015
+ out.push(`[memhouse] vanish refused: ${x.harness} ${x.harness_path}: ${x.vanishRefused}; nothing of it is recorded as vanished or removed${x.emptyStore
2016
+ ? `. If its sessions were deleted, run: memhouse doctor --confirm-empty-store ${shellWord(`${x.harness}:${x.harness_path}`)}` : ''}`);
2017
+ }
2018
+ if (x.confirmedEmpty) out.push(`[memhouse] vanish: ${x.harness} ${x.harness_path}: confirmed empty; ${x.vanished} of its sessions taken as deleted on the editor's answer${x.vanishWaiting ? `, ${x.vanishWaiting} more next pass` : ''}`);
2019
+ if (x.confirmDiscarded) out.push(`[memhouse] vanish: ${x.harness} ${x.harness_path}: the empty-store confirmation is discarded: its list names a session again`);
2020
+ if (x.unconfirmed) out.push(`[memhouse] vanish warning: ${x.harness} ${x.harness_path}: its list left out ${x.unconfirmed} source${x.unconfirmed === 1 ? '' : 's'} of listed sessions that still read; read as listed, not recorded as vanished`);
2021
+ }
2022
+ return out;
2023
+ }
2024
+
2025
+ /**
2026
+ * The bridge step of a pass: run the bridged editors' readers into their snapshot logs, then add
2027
+ * one root per snapshot directory (and its bridge capture) to what this pass captures.
2028
+ */
2029
+ /**
2030
+ * The bridge's roots beside the normalised logs' (editors/sessions-log.js): a store with a log is
2031
+ * captured from it, not from the bridge — its normaliser replaced the 0.18 reader there. Per STORE,
2032
+ * not per harness: one harness may have a bridged store and a logged one (Devin Desktop, Devin Local).
2033
+ */
2034
+ function combineRoots(bridgeRoots, logRoots) {
2035
+ const logged = new Set(logRoots.map((r) => `${r.harness}\t${r.harness_path}`));
2036
+ // A bridged store is covered by its harness's logs when a logged store is it, or lies in its
2037
+ // folder: OpenCode's stores are its DB files, inside the data folder the bridge keys by.
2038
+ const inside = (r) => logRoots.some((l) => l.harness === r.harness && String(l.harness_path).startsWith(`${r.harness_path}/`));
2039
+ return [...bridgeRoots.filter((r) => !logged.has(`${r.harness}\t${r.harness_path}`) && !inside(r)), ...logRoots];
2040
+ }
2041
+ /**
2042
+ * The sources beside the native transcripts, refreshed each pass: the bridge's snapshots (0.18
2043
+ * readers), and the v4 normalisers' session logs (memhouse/shipper/normalise-run.js) — then the
2044
+ * roots and adapters this pass captures.
2045
+ */
2046
+ /** Bridge and log roots, each carrying its adapter, and the adapter of each store (adapterOf). */
2047
+ function storeRoots(bridgeRoots, bridgeAdapters, logRoots) {
2048
+ const slog = require('../../editors/sessions-log');
2049
+ const roots = combineRoots(bridgeRoots.map((r) => ({ ...r, adapter: bridgeAdapters[r.harness] })),
2050
+ logRoots.map((r) => ({ ...r, adapter: slog.logCapture(r.harness) })));
2051
+ return { roots, storeAdapters: new Map(roots.map((r) => [`${r.harness}\t${r.harness_path}`, r.adapter])) };
2052
+ }
2053
+ // The harnesses read through @agent-realm/harness-session-adapters (memhouse/shipper/adapter-capture.js).
2054
+ // MEMHOUSE_ADAPTERS names them, comma-separated; step 3 of the switch takes Claude Code and Codex,
2055
+ // the other harnesses keep their 0.20 path until step 4.
2056
+ const ADAPTER_READY = new Set(['claude-code', 'codex']);
2057
+ function adapterHarnesses(value = process.env.MEMHOUSE_ADAPTERS) {
2058
+ return String(value || '').split(',').map((s) => s.trim()).filter((h) => ADAPTER_READY.has(h));
2059
+ }
2060
+ /**
2061
+ * Move the roots of the harnesses read through the package out of the file capture, into stores
2062
+ * ({host, harness, harness_path}), one per harness data folder: every Claude Code config directory is
2063
+ * its own store; Codex's sessions/ and archived_sessions/ are one.
2064
+ */
2065
+ function applyAdapters(ctx) {
2066
+ const want = ctx.adapterHarnesses || adapterHarnesses();
2067
+ const stores = new Map();
2068
+ // The roots stay: the file engine still discovers these sessions, and routeAdapterSessions hands each
2069
+ // to the package or keeps it (a session too big for the package path, at first sight).
2070
+ for (const r of ctx.roots || []) {
2071
+ if (!want.includes(r.harness)) continue;
2072
+ const hp = harnessPathOf(r);
2073
+ stores.set(`${r.harness}\t${hp}`, { host: ctx.host, harness: r.harness, harness_path: hp });
2074
+ }
2075
+ ctx.adapterStores = [...stores.values()];
2076
+ return ctx.adapterStores;
2077
+ }
2078
+
2079
+ // A session whose files total more than this at first sight stays on the 0.20 file engine for good.
2080
+ // The package path holds the session's whole record view (records-to-rows anchors usage on a
2081
+ // response's first row; profile-capture keeps the view and refolds a whole generation), so its memory
2082
+ // grows with the session. On the profile layout such a session is held back (profileRouting) until
2083
+ // a bounded refold lands. MEMHOUSE_ADAPTER_MAX_SESSION_BYTES overrides it.
2084
+ const ADAPTER_MAX_SESSION_BYTES = 256 * 1024 * 1024;
2085
+ function routingPath(home) { return path.join(home, 'state', 'adapter-routing.json'); }
2086
+ /**
2087
+ * Which engine captures each discovered session of a package harness, decided once and kept
2088
+ * ($MEMHOUSE_HOME/state/adapter-routing.json), so a session never flips between engines (each keys
2089
+ * its rows its own way). Up to the limit, the package; above it, the 0.20 file engine, unless the
2090
+ * house already holds package rows for it (it grew past the limit after the switch). Sessions handed
2091
+ * to the package leave `sessions`; the kept ones stay, and go into ctx.adapterKept.
2092
+ */
2093
+ async function routeAdapterSessions(ctx, sessions) {
2094
+ const want = new Set(ctx.adapterHarnesses || []);
2095
+ const limit = Number(process.env.MEMHOUSE_ADAPTER_MAX_SESSION_BYTES) || ctx.adapterMaxSessionBytes || ADAPTER_MAX_SESSION_BYTES;
2096
+ const file = ctx.home ? routingPath(ctx.home) : null;
2097
+ let routing = {};
2098
+ try { routing = file ? JSON.parse(fs.readFileSync(file, 'utf-8')) || {} : {}; } catch { routing = {}; }
2099
+ let dirty = false;
2100
+ ctx.adapterKept = new Map();
2101
+ for (const [sk, S] of [...sessions]) {
2102
+ if (!want.has(S.harness)) continue;
2103
+ let owner = routing[sk];
2104
+ const size = [...S.files.values()].reduce((a, f) => a + (Number(f.stat && f.stat.size) || 0), 0);
2105
+ if (!owner) {
2106
+ if (size <= limit) owner = 'package';
2107
+ else {
2108
+ const rows = await ctx.house.filesRows([refOf(S, ctx.host)]);
2109
+ owner = rows.some((r) => Number(r.adapter_ver) >= 1e9 && !Number(r.is_deleted)) ? 'package' : 'file';
2110
+ }
2111
+ routing[sk] = owner; dirty = true;
2112
+ }
2113
+ if (owner === 'package') sessions.delete(sk);
2114
+ else ctx.adapterKept.set(`${S.harness}\t${S.harness_path}\t${S.session_id}`, size);
2115
+ }
2116
+ if (dirty && file) {
2117
+ fs.mkdirSync(path.dirname(file), { recursive: true });
2118
+ const tmp = `${file}.${process.pid}.tmp`;
2119
+ fs.writeFileSync(tmp, JSON.stringify(routing), { mode: 0o600 });
2120
+ fs.renameSync(tmp, file);
2121
+ }
2122
+ return ctx.adapterKept;
2123
+ }
2124
+ /**
2125
+ * The package pass: every store, then the legacy dedupe of the package's sessions. The dedupe is a
2126
+ * sweep over the house's pending sessions (as dedupeSweep is for the file engine's), not over this
2127
+ * pass's captures: a session captured on an earlier pass, or one whose dedupe was cut short, is
2128
+ * deduped once the package has captured it to the end (its local state holds the list hint, which
2129
+ * is saved only after the last step). The file engine's sweep never sees these sessions: routing
2130
+ * takes them out of its list.
2131
+ */
2132
+ async function adapterPass(ctx, stats) {
2133
+ const ac = require('./adapter-capture');
2134
+ if (ctx.layout === 'profile') return profileAdapterPass(ctx, stats, ac);
2135
+ const pkg = ctx.sessionAdapters || ac.loadPackage();
2136
+ const actx = { house: ctx.house, home: ctx.home, host: ctx.host, now: ctx.now, hostLabel: ctx.hostLabel || '', buildSession, stopped: ctx.stopped };
2137
+ const out = [];
2138
+ const kept = ctx.adapterKept || new Map();
2139
+ const isKept = (k) => kept.has(`${k.harness}\t${k.harness_path}\t${k.session_id}`);
2140
+ for (const store of ctx.adapterStores) {
2141
+ const st = await ac.captureStore(actx, pkg.adapterFor(store.harness), store, { skip: isKept });
2142
+ const { refs, ...rest } = st;
2143
+ out.push(rest);
2144
+ stats.turns += st.turns; stats.calls += st.calls; stats.archiveRows += st.archived;
2145
+ stats.sessionRows = (stats.sessionRows || 0) + st.captured;
2146
+ stats.more += st.more || 0;
2147
+ if (st.stopped) { stats.stopped = true; return out; }
2148
+ }
2149
+ if (ctx.dedupe !== false && ctx.cache && typeof ctx.house.pendingDedupe === 'function') {
2150
+ const stores = new Set(ctx.adapterStores.map((s) => `${s.harness}\t${s.harness_path}`));
2151
+ for (const p of await ctx.house.pendingDedupe(5000, ctx.host, ctx.hostAliases || [])) {
2152
+ const ref = { harness: p.harness, host: ctx.host, harness_path: String(p.harness_path || ''), session_id: p.session_id, incarnation: String(p.incarnation || '') };
2153
+ if (!stores.has(`${ref.harness}\t${ref.harness_path}`) || isKept(ref)) continue;
2154
+ const sk = skey(ref.harness, ref.session_id, ref.harness_path, ref.incarnation);
2155
+ if (ctx.cache.data.deduped[sk]) continue;
2156
+ const local = ac.loadLocal(ctx.home, ref);
2157
+ if (!local || local.hint == null) continue; // not captured to the end yet
2158
+ const d = await dedupeSession(ctx, ref, stats);
2159
+ if (d && d.plan) {
2160
+ await ctx.house.insert('sessions', [buildSession(ref.harness, ref.session_id, await ctx.house.filesRows([ref]), { host: ctx.host, hostLabel: ctx.hostLabel, harnessPath: ref.harness_path, incarnation: ref.incarnation })]);
2161
+ stats.sessionRows = (stats.sessionRows || 0) + 1;
2162
+ }
2163
+ }
2164
+ ctx.cache.save();
2165
+ // --redupe for the package's sessions, as redupe() does for the file capture's.
2166
+ if (ctx.redupe) {
2167
+ const r = stats.redupe || (stats.redupe = { candidates: 0, repaired: 0, unchanged: 0, waiting: 0, elsewhere: 0, tombstoned: 0 });
2168
+ for (const p of await ctx.house.dedupedWithLiveLegacy(ctx.host, ctx.hostAliases || [])) {
2169
+ const ref = { harness: p.harness, host: ctx.host, harness_path: String(p.harness_path || ''), session_id: p.session_id, incarnation: String(p.incarnation || '') };
2170
+ if (!stores.has(`${ref.harness}\t${ref.harness_path}`) || isKept(ref)) continue;
2171
+ r.candidates++;
2172
+ const local = ac.loadLocal(ctx.home, ref);
2173
+ if (!local || local.hint == null) { r.waiting++; continue; }
2174
+ const before = stats.legacyTombstoned || 0;
2175
+ const d = await dedupeSession(ctx, ref, stats, { redo: true });
2176
+ if (d.skipped || d.unchanged) { r.unchanged++; continue; }
2177
+ r.repaired++; r.tombstoned += (stats.legacyTombstoned || 0) - before;
2178
+ await ctx.house.insert('sessions', [buildSession(ref.harness, ref.session_id, await ctx.house.filesRows([ref]), { host: ctx.host, hostLabel: ctx.hostLabel, harnessPath: ref.harness_path, incarnation: ref.incarnation })]);
2179
+ stats.sessionRows = (stats.sessionRows || 0) + 1;
2180
+ }
2181
+ }
2182
+ }
2183
+ return out;
2184
+ }
2185
+
2186
+ /**
2187
+ * The profile layout's pass: the package's stores and the normalised logs of the other editors
2188
+ * (log-adapter.js), both through profile-capture.js. The file engine, the snapshot bridge and
2189
+ * schema 3's maintenance write schema 3's tables, and two of those share their names with the
2190
+ * profile's (`{m}_sessions`, `{m}_calls`): none of them runs on a profile house.
2191
+ */
2192
+ async function profilePass(ctx) {
2193
+ // layout: 'profile' makes summary() lead with what this pass wrote (profileRows), not the file
2194
+ // engine's counts, which stay zero here.
2195
+ const stats = { layout: 'profile', sessions: 0, files: 0, changed: 0, written: 0, bytes: 0, archiveRows: 0, turns: 0, calls: 0, tombstones: 0, sessionRows: 0, waits: 0,
2196
+ rewrites: 0, upgrades: 0, more: 0, duplicates: 0, events: {}, deduped: 0, legacyTombstoned: 0, legacyKept: 0, legacyImports: 0, profileRows: 0, profileRemoved: 0 };
2197
+ try { stats.adapters = await adapterPass(ctx, stats); } catch (e) { stats.adapters = [{ error: e.message.split('\n')[0] }]; }
2198
+ if (ctx.normaliseStats) { stats.normalise = ctx.normaliseStats; ctx.normaliseStats = null; }
2199
+ // This machine's 0.18 id (the dedupe's alias) recorded in the member's meta, so readers fold its
2200
+ // converted rows into this machine (host-alias.js). Once per pair; a read-only or failed write
2201
+ // costs nothing and is tried again next pass.
2202
+ if (ctx.profileHouse && !ctx.dry && (ctx.hostAliases || [])[0]) {
2203
+ try {
2204
+ const P = ctx.profileHouse.P;
2205
+ if (await require('../house/host-alias').record(ctx.profileHouse, { meta: P.meta, sessions: P.sessions }, ctx.hostAliases[0], ctx.host)) stats.hostAlias = { old: ctx.hostAliases[0], host: ctx.host };
2206
+ } catch { /* next pass */ }
2207
+ }
2208
+ (ctx.log || (() => {}))(stats);
2209
+ return stats;
2210
+ }
2211
+
2212
+ /**
2213
+ * Which sessions the profile layout holds back, decided once per session at first sight and kept
2214
+ * ($MEMHOUSE_HOME/state/profile-routing.json) so a session never starts on the profile half way: one
2215
+ * whose source files total more than the limit (ADAPTER_MAX_SESSION_BYTES) is not captured on the
2216
+ * profile until a bounded refold lands. Sources that are not files under the store count 0.
2217
+ */
2218
+ function profileRouting(home, { stopped = () => false } = {}) {
2219
+ const file = path.join(home, 'state', 'profile-routing.json');
2220
+ let routes = {};
2221
+ try { routes = JSON.parse(fs.readFileSync(file, 'utf-8')) || {}; } catch { routes = {}; }
2222
+ let dirty = false;
2223
+ const max = Number(process.env.MEMHOUSE_ADAPTER_MAX_SESSION_BYTES) || ADAPTER_MAX_SESSION_BYTES;
2224
+ return {
2225
+ heldBack(key, listed) {
2226
+ const k = [key.harness, key.harness_path, key.session_id, key.incarnation || ''].join('\t');
2227
+ // A session the profile capture has written is on the profile: it goes on, whatever was decided
2228
+ // before (a route a stopped or killed pass could not save, a file that grew since).
2229
+ if (routes[k] !== 'profile' && require('./profile-capture').onProfile(home, key)) {
2230
+ routes[k] = 'profile'; dirty = true;
2231
+ }
2232
+ if (!(k in routes)) {
2233
+ let bytes = 0;
2234
+ for (const s of (listed && listed.sources) || []) { try { bytes += fs.statSync(path.resolve(key.harness_path, String(s.source_key))).size; } catch { /* not a file */ } }
2235
+ routes[k] = bytes > max ? 'held-back' : 'profile';
2236
+ dirty = true;
2237
+ }
2238
+ return routes[k] !== 'profile';
2239
+ },
2240
+ // A pass that lost its lock never writes the file: it is the holder's now.
2241
+ save() {
2242
+ if (!dirty || stopped()) return;
2243
+ fs.mkdirSync(path.dirname(file), { recursive: true });
2244
+ const tmp = `${file}.${process.pid}.tmp`;
2245
+ fs.writeFileSync(tmp, JSON.stringify(routes), { mode: 0o600 });
2246
+ fs.renameSync(tmp, file);
2247
+ dirty = false;
2248
+ },
2249
+ };
2250
+ }
2251
+
2252
+ /**
2253
+ * The package pass on harness-sessions-sql's profile (MEMHOUSE_LAYOUT=profile, step B): every store through
2254
+ * profile-capture.js, then the legacy dedupe of its sessions (profile-dedupe.js), a sweep over the
2255
+ * house's pending sessions as on schema 3, and --redupe over the marked ones that still hold live
2256
+ * legacy rows or report kept ones. A session too big at first sight is held back (profileRouting):
2257
+ * there is no file engine on the profile. The file engine and the bridge still write schema 3 until
2258
+ * their profile writers land.
2259
+ */
2260
+ async function profileAdapterPass(ctx, stats, ac) {
2261
+ const pc = require('./profile-capture');
2262
+ const pd = require('../house/profile-dedupe');
2263
+ const ph = ctx.profileHouse;
2264
+ const actx = { house: ph, home: ctx.home, now: ctx.now, hostLabel: ctx.hostLabel || '', captureSession: pc.captureSession, stateNs: pc.NS, prefetch: pc.prefetch,
2265
+ rowsVer: pc.ROWS_VER, edgesVer: pc.EDGES_VER, edgeBudget: { left: pc.FORK_BUDGET }, stopped: ctx.stopped };
2266
+ const out = [];
2267
+ const routing = profileRouting(ctx.home, { stopped: () => !!(ctx.stopped && ctx.stopped()) });
2268
+ // The normalised logs of the editors the package does not read (adapter 'memhouse-bridge').
2269
+ const { logAdapter } = require('./log-adapter');
2270
+ const pkgHarnesses = new Set((ctx.adapterStores || []).map((s) => s.harness));
2271
+ const logStores = (ctx.logRoots || scopedLogRoots(ctx))
2272
+ .filter((r) => !pkgHarnesses.has(r.harness)).map((r) => ({ host: ctx.host, harness: r.harness, harness_path: r.harness_path }));
2273
+ // The package is loaded only for the stores it reads: the logs need none, and a package that is
2274
+ // missing or broken fails its own stores, never the pass. So does any store that throws.
2275
+ let pkg = ctx.sessionAdapters || null;
2276
+ const pkgOf = () => pkg || (pkg = ac.loadPackage());
2277
+ const runs = [...(ctx.adapterStores || []).map((s) => [s, () => pkgOf().adapterFor(s.harness)]), ...logStores.map((s) => [s, () => logAdapter(ctx.home, s.harness)])];
2278
+ for (const [store, adOf] of runs) {
2279
+ let st;
2280
+ try { st = await ac.captureStore(actx, adOf(), store, { skip: routing.heldBack }); } catch (e) {
2281
+ out.push({ harness: store.harness, harness_path: store.harness_path, sessions: 0, captured: 0, steps: 0, errors: [], error: String(e && e.message).split('\n')[0] });
2282
+ continue;
2283
+ }
2284
+ const { refs, kept, ...rest } = st;
2285
+ out.push({ ...rest, heldBack: kept });
2286
+ stats.archiveRows += st.archived;
2287
+ stats.sessionRows = (stats.sessionRows || 0) + st.captured;
2288
+ stats.profileRows = (stats.profileRows || 0) + (st.rows || 0);
2289
+ stats.profileRemoved = (stats.profileRemoved || 0) + (st.removed || 0);
2290
+ stats.more += st.more || 0;
2291
+ if (st.stopped) { stats.stopped = true; break; }
2292
+ }
2293
+ // A stopped pass does not save: the routes file is the holder's. A session it captured first keeps
2294
+ // going on the profile anyway (heldBack reads its local state).
2295
+ if (stats.stopped) return out;
2296
+ routing.save();
2297
+ if (ctx.dedupe === false) return out;
2298
+ const stores = new Set(runs.map(([s]) => `${s.harness}\t${s.harness_path}`));
2299
+ const aliases = ctx.hostAliases || [];
2300
+ const refOf = (p) => ({ harness: p.harness, host: ctx.host, harness_path: String(p.harness_path || ''), session_id: p.session_id, incarnation: String(p.incarnation || '') });
2301
+ // Captured to the end: the local state holds the list hint, saved only after the last step.
2302
+ const caught = (ref) => { const l = ac.loadLocal(ctx.home, ref, pc.NS); return !!l && l.hint != null; };
2303
+ const o = { aliases, repeatsOf: (ref) => profileRepeatsOf(ph, ref) };
2304
+ const count = (d) => {
2305
+ const r = d.plan.report;
2306
+ stats.deduped = (stats.deduped || 0) + 1;
2307
+ stats.legacyTombstoned = (stats.legacyTombstoned || 0) + r.tombstoned.turns + r.tombstoned.calls;
2308
+ stats.legacyKept = (stats.legacyKept || 0) + r.kept.turns + r.kept.calls;
2309
+ stats.legacyImports = (stats.legacyImports || 0) + r.imports.turns + r.imports.calls;
2310
+ if (r.kept.turns || r.kept.calls) (ctx.log || (() => {}))({ dedupe: `${r.harness}:${r.session_id}`, matched: r.matched, kept: r.kept, captured: r.captured });
2311
+ };
2312
+ const passStores = runs.map(([x]) => ({ harness: x.harness, harness_path: x.harness_path }));
2313
+ for (const p of await ph.rows(`${pd.storesWithLegacySQL(ph.P, ctx.host, { marked: false, aliases, stores: passStores })} LIMIT 5000`)) {
2314
+ const ref = refOf(p);
2315
+ if (!stores.has(`${ref.harness}\t${ref.harness_path}`) || !caught(ref)) continue;
2316
+ const d = await pd.dedupeProfileSession(ph, ph.P, ref, o);
2317
+ if (d.plan) count(d);
2318
+ }
2319
+ if (ctx.redupe) {
2320
+ const r = stats.redupe || (stats.redupe = { candidates: 0, repaired: 0, unchanged: 0, waiting: 0, elsewhere: 0, tombstoned: 0 });
2321
+ for (const p of await ph.rows(pd.storesWithLegacySQL(ph.P, ctx.host, { marked: true, aliases }))) {
2322
+ const ref = refOf(p);
2323
+ if (!stores.has(`${ref.harness}\t${ref.harness_path}`)) continue;
2324
+ r.candidates++;
2325
+ if (!caught(ref)) { r.waiting++; continue; }
2326
+ const before = stats.legacyTombstoned || 0;
2327
+ const d = await pd.dedupeProfileSession(ph, ph.P, ref, { ...o, redo: true });
2328
+ if (!d.plan || d.unchanged) { r.unchanged++; continue; }
2329
+ count(d);
2330
+ r.repaired++; r.tombstoned += (stats.legacyTombstoned || 0) - before;
2331
+ }
2332
+ }
2333
+ return out;
2334
+ }
2335
+
2336
+ /**
2337
+ * The normalised logs a pass may capture. Logs an excluded editor's normaliser wrote before
2338
+ * MEMHOUSE_EDITORS (or ctx.editors) narrowed it are not captured, whether the normalisers run this
2339
+ * pass or not.
2340
+ */
2341
+ function scopedLogRoots(ctx) {
2342
+ const editors = ctx.editors !== undefined ? ctx.editors : process.env.MEMHOUSE_EDITORS;
2343
+ const scope = require('../../editors/scope');
2344
+ return require('../../editors/sessions-log').logRoots(ctx.home).filter((r) => scope.normaliserInScope(r.harness, editors));
2345
+ }
2346
+
2347
+ function applyBridge(ctx) {
2348
+ let m = null; let b = { roots: [], adapters: {} };
2349
+ // ctx.editors narrows like MEMHOUSE_EDITORS (tests); the environment otherwise.
2350
+ const editors = ctx.editors !== undefined ? ctx.editors : process.env.MEMHOUSE_EDITORS;
2351
+ if (ctx.bridge !== false) {
2352
+ const snapshots = require('./snapshots');
2353
+ m = snapshots.materialize({ home: ctx.home, editors, log: (l) => console.error(l) });
2354
+ b = snapshots.bridgeRoots(m.dir, m.harnesses);
2355
+ }
2356
+ if (ctx.normalise !== false) {
2357
+ try { ctx.normaliseStats = require('./normalise-run').run({ home: ctx.home, editors, log: (l) => console.error(l) }); }
2358
+ catch (e) { console.error(`[memhouse] normalisers skipped this pass: ${e.message}`); }
2359
+ }
2360
+ ctx.logRoots = scopedLogRoots(ctx);
2361
+ const s = storeRoots(b.roots, b.adapters, ctx.logRoots);
2362
+ ctx.roots = [...(ctx.baseRoots || []), ...s.roots];
2363
+ ctx.storeAdapters = s.storeAdapters;
2364
+ if (ctx.adapterHarnesses && ctx.adapterHarnesses.length) applyAdapters(ctx);
2365
+ ctx.adapters = { ...defaultAdapters(), ...b.adapters };
2366
+ ctx.bridgeStats = m ? m.stats : null;
2367
+ return m;
2368
+ }
2369
+
2370
+ /**
2371
+ * opts.afterSleep: called after each loop interval, before the next pass — ship.js passes its
2372
+ * self-update check, so a capture daemon notices an upgrade the way the 0.18 shipper does.
2373
+ */
2374
+ /** The cache as the file holds it now (see the ship loop: read again by every pass that takes the lock). */
2375
+ function reloadCache(ctx) {
2376
+ if (ctx.cache && ctx.cache.file) ctx.cache = new Cache(ctx.cache.file).load();
2377
+ if (ctx.cache) ctx.cache.guard = () => !!(ctx.stopped && ctx.stopped());
2378
+ }
2379
+
2380
+ /**
2381
+ * `ship --duplicates` lists live captured + legacy pairs in layout 3.2's turns (dedupe-v3.duplicates).
2382
+ * A profile house has no turns table to read: refused, with what to run there instead.
2383
+ */
2384
+ function duplicatesRefusal(layout) {
2385
+ if (layout !== 'profile') return null;
2386
+ return 'ship --duplicates reads layout 3.2\'s turns, and this capture runs on the profile layout, which has no duplicates report yet '
2387
+ + '(a 3.2 development house takes MEMHOUSE_LAYOUT=v3-dev). Every profile pass dedupes the sessions it captures against their '
2388
+ + 'converted rows; `memhouse ship --redupe` repairs the ones deduped too early.';
2389
+ }
2390
+
2391
+ async function main(argv = process.argv.slice(2), opts = {}) {
2392
+ const env = process.env;
2393
+ if (env.MEMHOUSE_SCHEMA && env.MEMHOUSE_SCHEMA !== '3') {
2394
+ console.error(`[memhouse] capture is the schema-3 writer; MEMHOUSE_SCHEMA=${env.MEMHOUSE_SCHEMA}. Refusing.`);
2395
+ process.exit(2);
2396
+ }
2397
+ const doctor = argv.includes('--duplicates'); // reads the house only: no source roots needed
2398
+ const dry = argv.includes('--dry-run');
2399
+ // Unset MEMHOUSE_SOURCE_ROOTS: the machine's default (source-roots.js), re-resolved each pass.
2400
+ const defaulted = !env.MEMHOUSE_SOURCE_ROOTS;
2401
+ const resolveRoots = () => (defaulted ? require('./source-roots').defaultSourceRoots().roots : parseSourceRoots(env.MEMHOUSE_SOURCE_ROOTS));
2402
+ let roots = [];
2403
+ if (!doctor) {
2404
+ try { roots = resolveRoots(); } catch (e) { console.error(`[memhouse] ${e.message}`); process.exit(2); }
2405
+ // No Claude Code or Codex here is no reason to stop: the normalisers and the bridge read every
2406
+ // other editor (a machine with Cursor alone ships). Said once, then the pass goes on.
2407
+ if (defaulted && !roots.length) {
2408
+ console.error('[memhouse] no Claude Code projects dir or Codex sessions dir in the default places (~/.claude, ~/.claude-playbooks/*, ~/.codex);');
2409
+ console.error('[memhouse] the other editors are still read. Set MEMHOUSE_SOURCE_ROOTS to name one elsewhere.');
2410
+ }
2411
+ }
2412
+ let house;
2413
+ if (dry) house = new DryHouse();
2414
+ else {
2415
+ if (!env.MEMHOUSE_URL || !env.MEMHOUSE_USER) { console.error('[memhouse] MEMHOUSE_URL and MEMHOUSE_USER are required (or --dry-run).'); process.exit(2); }
2416
+ let password = env.MEMHOUSE_PASSWORD || '';
2417
+ if (!password && env.MEMHOUSE_PASSWORD_FILE) password = fs.readFileSync(env.MEMHOUSE_PASSWORD_FILE, 'utf-8').trim();
2418
+ house = new HttpHouse({ url: env.MEMHOUSE_URL, user: env.MEMHOUSE_USER, password, db: env.MEMHOUSE_DB || 'mem', member: env.MEMHOUSE_MEMBER || null });
2419
+ }
2420
+ // Schema 3 is harness-sessions-sql's profile (step B): the package's stores write it, and every reader
2421
+ // reads it. MEMHOUSE_LAYOUT=v3-dev keeps the 0.20 development builds' writers (turns, files,
2422
+ // records) for a development house; a dry run uses them too, since it writes nothing.
2423
+ const layout = dry || env.MEMHOUSE_LAYOUT === 'v3-dev' ? '3' : 'profile';
2424
+ const loopIdx = argv.indexOf('--loop');
2425
+ const loop = loopIdx !== -1;
2426
+ const interval = loop && Number(argv[loopIdx + 1]) > 0 ? Number(argv[loopIdx + 1]) : 300;
2427
+ // A daemon started before the network (login, boot) must wait for the house, not die: the
2428
+ // 0.18 loop retried with backoff, and an unsupervised `memhouse start` daemon that exits here
2429
+ // never comes back. Logged as a failed pass so `status` and `doctor` can say why.
2430
+ for (let wait = 15; ; wait = Math.min(wait * 2, interval)) {
2431
+ try { await house.init(); break; } catch (e) {
2432
+ if (!loop) throw e;
2433
+ console.error(`[memhouse] v3 pass failed: ${e.message} — retrying in ${wait}s`);
2434
+ await new Promise((r) => setTimeout(r, wait * 1000));
2435
+ }
2436
+ }
2437
+ // The profile is never captured into a house that still holds the member's development layout
2438
+ // (<m>_turns): its admin moves the member first. MEMHOUSE_LAYOUT=v3-dev keeps capturing into it.
2439
+ if (layout === 'profile' && !dry) {
2440
+ const sc = require('../house/schema-choice');
2441
+ const devOf = async () => sc.devLayoutRefusal(await sc.detectHouseSchema((sql) => house.rows(sql), house.db, house.member), house.member);
2442
+ // A looping shipper waits, captures nothing, and checks again each interval: it starts once
2443
+ // the member has been moved, and never writes the profile beside <m>_turns meanwhile.
2444
+ for (let dev = await devOf(); dev; dev = await devOf()) {
2445
+ console.error(`[memhouse] ✗ not capturing: ${dev}. Until then this machine can keep capturing the development layout with MEMHOUSE_LAYOUT=v3-dev.`);
2446
+ if (!loop) { process.exitCode = 2; return; }
2447
+ await new Promise((r) => setTimeout(r, interval * 1000));
2448
+ }
2449
+ }
2450
+ const home = env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
2451
+ const ident = dry ? 'dry-run' : `${env.MEMHOUSE_URL}|${house.db}|${house.member}`;
2452
+ const cache = dry ? new Cache(null) : new Cache(cachePath(home, ident)).load();
2453
+ // The machine in every key: sessions-model/host/v0, derived from the OS machine id (host.js).
2454
+ // memhouse's own `<short>-<fp8>` id is this machine's alias for rows written before 0.20 (0.18
2455
+ // shipped them under it): the dedupe matches them as this host's.
2456
+ const hostMod = require('../host');
2457
+ const host = env.MEMHOUSE_HOST_ID || hostMod.sessionsHost();
2458
+ const legacyHost = (() => { try { return hostMod.identity().id; } catch { return ''; } })();
2459
+ const backfill = argv.includes('--backfill');
2460
+ const ctx = {
2461
+ house, cache, host, roots, adapters: defaultAdapters(), now: Date.now,
2462
+ hostLabel: hostMod.hostLabel(), hostAliases: legacyHost && legacyHost !== host ? [legacyHost] : [],
2463
+ // Dedupe is always on (the rule: never two copies of a record); --no-dedupe exists for
2464
+ // diagnosis. --reconcile, its predecessor's opt-in, is accepted and changes nothing.
2465
+ dedupe: !argv.includes('--no-dedupe') && env.MEMHOUSE_DEDUPE !== '0',
2466
+ redupe: argv.includes('--redupe'),
2467
+ // Tombstone cleanup (tombstoneCleanup): daily; --cleanup forces one now; MEMHOUSE_CLEANUP=0 / --no-cleanup turn it off.
2468
+ cleanup: !argv.includes('--no-cleanup') && env.MEMHOUSE_CLEANUP !== '0' && !dry,
2469
+ cleanupNow: argv.includes('--cleanup'),
2470
+ // The bridge (editors/bridge.js, snapshots.js): every editor without a transcript-file capture,
2471
+ // read through its 0.18 reader into snapshot logs. On unless MEMHOUSE_BRIDGE=0 or --no-bridge.
2472
+ bridge: !argv.includes('--no-bridge') && env.MEMHOUSE_BRIDGE !== '0' && !dry,
2473
+ // The v4 normalisers (editors/normalise/): their session logs replace the bridge store by store.
2474
+ // On unless MEMHOUSE_NORMALISE=0 or --no-normalise.
2475
+ normalise: !argv.includes('--no-normalise') && env.MEMHOUSE_NORMALISE !== '0' && !dry,
2476
+ // The harnesses read through @agent-realm/harness-session-adapters instead (MEMHOUSE_ADAPTERS; step 3).
2477
+ adapterHarnesses: adapterHarnesses(env.MEMHOUSE_ADAPTERS),
2478
+ home, layout, dry,
2479
+ };
2480
+ if (layout === 'profile') {
2481
+ // The package reads its harnesses; the normalisers write the other editors' logs, which the
2482
+ // profile engine reads through log-adapter.js. The snapshot bridge (schema 3's writer for the
2483
+ // editors with neither) stays off: those editors are not captured on the profile yet.
2484
+ ctx.bridge = false;
2485
+ if (!env.MEMHOUSE_ADAPTERS) ctx.adapterHarnesses = [...ADAPTER_READY];
2486
+ console.error(`[memhouse] profile layout: ${ctx.adapterHarnesses.join(', ') || 'no harness'} through the package, the normalised editors through their logs; editors with neither are not captured on this layout yet.`);
2487
+ const { ProfileHouse } = require('../house/profile-house');
2488
+ ctx.profileHouse = new ProfileHouse(house, require('../house/schema-profile').profileNames(house.db, house.member));
2489
+ }
2490
+ ctx.baseRoots = roots;
2491
+ if (doctor) {
2492
+ // The doctor: live captured + legacy pairs with the same text, per session (dedupe-v3.duplicates).
2493
+ if (dry) { console.error('[memhouse] --duplicates reads the house; it has nothing to read in a dry run.'); process.exit(2); }
2494
+ const refused = duplicatesRefusal(layout);
2495
+ if (refused) { console.error(`[memhouse] ${refused}`); process.exit(2); }
2496
+ const d = await dedupe.duplicates(house, house.t);
2497
+ console.log(JSON.stringify(d, null, 2));
2498
+ return;
2499
+ }
2500
+ console.log(`[memhouse] v3 capture → ${dry ? 'dry run (nothing written)' : `${env.MEMHOUSE_URL} as ${house.member} (${house.db}, ${house.adb})`}; host ${host}`);
2501
+ const showRoots = (rs) => { for (const r of rs) console.log(`[memhouse] ${r.harness.padEnd(11)} ${r.name.padEnd(14)} ${r.dir}`); };
2502
+ if (defaulted) console.log('[memhouse] (MEMHOUSE_SOURCE_ROOTS unset — the default roots, re-read every pass)');
2503
+ showRoots(roots);
2504
+ let passes = 0;
2505
+ do {
2506
+ const t0 = Date.now();
2507
+ if (defaulted && passes++ > 0) {
2508
+ // A playbook installed since the last pass is a new root; one removed drops out.
2509
+ try {
2510
+ const next = resolveRoots();
2511
+ const key = (rs) => rs.map((r) => `${r.harness}|${r.name}|${r.dir}`).join('\n');
2512
+ if (key(next) !== key(ctx.baseRoots)) { console.log('[memhouse] source roots changed:'); showRoots(next); ctx.baseRoots = next; ctx.roots = next; }
2513
+ } catch (e) { console.error(`[memhouse] could not re-read the default roots (keeping the last set): ${e.message}`); }
2514
+ }
2515
+ if (ctx.bridge || ctx.normalise) {
2516
+ try { applyBridge(ctx); } catch (e) { console.error(`[memhouse] bridge skipped this pass: ${e.message}`); }
2517
+ } else if (ctx.adapterHarnesses.length) {
2518
+ ctx.roots = [...(ctx.baseRoots || [])];
2519
+ applyAdapters(ctx);
2520
+ }
2521
+ const lock = dry ? { release() {} } : passLock(ctx.home);
2522
+ if (lock.held) console.log(`[memhouse] another capture pass is running in this home${lock.held.pid ? ` (pid ${lock.held.pid})` : ''}: this pass is skipped`);
2523
+ else try {
2524
+ let s;
2525
+ ctx.stopped = () => !!(lock.lost || (lock.check && lock.check()));
2526
+ // The cache file is read again by every pass that takes the lock: another pass may have saved
2527
+ // it since this process last did (a one-shot ship while a daemon slept, or a pass that took
2528
+ // over from this one), and this pass's save must never write an older copy back over it.
2529
+ reloadCache(ctx);
2530
+ do {
2531
+ s = await runPass(ctx);
2532
+ console.log(`${summary(s)} in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
2533
+ for (const line of vanishLines(s)) console.log(line);
2534
+ // Without the package a profile pass captures no Claude Code or Codex session at all; that
2535
+ // must not read as a quiet pass with nothing new.
2536
+ const ac = require('./adapter-capture');
2537
+ const unusable = (s.adapters || []).find((x) => ac.isPackageMissing(x.error));
2538
+ if (unusable) {
2539
+ console.error(`[memhouse] ✗ ${ac.packageMessage(unusable.error)}`);
2540
+ if (!loop) process.exitCode = 1;
2541
+ }
2542
+ if (s.stopped || lock.lost) {
2543
+ console.error('[memhouse] another capture pass took this home\'s lock over (this one stalled past its stale limit): this pass stopped early');
2544
+ if (!loop) process.exitCode = 1;
2545
+ break;
2546
+ }
2547
+ } while (backfill && s.more > 0);
2548
+ } catch (e) {
2549
+ console.error(`[memhouse] v3 pass failed: ${e.message}`);
2550
+ if (env.MEMHOUSE_DEBUG) console.error(e);
2551
+ if (!loop) process.exitCode = 1;
2552
+ } finally { lock.release(); ctx.stopped = null; }
2553
+ if (loop) {
2554
+ await new Promise((r) => setTimeout(r, interval * 1000));
2555
+ if (opts.afterSleep) opts.afterSleep();
2556
+ }
2557
+ } while (loop);
2558
+ }
2559
+
2560
+ module.exports = {
2561
+ applyAdapters, adapterHarnesses, adapterPass, routeAdapterSessions, ADAPTER_MAX_SESSION_BYTES,
2562
+ parseSourceRoots, classifyRoot, discover, Cache, cachePath, captureFile, runPass, buildSession, batches,
2563
+ archiveRows, recordsFromArchive, rebuildFromArchive, replayArchive, manifestRow, fkey, splitLines, prefixDigest, extendDigest, digestOf, digestBuffer, HttpHouse, DryHouse,
2564
+ TABLE_COLUMNS, dedupeSession, dedupeSweep, codexRepeatsOf, profileRepeatsOf, profileAdapterPass, profileRouting, main, defaultAdapters, applyBridge, combineRoots, storeRoots, adapterOf, summary, vanishLines,
2565
+ passLock, PASS_LOCK_STALE_MS, PASS_LOCK_REFRESH_MS, duplicatesRefusal, reloadCache,
2566
+ BLOCK, ANCHOR, CHUNK, BATCH_ROWS, BATCH_BYTES, FRAGMENT_MS,
2567
+ };
2568
+
2569
+ if (require.main === module) {
2570
+ main().catch((e) => { console.error(`[memhouse] fatal: ${e.message}`); process.exit(1); });
2571
+ }