@zhushanwen/pi-session-reader 0.4.0 → 0.5.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.
@@ -4,8 +4,16 @@
4
4
  * 分层约定(同 scheduler/cw-tool):本文件零 pi 依赖——agentDir 作参数注入,
5
5
  * 不调用 getAgentDir(),可完全单测;pi 注册与 getAgentDir() 调用在 index.ts。
6
6
  *
7
- * 按 action 分发到 10 条路径,串联 M1 core(parser/tree/turns/render)+ M2 discovery
8
- *(find/subagents)。content 给 LLM 读(人类可读摘要),details 供程序化消费/测试断言。
7
+ * 按 action 分发到 11 条路径,串联 M1 core(parser/tree/turns/render)+ M2 discovery
8
+ *(find/subagents)+ doctor 的环境判定与根表渲染(u8,discovery/env)。content 给 LLM
9
+ * 读(人类可读摘要),details 供程序化消费/测试断言。
10
+ *
11
+ * 域模块拆分(max-lines 拆分轮机械提取,零行为变更,result-action.ts 先例同型):
12
+ * result-action.ts(result)/ doctor.ts(doctor + SessionReadSignals)/
13
+ * search-across.ts(search 管线 + u12 跨会话)/ extract.ts(extract 预设)/
14
+ * no-match.ts(F1 自检行)/ handler-utils.ts(pad/err/turn 索引解析低层小工具)。
15
+ * 本模块保留公共类型、定位解析(resolveSessionId)、各 action 编排与共享渲染;
16
+ * 拆出域的公开导出经此 re-export(index.ts / 单测白盒 import 路径不变)。
9
17
  *
10
18
  * 错误规格 F1-F6:handler 抛 Error(message 含 👉 恢复指引),index.ts 的 execute 闭包
