@zhushanwen/pi-session-reader 0.3.0 → 0.4.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,244 @@
1
+ /**
2
+ * result action(subagent-sync-collect U6:design subagent-sync-collect.md §3.1.3)。
3
+ *
4
+ * 从 tool-handler.ts 机械提取(max-lines 拆分轮):纯移动零行为变更。tool-handler 的
5
+ * 定位/解析 helper(err/stripHash/requireStr/resolveSessionId/disambiguate/safeParse)
6
+ * 为文件私有且被其余 9 个 action 共用,不便搬移——经 {@link ResultActionDeps} 注入,
7
+ * tool-handler.ts 构造 RESULT_ACTION_DEPS 常量传入(构造期绑定,运行时零查找开销)。
8
+ *
9
+ * 设计来源:docs/design/subagent-sync-collect.md §3.1.3 ——
10
+ * session_read {"action":"result","session":"sa-aaa"} // 单个
11
+ * session_read {"action":"result","session":"sa-aaa,sa-bbb,sa-ccc"} // 批量(≤10)
12
+ * → 每条返回该 subagent session 的最终 assistant 正文(与通知 record.result 同源),
13
+ * 可选 limit 参数(默认 8000 字符/条,超出截断 + 提示读原文件)。
14
+ * 定位复用既有发现机制(sa-xxx manifest 反查 / uuid 片段 / 路径),不新造目录或文件。
15
+ */
16
+ import type { Entry, ParseResult } from './core/parser.js'
17
+ import type { MatchedSession } from './discovery/find.js'
18
+ import { listRecordManifests, type RecordManifest } from './discovery/subagents.js'
19
+ import type { ResolveResult, SessionReadAction, SessionReadParams, ToolResult } from './tool-handler.js'
20
+
21
+ /** result 批量上限(design subagent-sync-collect §3.1.3:一次最多 10 个)。 */
22
+ const RESULT_MAX_BATCH = 10
23
+
24
+ /** result 单条默认字符上限(design §3.1.3:默认 8000 字符/条)。 */
25
+ const RESULT_DEFAULT_LIMIT = 8000
26
+
27
+ /** result 批量条目分隔符(与 notifier 批通知 "\n\n---\n\n" join 同款)。 */
28
+ const RESULT_BATCH_SEPARATOR = '\n\n---\n\n'
29
+
30
+ /** result action 对 tool-handler 私有 helper 的注入面(构造期常量绑定)。 */
31
+ export interface ResultActionDeps {
32
+ err(message: string): Error
33
+ stripHash(s: string): string
34
+ requireStr(val: string | undefined, name: string, action: SessionReadAction): string
35
+ resolveSessionId(
36
+ rawSession: string | undefined,
37
+ action: SessionReadAction,
38
+ agentDir: string,
39
+ source?: 'main' | 'subagent',
40
+ /** S3:批量预取的 manifest 列表(避免逐 id 重复全量扫描);单 id 不传。 */
41
+ prefetchedManifests?: RecordManifest[],
42
+ ): Promise<ResolveResult>
43
+ disambiguate(query: string, candidates: MatchedSession[]): ToolResult
44
+ safeParse(fileName: string): Promise<ParseResult>
45
+ /** 批量头行短 id 截断宽度(tool-handler SESSION_ID_PREFIX_LEN 同源)。 */
46
+ sessionIdPrefixLen: number
47
+ }
48
+
49
+ /**
50
+ * 从 assistant message content 提取纯 text(text 块顺序拼接)。
51
+ *
52
+ * 与 record 侧 text_delta 直累积同构:同一 message 内多个 text 块无分隔拼接
53
+ *(流式 delta 逐段 append),thinking/toolCall 块不入 record.text,此处同样排除。
54
+ *
55
+ * [S7 code-simplify 登记] 本包内第 4 个同構「text 块提取」变体(其余三处均在
56
+ * tool-handler.ts / discovery/find.ts):messageReadableText('' join + 占位符)、
57
+ * extractContentText('\n' join)、extractTextFromContent(' ' join + 空返 undefined)。
58
+ * 本变体的 '' 无分隔拼接是 A4 取回逐字节一致锁定的硬理由(对齐 record.result 的
59
+ * 流式 delta 无分隔累积),不可与带分隔符的变体合并——差异是行为敏感点,勿「顺手统一」。
60
+ */
61
+ function assistantMessageText(content: unknown): string {
62
+ if (typeof content === 'string') return content
63
+ if (!Array.isArray(content)) return ''
64
+ let out = ''
65
+ for (const block of content) {
66
+ if (block !== null && typeof block === 'object') {
67
+ const o = block as Record<string, unknown>
68
+ if (o.type === 'text' && typeof o.text === 'string') out += o.text
69
+ }
70
+ }
71
+ return out
72
+ }
73
+
74
+ /**
75
+ * 从 session entries 提取最终结果正文(与完成通知 record.result 同源,A4 逐字节一致的前置)。
76
+ *
77
+ * 同源语义(packages/subagent-core/src/execution/execution-record.ts getFullText,只读参考):
78
+ * record.result = AgentResult.text = getFullText(record) = 全部 assistant turn 文本
79
+ * filter(非空,空白串按非空) join("\\n\\n");pi 每 turn_end 对应一条 assistant message
80
+ *(agent-session.js turn_end 携带 message),故文件侧重建 = 每条 assistant message 的
81
+ * text 块顺序拼接 → 跨 message 非空过滤 join("\\n\\n")。
82
+ *
83
+ * 取「最后一条 user message 之后」的 assistant 文本:one-shot(单 user prompt,
84
+ * collect:sync 的目标形态)与 join-all 逐字节一致;多轮 chatMode session 对齐
85
+ * 「最终一轮输出」语义。
86
+ *
87
+ * 导出供单测白盒锁定同源重建规则(message 内 '' join / 跨 message '\n\n' join /
88
+ * thinking 与 toolCall 排除 / 空 text 过滤 / 末 user 边界)。
89
+ */
90
+ export function extractFinalAssistantText(entries: Entry[]): string {
91
+ let lastUserIdx = -1
92
+ for (let i = entries.length - 1; i >= 0; i--) {
93
+ if (entries[i]?.message?.role === 'user') {
94
+ lastUserIdx = i
95
+ break
96
+ }
97
+ }
98
+ const texts: string[] = []
99
+ for (let i = lastUserIdx + 1; i < entries.length; i++) {
100
+ const e = entries[i]
101
+ if (e?.message?.role !== 'assistant') continue
102
+ const text = assistantMessageText(e.message.content)
103
+ if (text.length > 0) texts.push(text)
104
+ }
105
+ return texts.join('\n\n')
106
+ }
107
+
108
+ /** result 单条取回结果(单条 details 与批量 details.items 共用形状)。 */
109
+ interface ResultItem {
110
+ /** 调用方原始输入(sa-id / uuid 片段 / 路径) */
111
+ session: string
112
+ /** 反查到的真实 session id(header id,非 sa- 占位) */
113
+ sessionId: string
114
+ /** session .jsonl 绝对路径(截断提示读原文件用) */
115
+ sessionFile: string
116
+ /** 全量正文字符数(截断前口径) */
117
+ totalChars: number
118
+ truncated: boolean
119
+ /** 实际展示正文(截断后含尾提示;未截断时 = 全量正文) */
120
+ text: string
121
+ }
122
+
123
+ /** result 截断尾提示(design §3.1.3:超出截断 + 提示读原文件)。 */
124
+ /** result 条目截断提示行。
125
+ *
126
+ * [S8 code-simplify 口径注] 本函数的 X = **保留**字符数(limit);而 subagent-core
127
+ * notifier.ts buildTruncationPointer 同模板的 X = **丢弃**字符数(total - kept)。
128
+ * 两处措辞同构但口径相反(主 agent 会先后消费两种通知),统一字符串 = 行为变更,
129
+ * 勿顺手改口径——读本行时先确认在消费哪一侧。
130
+ * [C6] 英文去 emoji(对齐 notifier 指针行的纯英文形态,adversarial-review-fixes §3.4)。 */
131
+ function formatResultTruncation(
132
+ raw: string,
133
+ sessionFile: string,
134
+ limit: number,
135
+ totalChars: number,
136
+ ): string {
137
+ return (
138
+ `\n\n[truncated ${limit} of ${totalChars} chars — ` +
139
+ `full text: read ${sessionFile}, or session_read { action:"detail", session:"${raw}" }]`
140
+ )
141
+ }
142
+
143
+ /**
144
+ * result 的 limit 解析:缺省 8000;非有限数/非正数报错(schema 已限 Number,此处防御
145
+ * + 可单测绕过 schema)。向下取整防小数 slice 语义模糊。
146
+ */
147
+ function resolveResultLimit(raw: number | undefined): number {
148
+ if (raw === undefined) return RESULT_DEFAULT_LIMIT
149
+ if (!Number.isFinite(raw) || raw <= 0) {
150
+ throw new Error(
151
+ `limit "${String(raw)}" 无效,需为正数。👉 传正整数重试(缺省 ${RESULT_DEFAULT_LIMIT} 字符/条)。`,
152
+ )
153
+ }
154
+ return Math.floor(raw)
155
+ }
156
+
157
+ /**
158
+ * result 的 session 列表解析:单 id 或逗号批量(≤10)。
159
+ * 空条目(如 "sa-a," 或连续逗号)与超限均明确报错(含上限数字与 👉)。
160
+ */
161
+ function parseResultSessionList(raw: string | undefined, deps: ResultActionDeps): string[] {
162
+ const trimmed = deps.requireStr(raw, 'session', 'result')
163
+ const ids = trimmed.split(',').map((s) => deps.stripHash(s.trim()))
164
+ if (ids.some((id) => id === '')) {
165
+ throw deps.err(
166
+ `session 列表含空条目:"${trimmed}"。👉 检查逗号分隔格式(如 "sa-aaa,sa-bbb")重试。`,
167
+ )
168
+ }
169
+ if (ids.length > RESULT_MAX_BATCH) {
170
+ throw deps.err(
171
+ `批量取回 ${ids.length} 个 session 超上限:一次最多 ${RESULT_MAX_BATCH} 个。` +
172
+ `\n👉 分批取回(每批 ≤${RESULT_MAX_BATCH} 个 id)。`,
173
+ )
174
+ }
175
+ return ids
176
+ }
177
+
178
+ /**
179
+ * result:取回 subagent session 的最终结果正文(design subagent-sync-collect §3.1.3)。
180
+ *
181
+ * 流程:逐 id 走 resolveSessionId 三形态反查(sa-xxx manifest 精确反查 / uuid 片段 /
182
+ * 绝对路径,multi 走既有 disambiguate)→ safeParse → extractFinalAssistantText
183
+ *(与通知 record.result 同源)→ limit 截断。定位零新机制,不新造目录或文件。
184
+ *
185
+ * content 形态:单 id = 纯正文(无包装,未截断时与 record.result 逐字节一致,A4 取回门);
186
+ * 批量 = 每条目 `[i/n] <原始id> (session <短id>)` 头行 + 正文,'\\n\\n---\\n\\n' join。
187
+ *
188
+ * 错误路径(明确报错不返空串):id 无匹配(ES2/F1 风格)、manifest 有但文件不存在
189
+ *(ES1:GC 或 pi session 延迟写入尚未 flush)、无 assistant 输出(运行中/未写正文)、
190
+ * 批量超限 / 空条目 / limit 非法。
191
+ */
192
+ export async function doResult(
193
+ params: SessionReadParams,
194
+ agentDir: string,
195
+ deps: ResultActionDeps,
196
+ ): Promise<ToolResult> {
197
+ const ids = parseResultSessionList(params.session, deps)
198
+ const limit = resolveResultLimit(params.limit)
199
+ // S3(code-simplify):sa- 形态走 manifest 反查,逐 id 调用会重复全量扫 subagents/ 树
200
+ //(N+1)——批量入口预取一次注入。uuid 片段/路径形态不经 manifest,不预取。
201
+ const prefetchedManifests = ids.some((id) => id.startsWith('sa-'))
202
+ ? await listRecordManifests(agentDir)
203
+ : undefined
204
+ const items: ResultItem[] = []
205
+ for (const id of ids) {
206
+ const resolved = await deps.resolveSessionId(id, 'result', agentDir, params.source, prefetchedManifests)
207
+ if (resolved.kind === 'multi') return deps.disambiguate(resolved.query, resolved.candidates)
208
+ const { entries } = await deps.safeParse(resolved.fileName)
209
+ const text = extractFinalAssistantText(entries)
210
+ if (text.length === 0) {
211
+ throw deps.err(
212
+ `session "${id}" 尚无 assistant 输出(运行中或文件尚未 flush 完整)。` +
213
+ `\n👉 稍后重试,或用 session_read { action:"outline", session:"${id}" } 看当前进度。`,
214
+ )
215
+ }
216
+ const totalChars = text.length
217
+ const truncated = totalChars > limit
218
+ items.push({
219
+ session: id,
220
+ sessionId: resolved.sessionId,
221
+ sessionFile: resolved.fileName,
222
+ totalChars,
223
+ truncated,
224
+ text: truncated
225
+ ? text.slice(0, limit) + formatResultTruncation(id, resolved.fileName, limit, totalChars)
226
+ : text,
227
+ })
228
+ }
229
+ if (items.length === 1) {
230
+ const only = items[0]
231
+ return {
232
+ content: [{ type: 'text', text: only.text }],
233
+ details: { ...only },
234
+ }
235
+ }
236
+ const blocks = items.map(
237
+ (it, i) =>
238
+ `[${i + 1}/${items.length}] ${it.session} (session ${it.sessionId.slice(0, deps.sessionIdPrefixLen)}…)\n${it.text}`,
239
+ )
240
+ return {
241
+ content: [{ type: 'text', text: blocks.join(RESULT_BATCH_SEPARATOR) }],
242
+ details: { count: items.length, items },
243
+ }
244
+ }
@@ -4,7 +4,7 @@
4
4
  * 分层约定(同 scheduler/cw-tool):本文件零 pi 依赖——agentDir 作参数注入,
