@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.
@@ -1,6 +1,7 @@
1
- import { readdir, stat } from 'node:fs/promises'
1
+ import { readdir, stat, realpath } from 'node:fs/promises'
2
2
  import type { Dirent } from 'node:fs'
3
- import { join } from 'node:path'
3
+ import { join, basename, dirname } from 'node:path'
4
+ import { performance } from 'node:perf_hooks'
4
5
 
5
6
  /**
6
7
  * M2 discovery 发现层:文件系统扫描(design §3.3 D-5 首行扫描策略的文件定位部分)。
@@ -8,7 +9,226 @@ import { join } from 'node:path'
8
9
  * agentDir 注入:本模块所有函数接收 `agentDir: string` 参数,**不调用** `getAgentDir()`
9
10
  *(pi SDK,调用留到 M3 tool-adapter 层)。故本模块零 pi 依赖,仅 node:fs + 相对
10
11
  * import M1 core,可完全单测。
12
+ *
13
+ * U1(design 2026-09-10 §6.1/§7B):新增 `resolveSessionRoots(signals)` —— 信号包 →
14
+ * 带来源标签的候选根列表,逐根扫描、按 realpath 去重(同路径只扫一次,保留最高优先级
15
+ * kind 标签)。B 收缩(design §6.13):布局对齐后无 `[env]` 信号,`[default]` 为主根。
16
+ * 旧签名 `listMainSessions`/`listSubagentSessions` 保留为薄包装(内部构造只含 agentDir
17
+ * 的信号包 → main 源退化为 `[default]`+`[legacy]` 两根),存量调用(find.ts /
18
+ * subagents.ts)不破。
19
+ */
20
+
21
+ // ============================================================
22
+ // 候选根契约(U1 foundation:后续 doctor / find / family 共享)
23
+ // ============================================================
24
+
25
+ /** 发现层信号包(index.ts 采集,全部可降级;本模块只消费不采集) */
26
+ export interface SessionRootSignals {
27
+ /** pi agent 数据目录(两宿主均成立;B 后 xyz-agent = `<dataDir>/agent`) */
28
+ agentDir: string
29
+ /**
30
+ * 宿主已解析的 live session 目录(`ctx.sessionManager.getSessionDir()`,§6.1 信号 1)。
31
+ * 缺失/undefined 时走三根降级(`[default]`+`[legacy]`+`[subagent]`,§7B 要点 2)。
32
+ */
33
+ liveSessionDir?: string
34
+ }
35
+
36
+ /** 候选根来源标签;kind 优先级 live > default > legacy > subagent(§6.1 信号序) */
37
+ export type SessionRootKind = 'live' | 'default' | 'legacy' | 'subagent'
38
+
39
+ /** main 源 = 主 session 根(live/default/legacy);subagent 源 = subagent 会话根 */
40
+ export type SessionRootSource = 'main' | 'subagent'
41
+
42
+ /** 候选根(含扫描结果)。id 恒等于 kind:去重后每 kind 至多一个根,作 doctor 表行键。 */
43
+ export interface SessionRoot {
44
+ id: SessionRootKind
45
+ kind: SessionRootKind
46
+ /** 规范化后的绝对路径(`[live]` 已剥 encodeCwd 层) */
47
+ path: string
48
+ source: SessionRootSource
49
+ /** 目录是否存在(不存在的根仍列出——doctor 表「存在」列与三根降级可见性需要) */
50
+ exists: boolean
51
+ /** 扫描所得 .jsonl 数(= files.length);未扫描(被去重)时 undefined */
52
+ fileCount?: number
53
+ /** 本根 scanJsonlRecursive 耗时 ms;未扫描时 undefined */
54
+ scanMs?: number
55
+ /** 扫描所得文件列表(design §7B 要点 8「本就在返回值里」);未扫描时空数组 */
56
+ files: SessionFileMeta[]
57
+ /**
58
+ * realpath 与更高优先级根同路径 → 指向保留根的 id,本根未扫描(doctor 渲染
59
+ * 「与 N 同路径,已去重」注记的依据,§7B 要点 4)。
60
+ */
61
+ dedupedInto?: SessionRootKind
62
+ /**
63
+ * 本根数据来自调用方注入缓存(resolveSessionRoots options.cache 命中):fileCount/
64
+ * scanMs/exists 取缓存值,files 恒空数组——消费方不得把 cached 根的 files 当实扫
65
+ * 结果(u8 追加:doctor「同一数据源两处渲染」统一走 resolveSessionRoots,缓存语义
66
+ * 由调用方注入,§6.3)。
67
+ */
68
+ cached?: boolean
69
+ }
70
+
71
+ /** 各根固定子目录名(常量推导,不来自信号——§6.1 信号 5) */
72
+ const SESSIONS_DIRNAME = 'sessions'
73
+ const SUBAGENTS_DIRNAME = 'subagents'
74
+
75
+ /**
76
+ * `[live]` 规范化(§6.1/§7B 要点 3):`getSessionDir()` 在纯 pi 返回按 cwd 编码的子目录
77
+ *(`<agentDir>/sessions/--Users-x--`),在 xyz-agent 覆盖态返回根本身。判据:basename
78
+ * 匹配 encodeCwd 形态(`--` 开头 `--` 结尾,实证见 `~/.pi/agent/sessions/` 全部子目录
79
+ * 与 real-data.ts)则取 dirname,否则取自身。
11
80
  */
