@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,1160 @@
1
+ /**
2
+ * [M3 工具适配层] session_read 工具的纯逻辑 handler(design §3.4 接口规格)。
3
+ *
4
+ * 分层约定(同 scheduler/cw-tool):本文件零 pi 依赖——agentDir 作参数注入,
5
+ * 不调用 getAgentDir(),可完全单测;pi 注册与 getAgentDir() 调用在 index.ts。
6
+ *
7
+ * 按 action 分发到 8 条路径,串联 M1 core(parser/tree/turns/render)+ M2 discovery
8
+ *(find/subagents)。content 给 LLM 读(人类可读摘要),details 供程序化消费/测试断言。
9
+ *
10
+ * 错误规格 F1-F6:handler 抛 Error(message 含 👉 恢复指引),由 index.ts 的 execute
11
+ * 闭包 catch 转 isError:true 文本返回——handler 可抛(纯逻辑可测),execute 不抛(pi 契约)。
12
+ * 例外:F2 多匹配与 F1 find 零匹配「不视为错误」,返回消歧/提示结果而非抛错。
13
+ */
14
+ import { mkdir, writeFile } from 'node:fs/promises'
15
+ import { join } from 'node:path'
16
+ import { findSessions, type MatchedSession } from './discovery/find.js'
17
+ import { buildFamilyFromFs } from './discovery/subagents.js'
18
+ import { parseSessionFile, type Entry, type ParseResult } from './core/parser.js'
19
+ import { buildTreeView } from './core/tree.js'
20
+ import { segmentTurns, type Turn } from './core/turns.js'
21
+ import { extractToolCalls, formatToolCallSummary, basename } from './core/toolcall.js'
22
+ import {
23
+ renderOutline,
24
+ renderExpand,
25
+ renderDetail,
26
+ type OutlineOptions,
27
+ type OutlineResult,
28
+ type EntryBrief,
29
+ type ToolResultSummaryEntry,
30
+ } from './core/render.js'
31
+ import type { Family } from './core/family.js'
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // 公共类型(与 index.ts 的 TypeBox schema 对齐)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export type SessionReadAction =
38
+ | 'find'
39
+ | 'family'
40
+ | 'outline'
41
+ | 'expand'
42
+ | 'detail'
43
+ | 'search'
44
+ | 'export'
45
+ | 'extract'
46
+
47
+ export interface SessionReadParams {
48
+ action: SessionReadAction
49
+ session?: string
50
+ query?: string
51
+ turns?: string
52
+ turn?: string
53
+ pattern?: string
54
+ scope?: 'all' | 'user' | 'assistant' | 'toolResult'
55
+ format?: 'outline' | 'full' | 'family'
56
+ includeToolResult?: boolean
57
+ includeThinking?: boolean
58
+ allBranches?: boolean
59
+ granularity?: 'turn' | 'entry'
60
+ cwd?: string
61
+ limit?: number
62
+ /** extract action: 素材类型(必填)。其他 action 忽略。 */
63
+ what?: 'user-messages' | 'commands' | 'files' | 'commits' | 'tool-results'
64
+ /** extract action: 过滤 commands/tool-results 的工具名(可选)。 */
65
+ tool?: string
66
+ }
67
+
68
+ export interface ToolResult {
69
+ content: Array<{ type: 'text'; text: string }>
70
+ details: unknown
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // 小工具
75
+ // ---------------------------------------------------------------------------
76
+
77
+ const pad = (n: number): string => String(n).padStart(3, '0')
78
+
79
+ /** 构造带 👉 恢复指引的 Error(handler 抛出,由 execute 闭包 catch)。 */
80
+ function err(message: string): Error {
81
+ return new Error(message)
82
+ }
83
+
84
+ /** 剥 # 前缀(TUI `#e6c96` 引用 → 纯片段,design §3.3 D-3/D-4)。 */
85
+ function stripHash(s: string): string {
86
+ return s.replace(/^#+/, '')
87
+ }
88
+
89
+ function formatDate(ms: number): string {
90
+ if (!ms) return ''
91
+ const d = new Date(ms)
92
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
93
+ d.getDate(),
94
+ ).padStart(2, '0')}`
95
+ }
96
+
97
+ /** cwd 取末两段缩短显示(完整 cwd 在 details 里)。 */
98
+ function shortCwd(cwd: string): string {
99
+ const parts = cwd.split('/').filter(Boolean)
100
+ return parts.slice(-2).join('/')
101
+ }
102
+
103
+ function formatOmitted(bytes: number): string {
104
+ if (bytes <= 0) return ''
105
+ if (bytes < 1024) return `[${bytes}B omitted]`
106
+ return `[${Math.round(bytes / 1024)}KB omitted]`
107
+ }
108
+
109
+ /** F5 必填参数校验。 */
110
+ function requireStr(
111
+ val: string | undefined,
112
+ name: string,
113
+ action: SessionReadAction,
114
+ ): string {
115
+ if (val === undefined || val === null || val.trim() === '') {
116
+ throw err(`action:"${action}" 需要参数 "${name}"。👉 补上 "${name}" 重试。`)
117
+ }
118
+ return val.trim()
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // turn / turns 索引解析
123
+ // ---------------------------------------------------------------------------
124
+
125
+ const TURN_RE = /^T?(\d+)$/i
126
+
127
+ function parseTurnIndex(raw: string): number {
128
+ const m = raw.trim().match(TURN_RE)
129
+ if (!m) {
130
+ throw err(
131
+ `turn "${raw}" 格式无效(应为 T013 或 013)。👉 用合法 turn 索引重试,或 outline 重看有效范围。`,
132
+ )
133
+ }
134
+ return parseInt(m[1], 10)
135
+ }
136
+
137
+ function parseTurnsRange(raw: string): { start: number; end: number } {
138
+ const parts = raw.split('-').map((s) => s.trim())
139
+ if (parts.length === 1) {
140
+ const i = parseTurnIndex(parts[0])
141
+ return { start: i, end: i }
142
+ }
143
+ if (parts.length === 2) {
144
+ const start = parseTurnIndex(parts[0])
145
+ const end = parseTurnIndex(parts[1])
146
+ if (end < start) {
147
+ throw err(
148
+ `turns 范围 "${raw}" 起始大于结束。👉 检查范围格式(如 T013-T015)重试。`,
149
+ )
150
+ }
151
+ return { start, end }
152
+ }
153
+ throw err(`turns "${raw}" 格式无效(应为 T013 或 T013-T015)。👉 用合法范围重试。`)
154
+ }
155
+
156
+ function rangeLabel(r: { start: number; end: number }): string {
157
+ return r.start === r.end ? `T${pad(r.start)}` : `T${pad(r.start)}-T${pad(r.end)}`
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // resolveSessionId:片段 → 完整 id(design §3.4 resolveSessionId 辅助)
162
+ // ---------------------------------------------------------------------------
163
+
164
+ type ResolveResult =
165
+ | { kind: 'ok'; sessionId: string; fileName: string }
166
+ | { kind: 'multi'; query: string; candidates: MatchedSession[] }
167
+
168
+ /**
169
+ * 把 session 参数(完整 id 或片段,可能带 # 前缀)解析到唯一完整 id。
170
+ *
171
+ * 走 findSessions(M2 已实现三路匹配:uuid 片段 / recent / 名称关键词)。
172
+ * - 唯一匹配 → {kind:'ok'}(含 fileName,后续 parseSessionFile 直接用)
173
+ * - 多匹配 → {kind:'multi'}(调用方据此返回 F2 消歧,不抛错)
174
+ * - 零匹配 → 抛 F1(含最近 10 个 session 建议 + 👉,design §3.4 F1 模板)
175
+ *
176
+ * 仅用于 family/outline/expand/detail/search/export(find action 自行调 findSessions,
177
+ * 零匹配时返回空 + 提示,不抛错)。
178
+ */
179
+ async function resolveSessionId(
180
+ rawSession: string | undefined,
181
+ action: SessionReadAction,
182
+ agentDir: string,
183
+ ): Promise<ResolveResult> {
184
+ const session = stripHash(requireStr(rawSession, 'session', action))
185
+ const { matches } = await findSessions(session, agentDir, { limit: 10 })
186
+ if (matches.length === 0) {
187
+ const recent = await findSessions('recent', agentDir, { limit: 10 })
188
+ throw err(formatNoMatch(session, recent.matches))
189
+ }
190
+ if (matches.length === 1) {
191
+ return { kind: 'ok', sessionId: matches[0].sessionId, fileName: matches[0].fileName }
192
+ }
193
+ return { kind: 'multi', query: session, candidates: matches }
194
+ }
195
+
196
+ /** F1 无匹配 message(含最近 10 + 👉)。 */
197
+ function formatNoMatch(query: string, recent: MatchedSession[]): string {
198
+ const lines: string[] = recent.length
199
+ ? recent.map((m, i) => ` ${i + 1}. ${m.sessionId.slice(0, 8)}… ${m.firstMessagePreview ?? ''}`.trimEnd())
200
+ : [' (无历史 session)']
201
+ return (
202
+ `无匹配 session:"${query}"。最近 ${recent.length} 个 session:\n${lines.join('\n')}\n` +
203
+ `👉 用 session_read { action:"find", query:"recent" } 看全量,或换片段重试。`
204
+ )
205
+ }
206
+
207
+ /** F2 多匹配消歧结果(不抛错,返回候选 + 👉)。 */
208
+ function disambiguate(query: string, candidates: MatchedSession[]): ToolResult {
209
+ const lines = candidates.map(
210
+ (m, i) =>
211
+ ` ${i + 1}. ${m.sessionId} · ${formatDate(m.mtime)}${m.firstMessagePreview ? ' · ' + m.firstMessagePreview : ''}`,
212
+ )
213
+ const hint =
214
+ candidates[0] !== undefined
215
+ ? `(如 ${candidates[0].sessionId.slice(0, 12)})`
216
+ : ''
217
+ const text =
218
+ `${candidates.length} 个匹配 "${query}":\n${lines.join('\n')}\n` +
219
+ `👉 用更长的 uuid 片段${hint},或 action:"find" 加 cwd 过滤。`
220
+ return { content: [{ type: 'text', text }], details: { ambiguous: true, candidates } }
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // 文件读取(F6 包装)
225
+ // ---------------------------------------------------------------------------
226
+
227
+ async function safeParse(fileName: string): Promise<ParseResult> {
228
+ try {
229
+ return await parseSessionFile(fileName)
230
+ } catch (e) {
231
+ throw err(
232
+ `读取失败:${fileName}(${e instanceof Error ? e.message : String(e)})。👉 检查文件或换 session。`,
233
+ )
234
+ }
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // 文本渲染(content)
239
+ // ---------------------------------------------------------------------------
240
+
241
+ function formatFindContent(
242
+ query: string,
243
+ matches: MatchedSession[],
244
+ truncated: boolean,
245
+ ): string {
246
+ const lines = matches.map((m, i) => {
247
+ const parts = [`${i + 1}. ${m.sessionId.slice(0, 8)}…`, formatDate(m.mtime)]
248
+ if (m.cwd) parts.push(shortCwd(m.cwd))
249
+ if (m.firstMessagePreview) parts.push(m.firstMessagePreview)
250
+ return parts.join(' · ')
251
+ })
252
+ const head = `${matches.length} session(s) matched "${query}"${
253
+ truncated ? ` (truncated, showing first ${matches.length})` : ''
254
+ }`
255
+ return `${head}\n${lines.join('\n')}`
256
+ }
257
+
258
+ function formatOutlineText(r: OutlineResult): string {
259
+ const lines = r.turns.map((b) => {
260
+ const time = b.startTime ? b.startTime.match(/T(\d{2}:\d{2})/)?.[1] ?? '' : ''
261
+ const parts = [`T${pad(b.index)}${time ? ' ' + time : ''}`]
262
+ if (b.userBrief) parts.push(b.userBrief)
263
+ if (b.toolSummary) parts.push(b.toolSummary)
264
+ // v2 O1:补 assistant 结论行(→ )让 outline 单独可决策
265
+ if (b.assistantBrief) parts.push('→ ' + b.assistantBrief)
266
+ const om = formatOmitted(b.omittedBytes)
267
+ if (om) parts.push(om)
268
+ if (b.branch) parts.push('[旁支]')
269
+ return parts.join(' · ')
270
+ })
271
+ const tail = [
272
+ '',
273
+ `${r.stats.totalTurns} turns · ${r.stats.totalEntries} entries · ~${r.tokenEstimate} tokens`,
274
+ r.truncated ? `[还有 ${r.truncated} 轮未显示,用 detail 或调大 budget]` : '',
275
+ ]
276
+ .filter(Boolean)
277
+ .join('\n')
278
+ return `${lines.join('\n')}\n${tail}`
279
+ }
280
+
281
+ function formatExpandText(turn: string, entries: EntryBrief[]): string {
282
+ const lines = entries.map(
283
+ (e) =>
284
+ ` [${e.index}] ${e.type}${e.role ? '/' + e.role : ''} ${e.brief}${
285
+ e.omittedBytes > 0 ? ' ' + formatOmitted(e.omittedBytes) : ''
286
+ }`,
287
+ )
288
+ return `${turn}\n${lines.join('\n')}`
289
+ }
290
+
291
+ /** 从 message.content 提取可读文本(text/thinking 块;toolCall 留 name 占位)。 */
292
+ function messageReadableText(content: unknown): string {
293
+ if (typeof content === 'string') return content
294
+ if (Array.isArray(content)) {
295
+ return content
296
+ .map((b) => {
297
+ if (b && typeof b === 'object') {
298
+ const o = b as Record<string, unknown>
299
+ if (o.type === 'text' && typeof o.text === 'string') return o.text
300
+ if (o.type === 'thinking' && typeof o.thinking === 'string') return `[thinking] ${o.thinking}`
301
+ if (o.type === 'toolCall')
302
+ return `[toolCall: ${typeof o.name === 'string' ? o.name : '?'}]`
303
+ }
304
+ return ''
305
+ })
306
+ .filter(Boolean)
307
+ .join('\n')
308
+ }
309
+ return ''
310
+ }
311
+
312
+ /**
313
+ * ToolResultSummaryEntry 判别(Entry.type 是宽 string,TS 无法靠 === 判别联合,须显式谓词收窄)。
314
+ */
315
+ function isToolResultSummary(
316
+ e: Entry | ToolResultSummaryEntry,
317
+ ): e is ToolResultSummaryEntry {
318
+ return e.type === 'toolResultSummary'
319
+ }
320
+
321
+ /**
322
+ * 从 message.content 提取可读文本(text/thinking 块;toolCall 留 name 占位)。
323
+ * v2 O3:接受 Entry | ToolResultSummaryEntry,toolResultSummary 返摘要文本(doExport full 用)。
324
+ */
325
+ function entryReadableText(e: Entry | ToolResultSummaryEntry): string {
326
+ if (isToolResultSummary(e)) {
327
+ return `${e.summary} (共 ${e.totalLines} 行,前 3 行:${e.headLines})`
328
+ }
329
+ const msg = e.message
330
+ if (msg !== undefined) {
331
+ if (msg.role === 'toolResult') return `[toolResult] ${messageReadableText(msg.content)}`
332
+ return messageReadableText(msg.content)
333
+ }
334
+ if (e.type === 'compaction')
335
+ return `[compaction] ${typeof e.summary === 'string' ? e.summary : JSON.stringify(e.summary ?? '')}`
336
+ if (e.type === 'custom') return `[custom:${e.customType ?? '?'}]`
337
+ return `[${e.type}]`
338
+ }
339
+
340
+ function formatDetailText(
341
+ range: { start: number; end: number },
342
+ entries: Array<Entry | ToolResultSummaryEntry>,
343
+ ): string {
344
+ const head = `turns ${rangeLabel(range)} · ${entries.length} entries`
345
+ const body = entries
346
+ .map((e) => {
347
+ if (isToolResultSummary(e)) {
348
+ // v2 O3:摘要态渲染(summary + 头 3 行 + 看全文提示)
349
+ return `---\ntoolResultSummary (${e.id.slice(0, 8)})\n${e.summary}\n │ 共 ${e.totalLines} 行,前 3 行:${e.headLines}\n │ (+ includeToolResult:true 看全文)`
350
+ }
351
+ const role = e.message ? `/${e.message.role}` : ''
352
+ return `---\n${e.type}${role} (${e.id.slice(0, 8)})\n${entryReadableText(e)}`
353
+ })
354
+ .join('\n')
355
+ return `${head}\n${body}`
356
+ }
357
+
358
+ function formatFamilyText(f: Family): string {
359
+ const lines: string[] = []
360
+ lines.push(`root: ${f.root.sessionId} (${formatDate(f.root.mtime)})`)
361
+ if (f.parents.length)
362
+ lines.push(`parents: ${f.parents.map((p) => p.sessionId.slice(0, 8)).join(', ')}`)
363
+ if (f.forks.length)
364
+ lines.push(`forks: ${f.forks.map((p) => p.sessionId.slice(0, 8)).join(', ')}`)
365
+ if (f.subagents.length)
366
+ lines.push(
367
+ `subagents:\n${f.subagents
368
+ .map(
369
+ (s) =>
370
+ ` ${s.sessionId.slice(0, 8)} root=${s.rootSessionId.slice(0, 8)} slug=${s.slug}${
371
+ s.cleanedUp ? ' [已清理]' : ''
372
+ }`,
373
+ )
374
+ .join('\n')}`,
375
+ )
376
+ if (f.workflows.length)
377
+ lines.push(
378
+ `workflows:\n${f.workflows
379
+ .map((w) => ` ${w.runId} (${w.calls.length} calls)`)
380
+ .join('\n')}`,
381
+ )
382
+ return lines.join('\n')
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // search 辅助
387
+ // ---------------------------------------------------------------------------
388
+
389
+ /**
390
+ * 灾难性正则形态探测(MF-5):组内含量词/`|` 且组本身又被量词修饰的 pattern
391
+ *(`(a+)+`、`(a*)*`、`(a|aa)+`、`(a{1,3})*` 等)对长文本指数级回溯,可挂死整个 turn(5.4MB session
392
+ * 全文逐 entry 匹配)。内层字符类含 `{` 以捕获 `{m,n}` 范围量词(MF-1);`(a{1,3})` 单独使用
393
+ *(组后无尾随量词)不命中,仍按正则执行。命中则降级为字面子串匹配(与非法正则同一兜底路径)。
394
+ * 保守拒绝(把合法但形似的 pattern 降级为子串)比挂死可接受。
395
+ */
396
+ function isCatastrophicPattern(pattern: string): boolean {
397
+ return (
398
+ /\((?:[^()\\]|\\.)*[+*?{](?:[^()\\]|\\.)*\)[+*?{]/.test(pattern) ||
399
+ /\((?:[^()\\]|\\.)*\|(?:[^()\\]|\\.)*\)[+*?{]/.test(pattern)
400
+ )
401
+ }
402
+
403
+ /** 编译检索 pattern:先当正则,非法/灾难性则转义为字面子串(design §3.4 pattern 子串或正则)。 */
404
+ function compilePattern(pattern: string): RegExp {
405
+ if (isCatastrophicPattern(pattern)) {
406
+ return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
407
+ }
408
+ try {
409
+ return new RegExp(pattern, 'i')
410
+ } catch {
411
+ return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
412
+ }
413
+ }
414
+
415
+ /** 安全序列化:循环引用等异常时返回空串(catch 非空——记默认值)。 */
416
+ function safeStringify(v: unknown): string {
417
+ try {
418
+ return JSON.stringify(v)
419
+ } catch {
420
+ return '' // 循环引用等致 stringify 失败,跳过该块
421
+ }
422
+ }
423
+
424
+ /** search 的可检索文本:含 text/thinking/toolResult 全量(按 scope 过滤由调用方做)。 */
425
+ function searchableText(content: unknown): string {
426
+ if (typeof content === 'string') return content
427
+ if (Array.isArray(content)) {
428
+ const parts: string[] = []
429
+ for (const b of content) {
430
+ if (b && typeof b === 'object') {
431
+ const o = b as Record<string, unknown>
432
+ if (typeof o.text === 'string') parts.push(o.text)
433
+ else if (typeof o.thinking === 'string') parts.push(o.thinking)
434
+ else {
435
+ const serialized = safeStringify(o)
436
+ if (serialized) parts.push(serialized)
437
+ }
438
+ }
439
+ }
440
+ return parts.join('\n')
441
+ }
442
+ return safeStringify(content)
443
+ }
444
+
445
+ function snippet(text: string, idx: number, len: number): string {
446
+ const start = Math.max(0, idx - 20)
447
+ const end = Math.min(text.length, idx + len + 20)
448
+ return (
449
+ (start > 0 ? '…' : '') +
450
+ text.slice(start, end).replace(/\s+/g, ' ').trim() +
451
+ (end < text.length ? '…' : '')
452
+ )
453
+ }
454
+
455
+ // ===========================================================================
456
+ // 各 action 实现
457
+ // ===========================================================================
458
+
459
+ /** find:按片段/名称/recent 定位 session(design §3.4 find)。零匹配不抛,返回提示。 */
460
+ async function doFind(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
461
+ const query = requireStr(params.query, 'query', 'find')
462
+ const { matches, truncated } = await findSessions(query, agentDir, {
463
+ cwd: params.cwd,
464
+ limit: params.limit ?? 20,
465
+ })
466
+ if (matches.length === 0) {
467
+ const recent = await findSessions('recent', agentDir, { limit: 10 })
468
+ return {
469
+ content: [{ type: 'text', text: formatNoMatch(query, recent.matches) }],
470
+ details: { matches: [], truncated: false },
471
+ }
472
+ }
473
+ return {
474
+ content: [{ type: 'text', text: formatFindContent(query, matches, truncated) }],
475
+ details: { matches, truncated },
476
+ }
477
+ }
478
+
479
+ /** family:fork 父链/子代 + 隔代 subagent + workflow run(design §3.4 family)。 */
480
+ async function doFamily(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
481
+ const resolved = await resolveSessionId(params.session, 'family', agentDir)
482
+ if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
483
+ let family: Family
484
+ try {
485
+ family = await buildFamilyFromFs(resolved.sessionId, agentDir)
486
+ } catch (e) {
487
+ throw err(
488
+ `读取家族失败:${resolved.sessionId}(${e instanceof Error ? e.message : String(e)})。👉 检查 session 或用 find 重新定位。`,
489
+ )
490
+ }
491
+ return { content: [{ type: 'text', text: formatFamilyText(family) }], details: family }
492
+ }
493
+
494
+ /** outline:turn 级全貌 TOC(design §3.4 outline,~500 token)。 */
495
+ async function doOutline(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
496
+ const resolved = await resolveSessionId(params.session, 'outline', agentDir)
497
+ if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
498
+ const { entries, totalBytes } = await safeParse(resolved.fileName)
499
+ const tree = buildTreeView(entries)
500
+ const turns = segmentTurns(entries, new Set(tree.leafPath))
501
+ const opts: OutlineOptions = {
502
+ budget: 2000,
503
+ allBranches: params.allBranches,
504
+ granularity: params.granularity,
505
+ }
506
+ const result = renderOutline(turns, tree, opts)
507
+ // 覆盖 stats.totalBytes:render 用 parsedBytes(leaf entry JSON 字节和)近似,
508
+ // 此处用 ParseResult.totalBytes(原始文件字节数,design §3.4 stats.totalBytes 语义)
509
+ result.stats.totalBytes = totalBytes
510
+ return { content: [{ type: 'text', text: formatOutlineText(result) }], details: result }
511
+ }
512
+
513
+ /** expand:单 turn 的 entry 列表(design §3.4 expand)。turn 越界抛 F4。 */
514
+ async function doExpand(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
515
+ const resolved = await resolveSessionId(params.session, 'expand', agentDir)
516
+ if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
517
+ const turnIdx = parseTurnIndex(requireStr(params.turn, 'turn', 'expand'))
518
+ const { entries } = await safeParse(resolved.fileName)
519
+ const tree = buildTreeView(entries)
520
+ const turns = segmentTurns(entries, new Set(tree.leafPath))
521
+ const turn = turns.find((t) => t.index === turnIdx)
522
+ if (turn === undefined) {
523
+ const max = turns.length - 1
524
+ throw err(
525
+ `turn T${pad(turnIdx)} 越界,该 session 共 ${turns.length} 轮(T000-T${pad(Math.max(0, max))})。👉 用 outline 重看有效范围。`,
526
+ )
527
+ }
528
+ const result = renderExpand(turn)
529
+ return {
530
+ content: [{ type: 'text', text: formatExpandText(result.turn, result.entries) }],
531
+ details: result,
532
+ }
533
+ }
534
+
535
+ /** detail:turns 范围的完整文本(design §3.4 detail)。默认省略 toolResult/thinking。 */
536
+ async function doDetail(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
537
+ const resolved = await resolveSessionId(params.session, 'detail', agentDir)
538
+ if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
539
+ const range = parseTurnsRange(requireStr(params.turns, 'turns', 'detail'))
540
+ const { entries } = await safeParse(resolved.fileName)
541
+ const tree = buildTreeView(entries)
542
+ const turns = segmentTurns(entries, new Set(tree.leafPath))
543
+ const max = turns.length - 1
544
+ if (turns.length === 0 || range.start > max || range.end > max) {
545
+ throw err(
546
+ `turns "${rangeLabel(range)}" 越界,该 session 共 ${turns.length} 轮(T000-T${pad(Math.max(0, max))})。👉 用 outline 重看有效范围。`,
547
+ )
548
+ }
549
+ const inRange = turns.filter((t) => t.index >= range.start && t.index <= range.end)
550
+ const det = renderDetail(inRange, {
551
+ includeToolResult: params.includeToolResult,
552
+ includeThinking: params.includeThinking,
553
+ })
554
+ return {
555
+ content: [{ type: 'text', text: formatDetailText(range, det) }],
556
+ details: { turns: rangeLabel(range), entries: det },
557
+ }
558
+ }
559
+
560
+ /** search:session 内全文检索(design §3.4 search,M3 新实现)。 */
561
+ async function doSearch(
562
+ params: SessionReadParams,
563
+ agentDir: string,
564
+ signal?: AbortSignal,
565
+ ): Promise<ToolResult> {
566
+ const resolved = await resolveSessionId(params.session, 'search', agentDir)
567
+ if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
568
+ const pattern = requireStr(params.pattern, 'pattern', 'search')
569
+ const scope = params.scope ?? 'all'
570
+ const limit = params.limit ?? 20
571
+ const { entries } = await safeParse(resolved.fileName)
572
+ const tree = buildTreeView(entries)
573
+ const turns = segmentTurns(entries, new Set(tree.leafPath))
574
+ const regex = compilePattern(pattern)
575
+ // S-3:启发式降级时在 header 标注,避免 LLM 把 0 hit(s) 误读为「无匹配」(静默错数据)
576
+ const degraded = isCatastrophicPattern(pattern)
577
+ const hits: Array<{
578
+ turnIndex: number
579
+ entryIndex: number
580
+ role: string
581
+ matchSnippet: string
582
+ }> = []
583
+ for (const t of turns) {
584
+ // MF-5:Esc/abort 后 pi 已丢弃本 turn 结果,尽早退出避免继续扫描长 session
585
+ if (signal?.aborted) {
586
+ throw err('搜索已中断(信号 aborted)。👉 重试或换更精确的 pattern。')
587
+ }
588
+ for (let i = 0; i < t.entries.length; i++) {
589
+ const msg = t.entries[i].message
590
+ if (msg === undefined) continue
591
+ if (scope !== 'all' && msg.role !== scope) continue
592
+ const text = searchableText(msg.content)
593
+ const m = regex.exec(text)
594
+ if (m !== null) {
595
+ hits.push({
596
+ turnIndex: t.index,
597
+ entryIndex: i,
598
+ role: msg.role,
599
+ matchSnippet: snippet(text, m.index, m[0].length),
600
+ })
601
+ }
602
+ }
603
+ }
604
+ const truncated = hits.length > limit
605
+ const sliced = truncated ? hits.slice(0, limit) : hits
606
+ const lines = sliced.map(
607
+ (h) => ` T${pad(h.turnIndex)} #${h.entryIndex} ${h.role}: ${h.matchSnippet}`,
608
+ )
609
+ const text = `${sliced.length} hit(s) for /${pattern}/${degraded ? '(已降级为字面子串匹配)' : ''}${
610
+ scope !== 'all' ? ' scope=' + scope : ''
611
+ }${truncated ? ` (truncated, showing first ${sliced.length})` : ''}\n${lines.join('\n')}`
612
+ return { content: [{ type: 'text', text }], details: { hits: sliced, truncated } }
613
+ }
614
+
615
+ /** export:物化摘要到 <agentDir>/tmp/session-view-<id>.md(design §3.4 export,D-8)。 */
616
+ async function doExport(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
617
+ const format = params.format ?? 'outline'
618
+ const resolved = await resolveSessionId(params.session, 'export', agentDir)
619
+ if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
620
+
621
+ let text: string
622
+ let label: string
623
+ if (format === 'family') {
624
+ let family: Family
625
+ try {
626
+ family = await buildFamilyFromFs(resolved.sessionId, agentDir)
627
+ } catch (e) {
628
+ throw err(
629
+ `读取家族失败:${resolved.sessionId}(${e instanceof Error ? e.message : String(e)})。👉 检查 session 或用 find 重新定位。`,
630
+ )
631
+ }
632
+ text = formatFamilyText(family)
633
+ label = 'family'
634
+ } else if (format === 'full') {
635
+ const { entries } = await safeParse(resolved.fileName)
636
+ const tree = buildTreeView(entries)
637
+ const turns = segmentTurns(entries, new Set(tree.leafPath))
638
+ const det = renderDetail(turns, {
639
+ includeToolResult: params.includeToolResult,
640
+ includeThinking: false,
641
+ })
642
+ text = det
643
+ .map((e) => {
644
+ if (isToolResultSummary(e)) {
645
+ return `${'='.repeat(40)}\ntoolResultSummary (${e.id.slice(0, 8)})\n${entryReadableText(e)}`
646
+ }
647
+ return `${'='.repeat(40)}\n${e.type}${e.message ? '/' + e.message.role : ''} (${e.id.slice(0, 8)})\n${entryReadableText(e)}`
648
+ })
649
+ .join('\n')
650
+ label = 'full'
651
+ } else {
652
+ const { entries } = await safeParse(resolved.fileName)
653
+ const tree = buildTreeView(entries)
654
+ const turns = segmentTurns(entries, new Set(tree.leafPath))
655
+ const result = renderOutline(turns, tree, {
656
+ budget: 2000,
657
+ allBranches: params.allBranches,
658
+ granularity: params.granularity,
659
+ })
660
+ text = formatOutlineText(result)
661
+ label = 'outline'
662
+ }
663
+
664
+ const outDir = join(agentDir, 'tmp')
665
+ const outPath = join(outDir, `session-view-${resolved.sessionId}.md`)
666
+ await mkdir(outDir, { recursive: true })
667
+ await writeFile(outPath, text, 'utf8')
668
+ const sizeBytes = Buffer.byteLength(text, 'utf8')
669
+ return {
670
+ content: [
671
+ {
672
+ type: 'text',
673
+ text: `已导出 ${label} 视图到 ${outPath}(${sizeBytes} bytes)。可用 read/grep 进一步检索。`,
674
+ },
675
+ ],
676
+ details: { path: outPath, sizeBytes },
677
+ }
678
+ }
679
+
680
+ // ===========================================================================
681
+ // extract action(v2 O4:跨 turn 按类型提取素材)
682
+ // ===========================================================================
683
+ //
684
+ // design §3.3 D3 的 5 个预设 + F7/F8/F9 错误规格。复用 O1 共享层(extractToolCalls /
685
+ // formatToolCallSummary / basename)与现有 resolveSessionId/safeParse/buildTreeView/
686
+ // segmentTurns/parseTurnsRange。纯提取,不调 LLM。
687
+
688
+ /** extract 的 5 个合法 what(design §3.3 D3)。 */
689
+ type ExtractWhat = 'user-messages' | 'commands' | 'files' | 'commits' | 'tool-results'
690
+
691
+ /** extract 结果预算(design §3.3 F9):8000 字节 ≈ 2000 token。 */
692
+ const EXTRACT_BUDGET_BYTES = 8000
693
+
694
+ /** 含 path 参数的文件类工具(design §3.3 D3 files scope)。 */
695
+ const FILE_TOOLS = new Set(['read', 'edit', 'write', 'head'])
696
+
697
+ /** git 命令关键词(commits 预设判定 bash 结果是否来自 git 命令)。 */
698
+ const GIT_CMD_RE = /\bgit\s+(log|show|commit|push|merge|cherry-pick|revert|reset|rebase|diff)\b/
699
+ /** git short hash(commits 预设,保守限定 7-8 位避免 uuid 全量误报)。 */
700
+ const SHORT_HASH_RE = /\b[0-9a-f]{7,8}\b/g
701
+ /** commit 上下文消歧关键词(commits 次路径:hash 附近出现才纳入)。 */
702
+ const COMMIT_CTX_RE = /feat:|fix:|refactor:|chore:|docs:|\b(commit|commits|merged|pushed|merge)\b/i
703
+
704
+ /** what 类型守卫(直接比较,避开不安全断言;schema 已校验,此处防御 + 可单测绕过)。 */
705
+ function isExtractWhat(v: unknown): v is ExtractWhat {
706
+ return (
707
+ v === 'user-messages' ||
708
+ v === 'commands' ||
709
+ v === 'files' ||
710
+ v === 'commits' ||
711
+ v === 'tool-results'
712
+ )
713
+ }
714
+
715
+ /**
716
+ * 从 message.content 提取纯 text(string 直取;数组拼接 text 块)。
717
+ *
718
+ * 与 render.ts 内部 extractText 同语义,但那未导出;extract 仅需纯 text
719
+ *(user-messages / tool-results 的正文),不要 thinking/toolCall 占位,本地实现。
720
+ * content 是 unknown 做类型守卫。
721
+ */
722
+ function extractContentText(content: unknown): string {
723
+ if (typeof content === 'string') return content
724
+ if (Array.isArray(content)) {
725
+ const parts: string[] = []
726
+ for (const b of content) {
727
+ if (b !== null && typeof b === 'object') {
728
+ const o = b as Record<string, unknown>
729
+ if (o.type === 'text' && typeof o.text === 'string') parts.push(o.text)
730
+ }
731
+ }
732
+ return parts.join('\n')
733
+ }
734
+ return ''
735
+ }
736
+
737
+ /** 截断到 max 字符,超出加省略号(防爆;tool-results 正文用)。 */
738
+ function truncateText(s: string, max: number): string {
739
+ return s.length <= max ? s : s.slice(0, max) + '…'
740
+ }
741
+
742
+ /** turns 数组紧凑标签(前 5 个 + +N,避免一行过长撑爆预算)。 */
743
+ function turnsLabel(turns: number[]): string {
744
+ const head = turns.slice(0, 5).map((n) => `T${pad(n)}`)
745
+ const suffix = turns.length > 5 ? `+${turns.length - 5}` : ''
746
+ return head.join(',') + suffix
747
+ }
748
+
749
+ /**
750
+ * 计算工具分布(按出现次数降序),用于 F8 提示 + details.toolDistribution。
751
+ * 遍历 assistant entry 的 toolCall,复用 extractToolCalls。
752
+ */
753
+ function computeToolDistribution(
754
+ turns: Turn[],
755
+ ): Array<{ name: string; count: number }> {
756
+ const counts = new Map<string, number>()
757
+ for (const t of turns) {
758
+ for (const e of t.entries) {
759
+ if (e.message?.role !== 'assistant') continue
760
+ for (const tc of extractToolCalls(e)) {
761
+ counts.set(tc.name, (counts.get(tc.name) ?? 0) + 1)
762
+ }
763
+ }
764
+ }
765
+ return Array.from(counts.entries())
766
+ .map(([name, count]) => ({ name, count }))
767
+ .sort((a, b) => b.count - a.count)
768
+ }
769
+
770
+ /** F8:commands/tool-results 的 tool 过滤零匹配 → 返回工具分布 + 👉(不抛错,design §3.3 F8)。 */
771
+ function f8ToolNoMatch(what: ExtractWhat, tool: string, turns: Turn[]): ToolResult {
772
+ const dist = computeToolDistribution(turns).slice(0, 10)
773
+ const distStr = dist.map((d) => `${d.name}×${d.count}`).join(', ')
774
+ const text = `what=${what} tool="${tool}" 无匹配。该 session 工具:${distStr}。👉 用存在的工具名重试。`
775
+ return { content: [{ type: 'text', text }], details: { what, tool, toolDistribution: dist } }
776
+ }
777
+
778
+ /**
779
+ * 通用预算渲染:逐项累加字节,超 EXTRACT_BUDGET_BYTES 截断(design §3.3 F9)。
780
+ *
781
+ * details.items 放实际展示的子集(截断后),count 放全集长度,测试可断言 shown/count/truncated。
782
+ * emptyHint 仅 items 为空时用(files/commits 无匹配不报错,返空 + 提示)。
783
+ *
784
+ * 预算控制:按 item 累计字节达预算即截断。**首项超大也内部截断**(对单行 slice 到剩余字节预算,
785
+ * 字节→字符 ×3 近似防 UTF8 多字节被切半),保证 body 不超预算——而非放行首项致 body 远超预算。
786
+ * 导出供 tool-handler.test 单测 F9 截断逻辑(首项截断 + 文案含 turn 范围 + 实际 token)。
787
+ *
788
+ * getTurns:从 item 提取 turn 列表(files 是 turns 数组,其余单值包数组),供 F9 文案报 turn 范围。
789
+ */
790
+ export function renderExtractItems<I>(
791
+ what: ExtractWhat,
792
+ items: I[],
793
+ renderLine: (item: I) => string,
794
+ getTurns: (item: I) => number[],
795
+ emptyHint?: string,
796
+ ): ToolResult {
797
+ if (items.length === 0) {
798
+ const text = emptyHint ?? `what=${what} 无匹配。`
799
+ return {
800
+ content: [{ type: 'text', text }],
801
+ details: { what, count: 0, shown: 0, truncated: false, items: [] },
802
+ }
803
+ }
804
+ const shown: I[] = []
805
+ const shownLines: string[] = []
806
+ let bytes = 0
807
+ let cut = false
808
+ for (const item of items) {
809
+ const line = renderLine(item)
810
+ const lineBytes = Buffer.byteLength(line, 'utf8') + 1 // +\n
811
+ if (bytes + lineBytes > EXTRACT_BUDGET_BYTES) {
812
+ // 超预算:对当前 line 内部截断到剩余预算(首项超大也截断,但保留截断后的内容)
813
+ const remainingBytes = EXTRACT_BUDGET_BYTES - bytes
814
+ const charBudget = Math.floor(remainingBytes / 3) // 字节→字符 ×3 近似防 UTF8 切半
815
+ if (charBudget > 0) {
816
+ const sliced = line.slice(0, charBudget) + '…'
817
+ shown.push(item)
818
+ shownLines.push(sliced)
819
+ }
820
+ cut = true
821
+ break
822
+ }
823
+ shown.push(item)
824
+ shownLines.push(line)
825
+ bytes += lineBytes
826
+ }
827
+ const body = shownLines.join('\n')
828
+ if (!cut) {
829
+ return {
830
+ content: [{ type: 'text', text: body }],
831
+ details: {
832
+ what,
833
+ count: items.length,
834
+ shown: shown.length,
835
+ truncated: false,
836
+ items: shown,
837
+ },
838
+ }
839
+ }
840
+ // F9:超预算截断。tokens 反映 body 实际体积(非固定 2000);文案报 shown/count + turn 范围 + 实际 token
841
+ const shownTurns = shown.flatMap(getTurns)
842
+ const turnRange =
843
+ shownTurns.length > 0
844
+ ? `(T${pad(Math.min(...shownTurns))}-T${pad(Math.max(...shownTurns))})`
845
+ : ''
846
+ const actualTokens = Math.round(Buffer.byteLength(body, 'utf8') / 4)
847
+ const text =
848
+ body +
849
+ `\n[what=${what} 已显示 ${shown.length}/${items.length} 项${turnRange},约 ${actualTokens} token 达预算上限。👉 用较小 turns 范围(如 T000-T005)缩小,或换 what 重试。]`
850
+ return {
851
+ content: [{ type: 'text', text }],
852
+ details: {
853
+ what,
854
+ count: items.length,
855
+ shown: shown.length,
856
+ truncated: true,
857
+ items: shown,
858
+ },
859
+ }
860
+ }
861
+
862
+ /** 预设 1:user-messages——收集 role==='user' 的全文(按 turn 排列,design §3.3 D3)。 */
863
+ function extractUserMessages(turns: Turn[]): ToolResult {
864
+ const items: Array<{ turn: number; text: string }> = []
865
+ for (const t of turns) {
866
+ for (const e of t.entries) {
867
+ if (e.message?.role !== 'user') continue
868
+ items.push({ turn: t.index, text: extractContentText(e.message.content) })
869
+ }
870
+ }
871
+ return renderExtractItems(
872
+ 'user-messages',
873
+ items,
874
+ (it) => `T${pad(it.turn)}: ${it.text}`,
875
+ (it) => [it.turn],
876
+ )
877
+ }
878
+
879
+ /**
880
+ * 预设 2:commands——assistant 的 toolCall,带 name + D1 摘要(design §3.3 D3)。
881
+ * 可选 tool 过滤;过滤后零匹配 → F8(工具分布 + 👉,不抛错)。
882
+ * index = entry 在 turn.entries 内的位置,与 expand 的 [N] 对齐便于定位。
883
+ */
884
+ function extractCommands(turns: Turn[], tool: string | undefined): ToolResult {
885
+ const items: Array<{ turn: number; index: number; name: string; summary: string }> = []
886
+ for (const t of turns) {
887
+ for (let ei = 0; ei < t.entries.length; ei++) {
888
+ const e = t.entries[ei]
889
+ if (e.message?.role !== 'assistant') continue
890
+ for (const tc of extractToolCalls(e)) {
891
+ if (tool !== undefined && tc.name !== tool) continue
892
+ items.push({
893
+ turn: t.index,
894
+ index: ei,
895
+ name: tc.name,
896
+ summary: formatToolCallSummary(tc),
897
+ })
898
+ }
899
+ }
900
+ }
901
+ if (tool !== undefined && items.length === 0) return f8ToolNoMatch('commands', tool, turns)
902
+ return renderExtractItems(
903
+ 'commands',
904
+ items,
905
+ (it) => `T${pad(it.turn)} #${it.index} ${it.summary}`,
906
+ (it) => [it.turn],
907
+ )
908
+ }
909
+
910
+ /**
911
+ * 预设 3:files——read/edit/write/head 的 path 去重(design §3.3 D3)。
912
+ * 同 path 多次操作合并,op 聚合成 `read+edit` 形式,turns 记录出现过的轮次。
913
+ * todo/subagent/cw 无 path 不纳入。无匹配不报错(返空 + 提示)。
914
+ */
915
+ function extractFiles(turns: Turn[]): ToolResult {
916
+ const map = new Map<string, { ops: Set<string>; turns: Set<number> }>()
917
+ for (const t of turns) {
918
+ for (const e of t.entries) {
919
+ if (e.message?.role !== 'assistant') continue
920
+ for (const tc of extractToolCalls(e)) {
921
+ if (!FILE_TOOLS.has(tc.name)) continue
922
+ const p = tc.arguments.path
923
+ if (typeof p !== 'string') continue
924
+ let rec = map.get(p)
925
+ if (rec === undefined) {
926
+ rec = { ops: new Set(), turns: new Set() }
927
+ map.set(p, rec)
928
+ }
929
+ rec.ops.add(tc.name)
930
+ rec.turns.add(t.index)
931
+ }
932
+ }
933
+ }
934
+ const items = Array.from(map.entries()).map(([path, rec]) => ({
935
+ path,
936
+ basename: basename(path),
937
+ op: Array.from(rec.ops).sort().join('+'),
938
+ turns: Array.from(rec.turns).sort((a, b) => a - b),
939
+ }))
940
+ return renderExtractItems(
941
+ 'files',
942
+ items,
943
+ (it) => `${it.op}: ${it.path} (${turnsLabel(it.turns)})`,
944
+ (it) => it.turns,
945
+ `what=files 无匹配(该 session 无 read/edit/write/head 文件操作)。`,
946
+ )
947
+ }
948
+
949
+ /**
950
+ * 预设 4:commits——git 命令 toolResult 的 hash(design §3.3 D3 + D6 误匹配处理)。
951
+ *
952
+ * 保守策略(宁可少召回不要乱报 uuid):
953
+ * ① 主路径(高置信):只从 bash 且关联 command 含 git (log|show|commit|push|merge|...) 的
954
+ * toolResult 文本提取 7-8 位 hex;
955
+ * ② 次路径(中置信):扫所有 toolResult 文本,hash 前后各 30 字符内含
956
+ * feat:/fix:/commit/merge 等关键词的才纳入;
957
+ * ③ 去重,git-cmd 置信度优先;不扫 user/assistant 自由文本(uuid/session-id 误报太多)。
958
+ *
959
+ * 已知局限:7-8 位 hex 与 uuid v7 片段形似,靠 git 命令上下文过滤;仍可能漏报
960
+ *(git 操作未被 toolResult 捕获)或误报(git log 输出里的其他 hex)。每条标注来源 turn
961
+ * + source + context,agent 可快速辨认。完全语义判断需 LLM,本工具零 LLM 依赖。
962
+ */
963
+ function extractCommits(turns: Turn[]): ToolResult {
964
+ // 建 toolCallId → bash command 映射(用于判定 toolResult 是否来自 git 命令)
965
+ const bashCmds = new Map<string, string>()
966
+ for (const t of turns) {
967
+ for (const e of t.entries) {
968
+ if (e.message?.role !== 'assistant') continue
969
+ for (const tc of extractToolCalls(e)) {
970
+ if (tc.name === 'bash') {
971
+ const cmd = tc.arguments.command
972
+ if (typeof cmd === 'string') bashCmds.set(tc.id, cmd)
973
+ }
974
+ }
975
+ }
976
+ }
977
+
978
+ type CommitItem = {
979
+ hash: string
980
+ turn: number
981
+ source: 'git-cmd' | 'commit-context'
982
+ context: string
983
+ }
984
+ const high: CommitItem[] = []
985
+ const low: CommitItem[] = []
986
+
987
+ for (const t of turns) {
988
+ for (const e of t.entries) {
989
+ if (e.message?.role !== 'toolResult') continue
990
+ const msg = e.message
991
+ const text = extractContentText(msg.content)
992
+ if (text === '') continue
993
+ const cmd = msg.toolCallId !== undefined ? bashCmds.get(msg.toolCallId) : undefined
994
+ const isGitBash = msg.toolName === 'bash' && cmd !== undefined && GIT_CMD_RE.test(cmd)
995
+ for (const m of text.matchAll(SHORT_HASH_RE)) {
996
+ const hash = m[0]
997
+ const idx = m.index ?? 0
998
+ const ctx = text
999
+ .slice(Math.max(0, idx - 30), idx + hash.length + 30)
1000
+ .replace(/\s+/g, ' ')
1001
+ .trim()
1002
+ if (isGitBash) {
1003
+ high.push({ hash, turn: t.index, source: 'git-cmd', context: ctx })
1004
+ } else if (COMMIT_CTX_RE.test(ctx)) {
1005
+ low.push({ hash, turn: t.index, source: 'commit-context', context: ctx })
1006
+ }
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ // 去重:高置信优先,同 hash 保留首次
1012
+ const seen = new Set<string>()
1013
+ const items: CommitItem[] = []
1014
+ for (const c of high) {
1015
+ if (seen.has(c.hash)) continue
1016
+ seen.add(c.hash)
1017
+ items.push(c)
1018
+ }
1019
+ for (const c of low) {
1020
+ if (seen.has(c.hash)) continue
1021
+ seen.add(c.hash)
1022
+ items.push(c)
1023
+ }
1024
+
1025
+ return renderExtractItems(
1026
+ 'commits',
1027
+ items,
1028
+ (it) => `T${pad(it.turn)} ${it.hash} [${it.source}] ${it.context}`,
1029
+ (it) => [it.turn],
1030
+ `what=commits 无匹配(该 session 无 git commit hash,或未在 toolResult 中出现)。`,
1031
+ )
1032
+ }
1033
+
1034
+ /**
1035
+ * 预设 5:tool-results——role==='toolResult' 文本(design §3.3 D3)。
1036
+ * text 截断到 500 字防爆;可选 tool 过滤(msg.toolName);过滤零匹配 → F8。
1037
+ */
1038
+ function extractToolResults(turns: Turn[], tool: string | undefined): ToolResult {
1039
+ const items: Array<{ turn: number; index: number; toolName: string; text: string }> = []
1040
+ for (const t of turns) {
1041
+ for (let ei = 0; ei < t.entries.length; ei++) {
1042
+ const e = t.entries[ei]
1043
+ if (e.message?.role !== 'toolResult') continue
1044
+ const tn = e.message.toolName ?? '?'
1045
+ if (tool !== undefined && tn !== tool) continue
1046
+ const text = truncateText(extractContentText(e.message.content), 500)
1047
+ items.push({ turn: t.index, index: ei, toolName: tn, text })
1048
+ }
1049
+ }
1050
+ if (tool !== undefined && items.length === 0)
1051
+ return f8ToolNoMatch('tool-results', tool, turns)
1052
+ return renderExtractItems(
1053
+ 'tool-results',
1054
+ items,
1055
+ (it) => `T${pad(it.turn)} #${it.index} ${it.toolName}: ${it.text}`,
1056
+ (it) => [it.turn],
1057
+ )
1058
+ }
1059
+
1060
+ /**
1061
+ * extract:跨 turn 按类型提取素材(design §3.3 D3 五预设 + F7/F8/F9)。
1062
+ *
1063
+ * 流程:resolveSessionId(multi 走 disambiguate)→ safeParse → buildTreeView +
1064
+ * segmentTurns → 可选 turns 范围限定(复用 parseTurnsRange)→ F7 校验 what → 分发 5 预设。
1065
+ */
1066
+ async function doExtract(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
1067
+ const resolved = await resolveSessionId(params.session, 'extract', agentDir)
1068
+ if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
1069
+ const { entries } = await safeParse(resolved.fileName)
1070
+ // extract 遍历全量 entry(含旁支/压缩历史),与 outline/expand/detail 的 leaf 视图不同:
1071
+ // 素材提取要全量(design §2.3 实测全量 519 toolCall / 26 user / 515 toolResult),
1072
+ // 用 leafPath 过滤会漏掉旁支素材。turn 标注是全量分段 index(含 compaction 周期 + 旁支
1073
+ // turn),与 outline 的 32 leaf turn index 不一定逐一对齐,但素材内容完整。
1074
+ const allTurns = segmentTurns(entries, new Set(entries.map((e) => e.id)))
1075
+
1076
+ // 可选 turns 范围限定(复用 parseTurnsRange;未传则全 session)
1077
+ let turns = allTurns
1078
+ if (params.turns !== undefined) {
1079
+ const range = parseTurnsRange(params.turns)
1080
+ const max = allTurns.length - 1
1081
+ if (allTurns.length === 0 || range.start > max || range.end > max) {
1082
+ throw err(
1083
+ `turns "${rangeLabel(range)}" 越界,extract 的 turn 范围与 outline 不同(extract 含 compaction 周期/旁支,turn 数更多)。该 session extract 共 ${allTurns.length} 轮(T000-T${pad(Math.max(0, max))})。👉 用较小 turns 范围(如 T000-T005)试探,或先不带 turns extract 看全量 turn 标注。`,
1084
+ )
1085
+ }
1086
+ turns = allTurns.filter((t) => t.index >= range.start && t.index <= range.end)
1087
+ }
1088
+
1089
+ // F7:what 校验(schema 已校验,此处防御 + 可单测绕过 schema)
1090
+ const what = params.what
1091
+ if (!isExtractWhat(what)) {
1092
+ const given = what === undefined ? '(missing)' : String(what)
1093
+ throw err(
1094
+ `what "${given}" 无效,应为 user-messages/commands/files/commits/tool-results。👉 用合法 what 重试。`,
1095
+ )
1096
+ }
1097
+
1098
+ switch (what) {
1099
+ case 'user-messages':
1100
+ return extractUserMessages(turns)
1101
+ case 'commands':
1102
+ return extractCommands(turns, params.tool)
1103
+ case 'files':
1104
+ return extractFiles(turns)
1105
+ case 'commits':
1106
+ return extractCommits(turns)
1107
+ case 'tool-results':
1108
+ return extractToolResults(turns, params.tool)
1109
+ default: {
1110
+ // exhaustive guard:5 预设全覆盖,default 不可达;防御未来新增 what 未加 case
1111
+ const exhaustive: never = what
1112
+ throw err(`unreachable extract what: ${JSON.stringify(exhaustive)}`)
1113
+ }
1114
+ }
1115
+ }
1116
+
1117
+ // ===========================================================================
1118
+ // 入口:按 action 分发
1119
+ // ===========================================================================
1120
+
1121
+ /**
1122
+ * session_read 工具的纯逻辑 handler(agentDir 注入,零 pi 依赖,可单测)。
1123
+ *
1124
+ * 按 params.action 分发到 doFind/doFamily/doOutline/doExpand/doDetail/doSearch/doExport。
1125
+ * F1(resolve)/F4/F5/F6 抛 Error(含 👉);F2 多匹配与 find 零匹配返回结果不抛。
1126
+ *
1127
+ * @param signal 可选 AbortSignal(MF-5):仅 search 消费(长扫描可中断);其余 action 有界,不接。
1128
+ */
1129
+ export async function handleSessionRead(
1130
+ params: SessionReadParams,
1131
+ agentDir: string,
1132
+ signal?: AbortSignal,
1133
+ ): Promise<ToolResult> {
1134
+ switch (params.action) {
1135
+ case 'find':
1136
+ return doFind(params, agentDir)
1137
+ case 'family':
1138
+ return doFamily(params, agentDir)
1139
+ case 'outline':
1140
+ return doOutline(params, agentDir)
1141
+ case 'expand':
1142
+ return doExpand(params, agentDir)
1143
+ case 'detail':
1144
+ return doDetail(params, agentDir)
1145
+ case 'search':
1146
+ return doSearch(params, agentDir, signal)
1147
+ case 'export':
1148
+ return doExport(params, agentDir)
1149
+ case 'extract':
1150
+ return doExtract(params, agentDir)
1151
+ default: {
1152
+ // exhaustive guard:switch 覆盖全部 8 action,此处 params.action 收窄为 never;
1153
+ // 仅防御运行时非法 action(schema 正常校验下不可达)
1154
+ const exhaustive: never = params.action
1155
+ throw err(
1156
+ `未知 action "${JSON.stringify(exhaustive)}"。👉 合法 action: find/family/outline/expand/detail/search/export/extract。`,
1157
+ )
1158
+ }
1159
+ }
1160
+ }