5
5
  * 不调用 getAgentDir(),可完全单测;pi 注册与 getAgentDir() 调用在 index.ts。
6
6
  *
7
- * 按 action 分发到 9 条路径,串联 M1 core(parser/tree/turns/render)+ M2 discovery
7
+ * 按 action 分发到 10 条路径,串联 M1 core(parser/tree/turns/render)+ M2 discovery
8
8
  *(find/subagents)。content 给 LLM 读(人类可读摘要),details 供程序化消费/测试断言。
9
9
  *
10
10
  * 错误规格 F1-F6:handler 抛 Error(message 含 👉 恢复指引),index.ts 的 execute 闭包
@@ -35,6 +35,10 @@ import {
35
35
  type ToolResultSummaryEntry,
36
36
  } from './core/render.js'
37
37
  import type { Family, SessionRef, WorkflowRef } from './core/family.js'
38
+ import { doResult, extractFinalAssistantText, type ResultActionDeps } from './result-action.js'
39
+
40
+ // result action 主体在 result-action.ts(max-lines 拆分轮机械提取,零行为变更);
41
+ // 导出面保持不变:extractFinalAssistantText 仍从本模块导出(包内测试白盒导入路径不变)。
38
42
  import {
39
43
  buildExecutionTree,
40
44
  formatExecutionTreeText,
@@ -55,6 +59,7 @@ export type SessionReadAction =
55
59
  | 'export'
56
60
  | 'extract'
57
61
  | 'workflow'
62
+ | 'result'
58
63
 
59
64
  export interface SessionReadParams {
60
65
  action: SessionReadAction
@@ -185,7 +190,7 @@ function rangeLabel(r: { start: number; end: number }): string {
185
190
  // resolveSessionId:片段 → 完整 id(design §3.4 resolveSessionId 辅助)
186
191
  // ---------------------------------------------------------------------------
187
192
 
188
- type ResolveResult =
193
+ export type ResolveResult =
189
194
  | { kind: 'ok'; sessionId: string; fileName: string }
190
195
  | { kind: 'multi'; query: string; candidates: MatchedSession[] }
191
196
 
@@ -246,73 +251,87 @@ function expandHome(p: string): string {
246
251
  }
247
252
 
248
253
  /**
249
- * 把 session 参数解析到唯一完整 id(design §6.1 M0 + U2/U3)。
250
- *
251
- * 三形态:
252
- * - ① 绝对路径 / ~ 前缀 → 展开后读首行 header,sessionId=header 真实 id(文件名仅定位)
253
- * - ② sa-id 前缀 → listRecordManifests 精确反查,sessionId=sessionFile header id
254
- * (禁止降级 record.id——sa- 形态不可当 sessionId,CQ3 决策)
255
- * - ③ 其余 → findSessions 透传 source 沿用 F1/F2
256
- *
257
- * 错误契约(U3):① 文件不存在/非 .jsonl/header 读不出 → F6 风格;
258
- * ② sessionFile GC → ES1(manifest 元数据 + 👉);sa-id 0/>1 命中 → ES2(👉 family)。
259
- *
260
- * 仅用于 family/outline/expand/detail/search/export/extract(find action 自行调 findSessions,
261
- * 零匹配时返回空 + 提示,不抛错)。
254
+ * 把 session 参数解析到唯一完整 id(design §6.1 M0 + U2/U3)。三形态各拆独立解析器:
255
+ * resolveBySessionPath(① 绝对路径/~)→ resolveByRecordId(② sa- 前缀)→ resolveByFragment(③ 片段)。
256
+ * 错误契约(U3):① 文件不存在/非 .jsonl/header 读不出 → F6 风格;② sessionFile GC → ES1(manifest 元数据 + 👉);
257
+ * sa-id 0/>1 命中 → ES2(👉 family)。仅用于 family/outline/expand/detail/search/export/extract/result
258
+ *(find action 自行调 findSessions,零匹配时返回空 + 提示,不抛错)。
262
259
  */
263
260
  async function resolveSessionId(
264
261
  rawSession: string | undefined,
265
262
  action: SessionReadAction,
266
263
  agentDir: string,
267
264
  source?: 'main' | 'subagent',
265
+ /** S3(code-simplify):批量调用方(doResult)预取的 manifest 列表——省去逐 id
266
+ * 重复全量扫 subagents/ 树(N+1)。单 id 调用点不传,行为零变化。 */
267
+ prefetchedManifests?: RecordManifest[],
268
268
  ): Promise<ResolveResult> {
269
269
  const session = stripHash(requireStr(rawSession, 'session', action))
270
270
 
271
271
  // ① 绝对路径或 ~ 前缀(Windows 盘符由 isAbsolute 处理)
272
272
  if (isAbsolute(session) || session === '~' || session.startsWith('~/')) {
273
- const expanded = expandHome(session)
274
- if (!expanded.endsWith('.jsonl')) {
275
- throw err(
276
- `读取失败:${session}(非 .jsonl session 文件)。👉 检查文件或换 session。`,
277
- )
278
- }
279
- if (!existsSync(expanded)) {
280
- throw err(`读取失败:${session}(文件不存在)。👉 检查文件或换 session。`)
281
- }
282
- const headerId = readSessionHeaderId(expanded)
283
- if (headerId === undefined) {
284
- throw err(
285
- `读取失败:${session}(首行非合法 session header)。👉 检查文件或换 session。`,
286
- )
287
- }
288
- return { kind: 'ok', sessionId: headerId, fileName: expanded }
273
+ return resolveBySessionPath(session)
289
274
  }
290
275
 
291
276
  // ② sa-id 前缀 → record manifest 精确反查
292
277
  if (session.startsWith('sa-')) {
293
- const manifests = await listRecordManifests(agentDir)
294
- const hits = manifests.filter((m) => m.id === session)
295
- if (hits.length === 0) {
296
- throw err(formatSaIdNotFound(session))
297
- }
298
- if (hits.length > 1) {
299
- throw err(formatSaIdAmbiguous(session, hits))
300
- }
301
- const record = hits[0]
302
- if (!existsSync(record.sessionFile)) {
303
- throw err(formatSessionGc(record))
304
- }
305
- const headerId = readSessionHeaderId(record.sessionFile)
306
- if (headerId === undefined) {
307
- // header 读不出不降级 record.id(sa- 形态不可当 sessionId,CQ3)
308
- throw err(
309
- `读取失败:${record.sessionFile}(首行非合法 session header)。👉 检查文件或换 session。`,
310
- )
311
- }
312
- return { kind: 'ok', sessionId: headerId, fileName: record.sessionFile }
278
+ return resolveByRecordId(session, agentDir, prefetchedManifests)
313
279
  }
314
280
 
315
281
  // ③ 其余:findSessions 透传 source 沿用 F1/F2
282
+ return resolveByFragment(session, agentDir, source)
283
+ }
284
+
285
+ /** 形态①:绝对路径 / ~ 前缀 → 展开后读首行 header,sessionId=header 真实 id(文件名仅定位)。 */
286
+ function resolveBySessionPath(session: string): ResolveResult {
287
+ const expanded = expandHome(session)
288
+ if (!expanded.endsWith('.jsonl')) {
289
+ throw err(
290
+ `读取失败:${session}(非 .jsonl session 文件)。👉 检查文件或换 session。`,
291
+ )
292
+ }
293
+ if (!existsSync(expanded)) {
294
+ throw err(`读取失败:${session}(文件不存在)。👉 检查文件或换 session。`)
295
+ }
296
+ const headerId = readSessionHeaderId(expanded)
297
+ if (headerId === undefined) {
298
+ throw err(
299
+ `读取失败:${session}(首行非合法 session header)。👉 检查文件或换 session。`,
300
+ )
301
+ }
302
+ return { kind: 'ok', sessionId: headerId, fileName: expanded }
303
+ }
304
+
305
+ /**
306
+ * 形态②:sa-id 前缀 → record manifest 精确反查,sessionId=sessionFile header id
307
+ *(禁止降级 record.id——sa- 形态不可当 sessionId,CQ3 决策)。批量调用方传预取列表
308
+ *(S3:避免逐 id 全量重扫);单 id 调用点现场扫一次。
309
+ */
310
+ async function resolveByRecordId(session: string, agentDir: string, prefetchedManifests: RecordManifest[] | undefined): Promise<ResolveResult> {
311
+ const manifests = prefetchedManifests ?? (await listRecordManifests(agentDir))
312
+ const hits = manifests.filter((m) => m.id === session)
313
+ if (hits.length === 0) {
314
+ throw err(formatSaIdNotFound(session))
315
+ }
316
+ if (hits.length > 1) {
317
+ throw err(formatSaIdAmbiguous(session, hits))
318
+ }
319
+ const record = hits[0]
320
+ if (!existsSync(record.sessionFile)) {
321
+ throw err(formatSessionGc(record))
322
+ }
323
+ const headerId = readSessionHeaderId(record.sessionFile)
324
+ if (headerId === undefined) {
325
+ // header 读不出不降级 record.id(sa- 形态不可当 sessionId,CQ3)
326
+ throw err(
327
+ `读取失败:${record.sessionFile}(首行非合法 session header)。👉 检查文件或换 session。`,
328
+ )
329
+ }
330
+ return { kind: 'ok', sessionId: headerId, fileName: record.sessionFile }
331
+ }
332
+
333
+ /** 形态③:其余片段 → findSessions 透传 source 沿用 F1(零匹配)/ F2(多匹配消歧)。 */
334
+ async function resolveByFragment(session: string, agentDir: string, source?: 'main' | 'subagent'): Promise<ResolveResult> {
316
335
  const opts = { limit: 10, ...(source ? { source } : {}) }
317
336
  const { matches } = await findSessions(session, agentDir, opts)
318
337
  if (matches.length === 0) {
@@ -766,29 +785,22 @@ async function doDetail(params: SessionReadParams, agentDir: string): Promise<To
766
785
  /** search action 的默认命中数上限。 */
767
786
  const SEARCH_DEFAULT_LIMIT = 20
768
787
 
769
- /** search:session 内全文检索(design §3.4 search,M3 新实现)。 */
770
- async function doSearch(
771
- params: SessionReadParams,
772
- agentDir: string,
773
- signal?: AbortSignal,
774
- ): Promise<ToolResult> {
775
- const resolved = await resolveSessionId(params.session, 'search', agentDir, params.source)
776
- if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
777
- const pattern = requireStr(params.pattern, 'pattern', 'search')
778
- const scope = params.scope ?? 'all'
779
- const limit = params.limit ?? SEARCH_DEFAULT_LIMIT
780
- const { entries } = await safeParse(resolved.fileName)
781
- const tree = buildTreeView(entries)
782
- const turns = segmentTurns(entries, new Set(tree.leafPath))
783
- const regex = compilePattern(pattern)
784
- // S-3:启发式降级时在 header 标注,避免 LLM 把 0 hit(s) 误读为「无匹配」(静默错数据)
785
- const degraded = isCatastrophicPattern(pattern)
786
- const hits: Array<{
787
- turnIndex: number
788
- entryIndex: number
789
- role: string
790
- matchSnippet: string
791
- }> = []
788
+ /** search 单条命中(turn 内 entry 定位 + 角色 + 摘要片段)。 */
789
+ interface SearchHit {
790
+ turnIndex: number
791
+ entryIndex: number
792
+ role: string
793
+ matchSnippet: string
794
+ }
795
+
796
+ /** search 扫描阶段:遍历 turns 收集命中;每 turn 前检查 abort(MF-5 尽早退出)。 */
797
+ function collectSearchHits(
798
+ turns: Turn[],
799
+ regex: RegExp,
800
+ scope: NonNullable<SessionReadParams['scope']>,
801
+ signal: AbortSignal | undefined,
802
+ ): SearchHit[] {
803
+ const hits: SearchHit[] = []
792
804
  for (const t of turns) {
793
805
  // MF-5:Esc/abort 后 pi 已丢弃本 turn 结果,尽早退出避免继续扫描长 session
794
806
  if (signal?.aborted) {
@@ -810,14 +822,46 @@ async function doSearch(
810
822
  }
811
823
  }
812
824
  }
813
- const truncated = hits.length > limit
814
- const sliced = truncated ? hits.slice(0, limit) : hits
825
+ return hits
826
+ }
827
+
828
+ /** search 收口阶段:结果文本组装(header 标注降级/scope/截断 + 逐行命中列表)。 */
829
+ function formatSearchText(
830
+ pattern: string,
831
+ degraded: boolean,
832
+ scope: NonNullable<SessionReadParams['scope']>,
833
+ sliced: SearchHit[],
834
+ truncated: boolean,
835
+ ): string {
815
836
  const lines = sliced.map(
816
837
  (h) => ` T${pad(h.turnIndex)} #${h.entryIndex} ${h.role}: ${h.matchSnippet}`,
817
838
  )
818
- const text = `${sliced.length} hit(s) for /${pattern}/${degraded ? '(已降级为字面子串匹配)' : ''}${
839
+ return `${sliced.length} hit(s) for /${pattern}/${degraded ? '(已降级为字面子串匹配)' : ''}${
819
840
  scope !== 'all' ? ' scope=' + scope : ''
820
841
  }${truncated ? ` (truncated, showing first ${sliced.length})` : ''}\n${lines.join('\n')}`
842
+ }
843
+
844
+ /** search:session 内全文检索(design §3.4 search,M3 新实现)。 */
845
+ async function doSearch(
846
+ params: SessionReadParams,
847
+ agentDir: string,
848
+ signal?: AbortSignal,
849
+ ): Promise<ToolResult> {
850
+ const resolved = await resolveSessionId(params.session, 'search', agentDir, params.source)
851
+ if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
852
+ const pattern = requireStr(params.pattern, 'pattern', 'search')
853
+ const scope = params.scope ?? 'all'
854
+ const limit = params.limit ?? SEARCH_DEFAULT_LIMIT
855
+ const { entries } = await safeParse(resolved.fileName)
856
+ const tree = buildTreeView(entries)
857
+ const turns = segmentTurns(entries, new Set(tree.leafPath))
858
+ const regex = compilePattern(pattern)
859
+ // S-3:启发式降级时在 header 标注,避免 LLM 把 0 hit(s) 误读为「无匹配」(静默错数据)
860
+ const degraded = isCatastrophicPattern(pattern)
861
+ const hits = collectSearchHits(turns, regex, scope, signal)
862
+ const truncated = hits.length > limit
863
+ const sliced = truncated ? hits.slice(0, limit) : hits
864
+ const text = formatSearchText(pattern, degraded, scope, sliced, truncated)
821
865
  return { content: [{ type: 'text', text }], details: { hits: sliced, truncated } }
822
866
  }
823
867
 
@@ -1187,8 +1231,16 @@ function extractFiles(turns: Turn[]): ToolResult {
1187
1231
  /** commits 提取的 hash 前后上下文字符数(次路径关键词判定窗口)。 */
1188
1232
  const COMMIT_CONTEXT_CHARS = 30
1189
1233
 
1190
- function extractCommits(turns: Turn[]): ToolResult {
1191
- // 建 toolCallId → bash command 映射(用于判定 toolResult 是否来自 git 命令)
1234
+ /** commits 预设的单条提取结果(hash + 来源 turn + 置信来源 + 上下文片段)。 */
1235
+ interface CommitItem {
1236
+ hash: string
1237
+ turn: number
1238
+ source: 'git-cmd' | 'commit-context'
1239
+ context: string
1240
+ }
1241
+
1242
+ /** 建 toolCallId → bash command 映射(用于判定 toolResult 是否来自 git 命令)。 */
1243
+ function collectBashCommands(turns: Turn[]): Map<string, string> {
1192
1244
  const bashCmds = new Map<string, string>()
1193
1245
  for (const t of turns) {
1194
1246
  for (const e of t.entries) {
@@ -1201,16 +1253,39 @@ function extractCommits(turns: Turn[]): ToolResult {
1201
1253
  }
1202
1254
  }
1203
1255
  }
1256
+ return bashCmds
1257
+ }
1204
1258
 
1205
- type CommitItem = {
1206
- hash: string
1207
- turn: number
1208
- source: 'git-cmd' | 'commit-context'
1209
- context: string
1259
+ /** 单条 toolResult 文本的 hash 提取:git-cmd 全收(高置信),否则按上下文关键词过滤(次路径)。 */
1260
+ function matchCommitsInResult(
1261
+ text: string,
1262
+ turnIndex: number,
1263
+ isGitBash: boolean,
1264
+ high: CommitItem[],
1265
+ low: CommitItem[],
1266
+ ): void {
1267
+ for (const m of text.matchAll(SHORT_HASH_RE)) {
1268
+ const hash = m[0]
1269
+ const idx = m.index ?? 0
1270
+ const ctx = text
1271
+ .slice(Math.max(0, idx - COMMIT_CONTEXT_CHARS), idx + hash.length + COMMIT_CONTEXT_CHARS)
1272
+ .replace(/\s+/g, ' ')
1273
+ .trim()
1274
+ if (isGitBash) {
1275
+ high.push({ hash, turn: turnIndex, source: 'git-cmd', context: ctx })
1276
+ } else if (COMMIT_CTX_RE.test(ctx)) {
1277
+ low.push({ hash, turn: turnIndex, source: 'commit-context', context: ctx })
1278
+ }
1210
1279
  }
1280
+ }
1281
+
1282
+ /** 主扫描阶段:遍历 toolResult entry,分类收集高/低置信 commit 候选。 */
1283
+ function collectCommitCandidates(
1284
+ turns: Turn[],
1285
+ bashCmds: Map<string, string>,
1286
+ ): { high: CommitItem[]; low: CommitItem[] } {
1211
1287
  const high: CommitItem[] = []
1212
1288
  const low: CommitItem[] = []
1213
-
1214
1289
  for (const t of turns) {
1215
1290
  for (const e of t.entries) {
1216
1291
  if (e.message?.role !== 'toolResult') continue
@@ -1219,23 +1294,14 @@ function extractCommits(turns: Turn[]): ToolResult {
1219
1294
  if (text === '') continue
1220
1295
  const cmd = msg.toolCallId !== undefined ? bashCmds.get(msg.toolCallId) : undefined
1221
1296
  const isGitBash = msg.toolName === 'bash' && cmd !== undefined && GIT_CMD_RE.test(cmd)
1222
- for (const m of text.matchAll(SHORT_HASH_RE)) {
1223
- const hash = m[0]
1224
- const idx = m.index ?? 0
1225
- const ctx = text
1226
- .slice(Math.max(0, idx - COMMIT_CONTEXT_CHARS), idx + hash.length + COMMIT_CONTEXT_CHARS)
1227
- .replace(/\s+/g, ' ')
1228
- .trim()
1229
- if (isGitBash) {
1230
- high.push({ hash, turn: t.index, source: 'git-cmd', context: ctx })
1231
- } else if (COMMIT_CTX_RE.test(ctx)) {
1232
- low.push({ hash, turn: t.index, source: 'commit-context', context: ctx })
1233
- }
1234
- }
1297
+ matchCommitsInResult(text, t.index, isGitBash, high, low)
1235
1298
  }
1236
1299
  }
1300
+ return { high, low }
1301
+ }
1237
1302
 
1238
- // 去重:高置信优先,同 hash 保留首次
1303
+ /** 收口阶段:去重,高置信优先,同 hash 保留首次。 */
1304
+ function dedupeCommits(high: CommitItem[], low: CommitItem[]): CommitItem[] {
1239
1305
  const seen = new Set<string>()
1240
1306
  const items: CommitItem[] = []
1241
1307
  for (const c of high) {
@@ -1248,6 +1314,14 @@ function extractCommits(turns: Turn[]): ToolResult {
1248
1314
  seen.add(c.hash)
1249
1315
  items.push(c)
1250
1316
  }
1317
+ return items
1318
+ }
1319
+
1320
+ function extractCommits(turns: Turn[]): ToolResult {
1321
+ // 建 toolCallId → bash command 映射(用于判定 toolResult 是否来自 git 命令)
1322
+ const bashCmds = collectBashCommands(turns)
1323
+ const { high, low } = collectCommitCandidates(turns, bashCmds)
1324
+ const items = dedupeCommits(high, low)
1251
1325
 
1252
1326
  return renderExtractItems(
1253
1327
  'commits',
@@ -1474,6 +1548,19 @@ async function doWorkflow(params: SessionReadParams, agentDir: string): Promise<
1474
1548
  return { content: [{ type: 'text', text }], details }
1475
1549
  }
1476
1550
 
1551
+ /** result action 的注入依赖(构造期绑定本文件私有 helper,运行时零查找开销)。 */
1552
+ const RESULT_ACTION_DEPS: ResultActionDeps = {
1553
+ err,
1554
+ stripHash,
1555
+ requireStr,
1556
+ resolveSessionId,
1557
+ disambiguate,
1558
+ safeParse,
1559
+ sessionIdPrefixLen: SESSION_ID_PREFIX_LEN,
1560
+ }
1561
+
1562
+ export { extractFinalAssistantText }
1563
+
1477
1564
  // ===========================================================================
1478
1565
  // 入口:按 action 分发
1479
1566
  // ===========================================================================
@@ -1510,12 +1597,14 @@ export async function handleSessionRead(
1510
1597
  return doExtract(params, agentDir)
1511
1598
  case 'workflow':
1512
1599
  return doWorkflow(params, agentDir)
1600
+ case 'result':
1601
+ return doResult(params, agentDir, RESULT_ACTION_DEPS)
1513
1602
  default: {
1514
- // exhaustive guard:switch 覆盖全部 9 action,此处 params.action 收窄为 never;
1603
+ // exhaustive guard:switch 覆盖全部 10 action,此处 params.action 收窄为 never;
1515
1604
  // 仅防御运行时非法 action(schema 正常校验下不可达)
1516
1605
  const exhaustive: never = params.action
1517
1606
  throw err(
1518
- `未知 action "${JSON.stringify(exhaustive)}"。👉 合法 action: find/family/outline/expand/detail/search/export/extract/workflow。`,
1607
+ `未知 action "${JSON.stringify(exhaustive)}"。👉 合法 action: find/family/outline/expand/detail/search/export/extract/workflow/result。`,
1519
1608
  )
1520
1609
  }
1521
1610
  }