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,756 @@
1
+ 'use strict';
2
+ // Capture through @agent-realm/harness-session-adapters: memhouse as a sessions v0 consumer (step 3 of
3
+ // the switch, designed 2026-10-23: the package reads and derives each store, memhouse archives what is
4
+ // new and writes only the rows that changed).
5
+ //
6
+ // One store at a time. `list` names the sessions and their sources; per session, every source is
7
+ // read from its position, the new units are archived, `derive` runs over what is new, and only the
8
+ // rows that changed are written. The table shapes are not this module's: a `mapping` turns a
9
+ // session's record view into rows (records-to-rows for schema 3; harness-sessions-sql's profile in step B).
10
+ //
11
+ // Where the state lives. The house is the truth, the local state an accelerator:
12
+ // a source's generation, position and known units its `files` row (file_key = source_key,
13
+ // fgen = generation, derive_state = {position,
14
+ // known, ords})
15
+ // the units archive `records`, kind 'unit', raw = the unit
16
+ // as JSON, in 1 MiB chunks
17
+ // derive state, record view, row hashes, first-seen times, last rev
18
+ // $MEMHOUSE_HOME/state/derive/<sha1>.json
19
+ // Lost or stale local state is never a loss: the session refolds from its archived units, every
20
+ // row is written again at a higher rev, and keys the house holds that the refold no longer
21
+ // produces become removed versions.
22
+ //
23
+ // Write order, as capture.js: archive, then rows, then files, then sessions; the local state last.
24
+
25
+ const crypto = require('crypto');
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+
29
+ const base = require('../../editors/base').capture;
30
+ const recordsToRows = require('../house/records-to-rows');
31
+ const { ADAPTER: BRIDGE } = require('./log-adapter');
32
+
33
+ const CHUNK = 1 << 20;
34
+ const EPOCH_ISO = '1970-01-01T00:00:00.000Z';
35
+
36
+ /**
37
+ * The package. MEMHOUSE_SESSION_ADAPTERS names a package root for development (a harness-skeleton
38
+ * checkout's packages/harness-session-adapters, kept outside git); otherwise the installed
39
+ * dependency. A failure to load it says where it looked (PACKAGE_UNLOADABLE), so a pass never takes
40
+ * it for one store's error. A package whose adapters for the harnesses memhouse reads through it
41
+ * (ADAPTER_HARNESSES) still name themselves @agent-realm/session-adapters/… predates the rename:
42
+ * their rows would read as another adapter's (profile-capture.js, adapter switch), and a pass would
43
+ * re-read and re-archive every session. It is refused (PACKAGE_RENAMED).
44
+ */
45
+ const PACKAGE_NAME = '@agent-realm/harness-session-adapters';
46
+ const OLD_ADAPTER_PREFIX = '@agent-realm/session-adapters';
47
+ const ADAPTER_HARNESSES = ['claude-code', 'codex'];
48
+ const PACKAGE_MISSING = 'harness-session-adapters missing: Claude Code and Codex are not captured on the profile layout (install @agent-realm/harness-session-adapters)';
49
+ const PACKAGE_UNLOADABLE = 'harness-session-adapters could not be loaded, so Claude Code and Codex are not captured';
50
+ const PACKAGE_RENAMED = 'harness-session-adapters is from before its rename, so Claude Code and Codex are not captured';
51
+ function loadPackage(env = process.env) {
52
+ const root = env.MEMHOUSE_SESSION_ADAPTERS;
53
+ const where = root ? `MEMHOUSE_SESSION_ADAPTERS=${root}` : PACKAGE_NAME;
54
+ let pkg;
55
+ try { pkg = root ? require(path.join(path.resolve(root), 'src', 'index.js')) : require(PACKAGE_NAME); }
56
+ catch (e) { throw new Error(`${PACKAGE_UNLOADABLE} (${where}): ${String((e && e.message) || e).split('\n')[0]}`); }
57
+ const isOld = (a) => a === OLD_ADAPTER_PREFIX || a.startsWith(`${OLD_ADAPTER_PREFIX}/`);
58
+ const old = ADAPTER_HARNESSES.filter((h) => {
59
+ try { return isOld(String(pkg.adapterFor(h).describe().adapter || '')); } catch { return false; }
60
+ });
61
+ if (old.length) {
62
+ throw new Error(`${PACKAGE_RENAMED}: ${where} names its ${old.join(' and ')} adapter${old.length > 1 ? 's' : ''} ${OLD_ADAPTER_PREFIX}/…. ${root
63
+ ? 'Point MEMHOUSE_SESSION_ADAPTERS at packages/harness-session-adapters of an updated harness-skeleton checkout, or unset it to use the installed package.'
64
+ : `Reinstall memhouse, which depends on ${PACKAGE_NAME}.`}`);
65
+ }
66
+ return pkg;
67
+ }
68
+
69
+ /** The loaded package's version, from its package.json (1.0.0 exports none). '' when unknown. */
70
+ function packageVersion(env = process.env) {
71
+ try {
72
+ const root = env.MEMHOUSE_SESSION_ADAPTERS;
73
+ if (root) return String(JSON.parse(fs.readFileSync(path.join(path.resolve(root), 'package.json'), 'utf-8')).version || '');
74
+ for (let dir = path.dirname(require.resolve(PACKAGE_NAME)), i = 0; i < 4; dir = path.dirname(dir), i++) {
75
+ const f = path.join(dir, 'package.json');
76
+ if (!fs.existsSync(f)) continue;
77
+ const p = JSON.parse(fs.readFileSync(f, 'utf-8'));
78
+ if (p.name === PACKAGE_NAME) return String(p.version || '');
79
+ }
80
+ } catch { /* unknown */ }
81
+ return '';
82
+ }
83
+
84
+ /** Whether the package loads: { ok, version } or { ok: false, error }. Never throws. */
85
+ function packageStatus(env = process.env) {
86
+ try {
87
+ const pkg = loadPackage(env);
88
+ return { ok: true, version: String((pkg && pkg.version) || packageVersion(env)) };
89
+ } catch (e) { return { ok: false, error: String((e && e.message) || e).split('\n')[0] }; }
90
+ }
91
+
92
+ /** Whether an adapter's error is the package failing to load at all, or loading from before the rename. */
93
+ const isPackageMissing = (msg) => String(msg || '').startsWith(PACKAGE_UNLOADABLE) || String(msg || '').startsWith(PACKAGE_RENAMED)
94
+ || /Cannot find module '@agent-realm\/harness-session-adapters'|MEMHOUSE_SESSION_ADAPTERS/.test(String(msg || ''))
95
+ || /Cannot find module '[^']*harness-session-adapters[^']*'/.test(String(msg || ''));
96
+
97
+ /** What a pass or doctor says about such an error: loadPackage's own errors say what and where. */
98
+ const packageMessage = (msg) => (String(msg || '').startsWith(PACKAGE_UNLOADABLE) || String(msg || '').startsWith(PACKAGE_RENAMED)
99
+ ? String(msg) : PACKAGE_MISSING);
100
+
101
+ const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
102
+ const sessionId = (k) => [k.host, k.harness, k.harness_path, k.session_id, k.incarnation || ''].join('\u0000');
103
+
104
+ /**
105
+ * "0.1.0" as the UInt32 the adapter_ver columns hold: 10^9 + major·10^6 + minor·10^3 + patch. The
106
+ * 10^9 floor keeps every package row apart from the 0.20 engines' (Claude Code 2, Codex 3, the log
107
+ * derive 1005), so a session switching to the package can tell its old rows from its own.
108
+ */
109
+ const PACKAGE_VER_FLOOR = 1e9;
110
+ function verNumber(v) {
111
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v || ''));
112
+ return m ? PACKAGE_VER_FLOOR + Number(m[1]) * 1e6 + Number(m[2]) * 1e3 + Number(m[3]) : 0;
113
+ }
114
+ const isPackageRow = (r) => Number(r.adapter_ver) >= PACKAGE_VER_FLOOR;
115
+
116
+ /** JSON with object keys sorted: the canonical form for the scalars and plain objects a link key holds. */
117
+ function canon(v) {
118
+ if (Array.isArray(v)) return `[${v.map(canon).join(',')}]`;
119
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
120
+ return JSON.stringify(v === undefined ? null : v);
121
+ }
122
+ /** A record's own key in its session, as `live` lists it (sessions §2). */
123
+ function recordKey(r) {
124
+ switch (r.type) {
125
+ case 'session': return '';
126
+ case 'message': return String(r.message_id);
127
+ case 'usage': return String(r.usage_id);
128
+ case 'lifecycle': return String(r.lifecycle_id);
129
+ case 'link': return canon([r.kind, r.stated_on === 'origin' ? r.child : r.origin, r.at ?? '']);
130
+ default: throw new Error(`unknown record type ${r.type}`);
131
+ }
132
+ }
133
+ /** The view after one derive: `changed` upserted, then every key absent from both `live` and `held` dropped. */
134
+ function applyDerive(view, { changed, live, held = {} }) {
135
+ const next = new Map(view);
136
+ for (const r of changed) next.set(`${r.type}\u0000${recordKey(r)}`, r);
137
+ const keep = new Set();
138
+ for (const set of [live || {}, held || {}]) for (const [type, keys] of Object.entries(set)) for (const k of keys) keep.add(`${type}\u0000${k}`);
139
+ for (const k of [...next.keys()]) if (!keep.has(k)) next.delete(k);
140
+ return next;
141
+ }
142
+
143
+ // ── local state ──────────────────────────────────────────────────────────────────────────────
144
+ /** `ns`: the state's namespace, one per table layout ('derive' for schema 3, 'derive-profile'). */
145
+ function statePath(home, key, ns = 'derive') { return path.join(home, 'state', ns, `${sha1(sessionId(key))}.json`); }
146
+ function loadLocal(home, key, ns) {
147
+ try {
148
+ const s = JSON.parse(fs.readFileSync(statePath(home, key, ns), 'utf-8'));
149
+ return s && s.v === 1 && s.id === sessionId(key) ? s : null;
150
+ } catch { return null; }
151
+ }
152
+ function saveLocal(home, key, s, ns) {
153
+ const f = statePath(home, key, ns);
154
+ fs.mkdirSync(path.dirname(f), { recursive: true });
155
+ const tmp = `${f}.${process.pid}.tmp`;
156
+ fs.writeFileSync(tmp, JSON.stringify({ ...s, v: 1, id: sessionId(key) }), { mode: 0o600 });
157
+ fs.renameSync(tmp, f);
158
+ }
159
+
160
+ // ── archive ──────────────────────────────────────────────────────────────────────────────────
161
+ /** One unit as archive rows: its JSON, chunked; `pos` orders it within its source's generation. */
162
+ function unitRows(key, sourceKey, gen, pos, unit, capturedAt) {
163
+ const raw = JSON.stringify(unit);
164
+ const rows = [];
165
+ for (let i = 0, c = 0; i < raw.length || c === 0; i += CHUNK, c++) {
166
+ rows.push({ harness: key.harness, harness_path: key.harness_path, session_id: key.session_id, incarnation: key.incarnation || '',
167
+ file_key: sourceKey, fgen: gen, pos, chunk: c, sha: unit.sha256, kind: 'unit', enc: '', raw: raw.slice(i, i + CHUNK), host: key.host, captured_at: capturedAt });
168
+ }
169
+ return rows;
170
+ }
171
+ /** The units of one generation, back from their archive rows. */
172
+ function unitsFromArchive(rows) {
173
+ const byPos = new Map();
174
+ for (const r of rows) {
175
+ if (r.kind !== 'unit') continue;
176
+ const k = Number(r.pos);
177
+ if (!byPos.has(k)) byPos.set(k, []);
178
+ byPos.get(k).push(r);
179
+ }
180
+ return [...byPos.keys()].sort((a, b) => a - b).map((k) => JSON.parse(byPos.get(k).sort((a, b) => a.chunk - b.chunk).map((r) => r.raw).join('')));
181
+ }
182
+
183
+ // ── rows ─────────────────────────────────────────────────────────────────────────────────────
184
+ function tsWire(iso) { const ms = Date.parse(iso); return base.chTs(Number.isFinite(ms) ? ms : 0); }
185
+ /** schema 3 rows from the mapping's output, every column filled. */
186
+ function schema3Rows(mapped, key, { rev, adapterVer }) {
187
+ const meta = mapped.meta || {};
188
+ const folder = String(meta.folder || '');
189
+ const project = folder ? folder.replace(/\/+$/, '').split('/').pop() : '';
190
+ const turns = mapped.turns.map((t) => {
191
+ const full = base.cleanText(t.text);
192
+ const cap = t.kind === 'tool_result' || t.kind === 'tool_call' ? base.TOOL_RESULT_CAP : base.TEXT_CAP;
193
+ return {
194
+ harness: key.harness, harness_version: String(meta.harness_version || ''), harness_path: key.harness_path, session_id: key.session_id,
195
+ incarnation: key.incarnation || '', agent_id: String(t.agent_id || ''), message_id: String(t.message_id), part_id: String(t.part_id),
196
+ part_ord: Number(t.part_ord) || 0, parent_id: String(t.parent_id || ''), response_id: String(t.response_id || ''), ts: tsWire(t.ts),
197
+ ord: Number(t.ord) || 0, rev: String(rev), is_deleted: 0, removed: 0, state: String(t.state || ''), role: String(t.role), kind: String(t.kind),
198
+ model: String(t.model || ''), effort: String(t.effort || ''),
199
+ input_tokens: t.input_tokens || 0, output_tokens: t.output_tokens || 0, cache_read_tokens: t.cache_read_tokens || 0,
200
+ cache_write_tokens: t.cache_write_tokens || 0, reasoning_tokens: t.reasoning_tokens || 0,
201
+ text: base.capText(full, cap), text_len: full.length, folder, project, host: key.host, origin: 'ship',
202
+ extra: JSON.stringify(t.extra || {}), adapter_ver: adapterVer,
203
+ };
204
+ });
205
+ const byMsg = new Map(turns.map((t) => [t.message_id, t]));
206
+ const calls = mapped.calls.filter((c) => c.tool_name !== undefined).map((c) => ({
207
+ harness: key.harness, harness_path: key.harness_path, session_id: key.session_id, incarnation: key.incarnation || '',
208
+ agent_id: String(c.agent_id || ''), call_id: String(c.call_id), message_id: String(c.message_id),
209
+ ts: c.ts ? tsWire(c.ts) : (byMsg.get(String(c.message_id)) || {}).ts || base.chTs(0), rev: String(rev), is_deleted: 0, removed: 0,
210
+ tool_name: String(c.tool_name || ''), args: base.capText(base.cleanText(c.args || '{}'), base.ARGS_CAP),
211
+ result_ts: c.result_ts ? tsWire(c.result_ts) : null, is_error: !!c.is_error, result_len: Number(c.result_len) || 0,
212
+ model: String(c.model || ''), folder, project, host: key.host, adapter_ver: adapterVer,
213
+ }));
214
+ return { turns, calls };
215
+ }
216
+ /** What a row says, without its rev: equal digests mean nothing to write. */
217
+ function rowDigest(r) { const { rev: _r, ...rest } = r; return sha1(JSON.stringify(rest)); }
218
+ /** The session's exact totals over its rows (base's contributions), with the metadata buildSession reads. */
219
+ function contribOf(rows, meta) {
220
+ const c = base.newContrib();
221
+ for (const t of rows.turns) base.applyTurn(c, t, 1);
222
+ for (const k of rows.calls) base.applyCall(c, k, 1);
223
+ const firstUser = rows.turns.filter((t) => t.role === 'user' && t.kind === 'text' && !t.agent_id && t.text).sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : a.ord - b.ord))[0];
224
+ c.meta = {
225
+ ...(meta && meta.folder ? { folder: meta.folder } : {}), ...(meta && meta.title ? { title: meta.title } : {}),
226
+ ...(meta && meta.harness_version ? { version: meta.harness_version } : {}), ...(firstUser ? { first_prompt: firstUser.text.slice(0, 200) } : {}),
227
+ };
228
+ return base.serializeContrib(c);
229
+ }
230
+
231
+ /**
232
+ * Which 0.20 engine rows the package's view supersedes. The two key the same content differently, so
233
+ * content decides: a turn by kind and text (a textless one by its message and part id), a call by its
234
+ * call_id. Each view row answers for one row the house holds, and a view row that replaced a row under
235
+ * the same key has answered already: two engine rows with one text, where the source now holds that
236
+ * text once, are one duplicate and one removed line. Returns (kind, row) => is it a duplicate.
237
+ */
238
+ function twinsOf(rows, cur, digests) {
239
+ const sig = { turns: (r) => (r.text ? `${r.kind}\u0000${r.text}` : `${r.kind}\u0000\u0000${r.message_id}\u0000${r.part_id}`), calls: (r) => String(r.call_id) };
240
+ const bag = { turns: new Map(), calls: new Map() };
241
+ const take = (kind, r) => { const k = sig[kind](r); const n = bag[kind].get(k) || 0; if (!n) return false; bag[kind].set(k, n - 1); return true; };
242
+ for (const r of rows.turns) bag.turns.set(sig.turns(r), (bag.turns.get(sig.turns(r)) || 0) + 1);
243
+ for (const r of rows.calls) bag.calls.set(sig.calls(r), (bag.calls.get(sig.calls(r)) || 0) + 1);
244
+ for (const r of cur.turns) if (base.turnKey(r) in digests.t) take('turns', r);
245
+ for (const r of cur.calls) if (base.callKey(r) in digests.c) take('calls', r);
246
+ return take;
247
+ }
248
+
249
+ /**
250
+ * Capture one session. `ctx`: { house, home, host, now(), mapping? }. `ad`: the harness's adapter;
251
+ * `d`: its describe(). `listed`: its `list` entry. Returns what it did.
252
+ */
253
+ async function captureSession(ctx, ad, d, store, listed) {
254
+ const key = { ...store, session_id: listed.session_id, incarnation: listed.incarnation ?? '' };
255
+ const ref = { harness: key.harness, host: key.host, harness_path: key.harness_path, session_id: key.session_id, incarnation: key.incarnation };
256
+ const out = { session_id: key.session_id, archived: 0, turns: 0, calls: 0, removed: 0, refold: false, skipped: false, more: false };
257
+ let local = loadLocal(ctx.home, key);
258
+ const H = new Map((await ctx.house.filesRows([ref])).map((r) => [r.file_key, r]));
259
+ // The local state counts only while it agrees with the house on every source it read.
260
+ if (local && (local.adapter_ver !== d.adapter_ver || local.state_ver !== d.state_ver
261
+ || Object.entries(local.sources || {}).some(([sk, s]) => { const h = H.get(sk); return !h || Number(h.fgen) !== s.gen || String(h.upto) !== String(s.upto); }))) local = null;
262
+ const reDerive = !local && [...H.values()].some((h) => h.role !== 'legacy' && h.role !== 'inherited');
263
+ const capturedAt = base.chTs(ctx.now());
264
+ const sources = {};
265
+ const archive = [];
266
+ const fresh = [];
267
+ let full = !local;
268
+ for (const src of listed.sources) {
269
+ const sk = src.source_key;
270
+ const h = H.get(sk);
271
+ // A files row the package wrote seeds the source. A 0.20 engine's row under the same key (its
272
+ // file_key can equal a source_key: a Codex rollout's base name) does not: its state is the old
273
+ // engine's, so the source starts from nothing in the generation past it, and the package's row
274
+ // (a higher fgen, so a higher ver) replaces it.
275
+ const pkgRow = h && isPackageRow(h);
276
+ const prior = local ? local.sources[sk] : (pkgRow ? { gen: Number(h.fgen), upto: String(h.upto), ...JSON.parse(h.derive_state || '{}') } : null);
277
+ let gen = prior ? prior.gen : (h ? Number(h.fgen) + 1 : 0);
278
+ const ords = { ...(prior && prior.ords) };
279
+ let next = Object.keys(ords).length ? Math.max(...Object.values(ords)) + 1 : 0;
280
+ const known = prior && prior.known && Object.keys(prior.known).length ? prior.known : null;
281
+ // One capped read per source per step (the package reads at most 64 MiB and says `more`). A big
282
+ // session is captured over several steps, each derived and written on its own, so memory holds
283
+ // one cap of units and that step's records, never the whole transcript.
284
+ const r = ad.read(store, key, sk, prior ? prior.position ?? null : null, known);
285
+ if (r.more) out.more = true;
286
+ let units = [];
287
+ let gone = r.gone || [];
288
+ let knownNext = { ...(known || {}) };
289
+ switch (r.continuity) {
290
+ case 'reset':
291
+ gen = prior ? gen + 1 : gen;
292
+ units = r.units; gone = [];
293
+ knownNext = {};
294
+ full = true;
295
+ for (const k of Object.keys(ords)) delete ords[k];
296
+ next = 0;
297
+ break;
298
+ case 'snapshot':
299
+ units = r.units; knownNext = {};
300
+ break;
301
+ case 'appended': case 'delta':
302
+ units = r.units;
303
+ break;
304
+ case 'unchanged':
305
+ break;
306
+ default: throw new Error(`${sk}: unknown continuity ${r.continuity}`);
307
+ }
308
+ for (const k of gone) delete knownNext[k];
309
+ for (const u of units) {
310
+ knownNext[u.unit_key] = u.sha256;
311
+ const stream = /^\d+$/.test(String(u.unit_key)) && src.mode === 'stream';
312
+ if (!stream && ords[u.unit_key] === undefined) ords[u.unit_key] = next++;
313
+ const pos = stream ? Number(u.unit_key) : ords[u.unit_key];
314
+ archive.push(...unitRows(key, sk, gen, pos, u, capturedAt));
315
+ }
316
+ const upto = r.position && Number.isFinite(Number(r.position.offset)) ? String(r.position.offset)
317
+ : String((prior ? Number(prior.upto) : 0) + (units.length || gone.length ? 1 : 0));
318
+ sources[sk] = { gen, upto, position: r.position ?? null, known: src.mode === 'stream' ? {} : knownNext, ords: src.mode === 'stream' ? {} : ords, role: src.role || 'main' };
319
+ if (units.length || gone.length) fresh.push({ source_key: sk, generation: gen, complete: false, units, gone });
320
+ }
321
+ out.archived = archive.length;
322
+ if (!full && !fresh.length) {
323
+ // Nothing new. Positions that moved without units (a held-back tail) are still kept.
324
+ if (local && Object.entries(sources).every(([sk, s]) => canon(s.position) === canon((local.sources[sk] || {}).position))) { out.skipped = true; return out; }
325
+ }
326
+ if (archive.length) await ctx.house.insert('records', archive);
327
+
328
+ // The input. A full refold takes every unit of every source's current generation from the archive.
329
+ let input = fresh;
330
+ if (full) {
331
+ input = [];
332
+ for (const [sk, s] of Object.entries(sources)) {
333
+ const units = unitsFromArchive(await ctx.house.archiveUnits(ref, sk, s.gen));
334
+ input.push({ source_key: sk, generation: s.gen, complete: true, units, gone: [] });
335
+ }
336
+ }
337
+ const res = ad.derive(key, input, full ? null : local.state);
338
+ const oldView = new Map(full ? [] : (local.view || []));
339
+ const view = applyDerive(oldView, res);
340
+ const firstSeen = { ...(local && local.firstSeen) };
341
+ // A refold reads what the house holds for the session once: first-seen times, the rev floor, and
342
+ // the keys to retire.
343
+ const cur = full ? await ctx.house.capturedRows(ref) : null;
344
+ if (cur) for (const r of cur.turns) if (!firstSeen[r.message_id]) firstSeen[r.message_id] = new Date(Date.parse(`${r.ts.replace(' ', 'T')}Z`)).toISOString();
345
+ const nowIso = new Date(ctx.now()).toISOString();
346
+ const tsOf = (id) => (firstSeen[id] || (firstSeen[id] = nowIso));
347
+ const mapped = (ctx.mapping || recordsToRows.rowsOf)([...view.values()], { tsOf });
348
+ // rev is at least last + 1 (harness-sessions-sql §7): the local state's last rev, or on a refold the house's
349
+ // greatest for the session, so a clock that went back (a restored VM) never writes below it.
350
+ let lastRev = BigInt(local ? local.lastRev : 0);
351
+ if (cur) for (const r of [...cur.turns, ...cur.calls]) if (r.rev != null && BigInt(r.rev) > lastRev) lastRev = BigInt(r.rev);
352
+ const rev = BigInt(ctx.now()) > lastRev ? BigInt(ctx.now()) : lastRev + 1n;
353
+ const adapterVer = verNumber(d.adapter_ver);
354
+ const rows = schema3Rows(mapped, key, { rev, adapterVer });
355
+
356
+ // What to write: rows whose digest moved; removed versions for keys the house holds and the view no longer makes.
357
+ const digests = { t: {}, c: {} };
358
+ for (const t of rows.turns) digests.t[base.turnKey(t)] = rowDigest(t);
359
+ for (const c of rows.calls) digests.c[base.callKey(c)] = rowDigest(c);
360
+ const old = local ? local.digests : null;
361
+ const writeT = rows.turns.filter((t) => !old || old.t[base.turnKey(t)] !== digests.t[base.turnKey(t)]);
362
+ const writeC = rows.calls.filter((c) => !old || old.c[base.callKey(c)] !== digests.c[base.callKey(c)]);
363
+ let goneT; let goneC;
364
+ if (old) {
365
+ goneT = Object.keys(old.t).filter((k) => !(k in digests.t));
366
+ goneC = Object.keys(old.c).filter((k) => !(k in digests.c));
367
+ } else {
368
+ // A held record (incomplete: neither written nor removed) is absent from a refold's view, so its
369
+ // rows are not "gone": they keep their last version (sessions §2).
370
+ const heldMsg = new Set((res.held && res.held.message) || []);
371
+ const heldUsage = new Set(((res.held && res.held.usage) || []).map((u) => `usage:${u}`));
372
+ const kept = (r) => { const id = String(r.message_id); return heldMsg.has(id) || heldUsage.has(id.replace(/~+$/, '')); };
373
+ goneT = cur.turns.filter((r) => !kept(r)).map(base.turnKey).filter((k) => !(k in digests.t));
374
+ goneC = cur.calls.filter((r) => !kept(r)).map(base.callKey).filter((k) => !(k in digests.c));
375
+ }
376
+ const tKeys = goneT.map((k) => { const [ts, message_id, part_id] = k.split('\u0000'); return { ts, message_id, part_id }; });
377
+ const cKeys = goneC.map((k) => { const [ts, call_id] = k.split('\u0000'); return { ts, call_id }; });
378
+ // A key the package made and no longer makes left the source: a removed version. A key the 0.20
379
+ // engine made (a session switching to the package) is a superseded duplicate, a tombstone, only
380
+ // when the view holds its content (twinsOf). Engine content the view lacks left the source before
381
+ // the switch, or is a kind the package does not make: kept as a removed version (sessions §4).
382
+ const twin = cur ? twinsOf(rows, cur, digests) : () => false;
383
+ const retire = (kind) => (r) => (!isPackageRow(r) && twin(kind, r) ? base.tombstoneOf(kind, r, rev) : base.removedOf(kind, r, rev));
384
+ const removedT = tKeys.length ? (await ctx.house.rowsByKey('turns', ref, tKeys)).map(retire('turns')) : [];
385
+ const removedC = cKeys.length ? (await ctx.house.rowsByKey('calls', ref, cKeys)).map(retire('calls')) : [];
386
+ if (writeT.length || removedT.length) await ctx.house.insert('turns', [...writeT, ...removedT]);
387
+ if (writeC.length || removedC.length) await ctx.house.insert('calls', [...writeC, ...removedC]);
388
+ out.turns = writeT.length; out.calls = writeC.length; out.refold = full; out.reDerive = reDerive;
389
+ out.removed = [...removedT, ...removedC].filter((r) => Number(r.removed)).length;
390
+ out.superseded = [...removedT, ...removedC].filter((r) => Number(r.is_deleted)).length;
391
+
392
+ // The sources' files rows: the session's totals ride on its first main source.
393
+ const contrib = contribOf(rows, mapped.meta);
394
+ const mainSk = Object.keys(sources).find((sk) => sources[sk].role === 'main') || Object.keys(sources)[0];
395
+ const empty = base.serializeContrib(base.newContrib());
396
+ const files = Object.entries(sources).map(([sk, s]) => {
397
+ const c = sk === mainSk ? contrib : empty;
398
+ const anchor = s.position && /^[0-9a-f]{64}$/.test(String(s.position.anchor_sha || '')) ? s.position.anchor_sha : sha1('').padEnd(64, '0');
399
+ return {
400
+ harness: key.harness, harness_path: key.harness_path, session_id: key.session_id, incarnation: key.incarnation || '',
401
+ file_key: sk, agent_id: '', role: s.role === 'agent' || s.role === 'side' ? s.role : 'main', agent_type: '', agent_desc: '',
402
+ fgen: s.gen, upto: s.upto, reparse_from: 0, derive_state: JSON.stringify({ position: s.position, known: s.known, ords: s.ords }),
403
+ adapter_ver: adapterVer, size: Number(s.position && s.position.offset) || 0, inode: Number(s.position && s.position.inode) || 0,
404
+ anchor_sha: anchor, prefix_sha: anchor, is_deleted: 0, contrib: JSON.stringify(c),
405
+ first_ts: c.first_ts || '1970-01-01 00:00:00.000', last_ts: c.last_ts || '1970-01-01 00:00:00.000', host: key.host,
406
+ };
407
+ });
408
+ // The 0.20 engine's files rows of this session (sources the package names otherwise) retire with
409
+ // their rows, or buildSession would add their totals to the package's.
410
+ const listedKeys = new Set(listed.sources.map((s) => s.source_key));
411
+ const SERVER = new Set(['ver', 'user_id']);
412
+ const oldFiles = [...H.values()].filter((h) => !listedKeys.has(h.file_key) && ['main', 'agent', 'side'].includes(h.role) && !Number(h.is_deleted) && !isPackageRow(h))
413
+ .map((h) => ({ ...Object.fromEntries(Object.entries(h).filter(([k]) => !SERVER.has(k))), is_deleted: 1, fgen: Number(h.fgen) + 1 }));
414
+ out.supersededFiles = oldFiles.length;
415
+ await ctx.house.insert('files', [...files, ...oldFiles]);
416
+ if (ctx.buildSession) {
417
+ const all = await ctx.house.filesRows([ref]);
418
+ await ctx.house.insert('sessions', [ctx.buildSession(key.harness, key.session_id, all, { host: key.host, hostLabel: ctx.hostLabel || '', harnessPath: key.harness_path, incarnation: key.incarnation })]);
419
+ }
420
+ if (res.checkpoint !== false) {
421
+ saveLocal(ctx.home, key, {
422
+ // The list hint is kept only once the last step is in: a pass that dies between steps must
423
+ // not skip the session next time.
424
+ adapter_ver: d.adapter_ver, state_ver: d.state_ver, hint: out.more ? null : canon(listed.updated_hint ?? null), state: res.state,
425
+ view: [...view.entries()], digests, firstSeen, lastRev: String(rev),
426
+ sources: Object.fromEntries(Object.entries(sources).map(([sk, s]) => [sk, { gen: s.gen, upto: s.upto, position: s.position, known: s.known, ords: s.ords }])),
427
+ });
428
+ }
429
+ return out;
430
+ }
431
+
432
+ /**
433
+ * The sessions of `store` the house holds, as far as `adapter` goes, on the profile layout: every
434
+ * session whose cursor (session_sources) names a source of `adapter` that is neither deleted nor
435
+ * gone, and every session `adapter` archived manifests for that has no cursor row of `adapter` at all
436
+ * (an anti-join): a replay from the archive alone (harness-sessions-sql §4.1), however far an interrupted one
437
+ * got, and a session whose first capture stopped before its first cursor write. An archive-only
438
+ * session whose every source's newest generation is source_gone is `allGone`: replaying it reads
439
+ * nothing of the store. A pass hands each one `list` no longer returns to the session capture (THE
440
+ * VANISH SWEEP below). One round trip, ordered by session. Another layout's house holds no such
441
+ * cursor: none. Returns [{ session_id, incarnation, allGone }].
442
+ */
443
+ async function heldSessions(house, store, adapter) {
444
+ const P = house && house.P;
445
+ if (!P || !P.session_sources || typeof house.rows !== 'function') return [];
446
+ const { lit } = require('../house/convert-v3');
447
+ const where = `(host, harness, harness_path) = (${[store.host, store.harness, store.harness_path].map(lit).join(', ')})`;
448
+ const mine = `(adapter = ${lit(adapter)} OR adapter = '')`;
449
+ const cursored = `SELECT session_id, incarnation, max(is_deleted = 0 AND ${mine} AND NOT JSONExtractBool(state, 'gone')) AS live, toUInt8(0) AS all_gone
450
+ FROM ${P.session_sources} FINAL WHERE ${where} AND role != 'legacy' GROUP BY session_id, incarnation`;
451
+ const gone = "JSONExtractBool(raw, 'source_gone')";
452
+ const archived = !P.session_units ? '' : `
453
+ UNION ALL
454
+ SELECT session_id, incarnation, toUInt8(1) AS live, min(src_gone) AS all_gone FROM (
455
+ SELECT session_id, incarnation, source_key, toUInt8(countIf(${gone}) > 0 AND maxIf(generation, ${gone}) = max(generation)) AS src_gone
456
+ FROM ${P.session_units}
457
+ WHERE ${where} AND unit_key = '' AND enc = 'manifest' AND adapter = ${lit(adapter)}
458
+ AND (session_id, incarnation) NOT IN (SELECT session_id, incarnation FROM ${P.session_sources} WHERE ${where} AND role != 'legacy' AND ${mine})
459
+ GROUP BY session_id, incarnation, source_key)
460
+ GROUP BY session_id, incarnation`;
461
+ const rows = await house.rows(`SELECT session_id, incarnation, max(live) AS live, max(all_gone) AS all_gone FROM (${cursored}${archived})
462
+ GROUP BY session_id, incarnation ORDER BY session_id, incarnation`);
463
+ return rows.filter((r) => Number(r.live)).map((r) => ({ session_id: String(r.session_id), incarnation: String(r.incarnation || ''), allGone: !!Number(r.all_gone) }));
464
+ }
465
+
466
+ /**
467
+ * Capture every session of one harness store. A session whose `updated_hint` and adapter are what
468
+ * the local state last saw costs nothing past `list`. `ctx.captureSession` and `ctx.stateNs` swap
469
+ * in another layout's pass (profile-capture.js).
470
+ *
471
+ * THE VANISH SWEEP (each line is a decision)
472
+ * candidates a session the house holds (heldSessions) that `list` no longer returns. It is
473
+ * captured with no source listed, so the session capture reads each source it held
474
+ * and records it as vanished (profile-capture.js: a source_gone manifest with the
475
+ * policy it applies, harness-sessions-sql §4.1) only when the adapter answers it is not there
476
+ * (session_unknown, incarnation_gone, source_unknown); replaying an archive, a source
477
+ * already gone there replays as its manifests say. The house is read before this pass
478
+ * writes anything of the store
479
+ * confirmed by the adapter's own read, never by an absence: a list can come back empty or short
480
+ * without throwing (a folder briefly missing, a DB file being replaced, a scan race),
481
+ * and a vanish recorded from it re-archives the whole source in a new generation the
482
+ * moment it is listed again. A candidate that still reads (`stillThere`) or whose read
483
+ * fails (`candidateErrors`) is a refusal the pass reports. A listed session's source
484
+ * the list left out that still reads is read as listed and counted `unconfirmed`, a
485
+ * warning: an adapter may list a source only while it holds something (a sidebar row,
486
+ * a summaries table) and still read it as empty
487
+ * refused when `list` names none of the sessions the house holds: an empty or unrelated list is
488
+ * what a store that cannot be read looks like, and the adapter's read of that same store
489
+ * would agree with it. Nothing of the store is taken as vanished (`vanishRefused`). An
490
+ * archive-only session whose sources are all gone already is still replayed (it reads
491
+ * nothing of the store)
492
+ * confirmed a store whose every session the editor deleted lists nothing, and is refused on every
493
+ * empty pass; the refusal records which sessions it refused (the ids of the held sessions that
494
+ * are not all gone already, in the store's state: those replay under the refusal and
495
+ * need no confirmation). `memhouse doctor --confirm-empty-store <harness>:<store>`
496
+ * (confirmEmptyStore) writes a confirmation of exactly those, in a file of its own beside
497
+ * the store's state, which a pass never rewrites: the pass claims it by renaming it, and
498
+ * puts it back only where no newer one was written meanwhile. The next sweep that finds
499
+ * the list still empty, the store's folder (or file) there and readable, and no session
500
+ * held (all gone already aside) that was not confirmed lifts the refusal: the held
501
+ * sessions go through the session capture's typed-read confirmation as any candidate
502
+ * does, and vanish only on the adapter's answer. The confirmation is used up by that
503
+ * sweep, unless candidates wait past the cap, failed to read or were not reached (a
504
+ * stop): then it is put back as it is, and applies to what is left of its sessions. A
505
+ * list that names a held session again, or a session held that was not confirmed,
506
+ * discards it
507
+ * bridge never swept. The log adapter's stores (memhouse-bridge, harness-sessions-sql §4.1's exemption)
508
+ * hold logs the normaliser never deletes, so a log missing from its list is an error,
509
+ * and its read answers a missing log as an empty source, so it cannot confirm one
510
+ * gate the house is asked only when something can have left: no local store state yet (or
511
+ * another house's or adapter's), a session the last pass listed is not listed now, or
512
+ * the last sweep left something pending: a refusal, candidates past the cap, a candidate
513
+ * that still read, failed or was not reached (a stop), a sweep read that failed, or an
514
+ * empty-store confirmation waiting. A listed session's own error or refold does not
515
+ * reopen it, nor does a candidate's refold (its outcome is one of the above). A quiet pass costs
516
+ * nothing past `list`. The local store state ($MEMHOUSE_HOME/state/<ns>/stores/) is saved
517
+ * after every pass whose list answered. It is this home's: a house replaced under the
518
+ * same name while the home is kept is swept only once a listed session goes missing,
519
+ * or after `state/<ns>/stores/` alone is removed (each session's state stays)
520
+ * cap at most ctx.vanishCap (VANISH_CAP) candidates a store pass, in session order from
521
+ * where the last capped pass stopped (store state `after`), so candidates that keep
522
+ * failing do not starve the rest; the others wait for the next pass (`vanishWaiting`).
523
+ * The first pass after this lands meets every session the store deleted since profile
524
+ * capture began, and each candidate costs a read
525
+ */
526
+ const VANISH_CAP = 100;
527
+ const storeIdOf = (store) => [store.host, store.harness, store.harness_path].join('\u0000');
528
+ const storeStatePath = (home, store, ns) => path.join(home, 'state', ns, 'stores', `${sha1(storeIdOf(store))}.json`);
529
+ function loadStoreState(home, store, ns) {
530
+ try {
531
+ const s = JSON.parse(fs.readFileSync(storeStatePath(home, store, ns), 'utf-8'));
532
+ return s && s.v === 1 && s.id === storeIdOf(store) && Array.isArray(s.listed) ? s : null;
533
+ } catch { return null; }
534
+ }
535
+ /** Whether a store's folder (or file) is there and can be read: a confirmed empty store must be. */
536
+ function storeReadable(store) {
537
+ try {
538
+ const st = fs.statSync(store.harness_path);
539
+ if (st.isDirectory()) fs.readdirSync(store.harness_path); else fs.accessSync(store.harness_path, fs.constants.R_OK);
540
+ return true;
541
+ } catch { return false; }
542
+ }
543
+ /**
544
+ * The sessions a refusal refused, as a confirmation names them: the ids of the held sessions that are
545
+ * not all gone already (those replay under the refusal and need none), sorted. A confirmation applies
546
+ * while every such session held is one it names, however many of them have gone since.
547
+ */
548
+ const refusedMark = (held) => { const ids = held.filter((h) => !h.allGone).map(idOfHeld).sort(); return { n: ids.length, ids }; };
549
+ const idOfHeld = (h) => `${h.session_id}\u0000${h.incarnation ?? ''}`;
550
+ const confirms = (confirm, held) => {
551
+ if (!Array.isArray(confirm.ids)) return false;
552
+ const named = new Set(confirm.ids);
553
+ return held.every((h) => h.allGone || named.has(idOfHeld(h)));
554
+ };
555
+ const confirmPath = (home, store, ns) => storeStatePath(home, store, ns).replace(/\.json$/, '.confirm.json');
556
+ const claimedPath = (home, store, ns) => storeStatePath(home, store, ns).replace(/\.json$/, '.confirm.claimed');
557
+ /** The empty-store confirmation waiting for `store`, claimed by a pass or not: what doctor wrote, or null. */
558
+ function readConfirmation(home, store, ns) {
559
+ for (const f of [confirmPath(home, store, ns), claimedPath(home, store, ns)]) {
560
+ try { const c = JSON.parse(fs.readFileSync(f, 'utf-8')); if (c && c.v === 1 && c.id === storeIdOf(store)) return c; } catch { /* none */ }
561
+ }
562
+ return null;
563
+ }
564
+ /**
565
+ * `memhouse doctor --confirm-empty-store <harness>:<store>`: a confirmation that the sessions the last
566
+ * pass refused of that store were deleted (THE VANISH SWEEP: confirmed empty), written for every store
567
+ * of this home that matches. Returns { confirmed: [{ harness, harness_path, n }], notRefused: [...] }:
568
+ * a store whose last pass did not refuse it as empty has nothing to confirm.
569
+ */
570
+ function confirmEmptyStore(home, ns, harness, harnessPath, at = Date.now()) {
571
+ const dir = path.join(home, 'state', ns, 'stores');
572
+ const out = { confirmed: [], notRefused: [] };
573
+ let names = [];
574
+ try { names = fs.readdirSync(dir).filter((x) => /^[0-9a-f]{40}\.json$/.test(x)); } catch { return out; }
575
+ for (const x of names) {
576
+ let s;
577
+ try { s = JSON.parse(fs.readFileSync(path.join(dir, x), 'utf-8')); } catch { continue; }
578
+ const [host, h, hp] = String((s && s.id) || '').split('\u0000');
579
+ if (!s || s.v !== 1 || h !== harness || hp !== harnessPath) continue;
580
+ const store = { host, harness: h, harness_path: hp };
581
+ if (!s.refused) { out.notRefused.push({ harness: h, harness_path: hp }); continue; }
582
+ const f = confirmPath(home, store, ns);
583
+ const tmp = `${f}.${process.pid}.tmp`;
584
+ fs.writeFileSync(tmp, JSON.stringify({ v: 1, id: storeIdOf(store), at, n: s.refused.n, ids: s.refused.ids || [] }), { mode: 0o600 });
585
+ fs.renameSync(tmp, f);
586
+ out.confirmed.push({ harness: h, harness_path: hp, n: s.refused.n });
587
+ }
588
+ return out;
589
+ }
590
+ /**
591
+ * Write `c` to `to` unless a file is there (a newer confirmation doctor wrote): a hard link of a whole
592
+ * written file where the filesystem has them, else an exclusive create, which refuses in the same
593
+ * case. EEXIST is the newer one; any other link error (EPERM, ENOTSUP, EXDEV, ...: exFAT, some SMB or
594
+ * FUSE mounts) is only a filesystem without links. Returns 'written', 'newer' or 'failed'.
595
+ */
596
+ function putBack(beside, to, c) {
597
+ const body = JSON.stringify(c);
598
+ const tmp = `${beside}.${process.pid}.tmp`;
599
+ try {
600
+ fs.writeFileSync(tmp, body, { mode: 0o600 });
601
+ try { fs.linkSync(tmp, to); return 'written'; } catch (e) {
602
+ if (e && e.code === 'EEXIST') return 'newer';
603
+ try { fs.writeFileSync(to, body, { mode: 0o600, flag: 'wx' }); return 'written'; } catch (e2) { return e2 && e2.code === 'EEXIST' ? 'newer' : 'failed'; }
604
+ }
605
+ } catch { return 'failed'; } finally { try { fs.rmSync(tmp, { force: true }); } catch { /* gone */ } }
606
+ }
607
+ function saveStoreState(home, store, ns, s) {
608
+ const f = storeStatePath(home, store, ns);
609
+ fs.mkdirSync(path.dirname(f), { recursive: true });
610
+ const tmp = `${f}.${process.pid}.tmp`;
611
+ fs.writeFileSync(tmp, JSON.stringify({ ...s, v: 1, id: storeIdOf(store) }), { mode: 0o600 });
612
+ fs.renameSync(tmp, f);
613
+ }
614
+ async function captureStore(ctx, ad, store, { skip = null } = {}) {
615
+ const session = ctx.captureSession || captureSession;
616
+ const d = ad.describe();
617
+ const st = { harness: store.harness, harness_path: store.harness_path, sessions: 0, captured: 0, skipped: 0, kept: 0, vanished: 0, unlisted: 0, unconfirmed: 0, stillThere: 0, candidateErrors: 0, vanishRefused: null, vanishWaiting: 0,
618
+ steps: 0, refolds: 0, turns: 0, calls: 0, rows: 0, removed: 0, archived: 0, errors: [], refs: [] };
619
+ // A store whose list throws (an unreadable folder) reports it and captures nothing; the next stores go on.
620
+ // An adapter may name entries it had to leave out (list `errors`, as the log adapter does for a log
621
+ // it cannot stat): reported, and the rest captured.
622
+ let list;
623
+ try {
624
+ const got = ad.list(store);
625
+ list = [...got];
626
+ for (const x of (got && Array.isArray(got.errors) ? got.errors : [])) st.errors.push({ code: x.code, error: `list: ${x.error}` });
627
+ } catch (e) { st.errors.push({ code: e && e.code, error: `list: ${String(e && e.message).split('\n')[0]}` }); return st; }
628
+ // The vanish sweep (above). A house that cannot say what it holds reports it; the listed sessions
629
+ // are still captured.
630
+ const idOf = (x) => `${x.session_id}\u0000${x.incarnation ?? ''}`;
631
+ const adapter = String(d.adapter || d.harness || store.harness);
632
+ const P = ctx.house && ctx.house.P;
633
+ const sweeps = adapter !== BRIDGE && !!P && !!P.session_sources && typeof ctx.house.rows === 'function';
634
+ const ns = ctx.stateNs || 'derive';
635
+ const listedNow = list.map(idOf);
636
+ let prev = null; let confirm = null; let after = null; let refused = null;
637
+ if (sweeps) {
638
+ prev = ctx.home ? loadStoreState(ctx.home, store, ns) : null;
639
+ // A confirmation doctor wrote (header: confirmed empty), claimed by this pass.
640
+ if (ctx.home) {
641
+ try { fs.renameSync(confirmPath(ctx.home, store, ns), claimedPath(ctx.home, store, ns)); } catch { /* none written since */ }
642
+ confirm = readConfirmation(ctx.home, store, ns);
643
+ }
644
+ const now = new Set(listedNow);
645
+ st.swept = !prev || prev.house !== P.session_sources || prev.adapter !== adapter || !!prev.pending || prev.listed.some((id) => !now.has(id)) || !!confirm;
646
+ if (st.swept) {
647
+ try {
648
+ const held = await heldSessions(ctx.house, store, adapter);
649
+ let unlisted = held.filter((h) => !now.has(idOf(h)));
650
+ const namesNone = unlisted.length > 0 && unlisted.length === held.length;
651
+ // A confirmed empty store (header): a list that names a held session again, or other sessions
652
+ // held than were confirmed, discards it.
653
+ if (confirm && (!namesNone || (!list.length && !confirms(confirm, held)))) {
654
+ st.confirmDiscarded = !namesNone ? (held.length ? 'its list names a session again' : 'the house holds nothing of it any more') : `it holds a session the confirmation of ${confirm.n} does not name`;
655
+ confirm = null;
656
+ }
657
+ if (namesNone && !(confirm && !list.length && storeReadable(store))) {
658
+ st.vanishRefused = `its list names none of the ${held.length} session${held.length === 1 ? '' : 's'} the house holds of it${list.length ? '' : ' (the list is empty)'}`;
659
+ st.emptyStore = !list.length;
660
+ if (st.emptyStore) refused = refusedMark(held);
661
+ // Refused, an archive-only session whose sources are all gone already is still replayed.
662
+ unlisted = unlisted.filter((h) => h.allGone);
663
+ } else if (namesNone) st.confirmedEmpty = true;
664
+ const cap = Number.isFinite(ctx.vanishCap) ? ctx.vanishCap : VANISH_CAP;
665
+ // In session order, from where the last capped pass stopped (header: cap).
666
+ unlisted.sort((x, y) => (idOf(x) < idOf(y) ? -1 : idOf(x) > idOf(y) ? 1 : 0));
667
+ const from = prev && prev.after != null && unlisted.length > cap ? unlisted.findIndex((h) => idOf(h) > prev.after) : -1;
668
+ if (from > 0) unlisted = [...unlisted.slice(from), ...unlisted.slice(0, from)];
669
+ const hand = unlisted.slice(0, cap);
670
+ st.unlisted = hand.length;
671
+ st.vanishWaiting = unlisted.length - hand.length;
672
+ after = st.vanishWaiting ? idOf(hand[hand.length - 1]) : null;
673
+ list.push(...hand.map((h) => ({ session_id: h.session_id, incarnation: h.incarnation, updated_hint: null, sources: [], unlisted: true })));
674
+ } catch (e) { st.sweepFailed = true; st.errors.push({ code: e && e.code, error: `vanished: ${String(e && e.message).split('\n')[0]}` }); }
675
+ }
676
+ }
677
+ const keyOf = (listed) => ({ ...store, session_id: listed.session_id, incarnation: listed.incarnation ?? '' });
678
+ const unchanged = (key, listed) => {
679
+ const local = loadLocal(ctx.home, key, ctx.stateNs);
680
+ return !!local && local.adapter_ver === d.adapter_ver && local.state_ver === d.state_ver && local.hint === canon(listed.updated_hint ?? null) && listed.updated_hint != null
681
+ // A store whose rows have a mapping version of their own (profile-capture's ROWS_VER): a state it moved past is read again.
682
+ && (ctx.rowsVer == null || (local.rows_ver || 1) === ctx.rowsVer)
683
+ // ...and one whose engine edges have a version of their own (profile-capture's EDGES_VER).
684
+ && (ctx.edgesVer == null || (local.edges_ver || 0) === ctx.edgesVer);
685
+ };
686
+ // The sessions to read are taken in groups: a group's first reads are made together (ctx.prefetch).
687
+ const GROUP = 50;
688
+ // A pass whose lock was taken over (capture.js passLock) stops before its next session.
689
+ const stopped = () => !!(ctx.stopped && ctx.stopped());
690
+ for (let g = 0; g < list.length && !st.stopped; g += GROUP) {
691
+ if (ctx.prefetch && !stopped()) {
692
+ const group = list.slice(g, g + GROUP).filter((listed) => { const key = keyOf(listed); return !(skip && skip(key, listed)) && !unchanged(key, listed); });
693
+ try { if (group.length) await ctx.prefetch(ctx, d, store, group); } catch { ctx.pre = null; /* each session reads its own */ }
694
+ }
695
+ for (const listed of list.slice(g, g + GROUP)) {
696
+ if (st.stopped || stopped()) { st.stopped = true; break; }
697
+ st.sessions++;
698
+ const key = keyOf(listed);
699
+ // A session another path keeps (capture.js: too big at first sight).
700
+ if (skip && skip(key, listed)) { st.kept++; continue; }
701
+ if (unchanged(key, listed)) { st.skipped++; continue; }
702
+ try {
703
+ let r; let first = true; let any = false; let gone = 0; let unconfirmed = 0;
704
+ do {
705
+ r = await session(ctx, ad, d, store, listed);
706
+ st.steps++;
707
+ gone += r.gone || 0; unconfirmed += r.unconfirmed || 0;
708
+ if (r.skipped) break;
709
+ any = true;
710
+ if (first) st.refolds += r.refold ? 1 : 0;
711
+ first = false;
712
+ st.turns += r.turns || 0; st.calls += r.calls || 0; st.rows += r.rows || 0; st.removed += r.removed; st.archived += r.archived;
713
+ // Between steps too: a session's next step is the new holder's (its local state is saved with
714
+ // no hint, so the next pass goes on from the step this one finished).
715
+ if (r.more && stopped()) { st.stopped = true; break; }
716
+ } while (r.more);
717
+ // A candidate is vanished when the adapter answered for every source it held; one that still
718
+ // reads was left out by a short list (a refusal the pass reports). A listed session's source the
719
+ // list left out that still reads is a warning (header: confirmed).
720
+ if (listed.unlisted) { if (unconfirmed) st.stillThere++; else if (gone) st.vanished++; } else st.unconfirmed += unconfirmed;
721
+ if (st.stopped) { st.more = 1; break; }
722
+ if (!any) { st.skipped++; continue; }
723
+ st.captured++;
724
+ st.refs.push({ harness: key.harness, host: key.host, harness_path: key.harness_path, session_id: key.session_id, incarnation: key.incarnation });
725
+ } catch (e) {
726
+ st.errors.push({ session_id: listed.session_id, code: e && e.code, error: String(e && e.message).split('\n')[0] });
727
+ if (listed.unlisted) st.candidateErrors++;
728
+ }
729
+ }
730
+ ctx.pre = null;
731
+ }
732
+ // What a stopped pass left: the session it stopped in, and the ones it did not reach (some of
733
+ // which may have had nothing new).
734
+ if (st.stopped) st.more = (st.more || 0) + list.length - st.sessions;
735
+ // The sweep's gate for the next pass (THE VANISH SWEEP: gate).
736
+ if (sweeps && ctx.home) {
737
+ // Pending: only what the sweep itself left (header: gate), never a listed session's error or refold.
738
+ const reached = st.sessions - (list.length - st.unlisted);
739
+ const pending = !!(st.vanishRefused || st.vanishWaiting || st.stillThere || st.candidateErrors || st.sweepFailed
740
+ || (st.stopped && st.unlisted && reached < st.unlisted));
741
+ try { saveStoreState(ctx.home, store, ns, { house: P.session_sources, adapter, listed: [...new Set(listedNow)].sort(), pending, ...(after != null ? { after } : {}), ...(refused ? { refused } : {}) }); }
742
+ catch { try { fs.rmSync(storeStatePath(ctx.home, store, ns), { force: true }); } catch { /* the next pass asks the house */ } }
743
+ // The claimed confirmation (header: confirmed empty): used up, or put back as it is, for what is left
744
+ // of its sessions (candidates waiting, failed or not reached) or while it did not apply (the
745
+ // store's folder away, a list that names nothing held). Put back only where doctor wrote none
746
+ // meanwhile: a newer one wins, and applies as any does.
747
+ const claimed = claimedPath(ctx.home, store, ns);
748
+ const back = confirm && (!st.confirmedEmpty || st.vanishWaiting || st.candidateErrors || st.stopped) ? confirm : null;
749
+ if (back) putBack(claimed, confirmPath(ctx.home, store, ns), back);
750
+ try { fs.rmSync(claimed, { force: true }); } catch { /* none */ }
751
+ }
752
+ return st;
753
+ }
754
+
755
+ module.exports = { PACKAGE_RENAMED, PACKAGE_UNLOADABLE, OLD_ADAPTER_PREFIX, packageMessage, packageVersion, PACKAGE_VER_FLOOR, isPackageRow, loadPackage, loadLocal, saveLocal, rowDigest, captureStore, captureSession, heldSessions, confirmEmptyStore, readConfirmation, loadStoreState, storeReadable, applyDerive, recordKey, canon, verNumber, unitsFromArchive, unitRows, schema3Rows, statePath, packageStatus, isPackageMissing, PACKAGE_MISSING,
756
+ };