@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.
- package/index.ts +1 -0
- package/package.json +47 -0
- package/src/__tests__/family.test.ts +207 -0
- package/src/__tests__/find.test.ts +214 -0
- package/src/__tests__/hash-provider.test.ts +498 -0
- package/src/__tests__/index.test.ts +193 -0
- package/src/__tests__/parser.test.ts +166 -0
- package/src/__tests__/real-data.ts +53 -0
- package/src/__tests__/render.test.ts +367 -0
- package/src/__tests__/roots.test.ts +132 -0
- package/src/__tests__/session-command.test.ts +198 -0
- package/src/__tests__/subagents.test.ts +430 -0
- package/src/__tests__/tool-handler.test.ts +510 -0
- package/src/__tests__/toolcall.test.ts +256 -0
- package/src/__tests__/tree.test.ts +101 -0
- package/src/__tests__/turns.test.ts +142 -0
- package/src/core/family.ts +240 -0
- package/src/core/parser.ts +160 -0
- package/src/core/render.ts +584 -0
- package/src/core/toolcall.ts +168 -0
- package/src/core/tree.ts +93 -0
- package/src/core/turns.ts +97 -0
- package/src/discovery/find.ts +247 -0
- package/src/discovery/roots.ts +92 -0
- package/src/discovery/subagents.ts +470 -0
- package/src/index.ts +189 -0
- package/src/tool-handler.ts +1160 -0
- package/src/tui/hash-provider.ts +230 -0
- package/src/tui/session-command.ts +85 -0
- package/vitest.config.ts +7 -0
|
@@ -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
|
+
}
|