cc-viewer 1.7.0 → 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 (115) hide show
  1. package/dist/assets/App-gUSmU6Ny.js +2 -0
  2. package/dist/assets/{MdxEditorPanel-C5Q6uUyH.js → MdxEditorPanel-DAR39A3S.js} +1 -1
  3. package/dist/assets/{Mobile-MoBp9c8k.js → Mobile-qfRsRth0.js} +1 -1
  4. package/dist/assets/index-B51Ci0GL.js +2 -0
  5. package/dist/assets/{seqResourceLoaders-CxuD1CYl.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
  6. package/dist/assets/seqResourceLoaders-DZJxMMaR.js +2 -0
  7. package/dist/index.html +1 -1
  8. package/package.json +2 -1
  9. package/server/lib/log-watcher.js +29 -3
  10. package/server/lib/v2/adapter.js +71 -6
  11. package/server/lib/v2/live-feed.js +110 -3
  12. package/server/lib/v2/meta-rows.js +360 -0
  13. package/server/lib/v2/v2-writer.js +15 -0
  14. package/server/lib/wire-compress.js +167 -0
  15. package/server/routes/events.js +96 -26
  16. package/server/routes/logs.js +15 -12
  17. package/server/routes/v2.js +60 -0
  18. package/server/routes/workspaces.js +7 -6
  19. package/server/server.js +23 -1
  20. package/src/utils/apiUrl.js +26 -0
  21. package/src/utils/askFallback.js +21 -0
  22. package/src/utils/askOptionDesc.js +55 -0
  23. package/src/utils/askPortalMatcher.js +54 -0
  24. package/src/utils/autoApproveOptions.js +27 -0
  25. package/src/utils/avatarAnimationPostPass.js +49 -0
  26. package/src/utils/builtinPresets.js +18 -0
  27. package/src/utils/clearCheckpoint.js +7 -0
  28. package/src/utils/commandValidator.js +45 -0
  29. package/src/utils/contentFilter.js +620 -0
  30. package/src/utils/contextRaw.js +18 -0
  31. package/src/utils/contextSidebarNavigation.js +16 -0
  32. package/src/utils/contextTurns.js +117 -0
  33. package/src/utils/displayScaleHelper.js +47 -0
  34. package/src/utils/effectiveModel.js +13 -0
  35. package/src/utils/entry-slim.js +563 -0
  36. package/src/utils/entryCache.js +144 -0
  37. package/src/utils/errorReport.js +6 -0
  38. package/src/utils/fileExpandedPathsStorage.js +70 -0
  39. package/src/utils/fileIcons.jsx +36 -0
  40. package/src/utils/fileOpen.js +37 -0
  41. package/src/utils/formatters.js +48 -0
  42. package/src/utils/gitApi.js +50 -0
  43. package/src/utils/gitTreeBuilder.js +19 -0
  44. package/src/utils/helpers.js +846 -0
  45. package/src/utils/identityHeal.js +99 -0
  46. package/src/utils/imConnState.js +59 -0
  47. package/src/utils/imOrigin.js +24 -0
  48. package/src/utils/imTr.js +7 -0
  49. package/src/utils/imageCompress.js +83 -0
  50. package/src/utils/imageDownscale.js +28 -0
  51. package/src/utils/imageResize.js +112 -0
  52. package/src/utils/ingestPipeline.js +47 -0
  53. package/src/utils/lazyWithReload.js +53 -0
  54. package/src/utils/markdown.js +30 -0
  55. package/src/utils/markdownIncremental.js +39 -0
  56. package/src/utils/markdownProfiler.js +113 -0
  57. package/src/utils/mdExtensionDetect.js +64 -0
  58. package/src/utils/memoryLinkParser.js +44 -0
  59. package/src/utils/modalMask.js +15 -0
  60. package/src/utils/monotime.js +6 -0
  61. package/src/utils/pinnedMenu.js +55 -0
  62. package/src/utils/presetShortcuts.js +14 -0
  63. package/src/utils/projectAlias.js +135 -0
  64. package/src/utils/promptClassifier.js +112 -0
  65. package/src/utils/promptDetect.js +196 -0
  66. package/src/utils/promptNav.js +60 -0
  67. package/src/utils/ptyChunkBuilder.js +251 -0
  68. package/src/utils/quickMenuHoverIntent.js +35 -0
  69. package/src/utils/rateLimitParser.js +119 -0
  70. package/src/utils/readResultPool.js +106 -0
  71. package/src/utils/refreshCachedItemProp.js +30 -0
  72. package/src/utils/requestType.js +208 -0
  73. package/src/utils/resizeCalc.js +21 -0
  74. package/src/utils/resolveLocalized.js +17 -0
  75. package/src/utils/searchApi.js +47 -0
  76. package/src/utils/searchReplace.js +51 -0
  77. package/src/utils/seqResourceLoaders.js +79 -0
  78. package/src/utils/sessionManager.js +471 -0
  79. package/src/utils/sessionMerge.js +180 -0
  80. package/src/utils/skillModalController.js +111 -0
  81. package/src/utils/skillsParser.js +116 -0
  82. package/src/utils/slashCommandLabels.js +80 -0
  83. package/src/utils/splitDragCalc.js +73 -0
  84. package/src/utils/stickyBottomController.js +643 -0
  85. package/src/utils/svgSanitize.js +48 -0
  86. package/src/utils/systemTags.js +47 -0
  87. package/src/utils/tClaude.js +20 -0
  88. package/src/utils/teamModalBuilder.js +351 -0
  89. package/src/utils/teamSessionParser.js +190 -0
  90. package/src/utils/teammateAvatars.js +194 -0
  91. package/src/utils/teammateDetector.js +121 -0
  92. package/src/utils/termDiag.js +114 -0
  93. package/src/utils/terminalClipboard.js +117 -0
  94. package/src/utils/terminalWriteQueue.js +398 -0
  95. package/src/utils/thinkingMerge.js +49 -0
  96. package/src/utils/toolCatalog.js +29 -0
  97. package/src/utils/toolResultBuilder.js +393 -0
  98. package/src/utils/toolResultClassifier.js +16 -0
  99. package/src/utils/toolResultCore.js +167 -0
  100. package/src/utils/toolsDiff.js +44 -0
  101. package/src/utils/toolsXmlFormatter.js +3 -0
  102. package/src/utils/ultraplanController.js +109 -0
  103. package/src/utils/ultraplanExperts.js +86 -0
  104. package/src/utils/ultraplanTemplates.js +154 -0
  105. package/src/utils/userImageRefs.js +49 -0
  106. package/src/utils/v3Assembler.js +112 -0
  107. package/src/utils/v3Rows.js +48 -0
  108. package/src/utils/voicePackPlayer.js +233 -0
  109. package/src/utils/webSearchGrouping.js +111 -0
  110. package/src/utils/workflowFormat.js +52 -0
  111. package/src/utils/workflowRuns.js +118 -0
  112. package/src/utils/workflowStore.js +94 -0
  113. package/dist/assets/App-DTkacfRW.js +0 -2
  114. package/dist/assets/index-Ac8vnV3v.js +0 -2
  115. package/dist/assets/seqResourceLoaders-Cd_TAXTb.js +0 -2
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Entry Slim — 流式接收剪枝模块
3
+ *
4
+ * 老格式日志每条 MainAgent entry 包含累积的完整 messages,
5
+ * 480MB 文件 JSON.parse 后在浏览器中膨胀到 ~1.2GB → OOM。
6
+ *
7
+ * 核心机制:同 session 内只保留最新一条 MainAgent 的完整 messages 与 body 大字段,
8
+ * 前一条立即释放。被剪枝的 entry 记录 _fullEntryIndex 供按需还原。
9
+ *
10
+ * v2: 除 messages 外,body.system / body.metadata / body.tool_choice 也参与 slim。
11
+ * - body.tools: 不再 slim(见下方 §tools 修复)。tools 完整保留,由 v3 pool 去重控内存。
12
+ * - body.system: 每个 text block 仅保留前 SYSTEM_TEXT_KEEP_PREFIX 字符
13
+ * - body.metadata: 仅保留 user_id(slim session boundary 检测依赖)
14
+ * - body.tool_choice: 直接删除
15
+ * 兼顾:保留 isMainAgent / isNativeTeammate / classifyRequest 等 read path 所需的 shape。
16
+ * 单条 MainAgent entry 节省 ~250–300KB,全 session 累计节省 ~50% 渲染进程堆内存。
17
+ *
18
+ * 导出:
19
+ * - createEntrySlimmer(isMainAgentFn): 批量剪枝器(历史日志加载,process + finalize)
20
+ * - createIncrementalSlimmer(isMainAgentFn): 增量剪枝器(实时 SSE,无需 finalize)
21
+ * - restoreSlimmedEntry(entry, requests): 按需还原被剪枝的 entry
22
+ */
23
+
24
+ // system text 每个 block 保留的前缀长度(字符数)。
25
+ // 必须足够覆盖现有的 system text 检测关键词(contentFilter.js / teammateDetector.js):
26
+ // - "You are Claude Code" ~50 字符内
27
+ // - "You are a Claude agent" ~50 字符内
28
+ // - SUBAGENT_SYSTEM_RE: "command execution specialist | file search specialist
29
+ // | planning specialist | general-purpose agent"
30
+ // - cc_version=X.Y.Z (extractCcVersion)
31
+ // 假设:所有上述检测器消费的关键词都在 system block 前 2KB 内。新增依赖 system text
32
+ // 更长前缀的检测器时,同步上调此常数。2048 字符相对原始 ~50KB 节省 ~96%。
33
+ export const SYSTEM_TEXT_KEEP_PREFIX = 2048;
34
+
35
+ // ─── intern pools (v3) ──────────────────────────────────────────────────────
36
+ // 1.6.237 实测:每个 session 的"最后一条" MainAgent 仍保留完整 body.tools(fullEntry
37
+ // 不被 slim),678 个 fullEntry 各持一份 ~250KB tools 描述 ≈ 170MB 浪费。
38
+ // v3 修正:所有 entry(含 fullEntry)的 body.tools / body.system 走 module-level pool
39
+ // 共享同一引用。signature 命中时直接替换为 pool ref,pool 内是完整原始数据。
40
+ // pool 容量上限防御异常输入;正常 cc-viewer session 内 tools/system 配置稳定,
41
+ // pool 实测 size 通常 < 5。FIFO eviction 见 _internOrAdd。
42
+ const _MAX_INTERN_POOL_SIZE = 200;
43
+ const _toolsPool = new Map(); // sig → tools array (full, shared)
44
+ const _systemPool = new Map(); // sig → system array (full, shared)
45
+
46
+ // v5: messages 内 tool_result block.content 走 readResultPool 同一池化逻辑。
47
+ // SubAgent / Teammate entry 不被 slim,body.messages 中 inline 嵌入的 Read/Bash 等
48
+ // tool_result 跨多个 SubAgent run 是同一份内容重复。v4 已 dedup 派生层(toolResultMap.resultText),
49
+ // v5 补 raw payload(req.body.messages[*].content[*].content)这一关键缺口。
50
+ // internToolResultIfPooled 的命中信号是 lazy-clone 决策的关键:JS string === 是值比较,
51
+ // 普通 internToolResult 返回的 ref 无法用于 ref-不变性判断。
52
+ import { internToolResultIfPooled } from './readResultPool.js';
53
+ import { isCompactContinuation } from './clearCheckpoint.js';
54
+
55
+ function _toolsSig(tools) {
56
+ if (!Array.isArray(tools) || tools.length === 0) return '';
57
+ // 't:' 前缀让 tools / system / readResult 三套 sig 命名空间正交,
58
+ // 避免未来如果共用同一个 pool 时碰撞。
59
+ let sig = 't:' + tools.length;
60
+ for (const t of tools) {
61
+ sig += '|' + (t?.name || '') + ':' + (t?.description?.length || 0);
62
+ }
63
+ return sig;
64
+ }
65
+
66
+ function _systemSig(system) {
67
+ if (Array.isArray(system)) {
68
+ if (system.length === 0) return 'a:0';
69
+ let sig = 'a:' + system.length;
70
+ for (const b of system) {
71
+ const text = b?.text || '';
72
+ // 边界增强(CR P1 defensive):cc-viewer 场景中 system block 跨 entry 通常同模板,
73
+ // 仅前缀检测在长 text 后段差异时不足。加中段 50 字符显著降低误共享概率。
74
+ const mid = text.length > 100 ? Math.floor(text.length / 2) : 0;
75
+ sig += '|' + (b?.type || '') + ':' + text.length + ':' + text.slice(0, 50) + ':' + text.slice(mid, mid + 50);
76
+ }
77
+ return sig;
78
+ }
79
+ if (typeof system === 'string') {
80
+ const mid = system.length > 100 ? Math.floor(system.length / 2) : 0;
81
+ return 's:' + system.length + ':' + system.slice(0, 50) + ':' + system.slice(mid, mid + 50);
82
+ }
83
+ return '';
84
+ }
85
+
86
+ function _internOrAdd(pool, sig, value) {
87
+ let pooled = pool.get(sig);
88
+ if (pooled) return pooled;
89
+ if (pool.size >= _MAX_INTERN_POOL_SIZE) {
90
+ // FIFO eviction — 早期插入的优先丢;cc-viewer 场景 tools/system 配置稳定,pool
91
+ // 实测命中率极高(典型 size <5),FIFO/LRU 等价;远期升级 LRU 时再换。
92
+ pool.delete(pool.keys().next().value);
93
+ }
94
+ // 浅冻结 pool entries(CR P1 defensive):防止 caller 拿到 ref 后 push/splice 污染
95
+ // 所有共享该 ref 的 entry。Object.freeze 是浅层(数组顶层不可变,元素对象不冻结
96
+ // 以避免影响 React 渲染期可能 mutate 内部字段——cc-viewer 实际不发生但保留余地)。
97
+ if (Array.isArray(value)) Object.freeze(value);
98
+ pool.set(sig, value);
99
+ return value;
100
+ }
101
+
102
+ /**
103
+ * 把 messages[*].content[*] 中的 tool_result block.content 字符串替换为 readResultPool
104
+ * 共享引用。仅处理 string 形态的 content(minority array/object 形态原样透传)。
105
+ *
106
+ * 设计要点:
107
+ * - lazy clone:只在至少有一个 block 命中 pool 替换时才 clone messages / content / block。
108
+ * 未命中场景零开销,原数组直接返回。
109
+ * - 浅 clone({...block})保留 _timestamp 等顶层字段;不冻结 messages 数组——
110
+ * AppBase.jsx:1170-1175 mutate `messages[i]._timestamp` 仍可工作。
111
+ * - tool_result.content 字符串本身不可变(JS string primitive),多个 entry 共享 pool ref
112
+ * 完全安全。
113
+ *
114
+ * @param {Array} messages
115
+ * @returns {Array} 原数组(无变化)或浅 clone 后的新数组
116
+ */
117
+ export function internMessagesToolResultBlocks(messages) {
118
+ if (!Array.isArray(messages) || messages.length === 0) return messages;
119
+
120
+ let newMessages = null;
121
+ for (let i = 0; i < messages.length; i++) {
122
+ const msg = messages[i];
123
+ const content = msg?.content;
124
+ if (!Array.isArray(content) || content.length === 0) continue;
125
+
126
+ let newContent = null;
127
+ for (let j = 0; j < content.length; j++) {
128
+ const block = content[j];
129
+ if (!block || block.type !== 'tool_result') continue;
130
+ const c = block.content;
131
+ // 仅 dedup string 形态。array 形态(含 text/image blocks)少见且结构复杂,留待后续。
132
+ if (typeof c !== 'string') continue;
133
+ // pooled = null 时为未命中(已注册到 pool 但调用方无需替换 ref——block 已持原始 ref,
134
+ // 且原始 ref 也是 pool 持有的 ref,零浪费);pooled 非 null 时是命中,原始 c 是独立分配
135
+ // 副本,必须替换为 pool ref 才能让原副本可被 GC 回收。
136
+ const pooled = internToolResultIfPooled(c);
137
+ if (pooled !== null) {
138
+ if (newContent === null) newContent = content.slice();
139
+ newContent[j] = { ...block, content: pooled };
140
+ }
141
+ }
142
+
143
+ if (newContent !== null) {
144
+ if (newMessages === null) newMessages = messages.slice();
145
+ newMessages[i] = { ...msg, content: newContent };
146
+ }
147
+ }
148
+
149
+ return newMessages || messages;
150
+ }
151
+
152
+ /**
153
+ * 把 entry.body.tools / body.system / body.messages 替换为 module-level pool 中的共享引用。
154
+ * 在 entry 进入 state.requests 之前调用,所有路径(SSE flush / batch load /
155
+ * IndexedDB cached restore)都应过一遍此函数。
156
+ *
157
+ * 返回新 entry 对象(如果 body 字段被替换)或原 entry(无变化时)。
158
+ *
159
+ * @param {object} entry
160
+ * @returns {object}
161
+ */
162
+ export function internEntryBigFields(entry) {
163
+ if (!entry?.body) return entry;
164
+ const body = entry.body;
165
+ let newTools = body.tools;
166
+ let newSystem = body.system;
167
+ let newMessages = body.messages;
168
+ let dirty = false;
169
+
170
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
171
+ const sig = _toolsSig(body.tools);
172
+ const pooled = _internOrAdd(_toolsPool, sig, body.tools);
173
+ if (pooled !== body.tools) {
174
+ newTools = pooled;
175
+ dirty = true;
176
+ }
177
+ }
178
+
179
+ if ((Array.isArray(body.system) && body.system.length > 0) || typeof body.system === 'string') {
180
+ const sig = _systemSig(body.system);
181
+ if (sig) {
182
+ const pooled = _internOrAdd(_systemPool, sig, body.system);
183
+ if (pooled !== body.system) {
184
+ newSystem = pooled;
185
+ dirty = true;
186
+ }
187
+ }
188
+ }
189
+
190
+ // v5: walk messages 内的 tool_result block.content 走通用 pool。
191
+ // SubAgent / Teammate 不被 slim 路径,这是 raw payload 唯一的 dedup 入口。
192
+ if (Array.isArray(body.messages) && body.messages.length > 0) {
193
+ const interned = internMessagesToolResultBlocks(body.messages);
194
+ if (interned !== body.messages) {
195
+ newMessages = interned;
196
+ dirty = true;
197
+ }
198
+ }
199
+
200
+ if (dirty) {
201
+ return {
202
+ ...entry,
203
+ body: { ...entry.body, tools: newTools, system: newSystem, messages: newMessages },
204
+ };
205
+ }
206
+ return entry;
207
+ }
208
+
209
+ /**
210
+ * 测试辅助:清空 intern pools。仅用于单元测试隔离。
211
+ */
212
+ export function _resetInternPoolsForTest() {
213
+ _toolsPool.clear();
214
+ _systemPool.clear();
215
+ }
216
+
217
+ /**
218
+ * 测试辅助:观察 pool 当前状态(仅 size)。
219
+ */
220
+ export function _getInternPoolStatsForTest() {
221
+ return { toolsPoolSize: _toolsPool.size, systemPoolSize: _systemPool.size };
222
+ }
223
+
224
+ /**
225
+ * 把一个 body 的大字段降级为占位 shape,返回**新 body 对象**(不 mutate 原 body)。
226
+ * 调用方拿到新 body 后需自行赋值给 entry,例如 `entry.body = slimBodyBigFields(entry.body)`
227
+ * (批量路径)或在 clone 中嵌入(增量路径,避免 React 渲染中间态)。
228
+ * Export 仅用于单元测试;运行时调用方应使用 createEntrySlimmer / createIncrementalSlimmer。
229
+ *
230
+ * @param {object} body
231
+ * @returns {object} 新 body 对象
232
+ */
233
+ export function slimBodyBigFields(body) {
234
+ if (!body) return body;
235
+ const next = { ...body, messages: [] };
236
+
237
+ // §tools 修复:body.tools 不再降级。
238
+ // 旧逻辑把非末位请求的 tools 降级为 [{name}],并在 restoreSlimmedEntry 时从末位
239
+ // fullEntry 继承 tools。在 tools_search 等"tools 列表逐请求变化"的场景下,这会让所有
240
+ // 历史请求都显示最后一条的 tools、变化时机彻底丢失。这里完整保留 tools(next 已从
241
+ // body 浅拷贝携带原引用):internEntryBigFields 先把 tools 走 module-level pool 按内容
242
+ // 签名去重,相同 tools 共享同一引用、内存仍有界;不同 tools 各自保留,才能还原真实变化。
243
+
244
+ if (Array.isArray(body.system)) {
245
+ next.system = body.system.map(blk => {
246
+ if (!blk || typeof blk !== 'object') return blk;
247
+ if (blk.type === 'text' && typeof blk.text === 'string' && blk.text.length > SYSTEM_TEXT_KEEP_PREFIX) {
248
+ const slimBlock = { ...blk, text: blk.text.slice(0, SYSTEM_TEXT_KEEP_PREFIX) };
249
+ return slimBlock;
250
+ }
251
+ return blk;
252
+ });
253
+ } else if (typeof body.system === 'string' && body.system.length > SYSTEM_TEXT_KEEP_PREFIX) {
254
+ next.system = body.system.slice(0, SYSTEM_TEXT_KEEP_PREFIX);
255
+ }
256
+
257
+ if (body.metadata && typeof body.metadata === 'object') {
258
+ next.metadata = body.metadata.user_id ? { user_id: body.metadata.user_id } : {};
259
+ }
260
+
261
+ if ('tool_choice' in next) delete next.tool_choice;
262
+
263
+ return next;
264
+ }
265
+
266
+ /**
267
+ * 创建流式剪枝器。
268
+ *
269
+ * 在 load_chunk 中对每条 entry 调用 process(),
270
+ * 在 load_end 中调用 finalize() 设置 _fullEntryIndex。
271
+ *
272
+ * @param {Function} isMainAgentFn - (entry) => boolean
273
+ * @returns {{ process, finalize }}
274
+ */
275
+ export function createEntrySlimmer(isMainAgentFn) {
276
+ let prevMainIdx = -1;
277
+ let prevMsgCount = 0;
278
+ let prevUserId = null;
279
+ let prevEpoch = null; // task B: v2 _seqEpoch — a change is a definitive session boundary (never slim across it)
280
+
281
+ return {
282
+ /**
283
+ * 处理一条新 entry。
284
+ * 副作用:可能剪枝 entries[prevMainIdx] 的 messages。
285
+ *
286
+ * @param {object} entry - 新接收的 entry
287
+ * @param {Array} entries - 已累积的 entries 数组
288
+ * @param {number} currentIdx - 当前 entry 将存入的索引
289
+ * @returns {object} entry(原样返回)
290
+ */
291
+ process(entry, entries, currentIdx) {
292
+ if (!isMainAgentFn(entry)) return entry;
293
+ if (!entry.body || !Array.isArray(entry.body.messages) || entry.body.messages.length === 0) return entry;
294
+
295
+ const count = entry.body.messages.length;
296
+ const userId = entry.body.metadata?.user_id || null;
297
+ const epoch = entry._seqEpoch || null;
298
+ // Three-part guard (never two-part): only when BOTH sides carry an epoch.
299
+ const epochChanged = !!(epoch && prevEpoch && epoch !== prevEpoch);
300
+
301
+ // Preserve the /compact-continuation signal BEFORE this entry can be slimmed
302
+ // by a later one: once body.messages is emptied, isCompactContinuation() can
303
+ // no longer see the summary msg[0], and isSessionBoundary (clearCheckpoint.js)
304
+ // would misread the big count drop as a new-terminal session on batch reload.
305
+ entry._compactContinuation = isCompactContinuation(entry);
306
+
307
+ // session 边界检测(同 mergeMainAgentSessions)
308
+ // KEEP IN SYNC (semantics): isSessionBoundary in clearCheckpoint.js is the
309
+ // shared predicate for session segmentation. This copy intentionally stays
310
+ // divergent (no compact exclusion): if the slimmer treated a /compact
311
+ // continuation as same-session, the pre-compact entry would be slimmed with
312
+ // _fullEntryIndex pointing at the shorter compact entry, and
313
+ // restoreSlimmedEntry's guard (fullEntry.messages.length < _messageCount)
314
+ // would permanently fail to restore it.
315
+ const isNewSession = prevMsgCount > 0 && (
316
+ (count < prevMsgCount * 0.5 && (prevMsgCount - count) > 4) ||
317
+ (prevUserId && userId && userId !== prevUserId) ||
318
+ epochChanged
319
+ );
320
+
321
+ // 瞬态请求过滤(阈值与 App.jsx _flushPendingEntries 保持一致:>4)
322
+ // epoch 变化=确定性新会话,即便短也不可当瞬态丢弃(task B,与 sessionManager 一致)。
323
+ if (isNewSession && !epochChanged && count <= 4 && prevMsgCount > 4) {
324
+ return entry;
325
+ }
326
+
327
+ if (isNewSession) {
328
+ prevMainIdx = currentIdx;
329
+ prevMsgCount = count;
330
+ prevUserId = userId; prevEpoch = epoch;
331
+ return entry;
332
+ }
333
+
334
+ // 同 session:剪枝前一条 MainAgent 的 messages 与 body 大字段
335
+ if (prevMainIdx >= 0 && prevMainIdx < entries.length) {
336
+ const prev = entries[prevMainIdx];
337
+ if (prev.body?.messages?.length > 0) {
338
+ const pCount = prev.body.messages.length;
339
+ const startIdx = prev._prevMsgCount || 0;
340
+ const idxArr = [];
341
+ for (let j = startIdx; j < pCount; j++) idxArr.push(j);
342
+
343
+ prev._messageCount = pCount;
344
+ prev._messagesIndex = idxArr;
345
+ prev._slimmed = true;
346
+ // 批量路径:原代码就是 in-place mutate prev.body.messages = [],
347
+ // 这里同样 in-place 替换 body 各大字段。entries 数组在 _batchSlim 阶段
348
+ // 还未传给 React,无渲染中间态风险。
349
+ prev.body = slimBodyBigFields(prev.body);
350
+ }
351
+ }
352
+
353
+ entry._prevMsgCount = prevMsgCount;
354
+ prevMainIdx = currentIdx;
355
+ prevMsgCount = count;
356
+ prevUserId = userId; prevEpoch = epoch;
357
+ return entry;
358
+ },
359
+
360
+ /**
361
+ * 流结束后调用:为所有被剪枝的 entry 设置 _fullEntryIndex。
362
+ * @param {Array} entries
363
+ */
364
+ finalize(entries) {
365
+ // 正向扫描每个 session,找到最后一条有完整 messages 的 MainAgent
366
+ let sessionSlimmed = []; // 当前 session 内被剪枝的 entry 索引
367
+ let currentFullIdx = -1;
368
+ let pCount = 0;
369
+ let pUserId = null;
370
+
371
+ for (let i = 0; i < entries.length; i++) {
372
+ const e = entries[i];
373
+ const isSlimmed = e._slimmed;
374
+ const hasMsgs = e.body?.messages?.length > 0;
375
+
376
+ // 跳过非 MainAgent
377
+ if (!isSlimmed && !hasMsgs) continue;
378
+ if (!isSlimmed && !isMainAgentFn(e)) continue;
379
+
380
+ const count = e._messageCount || e.body?.messages?.length || 0;
381
+ const userId = e.body?.metadata?.user_id || null;
382
+
383
+ // KEEP IN SYNC (semantics): intentionally divergent from isSessionBoundary
384
+ // (clearCheckpoint.js) — see the comment on the process() predicate above.
385
+ const isNew = pCount > 0 && (
386
+ (count < pCount * 0.5 && (pCount - count) > 4) ||
387
+ (pUserId && userId && userId !== pUserId)
388
+ );
389
+ if (isNew && count <= 4 && pCount > 10) continue;
390
+
391
+ if (isNew) {
392
+ // 上一个 session 结束:回填 _fullEntryIndex
393
+ for (const idx of sessionSlimmed) {
394
+ entries[idx]._fullEntryIndex = currentFullIdx;
395
+ }
396
+ sessionSlimmed = [];
397
+ currentFullIdx = -1;
398
+ pCount = 0;
399
+ }
400
+
401
+ if (isSlimmed) {
402
+ sessionSlimmed.push(i);
403
+ }
404
+ if (hasMsgs || !isSlimmed) {
405
+ currentFullIdx = i;
406
+ }
407
+ pCount = count;
408
+ pUserId = userId;
409
+ }
410
+
411
+ // 最后一个 session
412
+ for (const idx of sessionSlimmed) {
413
+ entries[idx]._fullEntryIndex = currentFullIdx;
414
+ }
415
+ }
416
+ };
417
+ }
418
+
419
+ /**
420
+ * 按需还原被剪枝的 entry 的 messages(不修改原始 entry)。
421
+ *
422
+ * @param {object} entry - 被剪枝的 entry(_slimmed === true)
423
+ * @param {Array} requests - state.requests 数组
424
+ * @returns {object} 还原后的 entry(新对象)或原样返回
425
+ */
426
+ export function restoreSlimmedEntry(entry, requests) {
427
+ if (!entry._slimmed || entry._fullEntryIndex == null) return entry;
428
+ const fullEntry = requests[entry._fullEntryIndex];
429
+ if (!fullEntry?.body?.messages) return entry;
430
+ if (fullEntry.body.messages.length < entry._messageCount) return entry;
431
+ // 从 fullEntry 还原被 slim 降级的大字段;entry.body 自身的非 big-field(model、max_tokens、stream 等)保留。
432
+ const fullBody = fullEntry.body;
433
+ // messages 是累积量:fullEntry 的 messages 前缀 slice 即本 entry 的原始 messages。
434
+ // system / metadata / tool_choice 仍被 slim 降级,故从 fullEntry 还原。
435
+ // tools 不在此还原(§tools 修复):slim 已不降级 tools,entry 自身的 body.tools
436
+ // 即该请求真实的(pool 完整)tools;从 fullEntry 取会错误继承末位请求的 tools。
437
+ return {
438
+ ...entry,
439
+ _slimmed: false,
440
+ _fullEntryIndex: undefined,
441
+ body: {
442
+ ...entry.body,
443
+ messages: fullBody.messages.slice(0, entry._messageCount),
444
+ system: fullBody.system,
445
+ metadata: fullBody.metadata,
446
+ tool_choice: fullBody.tool_choice,
447
+ },
448
+ };
449
+ }
450
+
451
+ /**
452
+ * 创建增量剪枝器(实时 SSE 链路)。
453
+ *
454
+ * 与批量剪枝器的区别:无需 finalize,每条 MainAgent entry 到达时
455
+ * 立即 slim 上一条并设置 _fullEntryIndex 指向当前 entry。
456
+ *
457
+ * 在 _flushPendingEntries 的 new entry 路径(requests.push)中调用 processEntry;
458
+ * 在 dedup 路径(requests[existingIndex] = entry)中调用 onDedup。
459
+ *
460
+ * @param {Function} isMainAgentFn - (entry) => boolean
461
+ * @returns {{ processEntry, onDedup }}
462
+ */
463
+ export function createIncrementalSlimmer(isMainAgentFn) {
464
+ let prevMainIdx = -1;
465
+ let prevMsgCount = 0;
466
+ let prevUserId = null;
467
+ let prevEpoch = null; // task B: v2 _seqEpoch — a change is a definitive session boundary (never slim across it)
468
+ const sessionSlimmedIndices = new Set();
469
+
470
+ return {
471
+ /**
472
+ * 处理一条新 entry(仅在 new entry 路径调用,dedup 路径不调用)。
473
+ * 副作用:可能剪枝 requests[prevMainIdx] 的 messages,并更新所有已剪枝 entry 的 _fullEntryIndex。
474
+ *
475
+ * @param {object} entry - 新到达的 entry
476
+ * @param {Array} requests - state.requests 数组(slim 前的快照)
477
+ * @param {number} currentIdx - entry 将存入的索引(= requests.length)
478
+ * @returns {object} entry(原样返回)
479
+ */
480
+ processEntry(entry, requests, currentIdx) {
481
+ if (!isMainAgentFn(entry)) return entry;
482
+ if (!entry.body?.messages?.length) return entry;
483
+
484
+ const count = entry.body.messages.length;
485
+ const userId = entry.body.metadata?.user_id || null;
486
+ const epoch = entry._seqEpoch || null;
487
+ // Three-part guard (never two-part): only when BOTH sides carry an epoch.
488
+ const epochChanged = !!(epoch && prevEpoch && epoch !== prevEpoch);
489
+
490
+ // Preserve the /compact-continuation signal before slimming — same rationale
491
+ // as createEntrySlimmer.process(): incrementally-slimmed entries can be
492
+ // re-ingested by the batch pipeline on a warm-cache reconnect, where
493
+ // isSessionBoundary needs this flag because messages are gone.
494
+ entry._compactContinuation = isCompactContinuation(entry);
495
+
496
+ // session 边界检测(与 batch slimmer / mergeMainAgentSessions 一致)
497
+ // KEEP IN SYNC (semantics): intentionally divergent from isSessionBoundary
498
+ // (clearCheckpoint.js) — see the comment on createEntrySlimmer.process().
499
+ const isNewSession = prevMsgCount > 0 && (
500
+ (count < prevMsgCount * 0.5 && (prevMsgCount - count) > 4) ||
501
+ (prevUserId && userId && userId !== prevUserId) ||
502
+ epochChanged
503
+ );
504
+
505
+ // 瞬态请求过滤(阈值与 App.jsx _flushPendingEntries 保持一致:>4)
506
+ // epoch 变化=确定性新会话,即便短也不可当瞬态丢弃(task B,与 sessionManager 一致)。
507
+ if (isNewSession && !epochChanged && count <= 4 && prevMsgCount > 4) {
508
+ return entry;
509
+ }
510
+
511
+ if (isNewSession) {
512
+ sessionSlimmedIndices.clear();
513
+ prevMainIdx = currentIdx;
514
+ prevMsgCount = count;
515
+ prevUserId = userId; prevEpoch = epoch;
516
+ return entry;
517
+ }
518
+
519
+ // 前向 slim:剪枝上一条 MainAgent 的 messages 与 body 大字段
520
+ // 注意:必须 clone entry 再修改,不能 in-place mutate。
521
+ // requests 数组是 [...prev.requests] 浅拷贝,元素仍与 React 上一次 state 共享引用,
522
+ // 直接 mutate 会导致 React 渲染中途看到 messages=[] 的中间态,引起对话闪烁。
523
+ if (prevMainIdx >= 0 && prevMainIdx < requests.length) {
524
+ const orig = requests[prevMainIdx];
525
+ if (orig.body?.messages?.length > 0) {
526
+ const cloned = {
527
+ ...orig,
528
+ body: slimBodyBigFields(orig.body),
529
+ _messageCount: orig.body.messages.length,
530
+ _slimmed: true,
531
+ _fullEntryIndex: currentIdx,
532
+ };
533
+ requests[prevMainIdx] = cloned;
534
+ sessionSlimmedIndices.add(prevMainIdx);
535
+ }
536
+ }
537
+
538
+ // 全量回填:更新本 session 内所有已剪枝 entries 的 _fullEntryIndex
539
+ // 同样需要 clone,避免 mutate React state 中的共享引用
540
+ for (const idx of sessionSlimmedIndices) {
541
+ if (requests[idx]._fullEntryIndex !== currentIdx) {
542
+ requests[idx] = { ...requests[idx], _fullEntryIndex: currentIdx };
543
+ }
544
+ }
545
+
546
+ entry._prevMsgCount = prevMsgCount;
547
+ prevMainIdx = currentIdx;
548
+ prevMsgCount = count;
549
+ prevUserId = userId; prevEpoch = epoch;
550
+ return entry;
551
+ },
552
+
553
+ /**
554
+ * dedup 替换时调用:从 sessionSlimmedIndices 移除被替换的索引,
555
+ * 防止全量回填时污染非 slimmed entry。
556
+ *
557
+ * @param {number} existingIndex - 被 dedup 替换的索引
558
+ */
559
+ onDedup(existingIndex) {
560
+ sessionSlimmedIndices.delete(existingIndex);
561
+ },
562
+ };
563
+ }