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,742 @@
1
+ // Legacy -> v3 conversion (docs/design/source-identity.md draft 5, §7 step 4).
2
+ //
3
+ // Reads the member's legacy rooms, renamed into the archive database:
4
+ //
5
+ // <db>_archive.<m>_messages_pre_v3, <m>_tool_calls_pre_v3, <m>_sessions_pre_v3
6
+ //
7
+ // and writes the conversation layer:
8
+ //
9
+ // <db>.<m>_turns, <m>_calls, <m>_sessions
10
+ //
11
+ // Everything runs SERVER-SIDE as INSERT … SELECT, one hash bucket of sessions at a time
12
+ // (sized by rows and by text bytes — see bucketCount). Nothing is read into this process but
13
+ // counts.
14
+ //
15
+ // THE MAPPING (each line is a decision; docs/design/source-identity-build.md lists them too)
16
+ //
17
+ // harness, session_id split from the old '<source>:<native id>'. Only when the prefix IS
18
+ // the row's `source` (every shipped row, by construction); an id that
19
+ // does not carry its own source keeps the whole id and harness = source,
20
+ // and is counted as `unprefixed` in the report.
21
+ // message_id 'legacy:<origin>:<epoch>:<seq>' — the full old key, so a converted
22
+ // call_id 'legacy:<origin>:<epoch>:<idx>' row can never collide with a
23
+ // shipped one, and an import never
24
+ // with a ship row
25
+ // part_id '0' · part_ord 0 · incarnation '' · removed 0 · state '' · parent_id '' ·
26
+ // response_id '' · effort '' · reasoning_tokens 0 · harness_version ''
27
+ // ord the legacy seq — dense for the main conversation, a fixed block per
28
+ // sub-agent; the display tie-break within equal ts
29
+ // rev 0 — the lowest value there is. Every row the capture layer writes
30
+ // (fgen·2^40 + upto ≥ 1) and every tombstone of a legacy key wins
31
+ // against it, and a re-run of this conversion can never resurrect a
32
+ // tombstoned legacy row.
33
+ // ts, role, model, tokens, text, folder, project, host, origin carried as they are, but:
34
+ // input_tokens for Gemini CLI, input minus cache reads (floor 0) — legacyInput().
35
+ // 0.18 stored Gemini's promptTokenCount, which already counts the
36
+ // cached tokens it also stored as cache_read; the house split keeps
37
+ // them apart, as the 0.20 normaliser does (input_includes_cache).
38
+ // 0.18 never stored Gemini's thought tokens: they cannot be added
39
+ // text_len lengthUTF8(text), characters
40
+ // agent_id extra.agent.id of the legacy message ('' = main conversation); a
41
+ // call takes it, and its model, from the message it belongs to
42
+ // kind best effort from the legacy text markers — legacyKind() below, the
43
+ // SQL in kindSQL() is the same rule
44
+ // harness_path the harness's data folder, from the legacy session's transcript path
45
+ // (pathFromPath): the part before /projects/ (Claude Code) or before
46
+ // /sessions/, /archived_sessions/ (Codex); for any other harness, or a
47
+ // path without that marker, the path's home plus the harness's default
48
+ // folder (editors/harness-paths.js) — what the 0.20 bridge keys it by.
49
+ // Not symlink-resolved: the server cannot, so a session under a
50
+ // symlinked folder keeps its legacy rows undeduped.
51
+ // extra the legacy extra, as a JSON string (agent id/type/description/turn)
52
+ // adapter_ver 0
53
+ // user_id, ingested_at PRESERVED — insert_allow_materialized_columns = 1, as migrate.js
54
+ //
55
+ // sessions rebuilt from the converted turns and calls (totals, usage per hour ×
56
+ // model, tools, agents, days) plus the legacy session's metadata;
57
+ // usage_basis 'per_record', rev 0
58
+ //
59
+ // The legacy rows are read with FINAL and restricted to the CURRENT parse (house.js
60
+ // currentParse: every non-ship row, and ship rows of the newest epoch per session — tool
61
+ // calls take their epoch from messages). The restored rooms are already compacted to that,
62
+ // so the filter is a no-op there; it keeps the conversion right on a room that is not.
63
+ //
64
+ // RESUMABLE. The tables are the truth: per bucket, the distinct v3 keys the legacy side
65
+ // yields are compared with the distinct `legacy:` keys already in turns/calls (counted
66
+ // physically, tombstoned ones included). A complete bucket is skipped; an incomplete one is
67
+ // re-inserted whole — ReplacingMergeTree collapses what was already there, and rev 0 cannot
68
+ // beat a tombstone. Sessions are rebuilt for every bucket whose turns or calls moved, and for
69
+ // any bucket whose sessions are incomplete.
70
+ //
71
+ // Transport-free (`q`, like migrate.js). Pure helpers are exported for the unit gate.
72
+
73
+ const { v3Names } = require('./schema-v3');
74
+ const { defaultFolder } = require('../../editors/harness-paths');
75
+
76
+ // ── pure: the rules, in JavaScript ───────────────────────────────────────────────────
77
+
78
+ const SUBAGENT_TAG = '[subagent] ';
79
+ const MARKERS = { thinking: '[thinking] ', tool_call: '[tool-call: ', tool_result: '[tool-result: ' };
80
+ const PLAYBOOKS_DIR = '/.claude-playbooks/';
81
+
82
+ /** Harnesses whose 0.18 input_tokens counted the cache reads stored beside them. */
83
+ const INPUT_INCLUDES_CACHE = ['gemini-cli'];
84
+ /** A legacy row's input tokens as the house stores them: without the cache reads it also
85
+ * stored, for a harness in INPUT_INCLUDES_CACHE (floor 0); otherwise as stored. */
86
+ function legacyInput(harness, input, cacheRead) {
87
+ const i = Number(input) || 0; const c = Number(cacheRead) || 0;
88
+ return INPUT_INCLUDES_CACHE.includes(harness) ? Math.max(i - c, 0) : i;
89
+ }
90
+
91
+ /** '<source>:<native>' -> { harness, session_id, prefixed }. */
92
+ function splitLegacySessionId(id, source) {
93
+ const sid = String(id ?? '');
94
+ const src = String(source ?? '');
95
+ if (src && sid.startsWith(`${src}:`)) return { harness: src, session_id: sid.slice(src.length + 1), prefixed: true };
96
+ if (!src && sid.includes(':')) {
97
+ const i = sid.indexOf(':');
98
+ return { harness: sid.slice(0, i), session_id: sid.slice(i + 1), prefixed: true };
99
+ }
100
+ return { harness: src, session_id: sid, prefixed: false };
101
+ }
102
+
103
+ function legacyMessageId(origin, epoch, seq) { return `legacy:${origin}:${epoch}:${seq}`; }
104
+ function legacyCallId(origin, epoch, idx) { return `legacy:${origin}:${epoch}:${idx}`; }
105
+
106
+ /** The logical install a transcript path belongs to. */
107
+ function rootFromPath(p) {
108
+ const s = String(p ?? '');
109
+ const i = s.indexOf(PLAYBOOKS_DIR);
110
+ if (i >= 0) return s.slice(i + PLAYBOOKS_DIR.length).split('/')[0];
111
+ return s ? 'default' : '';
112
+ }
113
+
114
+ // The home a transcript path lies in, and a harness's default data folder relative to it.
115
+ const HOME_RE = '^(/Users/[^/]+|/home/[^/]+|/root)(/|$)';
116
+ const PATH_HARNESSES = ['claude-code', 'codex', 'gemini-cli', 'opencode', 'goose', 'zed', 'cursor', 'cursor-agent', 'vscode',
117
+ 'copilot-cli', 'copilot-jetbrains', 'kiro', 'devin', 'antigravity', 'agy', 'commandcode', 'droid'];
118
+ const OPENCODE_CHANNEL_DB = /\/opencode-[A-Za-z0-9._-]+\.db$/;
119
+ function relFolder(harness, mac) {
120
+ // Never the converting process's own environment (XDG_*, APPDATA): the rule is about the machine
121
+ // that wrote the 0.18 row, and must give the same answer on any server.
122
+ return defaultFolder(harness, { home: '/', platform: mac ? 'darwin' : 'linux', env: {} }) || '';
123
+ }
124
+
125
+ /** The harness_path (data folder) a legacy transcript path belongs to: what capture keys its session by. */
126
+ function pathFromPath(harness, p) {
127
+ const s = String(p ?? '');
128
+ if (!s) return '';
129
+ let m;
130
+ if (harness === 'claude-code' && (m = /^(.*)\/projects\//.exec(s))) return m[1];
131
+ if (harness === 'codex' && (m = /^(.*)\/(archived_)?sessions\//.exec(s))) return m[1];
132
+ // OpenCode is keyed by its DB file (sessions-model §1): a path naming a channel DB is that store;
133
+ // anything else is the default DB, the only one the 0.18 reader read.
134
+ if (harness === 'opencode' && OPENCODE_CHANNEL_DB.test(s)) return s;
135
+ const home = (new RegExp(HOME_RE).exec(s) || [])[1] || '';
136
+ const rel = relFolder(harness, s.startsWith('/Users/'));
137
+ if (harness === 'opencode') return home && rel ? `${home}${rel}/opencode.db` : '';
138
+ return home && rel ? home + rel : '';
139
+ }
140
+
141
+ function isMarkerLine(l) {
142
+ return l.startsWith(MARKERS.thinking) || l.startsWith(MARKERS.tool_call) || l.startsWith(MARKERS.tool_result);
143
+ }
144
+
145
+ /**
146
+ * The v3 `kind` of a legacy row — best effort, because a legacy row is a JOIN of parts:
147
+ * Claude Code wrote one row per record (almost always one content block), Codex one row per
148
+ * turn (reasoning summaries, tool calls, tool results and the answer, '\n'-joined).
149
+ *
150
+ * 1. not an assistant row text
151
+ * 2. exactly '[assistant activity]' (Codex: tokens, no text) usage
152
+ * 3. starts '[thinking] ' and the harness is not codex thinking (a Claude thinking block
153
+ * is its own record; its
154
+ * body may span lines)
155
+ * 4. every line starts with a marker the first line's marker kind
156
+ * 5. starts '[tool-result: ' tool_result (a multi-line result)
157
+ * 6. otherwise text (a Codex turn with an
158
+ * answer in it)
159
+ *
160
+ * A leading '[subagent] ' tag is ignored. kindSQL() is the same rule in ClickHouse SQL; the
161
+ * conversion matrix holds the two against each other on every fixture row.
162
+ */
163
+ function legacyKind(role, text, harness) {
164
+ if (role !== 'assistant') return 'text';
165
+ let t = String(text ?? '');
166
+ if (t.startsWith(SUBAGENT_TAG)) t = t.slice(SUBAGENT_TAG.length);
167
+ if (t === '[assistant activity]') return 'usage';
168
+ if (harness !== 'codex' && t.startsWith(MARKERS.thinking)) return 'thinking';
169
+ if (t.split('\n').every(isMarkerLine)) {
170
+ if (t.startsWith(MARKERS.thinking)) return 'thinking';
171
+ if (t.startsWith(MARKERS.tool_call)) return 'tool_call';
172
+ return 'tool_result';
173
+ }
174
+ if (t.startsWith(MARKERS.tool_result)) return 'tool_result';
175
+ return 'text';
176
+ }
177
+
178
+ /**
179
+ * How many hash buckets a conversion is split into: enough that no bucket holds more than
180
+ * `batchRows` rows or `batchBytes` bytes of message text. Bytes matter more than rows — the
181
+ * insert's memory is the text indexes being built over the bucket's text (measured on lab
182
+ * 26.7.4.58: ~1.4 GiB for one 175k-row bucket of ~230 MB text with default insert blocks,
183
+ * ~0.6 GiB with INSERT_BLOCKS below).
184
+ */
185
+ const BATCH_ROWS = 250000;
186
+ const BATCH_BYTES = 64 * 1024 * 1024;
187
+ function bucketCount(rows, batchRows = BATCH_ROWS, bytes = 0, batchBytes = BATCH_BYTES) {
188
+ const r = Math.max(1, Math.floor(Number(batchRows) || BATCH_ROWS));
189
+ const y = Math.max(1, Math.floor(Number(batchBytes) || BATCH_BYTES));
190
+ return Math.max(1, Math.ceil((Number(rows) || 0) / r), Math.ceil((Number(bytes) || 0) / y));
191
+ }
192
+
193
+ // ── SQL: the same rules, server-side ──────────────────────────────────────────────────
194
+
195
+ /** A ClickHouse string literal. */
196
+ function lit(v) { return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; }
197
+
198
+ const PREFIXED = (a) => `(${a}.source != '' AND startsWith(${a}.session_id, concat(${a}.source, ':')))`;
199
+ const BARE_PREFIX = (a) => `(${a}.source = '' AND position(${a}.session_id, ':') > 0)`;
200
+
201
+ function harnessSQL(a) {
202
+ return `multiIf(${PREFIXED(a)}, toString(${a}.source), ${BARE_PREFIX(a)}, substringIndex(${a}.session_id, ':', 1), toString(${a}.source))`;
203
+ }
204
+ function sessionSQL(a) {
205
+ return `multiIf(${PREFIXED(a)}, substring(${a}.session_id, length(${a}.source) + 2), `
206
+ + `${BARE_PREFIX(a)}, substring(${a}.session_id, position(${a}.session_id, ':') + 1), ${a}.session_id)`;
207
+ }
208
+ function prefixedSQL(a) { return `(${PREFIXED(a)} OR ${BARE_PREFIX(a)})`; }
209
+
210
+ function rootSQL(p) {
211
+ return `if(position(${p}, '${PLAYBOOKS_DIR}') > 0, `
212
+ + `splitByChar('/', substring(${p}, position(${p}, '${PLAYBOOKS_DIR}') + ${PLAYBOOKS_DIR.length}))[1], `
213
+ + `if(${p} != '', 'default', ''))`;
214
+ }
215
+
216
+ /** pathFromPath in SQL: `h` the v3 harness expression, `p` the legacy path. */
217
+ function pathSQL(h, p) {
218
+ const H = `[${PATH_HARNESSES.map(lit).join(', ')}]`;
219
+ const rels = (mac) => `[${PATH_HARNESSES.map((x) => lit(relFolder(x, mac))).join(', ')}]`;
220
+ const home = `extract(${p}, ${lit(HOME_RE)})`;
221
+ const rel = `if(startsWith(${p}, '/Users/'), transform(${h}, ${H}, ${rels(true)}, ''), transform(${h}, ${H}, ${rels(false)}, ''))`;
222
+ return `multiIf(${p} = '', '',
223
+ ${h} = 'opencode' AND match(${p}, '/opencode-[A-Za-z0-9._-]+\\.db$'), ${p},
224
+ ${h} = 'opencode' AND ${home} != '' AND ${rel} != '', concat(${home}, ${rel}, '/opencode.db'),
225
+ ${h} = 'claude-code' AND match(${p}, '/projects/'), extract(${p}, '^(.*)/projects/'),
226
+ ${h} = 'codex' AND match(${p}, '/(archived_)?sessions/'), extract(${p}, '^(.*)/(archived_)?sessions/'),
227
+ ${home} != '' AND ${rel} != '', concat(${home}, ${rel}), '')`;
228
+ }
229
+
230
+ function kindSQL(role, text, harness) {
231
+ const t = `if(startsWith(${text}, '${SUBAGENT_TAG}'), substring(${text}, ${SUBAGENT_TAG.length + 1}), ${text})`;
232
+ const marker = (x) => `(startsWith(${x}, '${MARKERS.thinking}') OR startsWith(${x}, '${MARKERS.tool_call}') OR startsWith(${x}, '${MARKERS.tool_result}'))`;
233
+ return `multiIf(${role} != 'assistant', 'text',
234
+ ${t} = '[assistant activity]', 'usage',
235
+ ${harness} != 'codex' AND startsWith(${t}, '${MARKERS.thinking}'), 'thinking',
236
+ arrayAll(l -> ${marker('l')}, splitByChar('\\n', ${t})),
237
+ multiIf(startsWith(${t}, '${MARKERS.thinking}'), 'thinking', startsWith(${t}, '${MARKERS.tool_call}'), 'tool_call', 'tool_result'),
238
+ startsWith(${t}, '${MARKERS.tool_result}'), 'tool_result',
239
+ 'text')`;
240
+ }
241
+
242
+ function bucketSQL(h, s, n, b) {
243
+ return n > 1 ? `cityHash64(toString(${h}), toString(${s})) % ${n} = ${b}` : '1';
244
+ }
245
+
246
+ /**
247
+ * House.js currentParse as a predicate on alias `a`; tool calls take the epoch from messages.
248
+ *
249
+ * With `member`, a session that has rows under the member's own user_id takes ONLY those: rows
250
+ * of the same session under another user_id are older copies (a pre-member `default` shipper),
251
+ * and v3's key has no user_id, so keeping both would merge two parses row by row. Measured on
252
+ * a development copy (2026-10-14): hundreds of sessions existed under both the member and
253
+ * `default`; the `default` copy was never longer (about nine in ten equal, the rest shorter) and
254
+ * its newest message predated the member.
255
+ */
256
+ function currentParseSQL(a, messagesTable, member = null) {
257
+ const own = member == null ? '' : ` AND (${a}.user_id = ${lit(member)} OR ${a}.session_id NOT IN (
258
+ SELECT DISTINCT session_id FROM ${messagesTable} WHERE user_id = ${lit(member)}))`;
259
+ return `((${a}.origin != 'ship' OR (${a}.session_id, ${a}.user_id, ${a}.epoch) IN (
260
+ SELECT session_id, user_id, max(epoch) FROM ${messagesTable} WHERE origin = 'ship' GROUP BY session_id, user_id))${own})`;
261
+ }
262
+
263
+ /**
264
+ * Which legacy row wins when two land on ONE v3 key (same split session, origin, epoch, seq —
265
+ * or idx — and ts): two user_ids on one row, or a prefixed and an unprefixed id of one
266
+ * session. The winner is the member's own row, then the newest ingested_at, then the greatest
267
+ * (user_id, legacy session id) — deterministic, so a re-run and a resumed run write the same
268
+ * row. The losers are counted as `collisions` in the report.
269
+ *
270
+ * Computed as an exclusion set from key columns only — a GROUP BY over ids and times, never a
271
+ * sort of whole rows with their text (ORDER BY … LIMIT 1 BY would hold a bucket's text in
272
+ * memory to find, in practice, nothing).
273
+ */
274
+ function losersSQL(N, table, idOf, n, b) {
275
+ const a = 'x';
276
+ return `SELECT kh, ks, kid, kt, c.1 AS ku, c.2 AS kl FROM (
277
+ SELECT kh, ks, kid, kt, groupArray((ku, kl)) AS cand,
278
+ argMax((ku, kl), (ku = ${lit(N.member)}, king, ku, kl)) AS win
279
+ FROM (
280
+ SELECT ${harnessSQL(a)} AS kh, ${sessionSQL(a)} AS ks, ${idOf(a)} AS kid, ${a}.ts AS kt,
281
+ ${a}.user_id AS ku, ${a}.session_id AS kl, ${a}.ingested_at AS king
282
+ FROM ${table} AS ${a} FINAL
283
+ WHERE ${currentParseSQL(a, N.legacy.messages, N.member)} AND ${bucketSQL('kh', 'ks', n, b)}
284
+ )
285
+ GROUP BY kh, ks, kid, kt HAVING count() > 1
286
+ ) ARRAY JOIN cand AS c WHERE c != win`;
287
+ }
288
+ /** legacyInput() in SQL: `h` is the row's harness expression. UInt64 like the column: the difference is
289
+ * taken signed and floored at 0, since a UInt64 minus a UInt64 is Int64 in ClickHouse. */
290
+ const legacyInputSQL = (a, h) => `if(${h} IN (${INPUT_INCLUDES_CACHE.map(lit).join(', ')}), `
291
+ + `toUInt64(greatest(toInt64(${a}.input_tokens) - toInt64(${a}.cache_read_tokens), 0)), ${a}.input_tokens)`;
292
+ const MESSAGE_ID = (a) => `concat('legacy:', toString(${a}.origin), ':', toString(${a}.epoch), ':', toString(${a}.seq))`;
293
+ const CALL_ID = (a) => `concat('legacy:', toString(${a}.origin), ':', toString(${a}.epoch), ':', toString(${a}.idx))`;
294
+
295
+ /** The converted legacy messages of one bucket, with v3 names prefixed `v_`. */
296
+ function legacyMessagesSQL(N, n, b, { onePerKey = true } = {}) {
297
+ const L = N.legacy;
298
+ return `SELECT
299
+ ${harnessSQL('m')} AS v_harness,
300
+ ${sessionSQL('m')} AS v_session,
301
+ ${prefixedSQL('m')} AS v_prefixed,
302
+ m.session_id AS l_session,
303
+ toJSONString(m.extra) AS v_extra,
304
+ JSONExtractString(v_extra, 'agent', 'id') AS v_agent,
305
+ ${MESSAGE_ID('m')} AS v_message_id,
306
+ m.ts AS v_ts,
307
+ toUInt64(m.seq) AS v_ord,
308
+ toString(m.role) AS v_role,
309
+ ${kindSQL('m.role', 'm.text', 'v_harness')} AS v_kind,
310
+ toString(m.model) AS v_model,
311
+ ${legacyInputSQL('m', 'v_harness')} AS v_in, m.input_tokens AS l_in, m.output_tokens AS v_out,
312
+ m.cache_read_tokens AS v_cr, m.cache_write_tokens AS v_cw,
313
+ m.text AS v_text,
314
+ toUInt32(lengthUTF8(m.text)) AS v_text_len,
315
+ m.folder AS v_folder, m.project AS v_project, toString(m.host) AS v_host,
316
+ toString(m.origin) AS v_origin,
317
+ m.user_id AS v_user, m.ingested_at AS v_ingested
318
+ FROM ${L.messages} AS m FINAL
319
+ WHERE ${currentParseSQL('m', L.messages, N.member)} AND ${bucketSQL('v_harness', 'v_session', n, b)}${onePerKey ? `
320
+ AND (v_harness, v_session, v_message_id, v_ts, v_user, l_session) NOT IN (${losersSQL(N, L.messages, MESSAGE_ID, n, b)})` : ''}`;
321
+ }
322
+
323
+ function legacyCallsSQL(N, n, b, { onePerKey = true } = {}) {
324
+ const L = N.legacy;
325
+ return `SELECT
326
+ ${harnessSQL('t')} AS v_harness,
327
+ ${sessionSQL('t')} AS v_session,
328
+ t.session_id AS l_session, t.user_id AS v_user, toString(t.origin) AS v_origin,
329
+ t.epoch AS l_epoch, t.seq AS l_seq,
330
+ concat('legacy:', v_origin, ':', toString(t.epoch), ':', toString(t.idx)) AS v_call_id,
331
+ concat('legacy:', v_origin, ':', toString(t.epoch), ':', toString(t.seq)) AS v_message_id,
332
+ t.ts AS v_ts, toString(t.tool_name) AS v_tool, t.args AS v_args,
333
+ t.folder AS v_folder, t.project AS v_project, toString(t.host) AS v_host,
334
+ t.ingested_at AS v_ingested
335
+ FROM ${L.tool_calls} AS t FINAL
336
+ WHERE ${currentParseSQL('t', L.messages, N.member)} AND ${bucketSQL('v_harness', 'v_session', n, b)}${onePerKey ? `
337
+ AND (v_harness, v_session, v_call_id, v_ts, v_user, l_session) NOT IN (${losersSQL(N, L.tool_calls, CALL_ID, n, b)})` : ''}`;
338
+ }
339
+
340
+ function legacySessionsSQL(N, n, b) {
341
+ return `SELECT
342
+ ${harnessSQL('z')} AS v_harness,
343
+ ${sessionSQL('z')} AS v_session,
344
+ z.session_id AS l_session, toString(z.host) AS l_host, z.name AS l_name, toString(z.mode) AS l_mode,
345
+ z.folder AS l_folder, z.project AS l_project, z.git_branch AS l_branch,
346
+ z.created_at AS l_created, z.last_updated_at AS l_updated, toString(z.origin) AS l_origin,
347
+ z.user_id AS l_user, z.ingested_at AS l_ing, ${pathSQL(harnessSQL('z'), 'z.path')} AS l_path
348
+ FROM ${N.legacy.sessions} AS z FINAL
349
+ WHERE ${bucketSQL('v_harness', 'v_session', n, b)}`;
350
+ }
351
+
352
+ const TURN_COLUMNS = [
353
+ 'harness', 'harness_version', 'harness_path', 'session_id', 'incarnation', 'agent_id', 'message_id', 'part_id', 'part_ord',
354
+ 'parent_id', 'response_id', 'ts', 'ord', 'rev', 'is_deleted', 'removed', 'state', 'role', 'kind', 'model', 'effort',
355
+ 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'reasoning_tokens',
356
+ 'text', 'text_len', 'folder', 'project', 'host', 'origin', 'extra', 'adapter_ver', 'user_id', 'ingested_at',
357
+ ];
358
+
359
+ function turnsInsertSQL(N, n, b) {
360
+ return `INSERT INTO ${N.turns} (${TURN_COLUMNS.join(', ')})
361
+ SELECT c.v_harness, '', ifNull(r.l_path, ''), c.v_session, '', c.v_agent, c.v_message_id, '0', 0, '', '',
362
+ c.v_ts, c.v_ord, 0, 0, 0, '', c.v_role, c.v_kind, c.v_model, '',
363
+ c.v_in, c.v_out, c.v_cr, c.v_cw, 0,
364
+ c.v_text, c.v_text_len, c.v_folder, c.v_project, c.v_host, c.v_origin, c.v_extra, 0, c.v_user, c.v_ingested
365
+ FROM (${legacyMessagesSQL(N, n, b)}) AS c
366
+ LEFT JOIN (${legacyPathsSQL(N)}) AS r ON r.l_session = c.l_session`;
367
+ }
368
+
369
+ /** Each legacy session's harness_path, from its newest legacy sessions row. */
370
+ function legacyPathsSQL(N) {
371
+ return `SELECT z.session_id AS l_session, argMax(${pathSQL(harnessSQL('z'), 'z.path')}, z.ingested_at) AS l_path
372
+ FROM ${N.legacy.sessions} AS z FINAL GROUP BY z.session_id`;
373
+ }
374
+
375
+ const CALL_COLUMNS = [
376
+ 'harness', 'harness_path', 'session_id', 'incarnation', 'agent_id', 'call_id', 'message_id', 'ts', 'rev', 'is_deleted', 'removed', 'tool_name',
377
+ 'args', 'result_ts', 'is_error', 'result_len', 'model', 'folder', 'project', 'host', 'adapter_ver',
378
+ 'user_id', 'ingested_at',
379
+ ];
380
+
381
+ function callsInsertSQL(N, n, b) {
382
+ const L = N.legacy;
383
+ return `INSERT INTO ${N.calls} (${CALL_COLUMNS.join(', ')})
384
+ SELECT c.v_harness, ifNull(r.l_path, ''), c.v_session, '', ifNull(j.j_agent, ''), c.v_call_id, c.v_message_id, c.v_ts, 0, 0, 0,
385
+ c.v_tool, c.v_args, NULL, false, 0, ifNull(j.j_model, ''), c.v_folder, c.v_project, c.v_host, 0,
386
+ c.v_user, c.v_ingested
387
+ FROM (${legacyCallsSQL(N, n, b)}) AS c
388
+ LEFT JOIN (
389
+ SELECT m.session_id AS j_session, m.user_id AS j_user, toString(m.origin) AS j_origin,
390
+ m.epoch AS j_epoch, m.seq AS j_seq,
391
+ JSONExtractString(toJSONString(m.extra), 'agent', 'id') AS j_agent, toString(m.model) AS j_model
392
+ FROM ${L.messages} AS m FINAL
393
+ WHERE ${bucketSQL(harnessSQL('m'), sessionSQL('m'), n, b)}
394
+ ) AS j ON j.j_session = c.l_session AND j.j_user = c.v_user AND j.j_origin = c.v_origin
395
+ AND j.j_epoch = c.l_epoch AND j.j_seq = c.l_seq
396
+ LEFT JOIN (${legacyPathsSQL(N)}) AS r ON r.l_session = c.l_session`;
397
+ }
398
+
399
+ const SESSION_COLUMNS = [
400
+ 'harness', 'session_id', 'incarnation', 'harness_version', 'harness_path', 'host', 'name', 'mode', 'folder', 'project',
401
+ 'git_branch', 'created_at', 'updated_at', 'started', 'ended', 'messages', 'user_messages',
402
+ 'assistant_messages', 'agent_messages', 'tool_calls', 'input_tokens', 'output_tokens',
403
+ 'cache_read_tokens', 'cache_write_tokens', 'reasoning_tokens', 'user_chars', 'assistant_chars',
404
+ 'first_prompt', 'models', 'dominant_model', 'days',
405
+ '`usage.hour`', '`usage.model`', '`usage.messages`', '`usage.input`', '`usage.output`',
406
+ '`usage.cache_read`', '`usage.cache_write`', '`usage.reasoning`',
407
+ '`tools.name`', '`tools.calls`', '`tools.errors`',
408
+ '`agents.agent_id`', '`agents.type`', '`agents.description`', '`agents.workflow`', '`agents.messages`', '`agents.first_ts`',
409
+ '`links.kind`', '`links.origin`', '`links.origin_store`', '`links.evidence`', '`links.at`', '`links.through`',
410
+ 'usage_basis', 'origin', 'rev', 'is_deleted', 'removed', 'user_id', 'ingested_at',
411
+ ];
412
+
413
+ /** The converted turns of one bucket — read with FINAL, tombstones dropped. */
414
+ function convertedTurnsSQL(N, n, b) {
415
+ // Short aliases, none of them a column name or a table alias of sessionsInsertSQL — an alias
416
+ // that shadows a column inside its own aggregate is a cyclic-alias error in ClickHouse.
417
+ return `SELECT toString(harness) AS h, session_id AS s, agent_id AS aid, ts AS t, ord AS o,
418
+ toString(role) AS rl, toString(kind) AS kd, toString(model) AS md,
419
+ input_tokens AS ti, output_tokens AS tout, cache_read_tokens AS tcr, cache_write_tokens AS tcw,
420
+ reasoning_tokens AS trs, text AS tx, text_len AS tlen, folder AS fo, project AS pr,
421
+ toString(host) AS ho, toString(harness_path) AS ro, toString(origin) AS og, extra AS ex,
422
+ user_id AS uid, ingested_at AS ing
423
+ FROM ${N.turns} FINAL
424
+ WHERE is_deleted = 0 AND removed = 0 AND startsWith(message_id, 'legacy:') AND ${bucketSQL('harness', 'session_id', n, b)}`;
425
+ }
426
+
427
+ function sessionsInsertSQL(N, n, b) {
428
+ const T = convertedTurnsSQL(N, n, b);
429
+ return `INSERT INTO ${N.sessions} (${SESSION_COLUMNS.join(', ')})
430
+ SELECT
431
+ k.kh, k.ks, '', '',
432
+ if(ls.ls_path != '', ls.ls_path, a.a_root),
433
+ if(ls.ls_host != '', ls.ls_host, a.a_host),
434
+ ls.ls_name, ls.ls_mode,
435
+ if(ls.ls_folder != '', ls.ls_folder, a.a_folder),
436
+ if(ls.ls_project != '', ls.ls_project, a.a_project),
437
+ ls.ls_branch, ls.ls_created, ls.ls_updated,
438
+ if(a.a_n > 0, a.a_started, coalesce(ls.ls_created, ls.ls_updated, ls.ls_ing)),
439
+ if(a.a_n > 0, a.a_ended, coalesce(ls.ls_updated, ls.ls_created, ls.ls_ing)),
440
+ a.a_messages, a.a_user_messages, a.a_assistant_messages, a.a_agent_messages,
441
+ toUInt32(tl.tl_calls),
442
+ a.a_in, a.a_out, a.a_cr, a.a_cw, a.a_rs, a.a_user_chars, a.a_assistant_chars,
443
+ a.a_first_prompt, a.a_models, dm.dm_model,
444
+ arraySort(arrayDistinct(arrayConcat(a.a_days, tl.tl_days))),
445
+ arrayMap(x -> x.1, u.u_arr), arrayMap(x -> x.2, u.u_arr), arrayMap(x -> x.3, u.u_arr),
446
+ arrayMap(x -> x.4, u.u_arr), arrayMap(x -> x.5, u.u_arr), arrayMap(x -> x.6, u.u_arr),
447
+ arrayMap(x -> x.7, u.u_arr), arrayMap(x -> x.8, u.u_arr),
448
+ arrayMap(x -> x.1, tl.tl_arr), arrayMap(x -> x.2, tl.tl_arr), arrayMap(x -> x.3, tl.tl_arr),
449
+ arrayMap(x -> x.1, ag.ag_arr), arrayMap(x -> x.2, ag.ag_arr), arrayMap(x -> x.3, ag.ag_arr),
450
+ arrayMap(x -> x.4, ag.ag_arr), arrayMap(x -> x.5, ag.ag_arr), arrayMap(x -> x.6, ag.ag_arr),
451
+ [], [], [], [], [], [],
452
+ 'per_record',
453
+ if(ls.ls_present = 1, ls.ls_origin, if(a.a_ship > 0, 'ship', a.a_origin)),
454
+ 0, 0, 0,
455
+ if(ls.ls_present = 1, ls.ls_user, a.a_user),
456
+ if(ls.ls_present = 1, ls.ls_ing, a.a_ing)
457
+ FROM (
458
+ SELECT DISTINCT kh, ks FROM (
459
+ SELECT h AS kh, s AS ks FROM (${T})
460
+ UNION ALL
461
+ SELECT v_harness AS kh, v_session AS ks FROM (${legacySessionsSQL(N, n, b)})
462
+ )
463
+ ) AS k
464
+ LEFT JOIN (
465
+ SELECT h, s, count() AS a_n, min(t) AS a_started, max(t) AS a_ended,
466
+ toUInt32(countIf(kd = 'text')) AS a_messages,
467
+ toUInt32(countIf(kd = 'text' AND rl = 'user')) AS a_user_messages,
468
+ toUInt32(countIf(kd = 'text' AND rl = 'assistant')) AS a_assistant_messages,
469
+ toUInt32(countIf(kd = 'text' AND aid != '')) AS a_agent_messages,
470
+ sum(ti) AS a_in, sum(tout) AS a_out, sum(tcr) AS a_cr, sum(tcw) AS a_cw, sum(trs) AS a_rs,
471
+ sumIf(toUInt64(tlen), rl = 'user') AS a_user_chars, sumIf(toUInt64(tlen), rl = 'assistant') AS a_assistant_chars,
472
+ substringUTF8(argMinIf(tx, (t, o), rl = 'user' AND aid = '' AND kd = 'text' AND tx != ''), 1, 200) AS a_first_prompt,
473
+ arraySort(groupUniqArrayIf(md, md NOT IN ('', '<synthetic>'))) AS a_models,
474
+ arraySort(groupUniqArray(toDate(t))) AS a_days,
475
+ argMin(ro, (t, o)) AS a_root, argMin(ho, (t, o)) AS a_host,
476
+ argMin(fo, (t, o)) AS a_folder, argMin(pr, (t, o)) AS a_project,
477
+ countIf(og = 'ship') AS a_ship, argMin(og, (t, o)) AS a_origin,
478
+ argMin(uid, (t, o)) AS a_user, max(ing) AS a_ing
479
+ FROM (${T}) GROUP BY h, s
480
+ ) AS a ON a.h = k.kh AND a.s = k.ks
481
+ LEFT JOIN (
482
+ SELECT h, s, arraySort(x -> (x.1, x.2), groupArray((hr, md, um, ui, uo, ucr, ucw, urs))) AS u_arr
483
+ FROM (
484
+ SELECT h, s, toStartOfHour(toDateTime(t, 'UTC')) AS hr, md, toUInt32(countIf(kd = 'text')) AS um,
485
+ sum(ti) AS ui, sum(tout) AS uo, sum(tcr) AS ucr, sum(tcw) AS ucw, sum(trs) AS urs
486
+ FROM (${T}) GROUP BY h, s, hr, md
487
+ ) GROUP BY h, s
488
+ ) AS u ON u.h = k.kh AND u.s = k.ks
489
+ LEFT JOIN (
490
+ SELECT h, s, arraySort(x -> x.1, groupArray((tn, tc, te))) AS tl_arr, sum(tc) AS tl_calls,
491
+ arraySort(arrayDistinct(groupArrayArray(td))) AS tl_days
492
+ FROM (
493
+ SELECT toString(harness) AS h, session_id AS s, toString(tool_name) AS tn,
494
+ toUInt32(count()) AS tc, toUInt32(countIf(is_error)) AS te, groupUniqArray(toDate(ts)) AS td
495
+ FROM ${N.calls} FINAL
496
+ WHERE is_deleted = 0 AND startsWith(call_id, 'legacy:') AND ${bucketSQL('harness', 'session_id', n, b)}
497
+ GROUP BY h, s, tn
498
+ ) GROUP BY h, s
499
+ ) AS tl ON tl.h = k.kh AND tl.s = k.ks
500
+ LEFT JOIN (
501
+ SELECT h, s, arraySort(x -> (x.6, x.1), groupArray((aid, g_type, g_desc, g_wf, g_msgs, g_first))) AS ag_arr
502
+ FROM (
503
+ SELECT h, s, aid,
504
+ argMin(JSONExtractString(ex, 'agent', 'type'), (t, o)) AS g_type,
505
+ argMin(JSONExtractString(ex, 'agent', 'description'), (t, o)) AS g_desc,
506
+ argMin(JSONExtractString(ex, 'agent', 'workflow'), (t, o)) AS g_wf,
507
+ toUInt32(countIf(kd = 'text')) AS g_msgs, min(t) AS g_first
508
+ FROM (${T}) WHERE aid != '' GROUP BY h, s, aid
509
+ ) GROUP BY h, s
510
+ ) AS ag ON ag.h = k.kh AND ag.s = k.ks
511
+ LEFT JOIN (
512
+ SELECT h, s, argMax(md, (c, md)) AS dm_model
513
+ FROM (SELECT h, s, md, count() AS c FROM (${T}) WHERE md NOT IN ('', '<synthetic>') GROUP BY h, s, md)
514
+ GROUP BY h, s
515
+ ) AS dm ON dm.h = k.kh AND dm.s = k.ks
516
+ LEFT JOIN (
517
+ SELECT v_harness, v_session, 1 AS ls_present,
518
+ argMax(l_host, l_ing) AS ls_host, argMax(l_name, l_ing) AS ls_name, argMax(l_mode, l_ing) AS ls_mode,
519
+ argMax(l_folder, l_ing) AS ls_folder, argMax(l_project, l_ing) AS ls_project,
520
+ argMax(l_branch, l_ing) AS ls_branch,
521
+ argMax(l_created, l_ing) AS ls_created, argMax(l_updated, l_ing) AS ls_updated,
522
+ argMax(l_origin, l_ing) AS ls_origin, argMax(l_user, l_ing) AS ls_user, max(l_ing) AS ls_ing,
523
+ argMax(l_path, l_ing) AS ls_path
524
+ FROM (${legacySessionsSQL(N, n, b)})
525
+ GROUP BY v_harness, v_session
526
+ ) AS ls ON ls.v_harness = k.kh AND ls.v_session = k.ks`;
527
+ }
528
+
529
+ // ── counting: what the legacy side yields, what is already converted ─────────────────
530
+
531
+ function legacyTurnCountsSQL(N, n) {
532
+ return `SELECT ${n > 1 ? `cityHash64(toString(v_harness), toString(v_session)) % ${n}` : '0'} AS b,
533
+ count() AS rows, uniqExact(v_harness, v_session, v_message_id, v_ts) AS keys,
534
+ uniqExactIf(l_session, NOT v_prefixed) AS unprefixed
535
+ FROM (${legacyMessagesSQL(N, 1, 0, { onePerKey: false })}) GROUP BY b`;
536
+ }
537
+ /** The Gemini CLI rows whose input loses its cache reads, and the tokens it loses: over the rows the
538
+ * insert writes (current parse, one per v3 key), so a dry run counts what the conversion takes out. */
539
+ function legacyInputAdjustSQL(N) {
540
+ return `SELECT countIf(l_in != v_in) AS rows, sum(l_in - v_in) AS tokens
541
+ FROM (${legacyMessagesSQL(N, 1, 0)}) WHERE v_harness IN (${INPUT_INCLUDES_CACHE.map(lit).join(', ')})`;
542
+ }
543
+ function convertedTurnCountsSQL(N, n) {
544
+ return `SELECT ${n > 1 ? `cityHash64(toString(harness), toString(session_id)) % ${n}` : '0'} AS b,
545
+ uniqExact(toString(harness), session_id, message_id, ts) AS keys
546
+ FROM ${N.turns} WHERE startsWith(message_id, 'legacy:') GROUP BY b`;
547
+ }
548
+ function legacyCallCountsSQL(N, n) {
549
+ return `SELECT ${n > 1 ? `cityHash64(toString(v_harness), toString(v_session)) % ${n}` : '0'} AS b,
550
+ count() AS rows, uniqExact(v_harness, v_session, v_call_id, v_ts) AS keys
551
+ FROM (${legacyCallsSQL(N, 1, 0, { onePerKey: false })}) GROUP BY b`;
552
+ }
553
+ function convertedCallCountsSQL(N, n) {
554
+ return `SELECT ${n > 1 ? `cityHash64(toString(harness), toString(session_id)) % ${n}` : '0'} AS b,
555
+ uniqExact(toString(harness), session_id, call_id, ts) AS keys
556
+ FROM ${N.calls} WHERE startsWith(call_id, 'legacy:') GROUP BY b`;
557
+ }
558
+ function legacySessionCountsSQL(N, n) {
559
+ return `SELECT ${n > 1 ? `cityHash64(toString(kh), toString(ks)) % ${n}` : '0'} AS b, uniqExact(kh, ks) AS keys
560
+ FROM (
561
+ SELECT v_harness AS kh, v_session AS ks FROM (${legacyMessagesSQL(N, 1, 0, { onePerKey: false })})
562
+ UNION ALL
563
+ SELECT v_harness AS kh, v_session AS ks FROM (${legacySessionsSQL(N, 1, 0)})
564
+ ) GROUP BY b`;
565
+ }
566
+ function convertedSessionCountsSQL(N, n) {
567
+ // Rows this conversion wrote: rev 0 and the legacy usage basis. The capture layer's rows
568
+ // (rev ≥ 1, per_response) are not counted, whether or not they have replaced these.
569
+ return `SELECT ${n > 1 ? `cityHash64(toString(harness), toString(session_id)) % ${n}` : '0'} AS b,
570
+ uniqExact(toString(harness), session_id) AS keys
571
+ FROM ${N.sessions} WHERE rev = 0 AND usage_basis = 'per_record' GROUP BY b`;
572
+ }
573
+
574
+ /**
575
+ * Every query's settings, explicitly — never the CLI's session-wide final=1 / join_use_nulls=1:
576
+ * FINAL is written where it is meant, join misses read as defaults, user_id is carried rather
577
+ * than restamped, and toJSONString keeps integers as numbers.
578
+ */
579
+ const CONVERT_SETTINGS = {
580
+ final: 0, join_use_nulls: 0, async_insert: 0, insert_allow_materialized_columns: 1,
581
+ output_format_json_quote_64bit_integers: 0,
582
+ };
583
+ /** Smaller insert blocks: parts of ≤16k rows / 16 MiB, so no text index is built over a whole bucket at once. */
584
+ const INSERT_BLOCKS = {
585
+ max_insert_block_size: 16384, min_insert_block_size_rows: 16384, min_insert_block_size_bytes: 16 * 1024 * 1024,
586
+ };
587
+ const INSERT_SETTINGS = { ...CONVERT_SETTINGS, ...INSERT_BLOCKS };
588
+
589
+ function byBucket(rows, field) {
590
+ const m = new Map();
591
+ for (const r of rows) m.set(Number(r.b), Number(r[field]) || 0);
592
+ return m;
593
+ }
594
+ const sumMap = (m) => [...m.values()].reduce((a, x) => a + x, 0);
595
+
596
+ async function rowsOrEmpty(q, sql) {
597
+ try { return await q.rows(sql, CONVERT_SETTINGS); }
598
+ catch (e) {
599
+ if (/UNKNOWN_TABLE|Unknown table|doesn't exist|does not exist/i.test(String(e && e.message))) return [];
600
+ throw e;
601
+ }
602
+ }
603
+
604
+ /**
605
+ * What sizes the buckets: the legacy rows and the bytes of message text — under FINAL, so a
606
+ * background merge between two runs cannot change the bucket count (and with it every
607
+ * bucket's membership).
608
+ */
609
+ async function legacySize(q, N) {
610
+ const r = await q.rows(`SELECT (SELECT count() FROM ${N.legacy.messages} FINAL) AS m,
611
+ (SELECT sum(length(text)) FROM ${N.legacy.messages} FINAL) AS bytes,
612
+ (SELECT count() FROM ${N.legacy.tool_calls} FINAL) AS t`, CONVERT_SETTINGS);
613
+ return { rows: Math.max(Number(r[0]?.m) || 0, Number(r[0]?.t) || 0), bytes: Number(r[0]?.bytes) || 0 };
614
+ }
615
+
616
+ /** Per-bucket legacy and converted counts for all three tables. */
617
+ async function plan(q, N, n) {
618
+ const [lt, ct, lc, cc, ls, cs] = await Promise.all([
619
+ q.rows(legacyTurnCountsSQL(N, n), CONVERT_SETTINGS),
620
+ rowsOrEmpty(q, convertedTurnCountsSQL(N, n)),
621
+ q.rows(legacyCallCountsSQL(N, n), CONVERT_SETTINGS),
622
+ rowsOrEmpty(q, convertedCallCountsSQL(N, n)),
623
+ q.rows(legacySessionCountsSQL(N, n), CONVERT_SETTINGS),
624
+ rowsOrEmpty(q, convertedSessionCountsSQL(N, n)),
625
+ ]);
626
+ return {
627
+ turns: { rows: byBucket(lt, 'rows'), want: byBucket(lt, 'keys'), have: byBucket(ct, 'keys'), unprefixed: sumMap(byBucket(lt, 'unprefixed')) },
628
+ calls: { rows: byBucket(lc, 'rows'), want: byBucket(lc, 'keys'), have: byBucket(cc, 'keys') },
629
+ sessions: { want: byBucket(ls, 'keys'), have: byBucket(cs, 'keys') },
630
+ };
631
+ }
632
+
633
+ function complete(p, b) { return (p.want.get(b) || 0) === (p.have.get(b) || 0); }
634
+
635
+ function summarize(p, inserted, skipped) {
636
+ const out = {
637
+ legacy_keys: sumMap(p.want), converted_keys: sumMap(p.have),
638
+ buckets_inserted: inserted, buckets_skipped: skipped,
639
+ };
640
+ if (p.rows) {
641
+ out.legacy_rows = sumMap(p.rows);
642
+ // Two legacy rows that land on ONE v3 key: same split session, origin, epoch, seq and ts,
643
+ // different user_id (or a prefixed and an unprefixed id of one session). Reported, never hidden.
644
+ out.collisions = out.legacy_rows - out.legacy_keys;
645
+ }
646
+ return out;
647
+ }
648
+
649
+ /**
650
+ * Convert one member. `ctx`: { db, member, ledger?, host? }. `opts`: { batchRows, dryRun }.
651
+ * @returns the report: per-table counts, buckets, collisions, unprefixed ids, ok.
652
+ */
653
+ async function convert(q, ctx, { batchRows = BATCH_ROWS, batchBytes = BATCH_BYTES, dryRun = false } = {}, ui = null) {
654
+ const N = v3Names(ctx.db, ctx.member);
655
+ const say = (m) => ui && ui.ok && ui.ok(m);
656
+ // A conversion resumes by comparing the legacy keys a bucket yields with the `legacy:` keys
657
+ // already in turns/calls. Once the capture has deduped converted rows (a 'legacy' files row), a
658
+ // tombstone cleanup may have dropped those rows and their tombstones together; a re-run would then
659
+ // find the bucket incomplete and write the duplicates back. So: never after dedupe, unless forced.
660
+ const deduped = await rowsOrEmpty(q, `SELECT count() AS n FROM ${N.files} FINAL WHERE role = 'legacy' AND is_deleted = 0`);
661
+ const dedupedSessions = Number((deduped[0] || {}).n) || 0;
662
+ if (dedupedSessions && !ctx.forceAfterDedupe) {
663
+ return { member: ctx.member, ok: false, deduped_sessions: dedupedSessions, refused: `${dedupedSessions} session(s) of ${ctx.member} are already deduped against their capture; `
664
+ + 're-converting could bring back rows the dedupe removed (and a cleanup may have dropped). Refusing.' };
665
+ }
666
+ const started = Date.now();
667
+ const size = await legacySize(q, N);
668
+ const n = bucketCount(size.rows, batchRows, size.bytes, batchBytes);
669
+ const before = await plan(q, N, n);
670
+ const adj = (await q.rows(legacyInputAdjustSQL(N), CONVERT_SETTINGS))[0] || {};
671
+ const report = {
672
+ member: ctx.member, source: N.legacy, target: { turns: N.turns, calls: N.calls, sessions: N.sessions },
673
+ buckets: n, legacy_text_bytes: size.bytes, dry_run: Boolean(dryRun), unprefixed_sessions: before.turns.unprefixed,
674
+ // Gemini CLI input without its cache reads (legacyInput): what the conversion takes out.
675
+ input_without_cache: { harnesses: INPUT_INCLUDES_CACHE, rows: Number(adj.rows) || 0, tokens: Number(adj.tokens) || 0 },
676
+ };
677
+ if (dryRun) {
678
+ report.turns = summarize(before.turns, 0, 0);
679
+ report.calls = summarize(before.calls, 0, 0);
680
+ report.sessions = summarize(before.sessions, 0, 0);
681
+ report.pending_buckets = [...Array(n).keys()].filter((b) =>
682
+ !complete(before.turns, b) || !complete(before.calls, b) || !complete(before.sessions, b));
683
+ report.ok = report.pending_buckets.length === 0;
684
+ return report;
685
+ }
686
+
687
+ const ledger = ctx.ledger || null;
688
+ const event = async (e) => { if (ledger) { try { await ledger.event(e); } catch { /* best effort */ } } };
689
+ await event({ kind: 'migration', id: '0200-v3-convert', status: 'pending', host: ctx.host || '',
690
+ detail: `convert ${ctx.member}: ${n} bucket(s)` });
691
+
692
+ const done = { turns: [0, 0], calls: [0, 0], sessions: [0, 0] };
693
+ try {
694
+ for (let b = 0; b < n; b++) {
695
+ let moved = false;
696
+ for (const [t, build] of [['turns', turnsInsertSQL], ['calls', callsInsertSQL]]) {
697
+ if (complete(before[t], b)) { done[t][1]++; continue; }
698
+ await q.sql(build(N, n, b), INSERT_SETTINGS);
699
+ done[t][0]++; moved = true;
700
+ say(`${t}: bucket ${b + 1}/${n} converted`);
701
+ }
702
+ if (moved || !complete(before.sessions, b)) {
703
+ await q.sql(sessionsInsertSQL(N, n, b), INSERT_SETTINGS);
704
+ done.sessions[0]++;
705
+ say(`sessions: bucket ${b + 1}/${n} built`);
706
+ } else done.sessions[1]++;
707
+ }
708
+ } catch (e) {
709
+ await event({ kind: 'migration', id: '0200-v3-convert', status: 'failed', host: ctx.host || '',
710
+ detail: `convert ${ctx.member}: ${String(e && e.message).split('\n')[0]}` });
711
+ throw e;
712
+ }
713
+
714
+ const after = await plan(q, N, n);
715
+ for (const t of ['turns', 'calls', 'sessions']) {
716
+ report[t] = summarize(after[t], done[t][0], done[t][1]);
717
+ report[t].converted_before = sumMap(before[t].have);
718
+ }
719
+ report.ok = ['turns', 'calls', 'sessions'].every((t) => [...Array(n).keys()].every((b) => complete(after[t], b)));
720
+ report.seconds = Math.round((Date.now() - started) / 100) / 10;
721
+ await event({ kind: 'migration', id: '0200-v3-convert', status: report.ok ? 'applied' : 'failed', host: ctx.host || '',
722
+ rows_before: report.turns.legacy_rows, rows_after: report.turns.converted_keys,
723
+ detail: `convert ${ctx.member}: turns ${report.turns.converted_keys}/${report.turns.legacy_keys}, `
724
+ + `calls ${report.calls.converted_keys}/${report.calls.legacy_keys}, `
725
+ + `sessions ${report.sessions.converted_keys}/${report.sessions.legacy_keys}`
726
+ + `${report.turns.collisions ? `, ${report.turns.collisions} turn collision(s)` : ''}` });
727
+ return report;
728
+ }
729
+
730
+ module.exports = {
731
+ // pure
732
+ splitLegacySessionId, legacyMessageId, legacyCallId, rootFromPath, pathFromPath, legacyKind, legacyInput, bucketCount, lit,
733
+ INPUT_INCLUDES_CACHE,
734
+ BATCH_ROWS, BATCH_BYTES,
735
+ // SQL builders (exported for the matrix and the unit gate)
736
+ harnessSQL, sessionSQL, rootSQL, pathSQL, kindSQL, bucketSQL, currentParseSQL, legacyInputSQL, legacyInputAdjustSQL, MESSAGE_ID,
737
+ legacyMessagesSQL, legacyCallsSQL, legacySessionsSQL,
738
+ turnsInsertSQL, callsInsertSQL, sessionsInsertSQL,
739
+ TURN_COLUMNS, CALL_COLUMNS, SESSION_COLUMNS, CONVERT_SETTINGS, INSERT_SETTINGS,
740
+ // running
741
+ legacySize, plan, convert,
742
+ };