@zhushanwen/pi-session-reader 0.5.0 → 0.6.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.
@@ -11,26 +11,30 @@
11
11
  * 域模块拆分(max-lines 拆分轮机械提取,零行为变更,result-action.ts 先例同型):
12
12
  * result-action.ts(result)/ doctor.ts(doctor + SessionReadSignals)/
13
13
  * search-across.ts(search 管线 + u12 跨会话)/ extract.ts(extract 预设)/
14
- * no-match.ts(F1 自检行)/ handler-utils.ts(pad/err/turn 索引解析低层小工具)。
14
+ * no-match.ts(F1 自检行)/ handler-utils.ts(pad/err/stripHash/requireStr/
15
+ * SESSION_ID_PREFIX_LEN/turn 索引解析低层小工具)。
15
16
  * 本模块保留公共类型、定位解析(resolveSessionId)、各 action 编排与共享渲染;
16
- * 拆出域的公开导出经此 re-export(index.ts / 单测白盒 import 路径不变)。
17
+ * 域模块符号不经此 re-export——从所属域模块直接 import(唯一例外 SessionReadSignals:
18
+ * 本模块 re-export 供 index.ts 生产消费)。
17
19
  *
18
20
  * 错误规格 F1-F6:handler 抛 Error(message 含 👉 恢复指引),index.ts 的 execute 闭包
19
21
  * 原样传播给 pi——pi-agent-core 只对 execute throw 置 isError:true(返回值里的 isError
20
22
  * 字段被丢弃,agent-loop.js:453-483)。handler 可抛(纯逻辑可测)。
21
23
  * 例外:F2 多匹配与 F1 find 零匹配「不视为错误」,返回消歧/提示结果而非抛错。
22
24
  */
23
- import { existsSync, openSync, readSync, closeSync } from 'node:fs'
24
- import { mkdir, writeFile } from 'node:fs/promises'
25
+ import { existsSync } from 'node:fs'
26
+ import { mkdir, stat, writeFile } from 'node:fs/promises'
25
27
  import { join, isAbsolute } from 'node:path'
26
28
  import { homedir } from 'node:os'
29
+ import { toErrorMessage } from '@zhushanwen/pi-ext-guards'
27
30
  import {
28
31
  findSessions,
29
32
  type MatchedSession,
30
33
  type SessionMetadataEntry,
31
34
  type SessionMetadataProvider,
32
35
  } from './discovery/find.js'