11
19
  * 原样传播给 pi——pi-agent-core 只对 execute throw 置 isError:true(返回值里的 isError
@@ -16,14 +24,23 @@ import { existsSync, openSync, readSync, closeSync } from 'node:fs'
16
24
  import { mkdir, writeFile } from 'node:fs/promises'
17
25
  import { join, isAbsolute } from 'node:path'
18
26
  import { homedir } from 'node:os'
19
- import { findSessions, type MatchedSession } from './discovery/find.js'
20
- import { buildFamilyFromFs, listRecordManifests, type RecordManifest } from './discovery/subagents.js'
27
+ import {
28
+ findSessions,
29
+ type MatchedSession,
30
+ type SessionMetadataEntry,
31
+ type SessionMetadataProvider,
32
+ } from './discovery/find.js'
33
+ import { resolveSessionRoots } from './discovery/roots.js'
34
+ import {
35
+ buildFamilyFromFs,
36
+ listRecordManifests,
37
+ type RecordManifest,
38
+ } from './discovery/subagents.js'
21
39
  import { readRunSnapshot, resolveWorkflows } from './discovery/workflows.js'
22
40
  import { parseSessionFile, type Entry, type ParseResult } from './core/parser.js'
23
41
  import { parseRunSnapshot, renderWorkflowOverview, type WorkflowOverview } from './core/workflow.js'
24
42
  import { buildTreeView } from './core/tree.js'
25
- import { segmentTurns, type Turn } from './core/turns.js'
26
- import { extractToolCalls, formatToolCallSummary, basename } from './core/toolcall.js'
43
+ import { segmentTurns } from './core/turns.js'
27
44
  import {
28
45
  renderOutline,
29
46
  renderExpand,
@@ -44,6 +61,34 @@ import {
44
61
  formatExecutionTreeText,
45
62
  type ExecutionTree,
46
63
  } from './core/execution-tree.js'
64
+ // 同轮拆分的域模块(依赖方向:本模块 → 域模块 → handler-utils,无循环;
65
+ // 域模块对本模块仅 type import——编译期擦除,同 result-action.ts 先例)。
66
+ import { err, pad, parseTurnIndex, parseTurnsRange, rangeLabel } from './handler-utils.js'
67
+ import { formatNoMatch } from './no-match.js'
68
+ import {
69
+ collectSearchHits,
70
+ compilePattern,
71
+ formatSearchText,
72
+ isCatastrophicPattern,
73
+ searchAcrossSessions,
74
+ SEARCH_DEFAULT_LIMIT,
75
+ } from './search-across.js'
76
+ import {
77
+ extractCommits,
78
+ extractCommands,
79
+ extractFiles,
80
+ extractToolResults,
81
+ extractUserMessages,
82
+ type ExtractWhat,
83
+ } from './extract.js'
84
+ import { doDoctor, DOCTOR_CACHE_TTL_MS, statDirMtimeOrNull, type SessionReadSignals } from './doctor.js'
85
+
86
+ // 拆出域的公开导出面保持从本模块可见(index.ts / 单测白盒 import 路径不变)。
87
+ export type { SessionReadSignals }
88
+ export { DOCTOR_CACHE_TTL_MS }
89
+ export { levenshtein } from './no-match.js'
90
+ export { MULTI_SEARCH_MAX_SESSIONS, SEARCH_SCAN_BYTE_BUDGET, searchAcrossSessions } from './search-across.js'
91
+ export { renderExtractItems } from './extract.js'
47
92
 
48
93
  // ---------------------------------------------------------------------------
49
94
  // 公共类型(与 index.ts 的 TypeBox schema 对齐)
@@ -60,6 +105,7 @@ export type SessionReadAction =
60
105
  | 'extract'
61
106
  | 'workflow'
62
107
  | 'result'
108
+ | 'doctor'
63
109
 
64
110
  export interface SessionReadParams {
65
111
  action: SessionReadAction
@@ -86,6 +132,12 @@ export interface SessionReadParams {
86
132
  tool?: string
87
133
  /** family action: 返回嵌套执行树(任意深度 subagent↔workflow-call 相互嵌套)。默认 false(flat family)。 */
88
134
  recursive?: boolean
135
+ /**
136
+ * doctor action: 是否同时扫描 subagent 根(产文件数)。默认 false——subagent 根在纯 pi
137
+ * 下可达数千文件,doctor 可能被反复询问(design 2026-09-10 §6.3 成本控制),默认只列
138
+ * 路径与可扫性(exists)。
139
+ */
140
+ includeSubagents?: boolean
89
141
  }
90
142
 
91
143
  export interface ToolResult {
@@ -97,16 +149,6 @@ export interface ToolResult {
97
149
  // 小工具
98
150
  // ---------------------------------------------------------------------------
99
151
 
100
- /** turn 索引显示宽度(T013 三位补零)。 */
101
- const TURN_INDEX_WIDTH = 3
102
-
103
- const pad = (n: number): string => String(n).padStart(TURN_INDEX_WIDTH, '0')
104
-
105
- /** 构造带 👉 恢复指引的 Error(handler 抛出,由 execute 闭包 catch)。 */
106
- function err(message: string): Error {
107
- return new Error(message)
108
- }
109
-
110
152
  /** 剥 # 前缀(TUI `#e6c96` 引用 → 纯片段,design §3.3 D-3/D-4)。 */
111
153
  function stripHash(s: string): string {
112
154
  return s.replace(/^#+/, '')
@@ -144,48 +186,6 @@ function requireStr(
144
186
  return val.trim()
145
187
  }
146
188
 
147
- // ---------------------------------------------------------------------------
148
- // turn / turns 索引解析
149
- // ---------------------------------------------------------------------------
150
-
151
- const TURN_RE = /^T?(\d+)$/i
152
-
153
- function parseTurnIndex(raw: string): number {
154
- const m = raw.trim().match(TURN_RE)
155
- if (!m) {
156
- throw err(
157
- `turn "${raw}" 格式无效(应为 T013 或 013)。👉 用合法 turn 索引重试,或 outline 重看有效范围。`,
158
- )
159
- }
160
- return parseInt(m[1], 10)
161
- }
162
-
163
- /** turns 范围 "T013-T015" 的段数。 */
164
- const TURNS_RANGE_PARTS = 2
165
-
166
- function parseTurnsRange(raw: string): { start: number; end: number } {
167
- const parts = raw.split('-').map((s) => s.trim())
168
- if (parts.length === 1) {
169
- const i = parseTurnIndex(parts[0])
170
- return { start: i, end: i }
171
- }
172
- if (parts.length === TURNS_RANGE_PARTS) {
173
- const start = parseTurnIndex(parts[0])
174
- const end = parseTurnIndex(parts[1])
175
- if (end < start) {
176
- throw err(
177
- `turns 范围 "${raw}" 起始大于结束。👉 检查范围格式(如 T013-T015)重试。`,
178
- )
179
- }
180
- return { start, end }
181
- }
182
- throw err(`turns "${raw}" 格式无效(应为 T013 或 T013-T015)。👉 用合法范围重试。`)
183
- }
184
-
185
- function rangeLabel(r: { start: number; end: number }): string {
186
- return r.start === r.end ? `T${pad(r.start)}` : `T${pad(r.start)}-T${pad(r.end)}`
187
- }
188
-
189
189
  // ---------------------------------------------------------------------------
190
190
  // resolveSessionId:片段 → 完整 id(design §3.4 resolveSessionId 辅助)
191
191
  // ---------------------------------------------------------------------------
@@ -254,8 +254,13 @@ function expandHome(p: string): string {
254
254
  * 把 session 参数解析到唯一完整 id(design §6.1 M0 + U2/U3)。三形态各拆独立解析器:
255
255
  * resolveBySessionPath(① 绝对路径/~)→ resolveByRecordId(② sa- 前缀)→ resolveByFragment(③ 片段)。
256
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,零匹配时返回空 + 提示,不抛错)。
257
+ * sa-id 0/>1 命中 → ES2(👉 family)。仅用于 family/outline/expand/detail/search/export/extract/workflow/result
258
+ *(find 自行调 findSessions,零匹配时返回空 + 提示,不抛错;doctor 为环境自检,无 session 解析)。
259
+ *
260
+ * liveSessionDir(§6.1 信号 1)只作用于形态③:片段匹配与 find 消费同一 roots
261
+ *([live] 根对片段解析可见——否则 find 能列出的 session 在 [live]≠[default] 环境
262
+ * 下 outline 等解析不到,违反「同一 roots」契约);①按路径直读、②按 agentDir 下
263
+ * manifest 反查,均不依赖根列表,不消费该信号。
259
264
  */
260
265
  async function resolveSessionId(
261
266
  rawSession: string | undefined,
@@ -265,6 +270,8 @@ async function resolveSessionId(
265
270
  /** S3(code-simplify):批量调用方(doResult)预取的 manifest 列表——省去逐 id
266
271
  * 重复全量扫 subagents/ 树(N+1)。单 id 调用点不传,行为零变化。 */
267
272
  prefetchedManifests?: RecordManifest[],
273
+ /** 信号包中的 liveSessionDir,透传形态③(resolveByFragment);缺省 = 三根降级。 */
274
+ liveSessionDir?: string,
268
275
  ): Promise<ResolveResult> {
269
276
  const session = stripHash(requireStr(rawSession, 'session', action))
270
277
 
@@ -278,8 +285,8 @@ async function resolveSessionId(
278
285
  return resolveByRecordId(session, agentDir, prefetchedManifests)
279
286
  }
280
287
 
281
- // ③ 其余:findSessions 透传 source 沿用 F1/F2
282
- return resolveByFragment(session, agentDir, source)
288
+ // ③ 其余:findSessions 透传 source/liveSessionDir 沿用 F1/F2
289
+ return resolveByFragment(session, agentDir, source, liveSessionDir)
283
290
  }
284
291
 
285
292
  /** 形态①:绝对路径 / ~ 前缀 → 展开后读首行 header,sessionId=header 真实 id(文件名仅定位)。 */
@@ -330,13 +337,29 @@ async function resolveByRecordId(session: string, agentDir: string, prefetchedMa
330
337
  return { kind: 'ok', sessionId: headerId, fileName: record.sessionFile }
331
338
  }
332
339
 
333
- /** 形态③:其余片段 → findSessions 透传 source 沿用 F1(零匹配)/ F2(多匹配消歧)。 */
334
- async function resolveByFragment(session: string, agentDir: string, source?: 'main' | 'subagent'): Promise<ResolveResult> {
335
- const opts = { limit: 10, ...(source ? { source } : {}) }
340
+ /**
341
+ * 形态③:其余片段 → findSessions 透传 source/liveSessionDir 沿用 F1(零匹配)/ F2(多匹配消歧)。
342
+ * liveSessionDir 透传保证片段匹配与 find 同一 roots;F1 自检行同理须含 [live] 根行
343
+ *(否则 [live]≠[default] 环境下自检行看不到最高优先级根,计数失真)。
344
+ */
345
+ async function resolveByFragment(
346
+ session: string,
347
+ agentDir: string,
348
+ source?: 'main' | 'subagent',
349
+ liveSessionDir?: string,
350
+ ): Promise<ResolveResult> {
351
+ const opts = {
352
+ limit: 10,
353
+ ...(source ? { source } : {}),
354
+ ...(liveSessionDir ? { liveSessionDir } : {}),
355
+ }
336
356
  const { matches } = await findSessions(session, agentDir, opts)
337
357
  if (matches.length === 0) {
338
- const recent = await findSessions('recent', agentDir, opts)
339
- throw err(formatNoMatch(session, recent.matches))
358
+ // F1 自检行需要发现层实况:无 options 的 resolveSessionRoots 恒实扫(不读 doctor
359
+ // 缓存,§7B 要点 8),与 find 刚完成的扫描同一数据源(roots.ts 薄包装语义);
360
+ // 信号包同源(liveSessionDir 透传)——findSessions 内部对空串/undefined 已有降级 guard。
361
+ const roots = await resolveSessionRoots({ agentDir, liveSessionDir })
362
+ throw err(formatNoMatch(session, roots))
340
363
  }
341
364
  if (matches.length === 1) {
342
365
  return { kind: 'ok', sessionId: matches[0].sessionId, fileName: matches[0].fileName }
@@ -380,17 +403,6 @@ const SESSION_ID_PREFIX_LEN = 8
380
403
  /** 消歧提示的 uuid 片段长度(比短显略长,引导输入更长片段消歧)。 */
381
404
  const HINT_ID_PREFIX_LEN = 12
382
405
 
383
- /** F1 无匹配 message(含最近 10 + 👉)。 */
384
- function formatNoMatch(query: string, recent: MatchedSession[]): string {
385
- const lines: string[] = recent.length
386
- ? recent.map((m, i) => ` ${i + 1}. ${m.sessionId.slice(0, SESSION_ID_PREFIX_LEN)}… ${m.firstMessagePreview ?? ''}`.trimEnd())
387
- : [' (无历史 session)']
388
- return (
389
- `无匹配 session:"${query}"。最近 ${recent.length} 个 session:\n${lines.join('\n')}\n` +
390
- `👉 用 session_read { action:"find", query:"recent" } 看全量,或换片段重试。`
391
- )
392
- }
393
-
394
406
  /** F2 多匹配消歧结果(不抛错,返回候选 + 👉)。 */
395
407
  function disambiguate(query: string, candidates: MatchedSession[]): ToolResult {
396
408
  const lines = candidates.map(
@@ -425,20 +437,66 @@ async function safeParse(fileName: string): Promise<ParseResult> {
425
437
  // 文本渲染(content)
426
438
  // ---------------------------------------------------------------------------
427
439
 
428
- function formatFindContent(
429
- query: string,
430
- matches: MatchedSession[],
431
- truncated: boolean,
432
- ): string {
433
- const lines = matches.map((m, i) => {
434
- const parts = [`${i + 1}. ${m.sessionId.slice(0, SESSION_ID_PREFIX_LEN)}…`, formatDate(m.mtime)]
435
- if (m.cwd) parts.push(shortCwd(m.cwd))
436
- if (m.firstMessagePreview) parts.push(m.firstMessagePreview)
437
- return parts.join(' · ')
438
- })
439
- const head = `${matches.length} session(s) matched "${query}"${
440
- truncated ? ` (truncated, showing first ${matches.length})` : ''
440
+ /**
441
+ * find 分组渲染的单组数据(u10,design 2026-09-10 §6.7 子决策 2)。
442
+ * main 组恒在 groups 首位(置顶),编号跨组连续。
443
+ */
444
+ interface FindGroup {
445
+ source: 'main' | 'subagent'
446
+ /** 配额切片后实际展示的候选 */
447
+ shown: MatchedSession[]
448
+ /** 溢出:该组命中数 > shown.length(+1 探测;溢出时精确总数未知,只知更多) */
449
+ overflow: boolean
450
+ }
451
+
452
+ /**
453
+ * find 输出渲染(u10 分组版,design §5.1 形态 + §6.7 子决策 2/3 精确规格):
454
+ *
455
+ * - 按 source 分组、main 段置顶(subagent 噪声不淹没目标),组头标注各组命中数,
456
+ * 组内编号跨组连续(§5.1 示例:subagent 段从 main 段末尾续号)。
457
+ * - 候选行打印完整 sessionId(废除 8 字符截断——agent 拿到截断 id 无法粘回做精确调用,
458
+ * §3.2 失败模式 D)。SESSION_ID_PREFIX_LEN 常量本体与 result 通路不动(§6.7 范围声明)。
459
+ * - 每条 main 候选附一行可直接复制执行的 outline 调用串(↳,§6.7 子决策 3);
460
+ * subagent 候选不附(噪声不配指针)。
461
+ * - 候选行末段文本:标题优先(u11,name 来自 SessionManager.listAll,§5.1 形态
462
+ * 「… · 福耀玻璃深度研究」),无标题回退首消息预览(现状行为)。
463
+ * - truncated 按**合并总量**(命中总数 vs 实际输出数)计算,由调用方传入,此处只负责标注。
464
+ * - subagent 段超配额折叠为一行展开提示(加 source:"subagent" 查看);main 段溢出
465
+ * 仅在组头标注(规格的折叠提示只针对 subagent)。
466
+ */
467
+ function formatFindContent(query: string, groups: FindGroup[], truncated: boolean): string {
468
+ const shown = groups.flatMap((g) => g.shown)
469
+ const head = `${shown.length} session(s) matched "${query}"${
470
+ truncated ? ` (truncated, showing first ${shown.length})` : ''
441
471
  }`
472
+ const lines: string[] = []
473
+ let index = 0
474
+ for (const g of groups) {
475
+ lines.push('')
476
+ if (g.overflow && g.shown.length === 0) {
477
+ // main 占满配额、subagent 有命中但 0 条展示(§6.7:「subagent 段为 0 条仅显示计数」;
478
+ // 命中总数须全量深读首条 user 才能精确计数,recent 形态下 IO 不可接受,只报有命中)
479
+ lines.push(`${g.source}(有命中未显示——展示配额已被 main 占满):`)
480
+ } else if (g.overflow) {
481
+ lines.push(`${g.source}(>${g.shown.length} 条命中,显示前 ${g.shown.length} 条):`)
482
+ } else {
483
+ lines.push(`${g.source}(${g.shown.length} 条命中):`)
484
+ }
485
+ for (const m of g.shown) {
486
+ index += 1
487
+ const parts = [`${index}. ${m.sessionId}`, formatDate(m.mtime)]
488
+ if (m.cwd) parts.push(shortCwd(m.cwd))
489
+ if (m.name) parts.push(m.name)
490
+ else if (m.firstMessagePreview) parts.push(m.firstMessagePreview)
491
+ lines.push(` ${parts.join(' · ')}`)
492
+ if (g.source === 'main') {
493
+ lines.push(` ↳ session_read { action:"outline", session:"${m.sessionId}" }`)
494
+ }
495
+ }
496
+ if (g.overflow && g.source === 'subagent') {
497
+ lines.push(' … 另有 subagent 命中未显示。👉 加 source:"subagent" 查看')
498
+ }
499
+ }
442
500
  return `${head}\n${lines.join('\n')}`
443
501
  }
444
502
 
@@ -571,79 +629,6 @@ function formatFamilyText(f: Family): string {
571
629
  return lines.join('\n')
572
630
  }
573
631
 
574
- // ---------------------------------------------------------------------------
575
- // search 辅助
576
- // ---------------------------------------------------------------------------
577
-
578
- /**
579
- * 灾难性正则形态探测(MF-5):组内含量词/`|` 且组本身又被量词修饰的 pattern
580
- *(`(a+)+`、`(a*)*`、`(a|aa)+`、`(a{1,3})*` 等)对长文本指数级回溯,可挂死整个 turn(5.4MB session
581
- * 全文逐 entry 匹配)。内层字符类含 `{` 以捕获 `{m,n}` 范围量词(MF-1);`(a{1,3})` 单独使用
582
- *(组后无尾随量词)不命中,仍按正则执行。命中则降级为字面子串匹配(与非法正则同一兜底路径)。
583
- * 保守拒绝(把合法但形似的 pattern 降级为子串)比挂死可接受。
584
- */
585
- function isCatastrophicPattern(pattern: string): boolean {
586
- return (
587
- /\((?:[^()\\]|\\.)*[+*?{](?:[^()\\]|\\.)*\)[+*?{]/.test(pattern) ||
588
- /\((?:[^()\\]|\\.)*\|(?:[^()\\]|\\.)*\)[+*?{]/.test(pattern)
589
- )
590
- }
591
-
592
- /** 编译检索 pattern:先当正则,非法/灾难性则转义为字面子串(design §3.4 pattern 子串或正则)。 */
593
- function compilePattern(pattern: string): RegExp {
594
- if (isCatastrophicPattern(pattern)) {
595
- return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
596
- }
597
- try {
598
- return new RegExp(pattern, 'i')
599
- } catch {
600
- return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
601
- }
602
- }
603
-
604
- /** 安全序列化:循环引用等异常时返回空串(catch 非空——记默认值)。 */
605
- function safeStringify(v: unknown): string {
606
- try {
607
- return JSON.stringify(v)
608
- } catch {
609
- return '' // 循环引用等致 stringify 失败,跳过该块
610
- }
611
- }
612
-
613
- /** search 的可检索文本:含 text/thinking/toolResult 全量(按 scope 过滤由调用方做)。 */
614
- function searchableText(content: unknown): string {
615
- if (typeof content === 'string') return content
616
- if (Array.isArray(content)) {
617
- const parts: string[] = []
618
- for (const b of content) {
619
- if (b && typeof b === 'object') {
620
- const o = b as Record<string, unknown>
621
- if (typeof o.text === 'string') parts.push(o.text)
622
- else if (typeof o.thinking === 'string') parts.push(o.thinking)
623
- else {
624
- const serialized = safeStringify(o)
625
- if (serialized) parts.push(serialized)
626
- }
627
- }
628
- }
629
- return parts.join('\n')
630
- }
631
- return safeStringify(content)
632
- }
633
-
634
- /** search 命中片段的前后上下文字符数。 */
635
- const SNIPPET_CONTEXT_CHARS = 20
636
-
637
- function snippet(text: string, idx: number, len: number): string {
638
- const start = Math.max(0, idx - SNIPPET_CONTEXT_CHARS)
639
- const end = Math.min(text.length, idx + len + SNIPPET_CONTEXT_CHARS)
640
- return (
641
- (start > 0 ? '…' : '') +
642
- text.slice(start, end).replace(/\s+/g, ' ').trim() +
643
- (end < text.length ? '…' : '')
644
- )
645
- }
646
-
647
632
  // ===========================================================================
648
633
  // 各 action 实现
649
634
  // ===========================================================================
@@ -651,23 +636,114 @@ function snippet(text: string, idx: number, len: number): string {
651
636
  /** find action 的默认匹配数上限。 */
652
637
  const FIND_DEFAULT_LIMIT = 20
653
638
 
654
- /** find:按片段/名称/recent 定位 session(design §3.4 find)。零匹配不抛,返回提示。 */
655
- async function doFind(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
639
+ /**
640
+ * find 零匹配:F1 自检行(u9)。计数取本次实扫(无 options 恒实扫,不读 doctor
641
+ * 缓存——§7B 要点 8 PS-14),完整信号包保证 [live] 根(最高优先级)计数可见。
642
+ */
643
+ function findNoMatch(query: string, signals: SessionReadSignals): Promise<ToolResult> {
644
+ // F1 自检行需要发现层实况:resolveSessionRoots 与 find 刚完成的扫描同一数据源。
645
+ return resolveSessionRoots(signals).then((roots) => ({
646
+ content: [{ type: 'text', text: formatNoMatch(query, roots) }],
647
+ details: { matches: [], truncated: false },
648
+ }))
649
+ }
650
+
651
+ /**
652
+ * find:按片段/名称/recent 定位 session(design §3.4 find)。零匹配不抛,返回提示。
653
+ *
654
+ * u10 分组(design 2026-09-10 §6.7 子决策 2 精确规格,纯展示层规则):
655
+ * - `limit` 作用于**分组后的合并列表**:main 段优先占满(上限 limit),剩余配额给
656
+ * subagent 段;main 命中 > limit 时 subagent 段为 0 条仅显示计数 + 展开提示。
657
+ * - `truncated` 按**合并总量**(命中总数 vs 实际输出数)计算:各 source 独立查询以
658
+ * 「配额 +1」探测溢出,`hasMore ⟺ 该组命中数 > 该组展示数`,合取即精确等价于
659
+ * 「命中总数 > 实际输出数」,与单次合并查询的 truncated 语义逐值一致。
660
+ * - 匹配层 findSessions 不动:分组只是展示规则,各 source 独立查询的组内语义仍是
661
+ * mtime 排序 + limit 截断。subagent 溢出时不做精确计数(需对全部命中深读首条 user,
662
+ * recent 形态下命中可达全库量级,IO 不可接受),折叠行只报「有更多 + 展开方式」。
663
+ * - 显式 source 过滤走单组查询(现状语义):显式 source 本身就是折叠提示所指的展开
664
+ * 动作,不再折叠。
665
+ *
666
+ * u11(design 2026-09-10 §6.6):metadataProvider / liveSessionDir 透传匹配层——标题
667
+ * 检索的惰性/窄化/TTL 缓存策略都在发现层与注入包装侧,本函数只负责透传(缺省
668
+ * undefined = 现状行为)。多次 findSessions 调用(分组探测)经注入侧 TTL 缓存去重,
669
+ * 标题 listAll 每 TTL 窗口至多一次/目录。
670
+ */
671
+ async function doFind(
672
+ params: SessionReadParams,
673
+ signals: SessionReadSignals,
674
+ metadataProvider?: SessionMetadataProvider,
675
+ ): Promise<ToolResult> {
656
676
  const query = requireStr(params.query, 'query', 'find')
657
- const { matches, truncated } = await findSessions(query, agentDir, {
658
- cwd: params.cwd,
659
- limit: params.limit ?? FIND_DEFAULT_LIMIT,
660
- ...(params.source ? { source: params.source } : {}),
661
- })
662
- if (matches.length === 0) {
663
- const recent = await findSessions('recent', agentDir, { limit: 10 })
677
+ const limit = params.limit ?? FIND_DEFAULT_LIMIT
678
+ const cwd = params.cwd
679
+
680
+ // 显式 source:单组,匹配层原语义(mtime 排序 + limit 截断),无分组展示
681
+ if (params.source !== undefined) {
682
+ const { matches, truncated } = await findSessions(query, signals.agentDir, {
683
+ cwd,
684
+ limit,
685
+ source: params.source,
686
+ liveSessionDir: signals.liveSessionDir,
687
+ metadataProvider,
688
+ })
689
+ if (matches.length === 0) return findNoMatch(query, signals)
664
690
  return {
665
- content: [{ type: 'text', text: formatNoMatch(query, recent.matches) }],
666
- details: { matches: [], truncated: false },
691
+ content: [
692
+ {
693
+ type: 'text',
694
+ text: formatFindContent(
695
+ query,
696
+ [{ source: params.source, shown: matches, overflow: false }],
697
+ truncated,
698
+ ),
699
+ },
700
+ ],
701
+ details: { matches, truncated },
667
702
  }
668
703
  }
704
+
705
+ // 分组查询:main 段以 limit+1 探测溢出,优先占满配额
706
+ const mainRes = await findSessions(query, signals.agentDir, {
707
+ cwd,
708
+ limit: limit + 1,
709
+ source: 'main',
710
+ liveSessionDir: signals.liveSessionDir,
711
+ metadataProvider,
712
+ })
713
+ const mainHasMore = mainRes.matches.length > limit
714
+ const mainShown = mainHasMore ? mainRes.matches.slice(0, limit) : mainRes.matches
715
+
716
+ // subagent 段:remaining>0 → 配额 remaining+1 探测溢出;remaining=0(main 占满/溢出)
717
+ // → limit:1 仅探测有无命中(折叠计数判据,不做全量深读)
718
+ const remaining = limit - mainShown.length
719
+ const subLimit = remaining > 0 ? remaining + 1 : 1
720
+ const subRes = await findSessions(query, signals.agentDir, {
721
+ cwd,
722
+ limit: subLimit,
723
+ source: 'subagent',
724
+ liveSessionDir: signals.liveSessionDir,
725
+ metadataProvider,
726
+ })
727
+ const subOverflow = remaining > 0 ? subRes.matches.length > remaining : subRes.matches.length > 0
728
+ const subShown = subRes.matches.slice(0, Math.max(remaining, 0))
729
+
730
+ const matches = [...mainShown, ...subShown]
731
+ if (matches.length === 0) return findNoMatch(query, signals)
732
+ const truncated = mainHasMore || subOverflow
669
733
  return {
670
- content: [{ type: 'text', text: formatFindContent(query, matches, truncated) }],
734
+ content: [
735
+ {
736
+ type: 'text',
737
+ text: formatFindContent(
738
+ query,
739
+ [
740
+ { source: 'main', shown: mainShown, overflow: mainHasMore },
741
+ { source: 'subagent', shown: subShown, overflow: subOverflow },
742
+ ],
743
+ truncated,
744
+ ),
745
+ },
746
+ ],
671
747
  details: { matches, truncated },
672
748
  }
673
749
  }
@@ -679,8 +755,19 @@ async function doFind(params: SessionReadParams, agentDir: string): Promise<Tool
679
755
  * recursive=true → 嵌套执行树(buildExecutionTree + formatExecutionTreeText,任意深度
680
756
  * subagent↔workflow-call 相互嵌套,IF4)。错误契约同构:multi→disambiguate;构建抛错→catch 转 👉。
681
757
  */
682
- async function doFamily(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
683
- const resolved = await resolveSessionId(params.session, 'family', agentDir, params.source)
758
+ async function doFamily(
759
+ params: SessionReadParams,
760
+ agentDir: string,
761
+ liveSessionDir?: string,
762
+ ): Promise<ToolResult> {
763
+ const resolved = await resolveSessionId(
764
+ params.session,
765
+ 'family',
766
+ agentDir,
767
+ params.source,
768
+ undefined,
769
+ liveSessionDir,
770
+ )
684
771
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
685
772
 
686
773
  // recursive=true:嵌套执行树(U7/U8)
@@ -714,8 +801,19 @@ async function doFamily(params: SessionReadParams, agentDir: string): Promise<To
714
801
  }
715
802
 
716
803
  /** outline:turn 级全貌 TOC(design §3.4 outline,~1500 token;render budget 硬编码 2000)。 */
717
- async function doOutline(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
718
- const resolved = await resolveSessionId(params.session, 'outline', agentDir, params.source)
804
+ async function doOutline(
805
+ params: SessionReadParams,
806
+ agentDir: string,
807
+ liveSessionDir?: string,
808
+ ): Promise<ToolResult> {
809
+ const resolved = await resolveSessionId(
810
+ params.session,
811
+ 'outline',
812
+ agentDir,
813
+ params.source,
814
+ undefined,
815
+ liveSessionDir,
816
+ )
719
817
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
720
818
  const { entries, totalBytes, skippedLines } = await safeParse(resolved.fileName)
721
819
  const tree = buildTreeView(entries)
@@ -736,8 +834,19 @@ async function doOutline(params: SessionReadParams, agentDir: string): Promise<T
736
834
  }
737
835
 
738
836
  /** expand:单 turn 的 entry 列表(design §3.4 expand)。turn 越界抛 F4。 */
739
- async function doExpand(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
740
- const resolved = await resolveSessionId(params.session, 'expand', agentDir, params.source)
837
+ async function doExpand(
838
+ params: SessionReadParams,
839
+ agentDir: string,
840
+ liveSessionDir?: string,
841
+ ): Promise<ToolResult> {
842
+ const resolved = await resolveSessionId(
843
+ params.session,
844
+ 'expand',
845
+ agentDir,
846
+ params.source,
847
+ undefined,
848
+ liveSessionDir,
849
+ )
741
850
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
742
851
  const turnIdx = parseTurnIndex(requireStr(params.turn, 'turn', 'expand'))
743
852
  const { entries } = await safeParse(resolved.fileName)
@@ -758,8 +867,19 @@ async function doExpand(params: SessionReadParams, agentDir: string): Promise<To
758
867
  }
759
868
 
760
869
  /** detail:turns 范围的完整文本(design §3.4 detail)。默认省略 toolResult/thinking。 */
761
- async function doDetail(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
762
- const resolved = await resolveSessionId(params.session, 'detail', agentDir, params.source)
870
+ async function doDetail(
871
+ params: SessionReadParams,
872
+ agentDir: string,
873
+ liveSessionDir?: string,
874
+ ): Promise<ToolResult> {
875
+ const resolved = await resolveSessionId(
876
+ params.session,
877
+ 'detail',
878
+ agentDir,
879
+ params.source,
880
+ undefined,
881
+ liveSessionDir,
882
+ )
763
883
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
764
884
  const range = parseTurnsRange(requireStr(params.turns, 'turns', 'detail'))
765
885
  const { entries } = await safeParse(resolved.fileName)
@@ -782,74 +902,43 @@ async function doDetail(params: SessionReadParams, agentDir: string): Promise<To
782
902
  }
783
903
  }
784
904
 
785
- /** search action 的默认命中数上限。 */
786
- const SEARCH_DEFAULT_LIMIT = 20
787
-
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[] = []
804
- for (const t of turns) {
805
- // MF-5:Esc/abort 后 pi 已丢弃本 turn 结果,尽早退出避免继续扫描长 session
806
- if (signal?.aborted) {
807
- throw err('搜索已中断(信号 aborted)。👉 重试或换更精确的 pattern。')
808
- }
809
- for (let i = 0; i < t.entries.length; i++) {
810
- const msg = t.entries[i].message
811
- if (msg === undefined) continue
812
- if (scope !== 'all' && msg.role !== scope) continue
813
- const text = searchableText(msg.content)
814
- const m = regex.exec(text)
815
- if (m !== null) {
816
- hits.push({
817
- turnIndex: t.index,
818
- entryIndex: i,
819
- role: msg.role,
820
- matchSnippet: snippet(text, m.index, m[0].length),
821
- })
822
- }
823
- }
824
- }
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 {
836
- const lines = sliced.map(
837
- (h) => ` T${pad(h.turnIndex)} #${h.entryIndex} ${h.role}: ${h.matchSnippet}`,
838
- )
839
- return `${sliced.length} hit(s) for /${pattern}/${degraded ? '(已降级为字面子串匹配)' : ''}${
840
- scope !== 'all' ? ' scope=' + scope : ''
841
- }${truncated ? ` (truncated, showing first ${sliced.length})` : ''}\n${lines.join('\n')}`
842
- }
843
-
844
- /** search:session 内全文检索(design §3.4 search,M3 新实现)。 */
905
+ /** search:全文检索(design §3.4 search,M3 新实现)。
906
+ *
907
+ * u12 两种形态(design 2026-09-10 §2 目标 5 / §8.2 V8):
908
+ * - session 含逗号 → 跨会话模式(searchAcrossSessions):候选集 = find 输出的完整 id
909
+ * 列表,窄化前置 + 字节上限 + 分 session 渲染;
910
+ * - 否则单会话模式(现状零变化):session 经 resolveSessionId 解析后对单个 session 检索。
911
+ */
845
912
  async function doSearch(
846
913
  params: SessionReadParams,
847
- agentDir: string,
914
+ signals: SessionReadSignals,
848
915
  signal?: AbortSignal,
916
+ metadataProvider?: SessionMetadataProvider,
849
917
  ): 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
918
  const pattern = requireStr(params.pattern, 'pattern', 'search')
919
+ const rawSession = params.session
920
+ if (rawSession !== undefined && rawSession.includes(',')) {
921
+ const ids = rawSession
922
+ .split(',')
923
+ .map((s) => stripHash(s.trim()))
924
+ .filter((s) => s.length > 0)
925
+ return searchAcrossSessions(ids, pattern, signals, {
926
+ scope: params.scope,
927
+ limit: params.limit,
928
+ signal,
929
+ metadataProvider,
930
+ })
931
+ }
932
+ const agentDir = signals.agentDir
933
+ const resolved = await resolveSessionId(
934
+ rawSession,
935
+ 'search',
936
+ agentDir,
937
+ params.source,
938
+ undefined,
939
+ signals.liveSessionDir,
940
+ )
941
+ if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
853
942
  const scope = params.scope ?? 'all'
854
943
  const limit = params.limit ?? SEARCH_DEFAULT_LIMIT
855
944
  const { entries } = await safeParse(resolved.fileName)
@@ -869,9 +958,20 @@ async function doSearch(
869
958
  const EXPORT_SEPARATOR_LEN = 40
870
959
 
871
960
  /** export:物化摘要到 <agentDir>/tmp/session-view-<id>.md(design §3.4 export,D-8)。 */
872
- async function doExport(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
961
+ async function doExport(
962
+ params: SessionReadParams,
963
+ agentDir: string,
964
+ liveSessionDir?: string,
965
+ ): Promise<ToolResult> {
873
966
  const format = params.format ?? 'outline'
874
- const resolved = await resolveSessionId(params.session, 'export', agentDir, params.source)
967
+ const resolved = await resolveSessionId(
968
+ params.session,
969
+ 'export',
970
+ agentDir,
971
+ params.source,
972
+ undefined,
973
+ liveSessionDir,
974
+ )
875
975
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
876
976
 
877
977
  let text: string
@@ -937,25 +1037,9 @@ async function doExport(params: SessionReadParams, agentDir: string): Promise<To
937
1037
  // extract action(v2 O4:跨 turn 按类型提取素材)
938
1038
  // ===========================================================================
939
1039
  //
940
- // design §3.3 D3 的 5 个预设 + F7/F8/F9 错误规格。复用 O1 共享层(extractToolCalls /
941
- // formatToolCallSummary / basename)与现有 resolveSessionId/safeParse/buildTreeView/
942
- // segmentTurns/parseTurnsRange。纯提取,不调 LLM。
943
-
944
- /** extract 的 5 个合法 what(design §3.3 D3)。 */
945
- type ExtractWhat = 'user-messages' | 'commands' | 'files' | 'commits' | 'tool-results'
946
-
947
- /** extract 结果预算(design §3.3 F9):8000 字节 ≈ 2000 token。 */
948
- const EXTRACT_BUDGET_BYTES = 8000
949
-
950
- /** 含 path 参数的文件类工具(design §3.3 D3 files scope)。 */
951
- const FILE_TOOLS = new Set(['read', 'edit', 'write', 'head'])
952
-
953
- /** git 命令关键词(commits 预设判定 bash 结果是否来自 git 命令)。 */
954
- const GIT_CMD_RE = /\bgit\s+(log|show|commit|push|merge|cherry-pick|revert|reset|rebase|diff)\b/
955
- /** git short hash(commits 预设,保守限定 7-8 位避免 uuid 全量误报)。 */
956
- const SHORT_HASH_RE = /\b[0-9a-f]{7,8}\b/g
957
- /** commit 上下文消歧关键词(commits 次路径:hash 附近出现才纳入)。 */
958
- const COMMIT_CTX_RE = /feat:|fix:|refactor:|chore:|docs:|\b(commit|commits|merged|pushed|merge)\b/i
1040
+ // design §3.3 D3 的 5 个预设 + F7/F8/F9 错误规格。预设管线与预算渲染在 extract.ts
1041
+ //(max-lines 拆分轮机械提取);本段保留 F7 what 校验与 doExtract 编排(定位依赖
1042
+ // tool-handler 私有的 resolveSessionId/safeParse/segmentTurns)。纯提取,不调 LLM。
959
1043
 
960
1044
  /** what 类型守卫(直接比较,避开不安全断言;schema 已校验,此处防御 + 可单测绕过)。 */
961
1045
  function isExtractWhat(v: unknown): v is ExtractWhat {
@@ -968,407 +1052,25 @@ function isExtractWhat(v: unknown): v is ExtractWhat {
968
1052
  )
969
1053
  }
970
1054
 
971
- /**
972
- * 从 message.content 提取纯 text(string 直取;数组拼接 text 块)。
973
- *
974
- * 与 render.ts 内部 extractText 同语义,但那未导出;extract 仅需纯 text
975
- *(user-messages / tool-results 的正文),不要 thinking/toolCall 占位,本地实现。
976
- * content 是 unknown 做类型守卫。
977
- */
978
- function extractContentText(content: unknown): string {
979
- if (typeof content === 'string') return content
980
- if (Array.isArray(content)) {
981
- const parts: string[] = []
982
- for (const b of content) {
983
- if (b !== null && typeof b === 'object') {
984
- const o = b as Record<string, unknown>
985
- if (o.type === 'text' && typeof o.text === 'string') parts.push(o.text)
986
- }
987
- }
988
- return parts.join('\n')
989
- }
990
- return ''
991
- }
992
-
993
- /** 截断到 max 字符,超出加省略号(防爆;tool-results 正文用)。 */
994
- function truncateText(s: string, max: number): string {
995
- return s.length <= max ? s : s.slice(0, max) + '…'
996
- }
997
-
998
- /** turnsLabel 展示的 turn 数上限(超出折叠为 +N)。 */
999
- const TURNS_LABEL_HEAD_COUNT = 5
1000
-
1001
- /** turns 数组紧凑标签(前 5 个 + +N,避免一行过长撑爆预算)。 */
1002
- function turnsLabel(turns: number[]): string {
1003
- const head = turns.slice(0, TURNS_LABEL_HEAD_COUNT).map((n) => `T${pad(n)}`)
1004
- const suffix = turns.length > TURNS_LABEL_HEAD_COUNT ? `+${turns.length - TURNS_LABEL_HEAD_COUNT}` : ''
1005
- return head.join(',') + suffix
1006
- }
1007
-
1008
- /**
1009
- * 计算工具分布(按出现次数降序),用于 F8 提示 + details.toolDistribution。
1010
- * 遍历 assistant entry 的 toolCall,复用 extractToolCalls。
1011
- */
1012
- function computeToolDistribution(
1013
- turns: Turn[],
1014
- ): Array<{ name: string; count: number }> {
1015
- const counts = new Map<string, number>()
1016
- for (const t of turns) {
1017
- for (const e of t.entries) {
1018
- if (e.message?.role !== 'assistant') continue
1019
- for (const tc of extractToolCalls(e)) {
1020
- counts.set(tc.name, (counts.get(tc.name) ?? 0) + 1)
1021
- }
1022
- }
1023
- }
1024
- return Array.from(counts.entries())
1025
- .map(([name, count]) => ({ name, count }))
1026
- .sort((a, b) => b.count - a.count)
1027
- }
1028
-
1029
- /** F8 提示展示的工具分布条数上限。 */
1030
- const TOOL_DISTRIBUTION_LIMIT = 10
1031
-
1032
- /** F8:commands/tool-results 的 tool 过滤零匹配 → 返回工具分布 + 👉(不抛错,design §3.3 F8)。 */
1033
- function f8ToolNoMatch(what: ExtractWhat, tool: string, turns: Turn[]): ToolResult {
1034
- const dist = computeToolDistribution(turns).slice(0, TOOL_DISTRIBUTION_LIMIT)
1035
- const distStr = dist.map((d) => `${d.name}×${d.count}`).join(', ')
1036
- const text = `what=${what} tool="${tool}" 无匹配。该 session 工具:${distStr}。👉 用存在的工具名重试。`
1037
- return { content: [{ type: 'text', text }], details: { what, tool, toolDistribution: dist } }
1038
- }
1039
-
1040
- /**
1041
- * 通用预算渲染:逐项累加字节,超 EXTRACT_BUDGET_BYTES 截断(design §3.3 F9)。
1042
- *
1043
- * details.items 放实际展示的子集(截断后),count 放全集长度,测试可断言 shown/count/truncated。
1044
- * emptyHint 仅 items 为空时用(files/commits 无匹配不报错,返空 + 提示)。
1045
- *
1046
- * 预算控制:按 item 累计字节达预算即截断。**首项超大也内部截断**(对单行 slice 到剩余字节预算,
1047
- * 字节→字符 ×3 近似防 UTF8 多字节被切半),保证 body 不超预算——而非放行首项致 body 远超预算。
1048
- * 导出供 tool-handler.test 单测 F9 截断逻辑(首项截断 + 文案含 turn 范围 + 实际 token)。
1049
- *
1050
- * getTurns:从 item 提取 turn 列表(files 是 turns 数组,其余单值包数组),供 F9 文案报 turn 范围。
1051
- */
1052
-
1053
- /** UTF8 单字符最大字节数(预算字节→字符的保守换算基数,防多字节字符被切半)。 */
1054
- const UTF8_MAX_BYTES_PER_CHAR = 3
1055
- /** token 估算换算基数(bytes/4 口径,与 render 层 chars/4 同近似)。 */
1056
- const CHARS_PER_TOKEN = 4
1057
-
1058
- export function renderExtractItems<I>(
1059
- what: ExtractWhat,
1060
- items: I[],
1061
- renderLine: (item: I) => string,
1062
- getTurns: (item: I) => number[],
1063
- emptyHint?: string,
1064
- ): ToolResult {
1065
- if (items.length === 0) {
1066
- const text = emptyHint ?? `what=${what} 无匹配。`
1067
- return {
1068
- content: [{ type: 'text', text }],
1069
- details: { what, count: 0, shown: 0, truncated: false, items: [] },
1070
- }
1071
- }
1072
- const shown: I[] = []
1073
- const shownLines: string[] = []
1074
- let bytes = 0
1075
- let cut = false
1076
- for (const item of items) {
1077
- const line = renderLine(item)
1078
- const lineBytes = Buffer.byteLength(line, 'utf8') + 1 // +\n
1079
- if (bytes + lineBytes > EXTRACT_BUDGET_BYTES) {
1080
- // 超预算:对当前 line 内部截断到剩余预算(首项超大也截断,但保留截断后的内容)
1081
- const remainingBytes = EXTRACT_BUDGET_BYTES - bytes
1082
- const charBudget = Math.floor(remainingBytes / UTF8_MAX_BYTES_PER_CHAR) // 字节→字符 ×3 近似防 UTF8 切半
1083
- if (charBudget > 0) {
1084
- const sliced = line.slice(0, charBudget) + '…'
1085
- shown.push(item)
1086
- shownLines.push(sliced)
1087
- }
1088
- cut = true
1089
- break
1090
- }
1091
- shown.push(item)
1092
- shownLines.push(line)
1093
- bytes += lineBytes
1094
- }
1095
- const body = shownLines.join('\n')
1096
- if (!cut) {
1097
- return {
1098
- content: [{ type: 'text', text: body }],
1099
- details: {
1100
- what,
1101
- count: items.length,
1102
- shown: shown.length,
1103
- truncated: false,
1104
- items: shown,
1105
- },
1106
- }
1107
- }
1108
- // F9:超预算截断。tokens 反映 body 实际体积(非固定 2000);文案报 shown/count + turn 范围 + 实际 token
1109
- const shownTurns = shown.flatMap(getTurns)
1110
- const turnRange =
1111
- shownTurns.length > 0
1112
- ? `(T${pad(Math.min(...shownTurns))}-T${pad(Math.max(...shownTurns))})`
1113
- : ''
1114
- const actualTokens = Math.round(Buffer.byteLength(body, 'utf8') / CHARS_PER_TOKEN)
1115
- const text =
1116
- body +
1117
- `\n[what=${what} 已显示 ${shown.length}/${items.length} 项${turnRange},约 ${actualTokens} token 达预算上限。👉 用较小 turns 范围(如 T000-T005)缩小,或换 what 重试。]`
1118
- return {
1119
- content: [{ type: 'text', text }],
1120
- details: {
1121
- what,
1122
- count: items.length,
1123
- shown: shown.length,
1124
- truncated: true,
1125
- items: shown,
1126
- },
1127
- }
1128
- }
1129
-
1130
- /** 预设 1:user-messages——收集 role==='user' 的全文(按 turn 排列,design §3.3 D3)。 */
1131
- function extractUserMessages(turns: Turn[]): ToolResult {
1132
- const items: Array<{ turn: number; text: string }> = []
1133
- for (const t of turns) {
1134
- for (const e of t.entries) {
1135
- if (e.message?.role !== 'user') continue
1136
- items.push({ turn: t.index, text: extractContentText(e.message.content) })
1137
- }
1138
- }
1139
- return renderExtractItems(
1140
- 'user-messages',
1141
- items,
1142
- (it) => `T${pad(it.turn)}: ${it.text}`,
1143
- (it) => [it.turn],
1144
- )
1145
- }
1146
-
1147
- /**
1148
- * 预设 2:commands——assistant 的 toolCall,带 name + D1 摘要(design §3.3 D3)。
1149
- * 可选 tool 过滤;过滤后零匹配 → F8(工具分布 + 👉,不抛错)。
1150
- * index = entry 在 turn.entries 内的位置,与 expand 的 [N] 对齐便于定位。
1151
- */
1152
- function extractCommands(turns: Turn[], tool: string | undefined): ToolResult {
1153
- const items: Array<{ turn: number; index: number; name: string; summary: string }> = []
1154
- for (const t of turns) {
1155
- for (let ei = 0; ei < t.entries.length; ei++) {
1156
- const e = t.entries[ei]
1157
- if (e.message?.role !== 'assistant') continue
1158
- for (const tc of extractToolCalls(e)) {
1159
- if (tool !== undefined && tc.name !== tool) continue
1160
- items.push({
1161
- turn: t.index,
1162
- index: ei,
1163
- name: tc.name,
1164
- summary: formatToolCallSummary(tc),
1165
- })
1166
- }
1167
- }
1168
- }
1169
- if (tool !== undefined && items.length === 0) return f8ToolNoMatch('commands', tool, turns)
1170
- return renderExtractItems(
1171
- 'commands',
1172
- items,
1173
- (it) => `T${pad(it.turn)} #${it.index} ${it.summary}`,
1174
- (it) => [it.turn],
1175
- )
1176
- }
1177
-
1178
- /**
1179
- * 预设 3:files——read/edit/write/head 的 path 去重(design §3.3 D3)。
1180
- * 同 path 多次操作合并,op 聚合成 `read+edit` 形式,turns 记录出现过的轮次。
1181
- * todo/subagent/cw 无 path 不纳入。无匹配不报错(返空 + 提示)。
1182
- */
1183
- function extractFiles(turns: Turn[]): ToolResult {
1184
- const map = new Map<string, { ops: Set<string>; turns: Set<number> }>()
1185
- for (const t of turns) {
1186
- for (const e of t.entries) {
1187
- if (e.message?.role !== 'assistant') continue
1188
- for (const tc of extractToolCalls(e)) {
1189
- if (!FILE_TOOLS.has(tc.name)) continue
1190
- const p = tc.arguments.path
1191
- if (typeof p !== 'string') continue
1192
- let rec = map.get(p)
1193
- if (rec === undefined) {
1194
- rec = { ops: new Set(), turns: new Set() }
1195
- map.set(p, rec)
1196
- }
1197
- rec.ops.add(tc.name)
1198
- rec.turns.add(t.index)
1199
- }
1200
- }
1201
- }
1202
- const items = Array.from(map.entries()).map(([path, rec]) => ({
1203
- path,
1204
- basename: basename(path),
1205
- op: Array.from(rec.ops).sort().join('+'),
1206
- turns: Array.from(rec.turns).sort((a, b) => a - b),
1207
- }))
1208
- return renderExtractItems(
1209
- 'files',
1210
- items,
1211
- (it) => `${it.op}: ${it.path} (${turnsLabel(it.turns)})`,
1212
- (it) => it.turns,
1213
- `what=files 无匹配(该 session 无 read/edit/write/head 文件操作)。`,
1214
- )
1215
- }
1216
-
1217
- /**
1218
- * 预设 4:commits——git 命令 toolResult 的 hash(design §3.3 D3 + D6 误匹配处理)。
1219
- *
1220
- * 保守策略(宁可少召回不要乱报 uuid):
1221
- * ① 主路径(高置信):只从 bash 且关联 command 含 git (log|show|commit|push|merge|...) 的
1222
- * toolResult 文本提取 7-8 位 hex;
1223
- * ② 次路径(中置信):扫所有 toolResult 文本,hash 前后各 30 字符内含
1224
- * feat:/fix:/commit/merge 等关键词的才纳入;
1225
- * ③ 去重,git-cmd 置信度优先;不扫 user/assistant 自由文本(uuid/session-id 误报太多)。
1226
- *
1227
- * 已知局限:7-8 位 hex 与 uuid v7 片段形似,靠 git 命令上下文过滤;仍可能漏报
1228
- *(git 操作未被 toolResult 捕获)或误报(git log 输出里的其他 hex)。每条标注来源 turn
1229
- * + source + context,agent 可快速辨认。完全语义判断需 LLM,本工具零 LLM 依赖。
1230
- */
1231
- /** commits 提取的 hash 前后上下文字符数(次路径关键词判定窗口)。 */
1232
- const COMMIT_CONTEXT_CHARS = 30
1233
-
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> {
1244
- const bashCmds = new Map<string, string>()
1245
- for (const t of turns) {
1246
- for (const e of t.entries) {
1247
- if (e.message?.role !== 'assistant') continue
1248
- for (const tc of extractToolCalls(e)) {
1249
- if (tc.name === 'bash') {
1250
- const cmd = tc.arguments.command
1251
- if (typeof cmd === 'string') bashCmds.set(tc.id, cmd)
1252
- }
1253
- }
1254
- }
1255
- }
1256
- return bashCmds
1257
- }
1258
-
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
- }
1279
- }
1280
- }
1281
-
1282
- /** 主扫描阶段:遍历 toolResult entry,分类收集高/低置信 commit 候选。 */
1283
- function collectCommitCandidates(
1284
- turns: Turn[],
1285
- bashCmds: Map<string, string>,
1286
- ): { high: CommitItem[]; low: CommitItem[] } {
1287
- const high: CommitItem[] = []
1288
- const low: CommitItem[] = []
1289
- for (const t of turns) {
1290
- for (const e of t.entries) {
1291
- if (e.message?.role !== 'toolResult') continue
1292
- const msg = e.message
1293
- const text = extractContentText(msg.content)
1294
- if (text === '') continue
1295
- const cmd = msg.toolCallId !== undefined ? bashCmds.get(msg.toolCallId) : undefined
1296
- const isGitBash = msg.toolName === 'bash' && cmd !== undefined && GIT_CMD_RE.test(cmd)
1297
- matchCommitsInResult(text, t.index, isGitBash, high, low)
1298
- }
1299
- }
1300
- return { high, low }
1301
- }
1302
-
1303
- /** 收口阶段:去重,高置信优先,同 hash 保留首次。 */
1304
- function dedupeCommits(high: CommitItem[], low: CommitItem[]): CommitItem[] {
1305
- const seen = new Set<string>()
1306
- const items: CommitItem[] = []
1307
- for (const c of high) {
1308
- if (seen.has(c.hash)) continue
1309
- seen.add(c.hash)
1310
- items.push(c)
1311
- }
1312
- for (const c of low) {
1313
- if (seen.has(c.hash)) continue
1314
- seen.add(c.hash)
1315
- items.push(c)
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)
1325
-
1326
- return renderExtractItems(
1327
- 'commits',
1328
- items,
1329
- (it) => `T${pad(it.turn)} ${it.hash} [${it.source}] ${it.context}`,
1330
- (it) => [it.turn],
1331
- `what=commits 无匹配(该 session 无 git commit hash,或未在 toolResult 中出现)。`,
1332
- )
1333
- }
1334
-
1335
- /** tool-results 正文截断上限(防爆)。 */
1336
- const TOOL_RESULT_TEXT_MAX_CHARS = 500
1337
-
1338
- /**
1339
- * 预设 5:tool-results——role==='toolResult' 文本(design §3.3 D3)。
1340
- * text 截断到 500 字防爆;可选 tool 过滤(msg.toolName);过滤零匹配 → F8。
1341
- */
1342
- function extractToolResults(turns: Turn[], tool: string | undefined): ToolResult {
1343
- const items: Array<{ turn: number; index: number; toolName: string; text: string }> = []
1344
- for (const t of turns) {
1345
- for (let ei = 0; ei < t.entries.length; ei++) {
1346
- const e = t.entries[ei]
1347
- if (e.message?.role !== 'toolResult') continue
1348
- const tn = e.message.toolName ?? '?'
1349
- if (tool !== undefined && tn !== tool) continue
1350
- const text = truncateText(extractContentText(e.message.content), TOOL_RESULT_TEXT_MAX_CHARS)
1351
- items.push({ turn: t.index, index: ei, toolName: tn, text })
1352
- }
1353
- }
1354
- if (tool !== undefined && items.length === 0)
1355
- return f8ToolNoMatch('tool-results', tool, turns)
1356
- return renderExtractItems(
1357
- 'tool-results',
1358
- items,
1359
- (it) => `T${pad(it.turn)} #${it.index} ${it.toolName}: ${it.text}`,
1360
- (it) => [it.turn],
1361
- )
1362
- }
1363
-
1364
1055
  /**
1365
1056
  * extract:跨 turn 按类型提取素材(design §3.3 D3 五预设 + F7/F8/F9)。
1366
1057
  *
1367
1058
  * 流程:resolveSessionId(multi 走 disambiguate)→ safeParse → buildTreeView +
1368
1059
  * segmentTurns → 可选 turns 范围限定(复用 parseTurnsRange)→ F7 校验 what → 分发 5 预设。
1369
1060
  */
1370
- async function doExtract(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
1371
- const resolved = await resolveSessionId(params.session, 'extract', agentDir, params.source)
1061
+ async function doExtract(
1062
+ params: SessionReadParams,
1063
+ agentDir: string,
1064
+ liveSessionDir?: string,
1065
+ ): Promise<ToolResult> {
1066
+ const resolved = await resolveSessionId(
1067
+ params.session,
1068
+ 'extract',
1069
+ agentDir,
1070
+ params.source,
1071
+ undefined,
1072
+ liveSessionDir,
1073
+ )
1372
1074
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
1373
1075
  const { entries } = await safeParse(resolved.fileName)
1374
1076
  // extract 遍历全量 entry(含旁支/压缩历史),与 outline/expand/detail 的 leaf 视图不同:
@@ -1459,8 +1161,19 @@ interface SkippedRun {
1459
1161
  * step 的 call sessionId/sessionFile 是 LLM 跳 outline/detail 的入口(m0 resolveSessionId
1460
1162
  * 三形态复用:sessionId/绝对路径/sa-id 均可深读,TC-wf-step-sessionfile-link)。
1461
1163
  */
1462
- async function doWorkflow(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
1463
- const resolved = await resolveSessionId(params.session, 'workflow', agentDir, params.source)
1164
+ async function doWorkflow(
1165
+ params: SessionReadParams,
1166
+ agentDir: string,
1167
+ liveSessionDir?: string,
1168
+ ): Promise<ToolResult> {
1169
+ const resolved = await resolveSessionId(
1170
+ params.session,
1171
+ 'workflow',
1172
+ agentDir,
1173
+ params.source,
1174
+ undefined,
1175
+ liveSessionDir,
1176
+ )
1464
1177
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
1465
1178
 
1466
1179
  let workflows: WorkflowRef[]
@@ -1548,7 +1261,65 @@ async function doWorkflow(params: SessionReadParams, agentDir: string): Promise<
1548
1261
  return { content: [{ type: 'text', text }], details }
1549
1262
  }
1550
1263
 
1551
- /** result action 的注入依赖(构造期绑定本文件私有 helper,运行时零查找开销)。 */
1264
+ // ---------------------------------------------------------------------------
1265
+ // u11 标题元数据 TTL 缓存(design 2026-09-10 §6.6 调用策略 ③)
1266
+ // ---------------------------------------------------------------------------
1267
+
1268
+ /**
1269
+ * 标题缓存条目(SessionMetadataEntry[] keyed by 目录字面路径)。与 doctor 的
1270
+ * doctorScanCache **独立实例**——两者语义不同:doctor 缓存根扫描统计(文件数/耗时),
1271
+ * 本缓存标题元数据(session_info name / firstMessage,低频变更)。
1272
+ */
1273
+ interface MetadataCacheEntry {
1274
+ entries: SessionMetadataEntry[]
1275
+ /** 写入时刻(Date.now()),TTL 判定用 */
1276
+ cachedAt: number
1277
+ /** 目录 mtime(ms);不存在为 null(存在性翻转即失效) */
1278
+ dirMtimeMs: number | null
1279
+ }
1280
+
1281
+ const metadataCache = new Map<string, MetadataCacheEntry>()
1282
+ // §7.5 豁免(development-guide.md「纯性能缓存豁免」):TTL 纯性能缓存,jiti 双路径加载
1283
+ // 分裂成两份仅多一次 miss 重扫,无正确性影响,不升级 globalThis 单例。
1284
+
1285
+ /**
1286
+ * 标题缓存 TTL(秒级,§6.6 策略 ③)。量级与 doctor 根扫描缓存同档(DOCTOR_CACHE_TTL_MS),
1287
+ * 待 §11.3a 实测校准;mtime 是主失效通道,TTL 兜「目录内文件追加不改目录 mtime」的陈旧面
1288
+ *(标题恰好随首条消息落盘,同窗口内新增标题最多延迟一个 TTL 可见,可接受)。
1289
+ */
1290
+ export const METADATA_CACHE_TTL_MS = DOCTOR_CACHE_TTL_MS
1291
+
1292
+ /**
1293
+ * 把注入的 metadataProvider 包上 TTL 缓存(get 失效判定 + set 快照)。
1294
+ *
1295
+ * 只缓存成功结果——provider 抛错原样上抛,由发现层单目录 try/catch 记空继续(guard),
1296
+ * 且瞬态失败不污染缓存(下个查询即重试)。find 的多次 findSessions 调用(u10 分组探测
1297
+ * main/subagent 两路)与连续 keyword 查询都经此处去重,listAll 每 TTL 窗口至多一次/目录。
1298
+ */
1299
+ export function withMetadataCache(provider: SessionMetadataProvider): SessionMetadataProvider {
1300
+ return async (dir) => {
1301
+ const hit = metadataCache.get(dir)
1302
+ if (hit !== undefined) {
1303
+ const expired = Date.now() - hit.cachedAt >= METADATA_CACHE_TTL_MS
1304
+ const mtime = await statDirMtimeOrNull(dir)
1305
+ if (!expired && mtime === hit.dirMtimeMs) return hit.entries
1306
+ metadataCache.delete(dir)
1307
+ }
1308
+ const entries = await provider(dir)
1309
+ metadataCache.set(dir, {
1310
+ entries,
1311
+ cachedAt: Date.now(),
1312
+ dirMtimeMs: await statDirMtimeOrNull(dir),
1313
+ })
1314
+ return entries
1315
+ }
1316
+ }
1317
+
1318
+ /**
1319
+ * result action 的注入依赖(构造期绑定本文件私有 helper,运行时零查找开销)。
1320
+ * resolveSessionId 仅作类型/缺省绑定——入口分发时被 per-call 包装覆盖(闭包捕获
1321
+ * 信号包 liveSessionDir,见 handleSessionRead result case)。
1322
+ */
1552
1323
  const RESULT_ACTION_DEPS: ResultActionDeps = {
1553
1324
  err,
1554
1325
  stripHash,
@@ -1566,45 +1337,71 @@ export { extractFinalAssistantText }
1566
1337
  // ===========================================================================
1567
1338
 
1568
1339
  /**
1569
- * session_read 工具的纯逻辑 handler(agentDir 注入,零 pi 依赖,可单测)。
1340
+ * session_read 工具的纯逻辑 handler(信号包注入,零 pi 依赖,可单测)。
1570
1341
  *
1571
- * 按 params.action 分发到 doFind/doFamily/doOutline/doExpand/doDetail/doSearch/doExport。
1342
+ * 按 params.action 分发到 do* 家族(11 个 action,与本文件各 action 实现一一对应)。
1572
1343
  * F1(resolve)/F4/F5/F6 抛 Error(含 👉);F2 多匹配与 find 零匹配返回结果不抛。
1573
1344
  *
1345
+ * @param signals 发现层信号包(design §7B:index.ts 采集 { agentDir, liveSessionDir? },
1346
+ * 采集端全可选链可降级)。兼容接受裸 agentDir string(存量单测与外部深 import 的旧签名
1347
+ * 形态,入口归一化为只含 agentDir 的信号包,行为与旧签名逐字节一致;工具运行路径恒传
1348
+ * 完整信号包)。u9 起 find/F1 路径消费根列表(F1 自检行恒走无 options 实扫,
1349
+ * 不读 doctor 缓存,§7B 要点 8)。
1574
1350
  * @param signal 可选 AbortSignal(MF-5):仅 search 消费(长扫描可中断);其余 action 有界,不接。
1351
+ * @param metadataProvider 可选标题元数据注入(u11,design 2026-09-10 §6.6):index.ts 构造
1352
+ * `(dir) => SessionManager.listAll(dir)`,此处包 TTL 缓存后透传 find。缺省 = undefined =
1353
+ * 现状行为(标题检索不可用,首条 user 匹配不受影响);provider 抛错由发现层单目录
1354
+ * try/catch 降级,不外抛。
1575
1355
  */
1576
1356
  export async function handleSessionRead(
1577
1357
  params: SessionReadParams,
1578
- agentDir: string,
1358
+ signals: SessionReadSignals | string,
1579
1359
  signal?: AbortSignal,
1360
+ metadataProvider?: SessionMetadataProvider,
1580
1361
  ): Promise<ToolResult> {
1362
+ // 裸 string(存量单测/外部深 import 旧签名,D-8)归一化为信号包;doctor 需要完整
1363
+ // 信号包(liveSessionDir/env/bundleUrl),缺省字段按各自降级语义处理。
1364
+ const norm: SessionReadSignals = typeof signals === 'string' ? { agentDir: signals } : signals
1365
+ const agentDir = norm.agentDir
1366
+ // u11:TTL 缓存包装在注入边界(策略 ③,独立于 doctorScanCache 的实例);仅 find 消费。
1367
+ const cachedProvider =
1368
+ metadataProvider === undefined ? undefined : withMetadataCache(metadataProvider)
1581
1369
  switch (params.action) {
1582
1370
  case 'find':
1583
- return doFind(params, agentDir)
1371
+ return doFind(params, norm, cachedProvider)
1584
1372
  case 'family':
1585
- return doFamily(params, agentDir)
1373
+ return doFamily(params, agentDir, norm.liveSessionDir)
1586
1374
  case 'outline':
1587
- return doOutline(params, agentDir)
1375
+ return doOutline(params, agentDir, norm.liveSessionDir)
1588
1376
  case 'expand':
1589
- return doExpand(params, agentDir)
1377
+ return doExpand(params, agentDir, norm.liveSessionDir)
1590
1378
  case 'detail':
1591
- return doDetail(params, agentDir)
1379
+ return doDetail(params, agentDir, norm.liveSessionDir)
1592
1380
  case 'search':
1593
- return doSearch(params, agentDir, signal)
1381
+ return doSearch(params, norm, signal, cachedProvider)
1594
1382
  case 'export':
1595
- return doExport(params, agentDir)
1383
+ return doExport(params, agentDir, norm.liveSessionDir)
1596
1384
  case 'extract':
1597
- return doExtract(params, agentDir)
1385
+ return doExtract(params, agentDir, norm.liveSessionDir)
1598
1386
  case 'workflow':
1599
- return doWorkflow(params, agentDir)
1387
+ return doWorkflow(params, agentDir, norm.liveSessionDir)
1600
1388
  case 'result':
1601
- return doResult(params, agentDir, RESULT_ACTION_DEPS)
1389
+ // per-call 覆盖 deps.resolveSessionId:把信号包中的 liveSessionDir 闭包进解析调用
1390
+ //(ResultActionDeps 接口签名固定 5 参,包装保持同形、末位补传),result 的片段
1391
+ // 形态与 find/outline 消费同一 roots(sa-/绝对路径分支在 resolveSessionId 内不受影响)。
1392
+ return doResult(params, agentDir, {
1393
+ ...RESULT_ACTION_DEPS,
1394
+ resolveSessionId: (rawSession, action, ad, source, prefetchedManifests) =>
1395
+ resolveSessionId(rawSession, action, ad, source, prefetchedManifests, norm.liveSessionDir),
1396
+ })
1397
+ case 'doctor':
1398
+ return doDoctor(params, norm)
1602
1399
  default: {
1603
- // exhaustive guard:switch 覆盖全部 10 action,此处 params.action 收窄为 never;
1400
+ // exhaustive guard:switch 覆盖全部 11 action,此处 params.action 收窄为 never;
1604
1401
  // 仅防御运行时非法 action(schema 正常校验下不可达)
1605
1402
  const exhaustive: never = params.action
1606
1403
  throw err(
1607
- `未知 action "${JSON.stringify(exhaustive)}"。👉 合法 action: find/family/outline/expand/detail/search/export/extract/workflow/result。`,
1404
+ `未知 action "${JSON.stringify(exhaustive)}"。👉 合法 action: find/family/outline/expand/detail/search/export/extract/workflow/result/doctor。`,
1608
1405
  )
1609
1406
  }
1610
1407
  }