cc-viewer 1.6.348 → 1.7.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 (222) hide show
  1. package/README.md +13 -1
  2. package/cli.js +116 -37
  3. package/dist/assets/App-gUSmU6Ny.js +2 -0
  4. package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-DAR39A3S.js} +1 -1
  5. package/dist/assets/Mobile-qfRsRth0.js +1 -0
  6. package/dist/assets/{_baseUniq-CG81mgN_.js → _baseUniq-D9_E5ZDp.js} +1 -1
  7. package/dist/assets/{arc-C161J4f2.js → arc-D4iFrlhb.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-BmbDMdn9.js → architectureDiagram-Q4EWVU46-f1neo7hg.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-DXpMvdxd.js → blockDiagram-DXYQGD6D-DWi9e9tG.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-8Mypg0IU.js → c4Diagram-AHTNJAMY-DAdj49A2.js} +1 -1
  11. package/dist/assets/{channel-B2khdmvc.js → channel-B3VthT0m.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-I61r1g6z.js → chunk-4BX2VUAB-BrPcAHV3.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-VFPlRE5E.js → chunk-4TB4RGXK-Bvopc1V9.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-DvCamg_6.js → chunk-55IACEB6-Juw-7Z3i.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-Dj4Q8RwR.js → chunk-EDXVE4YY-DVYP9tpB.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-_bgQuuJh.js → chunk-FMBD7UC4-VQ7VOXl3.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-3to38LyN.js → chunk-OYMX7WX6-F8Rj4ZUZ.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-Zl26wKKz.js → chunk-QZHKN3VN-Dn7K3yi9.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-RLYPrlh0.js → chunk-YZCP3GAM-UjO-KTsn.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +1 -0
  22. package/dist/assets/clone-BQtvy9Ae.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-CsYOjJ0V.js → cose-bilkent-S5V4N54A-BDhNj8rq.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-C3YXXaFU.js → dagre-KV5264BT-BzdcDuyt.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-DUCOg1ld.js → diagram-5BDNPKRD-DP_SX65N.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-BtlLl_zX.js → diagram-G4DWMVQ6-B-pBFw8Q.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-1Qp9xfOk.js → diagram-MMDJMWI5-6XSfN1eN.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-Cj7VMp3L.js → diagram-TYMM5635-DJvGxvpE.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-CVZVn541.js → erDiagram-SMLLAGMA-CpqdpGYj.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-CiU9Fkkv.js → flowDiagram-DWJPFMVM-C9m7wjwI.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-aRaG97ID.js → ganttDiagram-T4ZO3ILL-CU0XOfY3.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Cr2LJlvX.js → gitGraphDiagram-UUTBAWPF-ipyGULnP.js} +1 -1
  33. package/dist/assets/{graph-B59lDyMn.js → graph-BR2KmES9.js} +1 -1
  34. package/dist/assets/{index-C1ptnCpq.js → index--wgRW_OB.js} +1 -1
  35. package/dist/assets/index-B51Ci0GL.js +2 -0
  36. package/dist/assets/{index-CRAWA8PC.js → index-C-be29ey.js} +1 -1
  37. package/dist/assets/{index-CSM1Kw8l.js → index-C16fiVhv.js} +1 -1
  38. package/dist/assets/{index-Ddk-kg22.js → index-D5YruOKD.js} +1 -1
  39. package/dist/assets/{index-BnhNIjbj.js → index-D5tSeMU7.js} +1 -1
  40. package/dist/assets/{index-vFEQq-NB.js → index-DuZQ6FqR.js} +1 -1
  41. package/dist/assets/{index-CO2hZ1s4.js → index-OWzSX2T8.js} +1 -1
  42. package/dist/assets/{infoDiagram-42DDH7IO-CnO22bw8.js → infoDiagram-42DDH7IO-BQWQjfFX.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-ByNzpScH.js → ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-yaVaCWPq.js → journeyDiagram-VCZTEJTY-CMbB_4G9.js} +1 -1
  45. package/dist/assets/{jszip.min-BdVFP3OW.js → jszip.min-B6k2t9Ch.js} +1 -1
  46. package/dist/assets/{kanban-definition-6JOO6SKY-RIPR_RQ3.js → kanban-definition-6JOO6SKY-CImdlKN3.js} +1 -1
  47. package/dist/assets/{layout-0rzel0v_.js → layout-DQl4aw09.js} +1 -1
  48. package/dist/assets/{linear-sktN0tB6.js → linear-CLD3aPlK.js} +1 -1
  49. package/dist/assets/mermaid.core-CN35dvKJ.js +7 -0
  50. package/dist/assets/{min-rspGOkz_.js → min-_D86henJ.js} +1 -1
  51. package/dist/assets/{mindmap-definition-QFDTVHPH-BsVzZzTz.js → mindmap-definition-QFDTVHPH-2JVcu1nk.js} +1 -1
  52. package/dist/assets/{pieDiagram-DEJITSTG-CsoWsw4f.js → pieDiagram-DEJITSTG-Dik-m5u7.js} +1 -1
  53. package/dist/assets/{quadrantDiagram-34T5L4WZ-CTMKuCAI.js → quadrantDiagram-34T5L4WZ-Bm5l40Jq.js} +1 -1
  54. package/dist/assets/{requirementDiagram-MS252O5E-C67256te.js → requirementDiagram-MS252O5E-Ck9w5rTj.js} +1 -1
  55. package/dist/assets/{sankeyDiagram-XADWPNL6-X8Y7KnW0.js → sankeyDiagram-XADWPNL6-BbGVCFEf.js} +1 -1
  56. package/dist/assets/{seqResourceLoaders-B5Zy_CC_.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
  57. package/dist/assets/seqResourceLoaders-DZJxMMaR.js +2 -0
  58. package/dist/assets/{sequenceDiagram-FGHM5R23-Cils0TAp.js → sequenceDiagram-FGHM5R23-vjvEdksk.js} +1 -1
  59. package/dist/assets/{stateDiagram-FHFEXIEX-DFnohvpG.js → stateDiagram-FHFEXIEX-DTqMoiB2.js} +1 -1
  60. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BTX_4o81.js → stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js} +1 -1
  61. package/dist/assets/{timeline-definition-GMOUNBTQ-DrIYFrzZ.js → timeline-definition-GMOUNBTQ-Cw8O-52d.js} +1 -1
  62. package/dist/assets/{vendor-antd-D5HmSDSg.js → vendor-antd-DI7JL-mE.js} +1 -1
  63. package/dist/assets/{vendor-codemirror-C5yuj7ze.js → vendor-codemirror-B9c49dtM.js} +1 -1
  64. package/dist/assets/{vendor-mdxeditor-BcEooTvb.js → vendor-mdxeditor-B6cpBtIE.js} +2 -2
  65. package/dist/assets/{vendor-qrcode-d9LCn0-9.js → vendor-qrcode-Dn90-s-u.js} +1 -1
  66. package/dist/assets/{vendor-virtuoso-K9m4MN6a.js → vendor-virtuoso-CpdSKD_6.js} +1 -1
  67. package/dist/assets/{vennDiagram-DHZGUBPP-lRqBYTr9.js → vennDiagram-DHZGUBPP-DCuqymb7.js} +1 -1
  68. package/dist/assets/{wardley-RL74JXVD-e8YfxpOe.js → wardley-RL74JXVD-BJ20wJUU.js} +1 -1
  69. package/dist/assets/{wardleyDiagram-NUSXRM2D-Bxrg774h.js → wardleyDiagram-NUSXRM2D-Vz6US3qt.js} +1 -1
  70. package/dist/assets/{xychartDiagram-5P7HB3ND-DWhZqhq8.js → xychartDiagram-5P7HB3ND-CzkLKrmi.js} +1 -1
  71. package/dist/index.html +4 -4
  72. package/findcc.js +4 -3
  73. package/package.json +3 -1
  74. package/server/i18n.js +21 -141
  75. package/server/interceptor.js +211 -403
  76. package/server/lib/async-write-queue.js +12 -1
  77. package/server/lib/delta-reconstructor.js +3 -3
  78. package/server/lib/error-report.js +45 -0
  79. package/server/lib/im-log-watcher.js +68 -8
  80. package/server/lib/interceptor-core.js +19 -174
  81. package/server/lib/log-management.js +214 -247
  82. package/server/lib/log-stream.js +59 -108
  83. package/server/lib/log-watcher.js +62 -274
  84. package/server/lib/log-zip.js +148 -0
  85. package/server/lib/session-boundary.js +266 -0
  86. package/server/lib/session-pin-store.js +10 -19
  87. package/server/lib/stats-worker.js +138 -267
  88. package/server/lib/user-prompt-extract.js +250 -0
  89. package/server/lib/v2/adapter.js +1124 -0
  90. package/server/lib/v2/blob-store.js +44 -0
  91. package/server/lib/v2/conversation-store.js +217 -0
  92. package/server/lib/v2/convert-manager.js +79 -0
  93. package/server/lib/v2/convert-worker.js +42 -0
  94. package/server/lib/v2/convert.js +409 -0
  95. package/server/lib/v2/identity.js +204 -0
  96. package/server/lib/v2/journal.js +62 -0
  97. package/server/lib/v2/layout.js +167 -0
  98. package/server/lib/v2/live-feed.js +558 -0
  99. package/server/lib/v2/meta-rows.js +360 -0
  100. package/server/lib/v2/migrate-prompt.js +54 -0
  101. package/server/lib/v2/replay.js +191 -0
  102. package/server/lib/v2/session-select.js +222 -0
  103. package/server/lib/v2/singleflight.js +104 -0
  104. package/server/lib/v2/v2-writer.js +528 -0
  105. package/server/lib/v2/verify.js +233 -0
  106. package/server/lib/wire-compress.js +167 -0
  107. package/server/routes/events.js +120 -150
  108. package/server/routes/files-fs.js +4 -2
  109. package/server/routes/im.js +11 -2
  110. package/server/routes/logs.js +209 -164
  111. package/server/routes/preferences.js +0 -3
  112. package/server/routes/project-meta.js +2 -3
  113. package/server/routes/session-pin.js +7 -8
  114. package/server/routes/v2.js +60 -0
  115. package/server/routes/workspaces.js +50 -14
  116. package/server/server.js +70 -66
  117. package/server/workspace-registry.js +24 -4
  118. package/src/utils/apiUrl.js +26 -0
  119. package/src/utils/askFallback.js +21 -0
  120. package/src/utils/askOptionDesc.js +55 -0
  121. package/src/utils/askPortalMatcher.js +54 -0
  122. package/src/utils/autoApproveOptions.js +27 -0
  123. package/src/utils/avatarAnimationPostPass.js +49 -0
  124. package/src/utils/builtinPresets.js +18 -0
  125. package/src/utils/clearCheckpoint.js +7 -0
  126. package/src/utils/commandValidator.js +45 -0
  127. package/src/utils/contentFilter.js +620 -0
  128. package/src/utils/contextRaw.js +18 -0
  129. package/src/utils/contextSidebarNavigation.js +16 -0
  130. package/src/utils/contextTurns.js +117 -0
  131. package/src/utils/displayScaleHelper.js +47 -0
  132. package/src/utils/effectiveModel.js +13 -0
  133. package/src/utils/entry-slim.js +563 -0
  134. package/src/utils/entryCache.js +144 -0
  135. package/src/utils/errorReport.js +6 -0
  136. package/src/utils/fileExpandedPathsStorage.js +70 -0
  137. package/src/utils/fileIcons.jsx +36 -0
  138. package/src/utils/fileOpen.js +37 -0
  139. package/src/utils/formatters.js +48 -0
  140. package/src/utils/gitApi.js +50 -0
  141. package/src/utils/gitTreeBuilder.js +19 -0
  142. package/src/utils/helpers.js +846 -0
  143. package/src/utils/identityHeal.js +99 -0
  144. package/src/utils/imConnState.js +59 -0
  145. package/src/utils/imOrigin.js +24 -0
  146. package/src/utils/imTr.js +7 -0
  147. package/src/utils/imageCompress.js +83 -0
  148. package/src/utils/imageDownscale.js +28 -0
  149. package/src/utils/imageResize.js +112 -0
  150. package/src/utils/ingestPipeline.js +47 -0
  151. package/src/utils/lazyWithReload.js +53 -0
  152. package/src/utils/markdown.js +30 -0
  153. package/src/utils/markdownIncremental.js +39 -0
  154. package/src/utils/markdownProfiler.js +113 -0
  155. package/src/utils/mdExtensionDetect.js +64 -0
  156. package/src/utils/memoryLinkParser.js +44 -0
  157. package/src/utils/modalMask.js +15 -0
  158. package/src/utils/monotime.js +6 -0
  159. package/src/utils/pinnedMenu.js +55 -0
  160. package/src/utils/presetShortcuts.js +14 -0
  161. package/src/utils/projectAlias.js +135 -0
  162. package/src/utils/promptClassifier.js +112 -0
  163. package/src/utils/promptDetect.js +196 -0
  164. package/src/utils/promptNav.js +60 -0
  165. package/src/utils/ptyChunkBuilder.js +251 -0
  166. package/src/utils/quickMenuHoverIntent.js +35 -0
  167. package/src/utils/rateLimitParser.js +119 -0
  168. package/src/utils/readResultPool.js +106 -0
  169. package/src/utils/refreshCachedItemProp.js +30 -0
  170. package/src/utils/requestType.js +208 -0
  171. package/src/utils/resizeCalc.js +21 -0
  172. package/src/utils/resolveLocalized.js +17 -0
  173. package/src/utils/searchApi.js +47 -0
  174. package/src/utils/searchReplace.js +51 -0
  175. package/src/utils/seqResourceLoaders.js +79 -0
  176. package/src/utils/sessionManager.js +471 -0
  177. package/src/utils/sessionMerge.js +180 -0
  178. package/src/utils/skillModalController.js +111 -0
  179. package/src/utils/skillsParser.js +116 -0
  180. package/src/utils/slashCommandLabels.js +80 -0
  181. package/src/utils/splitDragCalc.js +73 -0
  182. package/src/utils/stickyBottomController.js +643 -0
  183. package/src/utils/svgSanitize.js +48 -0
  184. package/src/utils/systemTags.js +47 -0
  185. package/src/utils/tClaude.js +20 -0
  186. package/src/utils/teamModalBuilder.js +351 -0
  187. package/src/utils/teamSessionParser.js +190 -0
  188. package/src/utils/teammateAvatars.js +194 -0
  189. package/src/utils/teammateDetector.js +121 -0
  190. package/src/utils/termDiag.js +114 -0
  191. package/src/utils/terminalClipboard.js +117 -0
  192. package/src/utils/terminalWriteQueue.js +398 -0
  193. package/src/utils/thinkingMerge.js +49 -0
  194. package/src/utils/toolCatalog.js +29 -0
  195. package/src/utils/toolResultBuilder.js +393 -0
  196. package/src/utils/toolResultClassifier.js +16 -0
  197. package/src/utils/toolResultCore.js +167 -0
  198. package/src/utils/toolsDiff.js +44 -0
  199. package/src/utils/toolsXmlFormatter.js +3 -0
  200. package/src/utils/ultraplanController.js +109 -0
  201. package/src/utils/ultraplanExperts.js +86 -0
  202. package/src/utils/ultraplanTemplates.js +154 -0
  203. package/src/utils/userImageRefs.js +49 -0
  204. package/src/utils/v3Assembler.js +112 -0
  205. package/src/utils/v3Rows.js +48 -0
  206. package/src/utils/voicePackPlayer.js +233 -0
  207. package/src/utils/webSearchGrouping.js +111 -0
  208. package/src/utils/workflowFormat.js +52 -0
  209. package/src/utils/workflowRuns.js +118 -0
  210. package/src/utils/workflowStore.js +94 -0
  211. package/dist/assets/App-VTo4NVdo.js +0 -2
  212. package/dist/assets/Mobile-CcmYvMm_.js +0 -1
  213. package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
  214. package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
  215. package/dist/assets/clone-md-Mc1-T.js +0 -1
  216. package/dist/assets/index-DCo650PG.js +0 -2
  217. package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
  218. package/dist/assets/seqResourceLoaders-BVozq68i.js +0 -2
  219. package/server/lib/instance-registry.js +0 -57
  220. package/server/lib/jsonl-archive.js +0 -180
  221. package/server/lib/teammate-detect.js +0 -32
  222. package/server/lib/zip-safety.js +0 -68
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Wire v3 (V3.S2) — request-list metadata rows.
3
+ *
4
+ * A row is a journal fold (req + done by seq) plus two body-derived fields
5
+ * (typeTag, cacheLoss) computed in a bounded streaming Pass B over exactly the
6
+ * windowed members: bodies are materialized one at a time, classified, and
7
+ * dropped — the wire carries rows only (~1.3KB each vs full entries).
8
+ *
9
+ * Two DELIBERATE classification divergences from the legacy full-entry list
10
+ * (both more correct; pinned in test/v2-meta-rows.test.js, declared in
11
+ * history.md):
12
+ * - membership is the journal fold — a SUPERSET of the synthesizer's output
13
+ * (conv-gapped crash-orphans get a row; their detail fetch answers 404 and
14
+ * typeTag stays journal-derived);
15
+ * - mainAgent is `kind==='main' && !meta.leader` — the isMain semantics the
16
+ * mainAgentRing already pinned, dropping "main-looking" sub/misc bodies.
17
+ */
18
+ import { readFileSync, readdirSync } from 'node:fs';
19
+ import { join, basename } from 'node:path';
20
+ import { readJsonlTolerant } from './replay.js';
21
+ import { iterateV2Items, findTeammateSessionDirs } from './adapter.js';
22
+ import { SingleFlight } from './singleflight.js';
23
+ import { reportSwallowed } from '../error-report.js';
24
+ import { classifyRequest } from '../../../src/utils/requestType.js';
25
+
26
+ // KEEP IN SYNC: server/lib/v2/adapter.js itemKey — (sessionId, seq) identity.
27
+ const itemKey = (sessionId, seq) => `${sessionId}\x00${seq}`;
28
+
29
+ const CACHE_TTL_MS = 5 * 60 * 1000;
30
+
31
+ /** KEEP IN SYNC: src/utils/helpers.js stripPrivateKeys. */
32
+ function stripPrivateKeys(obj) {
33
+ if (Array.isArray(obj)) return obj.map(stripPrivateKeys);
34
+ if (obj && typeof obj === 'object') {
35
+ const result = {};
36
+ for (const key of Object.keys(obj)) {
37
+ if (key.startsWith('_')) continue;
38
+ result[key] = stripPrivateKeys(obj[key]);
39
+ }
40
+ return result;
41
+ }
42
+ return obj;
43
+ }
44
+
45
+ /**
46
+ * KEEP IN SYNC: src/utils/helpers.js _computeCacheLoss (that module cannot be
47
+ * imported server-side — SVG/i18n dependency chain). One deliberate fix: the
48
+ * client computes the ttl gap as `stringTs - stringTs` (NaN, so its ttl branch
49
+ * never fires); here the timestamps are parsed — pinned in the unit test.
50
+ */
51
+ export function computeCacheLoss(prevEntry, currEntry) {
52
+ const gap = Date.parse(currEntry.timestamp) - Date.parse(prevEntry.timestamp);
53
+ if (gap > CACHE_TTL_MS) return { reason: 'ttl', reasons: ['ttl'] };
54
+ const prev = stripPrivateKeys(prevEntry.body);
55
+ const curr = stripPrivateKeys(currEntry.body);
56
+ if (!prev || !curr) return { reason: 'key_change', reasons: ['key_change'] };
57
+ const reasons = [];
58
+ if (prev.model !== curr.model) reasons.push('model_change');
59
+ if (JSON.stringify(prev.system) !== JSON.stringify(curr.system)) reasons.push('system_change');
60
+ if (JSON.stringify(prev.tools) !== JSON.stringify(curr.tools)) reasons.push('tools_change');
61
+ const prevMsgs = prev.messages || [];
62
+ const currMsgs = curr.messages || [];
63
+ if (currMsgs.length < prevMsgs.length) {
64
+ reasons.push('msg_truncated');
65
+ } else {
66
+ const prefixLen = Math.min(prevMsgs.length, currMsgs.length);
67
+ let prefixMatch = true;
68
+ for (let j = 0; j < prefixLen; j++) {
69
+ if (JSON.stringify(prevMsgs[j]) !== JSON.stringify(currMsgs[j])) { prefixMatch = false; break; }
70
+ }
71
+ if (!prefixMatch) reasons.push('msg_modified');
72
+ }
73
+ if (reasons.length === 0) reasons.push('key_change');
74
+ return { reason: reasons[0], reasons };
75
+ }
76
+
77
+ function readMeta(dir) {
78
+ try { return JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { return null; }
79
+ }
80
+
81
+ /** Fold one session dir's journal into row skeletons (no bodies touched). */
82
+ function foldDir(dir) {
83
+ const meta = readMeta(dir);
84
+ const isTeammateDir = !!(meta && meta.leader);
85
+ const sessionId = (meta && meta.sessionId) || basename(dir);
86
+ const reqs = new Map();
87
+ const dones = new Map();
88
+ for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
89
+ if (line.ph === 'req' && !reqs.has(line.seq)) reqs.set(line.seq, line);
90
+ else if (line.ph === 'done' && !dones.has(line.seq)) dones.set(line.seq, line);
91
+ }
92
+ const rows = [];
93
+ for (const [seq, r] of reqs) {
94
+ const d = dones.get(seq);
95
+ const usage = d && d.usage ? {
96
+ input_tokens: d.usage.in || 0,
97
+ output_tokens: d.usage.out || 0,
98
+ cache_read_input_tokens: d.usage.cr || 0,
99
+ cache_creation_input_tokens: d.usage.cw || 0,
100
+ } : null;
101
+ rows.push({
102
+ seq,
103
+ sessionId,
104
+ timestamp: r.ts || '',
105
+ url: r.url || '',
106
+ method: r.method || 'POST',
107
+ // V3.S5 assembler inputs: which conv channel this request's messages
108
+ // live in, and whether its event was a snapshot (checkpoint marker).
109
+ conv: r.conv,
110
+ evt: r.evt,
111
+ kind: r.kind || 'misc',
112
+ mainAgent: r.kind === 'main' && !isTeammateDir,
113
+ // v1 entry contract (adapter.js:427): teammate = agentName string | true
114
+ teammate: isTeammateDir ? ((meta.leader && meta.leader.agentName) || true) : undefined,
115
+ model: r.model,
116
+ proxyUrl: r.proxy && r.proxy.url ? r.proxy.url : undefined,
117
+ status: d && typeof d.http === 'number' ? d.http : undefined,
118
+ duration: d && typeof d.dur === 'number' ? d.dur : undefined,
119
+ usage,
120
+ inProgress: !d,
121
+ typeTag: null, // Pass B
122
+ cacheLoss: null, // Pass B
123
+ });
124
+ }
125
+ return rows;
126
+ }
127
+
128
+ /**
129
+ * Read the request-list metadata rows for a session (leader + folded
130
+ * teammates), windowed like the entry list: dedup timestamp|url last-wins →
131
+ * `before` filter → tail-`limit`. Pass B streams the window members' bodies
132
+ * once to attach typeTag/cacheLoss.
133
+ *
134
+ * @param {string} sessionDir
135
+ * @param {{limit?: number, before?: string|null, since?: string|null, passB?: boolean}} [opts] -
136
+ * passB:false skips body classification (live increments send rows first
137
+ * and re-send corrections once Pass B / the next request lands);
138
+ * `since` (review P1-2) scopes an incremental reconnect to the delta window
139
+ * — without it every mobile reconnect re-transmits the full session
140
+ * @returns {Promise<{rows: object[], totalCount: number, hasMore: boolean, oldestTimestamp: string}>}
141
+ */
142
+ export async function readV2RequestsMeta(sessionDir, opts = {}) {
143
+ const limit = opts.limit || 0;
144
+ const before = opts.before || null;
145
+ const since = opts.since || null;
146
+ const meta = readMeta(sessionDir);
147
+ const leaderUuid = (meta && meta.sessionId) || basename(sessionDir);
148
+ const dirs = [sessionDir];
149
+ try { dirs.push(...findTeammateSessionDirs(sessionDir, leaderUuid).map((t) => t.dir)); } catch { /* leaderless */ } // items are {dir, leader}
150
+
151
+ // k-way ordering parity: same (ts, sessionId, seq) comparator as iterateV2Items
152
+ const all = dirs.flatMap(foldDir);
153
+ all.sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : a.seq - b.seq));
154
+
155
+ // dedup timestamp|url last-wins (nokey rows key on seq — stable, unlike array index)
156
+ const dedup = new Map();
157
+ for (const row of all) {
158
+ const key = row.timestamp && row.url ? `${row.timestamp}|${row.url}` : `__nokey_${row.sessionId}_${row.seq}`;
159
+ dedup.set(key, row);
160
+ }
161
+ let rows = [...dedup.values()];
162
+ if (before) rows = rows.filter((r) => r.timestamp && r.timestamp < before);
163
+ const totalCount = rows.length;
164
+ let hasMore = false;
165
+ if (limit > 0 && rows.length > limit) {
166
+ hasMore = true;
167
+ rows = rows.slice(rows.length - limit);
168
+ }
169
+ const oldestTimestamp = rows.length > 0 ? rows[0].timestamp : '';
170
+ // Emission-time since filter (mirrors selectV2Window: applied after the
171
+ // window selection; nokey rows always resend — the legacy contract).
172
+ if (since) rows = rows.filter((r) => !r.timestamp || r.timestamp >= since);
173
+
174
+ if (opts.passB !== false && rows.length > 0) {
175
+ await attachBodyFields(sessionDir, rows);
176
+ }
177
+ return { rows, totalCount, hasMore, oldestTimestamp };
178
+ }
179
+
180
+ // In-flight coalescing for the v3 cold reads (review P2-b): a reconnect storm
181
+ // is N tabs hitting the same sessionDir at once — without this each ran its
182
+ // own full journal fold + Pass B + conv-file reads. cached:false — /events is
183
+ // a live-attach caller (S10b F5b: a stale TTL window would widen the
184
+ // cold-load→attach broadcast-loss gap), so callers only JOIN in-flight runs.
185
+ const _coldBundleFlight = new SingleFlight({ ttlMs: 500 });
186
+
187
+ /**
188
+ * The complete v3 cold read for one /events connection: windowed rows +
189
+ * native conv/responses payloads, single-flighted per (dir, window).
190
+ */
191
+ export function readV2ColdBundle(sessionDir, { limit = 0, since = null } = {}) {
192
+ const key = `bundle:${sessionDir}|${limit}|${since || ''}`;
193
+ return _coldBundleFlight.run(key, async () => {
194
+ const meta = await readV2RequestsMeta(sessionDir, { limit, since });
195
+ const native = await readV2NativeCold(sessionDir, meta.rows);
196
+ return { meta, native };
197
+ }, { cached: false });
198
+ }
199
+
200
+ /**
201
+ * Wire v3 (V3.S4) cold native lines for the flagged client's assembler:
202
+ * per conv channel, raw lines from the LAST snapshot at-or-before the
203
+ * window's oldest seq (per session) through the end; responses lines for
204
+ * exactly the window member seqs. Lines are forwarded verbatim (string
205
+ * concat, no parse/stringify round trip beyond seq/t sniffing).
206
+ *
207
+ * ASYNC with event-loop yields (review P1-4): a large session's conv files
208
+ * total tens of MB — reading and snapshot-scanning them in one synchronous
209
+ * stretch blocks every connected client for the duration of the cold load.
210
+ *
211
+ * @param {string} sessionDir - leader dir (teammate dirs folded like the rows)
212
+ * @param {object[]} rows - the window rows (readV2RequestsMeta output)
213
+ * @returns {Promise<{convPayloads: string[], respPayloads: string[]}>}
214
+ * pre-serialized JSON payloads for `v3_conv` / `v3_resp` SSE frames
215
+ */
216
+ export async function readV2NativeCold(sessionDir, rows) {
217
+ const yieldLoop = () => new Promise((resolve) => setImmediate(resolve));
218
+ const minSeqBySession = new Map();
219
+ const memberSeqsBySession = new Map();
220
+ for (const r of rows) {
221
+ const cur = minSeqBySession.get(r.sessionId);
222
+ if (cur === undefined || r.seq < cur) minSeqBySession.set(r.sessionId, r.seq);
223
+ let set = memberSeqsBySession.get(r.sessionId);
224
+ if (!set) { set = new Set(); memberSeqsBySession.set(r.sessionId, set); }
225
+ set.add(r.seq);
226
+ }
227
+ const meta = readMeta(sessionDir);
228
+ const leaderUuid = (meta && meta.sessionId) || basename(sessionDir);
229
+ const dirs = [sessionDir];
230
+ try { dirs.push(...findTeammateSessionDirs(sessionDir, leaderUuid).map((t) => t.dir)); } catch { /* leaderless */ } // items are {dir, leader}
231
+
232
+ const convPayloads = [];
233
+ const respPayloads = [];
234
+ for (const dir of dirs) {
235
+ const dirMeta = readMeta(dir);
236
+ const sessionId = (dirMeta && dirMeta.sessionId) || basename(dir);
237
+ const minSeq = minSeqBySession.get(sessionId);
238
+ const memberSeqs = memberSeqsBySession.get(sessionId);
239
+ if (minSeq === undefined) continue; // no window rows from this session
240
+ // conv channels: raw lines from the channel's last snapshot ≤ minSeq
241
+ const convRoot = join(dir, 'conversations');
242
+ let keys = [];
243
+ try { keys = readdirSync(convRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); } catch { /* no conv dir */ }
244
+ for (const key of keys) {
245
+ let files = [];
246
+ try { files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f)).sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0])); } catch { continue; }
247
+ const raws = [];
248
+ for (const f of files) {
249
+ let text = '';
250
+ try { text = readFileSync(join(convRoot, key, f), 'utf-8'); } catch { continue; }
251
+ for (const line of text.split('\n')) {
252
+ const trimmed = line.trim();
253
+ if (trimmed) raws.push(trimmed);
254
+ }
255
+ await yieldLoop(); // one yield per conv file read
256
+ }
257
+ // last snapshot at-or-before the window start for this session
258
+ let start = 0;
259
+ for (let i = 0; i < raws.length; i++) {
260
+ try {
261
+ const ev = JSON.parse(raws[i]);
262
+ if (ev.t === 'snapshot' && ev.seq <= minSeq) start = i;
263
+ if (ev.seq > minSeq) break;
264
+ } catch { /* tolerate torn line */ }
265
+ if (i % 2000 === 1999) await yieldLoop();
266
+ }
267
+ const windowRaws = raws.slice(start);
268
+ // Cap each frame at ~512KB of lines: a multi-MB single SSE event forces
269
+ // one giant synchronous JSON.parse on the client's main thread (no
270
+ // paint, no rAF — the loading UI freezes). Small frames are macrotask
271
+ // boundaries: the browser paints and the byte meter ticks between them.
272
+ pushChunked(convPayloads, windowRaws, (linesJson) => `{"sessionId":${JSON.stringify(sessionId)},"channel":${JSON.stringify(key)},"lines":[${linesJson}]}`);
273
+ }
274
+ // responses: exactly the window member seqs
275
+ let respText = '';
276
+ try { respText = readFileSync(join(dir, 'responses.jsonl'), 'utf-8'); } catch { /* none yet */ }
277
+ await yieldLoop();
278
+ const respRaws = [];
279
+ for (const line of respText.split('\n')) {
280
+ const trimmed = line.trim();
281
+ if (!trimmed) continue;
282
+ const m = /"seq":\s*(\d+)/.exec(trimmed);
283
+ if (m && memberSeqs && memberSeqs.has(Number(m[1]))) respRaws.push(trimmed);
284
+ }
285
+ pushChunked(respPayloads, respRaws, (linesJson) => `{"sessionId":${JSON.stringify(sessionId)},"lines":[${linesJson}]}`);
286
+ }
287
+ return { convPayloads, respPayloads };
288
+ }
289
+
290
+ const NATIVE_FRAME_BUDGET = 512 * 1024;
291
+
292
+ /** Split raw lines into ≤~512KB payloads (see comment at the conv call site). */
293
+ function pushChunked(out, raws, wrap) {
294
+ let buf = [];
295
+ let bytes = 0;
296
+ const flush = () => {
297
+ if (buf.length > 0) out.push(wrap(buf.join(',')));
298
+ buf = [];
299
+ bytes = 0;
300
+ };
301
+ for (const raw of raws) {
302
+ buf.push(raw);
303
+ bytes += raw.length + 1;
304
+ if (bytes >= NATIVE_FRAME_BUDGET) flush();
305
+ }
306
+ flush();
307
+ }
308
+
309
+ /**
310
+ * Bounded streaming Pass B: materialize exactly the rows' bodies (one at a
311
+ * time), classify typeTag with one-entry lookahead (classifyRequest's
312
+ * Preflight/Plan detection reads nextReq), compute cacheLoss against the
313
+ * retained previous mainAgent body, then drop the body.
314
+ */
315
+ async function attachBodyFields(sessionDir, rows) {
316
+ const rowByKey = new Map(rows.map((r) => [itemKey(r.sessionId, r.seq), r]));
317
+ const materialize = (sessionId, seq) => rowByKey.has(itemKey(sessionId, seq));
318
+ let pending = null; // { row, entry } awaiting its nextReq for classify
319
+ let prevMainFull = null; // previous mainAgent full-body entry (cacheLoss)
320
+ let n = 0;
321
+ const finish = (slot, nextEntry) => {
322
+ const { row, entry } = slot;
323
+ try {
324
+ const tag = classifyRequest(entry, nextEntry);
325
+ row.typeTag = tag ? { type: tag.type, subType: tag.subType ?? null } : null;
326
+ } catch (err) {
327
+ // Diagnostic-worthy (a throw silently mis-tags the row; the live path
328
+ // reports the same failure) — CLAUDE.md swallow rule.
329
+ reportSwallowed('v2-meta.row-classify', err);
330
+ row.typeTag = null; // journal-derived kind still renders
331
+ }
332
+ // authoritative synthesis-level markers (parity with the legacy list)
333
+ row.mainAgent = entry.mainAgent === true;
334
+ if (entry.teammate) row.teammate = entry.teammate;
335
+ if (entry.body && entry.body.model) row.model = entry.body.model;
336
+ if (row.mainAgent) {
337
+ const usage = entry.response?.body?.usage;
338
+ const cacheCreate = usage?.cache_creation_input_tokens || 0;
339
+ const cacheRead = usage?.cache_read_input_tokens || 0;
340
+ if (prevMainFull && cacheCreate > 0 && cacheCreate > cacheRead) {
341
+ try { row.cacheLoss = computeCacheLoss(prevMainFull, entry); } catch (err) { reportSwallowed('v2-meta.row-cacheloss', err); }
342
+ }
343
+ prevMainFull = entry;
344
+ }
345
+ };
346
+ for (const item of iterateV2Items(sessionDir, { materialize })) {
347
+ const row = rowByKey.get(itemKey(item.sessionId, item.seq));
348
+ if (!row) continue;
349
+ // main deltas carry only their appended slice — classification and
350
+ // cacheLoss need the full replayed state (stateRef aliases it transiently)
351
+ let entry = item.entry;
352
+ if (row.mainAgent && item.stateRef && entry.body) {
353
+ entry = { ...entry, body: { ...entry.body, messages: item.stateRef } };
354
+ }
355
+ if (pending) finish(pending, entry);
356
+ pending = { row, entry };
357
+ if (++n % 20 === 0) await new Promise((resolve) => setImmediate(resolve));
358
+ }
359
+ if (pending) finish(pending, null);
360
+ }
@@ -0,0 +1,54 @@
1
+ // 1.7.0 migration prompt (plan P2): detect legacy v1 logs that have not been
2
+ // converted to the v2 session store yet, so the UI can offer one-click
3
+ // migration at startup / workspace switch. Detection is pure filesystem
4
+ // arithmetic on top of the converter's own bookkeeping — a v1 file counts as
5
+ // pending unless the convert state marks it done AT ITS CURRENT SIZE (the
6
+ // converter's trust rule, convert.js), because the converter never deletes
7
+ // v1 sources ("files exist" alone is not "migration needed").
8
+ import { statSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { listV1Files, listConvertibleProjects, readConvertState } from './convert.js';
11
+
12
+ /** Pending v1 files + bytes of ONE project dir. */
13
+ function pendingOf(projectDir) {
14
+ const state = readConvertState(projectDir);
15
+ const doneAtSize = new Map(
16
+ (state && Array.isArray(state.files) ? state.files : [])
17
+ .filter((f) => f && f.done)
18
+ .map((f) => [f.name, f.size])
19
+ );
20
+ let files = 0;
21
+ let totalBytes = 0;
22
+ for (const name of listV1Files(projectDir)) {
23
+ let size = 0;
24
+ try { size = statSync(join(projectDir, name)).size; } catch { continue; }
25
+ if (size === 0) continue; // empty shells are not worth prompting over
26
+ if (doneAtSize.get(name) === size) continue; // converted & unchanged
27
+ files++;
28
+ totalBytes += size;
29
+ }
30
+ return { files, totalBytes };
31
+ }
32
+
33
+ /**
34
+ * Migration status of one project (plus how many OTHER projects also have
35
+ * pending v1 logs — the prompt mentions `ccv convert --all` for those).
36
+ * @param {string} logDir - LOG_DIR root
37
+ * @param {string} project - project directory name ('' → not pending)
38
+ * @returns {{pending: boolean, files: number, totalBytes: number, otherProjects: number}}
39
+ */
40
+ export function migrationStatus(logDir, project) {
41
+ const empty = { pending: false, files: 0, totalBytes: 0, otherProjects: 0 };
42
+ if (!logDir || !project) return empty;
43
+ try {
44
+ const { files, totalBytes } = pendingOf(join(logDir, project));
45
+ let otherProjects = 0;
46
+ for (const p of listConvertibleProjects(logDir)) {
47
+ if (p === project) continue;
48
+ if (pendingOf(join(logDir, p)).files > 0) otherProjects++;
49
+ }
50
+ return { pending: files > 0, files, totalBytes, otherProjects };
51
+ } catch {
52
+ return empty;
53
+ }
54
+ }
@@ -0,0 +1,191 @@
1
+ // Wire Format v2 — session-dir readers + MECHANICAL wire replay.
2
+ //
3
+ // Replay here is deliberately interpretation-free: conversation events are
4
+ // applied exactly as the write side derived them from wire state
5
+ // (snapshot → replace state; append → concat; ctl replace-tail → swap last;
6
+ // ctl compact → no-op, the surrounding snapshot already carries the state).
7
+ // This reproduces the EXACT wire messages of every request — byte-equal to
8
+ // what v1's delta reconstruction yields for the same request — without any
9
+ // reverse-anchor/session semantics (those belong to the S5 materializer,
10
+ // which presents conversations; verify only needs wire truth).
11
+
12
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { createHash } from 'node:crypto';
15
+ import { messageFingerprint, normalizeMsgForEquality } from '../session-boundary.js';
16
+ import { isSupportedWireFormat } from './layout.js';
17
+
18
+ /** Parse a JSONL file tolerating a truncated tail line (spec §14). */
19
+ export function readJsonlTolerant(path) {
20
+ if (!existsSync(path)) return [];
21
+ const out = [];
22
+ for (const line of readFileSync(path, 'utf-8').split('\n')) {
23
+ const t = line.trim();
24
+ if (!t) continue;
25
+ try { out.push(JSON.parse(t)); } catch { /* truncated tail — drop */ }
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /** List session ids under LOG_DIR/<project>/sessions/ (or a staging sibling
31
+ * when the offline converter passes `sessionsDirName`). */
32
+ export function listSessionIds(projectDir, sessionsDirName = 'sessions') {
33
+ const root = join(projectDir, sessionsDirName);
34
+ if (!existsSync(root)) return [];
35
+ return readdirSync(root, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
36
+ }
37
+
38
+ /**
39
+ * Read one session dir into a folded structure:
40
+ * { sessionId, meta, wireFormat, reqs: Map<seq, reqLine>,
41
+ * dones: Map<seq, doneLine>, convEvents: Map<convKey, event[] (sorted by seq)> }
42
+ *
43
+ * Reader version gate (spec §14): the version is taken from meta.json and the
44
+ * journal sentinel (`ph:'meta'` first line). A session stamped with a version
45
+ * this build doesn't understand returns `unsupported: true` with EMPTY folds —
46
+ * callers must refuse it loudly instead of interpreting bytes they can't parse.
47
+ * A missing version (torn creation) is treated as the current one.
48
+ */
49
+ export function readSession(projectDir, sessionId, sessionsDirName = 'sessions') {
50
+ const dir = join(projectDir, sessionsDirName, sessionId);
51
+ let meta = null;
52
+ try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { }
53
+ let wireFormat = meta && typeof meta.wireFormat === 'number' ? meta.wireFormat : null;
54
+ const unsupportedOf = (v) => ({
55
+ sessionId, dir, meta, wireFormat: v, unsupported: true,
56
+ reqs: new Map(), dones: new Map(), convEvents: new Map(),
57
+ });
58
+ if (!isSupportedWireFormat(wireFormat)) return unsupportedOf(wireFormat);
59
+ const reqs = new Map();
60
+ const dones = new Map();
61
+ for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
62
+ if (line.ph === 'req') reqs.set(line.seq, line);
63
+ else if (line.ph === 'done' && !dones.has(line.seq)) dones.set(line.seq, line);
64
+ else if (line.ph === 'meta' && typeof line.wireFormat === 'number') {
65
+ // Journal sentinel wins over meta.json (per-file self-description).
66
+ wireFormat = line.wireFormat;
67
+ if (!isSupportedWireFormat(wireFormat)) return unsupportedOf(wireFormat);
68
+ }
69
+ }
70
+ const convEvents = new Map();
71
+ const convRoot = join(dir, 'conversations');
72
+ if (existsSync(convRoot)) {
73
+ for (const entry of readdirSync(convRoot, { withFileTypes: true })) {
74
+ if (!entry.isDirectory()) continue;
75
+ const events = [];
76
+ for (const f of readdirSync(join(convRoot, entry.name))) {
77
+ if (!/^e\d+\.jsonl$/.test(f)) continue;
78
+ events.push(...readJsonlTolerant(join(convRoot, entry.name, f)));
79
+ }
80
+ events.sort((a, b) => a.seq - b.seq);
81
+ convEvents.set(entry.name, events);
82
+ }
83
+ }
84
+ return { sessionId, dir, meta, wireFormat, reqs, dones, convEvents };
85
+ }
86
+
87
+ /** Digest of a messages array: sha256 over the per-message fingerprint list.
88
+ * Shared formula between the v1 and v2 sides of verify. Messages are
89
+ * normalized first (S4 decision 2026-07-14): the client's cache_control
90
+ * breakpoint migration rewrites OLD messages (string → block array with
91
+ * cache_control) — v1 delta cannot express that mutation, so without
92
+ * normalization every real active day fails the soak gate on false
93
+ * positives (plan S4 table, 2026-07-13 row). */
94
+ export function messagesDigest(messages) {
95
+ const h = createHash('sha256');
96
+ for (const m of messages) h.update(messageFingerprint(normalizeMsgForEquality(m)) + '\n');
97
+ return h.digest('hex').slice(0, 16);
98
+ }
99
+
100
+ /** Same content-hash formula as BlobStore.put — lets verify compare v1 inline
101
+ * tools/system against v2 blob refs WITHOUT reading blob files.
102
+ * KEEP IN SYNC: server/lib/v2/blob-store.js `put` is the writing twin of this
103
+ * formula; any change to hash/slice/serialization must land in both. */
104
+ export function blobRefOf(value) {
105
+ if (value === undefined || value === null) return null;
106
+ return 'sha256-' + createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16);
107
+ }
108
+
109
+ /**
110
+ * Mechanically replay one conversation's events, yielding the wire state
111
+ * digest after each event: Map<seq, {digest, len}>. Also returns integrity
112
+ * violations against the journal's msgTo counts (checked by the caller,
113
+ * which owns the journal).
114
+ */
115
+ export function replayConversation(events) {
116
+ let state = [];
117
+ const bySeq = new Map();
118
+ for (const ev of events) {
119
+ if (ev.t === 'snapshot') {
120
+ state = Array.isArray(ev.msgs) ? ev.msgs : [];
121
+ } else if (ev.t === 'append') {
122
+ if (Array.isArray(ev.msgs) && ev.msgs.length > 0) state = state.concat(ev.msgs);
123
+ } else if (ev.t === 'ctl') {
124
+ if (ev.op === 'replace-tail' && ev.msg && state.length > 0) {
125
+ state = state.slice(0, -1).concat([ev.msg]);
126
+ }
127
+ // 'compact' is a marker for the materializer; wire state came via the
128
+ // snapshot event of the same request.
129
+ }
130
+ bySeq.set(ev.seq, { digest: messagesDigest(state), len: state.length });
131
+ }
132
+ return bySeq;
133
+ }
134
+
135
+ /**
136
+ * Build the per-request wire index of one session:
137
+ * Map<`${ts}|${url}`, Array<{seq, kind, conv, epoch, digest, len, toolsRef,
138
+ * sysRef, done, status, msgTo}>>
139
+ * — an ARRAY per key: `${ts}|${url}` is not unique in real logs (same-ms
140
+ * countTokens bursts), and collapsing twins loses their done lines/digests
141
+ * (real-data finding 2026-07-14). Also returns integrity violations
142
+ * (journal msgTo vs replayed length).
143
+ */
144
+ export function indexSession(session) {
145
+ const replayed = new Map(); // convKey → Map<seq, {digest, len}>
146
+ for (const [convKey, events] of session.convEvents) replayed.set(convKey, replayConversation(events));
147
+
148
+ const index = new Map();
149
+ const integrity = [];
150
+ // Walk reqs in seq order; for reqs without a conv event (unchanged wire),
151
+ // carry the conv's latest replayed state at or before that seq.
152
+ const lastStateOf = new Map(); // convKey → {digest, len}
153
+ const seqs = [...session.reqs.keys()].sort((a, b) => a - b);
154
+ for (const seq of seqs) {
155
+ const req = session.reqs.get(seq);
156
+ const done = session.dones.get(seq) || null;
157
+ let digest = null;
158
+ let len = null;
159
+ if (req.conv) {
160
+ const conv = replayed.get(req.conv);
161
+ const hit = conv && conv.get(seq);
162
+ if (hit) {
163
+ lastStateOf.set(req.conv, hit);
164
+ digest = hit.digest; len = hit.len;
165
+ } else {
166
+ const carried = lastStateOf.get(req.conv);
167
+ if (carried) { digest = carried.digest; len = carried.len; }
168
+ }
169
+ if (typeof req.msgTo === 'number' && len != null && req.msgTo !== len) {
170
+ integrity.push({ sessionId: session.sessionId, seq, conv: req.conv, msgTo: req.msgTo, replayedLen: len });
171
+ }
172
+ }
173
+ const key = `${req.ts}|${req.url}`;
174
+ const rec = {
175
+ sessionId: session.sessionId,
176
+ seq,
177
+ kind: req.kind,
178
+ conv: req.conv || null,
179
+ epoch: req.epoch,
180
+ digest,
181
+ len,
182
+ toolsRef: (req.blobs && req.blobs.tools) || null,
183
+ sysRef: (req.blobs && req.blobs.sys) || null,
184
+ done: !!done,
185
+ status: done ? done.status : null,
186
+ };
187
+ const arr = index.get(key);
188
+ if (arr) arr.push(rec); else index.set(key, [rec]);
189
+ }
190
+ return { index, integrity };
191
+ }