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,1027 @@
1
+ // Row-level dedupe of a session's converted legacy rows against its v3 capture.
2
+ // docs/design/source-identity.md draft 5 §7 step 5, as decided on 2026-10-14:
3
+ // THERE MUST NEVER BE DUPLICATE RECORDS.
4
+ //
5
+ // A session converted from the legacy rooms (§7 step 4) holds `legacy:` rows: message_id
6
+ // 'legacy:<origin>:<epoch>:<seq>', call_id 'legacy:<origin>:<epoch>:<idx>', rev 0. When the
7
+ // capture first reads the same session from disk, it writes the same messages again under their
8
+ // native ids. Before this module the engine tombstoned the legacy ship rows only when the WHOLE
9
+ // session reconciled; a session that did not kept both copies. Now every legacy row is decided
10
+ // on its own:
11
+ //
12
+ // matched a captured row (or run of rows) holds what the legacy row held → the legacy row
13
+ // is tombstoned (a FULL copy, rev raised, is_deleted = 1 — §2.2; a key-only
14
+ // tombstone is resurrected by 26.7's skip indexes under FINAL)
15
+ // unmatched the transcript no longer has it → the legacy row stays live, and is reported
16
+ // import 'legacy:import:' rows (and any origin but ship) are never tombstoned (§7 step 5)
17
+ //
18
+ // THE MATCH. Both sides are put in one order and aligned, keeping that order:
19
+ //
20
+ // legacy one row per Claude Code record; one row per Codex turn, its parts '\n'-joined:
21
+ // assistant text, '[thinking] <summary>', '[tool-call: <name>(<arg keys>)]',
22
+ // '[tool-result: <name>] <one-line preview>', or just '[assistant activity]'.
23
+ // Sub-agent rows lead with '[subagent] '. Text capped at 50,000 chars (ship.js).
24
+ // captured one row per content block (`part`), in file order (`ord` = byte offset).
25
+ //
26
+ // A legacy row is parsed into PIECES (a plain line, a thinking line, a call, a result, an
27
+ // activity marker); it matches a captured RUN starting at atom s when every piece is
28
+ // consumed in order by the atoms of one unit: plain and thinking lines equal (whitespace
29
+ // collapsed) the lines of text/thinking atoms, a call needs a tool_call atom of that name, a
30
+ // result a tool_result atom (its preview is a quality signal, not a gate), an activity a usage
31
+ // atom. The unit is the record (Claude Code: one message_id) or, for an assistant row of a
32
+ // turn-folding harness (Codex), the turn (response_id) — a run never crosses into another
33
+ // unit of its class or into a user message. Atoms the legacy parser never rendered (tool
34
+ // results of a Claude user record, reasoning without a summary, empty blocks) are skipped.
35
+ //
36
+ // Each (legacy row, start) that matches is a candidate interval; the alignment is the
37
+ // heaviest chain of candidates increasing in BOTH orders with disjoint runs (weighted interval
38
+ // chaining, Fenwick tree, O(c log c)). Weight: 1000 per matched row first — the count always
39
+ // wins — then same agent, whole-atom consumption, fewer skipped atoms, agreeing previews. Two
40
+ // identical legacy rows therefore need two captured runs (multiplicity), and a repeated text
41
+ // cannot pull the alignment out of order.
42
+ //
43
+ // Claude Code legacy rows carry the record's own timestamp (ship.js messageTs uses _ts), so a
44
+ // candidate must have the same ts (`tsExact`); Codex legacy timestamps were interpolated and
45
+ // are ignored. Two follow-ups: (1) tsExact — a legacy row identical in agent, role, ts and text
46
+ // to a matched one is a verbatim re-append of the same record (§2.5; the capture collapses
47
+ // it), so it is matched too; (2) turn units — an '[assistant activity]' row with no usage atom
48
+ // of its own (tokens the legacy parser flushed between two parts of one turn) is matched when
49
+ // the legacy row before it matched into a turn and the row after it is a user row, is absent,
50
+ // or matched into the same turn.
51
+ //
52
+ // A third, for turn units: an '[assistant activity]' row between two user messages with no
53
+ // captured assistant row between them may be a turn aborted before any output, where Codex only
54
+ // repeated the last token_count (the same running total) and the 0.18 parser counted it again
55
+ // (`rereport`): its tokens are the previous turn's, already in the capture. Matched only on the
56
+ // transcript's proof (o.repeats, codexRepeats): such a repeat with the row's input and output,
57
+ // each repeat proving one row. Without it the row stays live (report.rereport_unproven).
58
+ //
59
+ // CAPTURED FORMS. The captured side is the 0.20 engines' rows or the harness-session-adapters package's
60
+ // (records-to-rows.js), read alike: a call `name(args)` or `name <args JSON>`; `reasoning` is
61
+ // thinking; a compaction `summary` and an `event` (an API error, a notice) are text, as 0.18
62
+ // rendered them; a tool message is on the user side, and in a turn unit a tool result outside any
63
+ // response joins the turn it follows; a custom tool's input as a bare JSON string is { input }.
64
+ // A converted profile row (step B) is read through the same shapes.
65
+ //
66
+ // Calls: the same chaining over (legacy calls, captured calls): the tool name must be equal and
67
+ // the arguments compatible (equal JSON; or the legacy object contained in the captured one —
68
+ // the Codex custom-tool preview; or an empty legacy object — the '[tool-call: X]' fallback);
69
+ // a call whose message matched into a run holding the captured call weighs more. A phantom
70
+ // (the 0.18 conversion parsed '[tool-call: X]' out of prose) is retired without a match: see
71
+ // matchCalls.
72
+ //
73
+ // DEAD ROWS RESERVE. The alignment runs over EVERY legacy ship row — live and already
74
+ // tombstoned (the newest version per key; a tombstone is a full copy, so it still has its
75
+ // text). A tombstoned row keeps the captured run it had, so a re-run (a crash before the
76
+ // marker) decides the same way, never hands its run to a live twin, and never resurrects it:
77
+ // legacy keys are only ever written as tombstones here.
78
+ // The reserve does not survive a tombstone cleanup (maintenance.tombstoneCleanup), which drops
79
+ // tombstones with their originals. A re-plan after it (--redupe) can give a remaining live row
80
+ // the run a dropped copy held — only where the 0.18 parse holds more copies of a text than the
81
+ // transcript; Claude Code's echo rule matches those copies anyway, so one extra copy can be
82
+ // retired for Codex and the bridge profile. Accepted (2026-10-23).
83
+ //
84
+ // TOTALS. The session's `sessions` row = Σ files.contrib (the capture) + the contribution of
85
+ // every legacy row still live (unmatched ship rows and imports), which the engine stores as a
86
+ // `files` row of its own (file_key LEGACY_FILE_KEY, role 'legacy') — so `sessions` stays a sum
87
+ // over files rows and a pass still reads no conversation rows. Legacy tokens stay as they were
88
+ // (per record); `usage_basis` is 'mixed' while any legacy row is live, else 'per_response'.
89
+ // That files row is also the marker: a session that has it is deduped and is never matched
90
+ // again; later passes only append.
91
+ //
92
+ // Pure: no I/O. capture.js reads the rows and writes what this returns.
93
+
94
+ 'use strict';
95
+
96
+ const { capture: base } = require('../../editors/base');
97
+
98
+ const LEGACY_FILE_KEY = 'legacy:rows';
99
+ const LEGACY_ROLE = 'legacy';
100
+ const LEGACY_TEXT_MAX = 50000; // ship.js TEXT_MAX: a row at the cap may end mid-line
101
+ const SUBAGENT_TAG = '[subagent] ';
102
+ const SYSTEM_MARKER = /^\[(info|error|warning)\] /;
103
+ // Gemini CLI thoughts are { subject, description }: the normaliser writes '**subject** description',
104
+ // the 0.18 reader wrote the description alone.
105
+ const unsubject = (line) => line.replace(/^\*\*[^*]+\*\* ?/, '');
106
+ const MATCH = 1000; // weight of one matched row: the count always comes first
107
+ const CANDIDATES_PER_ROW = 4000; // a text repeated thousands of times: the nearest are enough
108
+ const ZERO_SHA = '0'.repeat(64);
109
+ const EPOCH_TS = '1970-01-01 00:00:00.000';
110
+
111
+ /** How a harness's legacy rows relate to its capture. */
112
+ const PROFILES = {
113
+ // One legacy row per record, stamped with the record's own timestamp.
114
+ 'claude-code': { tsExact: true, unit: 'record' },
115
+ // One legacy row per turn (split again at turn_context and user messages); timestamps interpolated.
116
+ codex: { tsExact: false, unit: 'turn' },
117
+ };
118
+ // Every other harness is captured through the bridge (editors/bridge.js): its snapshot lines carry
119
+ // exactly the text 0.18 stored for each message, from the same reader, so a legacy row and a captured
120
+ // row are the same message when their role and text are equal. Matched one-to-one by that, with
121
+ // multiplicity (two equal legacy rows need two captured rows); timestamps are not compared, because
122
+ // 0.18 interpolated them over a span that may have moved since.
123
+ const BRIDGE_PROFILE = { tsExact: false, unit: 'message', byText: true };
124
+ // What byText leaves is matched piece by piece: since layout 3.2 these harnesses are captured through
125
+ // the normalisers (editors/normalise), one row per part, where the bridge held 0.18's message text.
126
+ const PIECE_PROFILE = { tsExact: false, unit: 'message', systemMarkers: true };
127
+ function profileFor(harness) { return PROFILES[harness] || BRIDGE_PROFILE; }
128
+
129
+ // Since layout 3.2 the capture keeps every text whole (base.js TEXT_CAP, TOOL_RESULT_CAP): two texts are
130
+ // the same message only when they are equal. A row cut at a cap compares as a prefix: a 0.18 row at
131
+ // LEGACY_TEXT_MAX (the capture continues it), or a row an older build captured cut at its caps (the
132
+ // 0.18 row continues it).
133
+ const OLD_CAPS = [2000, 50000];
134
+ const cutAtOldCap = (t) => OLD_CAPS.some((n) => t.length === n || t.length === n - 1);
135
+ const textKey = (t) => norm(t);
136
+ /** byText: legacy turns and calls matched to captured ones by (role class, normalized text) / (tool, args). */
137
+ function matchByText(shipT, shipC, capT, capC) {
138
+ const pool = new Map(); const cut = [];
139
+ const put = (k, v) => { if (!pool.has(k)) pool.set(k, []); pool.get(k).push(v); };
140
+ for (const r of [...capT].sort((a, b) => cmp([String(a.ts), Number(a.ord) || 0], [String(b.ts), Number(b.ord) || 0]))) {
141
+ const t = String(r.text ?? ''); const k = `${roleClass(r.role)}\u0000${textKey(t)}`;
142
+ put(k, r);
143
+ if (cutAtOldCap(t)) cut.push({ k, r });
144
+ }
145
+ const takeRow = (k, r) => { const list = pool.get(k) || []; const i = list.indexOf(r); if (i < 0) return null; list.splice(i, 1); return r; };
146
+ // Every exact match first: a prefix may take only what no equal text claims.
147
+ const rows = [...shipT].sort((a, b) => seqOf(a.message_id) - seqOf(b.message_id)).map((r) => {
148
+ // 0.18 stored a tool message with role 'tool'; the bridge files it as a user-side row (bridge.js bRole).
149
+ const t = stripTag(String(r.text ?? ''));
150
+ const key = `${r.role === 'tool' ? 'user' : roleClass(r.role)}\u0000${textKey(t)}`;
151
+ const q = pool.get(key);
152
+ return { r, t, key, m: q && q.length ? q.shift() : null };
153
+ });
154
+ for (const x of rows) {
155
+ if (x.m) continue;
156
+ if (x.t.length >= LEGACY_TEXT_MAX - 1) for (const [k, list] of pool) if (list.length && k.startsWith(x.key)) { x.m = list.shift(); break; }
157
+ if (!x.m) for (const c of cut) if (x.key.startsWith(c.k) && (x.m = takeRow(c.k, c.r))) break;
158
+ }
159
+ const LT = rows.map(({ r, m }) => ({ row: r, match: m ? [{ mid: String(m.message_id), part: Number(m.part_ord) || 0 }] : null, how: 'aligned' }));
160
+ const cpool = new Map();
161
+ for (const c of capC) { const k = `${c.tool_name}\u0000${String(c.args ?? '').replace(/\s+/g, '')}`; if (!cpool.has(k)) cpool.set(k, []); cpool.get(k).push(c); }
162
+ const LC = [...shipC].sort((a, b) => seqOf(a.call_id) - seqOf(b.call_id)).map((r) => {
163
+ const q = cpool.get(`${r.tool_name}\u0000${String(r.args ?? '').replace(/\s+/g, '')}`);
164
+ let m = q && q.length ? q.shift() : null;
165
+ if (!m) { // 0.18 parsed '[tool-call: X]' markers without arguments: any captured call of that tool
166
+ for (const [k, list] of cpool) if (list.length && k.startsWith(`${r.tool_name}\u0000`) && String(r.args ?? '').replace(/\s+/g, '') === '{}') { m = list.shift(); break; }
167
+ }
168
+ return { row: r, match: m };
169
+ });
170
+ return { LT, LC };
171
+ }
172
+
173
+ // ── text ─────────────────────────────────────────────────────────────────────────────────
174
+ const norm = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
175
+ function linesOf(text) {
176
+ const out = [];
177
+ for (const l of String(text ?? '').split('\n')) { const n = norm(l); if (n) out.push(n); }
178
+ return out;
179
+ }
180
+ function roleClass(role) { return role === 'user' ? 'user' : (role === 'system' ? 'system' : 'assistant'); }
181
+ const isShip = (id) => String(id).startsWith('legacy:ship:');
182
+ const isLegacy = (id) => String(id).startsWith('legacy:');
183
+ function stripTag(text) { const t = String(text ?? ''); return t.startsWith(SUBAGENT_TAG) ? t.slice(SUBAGENT_TAG.length) : t; }
184
+
185
+ /**
186
+ * A legacy row's pieces. Markers exist only in assistant rows (the legacy parsers added them
187
+ * there); a user or system row is plain lines, whatever it starts with.
188
+ * { t: 'line', x } | { t: 'thinking', x } | { t: 'call', name, keys } | { t: 'result', name, x } | { t: 'activity' }
189
+ */
190
+ function legacyPieces(text, cls, { systemMarkers = false } = {}) {
191
+ const out = [];
192
+ const lines = stripTag(text).split('\n');
193
+ for (const raw of lines) {
194
+ if (cls !== 'assistant') {
195
+ // The bridge-era readers marked a harness's own notices '[info] ' / '[error] ' (system rows).
196
+ const n = norm(systemMarkers && cls === 'system' ? raw.replace(SYSTEM_MARKER, '') : raw);
197
+ if (n) out.push({ t: 'line', x: n });
198
+ continue;
199
+ }
200
+ const tail = raw.trimEnd();
201
+ if (tail.trim() === '[assistant activity]') { out.push({ t: 'activity' }); continue; }
202
+ if (raw.startsWith('[thinking]')) { out.push({ t: 'thinking', x: norm(raw.slice(10)) }); continue; }
203
+ if (raw.startsWith('[tool-call: ') && tail.endsWith(']')) {
204
+ const inner = tail.slice(12, -1);
205
+ const p = inner.indexOf('(');
206
+ const name = (p === -1 ? inner : inner.slice(0, p)).trim();
207
+ const keys = p !== -1 && inner.endsWith(')') ? inner.slice(p + 1, -1).split(',').map((k) => k.trim()).filter(Boolean) : null;
208
+ out.push({ t: 'call', name, keys });
209
+ continue;
210
+ }
211
+ if (raw.startsWith('[tool-result: ')) {
212
+ const close = raw.indexOf(']', 14);
213
+ out.push({ t: 'result', name: close === -1 ? '' : raw.slice(14, close), x: close === -1 ? '' : norm(raw.slice(close + 1)).replace(/…$/, '') });
214
+ continue;
215
+ }
216
+ const n = norm(raw);
217
+ if (n) out.push({ t: 'line', x: n });
218
+ }
219
+ return out;
220
+ }
221
+
222
+ /**
223
+ * A captured tool_call row's text → { name, keys } (keys null when the args do not parse). The 0.20
224
+ * engines wrote `name(args)`; the harness-session-adapters package (records-to-rows.js) writes
225
+ * `name <args JSON>`. A tool name holds neither a space nor a parenthesis, so whichever of the two
226
+ * comes first tells the forms apart, also when the arguments contain the other.
227
+ */
228
+ function callOfText(text) {
229
+ const s = String(text ?? '');
230
+ const p = s.indexOf('(');
231
+ const sp = s.indexOf(' ');
232
+ const keysOf = (j) => {
233
+ try { const o = JSON.parse(j); return o && typeof o === 'object' && !Array.isArray(o) ? Object.keys(o) : null; } catch { return null; }
234
+ };
235
+ if (sp !== -1 && (p === -1 || sp < p)) return { name: s.slice(0, sp), keys: keysOf(s.slice(sp + 1)) };
236
+ if (p === -1) return { name: s, keys: null };
237
+ return { name: s.slice(0, p), keys: s.endsWith(')') ? keysOf(s.slice(p + 1, -1)) : null };
238
+ }
239
+ /** The texts a captured tool_call row of this call holds, in either engine's form. */
240
+ function callTexts(name, args) {
241
+ return [base.capText(`${name}(${args})`, base.TOOL_RESULT_CAP), base.capText(`${name} ${args}`, base.TOOL_RESULT_CAP)];
242
+ }
243
+
244
+ // ── rows ─────────────────────────────────────────────────────────────────────────────────
245
+ /** The newest version of every key (a tombstone outranks an equal rev): what FINAL would answer, tombstones kept. */
246
+ function newestPerKey(rows, keyOf) {
247
+ const best = new Map();
248
+ for (const r of rows) {
249
+ const k = keyOf(r);
250
+ const cur = best.get(k);
251
+ const v = BigInt(r.rev || 0);
252
+ if (!cur || v > cur.v || (v === cur.v && Number(r.is_deleted) && !Number(cur.r.is_deleted))) best.set(k, { v, r });
253
+ }
254
+ return [...best.values()].map((x) => x.r);
255
+ }
256
+ const dead = (r) => Number(r.is_deleted) === 1;
257
+ function seqOf(id) { const m = /:(\d+)$/.exec(String(id)); return m ? Number(m[1]) : 0; }
258
+ function cmp(a, b) {
259
+ for (let i = 0; i < a.length; i++) {
260
+ if (a[i] < b[i]) return -1;
261
+ if (a[i] > b[i]) return 1;
262
+ }
263
+ return 0;
264
+ }
265
+
266
+ // ── the chain ────────────────────────────────────────────────────────────────────────────
267
+ /** Prefix maximum over legacy positions: (score, candidate index), ties to the earlier candidate. */
268
+ class MaxTree {
269
+ constructor(n) { this.n = n; this.s = new Float64Array(n + 1).fill(-1); this.i = new Int32Array(n + 1).fill(-1); }
270
+ better(s, i, at) { return s > this.s[at] || (s === this.s[at] && i !== -1 && (this.i[at] === -1 || i < this.i[at])); }
271
+ update(pos, s, i) { for (let p = pos + 1; p <= this.n; p += p & -p) if (this.better(s, i, p)) { this.s[p] = s; this.i[p] = i; } }
272
+ query(pos) {
273
+ let bs = -1; let bi = -1;
274
+ for (let p = pos + 1; p > 0; p -= p & -p) if (this.s[p] > bs || (this.s[p] === bs && this.i[p] !== -1 && (bi === -1 || this.i[p] < bi))) { bs = this.s[p]; bi = this.i[p]; }
275
+ return bi === -1 ? null : { s: bs, i: bi };
276
+ }
277
+ }
278
+ /**
279
+ * The heaviest chain of candidates { k, s, e, w }: k (legacy position) strictly increasing, and
280
+ * each run [s, e] after the previous one's end. Returns the chosen candidates.
281
+ */
282
+ function bestChain(cands, nLegacy) {
283
+ if (!cands.length) return [];
284
+ cands.sort((x, y) => x.s - y.s || x.k - y.k || x.e - y.e);
285
+ const byEnd = cands.map((_, i) => i).sort((x, y) => cands[x].e - cands[y].e || x - y);
286
+ const tree = new MaxTree(nLegacy);
287
+ const score = new Float64Array(cands.length);
288
+ const prev = new Int32Array(cands.length).fill(-1);
289
+ let rel = 0; let top = -1;
290
+ for (let i = 0; i < cands.length; i++) {
291
+ const c = cands[i];
292
+ while (rel < byEnd.length && cands[byEnd[rel]].e < c.s) { const j = byEnd[rel++]; tree.update(cands[j].k, score[j], j); }
293
+ const best = c.k > 0 ? tree.query(c.k - 1) : null;
294
+ score[i] = c.w + (best ? best.s : 0);
295
+ prev[i] = best ? best.i : -1;
296
+ if (top === -1 || score[i] > score[top]) top = i;
297
+ }
298
+ const out = [];
299
+ for (let i = top; i !== -1; i = prev[i]) out.push(cands[i]);
300
+ return out.reverse();
301
+ }
302
+
303
+ // ── turns ────────────────────────────────────────────────────────────────────────────────
304
+ const TEXTISH = new Set(['text', 'context']);
305
+ const WHOLE = new Set(['text', 'thinking', 'context']); // kinds whose every line a legacy row would have held
306
+
307
+ // The captured row in the matcher's terms, whichever engine wrote it. The harness-session-adapters package
308
+ // (records-to-rows.js) names some content as sessions-model does where the 0.20 engines had text:
309
+ // a compaction summary is a `summary` part, an API error or a harness notice an `event` row, and
310
+ // reasoning arrives as `reasoning` from a profile row. The 0.18 parser rendered every one of them as
311
+ // a plain line, so they are text here. A tool message (role `tool`, or `user` as records-to-rows
312
+ // files it) is on the user side.
313
+ const KIND_AS = { reasoning: 'thinking', summary: 'text', event: 'text', notice: 'text', error: 'text' };
314
+ const capClass = (role) => (role === 'tool' ? 'user' : roleClass(role));
315
+
316
+ function atomsOf(rows, harness, prof) {
317
+ const atoms = rows.map((r) => {
318
+ const text = String(r.text ?? '');
319
+ const kind = KIND_AS[String(r.kind)] || String(r.kind);
320
+ const cls = capClass(r.role);
321
+ const a = {
322
+ row: r, cls, kind, agent: String(r.agent_id || ''), ts: String(r.ts), mid: String(r.message_id),
323
+ part: Number(r.part_ord) || 0, ord: Number(r.ord) || 0, resp: String(r.response_id || ''),
324
+ text, lines: linesOf(text), flat: null, call: kind === 'tool_call' ? callOfText(text) : null,
325
+ };
326
+ a.unit = prof.unit === 'turn' && cls === 'assistant' && a.resp ? `${a.agent}\u0000turn:${a.resp}` : `${a.agent}\u0000${a.mid}`;
327
+ return a;
328
+ });
329
+ const key = prof.tsExact ? (a) => [a.ts, a.agent, a.ord, a.part, a.mid] : (a) => [a.agent, a.ord, a.part, a.ts, a.mid];
330
+ atoms.sort((x, y) => cmp(key(x), key(y)));
331
+ atoms.forEach((a, i) => { a.i = i; });
332
+ if (prof.unit === 'turn') {
333
+ // A tool result belongs to the turn that called it. The 0.20 Codex engine filed it under the
334
+ // turn's response; the package files it as a tool message of its own, outside any response. In
335
+ // file order it sits after its turn's call and before the next user message: it joins that turn,
336
+ // on the assistant side, as the 0.18 parser rendered it ('[tool-result: …]' inside the turn row).
337
+ let cur = null; let agent = null;
338
+ for (const a of atoms) {
339
+ if (a.agent !== agent) { cur = null; agent = a.agent; }
340
+ if (a.unit.includes('\u0000turn:')) cur = a.unit;
341
+ else if (a.kind === 'tool_result' && cur) { a.unit = cur; a.cls = 'assistant'; }
342
+ else if (a.cls === 'user') cur = null;
343
+ }
344
+ }
345
+ return atoms;
346
+ }
347
+ const flatOf = (a) => (a.flat === null ? (a.flat = norm(a.text)) : a.flat);
348
+
349
+ /** Signatures an atom can START a run under, for the candidate index. */
350
+ function atomSigs(a, prof) {
351
+ const pre = prof.tsExact ? `${a.ts}\u0001` : `${a.agent}\u0001`;
352
+ const out = [];
353
+ if (TEXTISH.has(a.kind) && a.lines.length) out.push(`L${a.cls}\u0001${a.lines[0]}`);
354
+ if (a.kind === 'thinking') {
355
+ out.push('T*');
356
+ if (a.lines.length) { out.push(`T\u0001${a.lines[0]}`); const u = unsubject(a.lines[0]); if (u !== a.lines[0] && u) out.push(`T\u0001${u}`); }
357
+ }
358
+ if (a.kind === 'tool_call' && a.call) out.push(`C\u0001${a.call.name}`);
359
+ if (a.kind === 'tool_result') { out.push('R*'); const f = flatOf(a); if (f.length >= 16) out.push(`R\u0001${f.slice(0, 16)}`); }
360
+ if (a.kind === 'usage') out.push('U');
361
+ return out.map((s) => pre + s);
362
+ }
363
+ function legacySig(L, prof) {
364
+ const pre = prof.tsExact ? `${L.ts}\u0001` : `${L.agent}\u0001`;
365
+ const p = L.pieces[0];
366
+ if (!p) return null;
367
+ if (p.t === 'line') return L.cut && L.pieces.length === 1 ? null : pre + `L${L.cls}\u0001${p.x}`;
368
+ if (p.t === 'thinking') return pre + (p.x ? `T\u0001${p.x}` : 'T*');
369
+ if (p.t === 'call') return pre + `C\u0001${p.name}`;
370
+ if (p.t === 'result') return pre + (p.x.length >= 16 ? `R\u0001${p.x.slice(0, 16)}` : 'R*');
371
+ return pre + 'U';
372
+ }
373
+
374
+ /**
375
+ * Does legacy row L match a run of atoms starting at s? → { e, used, w } or null.
376
+ * Greedy: a plain line continues the current atom when it is that atom's next line, else it
377
+ * opens the next text atom of the run; every marker opens the next atom of its kind.
378
+ */
379
+ function tryMatch(L, s, atoms) {
380
+ const start = atoms[s];
381
+ if (start.cls !== L.cls) return null;
382
+ const pieces = L.pieces;
383
+ let a = -1; let j = 0;
384
+ let gaps = 0; let partial = 0; let soft = 0;
385
+ const used = [];
386
+ const leave = () => { if (a !== -1 && WHOLE.has(atoms[a].kind) && j < atoms[a].lines.length) partial++; };
387
+ // The next atom of the run (from s for the first piece) that satisfies pred.
388
+ const open = (pred) => {
389
+ const from = a === -1 ? s : a + 1;
390
+ for (let k = from; k < atoms.length; k++) {
391
+ const x = atoms[k];
392
+ if (a === -1 && k > s) return -1; // the first piece starts AT s
393
+ if (x.unit !== start.unit) {
394
+ if (x.cls === 'system' && L.cls !== 'system') continue; // a context row inside a turn
395
+ return -1;
396
+ }
397
+ if (pred(x)) { leave(); a = k; j = 0; used.push(k); return k; }
398
+ if (x.lines.length && x.kind !== 'usage') gaps++;
399
+ }
400
+ return -1;
401
+ };
402
+ const eq = (cap, leg, last) => cap === leg || (last && L.cut && cap.startsWith(leg));
403
+ // The next atom with content after the current one, in the run's unit (or null).
404
+ const nextContent = () => {
405
+ for (let k = a + 1; k < atoms.length; k++) {
406
+ const x = atoms[k];
407
+ if (x.unit !== start.unit) { if (x.cls === 'system' && L.cls !== 'system') continue; return null; }
408
+ if (x.lines.length && x.kind !== 'usage') return x;
409
+ }
410
+ return null;
411
+ };
412
+ for (let pi = 0; pi < pieces.length; pi++) {
413
+ const p = pieces[pi];
414
+ const last = pi === pieces.length - 1;
415
+ let ok = true;
416
+ if (p.t === 'line') {
417
+ const cur = a === -1 ? null : atoms[a];
418
+ if (cur && WHOLE.has(cur.kind) && j < cur.lines.length && eq(cur.lines[j], p.x, last)) j++;
419
+ // A preview spanning lines continues the result, unless the line opens the text that follows
420
+ // it ('{' after a JSON output is also inside that output).
421
+ else if (cur && cur.kind === 'tool_result' && flatOf(cur).includes(p.x)
422
+ && !(() => { const x = nextContent(); return x && TEXTISH.has(x.kind) && eq(x.lines[0], p.x, last); })()) { /* a multi-line preview */ }
423
+ else if (open((x) => TEXTISH.has(x.kind) && x.lines.length > 0 && eq(x.lines[0], p.x, last)) !== -1) j = 1;
424
+ else ok = false;
425
+ } else if (p.t === 'thinking') {
426
+ if (open((x) => x.kind === 'thinking' && (p.x === '' || (x.lines.length > 0 && (eq(x.lines[0], p.x, last) || eq(unsubject(x.lines[0]), p.x, last))))) !== -1) j = p.x === '' ? 0 : 1;
427
+ else ok = false;
428
+ } else if (p.t === 'call') {
429
+ if (open((x) => x.kind === 'tool_call' && x.call && x.call.name === p.name) !== -1) {
430
+ const c = atoms[a].call;
431
+ if (p.keys && c.keys && p.keys.join(',') !== c.keys.join(',')) soft++;
432
+ j = atoms[a].lines.length;
433
+ } else ok = false;
434
+ } else if (p.t === 'result') {
435
+ if (open((x) => x.kind === 'tool_result') !== -1) {
436
+ if (p.x && !flatOf(atoms[a]).startsWith(p.x)) soft++;
437
+ j = atoms[a].lines.length;
438
+ } else ok = false;
439
+ } else if (open((x) => x.kind === 'usage') === -1) ok = false; // activity
440
+ if (!ok) {
441
+ // A row cut at the legacy cap may end in a broken piece: what came before it decides.
442
+ if (last && L.cut && used.length) break;
443
+ return null;
444
+ }
445
+ }
446
+ leave();
447
+ if (!used.length) return null;
448
+ const w = MATCH + (start.agent === L.agent ? 40 : 0) + (partial ? 0 : 20) - Math.min(gaps, 10) - Math.min(5 * soft, 20);
449
+ return { e: used[used.length - 1], used, w };
450
+ }
451
+
452
+ // ── the re-report proof (Codex) ──────────────────────────────────────────────────────────
453
+ /**
454
+ * What `rereport` needs from a Codex rollout's lines (strings or parsed, in file order): the
455
+ * last_token_usage of every token_count that repeats the previous total_token_usage inside a turn
456
+ * (task_started … task_complete | turn_aborted) where the model produced nothing — no response
457
+ * item but a user or developer message. In the 0.18 split: input without cached input, output.
458
+ * A turn still open at the end proves nothing (it may yet produce output); a rollout without
459
+ * task_started proves nothing.
460
+ */
461
+ function codexRepeats(lines) {
462
+ const out = [];
463
+ let prevTotal = null; let turn = null;
464
+ const close = () => { if (turn && !turn.model) out.push(...turn.repeats); turn = null; };
465
+ for (const line of lines) {
466
+ let o = line;
467
+ if (typeof line === 'string') {
468
+ if (!line.includes('"event_msg"') && !line.includes('"response_item"')) continue;
469
+ try { o = JSON.parse(line); } catch { continue; }
470
+ }
471
+ const p = o && o.payload;
472
+ if (!p || typeof p !== 'object') continue;
473
+ if (o.type === 'response_item') { if (turn && !(p.type === 'message' && p.role !== 'assistant')) turn.model = true; continue; }
474
+ if (o.type !== 'event_msg') continue;
475
+ if (p.type === 'task_started') { close(); turn = { model: false, repeats: [] }; } else if (p.type === 'task_complete' || p.type === 'turn_aborted') close();
476
+ else if (p.type === 'token_count' && p.info && p.info.total_token_usage) {
477
+ const total = JSON.stringify(p.info.total_token_usage);
478
+ const last = p.info.last_token_usage;
479
+ if (turn && total === prevTotal && last) {
480
+ turn.repeats.push({ input: Math.max(Number(last.input_tokens || 0) - Number(last.cached_input_tokens || 0), 0), output: Number(last.output_tokens || 0) });
481
+ }
482
+ prevTotal = total;
483
+ }
484
+ }
485
+ return out;
486
+ }
487
+ const repeatKey = (input, output) => `${Number(input) || 0}/${Number(output) || 0}`;
488
+ function repeatPool(repeats) {
489
+ const m = new Map();
490
+ for (const r of repeats) { const k = repeatKey(r.input, r.output); m.set(k, (m.get(k) || 0) + 1); }
491
+ return m;
492
+ }
493
+
494
+ function matchTurns(harness, prof, shipRows, capturedTurns, repeats = null) {
495
+ const atoms = atomsOf(capturedTurns, harness, prof);
496
+ const legacy = shipRows.map((r) => {
497
+ const cls = roleClass(r.role);
498
+ const text = String(r.text ?? '');
499
+ return {
500
+ row: r, cls, agent: String(r.agent_id || ''), ts: String(r.ts), ord: Number(r.ord) || seqOf(r.message_id),
501
+ pieces: legacyPieces(text, cls, { systemMarkers: !!prof.systemMarkers }), cut: text.length >= LEGACY_TEXT_MAX - 1, flat: norm(stripTag(text)),
502
+ match: null, how: null,
503
+ };
504
+ });
505
+ const lkey = prof.tsExact ? (L) => [L.ts, L.agent, L.ord, L.row.message_id] : (L) => [L.agent, L.ord, L.ts, L.row.message_id];
506
+ legacy.sort((x, y) => cmp(lkey(x), lkey(y)));
507
+ // Candidate index.
508
+ const index = new Map();
509
+ for (const a of atoms) for (const sig of atomSigs(a, prof)) { if (!index.has(sig)) index.set(sig, []); index.get(sig).push(a.i); }
510
+ const startsOf = (L) => {
511
+ const sig = legacySig(L, prof);
512
+ if (sig) return { starts: index.get(sig) || [], selective: prof.tsExact || SELECTIVE.test(sig) };
513
+ if (!L.pieces.length) return { starts: [], selective: true };
514
+ // A single line cut at the legacy cap: its own prefix is all there is to look for.
515
+ const x = L.pieces[0].x;
516
+ return {
517
+ starts: atoms.filter((a) => TEXTISH.has(a.kind) && a.cls === L.cls && a.lines.length && a.lines[0].startsWith(x)
518
+ && (prof.tsExact ? a.ts === L.ts : a.agent === L.agent)).map((a) => a.i),
519
+ selective: true,
520
+ };
521
+ };
522
+ const cands = [];
523
+ const tryAll = (L, k, starts) => {
524
+ if (starts.length > CANDIDATES_PER_ROW) starts = nearest(starts, k, legacy.length, atoms.length);
525
+ for (const s of starts) {
526
+ if (!prof.tsExact && atoms[s].agent !== L.agent) continue;
527
+ const m = tryMatch(L, s, atoms);
528
+ if (m) cands.push({ k, s, e: m.e, w: m.w, used: m.used });
529
+ }
530
+ };
531
+ // Two phases, so a row whose first piece is common (a call of the one shell tool, a result with
532
+ // no preview, a usage row) is not tried against every such atom of a long session: rows with a
533
+ // distinctive first piece are aligned first, and the others are tried only between the runs their
534
+ // aligned neighbours took.
535
+ const loose = [];
536
+ legacy.forEach((L, k) => {
537
+ const { starts, selective } = startsOf(L);
538
+ if (selective) tryAll(L, k, starts); else loose.push({ L, k, starts });
539
+ });
540
+ if (loose.length) {
541
+ const { lo, hi } = windows(bestChain(cands.slice(), legacy.length), legacy.length, atoms.length);
542
+ for (const { L, k, starts } of loose) tryAll(L, k, between(starts, lo[k], hi[k]));
543
+ }
544
+ for (const c of bestChain(cands, legacy.length)) { legacy[c.k].match = c.used.map((i) => atoms[i]); legacy[c.k].how = 'aligned'; }
545
+
546
+ if (prof.tsExact) {
547
+ // A verbatim copy of one record: within the session, the same role, ts (to the millisecond) and
548
+ // text as a matched row — or as one captured text row — is that record again, whatever agent id
549
+ // the copy was filed under. The agent is deliberately not in the key: the 0.18 parse filed one
550
+ // sub-agent conversation under eleven agent ids (copies of its sidechain file), the capture
551
+ // under one (2026-10-14, the dev house).
552
+ const seen = new Map();
553
+ for (const L of legacy) if (L.match) seen.set(`${L.cls}\u0000${L.ts}\u0000${L.flat}`, L.match);
554
+ for (const a of atoms) {
555
+ if (!TEXTISH.has(a.kind)) continue;
556
+ const k = `${a.cls}\u0000${a.ts}\u0000${flatOf(a)}`;
557
+ if (!seen.has(k)) seen.set(k, [a]);
558
+ }
559
+ for (const L of legacy) {
560
+ if (L.match) continue;
561
+ const m = seen.get(`${L.cls}\u0000${L.ts}\u0000${L.flat}`);
562
+ if (m) { L.match = m; L.how = 'echo'; }
563
+ }
564
+ }
565
+ if (prof.unit === 'turn') {
566
+ // '[assistant activity]' with no usage atom of its own: tokens the legacy parser flushed
567
+ // between two parts of ONE turn (at a steering message, at a repeated turn_context), which
568
+ // the capture bills on the turn's first row. Matched when it lies inside a captured turn:
569
+ // the rows around it matched atoms inside that turn's span (a steering message sits inside
570
+ // it), or — at the end — the row before it matched into the turn.
571
+ const turnAt = new Array(atoms.length).fill(null);
572
+ const span = new Map();
573
+ for (const a of atoms) {
574
+ if (!a.unit.includes('\u0000turn:')) continue;
575
+ const v = span.get(a.unit) || [a.i, a.i];
576
+ v[0] = Math.min(v[0], a.i); v[1] = Math.max(v[1], a.i);
577
+ span.set(a.unit, v);
578
+ }
579
+ for (const [u, [lo, hi]] of span) for (let i = lo; i <= hi; i++) if (!turnAt[i]) turnAt[i] = u;
580
+ const lastAt = (L) => (L && L.match && L.match.length ? L.match[L.match.length - 1].i : -1);
581
+ const firstAt = (L) => (L && L.match && L.match.length ? L.match[0].i : -1);
582
+ for (let k = 0; k < legacy.length; k++) {
583
+ const L = legacy[k];
584
+ if (L.match || L.pieces.length !== 1 || L.pieces[0].t !== 'activity') continue;
585
+ let p = k - 1; while (p >= 0 && (legacy[p].agent !== L.agent || (legacy[p].match && !legacy[p].match.length))) p--;
586
+ let n = k + 1; while (n < legacy.length && legacy[n].agent !== L.agent) n++;
587
+ const P = p >= 0 ? legacy[p] : null;
588
+ const Nx = n < legacy.length ? legacy[n] : null;
589
+ const pe = lastAt(P);
590
+ if (pe !== -1 && P.cls === 'user' && (!Nx || (Nx.cls === 'user' && firstAt(Nx) > pe))) {
591
+ // A turn the user aborted before the model produced anything. Codex opens a turn by
592
+ // repeating the last token_count (the same total_token_usage); in such a turn that repeat
593
+ // is all there is, and the 0.18 parser counted it again: '[assistant activity]' holding the
594
+ // previous turn's tokens. The capture adds nothing for a running total seen again (the
595
+ // 0.20 engine and the package alike), so between the two user messages there is no
596
+ // captured assistant row. Matched only on proof from the transcript (`repeats`, from
597
+ // codexRepeats): a repeat in an empty turn with the same input and output as the row. An
598
+ // aborted turn that really spent tokens has other numbers and stays live (2026-10-23).
599
+ const end = Nx ? firstAt(Nx) : atoms.length;
600
+ let empty = true;
601
+ for (let i = pe + 1; i < end && empty; i++) if (atoms[i].agent === L.agent && atoms[i].cls === 'assistant') empty = false;
602
+ if (empty) {
603
+ const key = repeatKey(L.row.input_tokens, L.row.output_tokens);
604
+ if (repeats && repeats.get(key) > 0) { repeats.set(key, repeats.get(key) - 1); L.match = []; L.how = 'rereport'; continue; }
605
+ L.unproven = true;
606
+ }
607
+ }
608
+ const u = pe === -1 ? null : turnAt[pe];
609
+ if (!u) continue;
610
+ const ns = firstAt(Nx);
611
+ const inside = (ns !== -1 && turnAt[ns] === u && ns > pe)
612
+ || (P.cls === 'assistant' && (!Nx || Nx.cls === 'user') && (ns === -1 || ns > pe));
613
+ if (inside) { L.match = []; L.how = 'activity'; }
614
+ }
615
+ }
616
+ return { legacy, atoms };
617
+ }
618
+ const SELECTIVE = /\u0001(L[a-z]+\u0001|T\u0001|R\u0001)/;
619
+ /** Per legacy position: the end of the nearest chosen run before it and the start of the nearest after it. */
620
+ function windows(chain, n, size) {
621
+ const lo = new Int32Array(n).fill(-1);
622
+ const hi = new Int32Array(n).fill(size);
623
+ let j = 0; let last = -1;
624
+ for (let k = 0; k < n; k++) { while (j < chain.length && chain[j].k < k) last = chain[j++].e; lo[k] = last; }
625
+ j = chain.length - 1; let next = size;
626
+ for (let k = n - 1; k >= 0; k--) { while (j >= 0 && chain[j].k > k) next = chain[j--].s; hi[k] = next; }
627
+ return { lo, hi };
628
+ }
629
+ /** The entries of an ascending list strictly between lo and hi. */
630
+ function between(list, lo, hi) {
631
+ let a = 0; let b = list.length;
632
+ while (a < b) { const m = (a + b) >> 1; if (list[m] <= lo) a = m + 1; else b = m; }
633
+ const out = [];
634
+ for (let i = a; i < list.length && list[i] < hi; i++) out.push(list[i]);
635
+ return out;
636
+ }
637
+ /** Of a long candidate list, the ones nearest the legacy row's relative position. */
638
+ function nearest(starts, k, nLegacy, nAtoms) {
639
+ const at = nLegacy > 1 ? (k / (nLegacy - 1)) * nAtoms : 0;
640
+ return starts.slice().sort((x, y) => Math.abs(x - at) - Math.abs(y - at) || x - y).slice(0, CANDIDATES_PER_ROW).sort((x, y) => x - y);
641
+ }
642
+
643
+ // ── calls ────────────────────────────────────────────────────────────────────────────────
644
+ /**
645
+ * Arguments as an object. A Codex custom tool's input (apply_patch) is free text: the 0.18 parser
646
+ * and the 0.20 engine wrapped it as { input }, the harness-session-adapters package keeps it as the bare
647
+ * JSON string — read as { input } here, so the forms compare. A bare string cut at the args cap
648
+ * (20,000) is closed again and read as the prefix it is: the 0.18 preview is its first 300 chars.
649
+ */
650
+ function parseObj(s) {
651
+ try {
652
+ const o = JSON.parse(s);
653
+ if (typeof o === 'string') return { input: o };
654
+ return o && typeof o === 'object' && !Array.isArray(o) ? o : null;
655
+ } catch {
656
+ if (!s.startsWith('"')) return null;
657
+ try { const o = JSON.parse(`${s.replace(/\\(u[0-9a-fA-F]{0,3})?$/, '')}"`); return typeof o === 'string' ? { input: o } : null; } catch { return null; }
658
+ }
659
+ }
660
+ function deepEq(a, b) {
661
+ if (a === b) return true;
662
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') return false;
663
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
664
+ const ka = Object.keys(a); const kb = Object.keys(b);
665
+ if (ka.length !== kb.length) return false;
666
+ return ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEq(a[k], b[k]));
667
+ }
668
+ function containedIn(big, small) {
669
+ return Object.keys(small).every((k) => {
670
+ if (!Object.prototype.hasOwnProperty.call(big, k)) return false;
671
+ if (deepEq(big[k], small[k])) return true;
672
+ // A value the legacy parser shortened to one line (Codex custom tools: 300 chars + '…').
673
+ return typeof big[k] === 'string' && typeof small[k] === 'string' && norm(big[k]).startsWith(norm(small[k]).replace(/…$/, ''));
674
+ });
675
+ }
676
+ /** Arguments, read once: the string, the object it parses to (or null), the string without whitespace. */
677
+ function argsOf(v) { const s = String(v ?? ''); return { s, o: parseObj(s), flat: null }; }
678
+ /** 'exact' | 'contained' | 'weak' | 'none' — legacy arguments against captured ones (strings, or argsOf results). */
679
+ function argsFit(legacyArgs, capturedArgs) {
680
+ const L = typeof legacyArgs === 'object' && legacyArgs ? legacyArgs : argsOf(legacyArgs);
681
+ const C = typeof capturedArgs === 'object' && capturedArgs ? capturedArgs : argsOf(capturedArgs);
682
+ if (L.s === C.s) return 'exact';
683
+ if (L.o && !Object.keys(L.o).length) return 'weak'; // '[tool-call: X]': the legacy parser saw no arguments
684
+ if (L.o && C.o) return deepEq(L.o, C.o) ? 'exact' : (containedIn(C.o, L.o) ? 'contained' : 'none');
685
+ // One side cut at the 20,000-char cap does not parse: compare as text.
686
+ if (L.flat === null) L.flat = L.s.replace(/\s+/g, '');
687
+ if (C.flat === null) C.flat = C.s.replace(/\s+/g, '');
688
+ return L.flat && C.flat && (L.flat.startsWith(C.flat) || C.flat.startsWith(L.flat)) ? 'contained' : 'none';
689
+ }
690
+ const FIT_W = { exact: 30, contained: 20, weak: 0 };
691
+
692
+ function matchCalls(prof, shipCalls, capturedCalls, legacyTurns, atoms) {
693
+ // Where each captured call sits in its transcript: the ord of its message's atoms, and its
694
+ // place among that message's tool_call atoms.
695
+ const atomsByMid = new Map();
696
+ for (const a of atoms) { if (!atomsByMid.has(a.mid)) atomsByMid.set(a.mid, []); atomsByMid.get(a.mid).push(a); }
697
+ const cap = capturedCalls.map((r) => {
698
+ const mine = atomsByMid.get(String(r.message_id)) || [];
699
+ const texts = callTexts(r.tool_name, r.args);
700
+ let ord = mine.length ? Math.min(...mine.map((a) => a.ord)) : Number.MAX_SAFE_INTEGER;
701
+ let part = 255;
702
+ for (const a of mine) {
703
+ if (a.kind !== 'tool_call') continue;
704
+ let x = null;
705
+ try { x = JSON.parse(a.row.extra || '{}').call_id; } catch { /* no extra */ }
706
+ if (x === String(r.call_id) || texts.includes(a.text)) { part = a.part; ord = a.ord; break; }
707
+ }
708
+ return { row: r, ts: String(r.ts), agent: String(r.agent_id || ''), name: String(r.tool_name), mid: String(r.message_id), ord, part, id: String(r.call_id), args: argsOf(r.args) };
709
+ });
710
+ const ckey = prof.tsExact ? (c) => [c.ts, c.agent, c.ord, c.part, c.id] : (c) => [c.agent, c.ord, c.part, c.ts, c.id];
711
+ cap.sort((x, y) => cmp(ckey(x), ckey(y)));
712
+ const byMid = new Map();
713
+ cap.forEach((c, i) => { if (!byMid.has(c.mid)) byMid.set(c.mid, []); byMid.get(c.mid).push(i); });
714
+ // The run each legacy message matched: the message ids of its atoms.
715
+ const runOf = new Map();
716
+ for (const L of legacyTurns) if (L.match) runOf.set(String(L.row.message_id), [...new Set(L.match.map((a) => a.mid))]);
717
+ const leg = shipCalls.map((r) => ({
718
+ row: r, ts: String(r.ts), agent: String(r.agent_id || ''), name: String(r.tool_name), seq: seqOf(r.message_id), idx: seqOf(r.call_id), match: null, args: argsOf(r.args),
719
+ }));
720
+ const lkey = prof.tsExact ? (c) => [c.ts, c.agent, c.seq, c.idx] : (c) => [c.agent, c.seq, c.idx, c.ts];
721
+ leg.sort((x, y) => cmp(lkey(x), lkey(y)));
722
+ const index = new Map();
723
+ cap.forEach((c, i) => {
724
+ const sig = `${prof.tsExact ? c.ts : c.agent}\u0001${c.name}`;
725
+ if (!index.has(sig)) index.set(sig, []);
726
+ index.get(sig).push(i);
727
+ });
728
+ const cands = [];
729
+ const add = (L, k, s, linked) => {
730
+ const c = cap[s];
731
+ if (c.name !== L.name || (prof.tsExact ? c.ts !== L.ts : c.agent !== L.agent)) return false;
732
+ const fit = argsFit(L.args, c.args);
733
+ if (fit === 'none') return false;
734
+ cands.push({ k, s, e: s, w: MATCH + (linked ? 200 : 0) + FIT_W[fit] + (c.agent === L.agent ? 40 : 0) });
735
+ return true;
736
+ };
737
+ // Phase 1: a call whose message matched looks in that message's run. Phase 2: the rest look
738
+ // between the calls their neighbours matched — or everywhere, when nothing matched at all.
739
+ const rest = [];
740
+ leg.forEach((L, k) => {
741
+ let any = false;
742
+ for (const mid of runOf.get(String(L.row.message_id)) || []) for (const s of byMid.get(mid) || []) any = add(L, k, s, true) || any;
743
+ if (!any) rest.push(k);
744
+ });
745
+ if (rest.length) {
746
+ const { lo, hi } = windows(bestChain(cands.slice(), leg.length), leg.length, cap.length);
747
+ for (const k of rest) {
748
+ const L = leg[k];
749
+ let starts = between(index.get(`${prof.tsExact ? L.ts : L.agent}\u0001${L.name}`) || [], lo[k], hi[k]);
750
+ if (starts.length > CANDIDATES_PER_ROW) starts = nearest(starts, k, leg.length, cap.length);
751
+ for (const s of starts) add(L, k, s, false);
752
+ }
753
+ }
754
+ for (const c of bestChain(cands, leg.length)) leg[c.k].match = cap[c.s];
755
+ // Echoes: the 0.18 parser emitted a record re-appended to the transcript (a resume, a sub-agent
756
+ // replay) once per copy, the capture once per uuid. The turns matcher folds those copies into
757
+ // the one captured record (`echo`); a call is one-to-one above, so the extra copies of its call
758
+ // stayed live (2026-10-14, the dev house: 77 pairs in 16 sessions). Where the legacy ts is the
759
+ // record's own time, an unmatched legacy call with the same ts, tool and arguments as a captured
760
+ // call is that call again.
761
+ if (prof.tsExact) {
762
+ for (const L of leg) {
763
+ if (L.match) continue;
764
+ for (const s of index.get(`${L.ts}\u0001${L.name}`) || []) {
765
+ const fit = argsFit(L.args, cap[s].args);
766
+ if (fit === 'exact' || fit === 'contained') { L.match = cap[s]; L.echo = true; break; }
767
+ }
768
+ }
769
+ }
770
+ // Phantoms: the 0.18 conversion parsed '[tool-call: X]' wherever a text held it, prose included
771
+ // (a session describing the marker, another house's transcript quoted), into a call without
772
+ // arguments that the transcript never made (2026-10-23, the dev house: 14 calls in 4 Claude
773
+ // Code sessions). A `{}` call is retired, not matched (`phantom`), when its message matched into
774
+ // a run that holds no tool_call of that name and holds the marker as prose. After a tombstone
775
+ // cleanup the message's own legacy row is gone with its tombstone; where the legacy ts is the
776
+ // record's own, the run is then the captured records at the call's ts, of any agent: a 0.18 parse
777
+ // before per-agent numbering shipped a sub-agent's calls with no agent id, and the turns matcher
778
+ // keys these by ts alone too.
779
+ const known = new Set(legacyTurns.map((L) => String(L.row.message_id)));
780
+ const atTs = new Map();
781
+ if (prof.tsExact) for (const a of atoms) { if (!atTs.has(a.ts)) atTs.set(a.ts, new Set()); atTs.get(a.ts).add(a.mid); }
782
+ for (const L of leg) {
783
+ if (L.match || !L.args.o || Object.keys(L.args.o).length) continue;
784
+ const mid = String(L.row.message_id);
785
+ const run = runOf.get(mid) || (prof.tsExact && !known.has(mid) ? [...(atTs.get(L.ts) || [])] : []);
786
+ const mine = run.flatMap((m) => atomsByMid.get(m) || []);
787
+ if (mine.some((a) => a.kind === 'tool_call' && a.call && a.call.name === L.name)) continue;
788
+ const marker = `[tool-call: ${L.name}`;
789
+ if (mine.some((a) => a.kind !== 'tool_call' && a.text.includes(marker))) L.phantom = true;
790
+ }
791
+ return leg;
792
+ }
793
+
794
+ // ── the plan ─────────────────────────────────────────────────────────────────────────────
795
+ function numTurn(r) {
796
+ return {
797
+ ...r, text_len: Number(r.text_len) || 0, input_tokens: Number(r.input_tokens) || 0, output_tokens: Number(r.output_tokens) || 0,
798
+ cache_read_tokens: Number(r.cache_read_tokens) || 0, cache_write_tokens: Number(r.cache_write_tokens) || 0,
799
+ reasoning_tokens: Number(r.reasoning_tokens) || 0, agent_id: String(r.agent_id || ''), model: String(r.model || ''),
800
+ };
801
+ }
802
+ const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true';
803
+
804
+ /** The contribution (contrib JSON, as a files row carries it) of the legacy rows that stay live. */
805
+ function keptContrib(turns, calls) {
806
+ const c = base.newContrib();
807
+ for (const r of turns) base.applyTurn(c, numTurn(r), +1);
808
+ for (const r of calls) base.applyCall(c, { tool_name: String(r.tool_name), is_error: truthy(r.is_error), ts: String(r.ts) }, +1);
809
+ const out = base.serializeContrib(c);
810
+ // What the legacy rows knew about their sub-agents (convert-v3 kept it in extra.agent).
811
+ const byAgent = new Map();
812
+ for (const r of turns) {
813
+ if (!r.agent_id || byAgent.has(r.agent_id)) continue;
814
+ let ag = null;
815
+ try { ag = (JSON.parse(r.extra || '{}') || {}).agent || null; } catch { /* not JSON */ }
816
+ if (ag) byAgent.set(r.agent_id, ag);
817
+ }
818
+ for (const a of out.agents) {
819
+ const ag = byAgent.get(a.agent_id);
820
+ if (ag) { a.type = String(ag.type || ''); a.description = String(ag.description || ''); a.workflow = String(ag.workflow || ''); }
821
+ }
822
+ // A fallback the session builder uses only when the capture has none.
823
+ const first = turns.filter((r) => r.role === 'user' && !r.agent_id && r.kind === 'text' && String(r.text || '').trim())
824
+ .sort((x, y) => cmp([String(x.ts), Number(x.ord) || 0], [String(y.ts), Number(y.ord) || 0]))[0];
825
+ out.meta = {};
826
+ if (first) out.meta.first_prompt = String(first.text).slice(0, 2000);
827
+ const folder = turns.find((r) => r.folder);
828
+ if (folder) out.meta.folder = String(folder.folder);
829
+ out.legacy = {
830
+ turns: turns.length, calls: calls.length,
831
+ ship_turns: turns.filter((r) => isShip(r.message_id)).length, ship_calls: calls.filter((r) => isShip(r.call_id)).length,
832
+ };
833
+ return out;
834
+ }
835
+
836
+ /**
837
+ * Decide every legacy row of ONE session.
838
+ *
839
+ * @param o.harness, o.session_id
840
+ * @param o.legacyTurns every `legacy:` turn of the session — all versions or the newest per key,
841
+ * live AND tombstoned, full rows (every insertable column)
842
+ * @param o.legacyCalls the same for calls
843
+ * @param o.capturedTurns live captured turns (message_id not 'legacy:'), at least: message_id, part,
844
+ * ts, ord, role, kind, text, agent_id, response_id, extra
845
+ * @param o.capturedCalls live captured calls: call_id, message_id, ts, tool_name, args, agent_id
846
+ * @param o.repeats Codex only: codexRepeats of the session's rollouts, the proof `rereport`
847
+ * needs; without it those rows stay live (report.rereport_unproven counts them)
848
+ * @returns {{ tombstones: {turns, calls}, kept: {turns, calls}, contrib, report, deadShip, pairs }}
849
+ */
850
+ function planDedupe(o) {
851
+ const prof = o.profile || profileFor(o.harness);
852
+ const turns = newestPerKey(o.legacyTurns || [], base.turnKey).filter((r) => isLegacy(r.message_id));
853
+ const calls = newestPerKey(o.legacyCalls || [], base.callKey).filter((r) => isLegacy(r.call_id));
854
+ const capT = (o.capturedTurns || []).filter((r) => !isLegacy(r.message_id) && !dead(r));
855
+ const capC = (o.capturedCalls || []).filter((r) => !isLegacy(r.call_id) && !dead(r));
856
+ const shipT = turns.filter((r) => isShip(r.message_id));
857
+ const shipC = calls.filter((r) => isShip(r.call_id));
858
+
859
+ let LT; let LC;
860
+ if (prof.byText) {
861
+ ({ LT, LC } = matchByText(shipT, shipC, capT, capC));
862
+ const rest = LT.filter((L) => !L.match);
863
+ if (rest.length) {
864
+ const used = new Set(LT.filter((L) => L.match).flatMap((L) => L.match.map((a) => `${a.mid}\u0000${a.part}`)));
865
+ const free = capT.filter((r) => !used.has(`${r.message_id}\u0000${Number(r.part_ord) || 0}`));
866
+ const m = matchTurns(o.harness, PIECE_PROFILE, rest.map((L) => L.row), free);
867
+ const got = new Map(m.legacy.filter((x) => x.match && x.match.length).map((x) => [base.turnKey(x.row), x]));
868
+ for (const L of rest) { const x = got.get(base.turnKey(L.row)); if (x) { L.match = x.match.map((a) => ({ mid: a.mid, part: a.part })); L.how = 'pieces'; } }
869
+ }
870
+ }
871
+ else {
872
+ const m = matchTurns(o.harness, prof, shipT, capT, o.repeats ? repeatPool(o.repeats) : null);
873
+ LT = m.legacy;
874
+ LC = matchCalls(prof, shipC, capC, LT, m.atoms);
875
+ }
876
+
877
+ const tomb = { turns: [], calls: [] };
878
+ const matched = { turns: new Set(), calls: new Set() };
879
+ const how = { aligned: 0, echo: 0, activity: 0, rereport: 0, pieces: 0 };
880
+ const unproven = LT.filter((L) => !L.match && L.unproven && !dead(L.row)).length;
881
+ for (const L of LT) {
882
+ if (!L.match) continue;
883
+ matched.turns.add(base.turnKey(L.row));
884
+ if (!dead(L.row)) { tomb.turns.push(base.tombstoneOf('turns', L.row, BigInt(L.row.rev || 0) + 1n)); how[L.how]++; }
885
+ }
886
+ const phantom = new Set();
887
+ for (const L of LC) {
888
+ if (!L.match && !L.phantom) continue;
889
+ (L.match ? matched.calls : phantom).add(base.callKey(L.row));
890
+ if (!dead(L.row)) tomb.calls.push(base.tombstoneOf('calls', L.row, BigInt(L.row.rev || 0) + 1n));
891
+ }
892
+ const kept = {
893
+ turns: turns.filter((r) => !dead(r) && !matched.turns.has(base.turnKey(r))),
894
+ calls: calls.filter((r) => !dead(r) && !matched.calls.has(base.callKey(r)) && !phantom.has(base.callKey(r))),
895
+ };
896
+ const deadShip = shipT.filter(dead).length + shipC.filter(dead).length + tomb.turns.length + tomb.calls.length;
897
+ const count = (rows, f) => rows.filter(f).length;
898
+ const report = {
899
+ harness: o.harness, session_id: o.session_id,
900
+ legacy: { turns: turns.length, calls: calls.length },
901
+ ship: { turns: shipT.length, calls: shipC.length },
902
+ dead_before: { turns: count(shipT, dead), calls: count(shipC, dead) },
903
+ matched: { turns: matched.turns.size, calls: matched.calls.size },
904
+ tombstoned: { turns: tomb.turns.length, calls: tomb.calls.length },
905
+ kept: { turns: count(kept.turns, (r) => isShip(r.message_id)), calls: count(kept.calls, (r) => isShip(r.call_id)) },
906
+ imports: { turns: count(kept.turns, (r) => !isShip(r.message_id)), calls: count(kept.calls, (r) => !isShip(r.call_id)) },
907
+ captured: { turns: capT.length, calls: capC.length },
908
+ how, rereport_unproven: unproven, phantom_calls: phantom.size,
909
+ };
910
+ const pairs = LT.filter((L) => L.match).map((L) => [L.row.message_id, L.match.map((a) => `${a.mid}#${a.part}`)]);
911
+ return { tombstones: tomb, kept, contrib: keptContrib(kept.turns, kept.calls), report, deadShip, pairs };
912
+ }
913
+
914
+ /**
915
+ * The `files` row that carries the live legacy rows' share of the session totals and marks the
916
+ * session deduped. Not a source file: fgen 0, and `upto` a counter (1 + the legacy ship rows
917
+ * tombstoned) — so its ver only rises, and sessions.rev = Σ files.ver rises with it.
918
+ */
919
+ function legacyFilesRow({ harness, session_id, harness_path = '', incarnation = '' }, plan, host) {
920
+ const c = plan.contrib;
921
+ return {
922
+ harness, harness_path, session_id, incarnation, file_key: LEGACY_FILE_KEY, agent_id: '', role: LEGACY_ROLE, agent_type: '', agent_desc: '',
923
+ fgen: 0, upto: plan.deadShip + 1, reparse_from: 0,
924
+ derive_state: JSON.stringify({ v: 1, dedupe: plan.report }),
925
+ adapter_ver: 0, size: 0, inode: 0, anchor_sha: ZERO_SHA, prefix_sha: ZERO_SHA, is_deleted: 0,
926
+ contrib: JSON.stringify(c), first_ts: c.first_ts || EPOCH_TS, last_ts: c.last_ts || EPOCH_TS, host: String(host || ''),
927
+ };
928
+ }
929
+
930
+ // ── doctor: captured + legacy pairs with the same text ───────────────────────────────────
931
+ // What dedupe leaves behind, measured without the matcher: per session, pairs of live rows —
932
+ // one captured, one legacy — of the same role class whose normalised text is identical (the
933
+ // '[subagent] ' tag and a leading '[thinking] ' marker dropped, whitespace collapsed). Pairs
934
+ // count multiplicity: three legacy and two captured rows of one text are two pairs. Only
935
+ // single-piece rows can pair (a folded Codex turn or a multi-block record equals no one row),
936
+ // so this undercounts what it cannot see and never invents a duplicate of a different text.
937
+ // pairs_ship legacy ship rows — 0 after dedupe, except a text the transcript repeats
938
+ // fewer times than the legacy parse did (the transcript lost one)
939
+ // same_ts_pairs ship pairs with the same ts too: 0 after dedupe for harnesses whose legacy
940
+ // rows carry the record's own time (Claude Code)
941
+ // pairs_import legacy import rows: never tombstoned, reported so they are seen
942
+ // call_pairs live legacy ship calls with a captured call of the same tool and arguments
943
+ // (whitespace removed)
944
+ const DUP_KINDS = ['text', 'thinking', 'context'];
945
+ function dupText(text) {
946
+ let t = stripTag(text);
947
+ if (t.startsWith('[thinking] ')) t = t.slice(11);
948
+ return norm(t);
949
+ }
950
+ /** The doctor on rows already read (live rows of one or more sessions). */
951
+ function findDuplicates(turns, calls = []) {
952
+ const per = new Map();
953
+ const S = (r) => { const k = `${r.harness}\u0000${r.session_id}`; if (!per.has(k)) per.set(k, { harness: r.harness, session_id: r.session_id, t: new Map(), ts: new Map(), c: new Map() }); return per.get(k); };
954
+ const bump = (m, k, f) => { const v = m.get(k) || { l: 0, i: 0, c: 0 }; v[f]++; m.set(k, v); };
955
+ for (const r of turns) {
956
+ if (dead(r) || !DUP_KINDS.includes(String(r.kind))) continue;
957
+ const x = dupText(r.text);
958
+ if (!x) continue;
959
+ const f = !isLegacy(r.message_id) ? 'c' : (isShip(r.message_id) ? 'l' : 'i');
960
+ const s = S(r);
961
+ bump(s.t, `${roleClass(r.role)}\u0000${x}`, f);
962
+ if (f !== 'i') bump(s.ts, `${roleClass(r.role)}\u0000${r.ts}\u0000${x}`, f);
963
+ }
964
+ for (const r of calls) {
965
+ if (dead(r) || (isLegacy(r.call_id) && !isShip(r.call_id))) continue;
966
+ bump(S(r).c, `${r.tool_name}\u0000${String(r.args ?? '').replace(/\s+/g, '')}`, isLegacy(r.call_id) ? 'l' : 'c');
967
+ }
968
+ const out = [];
969
+ for (const s of per.values()) {
970
+ const sum = (m, f) => [...m.values()].reduce((a, v) => a + Math.min(v[f], v.c), 0);
971
+ const row = { harness: s.harness, session_id: s.session_id, pairs_ship: sum(s.t, 'l'), same_ts_pairs: sum(s.ts, 'l'), pairs_import: sum(s.t, 'i'), call_pairs: sum(s.c, 'l') };
972
+ if (row.pairs_ship || row.same_ts_pairs || row.pairs_import || row.call_pairs) out.push(row);
973
+ }
974
+ return out.sort((a, b) => cmp([a.harness, a.session_id], [b.harness, b.session_id]));
975
+ }
976
+
977
+ function lit(v) { return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; }
978
+ /**
979
+ * The same doctor in the house: `q.rows(sql, settings)`, N = { turns, calls } (schema-v3 v3Names).
980
+ * `session` ({ harness, session_id }) narrows it to one. Reads with FINAL and is_deleted = 0.
981
+ * @returns {{ sessions: [{ harness, session_id, pairs_ship, same_ts_pairs, pairs_import, call_pairs }], total }}
982
+ */
983
+ async function duplicates(q, N, { session = null } = {}) {
984
+ const only = session ? ` AND harness = ${lit(session.harness)} AND session_id = ${lit(session.session_id)}` : '';
985
+ const t = `if(startsWith(text, '[subagent] '), substring(text, 12), text)`;
986
+ const x = `trimBoth(replaceRegexpAll(if(startsWith(${t}, '[thinking] '), substring(${t}, 12), ${t}), '\\\\s+', ' '))`;
987
+ const settings = { final: 0, join_use_nulls: 0, output_format_json_quote_64bit_integers: 0 };
988
+ const rows = await q.rows(`
989
+ SELECT h AS harness, s AS session_id, sum(pl) AS pairs_ship, sum(pim) AS pairs_import, sum(pt) AS same_ts_pairs, sum(pc) AS call_pairs
990
+ FROM (
991
+ SELECT h, s, least(countIf(o = 'l'), countIf(o = 'c')) AS pl, least(countIf(o = 'i'), countIf(o = 'c')) AS pim, 0 AS pt, 0 AS pc
992
+ FROM (SELECT toString(harness) AS h, session_id AS s, multiIf(NOT startsWith(message_id, 'legacy:'), 'c', startsWith(message_id, 'legacy:ship:'), 'l', 'i') AS o,
993
+ multiIf(role = 'user', 'user', role = 'system', 'system', 'assistant') AS rc, cityHash64(${x}) AS th
994
+ FROM ${N.turns} FINAL WHERE is_deleted = 0 AND removed = 0 AND kind IN ('text', 'thinking', 'context') AND ${x} != ''${only})
995
+ GROUP BY h, s, rc, th
996
+ UNION ALL
997
+ SELECT h, s, 0, 0, least(countIf(o = 'l'), countIf(o = 'c')), 0
998
+ FROM (SELECT toString(harness) AS h, session_id AS s, if(startsWith(message_id, 'legacy:'), 'l', 'c') AS o,
999
+ multiIf(role = 'user', 'user', role = 'system', 'system', 'assistant') AS rc, ts, cityHash64(${x}) AS th
1000
+ FROM ${N.turns} FINAL WHERE is_deleted = 0 AND removed = 0 AND kind IN ('text', 'thinking', 'context') AND ${x} != ''
1001
+ AND (NOT startsWith(message_id, 'legacy:') OR startsWith(message_id, 'legacy:ship:'))${only})
1002
+ GROUP BY h, s, rc, ts, th
1003
+ UNION ALL
1004
+ SELECT h, s, 0, 0, 0, least(countIf(o = 'l'), countIf(o = 'c'))
1005
+ FROM (SELECT toString(harness) AS h, session_id AS s, if(startsWith(call_id, 'legacy:'), 'l', 'c') AS o,
1006
+ cityHash64(toString(tool_name), replaceRegexpAll(args, '\\\\s+', '')) AS th
1007
+ FROM ${N.calls} FINAL WHERE is_deleted = 0 AND removed = 0 AND (NOT startsWith(call_id, 'legacy:') OR startsWith(call_id, 'legacy:ship:'))${only})
1008
+ GROUP BY h, s, th
1009
+ )
1010
+ GROUP BY h, s
1011
+ HAVING pairs_ship + pairs_import + same_ts_pairs + call_pairs > 0
1012
+ ORDER BY h, s`, settings);
1013
+ const sessions = rows.map((r) => ({
1014
+ harness: r.harness, session_id: r.session_id, pairs_ship: Number(r.pairs_ship), same_ts_pairs: Number(r.same_ts_pairs),
1015
+ pairs_import: Number(r.pairs_import), call_pairs: Number(r.call_pairs),
1016
+ }));
1017
+ const total = { sessions: sessions.length, pairs_ship: 0, same_ts_pairs: 0, pairs_import: 0, call_pairs: 0 };
1018
+ for (const s of sessions) for (const k of ['pairs_ship', 'same_ts_pairs', 'pairs_import', 'call_pairs']) total[k] += s[k];
1019
+ return { sessions, total };
1020
+ }
1021
+
1022
+ module.exports = {
1023
+ LEGACY_FILE_KEY, LEGACY_ROLE, PROFILES, profileFor,
1024
+ planDedupe, legacyFilesRow, keptContrib, findDuplicates, duplicates,
1025
+ // exported for the unit gate
1026
+ legacyPieces, argsFit, bestChain, newestPerKey, matchTurns, dupText, codexRepeats,
1027
+ };