33
- import { resolveSessionRoots } from './discovery/roots.js'
36
+ import { resolveSessionRoots, type SessionRoot } from './discovery/roots.js'
37
+ import { readSessionHeaderIdSync } from './discovery/session-header.js'
34
38
  import {
35
39
  buildFamilyFromFs,
36
40
  listRecordManifests,
@@ -52,10 +56,9 @@ import {
52
56
  type ToolResultSummaryEntry,
53
57
  } from './core/render.js'
54
58
  import type { Family, SessionRef, WorkflowRef } from './core/family.js'
55
- import { doResult, extractFinalAssistantText, type ResultActionDeps } from './result-action.js'
59
+ import { doResult } from './result-action.js'
56
60
 
57
- // result action 主体在 result-action.ts(max-lines 拆分轮机械提取,零行为变更);
58
- // 导出面保持不变:extractFinalAssistantText 仍从本模块导出(包内测试白盒导入路径不变)。
61
+ // result action 主体在 result-action.ts(max-lines 拆分轮机械提取,零行为变更)。
59
62
  import {
60
63
  buildExecutionTree,
61
64
  formatExecutionTreeText,
@@ -63,7 +66,18 @@ import {
63
66
  } from './core/execution-tree.js'
64
67
  // 同轮拆分的域模块(依赖方向:本模块 → 域模块 → handler-utils,无循环;
65
68
  // 域模块对本模块仅 type import——编译期擦除,同 result-action.ts 先例)。
66
- import { err, pad, parseTurnIndex, parseTurnsRange, rangeLabel } from './handler-utils.js'
69
+ // stripHash/requireStr/SESSION_ID_PREFIX_LEN 经 handler-utils 供本模块与 result-action
70
+ // 直接消费(ext-simplify-04 E5:同包 helper 获取范式单一化)。
71
+ import {
72
+ SESSION_ID_PREFIX_LEN,
73
+ err,
74
+ pad,
75
+ parseTurnIndex,
76
+ parseTurnsRange,
77
+ rangeLabel,
78
+ requireStr,
79
+ stripHash,
80
+ } from './handler-utils.js'
67
81
  import { formatNoMatch } from './no-match.js'
68
82
  import {
69
83
  collectSearchHits,
@@ -81,14 +95,10 @@ import {
81
95
  extractUserMessages,
82
96
  type ExtractWhat,
83
97
  } from './extract.js'
84
- import { doDoctor, DOCTOR_CACHE_TTL_MS, statDirMtimeOrNull, type SessionReadSignals } from './doctor.js'
98
+ import { doDoctor, type SessionReadSignals } from './doctor.js'
85
99
 
86
- // 拆出域的公开导出面保持从本模块可见(index.ts / 单测白盒 import 路径不变)。
100
+ // SessionReadSignals re-export 是生产链(index.ts 工具注册消费),非测试兼容转发。
87
101
  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'
92
102
 
93
103
  // ---------------------------------------------------------------------------
94
104
  // 公共类型(与 index.ts 的 TypeBox schema 对齐)
@@ -149,11 +159,6 @@ export interface ToolResult {
149
159
  // 小工具
150
160
  // ---------------------------------------------------------------------------
151
161
 
152
- /** 剥 # 前缀(TUI `#e6c96` 引用 → 纯片段,design §3.3 D-3/D-4)。 */
153
- function stripHash(s: string): string {
154
- return s.replace(/^#+/, '')
155
- }
156
-
157
162
  /** formatDate 日期段(月/日)补零宽度。 */
158
163
  const DATE_FIELD_WIDTH = 2
159
164
 
@@ -174,18 +179,6 @@ function shortCwd(cwd: string): string {
174
179
  return parts.slice(-SHORT_CWD_SEGMENTS).join('/')
175
180
  }
176
181
 
177
- /** F5 必填参数校验。 */
178
- function requireStr(
179
- val: string | undefined,
180
- name: string,
181
- action: SessionReadAction,
182
- ): string {
183
- if (val === undefined || val === null || val.trim() === '') {
184
- throw err(`action:"${action}" 需要参数 "${name}"。👉 补上 "${name}" 重试。`)
185
- }
186
- return val.trim()
187
- }
188
-
189
182
  // ---------------------------------------------------------------------------
190
183
  // resolveSessionId:片段 → 完整 id(design §3.4 resolveSessionId 辅助)
191
184
  // ---------------------------------------------------------------------------
@@ -194,50 +187,8 @@ export type ResolveResult =
194
187
  | { kind: 'ok'; sessionId: string; fileName: string }
195
188
  | { kind: 'multi'; query: string; candidates: MatchedSession[] }
196
189
 
197
- /** readSessionHeaderId 读首行的 buffer 上限。session header(id/cwd/parentSession)实测 < 300 字节,4KB 足够。 */
198
- const HEADER_READ_BYTES = 4096
199
-
200
- /**
201
- * 同步读 session 文件首行 header,返回 type==='session' 的 id。
202
- *
203
- * 任何异常(文件不存在/空文件/解析失败/type 不符)返回 undefined。与 find.ts readFirstLine/
204
- * parseHeader 同构(定长 buffer 读首行 + JSON.parse + type 校验),但用同步 fs API
205
- *(resolveSessionId 内仅调用 1 次,同步开销可接受),且不导出——避免与 w1 的 find.ts
206
- * 文件交叉(CQ2 决策)。
207
- */
208
- function readSessionHeaderId(filePath: string): string | undefined {
209
- let fd: number | undefined
210
- try {
211
- fd = openSync(filePath, 'r')
212
- const buf = Buffer.alloc(HEADER_READ_BYTES)
213
- const bytesRead = readSync(fd, buf, 0, HEADER_READ_BYTES, 0)
214
- if (bytesRead === 0) return undefined
215
- const text = buf.subarray(0, bytesRead).toString('utf8')
216
- const nl = text.indexOf('\n')
217
- const line = nl === -1 ? text : text.slice(0, nl)
218
- let raw: unknown
219
- try {
220
- raw = JSON.parse(line)
221
- } catch {
222
- return undefined
223
- }
224
- if (typeof raw !== 'object' || raw === null) return undefined
225
- const o = raw as Record<string, unknown>
226
- if (o.type !== 'session' || typeof o.id !== 'string') return undefined
227
- return o.id
228
- } catch {
229
- return undefined
230
- } finally {
231
- if (fd !== undefined) {
232
- try {
233
- closeSync(fd)
234
- } catch {
235
- // closeSync 失败:fd 可能已无效,header 数据已读取,关闭失败不影响结果(best-effort)
236
- void fd
237
- }
238
- }
239
- }
240
- }
190
+ // readSessionHeaderIdSync(同步读首行 header 取 id,resolveSessionId 形态①/②消费)
191
+ // 在 discovery/session-header.ts(D5 单源,sync 4KB 版原样搬入)。
241
192
 
242
193
  /** ~ 前缀(home 目录简写),与 expandHome 配套避免 magic number。 */
243
194
  const HOME_TILDE_PREFIX = '~/'
@@ -300,7 +251,7 @@ function resolveBySessionPath(session: string): ResolveResult {
300
251
  if (!existsSync(expanded)) {
301
252
  throw err(`读取失败:${session}(文件不存在)。👉 检查文件或换 session。`)
302
253
  }
303
- const headerId = readSessionHeaderId(expanded)
254
+ const headerId = readSessionHeaderIdSync(expanded)
304
255
  if (headerId === undefined) {
305
256
  throw err(
306
257
  `读取失败:${session}(首行非合法 session header)。👉 检查文件或换 session。`,
@@ -327,7 +278,7 @@ async function resolveByRecordId(session: string, agentDir: string, prefetchedMa
327
278
  if (!existsSync(record.sessionFile)) {
328
279
  throw err(formatSessionGc(record))
329
280
  }
330
- const headerId = readSessionHeaderId(record.sessionFile)
281
+ const headerId = readSessionHeaderIdSync(record.sessionFile)
331
282
  if (headerId === undefined) {
332
283
  // header 读不出不降级 record.id(sa- 形态不可当 sessionId,CQ3)
333
284
  throw err(
@@ -355,9 +306,10 @@ async function resolveByFragment(
355
306
  }
356
307
  const { matches } = await findSessions(session, agentDir, opts)
357
308
  if (matches.length === 0) {
358
- // F1 自检行需要发现层实况:无 options 的 resolveSessionRoots 恒实扫(不读 doctor
359
- // 缓存,§7B 要点 8),与 find 刚完成的扫描同一数据源(roots.ts 薄包装语义);
360
- // 信号包同源(liveSessionDir 透传)——findSessions 内部对空串/undefined 已有降级 guard。
309
+ // F1 自检行需要发现层实况:无 options 的 resolveSessionRoots 恒实扫(根扫描无
310
+ // 缓存——doctor 缓存机已删除,ext-simplify-04 U3),与 find 刚完成的扫描同一数据
311
+ // 源(roots.ts 薄包装语义);信号包同源(liveSessionDir 透传)——findSessions
312
+ // 内部对空串/undefined 已有降级 guard。
361
313
  const roots = await resolveSessionRoots({ agentDir, liveSessionDir })
362
314
  throw err(formatNoMatch(session, roots))
363
315
  }
@@ -398,8 +350,6 @@ function formatSaIdAmbiguous(saId: string, records: RecordManifest[]): string {
398
350
  )
399
351
  }
400
352
 
401
- /** sessionId 列表行内的短显前缀长度。 */
402
- const SESSION_ID_PREFIX_LEN = 8
403
353
  /** 消歧提示的 uuid 片段长度(比短显略长,引导输入更长片段消歧)。 */
404
354
  const HINT_ID_PREFIX_LEN = 12
405
355
 
@@ -428,7 +378,7 @@ async function safeParse(fileName: string): Promise<ParseResult> {
428
378
  return await parseSessionFile(fileName)
429
379
  } catch (e) {
430
380
  throw err(
431
- `读取失败:${fileName}(${e instanceof Error ? e.message : String(e)})。👉 检查文件或换 session。`,
381
+ `读取失败:${fileName}(${toErrorMessage(e)})。👉 检查文件或换 session。`,
432
382
  )
433
383
  }
434
384
  }
@@ -500,21 +450,15 @@ function formatFindContent(query: string, groups: FindGroup[], truncated: boolea
500
450
  return `${head}\n${lines.join('\n')}`
501
451
  }
502
452
 
503
- function formatOutlineText(r: OutlineResult): string {
504
- const lines = r.turns.map((b) => {
505
- const time = b.startTime ? b.startTime.match(/T(\d{2}:\d{2})/)?.[1] ?? '' : ''
506
- const parts = [`T${pad(b.index)}${time ? ' ' + time : ''}`]
507
- if (b.userBrief) parts.push(b.userBrief)
508
- if (b.toolSummary) parts.push(b.toolSummary)
509
- // v2 O1:补 assistant 结论行(→ )让 outline 单独可决策
510
- if (b.assistantBrief) parts.push('→ ' + b.assistantBrief)
511
- const om = formatBytesMarker(b.omittedBytes)
512
- if (om) parts.push(om)
513
- if (b.branch) parts.push('[旁支]')
514
- return parts.join(' · ')
515
- })
453
+ /**
454
+ * outline 尾段(stats 摘要行 + truncated 提示)。E7/D3 行渲染统一:行主体 = result.lines
455
+ *(renderOutline 返回的渲染行,预算度量与展示同一份),行格式知识只在 core/render.ts 的
456
+ * formatLine 一处,tool-handler 不再重建行格式;本函数只拼 stats 尾段(skippedLines 由
457
+ * doOutline 用 ParseResult 覆盖后再渲染)。doOutline/doExport 同一拼装
458
+ * (`lines.join('\n')` + 本尾段),两 action 输出一致 by construction。
459
+ */
460
+ function formatOutlineTail(r: OutlineResult): string {
516
461
  const tail = [
517
- '',
518
462
  `${r.stats.totalTurns} turns · ${r.stats.totalEntries} entries · ~${r.tokenEstimate} tokens${
519
463
  r.stats.skippedLines > 0 ? ` · ${r.stats.skippedLines} skipped lines` : ''
520
464
  }`,
@@ -522,7 +466,7 @@ function formatOutlineText(r: OutlineResult): string {
522
466
  ]
523
467
  .filter(Boolean)
524
468
  .join('\n')
525
- return `${lines.join('\n')}\n${tail}`
469
+ return tail
526
470
  }
527
471
 
528
472
  function formatExpandText(turn: string, entries: EntryBrief[]): string {
@@ -602,6 +546,35 @@ function formatDetailText(
602
546
  return `${head}\n${body}`
603
547
  }
604
548
 
549
+ /**
550
+ * family subagents 行的 task 摘要截断宽度(D2②:LLM 判断「哪个 subagent 分支相关」所需
551
+ * 的信息量,信息密度对齐 find 的 firstMessagePreview;探针 P4 输出量级锚点)。
552
+ */
553
+ const FAMILY_TASK_RENDER_LIMIT = 60
554
+
555
+ /**
556
+ * task → 单行摘要:压平空白(task 原文可含换行,换行会破坏 family 输出的行结构)后截断。
557
+ */
558
+ function familyTaskSummary(task: string): string {
559
+ const flat = task.replace(/\s+/g, ' ').trim()
560
+ return flat.length <= FAMILY_TASK_RENDER_LIMIT ? flat : flat.slice(0, FAMILY_TASK_RENDER_LIMIT) + '…'
561
+ }
562
+
563
+ /**
564
+ * subagents 行富字段展示(ext-simplify-04 D2②):status 终态短标签 + agent 名 + task 摘要。
565
+ * 富字段来自 manifest/identity 组装(SubagentRef,不经 enrichRefs——已删除);孤儿
566
+ *(cleanedUp)只标 [已清理],不再展开摘要(已清理即终局,文件 GC 后无深读入口)。
567
+ */
568
+ function formatSubagentLine(s: Family['subagents'][number]): string {
569
+ const base = ` ${s.sessionId.slice(0, SESSION_ID_PREFIX_LEN)} root=${s.rootSessionId.slice(0, SESSION_ID_PREFIX_LEN)} slug=${s.slug}`
570
+ if (s.cleanedUp) return `${base} [已清理]`
571
+ const parts: string[] = []
572
+ if (s.status) parts.push(`[${s.status}]`)
573
+ if (s.agentName) parts.push(s.agentName)
574
+ if (s.task) parts.push(`· ${familyTaskSummary(s.task)}`)
575
+ return parts.length > 0 ? `${base} ${parts.join(' ')}` : base
576
+ }
577
+
605
578
  function formatFamilyText(f: Family): string {
606
579
  const lines: string[] = []
607
580
  lines.push(`root: ${f.root.sessionId} (${formatDate(f.root.mtime)})`)
@@ -610,16 +583,7 @@ function formatFamilyText(f: Family): string {
610
583
  if (f.forks.length)
611
584
  lines.push(`forks: ${f.forks.map((p) => p.sessionId.slice(0, SESSION_ID_PREFIX_LEN)).join(', ')}`)
612
585
  if (f.subagents.length)
613
- lines.push(
614
- `subagents:\n${f.subagents
615
- .map(
616
- (s) =>
617
- ` ${s.sessionId.slice(0, SESSION_ID_PREFIX_LEN)} root=${s.rootSessionId.slice(0, SESSION_ID_PREFIX_LEN)} slug=${s.slug}${
618
- s.cleanedUp ? ' [已清理]' : ''
619
- }`,
620
- )
621
- .join('\n')}`,
622
- )
586
+ lines.push(`subagents:\n${f.subagents.map(formatSubagentLine).join('\n')}`)
623
587
  if (f.workflows.length)
624
588
  lines.push(
625
589
  `workflows:\n${f.workflows
@@ -637,15 +601,18 @@ function formatFamilyText(f: Family): string {
637
601
  const FIND_DEFAULT_LIMIT = 20
638
602
 
639
603
  /**
640
- * find 零匹配:F1 自检行(u9)。计数取本次实扫(无 options 恒实扫,不读 doctor
641
- * 缓存——§7B 要点 8 PS-14),完整信号包保证 [live] 根(最高优先级)计数可见。
604
+ * find 零匹配:F1 自检行(u9)。计数取本次实扫(无 options 恒实扫——根扫描无缓存,
605
+ * doctor 缓存机已删除,ext-simplify-04 U3),完整信号包保证 [live] 根(最高优先级)
606
+ * 计数可见。
607
+ *
608
+ * E1(ext-simplify-04 §3 D1):roots 由 doFind 预解析传入——与匹配用同一次实扫
609
+ * (调用方保证无 options),不再独立第三次全量扫盘。
642
610
  */
643
- function findNoMatch(query: string, signals: SessionReadSignals): Promise<ToolResult> {
644
- // F1 自检行需要发现层实况:resolveSessionRoots 与 find 刚完成的扫描同一数据源。
645
- return resolveSessionRoots(signals).then((roots) => ({
611
+ function findNoMatch(query: string, roots: SessionRoot[]): ToolResult {
612
+ return {
646
613
  content: [{ type: 'text', text: formatNoMatch(query, roots) }],
647
614
  details: { matches: [], truncated: false },
648
- }))
615
+ }
649
616
  }
650
617
 
651
618
  /**
@@ -667,6 +634,12 @@ function findNoMatch(query: string, signals: SessionReadSignals): Promise<ToolRe
667
634
  * 检索的惰性/窄化/TTL 缓存策略都在发现层与注入包装侧,本函数只负责透传(缺省
668
635
  * undefined = 现状行为)。多次 findSessions 调用(分组探测)经注入侧 TTL 缓存去重,
669
636
  * 标题 listAll 每 TTL 窗口至多一次/目录。
637
+ *
638
+ * E1 预解析根复用(ext-simplify-04 §3 D1,清 impl-plan D-15④ 债):开头一次
639
+ * resolveSessionRoots(signals)(无 options 恒实扫——根扫描无缓存,doctor 缓存机
640
+ * 已删除,ext-simplify-04 U3),显式 source / 分组两段 findSessions 与零匹配
641
+ * findNoMatch(F1 自检行)共用——一次 doFind 内 signals 恒同值、数据完全同源,
642
+ * 目录扫描从 2-3 次收敛为恒 1 次。
670
643
  */
671
644
  async function doFind(
672
645
  params: SessionReadParams,
@@ -676,6 +649,8 @@ async function doFind(
676
649
  const query = requireStr(params.query, 'query', 'find')
677
650
  const limit = params.limit ?? FIND_DEFAULT_LIMIT
678
651
  const cwd = params.cwd
652
+ // E1:单次根解析(完整信号包——空 liveSessionDir 由 sessionRootSpecs 内部 guard 降级)
653
+ const roots = await resolveSessionRoots(signals)
679
654
 
680
655
  // 显式 source:单组,匹配层原语义(mtime 排序 + limit 截断),无分组展示
681
656
  if (params.source !== undefined) {
@@ -685,8 +660,9 @@ async function doFind(
685
660
  source: params.source,
686
661
  liveSessionDir: signals.liveSessionDir,
687
662
  metadataProvider,
663
+ roots,
688
664
  })
689
- if (matches.length === 0) return findNoMatch(query, signals)
665
+ if (matches.length === 0) return findNoMatch(query, roots)
690
666
  return {
691
667
  content: [
692
668
  {
@@ -709,6 +685,7 @@ async function doFind(
709
685
  source: 'main',
710
686
  liveSessionDir: signals.liveSessionDir,
711
687
  metadataProvider,
688
+ roots,
712
689
  })
713
690
  const mainHasMore = mainRes.matches.length > limit
714
691
  const mainShown = mainHasMore ? mainRes.matches.slice(0, limit) : mainRes.matches
@@ -723,12 +700,13 @@ async function doFind(
723
700
  source: 'subagent',
724
701
  liveSessionDir: signals.liveSessionDir,
725
702
  metadataProvider,
703
+ roots,
726
704
  })
727
705
  const subOverflow = remaining > 0 ? subRes.matches.length > remaining : subRes.matches.length > 0
728
706
  const subShown = subRes.matches.slice(0, Math.max(remaining, 0))
729
707
 
730
708
  const matches = [...mainShown, ...subShown]
731
- if (matches.length === 0) return findNoMatch(query, signals)
709
+ if (matches.length === 0) return findNoMatch(query, roots)
732
710
  const truncated = mainHasMore || subOverflow
733
711
  return {
734
712
  content: [
@@ -779,7 +757,7 @@ async function doFamily(
779
757
  tree = await buildExecutionTree(resolved.sessionId, agentDir, resolved.fileName)
780
758
  } catch (e) {
781
759
  throw err(
782
- `构建执行树失败:${resolved.sessionId}(${e instanceof Error ? e.message : String(e)})。👉 检查 session 或用 find 重新定位,或改用 recursive:false 看 flat family 兜底。`,
760
+ `构建执行树失败:${resolved.sessionId}(${toErrorMessage(e)})。👉 检查 session 或用 find 重新定位,或改用 recursive:false 看 flat family 兜底。`,
783
761
  )
784
762
  }
785
763
  return {
@@ -794,13 +772,14 @@ async function doFamily(
794
772
  family = await buildFamilyFromFs(resolved.sessionId, agentDir)
795
773
  } catch (e) {
796
774
  throw err(
797
- `读取家族失败:${resolved.sessionId}(${e instanceof Error ? e.message : String(e)})。👉 检查 session 或用 find 重新定位。`,
775
+ `读取家族失败:${resolved.sessionId}(${toErrorMessage(e)})。👉 检查 session 或用 find 重新定位。`,
798
776
  )
799
777
  }
800
778
  return { content: [{ type: 'text', text: formatFamilyText(family) }], details: family }
801
779
  }
802
780
 
803
- /** outline:turn 级全貌 TOC(design §3.4 outline,~1500 token;render budget 硬编码 2000)。 */
781
+ /** outline:turn 级全貌 TOC(design §3.4 outline,~1500 token;budget 走 render 侧
782
+ * OUTLINE_DEFAULT_BUDGET_TOKENS 默认,handler 不再传测试专用缝参数)。 */
804
783
  async function doOutline(
805
784
  params: SessionReadParams,
806
785
  agentDir: string,
@@ -819,7 +798,6 @@ async function doOutline(
819
798
  const tree = buildTreeView(entries)
820
799
  const turns = segmentTurns(entries, new Set(tree.leafPath))
821
800
  const opts: OutlineOptions = {
822
- budget: 2000,
823
801
  allBranches: params.allBranches,
824
802
  granularity: params.granularity,
825
803
  }
@@ -830,7 +808,11 @@ async function doOutline(
830
808
  // [D8d] skippedLines 同模式覆盖:parser 已检测坏行计数(render 签名不含 ParseResult 恒 0),
831
809
  // 有检测必有报告——静默跳过行对调用方不可见 = 数据完整性缺口
832
810
  result.stats.skippedLines = skippedLines
833
- return { content: [{ type: 'text', text: formatOutlineText(result) }], details: result }
811
+ // E7 行渲染统一:行主体 = result.lines(renderOutline 渲染行),handler 只拼 stats 尾段
812
+ return {
813
+ content: [{ type: 'text', text: `${result.lines.join('\n')}\n${formatOutlineTail(result)}` }],
814
+ details: result,
815
+ }
834
816
  }
835
817
 
836
818
  /** expand:单 turn 的 entry 列表(design §3.4 expand)。turn 越界抛 F4。 */
@@ -982,7 +964,7 @@ async function doExport(
982
964
  family = await buildFamilyFromFs(resolved.sessionId, agentDir)
983
965
  } catch (e) {
984
966
  throw err(
985
- `读取家族失败:${resolved.sessionId}(${e instanceof Error ? e.message : String(e)})。👉 检查 session 或用 find 重新定位。`,
967
+ `读取家族失败:${resolved.sessionId}(${toErrorMessage(e)})。👉 检查 session 或用 find 重新定位。`,
986
968
  )
987
969
  }
988
970
  text = formatFamilyText(family)
@@ -1009,11 +991,11 @@ async function doExport(
1009
991
  const tree = buildTreeView(entries)
1010
992
  const turns = segmentTurns(entries, new Set(tree.leafPath))
1011
993
  const result = renderOutline(turns, tree, {
1012
- budget: 2000,
1013
994
  allBranches: params.allBranches,
1014
995
  granularity: params.granularity,
1015
996
  })
1016
- text = formatOutlineText(result)
997
+ // E7 行渲染统一:与 doOutline 同一拼装(lines + 尾段),两 action 输出一致
998
+ text = `${result.lines.join('\n')}\n${formatOutlineTail(result)}`
1017
999
  label = 'outline'
1018
1000
  }
1019
1001
 
@@ -1187,7 +1169,7 @@ async function doWorkflow(
1187
1169
  workflows = await resolveWorkflows(resolved.sessionId, sessionIdToPath, pathToRef)
1188
1170
  } catch (e) {
1189
1171
  throw err(
1190
- `读取 workflow run 失败:${resolved.sessionId}(${e instanceof Error ? e.message : String(e)})。👉 检查 session 或用 find 重新定位。`,
1172
+ `读取 workflow run 失败:${resolved.sessionId}(${toErrorMessage(e)})。👉 检查 session 或用 find 重新定位。`,
1191
1173
  )
1192
1174
  }
1193
1175
  const allRunIds = workflows.map((w) => w.runId)
@@ -1266,9 +1248,9 @@ async function doWorkflow(
1266
1248
  // ---------------------------------------------------------------------------
1267
1249
 
1268
1250
  /**
1269
- * 标题缓存条目(SessionMetadataEntry[] keyed by 目录字面路径)。与 doctor 的
1270
- * doctorScanCache **独立实例**——两者语义不同:doctor 缓存根扫描统计(文件数/耗时),
1271
- * 本缓存标题元数据(session_info name / firstMessage,低频变更)。
1251
+ * 标题缓存条目(SessionMetadataEntry[] keyed by 目录字面路径)。进程内唯一实例,
1252
+ * 仅缓存标题元数据(session_info name / firstMessage,低频变更)——根扫描统计
1253
+ * 不再有缓存(doctor 缓存机已删除,ext-simplify-04 U3),与根扫描无共享状态。
1272
1254
  */
1273
1255
  interface MetadataCacheEntry {
1274
1256
  entries: SessionMetadataEntry[]
@@ -1283,11 +1265,23 @@ const metadataCache = new Map<string, MetadataCacheEntry>()
1283
1265
  // 分裂成两份仅多一次 miss 重扫,无正确性影响,不升级 globalThis 单例。
1284
1266
 
1285
1267
  /**
1286
- * 标题缓存 TTL(秒级,§6.6 策略 ③)。量级与 doctor 根扫描缓存同档(DOCTOR_CACHE_TTL_MS),
1287
- * 待 §11.3a 实测校准;mtime 是主失效通道,TTL 兜「目录内文件追加不改目录 mtime」的陈旧面
1288
- *(标题恰好随首条消息落盘,同窗口内新增标题最多延迟一个 TTL 可见,可接受)。
1268
+ * 标题缓存 TTL(秒级,§6.6 策略 ③)。独立定义 5000(原「与 doctor 根扫描缓存同档
1269
+ * (DOCTOR_CACHE_TTL_MS)」的别名已随 doctor 缓存机删除而撤销,ext-simplify-04 U3);
1270
+ * 量级待 §11.3a 实测校准;mtime 是主失效通道,TTL 兜「目录内文件追加不改目录 mtime」
1271
+ * 的陈旧面(标题恰好随首条消息落盘,同窗口内新增标题最多延迟一个 TTL 可见,可接受)。
1289
1272
  */
1290
- export const METADATA_CACHE_TTL_MS = DOCTOR_CACHE_TTL_MS
1273
+ export const METADATA_CACHE_TTL_MS = 5000
1274
+
1275
+ /** stat 目录 mtime;不存在返回 null(与缓存条目的 null 比对 = 存在性未翻转)。 */
1276
+ async function statDirMtimeOrNull(path: string): Promise<number | null> {
1277
+ try {
1278
+ return (await stat(path)).mtimeMs
1279
+ } catch (err) {
1280
+ // 目录不存在是常态输入(候选根降级形态),非异常——void 同 roots.ts 容错
1281
+ void err
1282
+ return null
1283
+ }
1284
+ }
1291
1285
 
1292
1286
  /**
1293
1287
  * 把注入的 metadataProvider 包上 TTL 缓存(get 失效判定 + set 快照)。
@@ -1296,7 +1290,7 @@ export const METADATA_CACHE_TTL_MS = DOCTOR_CACHE_TTL_MS
1296
1290
  * 且瞬态失败不污染缓存(下个查询即重试)。find 的多次 findSessions 调用(u10 分组探测
1297
1291
  * main/subagent 两路)与连续 keyword 查询都经此处去重,listAll 每 TTL 窗口至多一次/目录。
1298
1292
  */
1299
- export function withMetadataCache(provider: SessionMetadataProvider): SessionMetadataProvider {
1293
+ function withMetadataCache(provider: SessionMetadataProvider): SessionMetadataProvider {
1300
1294
  return async (dir) => {
1301
1295
  const hit = metadataCache.get(dir)
1302
1296
  if (hit !== undefined) {
@@ -1315,23 +1309,6 @@ export function withMetadataCache(provider: SessionMetadataProvider): SessionMet
1315
1309
  }
1316
1310
  }
1317
1311
 
1318
- /**
1319
- * result action 的注入依赖(构造期绑定本文件私有 helper,运行时零查找开销)。
1320
- * resolveSessionId 仅作类型/缺省绑定——入口分发时被 per-call 包装覆盖(闭包捕获
1321
- * 信号包 liveSessionDir,见 handleSessionRead result case)。
1322
- */
1323
- const RESULT_ACTION_DEPS: ResultActionDeps = {
1324
- err,
1325
- stripHash,
1326
- requireStr,
1327
- resolveSessionId,
1328
- disambiguate,
1329
- safeParse,
1330
- sessionIdPrefixLen: SESSION_ID_PREFIX_LEN,
1331
- }
1332
-
1333
- export { extractFinalAssistantText }
1334
-
1335
1312
  // ===========================================================================
1336
1313
  // 入口:按 action 分发
1337
1314
  // ===========================================================================
@@ -1343,10 +1320,8 @@ export { extractFinalAssistantText }
1343
1320
  * F1(resolve)/F4/F5/F6 抛 Error(含 👉);F2 多匹配与 find 零匹配返回结果不抛。
1344
1321
  *
1345
1322
  * @param signals 发现层信号包(design §7B:index.ts 采集 { agentDir, liveSessionDir? },
1346
- * 采集端全可选链可降级)。兼容接受裸 agentDir string(存量单测与外部深 import 的旧签名
1347
- * 形态,入口归一化为只含 agentDir 的信号包,行为与旧签名逐字节一致;工具运行路径恒传
1348
- * 完整信号包)。u9 起 find/F1 路径消费根列表(F1 自检行恒走无 options 实扫,
1349
- * 不读 doctor 缓存,§7B 要点 8)。
1323
+ * 采集端全可选链可降级)。u9 起 find/F1 路径消费根列表(F1 自检行计数恒取本次
1324
+ * 实扫,无任何根扫描缓存,§7B 要点 8)。
1350
1325
  * @param signal 可选 AbortSignal(MF-5):仅 search 消费(长扫描可中断);其余 action 有界,不接。
1351
1326
  * @param metadataProvider 可选标题元数据注入(u11,design 2026-09-10 §6.6):index.ts 构造
1352
1327
  * `(dir) => SessionManager.listAll(dir)`,此处包 TTL 缓存后透传 find。缺省 = undefined =
@@ -1355,47 +1330,47 @@ export { extractFinalAssistantText }
1355
1330
  */
1356
1331
  export async function handleSessionRead(
1357
1332
  params: SessionReadParams,
1358
- signals: SessionReadSignals | string,
1333
+ signals: SessionReadSignals,
1359
1334
  signal?: AbortSignal,
1360
1335
  metadataProvider?: SessionMetadataProvider,
1361
1336
  ): 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 消费。
1337
+ const agentDir = signals.agentDir
1338
+ // u11:TTL 缓存包装在注入边界(策略 ③,metadata 缓存独立实例);仅 find/search 消费。
1367
1339
  const cachedProvider =
1368
1340
  metadataProvider === undefined ? undefined : withMetadataCache(metadataProvider)
1369
1341
  switch (params.action) {
1370
1342
  case 'find':
1371
- return doFind(params, norm, cachedProvider)
1343
+ return doFind(params, signals, cachedProvider)
1372
1344
  case 'family':
1373
- return doFamily(params, agentDir, norm.liveSessionDir)
1345
+ return doFamily(params, agentDir, signals.liveSessionDir)
1374
1346
  case 'outline':
1375
- return doOutline(params, agentDir, norm.liveSessionDir)
1347
+ return doOutline(params, agentDir, signals.liveSessionDir)
1376
1348
  case 'expand':
1377
- return doExpand(params, agentDir, norm.liveSessionDir)
1349
+ return doExpand(params, agentDir, signals.liveSessionDir)
1378
1350
  case 'detail':
1379
- return doDetail(params, agentDir, norm.liveSessionDir)
1351
+ return doDetail(params, agentDir, signals.liveSessionDir)
1380
1352
  case 'search':
1381
- return doSearch(params, norm, signal, cachedProvider)
1353
+ return doSearch(params, signals, signal, cachedProvider)
1382
1354
  case 'export':
1383
- return doExport(params, agentDir, norm.liveSessionDir)
1355
+ return doExport(params, agentDir, signals.liveSessionDir)
1384
1356
  case 'extract':
1385
- return doExtract(params, agentDir, norm.liveSessionDir)
1357
+ return doExtract(params, agentDir, signals.liveSessionDir)
1386
1358
  case 'workflow':
1387
- return doWorkflow(params, agentDir, norm.liveSessionDir)
1359
+ return doWorkflow(params, agentDir, signals.liveSessionDir)
1388
1360
  case 'result':
1389
- // per-call 覆盖 deps.resolveSessionId:把信号包中的 liveSessionDir 闭包进解析调用
1390
- //(ResultActionDeps 接口签名固定 5 参,包装保持同形、末位补传),result 的片段
1391
- // 形态与 find/outline 消费同一 roots(sa-/绝对路径分支在 resolveSessionId 内不受影响)。
1361
+ // per-call 构造注入面(仅剩 tool-handler 文件私有 helper,纯函数经 handler-utils
1362
+ // 直接 import——ext-simplify-04 E5):resolveSessionId 包装把信号包中的
1363
+ // liveSessionDir 闭包进解析调用(ResultActionDeps 接口签名固定 5 参,包装保持
1364
+ // 同形、末位补传),result 的片段形态与 find/outline 消费同一 roots
1365
+ //(sa-/绝对路径分支在 resolveSessionId 内不受影响)。
1392
1366
  return doResult(params, agentDir, {
1393
- ...RESULT_ACTION_DEPS,
1394
1367
  resolveSessionId: (rawSession, action, ad, source, prefetchedManifests) =>
1395
- resolveSessionId(rawSession, action, ad, source, prefetchedManifests, norm.liveSessionDir),
1368
+ resolveSessionId(rawSession, action, ad, source, prefetchedManifests, signals.liveSessionDir),
1369
+ disambiguate,
1370
+ safeParse,
1396
1371
  })
1397
1372
  case 'doctor':
1398
- return doDoctor(params, norm)
1373
+ return doDoctor(params, signals)
1399
1374
  default: {
1400
1375
  // exhaustive guard:switch 覆盖全部 11 action,此处 params.action 收窄为 never;
1401
1376
  // 仅防御运行时非法 action(schema 正常校验下不可达)
@@ -23,8 +23,9 @@ import { SessionManager, type SessionInfo } from '@earendil-works/pi-coding-agen
23
23
  * design D-4:插入纯文本 # uuid,不展开——工具侧(tool-handler)剥 # 前缀后按子串匹配。
24
24
  */
25
25
 
26
- /** # 补全默认返回上限(TUI 弹窗可读上限,design G6)。 */
27
- const DEFAULT_LIMIT = 10
26
+ /** # 补全默认返回上限(TUI 弹窗可读上限,design G6)。/session-pick 命令复用同一上限
27
+ * (两入口同一可读上限,2026-09 双常量合一,session-command 不再自持 PICK_LIMIT)。 */
28
+ export const DEFAULT_LIMIT = 10
28
29
  /**
29
30
  * description(预览/name)最大字符数。SessionInfo.firstMessage 是首消息全文(可能含
30
31
  * `<skill>` 注入全文,上千字符),预截断避免传超大字符串给 pi-tui。100 覆盖到 ~140 列
@@ -206,7 +207,10 @@ export function createHashAutocompleteProvider(
206
207
  return { items, prefix: `#${fragment}` }
207
208
  },
208
209
  applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
209
- // 非本 provider 的 item(不应发生,pi 按 suggestion source 路由 applyCompletion)→ 委托 current
210
+ // 非 # item = 经本 provider 的 getSuggestions 转发的 current(内置 @ 文件 / / 命令 /
211
+ // 路径)建议——pi-tui 实装(editor 持单一 autocompleteProvider,applyCompletion 直调
212
+ // 最外层包装者,无按 suggestion source 的路由)决定了这些 item 的 apply 也回到本
213
+ // provider,必须显式委托 current(组合责任在 factory,同 getSuggestions 的委托)
210
214
  if (!item.value.startsWith('#')) {
211
215
  return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix)
212
216
  }