81
+ export function normalizeLiveSessionDir(liveSessionDir: string): string {
82
+ const base = basename(liveSessionDir)
83
+ return base.startsWith('--') && base.endsWith('--')
84
+ ? dirname(liveSessionDir)
85
+ : liveSessionDir
86
+ }
87
+
88
+ /**
89
+ * 从信号包推导候选根(未扫描骨架),数组序 = 优先级序(§6.1)。
90
+ * `[subagent]` 恒在(常量推导);`[live]` 仅在信号存在时出现。
91
+ */
92
+ function sessionRootSpecs(signals: SessionRootSignals): SessionRoot[] {
93
+ // 空 agentDir 防御:派生式会退化为 cwd 相对路径('sessions'/'./sessions'),可能扫到
94
+ // 进程 cwd 下的无关目录——宁缺毋错(§11.14 空 agentDir 自举由宿主层兜底)
95
+ if (signals.agentDir.length === 0) return []
96
+ const specs: SessionRoot[] = []
97
+ if (signals.liveSessionDir !== undefined && signals.liveSessionDir.length > 0) {
98
+ specs.push(spec('live', 'main', normalizeLiveSessionDir(signals.liveSessionDir)))
99
+ }
100
+ specs.push(spec('default', 'main', join(signals.agentDir, SESSIONS_DIRNAME)))
101
+ specs.push(spec('legacy', 'main', join(dirname(signals.agentDir), SESSIONS_DIRNAME)))
102
+ specs.push(spec('subagent', 'subagent', join(signals.agentDir, SUBAGENTS_DIRNAME)))
103
+ return specs
104
+ }
105
+
106
+ function spec(id: SessionRootKind, source: SessionRootSource, path: string): SessionRoot {
107
+ return { id, kind: id, source, path, exists: false, files: [] }
108
+ }
109
+
110
+ /** realpath 解析失败(目录不存在等)→ 回退字面路径作去重键(同字面路径仍可去重) */
111
+ async function safeRealpath(path: string): Promise<string> {
112
+ try {
113
+ return await realpath(path)
114
+ } catch {
115
+ return path
116
+ }
117
+ }
118
+
119
+ async function pathExists(path: string): Promise<boolean> {
120
+ try {
121
+ await stat(path)
122
+ return true
123
+ } catch {
124
+ return false
125
+ }
126
+ }
127
+
128
+ const SKIP_DIRS_NONE = new Set<string>()
129
+
130
+ // ============================================================
131
+ // 扫描 options(u8 追加:doctor「同一数据源两处渲染,不重复实现探测」§6.3——
132
+ // doctor 经 options 驱动 subagent 根不扫与进程内缓存,本模块不内置 TTL/mtime
133
+ // 失效逻辑,缓存语义全部由调用方注入的句柄定义)
134
+ // ============================================================
135
+
136
+ /** 缓存条目载荷(扫描统计快照;失效判定所需元数据由缓存实现方自持,不进本契约) */
137
+ export interface SessionRootCacheEntry {
138
+ exists: boolean
139
+ fileCount: number
140
+ scanMs: number
141
+ }
142
+
143
+ /**
144
+ * 根扫描的进程内缓存句柄(调用方注入)。get 返回 undefined = 未命中(含调用方判定
145
+ * TTL 到期/目录 mtime 变化后自行淘汰)→ 本模块实扫并 set 回写;命中 → 直接以缓存值
146
+ * 构造根(cached: true,files 恒空数组)。get/set 允许同步或异步实现。
147
+ */
148
+ export interface SessionRootCache {
149
+ get(key: string): Promise<SessionRootCacheEntry | undefined> | SessionRootCacheEntry | undefined
150
+ set(key: string, value: SessionRootCacheEntry): void | Promise<void>
151
+ }
152
+
153
+ /** resolveSessionRoots 扫描行为选项(全部可选,缺省 = 既有行为零变化) */
154
+ export interface SessionRootScanOptions {
155
+ /**
156
+ * subagent 根扫描模式:`'scan'`(默认,现行为——递归扫出文件数)| `'stat'`
157
+ *(doctor 默认形态 §6.3——只做存在性检查,fileCount/scanMs 恒 undefined,
158
+ * 不产缓存条目)。subagent 根在纯 pi 下可达数千文件,doctor 可能被反复询问。
159
+ */
160
+ subagents?: 'scan' | 'stat'
161
+ /** 根扫描缓存句柄(见 SessionRootCache)。缺省 = 每次实扫(find 路径永不传)。 */
162
+ cache?: SessionRootCache
163
+ }
164
+
165
+ /**
166
+ * 解析候选根并逐根扫描(design 2026-09-10 §6.1 候选根探测 / §7B 要点 3/4/6)。
167
+ *
168
+ * 优先级 live > default > legacy > subagent;按 realpath 去重:同路径多信号只扫一次,
169
+ * 保留最高优先级 kind 标签,被去重的根以 `dedupedInto` 标注(不产生 fileCount/scanMs)。
170
+ * 逐根复用 scanJsonlRecursive(main 源跳 workflow-state;subagent 源不跳)。单根失败
171
+ * (不存在/无权限)→ 空结果继续,不抛错(沿用 listXxxSessions 契约)。
172
+ *
173
+ * options(u8 追加,全部缺省安全):`subagents:'stat'` 让 subagent 根只做存在性检查;
174
+ * `cache` 提供进程内缓存句柄(命中即不实扫,cached 根 files 恒空)——cache 命中优先于
175
+ * 一切实扫,`set` 仅在实扫后回写(命中不回写)。find/F1 路径恒不传 options(自检行
176
+ * 计数必须取本次实扫,§7B 要点 8 PS-14)。
177
+ */
178
+ export async function resolveSessionRoots(
179
+ signals: SessionRootSignals,
180
+ options?: SessionRootScanOptions,
181
+ ): Promise<SessionRoot[]> {
182
+ const subagentMode = options?.subagents ?? 'scan'
183
+ const out: SessionRoot[] = []
184
+ const byRealPath = new Map<string, SessionRoot>()
185
+ for (const candidate of sessionRootSpecs(signals)) {
186
+ const key = await safeRealpath(candidate.path)
187
+ const kept = byRealPath.get(key)
188
+ if (kept) {
189
+ // 同 realpath:不扫描,标注归属(exists 与保留根同目录,必然一致)
190
+ out.push({ ...candidate, exists: kept.exists, dedupedInto: kept.id })
191
+ continue
192
+ }
193
+ if (candidate.source === 'subagent' && subagentMode === 'stat') {
194
+ // doctor 默认形态(§6.3):只列路径与可扫性,不产文件数、不入缓存
195
+ out.push({ ...candidate, exists: await pathExists(candidate.path) })
196
+ continue
197
+ }
198
+ const cached = options?.cache ? await options.cache.get(candidate.path) : undefined
199
+ if (cached) {
200
+ // 命中:缓存值即数据源(不实扫、不回写);files 恒空——消费方不得当实扫结果
201
+ const root: SessionRoot = {
202
+ ...candidate,
203
+ exists: cached.exists,
204
+ fileCount: cached.fileCount,
205
+ scanMs: cached.scanMs,
206
+ cached: true,
207
+ }
208
+ byRealPath.set(key, root)
209
+ out.push(root)
210
+ continue
211
+ }
212
+ const skipDirs = candidate.source === 'main' ? SKIP_DIRS_MAIN : SKIP_DIRS_NONE
213
+ const t0 = performance.now()
214
+ const files = await scanJsonlRecursive(candidate.path, skipDirs)
215
+ const scanMs = performance.now() - t0
216
+ const exists = await pathExists(candidate.path)
217
+ if (options?.cache) {
218
+ await options.cache.set(candidate.path, { exists, fileCount: files.length, scanMs })
219
+ }
220
+ const root: SessionRoot = {
221
+ ...candidate,
222
+ exists,
223
+ fileCount: files.length,
224
+ scanMs,
225
+ files,
226
+ }
227
+ byRealPath.set(key, root)
228
+ out.push(root)
229
+ }
230
+ return out
231
+ }
12
232
 
