@zhushanwen/pi-session-reader 0.1.0

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.
@@ -0,0 +1,584 @@
1
+ import type { Entry } from './parser.js'
2
+ import { extractToolCalls, formatToolCallSummary, type ToolCallInfo } from './toolcall.js'
3
+ import type { Turn } from './turns.js'
4
+ import type { TreeView } from './tree.js'
5
+
6
+ /**
7
+ * L1 outline 的单行 turn 摘要(design §3.5 算法 1 的渲染单元,冻结接口)。
8
+ *
9
+ * 预算不足时按降级序清空 assistantBrief → toolSummary(保留 userBrief + omittedBytes 骨架),
10
+ * 由 renderOutline 直接改写本对象的字段(消费方拿到的就是降级后的形态)。
11
+ */
12
+ export interface TurnBrief {
13
+ index: number
14
+ startTime?: string
15
+ /** user message text 截 60 字符(compaction turn 为 `[compaction] 摘要…`) */
16
+ userBrief: string
17
+ /** 聚合该 turn 所有 toolCall.name 计数 → `bash×2,read×2`;无则空串 */
18
+ toolSummary: string
19
+ /** assistant text 截 80 字符 */
20
+ assistantBrief: string
21
+ /** 该 turn 省略的 toolResult + thinking 字节数 */
22
+ omittedBytes: number
23
+ /** forkPointId(仅 allBranches 时设置:该 turn 含 forkPoint 节点) */
24
+ branch?: string
25
+ }
26
+
27
+ export interface OutlineResult {
28
+ turns: TurnBrief[]
29
+ stats: { totalTurns: number; totalEntries: number; totalBytes: number; parsedBytes: number }
30
+ /** chars / 4 近似(与 design P-outline 口径一致) */
31
+ tokenEstimate: number
32
+ /** 被总预算截断的 turn 数(从尾部丢弃) */
33
+ truncated?: number
34
+ }
35
+
36
+ export interface OutlineOptions {
37
+ /** 默认 2000(token) */
38
+ budget?: number
39
+ allBranches?: boolean
40
+ /** 默认 turn;entry = 每 entry 一行不聚合(D-1 兜底,坏 session 调试) */
41
+ granularity?: 'turn' | 'entry'
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // content 类型守卫与提取(message.content 是 unknown,design §3.4)
46
+ // ---------------------------------------------------------------------------
47
+
48
+ interface ContentBlock {
49
+ type?: unknown
50
+ text?: unknown
51
+ thinking?: unknown
52
+ }
53
+
54
+ function isStringContent(c: unknown): c is string {
55
+ return typeof c === 'string'
56
+ }
57
+
58
+ function isBlockArray(c: unknown): c is ContentBlock[] {
59
+ if (!Array.isArray(c)) return false
60
+ return c.every((item) => typeof item === 'object' && item !== null)
61
+ }
62
+
63
+ function blockText(block: ContentBlock): string {
64
+ return typeof block.text === 'string' ? block.text : ''
65
+ }
66
+
67
+ function blockThinking(block: ContentBlock): string {
68
+ if (typeof block.thinking === 'string') return block.thinking
69
+ // 兜底:个别实现把 thinking 文本放在 text 字段
70
+ if (block.type === 'thinking' && typeof block.text === 'string') return block.text
71
+ return ''
72
+ }
73
+
74
+ /** 拼接 text 块文本(排除 thinking / toolCall / tool_use / tool_result 块)。 */
75
+ function extractText(content: unknown): string {
76
+ if (isStringContent(content)) return content
77
+ if (isBlockArray(content)) {
78
+ return content
79
+ .filter(
80
+ (b) =>
81
+ b.type !== 'thinking' &&
82
+ b.type !== 'toolCall' && // pi 当前真实工具调用 block type(probe 实测 519)
83
+ b.type !== 'tool_use' && // 历史/兼容防御(probe 实测 0,保留兜底)
84
+ b.type !== 'tool_result',
85
+ )
86
+ .map(blockText)
87
+ .join('')
88
+ }
89
+ return ''
90
+ }
91
+
92
+ /** 拼接 thinking 块文本(assistant 推理噪音)。 */
93
+ function extractThinking(content: unknown): string {
94
+ if (isBlockArray(content)) {
95
+ return content.filter((b) => b.type === 'thinking').map(blockThinking).join('')
96
+ }
97
+ return ''
98
+ }
99
+
100
+ function utf8Bytes(s: string): number {
101
+ return Buffer.byteLength(s, 'utf8')
102
+ }
103
+
104
+ /** 未知 content 的字节数(string 直测,结构化 JSON 序列化后测)。 */
105
+ function contentBytes(content: unknown): number {
106
+ if (content === undefined || content === null) return 0
107
+ if (typeof content === 'string') return utf8Bytes(content)
108
+ try {
109
+ return utf8Bytes(JSON.stringify(content))
110
+ } catch {
111
+ return 0
112
+ }
113
+ }
114
+
115
+ /**
116
+ * 该 entry 聚合的工具名列表(O1 toolSummary 用)。
117
+ * 从 assistant content 的 toolCall block 提取(修 v1 读 message.toolCalls 恒返 [] 的 bug——
118
+ * probe 实测 toolCalls 顶层字段从未存在,工具调用全在 content blocks,v1 全程没工作过)。
119
+ */
120
+ function entryToolCallNames(entry: Entry): string[] {
121
+ return extractToolCalls(entry).map((tc) => tc.name)
122
+ }
123
+
124
+ /** 该 entry 省略的字节:toolResult 整段 content + assistant 的 thinking 块。 */
125
+ function entryOmittedBytes(entry: Entry): number {
126
+ const msg = entry.message
127
+ if (msg === undefined) return 0
128
+ if (msg.role === 'toolResult') return contentBytes(msg.content)
129
+ if (msg.role === 'assistant') return utf8Bytes(extractThinking(msg.content))
130
+ return 0
131
+ }
132
+
133
+ /** toolResult content 提取为纯文本(content 是 [{type:'text',text}] 数组,拼接 text)。 */
134
+ function toolResultText(content: unknown): string {
135
+ if (typeof content === 'string') return content
136
+ if (isBlockArray(content)) {
137
+ return content.map(blockText).join('')
138
+ }
139
+ return ''
140
+ }
141
+
142
+ /**
143
+ * O2/O3:toolResult 的类型化摘要 = 参数摘要(formatToolCallSummary)+ 结果规模(按工具)。
144
+ *
145
+ * - bash → append 结果行数(命令输出多行,行数是核心规模信号)
146
+ * - read → append 结果 KB(文件内容体积)
147
+ * - 其余工具(edit/write/head/todo/cw/未知)→ 不 append(formatToolCallSummary 已含 edit/write
148
+ * 的参数规模 blocks/KB,head 含 limit;避免双重括号)
149
+ *
150
+ * tc 匹配失败(toolCallId 缺失或无对应 toolCall,probe 实测 0%)→ base 退化为 toolName;
151
+ * toolName 也缺失 → '[tool result]'。
152
+ */
153
+ function formatToolResultSummary(
154
+ toolName: string | undefined,
155
+ tc: ToolCallInfo | undefined,
156
+ resultText: string,
157
+ ): string {
158
+ const base = tc !== undefined ? formatToolCallSummary(tc) : (toolName ?? '[tool result]')
159
+ if (toolName === 'bash') {
160
+ const lines = resultText === '' ? 0 : resultText.split('\n').length
161
+ return lines > 0 ? `${base} (${lines}行)` : base
162
+ }
163
+ if (toolName === 'read') {
164
+ if (resultText === '') return base
165
+ const kb = Math.max(1, Math.round(utf8Bytes(resultText) / 1024))
166
+ return `${base} (${kb}KB)`
167
+ }
168
+ return base
169
+ }
170
+
171
+ function entryJsonBytes(e: Entry): number {
172
+ try {
173
+ return utf8Bytes(JSON.stringify(e))
174
+ } catch {
175
+ return 0
176
+ }
177
+ }
178
+
179
+ function truncate(s: string, max: number): string {
180
+ if (s.length <= max) return s
181
+ return s.slice(0, max) + '…'
182
+ }
183
+
184
+ function formatToolSummary(counts: Map<string, number>): string {
185
+ if (counts.size === 0) return ''
186
+ const parts: string[] = []
187
+ for (const [name, count] of counts) {
188
+ parts.push(count > 1 ? `${name}×${count}` : name)
189
+ }
190
+ return parts.join(',')
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // TurnBrief 计算
195
+ // ---------------------------------------------------------------------------
196
+
197
+ function computeBrief(turn: Turn): TurnBrief {
198
+ const toolCounts = new Map<string, number>()
199
+ let assistantText = ''
200
+ let omitted = 0
201
+
202
+ for (const entry of turn.entries) {
203
+ const msg = entry.message
204
+ if (msg !== undefined && msg.role === 'assistant') {
205
+ assistantText += extractText(msg.content)
206
+ }
207
+ for (const name of entryToolCallNames(entry)) {
208
+ toolCounts.set(name, (toolCounts.get(name) ?? 0) + 1)
209
+ }
210
+ omitted += entryOmittedBytes(entry)
211
+ }
212
+
213
+ // userBrief:compaction turn 显示 `[compaction] 摘要`;其余取 user text
214
+ let userBrief: string
215
+ if (turn.isCompaction) {
216
+ const first = turn.entries[0]
217
+ const summaryStr = typeof first?.summary === 'string' ? first.summary : ''
218
+ userBrief = truncate('[compaction] ' + summaryStr, 60)
219
+ } else {
220
+ let userText = ''
221
+ for (const entry of turn.entries) {
222
+ const msg = entry.message
223
+ if (msg !== undefined && msg.role === 'user') {
224
+ userText += extractText(msg.content)
225
+ }
226
+ }
227
+ userBrief = truncate(userText, 60)
228
+ }
229
+
230
+ return {
231
+ index: turn.index,
232
+ startTime: turn.startTime,
233
+ userBrief,
234
+ toolSummary: formatToolSummary(toolCounts),
235
+ assistantBrief: truncate(assistantText, 80),
236
+ omittedBytes: omitted,
237
+ }
238
+ }
239
+
240
+ /** 在 turn.entries 中找第一个属于 tree.branches 的 forkPointId(用于 allBranches 标注)。 */
241
+ function findBranchForkPoint(turn: Turn, tree: TreeView): string | undefined {
242
+ for (const e of turn.entries) {
243
+ if (tree.branches.has(e.id)) return e.id
244
+ }
245
+ return undefined
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // 行渲染(预算度量依据)
250
+ // ---------------------------------------------------------------------------
251
+
252
+ function formatHHMM(timestamp?: string): string {
253
+ if (timestamp === undefined) return ''
254
+ const m = timestamp.match(/T(\d{2}):(\d{2})/)
255
+ return m ? `${m[1]}:${m[2]}` : ''
256
+ }
257
+
258
+ function formatBytesMarker(bytes: number): string {
259
+ if (bytes <= 0) return ''
260
+ if (bytes < 1024) return `[${bytes}B omitted]`
261
+ return `[${Math.round(bytes / 1024)}KB omitted]`
262
+ }
263
+
264
+ /**
265
+ * 渲染单行为字符串(用于预算度量与降级判断)。
266
+ * v2 O1:L1 行含 assistantBrief(补 assistant 结论行让 outline 单独可决策,不再逼反复 expand)。
267
+ * level: 0=全有(toolSummary + assistantBrief)/ 1=砍 assistantBrief / 2=再砍 toolSummary(骨架)。
268
+ * userBrief + omittedBytes 骨架永保。assistantBrief 格式 `→ <结论>`,在 toolSummary 后、omitted 前。
269
+ */
270
+ function formatLine(b: TurnBrief, level: 0 | 1 | 2, branchSize?: number): string {
271
+ const parts: string[] = []
272
+ const head = `T${String(b.index).padStart(3, '0')}`
273
+ const time = formatHHMM(b.startTime)
274
+ parts.push(time ? `${head} ${time}` : head)
275
+ if (b.userBrief) parts.push(b.userBrief)
276
+ if (level <= 1 && b.toolSummary) parts.push(b.toolSummary)
277
+ if (level <= 0 && b.assistantBrief) parts.push(`→ ${b.assistantBrief}`)
278
+ const marker = formatBytesMarker(b.omittedBytes)
279
+ if (marker) parts.push(marker)
280
+ if (branchSize !== undefined && branchSize > 0) parts.push(`[旁支 ${branchSize} entries]`)
281
+ return parts.join(' · ')
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // renderOutline
286
+ // ---------------------------------------------------------------------------
287
+
288
+ function sumBranchEntries(tree: TreeView): number {
289
+ let s = 0
290
+ for (const n of tree.branches.values()) s += n
291
+ return s
292
+ }
293
+
294
+ /** 计算 parsedBytes(leaf entry JSON 字节和)。totalBytes/totalEntries 由 renderOutline 组装。 */
295
+ function leafParsedBytes(turns: Turn[]): number {
296
+ let parsedBytes = 0
297
+ for (const t of turns) {
298
+ for (const e of t.entries) parsedBytes += entryJsonBytes(e)
299
+ }
300
+ return parsedBytes
301
+ }
302
+
303
+ export function renderOutline(
304
+ turns: Turn[],
305
+ tree: TreeView,
306
+ options?: OutlineOptions,
307
+ ): OutlineResult {
308
+ const budget = options?.budget ?? 2000
309
+ const allBranches = options?.allBranches ?? false
310
+ const granularity = options?.granularity ?? 'turn'
311
+
312
+ const parsedBytes = leafParsedBytes(turns)
313
+ const leafEntryCount = turns.reduce((s, t) => s + t.entries.length, 0)
314
+ // totalEntries = leaf + 旁支子树 + 孤儿;totalBytes 无原始文件字节数(签名不含 ParseResult),
315
+ // 以 leaf entry JSON 字节近似,精确值由 M2 工具层用 ParseResult.totalBytes 覆盖。
316
+ const totalEntriesAll = leafEntryCount + sumBranchEntries(tree) + tree.orphans.length
317
+ const stats: OutlineResult['stats'] = {
318
+ totalTurns: turns.length,
319
+ totalEntries: totalEntriesAll,
320
+ totalBytes: parsedBytes,
321
+ parsedBytes,
322
+ }
323
+
324
+ if (turns.length === 0) {
325
+ return { turns: [], stats, tokenEstimate: 0 }
326
+ }
327
+
328
+ // granularity:entry —— 每 entry 一行,不聚合 turn(D-1 兜底)
329
+ if (granularity === 'entry') {
330
+ return renderEntryGranularity(turns, tree, budget, allBranches, stats)
331
+ }
332
+
333
+ // 1. 全量 brief
334
+ const briefs: TurnBrief[] = turns.map(computeBrief)
335
+
336
+ // allBranches:标注 forkPoint
337
+ if (allBranches) {
338
+ for (const b of briefs) {
339
+ const fp = findBranchForkPoint(turns[b.index], tree)
340
+ if (fp !== undefined) b.branch = fp
341
+ }
342
+ }
343
+
344
+ // 2. perTurnBudget(token → chars×4)
345
+ const perTurnCharBudget = (budget / turns.length) * 4
346
+
347
+ // 3. 降级序:level 0 全有;超预算降到 level 1 砍 assistantBrief;仍超降到 level 2 砍 toolSummary(骨架)。design §3.5 算法 1 step3
348
+ const lineCache: string[] = []
349
+ for (const b of briefs) {
350
+ const branchSize = b.branch !== undefined ? tree.branches.get(b.branch) : undefined
351
+ let line = formatLine(b, 0, branchSize)
352
+ if (line.length > perTurnCharBudget) {
353
+ // 超预算:先砍 assistantBrief(level 1),仍超再砍 toolSummary(level 2,骨架)
354
+ b.assistantBrief = ''
355
+ line = formatLine(b, 1, branchSize)
356
+ if (line.length > perTurnCharBudget) {
357
+ b.toolSummary = ''
358
+ line = formatLine(b, 2, branchSize)
359
+ }
360
+ }
361
+ lineCache.push(line)
362
+ }
363
+
364
+ // 4. 总预算截断(从尾部丢弃)
365
+ let totalChars = lineCache.reduce((s, l) => s + l.length, 0)
366
+ let truncated: number | undefined
367
+ if (totalChars / 4 > budget) {
368
+ let kept = lineCache.length
369
+ while (kept > 0 && totalChars / 4 > budget) {
370
+ kept--
371
+ totalChars -= lineCache[kept].length
372
+ }
373
+ truncated = lineCache.length - kept
374
+ lineCache.length = kept
375
+ briefs.length = kept
376
+ }
377
+
378
+ const tokenEstimate = Math.ceil(totalChars / 4)
379
+ return { turns: briefs, stats, tokenEstimate, truncated }
380
+ }
381
+
382
+ /** granularity:entry 模式:每 entry 一行 TurnBrief,不聚合,仅总预算截断。 */
383
+ function renderEntryGranularity(
384
+ turns: Turn[],
385
+ tree: TreeView,
386
+ budget: number,
387
+ allBranches: boolean,
388
+ stats: OutlineResult['stats'],
389
+ ): OutlineResult {
390
+ const briefs: TurnBrief[] = []
391
+ let entryIdx = 0
392
+ for (const t of turns) {
393
+ for (const e of t.entries) {
394
+ const msg = e.message
395
+ let text = ''
396
+ if (msg !== undefined && (msg.role === 'user' || msg.role === 'assistant')) {
397
+ text = extractText(msg.content)
398
+ }
399
+ const counts = new Map<string, number>()
400
+ for (const name of entryToolCallNames(e)) {
401
+ counts.set(name, (counts.get(name) ?? 0) + 1)
402
+ }
403
+ const b: TurnBrief = {
404
+ index: entryIdx++,
405
+ startTime: e.timestamp,
406
+ userBrief: truncate(text, 60) || `[${e.type}]`,
407
+ toolSummary: formatToolSummary(counts),
408
+ assistantBrief: '',
409
+ omittedBytes: entryOmittedBytes(e),
410
+ }
411
+ if (allBranches && tree.branches.has(e.id)) b.branch = e.id
412
+ briefs.push(b)
413
+ }
414
+ }
415
+
416
+ const lines = briefs.map((b) =>
417
+ formatLine(b, 0, b.branch !== undefined ? tree.branches.get(b.branch) : undefined),
418
+ )
419
+ let totalChars = lines.reduce((s, l) => s + l.length, 0)
420
+ let truncated: number | undefined
421
+ if (totalChars / 4 > budget) {
422
+ let kept = lines.length
423
+ while (kept > 0 && totalChars / 4 > budget) {
424
+ kept--
425
+ totalChars -= lines[kept].length
426
+ }
427
+ truncated = lines.length - kept
428
+ lines.length = kept
429
+ briefs.length = kept
430
+ }
431
+ return {
432
+ turns: briefs,
433
+ stats,
434
+ tokenEstimate: Math.ceil(totalChars / 4),
435
+ truncated,
436
+ }
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // renderExpand(L2 单轮展开)
441
+ // ---------------------------------------------------------------------------
442
+
443
+ export interface EntryBrief {
444
+ index: number
445
+ type: string
446
+ role?: string
447
+ brief: string
448
+ omittedBytes: number
449
+ }
450
+
451
+ /**
452
+ * 单 entry 的一行 brief(L2 expand 用)。
453
+ * O2:toolResult 改类型化摘要(toolName + toolCallId 关联取 args + 结果规模),
454
+ * 不再是结果文本前 100 字(v1 agent 不知工具维度)。
455
+ */
456
+ function entryBrief(e: Entry, tcMap?: Map<string, ToolCallInfo>): string {
457
+ const msg = e.message
458
+ if (msg !== undefined) {
459
+ if (msg.role === 'user' || msg.role === 'assistant') {
460
+ return truncate(extractText(msg.content), 100) || `[${msg.role}]`
461
+ }
462
+ if (msg.role === 'toolResult') {
463
+ const toolCallId = msg.toolCallId
464
+ const tc = toolCallId !== undefined ? tcMap?.get(toolCallId) : undefined
465
+ return formatToolResultSummary(msg.toolName, tc, toolResultText(msg.content))
466
+ }
467
+ }
468
+ if (e.type === 'compaction') {
469
+ const s = typeof e.summary === 'string' ? e.summary : '[compaction]'
470
+ return truncate(s, 100)
471
+ }
472
+ if (e.type === 'custom') return `[custom:${e.customType ?? 'unknown'}]`
473
+ return `[${e.type}]`
474
+ }
475
+
476
+ export function renderExpand(turn: Turn): {
477
+ turn: string
478
+ entries: EntryBrief[]
479
+ } {
480
+ const head = `T${String(turn.index).padStart(3, '0')}`
481
+ const bits = [`${turn.entries.length} entries`]
482
+ if (turn.startTime !== undefined) bits.push(`started ${formatHHMM(turn.startTime)}`)
483
+ if (turn.isCompaction) bits.push('compaction')
484
+ if (turn.userEntry === undefined && !turn.isCompaction) bits.push('preface')
485
+ const header = `${head} (${bits.join(', ')})`
486
+
487
+ // O2:建 toolCallId → ToolCallInfo 索引(收本 turn assistant entry 的 toolCall,供 toolResult 关联取 args)
488
+ const tcMap = new Map<string, ToolCallInfo>()
489
+ for (const e of turn.entries) {
490
+ for (const tc of extractToolCalls(e)) tcMap.set(tc.id, tc)
491
+ }
492
+
493
+ const entries: EntryBrief[] = turn.entries.map((e, i) => ({
494
+ index: i,
495
+ type: e.type,
496
+ role: e.message?.role,
497
+ brief: entryBrief(e, tcMap),
498
+ omittedBytes: entryOmittedBytes(e),
499
+ }))
500
+ return { turn: header, entries }
501
+ }
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // renderDetail(L3 全文,按 opts 过滤噪音)
505
+ // ---------------------------------------------------------------------------
506
+
507
+ /** 过滤掉 thinking 块;非 block 数组原样返回。 */
508
+ function stripThinking(content: unknown): unknown {
509
+ if (!isBlockArray(content)) return content
510
+ return content.filter((b) => b.type !== 'thinking')
511
+ }
512
+
513
+ /**
514
+ * L3 detail 默认摘要态的 toolResult entry(v2 O3:toolResult 不再整条消失,给中间态)。
515
+ * includeToolResult:true 时 renderDetail 返回原 Entry(全文),否则返回此摘要。
516
+ */
517
+ export interface ToolResultSummaryEntry {
518
+ type: 'toolResultSummary'
519
+ /** 原 toolResult entry 的 id(与全文 Entry 对齐,便于定位) */
520
+ id: string
521
+ /** 类型化摘要(同 O2 格式:toolName + 参数摘要 + 结果规模) */
522
+ summary: string
523
+ /** 结果文本前 3 行(每行截 80 字,' | ' 分隔,单行便于渲染) */
524
+ headLines: string
525
+ /** 结果文本总行数 */
526
+ totalLines: number
527
+ /** 原 toolResult entry(includeToolResult:true 时 renderDetail 改用此返回全文) */
528
+ fullEntry: Entry
529
+ }
530
+
531
+ export function renderDetail(
532
+ turns: Turn[],
533
+ opts: { includeToolResult?: boolean; includeThinking?: boolean } = {},
534
+ ): Array<Entry | ToolResultSummaryEntry> {
535
+ const includeToolResult = opts.includeToolResult ?? false
536
+ const includeThinking = opts.includeThinking ?? false
537
+
538
+ const out: Array<Entry | ToolResultSummaryEntry> = []
539
+ for (const t of turns) {
540
+ // O2/O3:turn 级 toolCallId → ToolCallInfo 索引(toolResult 摘要的参数部分用)
541
+ const tcMap = new Map<string, ToolCallInfo>()
542
+ for (const e of t.entries) {
543
+ for (const tc of extractToolCalls(e)) tcMap.set(tc.id, tc)
544
+ }
545
+
546
+ for (const e of t.entries) {
547
+ const msg = e.message
548
+ if (msg !== undefined && msg.role === 'toolResult') {
549
+ if (includeToolResult) {
550
+ // 全文态:返回原 entry(thinking 剥离不适用 toolResult)
551
+ out.push(e)
552
+ } else {
553
+ // O3 摘要态:不消失,给类型化摘要 + 头 3 行 + 总行数
554
+ const toolCallId = msg.toolCallId
555
+ const tc = toolCallId !== undefined ? tcMap.get(toolCallId) : undefined
556
+ const text = toolResultText(msg.content)
557
+ // 空文本口径与 formatToolResultSummary 一致:空结果 = 0 行
558
+ //(''.split('\n') 返 [''] length=1,会与 summary 的 (0行) 自相矛盾)
559
+ const lines = text === '' ? [] : text.split('\n')
560
+ const headLines = lines.slice(0, 3).map((l) => truncate(l, 80)).join(' | ')
561
+ out.push({
562
+ type: 'toolResultSummary',
563
+ id: e.id,
564
+ summary: formatToolResultSummary(msg.toolName, tc, text),
565
+ headLines,
566
+ totalLines: lines.length,
567
+ fullEntry: e,
568
+ })
569
+ }
570
+ continue
571
+ }
572
+ // 非 toolResult:thinking 剥离逻辑保留
573
+ if (msg !== undefined && !includeThinking) {
574
+ const cleaned = stripThinking(msg.content)
575
+ if (cleaned !== msg.content) {
576
+ out.push({ ...e, message: { ...msg, content: cleaned } })
577
+ continue
578
+ }
579
+ }
580
+ out.push(e)
581
+ }
582
+ }
583
+ return out
584
+ }