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,324 @@
1
+ // `memhouse doctor` on a schema-3 house (harness-sessions-sql's profile): the house-side checks,
2
+ // transport-free.
3
+ //
4
+ // tables the six conversation tables in <db> and the two capture tables in <db>_archive
5
+ // exist, with exactly the sorting keys schema-profile.js PROFILE_KEYS names
6
+ // columns every column of the template is on the server with its type and, where the
7
+ // template says MATERIALIZED, still materialized (a lost MATERIALIZED search_word
8
+ // is a search that silently finds nothing)
9
+ // identity no row with an empty writer (an async insert skips the stamp)
10
+ // ts stable no message part or call whose ts moved between its versions: sessions-model
11
+ // §1 rule 7 says a record's ts never changes
12
+ // dedupe captured sessions of this host whose converted rows still wait for the dedupe,
13
+ // and deduped ones that keep converted rows the capture lacks (profile-dedupe.js)
14
+ // pricing every model in the house has a price
15
+ // token capture no harness ships assistant messages with zero tokens
16
+ // forks the engine edges (profile-edges.js): forks billed once, the copied tokens counted
17
+ // only in the origin, and the edges not verified (informational)
18
+ // host identity which machines wrote sessions, and whether this one is among them
19
+ //
20
+ // NO WHOLE-TABLE SCANS BY DEFAULT. Everything on `sessions` reads it FINAL (one row per session,
21
+ // small by design). On `session_parts` and `calls` the identity and ts checks read only the last
22
+ // `days` days (`ts >= …`, the minmax index), and `full: true` (`doctor --full`) widens them to the
23
+ // whole tables, which on a big house is the one expensive thing doctor can be asked to do.
24
+ //
25
+ // Every entry point takes a transport `{ exec, q }` (q-cli.js httpTransport) and returns
26
+ // checks: [{ ok, label, hint }], the shape bin/memhouse.js renders.
27
+
28
+ 'use strict';
29
+
30
+ const V3 = require('./schema-profile');
31
+ const Q = require('./queries-v3');
32
+ const H = require('./house');
33
+
34
+ const SETTINGS = {};
35
+
36
+ function lit(s) { return `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; }
37
+
38
+ /** Split a column declaration's type from its DEFAULT / MATERIALIZED / CODEC / … tail (top level only). */
39
+ function splitDecl(decl) {
40
+ const s = String(decl).trim();
41
+ let depth = 0; let inStr = false;
42
+ for (let i = 0; i < s.length; i++) {
43
+ const c = s[i];
44
+ if (inStr) { if (c === '\\') i++; else if (c === "'") inStr = false; continue; }
45
+ if (c === "'") { inStr = true; continue; }
46
+ if (c === '(') depth++;
47
+ else if (c === ')') depth--;
48
+ else if (depth === 0 && /\s/.test(c)) {
49
+ const rest = s.slice(i).trimStart();
50
+ const m = /^(DEFAULT|MATERIALIZED|ALIAS|EPHEMERAL|CODEC|COMMENT|TTL)\b/i.exec(rest);
51
+ if (m) return { type: s.slice(0, i).trim(), kind: ['DEFAULT', 'MATERIALIZED', 'ALIAS', 'EPHEMERAL'].includes(m[1].toUpperCase()) ? m[1].toUpperCase() : (/\b(DEFAULT|MATERIALIZED|ALIAS|EPHEMERAL)\b/i.exec(rest.slice(m[0].length)) || [null, ''])[1].toUpperCase() };
52
+ }
53
+ }
54
+ return { type: s, kind: '' };
55
+ }
56
+
57
+ /** Split `a T, b U(x, y)` at top-level commas. */
58
+ function topSplit(s) {
59
+ const out = []; let depth = 0; let buf = '';
60
+ for (const c of s) {
61
+ if (c === '(') depth++;
62
+ if (c === ')') depth--;
63
+ if (c === ',' && depth === 0) { out.push(buf.trim()); buf = ''; continue; }
64
+ buf += c;
65
+ }
66
+ if (buf.trim()) out.push(buf.trim());
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * The columns the server should report for one table, from the template:
72
+ * [{ name, type, materialized }]. A Nested column becomes its `name.field Array(T)` columns.
73
+ */
74
+ function expectedColumns(tpl, type) {
75
+ const cols = V3.templateColumns(tpl)[type] || [];
76
+ const out = [];
77
+ for (const c of cols) {
78
+ const nm = /^Nested\s*\(([\s\S]*)\)\s*$/i.exec(c.type.trim());
79
+ if (nm) {
80
+ for (const f of topSplit(nm[1])) {
81
+ const sp = f.indexOf(' ');
82
+ out.push({ name: `${c.name}.${f.slice(0, sp)}`, type: `Array(${f.slice(sp + 1).trim()})`, materialized: false });
83
+ }
84
+ continue;
85
+ }
86
+ const d = splitDecl(c.type);
87
+ out.push({ name: c.name, type: d.type, materialized: d.kind === 'MATERIALIZED' });
88
+ }
89
+ return out;
90
+ }
91
+
92
+ const norm = (t) => String(t).replace(/\s+/g, ' ').replace(/\(\s+/g, '(').replace(/\s+\)/g, ')').trim();
93
+
94
+ /** Compare the server's system.columns rows for one table against the template. */
95
+ function columnProblems(fq, expected, serverCols) {
96
+ const by = new Map(serverCols.map((r) => [r.name, r]));
97
+ const missing = []; const wrong = [];
98
+ for (const c of expected) {
99
+ const got = by.get(c.name);
100
+ if (!got) { missing.push(`${fq}.${c.name}`); continue; }
101
+ if (norm(got.type) !== norm(c.type)) wrong.push(`${fq}.${c.name} is ${got.type}, template says ${c.type}`);
102
+ else if (c.materialized && got.default_kind !== 'MATERIALIZED') wrong.push(`${fq}.${c.name} lost its MATERIALIZED clause`);
103
+ }
104
+ return { missing, wrong };
105
+ }
106
+
107
+ /**
108
+ * The house-side checks.
109
+ * @param {{exec, q}} t
110
+ * @param {{ db, member, full?, days?, hostId?, tpl?, pricing?, user?, url? }} o user/url: who connects, to name a refused credential
111
+ */
112
+ async function houseChecks(t, { db, member, full = false, days = 14, hostId = null, hostAliases = [], tpl = null, calculateCost = null, user = '', url = '', pricingHint = null } = {}) {
113
+ const checks = [];
114
+ const add = (ok, label, hint, o = {}) => checks.push({ ok, label, hint, ...o });
115
+ // A credential the server refused is named as that, by ClickHouse's code (schema-choice.js), not
116
+ // as tables that could not be listed.
117
+ const SC = require('./schema-choice');
118
+ const listFailed = (what, e) => (SC.isAuthFailure(e) ? `${SC.authFailureText(user, url)} — fix the connection above first` : `could not list ${what}: ${e.message}`);
119
+ const N = V3.profileNames(db, member);
120
+ const rooms = Q.v3Rooms(member, db);
121
+ const rows = (sql, settings) => t.q.rows(sql, { ...SETTINGS, ...(settings || {}) });
122
+ rooms.hostAliases = await require('./host-alias').load((sql) => rows(sql), rooms);
123
+ const template = tpl || V3.readTemplate();
124
+
125
+ // 1. tables and keys
126
+ let present = new Map();
127
+ try {
128
+ present = new Map((await rows(`SELECT database, name, sorting_key FROM system.tables
129
+ WHERE (database = ${lit(N.db)} AND name IN (${V3.CONVERSATION_TYPES.map((x) => lit(`${member}_${x}`)).join(', ')}))
130
+ OR (database = ${lit(N.archive)} AND name IN (${V3.CAPTURE_TYPES.map((x) => lit(`${member}_${x}`)).join(', ')}))`))
131
+ .map((r) => [`${r.database}.${r.name}`, r.sorting_key]));
132
+ } catch (e) { add(false, 'schema-3 tables', listFailed('them', e)); return checks; }
133
+ // The statements that create the missing tables, from the template --ensure-schema applies, and
134
+ // for capture tables their grant: what an admin runs. Never an install as the member, which
135
+ // would configure the admin's machine as that member.
136
+ // In the server's own text-index dialect, as --ensure-schema writes it (house.js textIndexDialectFor).
137
+ const createSql = async (types) => {
138
+ let legacy = false;
139
+ try { legacy = H.textIndexDialectFor(String(((await rows('SELECT version() AS v'))[0] || {}).v || '')) === 'legacy'; } catch { /* the modern grammar */ }
140
+ const stmts = V3.renderStatements(template, { db, member }).filter((s) => types.includes(s.type)).map((s) => `${legacy ? H.legacyTextIndexDialect(s.sql) : s.sql};`);
141
+ const grants = V3.captureGrants(db, member).filter((g) => types.some((x) => V3.CAPTURE_TYPES.includes(x) && g.includes(` ${N[x]} `))).map((g) => `${g};`);
142
+ const dbs = types.some((x) => V3.CAPTURE_TYPES.includes(x)) ? [`CREATE DATABASE IF NOT EXISTS ${N.archive};`] : [];
143
+ return [...dbs, ...stmts, ...grants].join('\n');
144
+ };
145
+ const adminFix = async (types) => `as an admin, run (on servers before 26.x with SETTINGS allow_experimental_full_text_index = 1):\n${await createSql(types)}`;
146
+
147
+ // 1b. a member name that collides with another (bob, bob_smith): the grant <db>.<name>_* of one
148
+ // reaches the other's tables. Seen from here as tables we can list beyond our own names.
149
+ try {
150
+ const names = (await rows(`SELECT name FROM system.tables WHERE database = ${lit(N.db)}`)).map((r) => r.name);
151
+ const collide = require('../share').memberCollisions(member, names);
152
+ add(!collide.length, collide.length ? `member names collide: ${collide.join(', ')}` : 'member name: no other member\'s tables within reach',
153
+ collide.length ? `'${member}' and ${collide.join(', ')}: one name is the other's plus an underscore, so a grant <db>.<name>_* reaches the other's tables, to read and to write — docs/guides/troubleshooting.md, "Two member names that collide"` : undefined);
154
+ } catch (e) { add(false, 'member name', listFailed("the house's tables", e)); }
155
+ // 1c. sessions `memhouse import` wrote on the development layout (<m>_turns): a recapture cannot
156
+ // bring them back, so they are named before anyone moves the member (schema-choice.js).
157
+ try {
158
+ const sc = require('./schema-choice');
159
+ const det = await sc.detectHouseSchema((sql) => rows(sql), N.db, member);
160
+ if (det.devLayout) {
161
+ const notice = sc.importsNotice(det, member);
162
+ add(!notice, notice ? `${det.devImports === null ? 'some' : det.devImports} imported session(s) need a re-import` : `${member}_turns: no imported sessions to carry over`,
163
+ notice || `${member}_turns is the development builds' layout: its admin recaptures or converts '${member}' (docs/guides/upgrade-to-0.20.md)`);
164
+ }
165
+ } catch (e) { add(false, 'imported sessions', `could not check: ${e.message}`); }
166
+ for (const [layer, types, where] of [['conversation', V3.CONVERSATION_TYPES, N.db], ['capture', V3.CAPTURE_TYPES, N.archive]]) {
167
+ const missing = types.filter((x) => !present.has(N[x]));
168
+ const keyBad = types.filter((x) => present.has(N[x])).map((x) => [x, V3.keyProblem(x, present.get(N[x]))]).filter(([, p]) => p);
169
+ add(!missing.length && !keyBad.length,
170
+ missing.length ? `${layer} tables: ${types.length - missing.length}/${types.length} in '${where}' — missing ${missing.map((x) => N[x]).join(', ')}`
171
+ : keyBad.length ? `${layer} tables: wrong sorting key on ${keyBad.map(([x, p]) => `${N[x]} ${p}`).join('; ')}`
172
+ : `${layer} tables: ${types.length}/${types.length} in '${where}' (${types.map((x) => N[x]).join(', ')}), keys correct`,
173
+ missing.length ? (layer === 'capture' ? `the shipper cannot write without them, and a member cannot create them; ${await adminFix(missing)}`
174
+ : `memhouse ship --ensure-schema creates them (the member holds ${N.db}.${member}_*); if it is refused, ${await adminFix(missing)}`)
175
+ : 'a wrong key breaks replacement (§2): the table has to be rebuilt from the template by its owner');
176
+ }
177
+
178
+ // 2. columns against the template
179
+ try {
180
+ const missing = []; const wrong = []; let seen = 0;
181
+ for (const x of V3.PROFILE_TYPES) {
182
+ if (!present.has(N[x])) continue;
183
+ seen++;
184
+ const [d, tb] = N[x].split('.');
185
+ const cols = await rows(`SELECT name, type, default_kind FROM system.columns WHERE database = ${lit(d)} AND table = ${lit(tb)}`);
186
+ const p = columnProblems(N[x], expectedColumns(template, x), cols);
187
+ missing.push(...p.missing); wrong.push(...p.wrong);
188
+ }
189
+ const bad = missing.length + wrong.length;
190
+ add(bad === 0 && seen === V3.PROFILE_TYPES.length,
191
+ seen !== V3.PROFILE_TYPES.length ? `columns: only ${seen}/${V3.PROFILE_TYPES.length} tables exist, so the template comparison is incomplete`
192
+ : bad === 0 ? 'columns: every schema-3 table matches the template (name, type and kind)'
193
+ : `columns: ${missing.length} missing, ${wrong.length} wrong — ${[...missing, ...wrong].slice(0, 4).join('; ')}${bad > 4 ? ' …' : ''}`,
194
+ 'a missing column, or a type retyped in harness-sessions-sql v1: memhouse ship --ensure-schema (the archive tables: an admin, docs/guides/troubleshooting.md); any other changed type, or a lost MATERIALIZED clause, needs the table rebuilt by its owner');
195
+ } catch (e) { add(false, 'columns', `could not compare against the template: ${e.message}`); }
196
+
197
+ const haveConv = V3.CONVERSATION_TYPES.every((x) => present.has(N[x]));
198
+ if (!haveConv) return checks;
199
+ const window = full ? '' : ` AND ts >= now64(3) - toIntervalDay(${Number(days)})`;
200
+ const scope = full ? 'all rows' : `last ${days} days`;
201
+
202
+ // 3. identity stamping
203
+ try {
204
+ const s = (await rows(`SELECT count() AS n, countIf(writer = '') AS blank FROM ${N.sessions} FINAL WHERE is_deleted = 0`))[0] || {};
205
+ const tr = (await rows(`SELECT count() AS n, countIf(writer = '') AS blank FROM ${N.session_parts} WHERE 1${window}`))[0] || {};
206
+ const cr = (await rows(`SELECT count() AS n, countIf(writer = '') AS blank FROM ${N.calls} WHERE 1${window}`))[0] || {};
207
+ const blank = Number(s.blank || 0) + Number(tr.blank || 0) + Number(cr.blank || 0);
208
+ const total = Number(s.n || 0) + Number(tr.n || 0) + Number(cr.n || 0);
209
+ add(blank === 0, total === 0 ? 'identity stamping (no rows yet)'
210
+ : blank === 0 ? `identity stamping: ${s.n} sessions, ${tr.n} part and ${cr.n} call rows (${scope}), all attributed`
211
+ : `identity stamping: ${blank} of ${total} rows have an empty writer (${scope})`,
212
+ 'a writer used async_insert=1 — the MATERIALIZED currentUser() stamp does not run during an async flush');
213
+ } catch (e) { add(false, 'identity stamping', e.message); }
214
+
215
+ // 4. ts stable (sessions-model §1 rule 7): a part's or call's versions all carry one ts. The
216
+ // profile keys rows without ts, so a moved ts replaces; it is still an adapter breaking the rule.
217
+ try {
218
+ const dupSql = (table, id) => `SELECT count() AS n, groupArray(3)(concat(harness, ':', session_id, ' ', i)) AS sample FROM (
219
+ SELECT harness, session_id, ${id} AS i FROM ${table} WHERE is_deleted = 0${window}
220
+ GROUP BY harness, host, harness_path, session_id, incarnation, i HAVING uniqExact(ts) > 1)`;
221
+ const dt = (await rows(dupSql(N.session_parts, "concat(message_id, '#', part_id)")))[0] || {};
222
+ const dc = (await rows(dupSql(N.calls, 'call_id')))[0] || {};
223
+ const n = Number(dt.n || 0) + Number(dc.n || 0);
224
+ add(n === 0,
225
+ n === 0 ? `ts stable: no message part or call whose ts moved (${scope})`
226
+ : `ts stable: ${dt.n} message part(s), ${dc.n} call(s) whose ts moved between versions (${scope}) — e.g. ${[...(dt.sample || []), ...(dc.sample || [])].slice(0, 3).join('; ')}`,
227
+ 'sessions-model §1 rule 7: a record\'s ts never changes; the adapter that wrote them has an unstable ts');
228
+ } catch (e) { add(false, 'ts stable', e.message); }
229
+
230
+ // 5. the dedupe (profile-dedupe.js): this host's captured sessions whose converted rows still wait
231
+ // for it, and deduped sessions that keep converted rows the capture lacks (their transcript
232
+ // lost them; kept, and said so).
233
+ if (hostId) {
234
+ try {
235
+ const pd = require('./profile-dedupe');
236
+ const pending = await rows(pd.storesWithLegacySQL(N, hostId, { marked: false, aliases: hostAliases }));
237
+ const kept = await rows(`SELECT count() AS n, sum(JSONExtractUInt(state, 'dedupe', 'kept', 'turns') + JSONExtractUInt(state, 'dedupe', 'kept', 'calls')) AS rows
238
+ FROM ${N.session_sources} FINAL WHERE is_deleted = 0 AND host = ${lit(hostId)} AND role = 'legacy' AND NOT JSONExtractBool(state, 'pending')
239
+ AND JSONExtractUInt(state, 'dedupe', 'kept', 'turns') + JSONExtractUInt(state, 'dedupe', 'kept', 'calls') > 0`);
240
+ const k = kept[0] || {};
241
+ add(pending.length === 0,
242
+ (pending.length === 0 ? 'dedupe: no captured session here waits for its converted rows to be matched'
243
+ : `dedupe: ${pending.length} captured session(s) here still hold converted rows not yet matched — e.g. ${pending.slice(0, 3).map((x) => `${x.harness}:${x.session_id}`).join(', ')}`)
244
+ + (Number(k.n || 0) ? `; ${k.n} deduped session(s) keep ${k.rows} converted row(s) their transcript no longer has` : ''),
245
+ 'the capture dedupes a session once it has captured it to the end; one pass (memhouse ship) usually clears it');
246
+ } catch (e) { add(false, 'dedupe', e.message); }
247
+ }
248
+
249
+ // 6. pricing and token capture — from sessions FINAL (usage entries per session)
250
+ try {
251
+ const cost = calculateCost || require('../../pricing').calculateCost;
252
+ const models = await t.exec(Q.aggregate(rooms, { source: 'usage', dims: ['model'], metrics: ['messages'], where: { excludeModels: true } }));
253
+ const unpriced = models.filter((m) => cost(m.model, 1e6, 0, 0, 0) === null);
254
+ const total = models.reduce((a, m) => a + Number(m.messages), 0);
255
+ const missed = unpriced.reduce((a, m) => a + Number(m.messages), 0);
256
+ const pct = total ? (missed / total) * 100 : 0;
257
+ const share = pct >= 0.5 ? `${Math.round(pct)}% of messages` : `${missed} message${missed === 1 ? '' : 's'}`;
258
+ // An unpriced model is a gap in the cost figures, not a fault in the house: a warning, never a
259
+ // failure. The caller says whether an update would bring prices (pricingHint); without it, the
260
+ // hint names both cases rather than sending a current install to `memhouse update`.
261
+ if (!unpriced.length) add(true, `pricing: every model in this house has a price (${models.length} models)`);
262
+ else {
263
+ add(true, `pricing: ${unpriced.length} model${unpriced.length > 1 ? 's' : ''} unpriced — ${share} cost nothing in the dashboard (${unpriced.map((m) => m.model).join(', ')})`,
264
+ `${pricingHint || 'prices ship with each release: a newer one may add them (memhouse update), and on the latest one their cost stays unknown until a release does'}; memhouse q spend lists them`,
265
+ { warn: true });
266
+ }
267
+ const zero = await rows(`SELECT harness, sum(assistant_messages) AS n FROM ${N.sessions} FINAL
268
+ WHERE is_deleted = 0 AND origin = 'ship' GROUP BY harness
269
+ HAVING n > 0 AND sum(input_tokens) + sum(output_tokens) = 0 ORDER BY harness`);
270
+ add(zero.length === 0,
271
+ zero.length === 0 ? 'token capture: every harness shipping here reports usage'
272
+ : `token capture: ${zero.map((r) => `${r.harness} (${r.n} messages)`).join(', ')} ship ZERO tokens — their cost shows as $0, not as unknown`,
273
+ 'that adapter does not extract usage; the messages are stored, the numbers are not');
274
+ } catch (e) { add(false, 'pricing', e.message); }
275
+
276
+ // 6b. removed content (plan v4 M4): what every read hides, kept as removed versions. Informational.
277
+ try {
278
+ const rc = await rows(`SELECT toString(harness) AS h, uniqExact(session_id, harness_path, incarnation) AS s, uniqExact(session_id, harness_path, incarnation, message_id) AS m
279
+ FROM ${N.session_parts} FINAL WHERE removed = 1 AND is_deleted = 0${window} GROUP BY h ORDER BY m DESC`);
280
+ const m = rc.reduce((a, r) => a + Number(r.m), 0); const s = rc.reduce((a, r) => a + Number(r.s), 0);
281
+ add(true, m === 0 ? `removed content: none (${scope})`
282
+ : `removed content: ${m} message(s) in ${s} session(s) (${scope}) — ${rc.map((r) => `${r.h} ${r.m}`).join(', ')}; kept as removed versions, searchable with \`memhouse q removed\`, never counted`);
283
+ } catch (e) { add(false, 'removed content', e.message); }
284
+
285
+ // 6c. forks (plan v4 M4): the engine edges, and the copied usage they keep out of the forks'
286
+ // totals (billed once, in the origin). Informational.
287
+ try {
288
+ const edges = `FROM ${N.session_links} FINAL WHERE owner = 'engine' AND removed = 0 AND kind IN ('fork', 'rewind_copy')`;
289
+ // A lineage fork (verified null: Claude Code's and Codex's copies) is neither verified nor failed.
290
+ const fk = (await rows(`SELECT count() AS n, countIf(ifNull(verified, false)) AS ok, countIf(verified = false) AS failed, countIf(verified IS NULL) AS lineage,
291
+ countIf(ifNull(verified, false) AND length(r_copied_usage) > 0) AS billed, sum(length(r_copied_usage)) AS out ${edges}`))[0] || {};
292
+ // In the house split the rollup bills (profile-rollup.js: cached input counted once, reasoning in output).
293
+ // Every listed row is summed: a per_record response's earlier records too (only converted Claude Code
294
+ // rows are per_record, and the edges compare captured rows first, so they are not listed in practice).
295
+ const tk = Number(fk.out || 0) ? (await rows(`SELECT sum(toUInt64(greatest(if(ifNull(input_includes_cache, false), toInt64(ifNull(input, 0)) - toInt64(ifNull(cache_read, 0)) - toInt64(ifNull(cache_write, 0)), toInt64(ifNull(input, 0))), 0))
296
+ + ifNull(tool_prompt, 0) + ifNull(output, 0) + if(ifNull(output_includes_reasoning, true), 0, ifNull(reasoning, 0)) + ifNull(cache_read, 0) + ifNull(cache_write, 0)) AS t
297
+ FROM ${N.session_usage} FINAL WHERE removed = 0 AND (host, toString(harness), toString(harness_path), session_id, incarnation, usage_id) IN (
298
+ SELECT host, toString(harness), toString(harness_path), session_id, incarnation, arrayJoin(r_copied_usage) ${edges} AND ifNull(verified, false))`))[0] || {} : {};
299
+ const n = Number(fk.n || 0); const unverified = Number(fk.failed || 0); const lineage = Number(fk.lineage || 0);
300
+ add(true, n === 0 ? 'forks: no fork link in this house'
301
+ : `forks: ${n} fork link(s), ${fk.billed} billed once (${Number(tk.t || 0)} copied tokens counted only in the origin)`
302
+ + (unverified ? `; ${unverified} not verified, counted in both sessions` : '')
303
+ + (lineage ? `; ${lineage} of Claude Code or Codex, lineage only` : ''));
304
+ } catch (e) { add(false, 'forks', e.message); }
305
+
306
+ // 7. host identity
307
+ try {
308
+ // Folded as status and `q machines` fold them: a 0.18 id this member's machine recorded as
309
+ // its own reads as that machine (host-alias.js).
310
+ const hosts = require('./q-cli').machinesFolded(await t.exec(Q.machines(rooms)), rooms);
311
+ // host is sessions-model/host/v0 (a 32-hex machine fingerprint); the label is for people.
312
+ const name = (h) => (h.label ? `${h.label} (${h.host.slice(0, 8)}…)` : h.host);
313
+ const mine = hostId ? hosts.find((h) => h.host === hostId) : null;
314
+ const before = hosts.filter((h) => hostAliases.includes(h.host));
315
+ const others = hosts.filter((h) => h.host !== hostId && !hostAliases.includes(h.host));
316
+ add(true, hosts.length === 0 ? `host identity ${hostId || '?'} (nothing shipped from here yet)`
317
+ : `host identity ${hostId || '?'} — ${mine ? `${mine.sessions} sessions from this machine${mine.aliases ? ` (its pre-0.20 id ${mine.aliases.join(', ')} folded in)` : ''}` : 'no sessions from this machine yet'}`
318
+ + (before.length ? `, ${before.reduce((a, h) => a + Number(h.sessions), 0)} under its pre-0.20 id ${before.map((h) => h.host).join(', ')}` : '')
319
+ + (others.length ? `, ${others.length} other host${others.length > 1 ? 's' : ''}: ${others.map((h) => `${name(h)} (${h.sessions})`).join(', ')}` : ''));
320
+ } catch { /* the reads above already reported anything that would break this */ }
321
+ return checks;
322
+ }
323
+
324
+ module.exports = { houseChecks, expectedColumns, columnProblems, splitDecl, topSplit };
@@ -0,0 +1,85 @@
1
+ // One machine, two ids: the 0.18 one and the sessions-model one.
2
+ //
3
+ // 0.18 named a machine `<short>-<8 hex>` (host.js identity()); 0.20 names it by
4
+ // sessions-model/host/v0, a 32-hex digest of the OS machine id. Rows converted from the 0.18 rooms
5
+ // keep the 0.18 id, captured rows carry the new one, so after a move one machine read as two
6
+ // writers. Neither id can be computed from the other — the 8 hex are a different, truncated digest
7
+ // — so the conversion, which sees only the house, cannot map them. The machine itself knows both.
8
+ // It records the pair in its member's own meta room (`host_alias:<0.18 id>` = `<new id>`) when it
9
+ // switches and on each capture pass, and readers fold the 0.18 id's rows into the machine's at
10
+ // query time. Rows are never rewritten. An alias is read from a member's own meta and applied to
11
+ // that member's own rows only, so no member can fold another's machines.
12
+
13
+ const KEY = 'host_alias:';
14
+ const OLD_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; // the 0.18 `<short>-<fp8>` (and anything of its shape)
15
+ const NEW_RE = /^(r-)?[0-9a-f]{32}$/; // sessions-model/host/v0, or its random fallback
16
+
17
+ const lit = (s) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
18
+ const valid = (old, neu) => typeof old === 'string' && typeof neu === 'string' && old !== neu && OLD_RE.test(old) && NEW_RE.test(neu);
19
+
20
+ /** The recorded aliases of one member, latest per 0.18 id. `meta` is that member's meta room. */
21
+ function readSQL(meta) {
22
+ return `SELECT substring(key, ${KEY.length + 1}) AS old, argMax(value, updated_at) AS new
23
+ FROM ${meta} WHERE startsWith(key, ${lit(KEY)}) GROUP BY key`;
24
+ }
25
+
26
+ /** { old: new } from rows of readSQL, dropping anything that is not of the two ids' shapes. */
27
+ function parse(rows) {
28
+ const map = {};
29
+ for (const r of rows || []) if (valid(r.old, r.new)) map[r.old] = r.new;
30
+ return map;
31
+ }
32
+
33
+ /** A member's aliases, or {} when its meta cannot be read (a housemate's share does not grant it). */
34
+ async function load(rows, rooms) {
35
+ if (!rooms || !rooms.meta) return {};
36
+ try { return parse(await rows(readSQL(rooms.meta))); } catch { return {}; }
37
+ }
38
+
39
+ /**
40
+ * Record `old` -> `neu` in the member's meta when rows exist under `old` and the pair is not
41
+ * recorded yet. `ph` answers rows(sql) and run(sql) (profile-house.js, or a transport).
42
+ * @returns {Promise<boolean>} whether it wrote
43
+ */
44
+ async function record(ph, { meta, sessions }, old, neu) {
45
+ if (!valid(old, neu)) return false;
46
+ const have = parse(await ph.rows(readSQL(meta)));
47
+ if (have[old] === neu) return false;
48
+ const under = await ph.rows(`SELECT count() AS n FROM ${sessions} WHERE host = ${lit(old)} LIMIT 1`);
49
+ if (!under.length || !Number(under[0].n)) return false;
50
+ await ph.run(`INSERT INTO ${meta} (key, value) VALUES (${lit(KEY + old)}, ${lit(neu)})`);
51
+ return true;
52
+ }
53
+
54
+ /** The id a row's host reads as. */
55
+ const fold = (host, aliases) => (aliases && Object.prototype.hasOwnProperty.call(aliases, host) ? aliases[host] : host);
56
+
57
+ /** Every id that reads as `host`: itself and the 0.18 ids aliased to it. */
58
+ function idsOf(host, aliases) {
59
+ return [host, ...Object.keys(aliases || {}).filter((o) => aliases[o] === host && o !== host)];
60
+ }
61
+
62
+ /**
63
+ * Merge per-host rows whose hosts fold to one machine. `how` names the columns: sum, min, max,
64
+ * union (arrays, sorted), any (first non-empty). Row order is kept by first appearance.
65
+ */
66
+ function foldRows(rows, aliases, how = {}) {
67
+ if (!aliases || !Object.keys(aliases).length) return rows;
68
+ const key = how.key || 'host';
69
+ const out = new Map();
70
+ for (const r of rows) {
71
+ const k = fold(r[key], aliases);
72
+ const cur = out.get(k);
73
+ if (!cur) { out.set(k, { ...r, [key]: k, ...(k !== r[key] ? { aliases: [r[key]] } : {}) }); continue; }
74
+ if (k !== r[key]) cur.aliases = [...(cur.aliases || []), r[key]];
75
+ for (const c of how.sum || []) cur[c] = Number(cur[c] || 0) + Number(r[c] || 0);
76
+ for (const c of how.max || []) cur[c] = Math.max(Number(cur[c] || 0), Number(r[c] || 0));
77
+ for (const c of how.min || []) cur[c] = [cur[c], r[c]].filter((v) => v !== null && v !== undefined && v !== '').map(Number).reduce((a, b) => Math.min(a, b), Infinity);
78
+ for (const c of how.union || []) cur[c] = [...new Set([...(cur[c] || []), ...(r[c] || [])])].sort();
79
+ for (const c of how.any || []) if (!cur[c] && r[c]) cur[c] = r[c];
80
+ }
81
+ for (const r of out.values()) for (const c of how.min || []) if (r[c] === Infinity) r[c] = null;
82
+ return [...out.values()];
83
+ }
84
+
85
+ module.exports = { KEY, readSQL, parse, load, record, fold, idsOf, foldRows, valid };
@@ -80,7 +80,7 @@ const MIGRATIONS = {
80
80
  * mixed-fleet story: every release from 0.10.0 on REFUSES a house whose recorded schema
81
81
  * it does not support, in both directions — a house too new says "update memhouse", a
82
82
  * house too old says "memhouse migrate". The backward half cannot be code: releases
83
- * already on the registry read none of this, so for them the enforcement is the pilot's
83
+ * already on the registry read none of this, so for them the enforcement is the user's
84
84
  * GRANTs (revoke the mutation privileges after migrating, and a pre-0.10 shipper's
85
85
  * delete fails loudly instead of destroying retained parses). `memhouse migrate` prints
86
86
  * that advice.
@@ -184,8 +184,8 @@ function installCommand() {
184
184
  // user names. Refuse what would need quoting rather than quote it: a name a person cannot
185
185
  // type bare into their own SQL is a name they will get wrong. `default` is deliberately
186
186
  // ALLOWED as a house name — a stock container's default database is a real place to keep
187
- // a house, and the pilot may pick user 'alice' with house 'default'. Only the names that
188
- // are not a database at all are refused.
187
+ // a house, and the person running memhouse may pick user 'alice' with house 'default'.
188
+ // Only the names that are not a database at all are refused.
189
189
  const RESERVED_DBS = new Set(['system', 'information_schema']);
190
190
  function assertUsableName(name, what = 'name') {
191
191
  if (typeof name !== 'string' || !/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) {
@@ -23,7 +23,7 @@
23
23
  // measured losses and are not per-migration choices:
24
24
  //
25
25
  // NOTHING IS DELETED. A replaced object is renamed to `<name>_pre_<suffix>` and left
26
- // for the pilot to drop. A leftover `__migrating` table from an interrupted run is
26
+ // for the user to drop. A leftover `__migrating` table from an interrupted run is
27
27
  // never cleared automatically — it may hold the only copy of mid-flight rows.
28
28
  //
29
29
  // PROVENANCE IS COPIED, NOT RESTAMPED. `user_id` is MATERIALIZED currentUser(), so a
@@ -193,7 +193,7 @@ async function rebuildRoom(q, ctx, { type, name, keepSuffix }, ui) {
193
193
  * ADD COLUMN anything the template declares that the live table lacks — for objects a
194
194
  * migration does NOT rebuild. `sessions` keeps its key across schema 2 but still gains
195
195
  * `epoch`; without this, the very next ship opens with a "fields are being DISCARDED"
196
- * warning and sends the pilot to a second command.
196
+ * warning and sends the user to a second command.
197
197
  */
198
198
  async function healColumns(q, ctx, { type, name }, ui) {
199
199
  const { templateColumns } = require('../shipper/ship');
@@ -0,0 +1,183 @@
1
+ // The dedupe writer on harness-sessions-sql's profile (step B4): one session's converted rows (adapter
2
+ // 'legacy') matched against its adapter-derived rows by dedupe-profile.js, the matched ones
3
+ // tombstoned, the session marked deduped. The profile's form of capture.js dedupeSession.
4
+ //
5
+ // THE WRITER (each line is a decision)
6
+ // stores the captured session's store, and the same session id in the unknown store ('': a
7
+ // 0.18 row without a transcript path), under this host and its aliases (the old host
8
+ // id where the conversion kept it); the captured session's incarnation
9
+ // reads the legacy session_parts, calls and session_usage in EVERY version (no FINAL: a
10
+ // tombstoned row keeps the run it matched, and the tokens a Codex re-report proof reads),
11
+ // the store's live adapter-derived parts
12
+ // nothing a session with no legacy rows is { none }; one with no captured parts is { skipped }
13
+ // and gets no marker: matched against nothing, every legacy row would be kept for good
14
+ // tombstones server-side, by key: the live row (FINAL) written again at rev + 1, is_deleted 1. In
15
+ // order: a pending marker, parts, calls, then their usage, the sessions refresh, then
16
+ // the marker. A pass stopped anywhere between them leaves the pending marker (or live
17
+ // legacy rows), so the sweep finds the session and the next pass finishes it; its plan
18
+ // decides the same way (a dead message's leftover usage goes with it; a message still
19
+ // live keeps the usage its proof reads). Two passes racing on one session converge
20
+ // marker a session_sources row in the captured store: source_key 'legacy:rows', role
21
+ // 'legacy', mode 'snapshot', generation 0, progress 1 + the legacy ship rows dead
22
+ // (at least the previous marker's + 1, so a repair outranks it under FINAL), state
23
+ // { v: 1, dedupe: <report> }. The pending marker is the same row with `pending: true`,
24
+ // at the previous marker's progress + 1 (1 with none), written once before the first
25
+ // tombstone; the marker then outranks it
26
+ // sessions every store's sessions row of the session written again through the shared rollup
27
+ // (profile-rollup.js refreshSessionsSQL), before the marker; a store with no live row
28
+ // left is retired. A store that keeps legacy rows the capture lacks stays a session of
29
+ // its own: the profile counts a session per key, and harness-sessions-sql does not merge two
30
+ // hosts' copies
31
+ // repair { redo: true } writes nothing when the plan tombstones nothing new and the marker
32
+ // already reports the same kept counts
33
+
34
+ 'use strict';
35
+
36
+ const dp = require('../shipper/dedupe-profile');
37
+ const dedupe = require('../shipper/dedupe-v3');
38
+ const { lit } = require('./convert-v3');
39
+ const rollup = require('./profile-rollup');
40
+
41
+ const STORE = '(host, harness, harness_path, session_id, incarnation)';
42
+ const MARKER_KEY = dedupe.LEGACY_FILE_KEY;
43
+ const MARKER_ROLE = dedupe.LEGACY_ROLE;
44
+
45
+ const tuple = (vals) => `(${vals.map(lit).join(', ')})`;
46
+ const storeTuple = (r) => tuple([r.host, r.harness, r.harness_path, r.session_id, r.incarnation || '']);
47
+ /** The stores a session's legacy rows may sit in (header). */
48
+ function legacyStores(ref, aliases = []) {
49
+ const out = [];
50
+ for (const host of [ref.host, ...aliases]) for (const hp of [...new Set([ref.harness_path, ''])]) out.push({ ...ref, host, harness_path: hp });
51
+ return out;
52
+ }
53
+ const inStores = (stores) => `${STORE} IN (${stores.map(storeTuple).join(', ')})`;
54
+
55
+ const PART_READ = `host, toString(harness) AS harness, toString(harness_path) AS harness_path, session_id, incarnation, message_id, part_id, part_index,
56
+ order_key, agent_id, toString(role) AS role, toString(ts) AS ts, toString(served_model) AS served_model, toString(requested_model) AS requested_model,
57
+ response_id, toString(kind) AS kind, text, toString(tool_name) AS tool_name, args, output, call_id, error_message, extra, toString(rev) AS rev, is_deleted, removed`;
58
+ const CALL_READ = `host, toString(harness) AS harness, toString(harness_path) AS harness_path, session_id, incarnation, call_id, message_id, agent_id,
59
+ toString(ts) AS ts, toString(tool_name) AS tool_name, args, toString(rev) AS rev, is_deleted, removed`;
60
+
61
+ // A converted row the conversion wrote removed (content only an older 0.18 copy held) is history: the
62
+ // dedupe never matches, keeps or retires it.
63
+ function legacyPartsSQL(P, stores) { return `SELECT ${PART_READ} FROM ${P.session_parts} WHERE ${inStores(stores)} AND adapter = 'legacy' AND removed = 0`; }
64
+ function legacyCallsSQL(P, stores) { return `SELECT ${CALL_READ} FROM ${P.calls} WHERE ${inStores(stores)} AND adapter = 'legacy' AND removed = 0`; }
65
+ function legacyUsageSQL(P, stores) {
66
+ return `SELECT host, toString(harness_path) AS harness_path, usage_id, toString(anchor_kind) AS anchor_kind, anchor_id, input, output,
67
+ cache_read, cache_write, input_includes_cache, toString(rev) AS rev, is_deleted
68
+ FROM ${P.session_usage} WHERE ${inStores(stores)} AND adapter = 'legacy' AND removed = 0`;
69
+ }
70
+ function capturedPartsSQL(P, ref) {
71
+ return `SELECT ${PART_READ} FROM ${P.session_parts} FINAL WHERE ${inStores([ref])} AND adapter != 'legacy' AND removed = 0`;
72
+ }
73
+ function capturedUsageSQL(P, ref) {
74
+ return `SELECT usage_id, toString(anchor_kind) AS anchor_kind, anchor_id, toString(ts) AS ts, toString(model) AS model, toString(input) AS input, toString(output) AS output,
75
+ toString(cache_read) AS cache_read, toString(cache_write) AS cache_write, input_includes_cache
76
+ FROM ${P.session_usage} FINAL WHERE ${inStores([ref])} AND adapter != 'legacy' AND removed = 0`;
77
+ }
78
+ function markerSQL(P, ref) {
79
+ return `SELECT toString(progress) AS progress, state FROM ${P.session_sources} FINAL
80
+ WHERE ${inStores([ref])} AND source_key = ${lit(MARKER_KEY)} AND role = ${lit(MARKER_ROLE)} AND is_deleted = 0`;
81
+ }
82
+
83
+ /** The live legacy rows of `table` at these keys, written again at rev + 1 as tombstones. `id`: the key's own columns. */
84
+ function tombstoneSQL(P, table, stores, id, keys) {
85
+ // The filter sits in a subquery: ClickHouse resolves a SELECT alias in WHERE (profile-house.js retire).
86
+ return `INSERT INTO ${P[table]} SELECT * REPLACE (rev + 1 AS rev, toUInt8(1) AS is_deleted, now64(3) AS ingested_at)
87
+ FROM (SELECT * FROM ${P[table]} FINAL
88
+ WHERE ${inStores(stores)} AND adapter = 'legacy' AND (host, harness_path, ${id.join(', ')}) IN (${keys.map((k) => tuple([k.host, k.harness_path, ...id.map((c) => k[c])])).join(', ')}))`;
89
+ }
90
+ const TOMBSTONED = [['session_parts', ['message_id', 'part_id']], ['calls', ['call_id']], ['session_usage', ['usage_id']]];
91
+
92
+ /** The marker row (header); `pending`: the one written before the first tombstone. */
93
+ function markerRow(ref, plan, prev, { pending = false } = {}) {
94
+ let progress = pending ? 1 : plan.dead + 1;
95
+ if (prev && Number(prev.progress) >= progress) progress = Number(prev.progress) + 1;
96
+ return {
97
+ host: ref.host, harness: ref.harness, harness_path: ref.harness_path, session_id: ref.session_id, incarnation: ref.incarnation || '',
98
+ source_key: MARKER_KEY, mode: 'snapshot', position: '', generation: 0, progress,
99
+ state: JSON.stringify({ v: 1, ...(pending ? { pending: true } : {}), dedupe: plan.report }),
100
+ state_ver: 1, adapter: 'legacy', adapter_ver: '0', role: MARKER_ROLE, is_deleted: 0,
101
+ };
102
+ }
103
+ const keptOf = (m) => { try { const k = JSON.parse(m.state).dedupe.kept; return `${k.turns}/${k.calls}`; } catch { return null; } };
104
+ const isPending = (m) => { try { return JSON.parse(m.state).pending === true; } catch { return false; } };
105
+ const insertMarker = (db, P, m) => db.run(`INSERT INTO ${P.session_sources} FORMAT JSONEachRow\n${JSON.stringify(m)}`);
106
+
107
+ /**
108
+ * Dedupe one captured session on the profile.
109
+ * @param {{rows: (sql: string) => Promise<object[]>, run: (sql: string) => Promise<unknown>}} db
110
+ * @param {object} P profileNames(db, member)
111
+ * @param {object} ref the captured session's store: host, harness, harness_path, session_id, incarnation
112
+ * @param {object} [o]
113
+ * @param {string[]} [o.aliases] this host's other ids
114
+ * @param {boolean} [o.redo] a repair re-plan
115
+ * @param {(ref: object) => Promise<Array>} [o.repeatsOf] Codex re-report proofs, read only when the plan needs them
116
+ * @returns {Promise<{none?: true, skipped?: true, unchanged?: true, plan?: object, marker?: object}>}
117
+ */
118
+ async function dedupeProfileSession(db, P, ref, o = {}) {
119
+ const stores = legacyStores(ref, o.aliases || []);
120
+ const [legacyParts, legacyCalls] = [await db.rows(legacyPartsSQL(P, stores)), await db.rows(legacyCallsSQL(P, stores))];
121
+ if (!legacyParts.length && !legacyCalls.length) return { none: true };
122
+ const capturedParts = await db.rows(capturedPartsSQL(P, ref));
123
+ if (!capturedParts.length) return { skipped: true };
124
+ const legacyUsage = await db.rows(legacyUsageSQL(P, stores));
125
+ const capturedUsage = await db.rows(capturedUsageSQL(P, ref));
126
+ const input = { harness: ref.harness, session_id: ref.session_id, legacyParts, legacyCalls, legacyUsage, capturedParts, capturedUsage };
127
+ let plan = dp.planProfileDedupe(input);
128
+ if (plan.report.rereport_unproven > 0 && o.repeatsOf) {
129
+ const repeats = await o.repeatsOf(ref);
130
+ if (repeats.length) plan = dp.planProfileDedupe({ ...input, repeats });
131
+ }
132
+ const prev = (await db.rows(markerSQL(P, ref)))[0];
133
+ const T = plan.tombstones;
134
+ if (o.redo && !T.session_parts.length && !T.calls.length && !T.session_usage.length
135
+ && (!prev || (!isPending(prev) && keptOf(prev) === `${plan.report.kept.turns}/${plan.report.kept.calls}`))) return { plan, unchanged: true };
136
+ let last = prev;
137
+ if (!prev || !isPending(prev)) { last = markerRow(ref, plan, prev, { pending: true }); await insertMarker(db, P, last); }
138
+ for (const [table, id] of TOMBSTONED) {
139
+ const keys = T[table];
140
+ for (let i = 0; i < keys.length; i += 500) await db.run(tombstoneSQL(P, table, stores, id, keys.slice(i, i + 500)));
141
+ }
142
+ const keys = new Map([[storeTuple(ref), ref]]);
143
+ for (const r of [...legacyParts, ...legacyCalls]) keys.set(storeTuple(r), r);
144
+ await db.run(rollup.refreshSessionsSQL(P, `${STORE} IN (${[...keys.keys()].join(', ')})`));
145
+ const marker = markerRow(ref, plan, last);
146
+ await insertMarker(db, P, marker);
147
+ return { plan, marker };
148
+ }
149
+
150
+ /**
151
+ * The captured stores of this host whose session has live legacy rows in a store dedupe reads.
152
+ * `marked`: with a marker (repair candidates: live legacy ship rows, or a marker reporting kept rows),
153
+ * else without one (the sweep). A capture's store is one with a session_sources row of a source.
154
+ * `stores`: only these ({harness, harness_path}), so a LIMIT is not filled by stores a pass skips.
155
+ */
156
+ function storesWithLegacySQL(P, host, { marked, aliases = [], stores = null }) {
157
+ const hosts = [host, ...aliases].map(lit).join(', ');
158
+ const prefix = lit(marked ? 'legacy:ship:' : 'legacy:');
159
+ const legacy = (t, id) => `SELECT DISTINCT toString(harness) AS h, toString(harness_path) AS p, session_id AS s, incarnation AS i FROM ${t} FINAL
160
+ WHERE host IN (${hosts}) AND adapter = 'legacy' AND removed = 0 AND startsWith(${id}, ${prefix})`;
161
+ // The sweep also takes live legacy usage alone: a pass of an older build stopped after the parts and calls.
162
+ const usage = `SELECT DISTINCT toString(harness) AS h, toString(harness_path) AS p, session_id AS s, incarnation AS i FROM ${P.session_usage} FINAL
163
+ WHERE host IN (${hosts}) AND adapter = 'legacy' AND removed = 0`;
164
+ const L = `${legacy(P.session_parts, 'message_id')} UNION DISTINCT ${legacy(P.calls, 'call_id')}${marked ? '' : ` UNION DISTINCT ${usage}`}`;
165
+ const src = (role) => `SELECT DISTINCT toString(harness) AS h, toString(harness_path) AS p, session_id AS s, incarnation AS i,
166
+ JSONExtractUInt(state, 'dedupe', 'kept', 'turns') + JSONExtractUInt(state, 'dedupe', 'kept', 'calls') AS k
167
+ FROM ${P.session_sources} FINAL WHERE is_deleted = 0 AND host = ${lit(host)} AND ${role}`;
168
+ const isMarker = `source_key = ${lit(MARKER_KEY)} AND role = ${lit(MARKER_ROLE)}`;
169
+ // A pending marker is a dedupe that stopped part way: the sweep's, until the marker replaces it.
170
+ const markers = (pending) => `SELECT toString(harness), toString(harness_path), session_id, incarnation FROM ${P.session_sources} FINAL
171
+ WHERE is_deleted = 0 AND host = ${lit(host)} AND ${isMarker} AND ${pending ? '' : 'NOT '}JSONExtractBool(state, 'pending')`;
172
+ return `SELECT h AS harness, p AS harness_path, s AS session_id, i AS incarnation FROM (
173
+ ${src(marked ? isMarker : `role NOT IN ('side', ${lit(MARKER_ROLE)}, 'inherited')`)})
174
+ WHERE ((h, p, s, i) IN (${L}) OR (h, '', s, i) IN (${L})${marked ? ' OR k > 0' : ` OR (h, p, s, i) IN (${markers(true)})`})
175
+ ${marked ? '' : `AND (h, p, s, i) NOT IN (${markers(false)})`}
176
+ ${stores ? `AND (h, p) IN (${stores.length ? stores.map((x) => tuple([x.harness, x.harness_path])).join(', ') : "('', '')"})` : ''}
177
+ ORDER BY h, p, s`;
178
+ }
179
+
180
+ module.exports = {
181
+ dedupeProfileSession, storesWithLegacySQL, legacyStores, legacyPartsSQL, legacyCallsSQL, legacyUsageSQL, capturedPartsSQL, capturedUsageSQL, markerSQL,
182
+ tombstoneSQL, markerRow, MARKER_KEY, MARKER_ROLE,
183
+ };