13
233
  export interface SessionFileMeta {
14
234
  /** 绝对路径 */
@@ -76,20 +296,28 @@ async function scanJsonlRecursive(
76
296
  }
77
297
 
78
298
  /**
79
- * 列出 agentDir/sessions/ 下所有主 session 文件。
299
+ * 旧签名薄包装(U1,design §7B 要点 7):内部构造只含 agentDir 的信号包,main 源退化为
300
+ * 「`[default]`+`[legacy]`」两根并集。工具运行路径后续单元改走 resolveSessionRoots 新签名,
301
+ * 本包装仅为存量调用(find.ts / subagents.ts)与外部深 import 保持不破。
302
+ *
80
303
  * 递归扫描子目录(cwd 编码目录如 --Users-foo--),glob *.jsonl,排除 *.jsonl.finalized
81
304
  *(design §3.3 D-7 Q2);跳过 workflow-state 子目录(workflow 运行状态文件,非 session)。
82
305
  */
83
306
  export async function listMainSessions(agentDir: string): Promise<SessionFileMeta[]> {
84
- return scanJsonlRecursive(join(agentDir, 'sessions'), SKIP_DIRS_MAIN)
307
+ const roots = await resolveSessionRoots({ agentDir })
308
+ return roots
309
+ .filter((r) => r.source === 'main' && r.dedupedInto === undefined)
310
+ .flatMap((r) => r.files)
85
311
  }
86
312
 
87
313
  /**
88
- * 列出 agentDir/subagents/ 下所有 subagent session 文件(同样排除 .finalized)。
89
- * 结构:subagents/<cwd编码>/sessions/*.jsonl。records/ 子目录(.json manifest)无 .jsonl,
90
- * 天然不被误收。
314
+ * 旧签名薄包装:agentDir 信号包下 `[subagent]` 根 = `<agentDir>/subagents`(常量推导),
315
+ * 行为与旧实现等价。结构:subagents/<cwd编码>/sessions/*.jsonl。records/ 子目录
316
+ *(.json manifest)无 .jsonl,天然不被误收。
91
317
  */
92
318
  export async function listSubagentSessions(agentDir: string): Promise<SessionFileMeta[]> {
93
- return scanJsonlRecursive(join(agentDir, 'subagents'), new Set())
319
+ const roots = await resolveSessionRoots({ agentDir })
320
+ const sub = roots.find((r) => r.kind === 'subagent')
321
+ return sub?.files ?? []
94
322
  }
95
323
 
@@ -4,7 +4,7 @@ import { join, basename } from 'node:path'
4
4
  import type { Entry } from '../core/parser.js'
5
5
  import type { Family, SessionRef, SubagentRef } from '../core/family.js'
6
6
  import { buildFamilyIndex, resolveFamily } from '../core/family.js'
7
- import { listMainSessions, listSubagentSessions, type SessionFileMeta } from './roots.js'
7
+ import { listMainSessions, listSubagentSessions, resolveSessionRoots, type SessionFileMeta } from './roots.js'
8
8
  import { resolveWorkflows } from './workflows.js'
9
9
 
10
10
  /**
@@ -51,11 +51,7 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
51
51
 
52
52
  // ---- 4-5. build index + resolve(sessionId 不在 byId → resolveFamily 抛 Error)----
53
53
  if (!scan.sessionIdToPath.has(sessionId)) {
54
- throw new Error(
55
- `session "${sessionId}" not found under ${agentDir}/sessions — ` +
56
- `no main session file whose first-line header id matches. ` +
57
- `Verify the sessionId or agentDir; for partial uuid, use findSessions first.`,
58
- )
54
+ throw new Error(await formatSessionNotFound(sessionId, agentDir))
59
55
  }
60
56
  const index = buildFamilyIndex(scan.headers, scan.identities, scan.fileStats)
61
57
  const family = resolveFamily(sessionId, index)
@@ -71,6 +67,28 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
71
67
  // buildFamilyFromFs 扫描阶段 helpers(各阶段显式传入累积器,插入顺序 = 原实现顺序)
72
68
  // ============================================================
73
69
 
70
+ /**
71
+ * not-found 错误文案:列出实际扫描过的 main 候选根(U1 并入部分,design §6.1 自证能力
72
+ * ——「没有这条 session」与「根配错了」必须可区分,错误信息携带发现层内部状态)。
73
+ * 仅失败路径调用,额外一次 resolveSessionRoots 的扫描成本可接受;根过滤条件与
74
+ * collectMainSessions 实扫集合一致(listMainSessions = agentDir 信号包下未去重的 main 根)。
75
+ */
76
+ async function formatSessionNotFound(sessionId: string, agentDir: string): Promise<string> {
77
+ const mainRoots = (await resolveSessionRoots({ agentDir })).filter(
78
+ (r) => r.source === 'main' && r.dedupedInto === undefined,
79
+ )
80
+ const lines = mainRoots.map((r) => {
81
+ const state = r.exists ? `${r.fileCount} file(s)` : 'not exists'
82
+ return ` - ${r.path} [${r.kind}] ${state}`
83
+ })
84
+ return (
85
+ `session "${sessionId}" not found. Scanned ${mainRoots.length} main candidate root(s):\n` +
86
+ `${lines.join('\n')}\n` +
87
+ `No main session file whose first-line header id matches. ` +
88
+ `Verify the sessionId or agentDir; for partial uuid, use findSessions first.`
89
+ )
90
+ }
91
+
74
92
  /** buildFamilyFromFs 各扫描阶段的共享累积器(主函数创建,各阶段 helper 显式传入并写入) */
75
93
  interface FamilyFsScan {
76
94
  /** main session headers(buildFamilyIndex byId/childrenOf 素材) */
package/src/doctor.ts ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * doctor action(u8:design 2026-09-10 §6.2/§6.3/§6.4/§7B 要点 2/4/5/8 + §6.11 U14b 段)。
3
+ *
4
+ * 从 tool-handler.ts 机械提取(max-lines 拆分轮,零行为变更):环境判定 + 根表渲染 +
5
+ * doctor 扫描缓存 + 旧布局残留 glob 探测。SessionReadSignals(u3 信号包超集)随域迁移,
6
+ * tool-handler re-export 保持导出面不变(index.ts / 单测白盒 import 路径不变)。
7
+ * statDirMtimeOrNull / DOCTOR_CACHE_TTL_MS 供留守的 u11 metadata 缓存复用(一并导出)。
8
+ */
9
+ import { existsSync } from 'node:fs'
10
+ import { readdir, stat } from 'node:fs/promises'
11
+ import { join, dirname } from 'node:path'
12
+ import { detectEnvironment, type DetectedEnvironment } from './discovery/env.js'
13
+ import {
14
+ resolveSessionRoots,
15
+ type SessionRoot,
16
+ type SessionRootCache,
17
+ type SessionRootCacheEntry,
18
+ type SessionRootSignals,
19
+ } from './discovery/roots.js'
20
+ import type { SessionReadParams, ToolResult } from './tool-handler.js'
21
+
22
+ /**
23
+ * doctor 所需环境信号:u3 信号包(SessionRootSignals)的超集,index.ts execute 补采
24
+ * env/bundleUrl。env 属宿主信号(detectEnvironment 契约),与根信号分属两个注入接口,
25
+ * 在 handler 层合流;两字段可选——缺省时 doctor 按「空 env / bundleUrl 缺失」降级
26
+ *(kind=standalone-pi、distribution=null),根表照常输出。
27
+ */
28
+ export interface SessionReadSignals extends SessionRootSignals {
29
+ /** process.env 快照(doctor 环境判定,design §6.2;缺省按空 env 降级) */
30
+ env?: Record<string, string | undefined>
31
+ /** extension 模块 import.meta.url(doctor 发行形态判定;缺省 distribution=null 不猜) */
32
+ bundleUrl?: string
33
+ }
34
+
35
+ /**
36
+ * doctor 扫描缓存(进程内,keyed by 根字面路径;Map 生命周期 = pi 进程生命周期,
37
+ * 即「进程生命周期兜底上限」)。失效 = 秒级 TTL 到期 **或** 根目录 mtime 变化(任一)。
38
+ *
39
+ * 以 SessionRootCache 句柄注入 resolveSessionRoots(§6.3「同一数据源两处渲染」——根
40
+ * 骨架/去重/扫描全在 roots.ts,doctor 只注入缓存与 subagent 扫描模式);TTL/mtime
41
+ * 失效判定全在本侧实现,roots.ts 不内置。**仅 doctor 消费——find 一律不读缓存**
42
+ *(§7B 要点 8):find 恒不传 options;若 find 读缓存,最坏形态是 PS-14(首条
43
+ * assistant 前 main 根 0 文件被缓存,之后每次都把「根解析正常」误报成「主根为空」)
44
+ * ——正是本设计要消灭的错误归因。
45
+ */
46
+ interface DoctorCacheEntry extends SessionRootCacheEntry {
47
+ /** 写入时刻(Date.now()),TTL 判定用 */
48
+ cachedAt: number
49
+ /** 根目录 mtime(ms);目录不存在为 null(存在性翻转即失效) */
50
+ dirMtimeMs: number | null
51
+ }
52
+
53
+ const doctorScanCache = new Map<string, DoctorCacheEntry>()
54
+ // §7.5 豁免(development-guide.md「纯性能缓存豁免」):TTL 纯性能缓存,jiti 双路径加载
55
+ // 分裂成两份仅多一次 miss 重扫,无正确性影响,不升级 globalThis 单例。
56
+
57
+ /** doctor 根表来源标签列宽(最长 `[subagent]` 10 字符 + 1 对齐间距,§5.1 表格形态)。 */
58
+ const DOCTOR_ROOT_LABEL_PAD = 11
59
+
60
+ /** 缓存 TTL(秒级,§6.3)。mtime 是主失效通道;TTL 兜「子目录内增删不改变根 mtime」的陈旧面。 */
61
+ export const DOCTOR_CACHE_TTL_MS = 5000
62
+
63
+ /** stat 根目录 mtime;不存在返回 null(与缓存条目的 null 比对 = 存在性未翻转)。 */
64
+ export async function statDirMtimeOrNull(path: string): Promise<number | null> {
65
+ try {
66
+ return (await stat(path)).mtimeMs
67
+ } catch (err) {
68
+ // 目录不存在是 doctor 的常态输入(三根降级形态),非异常——void 同 roots.ts 容错
69
+ void err
70
+ return null
71
+ }
72
+ }
73
+
74
+ /** doctor 侧缓存句柄:get 做失效判定(未命中即删除条目),set 快照 mtime 与写入时刻。 */
75
+ const doctorRootCache: SessionRootCache = {
76
+ async get(key) {
77
+ const hit = doctorScanCache.get(key)
78
+ if (hit === undefined) return undefined
79
+ if (Date.now() - hit.cachedAt >= DOCTOR_CACHE_TTL_MS) {
80
+ doctorScanCache.delete(key)
81
+ return undefined
82
+ }
83
+ if ((await statDirMtimeOrNull(key)) !== hit.dirMtimeMs) {
84
+ doctorScanCache.delete(key)
85
+ return undefined
86
+ }
87
+ return { exists: hit.exists, fileCount: hit.fileCount, scanMs: hit.scanMs }
88
+ },
89
+ async set(key, value) {
90
+ doctorScanCache.set(key, {
91
+ ...value,
92
+ cachedAt: Date.now(),
93
+ dirMtimeMs: await statDirMtimeOrNull(key),
94
+ })
95
+ },
96
+ }
97
+
98
+ /** 旧布局残留条目(§6.11 U14b doctor 侧独立 glob 探测)。 */
99
+ /** 旧布局残留探测单条命中(doctor 与 u14b 启动探测同源判据,§6.11)。 */
100
+ export interface PiLayoutLeftover {
101
+ path: string
102
+ kind: 'backup' | 'unmigrated'
103
+ }
104
+
105
+ /**
106
+ * 旧布局残留独立 glob 探测(§6.11 v9.1)。基点 = dirname(agentDir)(xyz-agent 下 =
107
+ * `<dataDir>`,纯 pi 下 = `~/.pi`);`pi/`(未迁移)与 `pi.backup-v2-` 前缀目录(迁移备份)
108
+ * 都不在任何候选根推导式内([legacy] 够不到带时间戳的备份名与 pi/ 层),故须独立探测。
109
+ *
110
+ * 形态判据(与 u14b 启动探测 / u14a 脚本 0a 同源):目录下含 `agent/` 或 `sessions/`
111
+ * 子目录才算残留——防纯 pi 宿主下任意来源的 `~/.pi/pi/` 目录误报。读探测,每次现查
112
+ * 不入缓存(两次 readdir 成本可忽略,缓存只服务扫盘贵的根)。
113
+ */
114
+ async function detectPiLayoutLeftovers(
115
+ agentDir: string,
116
+ ): Promise<{ base: string; hits: PiLayoutLeftover[] }> {
117
+ const base = dirname(agentDir)
118
+ if (agentDir.length === 0) return { base, hits: [] }
119
+ let names: string[]
120
+ try {
121
+ names = (await readdir(base, { withFileTypes: true }))
122
+ .filter((e) => e.isDirectory())
123
+ .map((e) => e.name)
124
+ } catch {
125
+ return { base, hits: [] }
126
+ }
127
+ const hits: PiLayoutLeftover[] = []
128
+ for (const name of names) {
129
+ const isUnmigrated = name === 'pi'
130
+ const isBackup = name.startsWith('pi.backup-v2-')
131
+ if (!isUnmigrated && !isBackup) continue
132
+ const dir = join(base, name)
133
+ // 形态判据:含 agent/ 或 sessions/ 子目录(迁移备份必含其一——备份即原 pi/ 改名)
134
+ if (!existsSync(join(dir, 'agent')) && !existsSync(join(dir, 'sessions'))) continue
135
+ hits.push({ path: dir, kind: isBackup ? 'backup' : 'unmigrated' })
136
+ }
137
+ return { base, hits }
138
+ }
139
+
140
+ /**
141
+ * doctor:环境判定 + 根表 + 事实型诊断 + legacy 非空告警 + 旧布局残留探测。
142
+ *
143
+ * 诊断结论只陈述事实(「最高优先级 main 根 N 文件」),禁止「真的没有 session」类
144
+ * 归因断言(§7B 要点 5 / §3.3 教训);env/bundleUrl 信号缺失时环境判定按降级输出,
145
+ * 根表照常(§7B 要点 2 同款降级哲学)。
146
+ */
147
+ export async function doDoctor(
148
+ params: SessionReadParams,
149
+ signals: SessionReadSignals,
150
+ ): Promise<ToolResult> {
151
+ const environment = detectEnvironment({
152
+ env: signals.env ?? {},
153
+ ...(signals.bundleUrl !== undefined ? { bundleUrl: signals.bundleUrl } : {}),
154
+ agentDir: signals.agentDir,
155
+ })
156
+ const roots = await resolveSessionRoots(signals, {
157
+ // subagent 根默认只 stat(§6.3 成本控制:只列路径与可扫性);includeSubagents:true
158
+ // 才扫(走同一缓存句柄)。find/F1 路径恒不传 options——不读缓存(§7B 要点 8)。
159
+ subagents: params.includeSubagents === true ? 'scan' : 'stat',
160
+ cache: doctorRootCache,
161
+ })
162
+ const leftovers = await detectPiLayoutLeftovers(signals.agentDir)
163
+ return renderDoctor(roots, environment, leftovers)
164
+ }
165
+
166
+ /**
167
+ * doctor action 的 details 契约(与兄弟 action 命名接口同构:tool-handler WorkflowDetails /
168
+ * search-across CrossSearchDetails 先例)——消费侧测试直 import 本类型,不再自建局部镜像。
169
+ */
170
+ export interface DoctorDetails {
171
+ environment: DetectedEnvironment
172
+ roots: SessionRoot[]
173
+ leftovers: PiLayoutLeftover[]
174
+ globBase: string
175
+ }
176
+
177
+ /**
178
+ * 渲染 doctor 输出(§5.1 形态):环境判定 + evidence + 会话根表(来源标签/路径/存在/
179
+ * 文件数/扫描耗时/去重注记)+ 事实型诊断 + legacy 非空告警 + 旧布局残留(命中才出现)。
180
+ */
181
+ function renderDoctor(
182
+ roots: SessionRoot[],
183
+ environment: DetectedEnvironment,
184
+ leftovers: { base: string; hits: PiLayoutLeftover[] },
185
+ ): ToolResult {
186
+ const lines: string[] = []
187
+
188
+ // 环境判定行 + evidence(§6.2:判定可被证据推翻,非黑盒断言;evidence 恒非空)
189
+ lines.push(
190
+ `环境判定:${environment.kind === 'xyz-agent' ? 'xyz-agent(托管)' : 'standalone-pi'}` +
191
+ ` · 发行形态:${environment.distribution ?? '未知(不猜)'}`,
192
+ )
193
+ if (environment.dataDir !== undefined) lines.push(`数据目录:${environment.dataDir}`)
194
+ lines.push('依据:')
195
+ for (const e of environment.evidence) lines.push(` - ${e}`)
196
+
197
+ // 会话根表(按优先级)
198
+ lines.push('')
199
+ lines.push('会话根(按优先级):')
200
+ roots.forEach((r, i) => {
201
+ lines.push(` ${i + 1}. ${`[${r.kind}]`.padEnd(DOCTOR_ROOT_LABEL_PAD)} ${r.path}`)
202
+ if (r.dedupedInto !== undefined) {
203
+ const keptIndex = roots.findIndex((k) => k.kind === r.dedupedInto) + 1
204
+ lines.push(` 与 ${keptIndex} 同路径,已去重`)
205
+ return
206
+ }
207
+ const facts = [r.exists ? '存在' : '不存在']
208
+ if (r.fileCount === undefined) {
209
+ // 仅 subagents:'stat' 的 subagent 根(doctor 默认形态)——其余根恒有计数
210
+ //(实扫或缓存命中);被去重根已在上方提前返回
211
+ facts.push('未扫描(subagent 根默认不扫,includeSubagents:true 开启)')
212
+ } else {
213
+ facts.push(`${r.fileCount} 文件`, `扫 ${Math.round(r.scanMs ?? 0)}ms`)
214
+ }
215
+ if (r.cached === true) facts.push('缓存命中')
216
+ facts.push(r.source === 'main' ? 'main' : 'subagent')
217
+ lines.push(` ${facts.join(' · ')}`)
218
+ })
219
+
220
+ // 事实型诊断(§7B 要点 5):列表内首个 main 根即最高优先级 main 根(去重只移除后位根,
221
+ // 首位根恒有计数——实扫或缓存命中)。0 文件也是事实——首条 assistant 前 jsonl 不落盘
222
+ //(PS-14),不做归因。
223
+ lines.push('')
224
+ const firstMain = roots.find((r) => r.source === 'main')
225
+ if (firstMain === undefined) {
226
+ lines.push('诊断:无候选根(agentDir 为空,未派生任何根)。')
227
+ } else {
228
+ lines.push(
229
+ `诊断:最高优先级 main 根 [${firstMain.kind}] ${firstMain.path}:${firstMain.fileCount ?? 0} 文件。`,
230
+ )
231
+ }
232
+
233
+ // legacy 非空告警(§6.1/§5.1:判据 = 非空才告警;与主根同路径被去重 → 不告警)
234
+ const legacy = roots.find((r) => r.kind === 'legacy')
235
+ if (legacy !== undefined && legacy.dedupedInto === undefined && (legacy.fileCount ?? 0) > 0) {
236
+ lines.push(
237
+ `告警:[legacy] 根 ${legacy.path} 非空(${legacy.fileCount} 文件)——该位置已作为候选根纳入 find。`,
238
+ )
239
+ }
240
+
241
+ // 旧布局残留(§6.11:命中才列,附同一迁移指引)
242
+ if (leftovers.hits.length > 0) {
243
+ lines.push('')
244
+ lines.push(`旧布局残留探测(基点 ${leftovers.base}):`)
245
+ for (const h of leftovers.hits) {
246
+ lines.push(
247
+ ` - ${h.path}(${h.kind === 'backup' ? '迁移备份 pi.backup-v2-*' : '未迁移旧布局 pi/'})`,
248
+ )
249
+ }
250
+ lines.push(' 👉 关闭应用后运行 scripts/migrate-pi-layout-v2.mjs 完成迁移。')
251
+ }
252
+
253
+ return {
254
+ content: [{ type: 'text', text: lines.join('\n') }],
255
+ details: {
256
+ environment,
257
+ roots,
258
+ leftovers: leftovers.hits,
259
+ globBase: leftovers.base,
260
+ } satisfies DoctorDetails,
261
+ }
262
+ }