@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.
- package/package.json +5 -2
- package/src/__tests__/cross-package-subagent-core.test.ts +13 -10
- package/src/__tests__/execution-tree.test.ts +139 -31
- package/src/__tests__/family.test.ts +0 -7
- package/src/__tests__/find.test.ts +5 -5
- package/src/__tests__/real-data.ts +1 -1
- package/src/__tests__/rebuild-manifest-dual-write.test.ts +177 -0
- package/src/__tests__/render.test.ts +36 -1
- package/src/__tests__/result.test.ts +20 -23
- package/src/__tests__/roots.test.ts +46 -80
- package/src/__tests__/session-command.test.ts +23 -0
- package/src/__tests__/subagents.test.ts +4 -3
- package/src/__tests__/tool-handler.test.ts +419 -141
- package/src/__tests__/workflow.test.ts +1 -7
- package/src/core/execution-tree.ts +1 -0
- package/src/core/family.ts +12 -9
- package/src/core/render.ts +40 -12
- package/src/core/workflow.ts +7 -20
- package/src/discovery/find.ts +42 -134
- package/src/discovery/roots.ts +16 -87
- package/src/discovery/session-header.ts +103 -0
- package/src/discovery/subagents.ts +23 -94
- package/src/doctor.ts +11 -77
- package/src/extract.ts +6 -19
- package/src/handler-utils.ts +28 -5
- package/src/no-match.ts +3 -2
- package/src/result-action.ts +30 -35
- package/src/search-across.ts +5 -4
- package/src/tool-handler.ts +151 -176
- package/src/tui/hash-provider.ts +7 -3
- package/src/tui/session-command.ts +22 -10
package/src/discovery/roots.ts
CHANGED
|
@@ -13,9 +13,9 @@ import { performance } from 'node:perf_hooks'
|
|
|
13
13
|
* U1(design 2026-09-10 §6.1/§7B):新增 `resolveSessionRoots(signals)` —— 信号包 →
|
|
14
14
|
* 带来源标签的候选根列表,逐根扫描、按 realpath 去重(同路径只扫一次,保留最高优先级
|
|
15
15
|
* kind 标签)。B 收缩(design §6.13):布局对齐后无 `[env]` 信号,`[default]` 为主根。
|
|
16
|
-
* 旧签名 `listMainSessions`/`listSubagentSessions`
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* 旧签名 `listMainSessions`/`listSubagentSessions` 薄包装已删除(ext-simplify-04 U4/A3:
|
|
17
|
+
* 内部即「resolveSessionRoots + filter」恒等式,包内原唯一消费者 subagents.ts 改直调;
|
|
18
|
+
* npm 深 import 移除按 minor breaking 在 CHANGELOG 登记)。
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
// ============================================================
|
|
@@ -39,9 +39,8 @@ export type SessionRootKind = 'live' | 'default' | 'legacy' | 'subagent'
|
|
|
39
39
|
/** main 源 = 主 session 根(live/default/legacy);subagent 源 = subagent 会话根 */
|
|
40
40
|
export type SessionRootSource = 'main' | 'subagent'
|
|
41
41
|
|
|
42
|
-
/**
|
|
42
|
+
/** 候选根(含扫描结果)。去重后每 kind 至多一个根,kind 即 doctor 表行键。 */
|
|
43
43
|
export interface SessionRoot {
|
|
44
|
-
id: SessionRootKind
|
|
45
44
|
kind: SessionRootKind
|
|
46
45
|
/** 规范化后的绝对路径(`[live]` 已剥 encodeCwd 层) */
|
|
47
46
|
path: string
|
|
@@ -55,17 +54,10 @@ export interface SessionRoot {
|
|
|
55
54
|
/** 扫描所得文件列表(design §7B 要点 8「本就在返回值里」);未扫描时空数组 */
|
|
56
55
|
files: SessionFileMeta[]
|
|
57
56
|
/**
|
|
58
|
-
* realpath 与更高优先级根同路径 → 指向保留根的
|
|
57
|
+
* realpath 与更高优先级根同路径 → 指向保留根的 kind,本根未扫描(doctor 渲染
|
|
59
58
|
* 「与 N 同路径,已去重」注记的依据,§7B 要点 4)。
|
|
60
59
|
*/
|
|
61
60
|
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
61
|
}
|
|
70
62
|
|
|
71
63
|
/** 各根固定子目录名(常量推导,不来自信号——§6.1 信号 5) */
|
|
@@ -103,8 +95,8 @@ function sessionRootSpecs(signals: SessionRootSignals): SessionRoot[] {
|
|
|
103
95
|
return specs
|
|
104
96
|
}
|
|
105
97
|
|
|
106
|
-
function spec(
|
|
107
|
-
return {
|
|
98
|
+
function spec(kind: SessionRootKind, source: SessionRootSource, path: string): SessionRoot {
|
|
99
|
+
return { kind, source, path, exists: false, files: [] }
|
|
108
100
|
}
|
|
109
101
|
|
|
110
102
|
/** realpath 解析失败(目录不存在等)→ 回退字面路径作去重键(同字面路径仍可去重) */
|
|
@@ -129,37 +121,18 @@ const SKIP_DIRS_NONE = new Set<string>()
|
|
|
129
121
|
|
|
130
122
|
// ============================================================
|
|
131
123
|
// 扫描 options(u8 追加:doctor「同一数据源两处渲染,不重复实现探测」§6.3——
|
|
132
|
-
// doctor 经 options 驱动 subagent
|
|
133
|
-
//
|
|
124
|
+
// doctor 经 options 驱动 subagent 根不扫。原 u8 的进程内缓存注入面已删除
|
|
125
|
+
// (ext-simplify-04 U3):每次调用实扫,本模块不再持有任何缓存语义)
|
|
134
126
|
// ============================================================
|
|
135
127
|
|
|
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
128
|
/** resolveSessionRoots 扫描行为选项(全部可选,缺省 = 既有行为零变化) */
|
|
154
129
|
export interface SessionRootScanOptions {
|
|
155
130
|
/**
|
|
156
131
|
* subagent 根扫描模式:`'scan'`(默认,现行为——递归扫出文件数)| `'stat'`
|
|
157
|
-
*(doctor
|
|
158
|
-
*
|
|
132
|
+
*(doctor 默认形态与 not-found 文案 §6.3——只做存在性检查,fileCount/scanMs
|
|
133
|
+
* 恒 undefined)。subagent 根在纯 pi 下可达数千文件,doctor 可能被反复询问。
|
|
159
134
|
*/
|
|
160
135
|
subagents?: 'scan' | 'stat'
|
|
161
|
-
/** 根扫描缓存句柄(见 SessionRootCache)。缺省 = 每次实扫(find 路径永不传)。 */
|
|
162
|
-
cache?: SessionRootCache
|
|
163
136
|
}
|
|
164
137
|
|
|
165
138
|
/**
|
|
@@ -170,10 +143,9 @@ export interface SessionRootScanOptions {
|
|
|
170
143
|
* 逐根复用 scanJsonlRecursive(main 源跳 workflow-state;subagent 源不跳)。单根失败
|
|
171
144
|
* (不存在/无权限)→ 空结果继续,不抛错(沿用 listXxxSessions 契约)。
|
|
172
145
|
*
|
|
173
|
-
* options(u8 追加,全部缺省安全):`subagents:'stat'` 让 subagent
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* 计数必须取本次实扫,§7B 要点 8 PS-14)。
|
|
146
|
+
* options(u8 追加,全部缺省安全):`subagents:'stat'` 让 subagent 根只做存在性检查。
|
|
147
|
+
* 无缓存——每次调用实扫(原 u8 cache 注入面已删除,ext-simplify-04 U3;find/F1
|
|
148
|
+
* 自检行计数与 doctor 表计数同取本次实扫,§7B 要点 8)。
|
|
177
149
|
*/
|
|
178
150
|
export async function resolveSessionRoots(
|
|
179
151
|
signals: SessionRootSignals,
|
|
@@ -187,36 +159,19 @@ export async function resolveSessionRoots(
|
|
|
187
159
|
const kept = byRealPath.get(key)
|
|
188
160
|
if (kept) {
|
|
189
161
|
// 同 realpath:不扫描,标注归属(exists 与保留根同目录,必然一致)
|
|
190
|
-
out.push({ ...candidate, exists: kept.exists, dedupedInto: kept.
|
|
162
|
+
out.push({ ...candidate, exists: kept.exists, dedupedInto: kept.kind })
|
|
191
163
|
continue
|
|
192
164
|
}
|
|
193
165
|
if (candidate.source === 'subagent' && subagentMode === 'stat') {
|
|
194
|
-
// doctor
|
|
166
|
+
// doctor 默认形态 / not-found 文案(§6.3):只列路径与可扫性,不产文件数
|
|
195
167
|
out.push({ ...candidate, exists: await pathExists(candidate.path) })
|
|
196
168
|
continue
|
|
197
169
|
}
|
|
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
170
|
const skipDirs = candidate.source === 'main' ? SKIP_DIRS_MAIN : SKIP_DIRS_NONE
|
|
213
171
|
const t0 = performance.now()
|
|
214
172
|
const files = await scanJsonlRecursive(candidate.path, skipDirs)
|
|
215
173
|
const scanMs = performance.now() - t0
|
|
216
174
|
const exists = await pathExists(candidate.path)
|
|
217
|
-
if (options?.cache) {
|
|
218
|
-
await options.cache.set(candidate.path, { exists, fileCount: files.length, scanMs })
|
|
219
|
-
}
|
|
220
175
|
const root: SessionRoot = {
|
|
221
176
|
...candidate,
|
|
222
177
|
exists,
|
|
@@ -295,29 +250,3 @@ async function scanJsonlRecursive(
|
|
|
295
250
|
return results
|
|
296
251
|
}
|
|
297
252
|
|
|
298
|
-
/**
|
|
299
|
-
* 旧签名薄包装(U1,design §7B 要点 7):内部构造只含 agentDir 的信号包,main 源退化为
|
|
300
|
-
* 「`[default]`+`[legacy]`」两根并集。工具运行路径后续单元改走 resolveSessionRoots 新签名,
|
|
301
|
-
* 本包装仅为存量调用(find.ts / subagents.ts)与外部深 import 保持不破。
|
|
302
|
-
*
|
|
303
|
-
* 递归扫描子目录(cwd 编码目录如 --Users-foo--),glob *.jsonl,排除 *.jsonl.finalized
|
|
304
|
-
*(design §3.3 D-7 Q2);跳过 workflow-state 子目录(workflow 运行状态文件,非 session)。
|
|
305
|
-
*/
|
|
306
|
-
export async function listMainSessions(agentDir: string): Promise<SessionFileMeta[]> {
|
|
307
|
-
const roots = await resolveSessionRoots({ agentDir })
|
|
308
|
-
return roots
|
|
309
|
-
.filter((r) => r.source === 'main' && r.dedupedInto === undefined)
|
|
310
|
-
.flatMap((r) => r.files)
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* 旧签名薄包装:agentDir 信号包下 `[subagent]` 根 = `<agentDir>/subagents`(常量推导),
|
|
315
|
-
* 行为与旧实现等价。结构:subagents/<cwd编码>/sessions/*.jsonl。records/ 子目录
|
|
316
|
-
*(.json manifest)无 .jsonl,天然不被误收。
|
|
317
|
-
*/
|
|
318
|
-
export async function listSubagentSessions(agentDir: string): Promise<SessionFileMeta[]> {
|
|
319
|
-
const roots = await resolveSessionRoots({ agentDir })
|
|
320
|
-
const sub = roots.find((r) => r.kind === 'subagent')
|
|
321
|
-
return sub?.files ?? []
|
|
322
|
-
}
|
|
323
|
-
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { open, type FileHandle } from 'node:fs/promises'
|
|
2
|
+
import { closeSync, openSync, readSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* [ext-simplify-18 D5] session 文件首行 header 读取的包内单源(原 subagents.ts / find.ts
|
|
6
|
+
* 两份 async 副本 + tool-handler.ts 一份 sync 副本合一)。不进 shared——跨包第二消费方
|
|
7
|
+
* 不存在(runtime 侧首行读取是另一形态 + 分层禁反向 import,设计负面清单同口径)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** session header(首行 type==='session' entry 的消费子集)。 */
|
|
11
|
+
export interface SessionHeader {
|
|
12
|
+
id: string
|
|
13
|
+
cwd?: string
|
|
14
|
+
parentSession?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 首行读取 buffer 上限。header 超 8KB 的极端情况会截断致 parse 失败(parseSessionHeader
|
|
19
|
+
* 返回 null)——session header(id/cwd/parentSession)实测 < 300 字节,8KB 约 27 倍余量。
|
|
20
|
+
*/
|
|
21
|
+
export const HEADER_READ_BYTES = 8192
|
|
22
|
+
|
|
23
|
+
/** 读文件首行(header)。定长 8KB 一次 read;空文件/读失败返回 undefined。 */
|
|
24
|
+
export async function readSessionHeaderFirstLine(path: string): Promise<string | undefined> {
|
|
25
|
+
let fh: FileHandle | undefined
|
|
26
|
+
try {
|
|
27
|
+
fh = await open(path, 'r')
|
|
28
|
+
const buf = Buffer.alloc(HEADER_READ_BYTES)
|
|
29
|
+
const { bytesRead } = await fh.read(buf, 0, HEADER_READ_BYTES, 0)
|
|
30
|
+
if (bytesRead === 0) return undefined
|
|
31
|
+
const text = buf.subarray(0, bytesRead).toString('utf8')
|
|
32
|
+
const nl = text.indexOf('\n')
|
|
33
|
+
return nl === -1 ? text : text.slice(0, nl)
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined
|
|
36
|
+
} finally {
|
|
37
|
+
await fh?.close().catch(() => {})
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 解析 header 首行为 SessionHeader。非 session 行/缺 id → null。 */
|
|
42
|
+
export function parseSessionHeader(line: string | undefined): SessionHeader | null {
|
|
43
|
+
if (!line) return null
|
|
44
|
+
let raw: unknown
|
|
45
|
+
try {
|
|
46
|
+
raw = JSON.parse(line)
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
if (typeof raw !== 'object' || raw === null) return null
|
|
51
|
+
const o = raw as Record<string, unknown>
|
|
52
|
+
if (o.type !== 'session' || typeof o.id !== 'string') return null
|
|
53
|
+
const h: SessionHeader = { id: o.id }
|
|
54
|
+
if (typeof o.cwd === 'string') h.cwd = o.cwd
|
|
55
|
+
if (typeof o.parentSession === 'string') h.parentSession = o.parentSession
|
|
56
|
+
return h
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** readSessionHeaderIdSync 读首行的 buffer 上限。session header 实测 < 300 字节,4KB 足够。 */
|
|
60
|
+
const SYNC_HEADER_READ_BYTES = 4096
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 同步读 session 文件首行 header,返回 type==='session' 的 id。
|
|
64
|
+
*
|
|
65
|
+
* 任何异常(文件不存在/空文件/解析失败/type 不符)返回 undefined。与 async 版
|
|
66
|
+
* readSessionHeaderFirstLine/parseSessionHeader 同构(定长 buffer 读首行 + JSON.parse +
|
|
67
|
+
* type 校验),但用同步 fs API(resolveSessionId 内仅调用 1 次,同步开销可接受),
|
|
68
|
+
* 窗口取 4KB(仅取 id)——sync 形态与窗口差异是刻意的调用上下文适配,独立常量、
|
|
69
|
+
* 不与 async 版 8KB 强行统一。
|
|
70
|
+
*/
|
|
71
|
+
export function readSessionHeaderIdSync(filePath: string): string | undefined {
|
|
72
|
+
let fd: number | undefined
|
|
73
|
+
try {
|
|
74
|
+
fd = openSync(filePath, 'r')
|
|
75
|
+
const buf = Buffer.alloc(SYNC_HEADER_READ_BYTES)
|
|
76
|
+
const bytesRead = readSync(fd, buf, 0, SYNC_HEADER_READ_BYTES, 0)
|
|
77
|
+
if (bytesRead === 0) return undefined
|
|
78
|
+
const text = buf.subarray(0, bytesRead).toString('utf8')
|
|
79
|
+
const nl = text.indexOf('\n')
|
|
80
|
+
const line = nl === -1 ? text : text.slice(0, nl)
|
|
81
|
+
let raw: unknown
|
|
82
|
+
try {
|
|
83
|
+
raw = JSON.parse(line)
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
if (typeof raw !== 'object' || raw === null) return undefined
|
|
88
|
+
const o = raw as Record<string, unknown>
|
|
89
|
+
if (o.type !== 'session' || typeof o.id !== 'string') return undefined
|
|
90
|
+
return o.id
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined
|
|
93
|
+
} finally {
|
|
94
|
+
if (fd !== undefined) {
|
|
95
|
+
try {
|
|
96
|
+
closeSync(fd)
|
|
97
|
+
} catch {
|
|
98
|
+
// closeSync 失败:fd 可能已无效,header 数据已读取,关闭失败不影响结果(best-effort)
|
|
99
|
+
void fd
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -2,9 +2,10 @@ import { readFile, readdir, open, stat } from 'node:fs/promises'
|
|
|
2
2
|
import type { FileHandle } from 'node:fs/promises'
|
|
3
3
|
import { join, basename } from 'node:path'
|
|
4
4
|
import type { Entry } from '../core/parser.js'
|
|
5
|
-
import type { Family, SessionRef
|
|
5
|
+
import type { Family, SessionRef } from '../core/family.js'
|
|
6
6
|
import { buildFamilyIndex, resolveFamily } from '../core/family.js'
|
|
7
|
-
import {
|
|
7
|
+
import { resolveSessionRoots, type SessionFileMeta } from './roots.js'
|
|
8
|
+
import { parseSessionHeader, readSessionHeaderFirstLine } from './session-header.js'
|
|
8
9
|
import { resolveWorkflows } from './workflows.js'
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -56,8 +57,8 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
|
|
|
56
57
|
const index = buildFamilyIndex(scan.headers, scan.identities, scan.fileStats)
|
|
57
58
|
const family = resolveFamily(sessionId, index)
|
|
58
59
|
|
|
59
|
-
// ---- 6.
|
|
60
|
-
|
|
60
|
+
// ---- 6. workflows(enrichRefs 回填已删除,ext-simplify-04 U4/E2:fileName/cwd 占位
|
|
61
|
+
// 零文本读者,family 路径维持 core 层占位空串;富字段的消费者 = formatFamilyText 展示)----
|
|
61
62
|
family.workflows = await resolveWorkflows(sessionId, scan.sessionIdToPath, scan.pathToRef)
|
|
62
63
|
|
|
63
64
|
return family
|
|
@@ -71,10 +72,12 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
|
|
|
71
72
|
* not-found 错误文案:列出实际扫描过的 main 候选根(U1 并入部分,design §6.1 自证能力
|
|
72
73
|
* ——「没有这条 session」与「根配错了」必须可区分,错误信息携带发现层内部状态)。
|
|
73
74
|
* 仅失败路径调用,额外一次 resolveSessionRoots 的扫描成本可接受;根过滤条件与
|
|
74
|
-
* collectMainSessions 实扫集合一致(
|
|
75
|
+
* collectMainSessions 实扫集合一致(agentDir 信号包下未去重的 main 根)。
|
|
75
76
|
*/
|
|
76
77
|
async function formatSessionNotFound(sessionId: string, agentDir: string): Promise<string> {
|
|
77
|
-
|
|
78
|
+
// subagents:'stat'——本路径只渲染 main 根行,subagent 根不做深扫(ext-simplify-04 A2:
|
|
79
|
+
// 原默认 'scan' 白扫整个 subagent 根后 filter 丢弃)
|
|
80
|
+
const mainRoots = (await resolveSessionRoots({ agentDir }, { subagents: 'stat' })).filter(
|
|
78
81
|
(r) => r.source === 'main' && r.dedupedInto === undefined,
|
|
79
82
|
)
|
|
80
83
|
const lines = mainRoots.map((r) => {
|
|
@@ -97,9 +100,9 @@ interface FamilyFsScan {
|
|
|
97
100
|
identities: Entry[]
|
|
98
101
|
/** sessionId → { mtime, size }(buildFamilyIndex 存活判定素材) */
|
|
99
102
|
fileStats: Map<string, { mtime: number; size: number }>
|
|
100
|
-
/** sessionId → 真实文件路径,供 workflows 找目标文件
|
|
103
|
+
/** sessionId → 真实文件路径,供 workflows 找目标文件 */
|
|
101
104
|
sessionIdToPath: Map<string, string>
|
|
102
|
-
/** path → 完整 SessionRef(含真实 cwd/fileName/mtime/size),供
|
|
105
|
+
/** path → 完整 SessionRef(含真实 cwd/fileName/mtime/size),供 workflow calls 反查 */
|
|
103
106
|
pathToRef: Map<string, SessionRef>
|
|
104
107
|
/** 已扫描到的 subagent 文件路径集合,供 manifest 孤儿判定(alive 则跳过 manifest) */
|
|
105
108
|
aliveSubPaths: Set<string>
|
|
@@ -114,9 +117,13 @@ function indexManifestsBySessionFile(manifests: RecordManifest[]): Map<string, R
|
|
|
114
117
|
|
|
115
118
|
/** 步骤 1:main sessions —— 首行 header → headers + fileStats + 路径反查 */
|
|
116
119
|
async function collectMainSessions(agentDir: string, scan: FamilyFsScan): Promise<void> {
|
|
117
|
-
|
|
120
|
+
// A3(ext-simplify-04):直调 resolveSessionRoots + filter(原 listMainSessions 薄包装
|
|
121
|
+
// 已删除)——agentDir 信号包下 main 源未去重根([default]+[legacy],被 [live] 去重的除外)的文件并集
|
|
122
|
+
const mainMetas = (await resolveSessionRoots({ agentDir }))
|
|
123
|
+
.filter((r) => r.source === 'main' && r.dedupedInto === undefined)
|
|
124
|
+
.flatMap((r) => r.files)
|
|
118
125
|
for (const meta of mainMetas) {
|
|
119
|
-
const h =
|
|
126
|
+
const h = parseSessionHeader(await readSessionHeaderFirstLine(meta.path))
|
|
120
127
|
if (!h) continue // 非 session/坏 header → 跳过(不入 byId)
|
|
121
128
|
const entry: Entry = { type: 'session', id: h.id, parentId: null, cwd: h.cwd ?? '' }
|
|
122
129
|
if (h.parentSession) entry.parentSession = h.parentSession
|
|
@@ -171,9 +178,12 @@ async function collectSubagentIdentities(
|
|
|
171
178
|
): Promise<void> {
|
|
172
179
|
// U4 数据流:manifest 命中透全字段;未命中 P-fallback 读尾行 identity 取 task/slug/agent
|
|
173
180
|
//(model/status 不可回退,留 undefined);无 manifest 无 identity(运行中/异常)跳过。
|
|
174
|
-
|
|
181
|
+
// A3(ext-simplify-04):直调 resolveSessionRoots(原 listSubagentSessions 薄包装已删除)
|
|
182
|
+
//——agentDir 信号包下 [subagent] 根 = <agentDir>/subagents(常量推导,恒唯一)
|
|
183
|
+
const subMetas =
|
|
184
|
+
(await resolveSessionRoots({ agentDir })).find((r) => r.kind === 'subagent')?.files ?? []
|
|
175
185
|
for (const meta of subMetas) {
|
|
176
|
-
const h =
|
|
186
|
+
const h = parseSessionHeader(await readSessionHeaderFirstLine(meta.path))
|
|
177
187
|
if (!h) continue // 非 session/坏 header → 无真实 session id,无法 id 修正,跳过
|
|
178
188
|
const realId = h.id
|
|
179
189
|
// header 可解析即视为 alive(MF-3):identity 在文件尾行、完成时才写入,运行中的 subagent
|
|
@@ -244,55 +254,12 @@ function appendOrphanIdentities(manifests: RecordManifest[], scan: FamilyFsScan)
|
|
|
244
254
|
|
|
245
255
|
// ============================================================
|
|
246
256
|
// 文件读取 helpers(定长 buffer,避免全文读:subagent 文件均 269KB、总量 ~923MB)
|
|
257
|
+
// 首行 header 读取/解析在 session-header.ts(D5 单源);本文件仅保留尾行 identity 读取。
|
|
247
258
|
// ============================================================
|
|
248
259
|
|
|
249
|
-
/** 首行读取 buffer 上限。session header(id/cwd/parentSession)实测 < 300 字节,8KB 足够。 */
|
|
250
|
-
const HEADER_READ_BYTES = 8192
|
|
251
260
|
/** 尾行 identity 读取 buffer 上限。identity 含完整 task 文本可达数 KB;实测 64KB 覆盖 3203/3430。 */
|
|
252
261
|
const TAIL_READ_BYTES = 65536
|
|
253
262
|
|
|
254
|
-
/** 读文件首行(header)。定长 8KB 一次 read;空文件/读失败返回 undefined。 */
|
|
255
|
-
async function readFirstLine(path: string): Promise<string | undefined> {
|
|
256
|
-
let fh: FileHandle | undefined
|
|
257
|
-
try {
|
|
258
|
-
fh = await open(path, 'r')
|
|
259
|
-
const buf = Buffer.alloc(HEADER_READ_BYTES)
|
|
260
|
-
const { bytesRead } = await fh.read(buf, 0, HEADER_READ_BYTES, 0)
|
|
261
|
-
if (bytesRead === 0) return undefined
|
|
262
|
-
const text = buf.subarray(0, bytesRead).toString('utf8')
|
|
263
|
-
const nl = text.indexOf('\n')
|
|
264
|
-
return nl === -1 ? text : text.slice(0, nl)
|
|
265
|
-
} catch {
|
|
266
|
-
return undefined
|
|
267
|
-
} finally {
|
|
268
|
-
await fh?.close().catch(() => {})
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
interface SessionHeader {
|
|
273
|
-
id: string
|
|
274
|
-
cwd?: string
|
|
275
|
-
parentSession?: string
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/** 解析 header 首行为 SessionHeader。非 session 行/缺 id → null。 */
|
|
279
|
-
function parseHeaderLine(line: string | undefined): SessionHeader | null {
|
|
280
|
-
if (!line) return null
|
|
281
|
-
let raw: unknown
|
|
282
|
-
try {
|
|
283
|
-
raw = JSON.parse(line)
|
|
284
|
-
} catch {
|
|
285
|
-
return null
|
|
286
|
-
}
|
|
287
|
-
if (typeof raw !== 'object' || raw === null) return null
|
|
288
|
-
const o = raw as Record<string, unknown>
|
|
289
|
-
if (o.type !== 'session' || typeof o.id !== 'string') return null
|
|
290
|
-
const h: SessionHeader = { id: o.id }
|
|
291
|
-
if (typeof o.cwd === 'string') h.cwd = o.cwd
|
|
292
|
-
if (typeof o.parentSession === 'string') h.parentSession = o.parentSession
|
|
293
|
-
return h
|
|
294
|
-
}
|
|
295
|
-
|
|
296
263
|
/** readTailIdentity 的成功返回形状(rootSessionId 必有,其余存在才带) */
|
|
297
264
|
type TailIdentity = {
|
|
298
265
|
rootSessionId: string
|
|
@@ -488,41 +455,3 @@ export function extractSessionIdFromFilename(name: string): string {
|
|
|
488
455
|
const candidate = idx >= 0 ? noExt.slice(idx + 1) : noExt
|
|
489
456
|
return /^[0-9a-f-]{8,}$/i.test(candidate) ? candidate : ''
|
|
490
457
|
}
|
|
491
|
-
|
|
492
|
-
// ============================================================
|
|
493
|
-
// enrich:补 M1 占位字段(fileName / subagent cwd)
|
|
494
|
-
// ============================================================
|
|
495
|
-
|
|
496
|
-
/**
|
|
497
|
-
* M1 buildFamilyIndex 设 SessionRef.fileName=''(header 推不出路径)、subagent cwd=''
|
|
498
|
-
*(identity 无 cwd)。此处用已扫描的真实文件信息补全:alive 的 ref 补 fileName + cwd;
|
|
499
|
-
* cleanedUp 孤儿(无文件)保持占位。
|
|
500
|
-
*/
|
|
501
|
-
function enrichRefs(family: Family, pathToRef: Map<string, SessionRef>): void {
|
|
502
|
-
// sessionId → 完整 ref(含真实 fileName/cwd),由 pathToRef 反建
|
|
503
|
-
const bySid = new Map<string, SessionRef>()
|
|
504
|
-
for (const ref of pathToRef.values()) bySid.set(ref.sessionId, ref)
|
|
505
|
-
|
|
506
|
-
const enrichSessionRef = (ref: SessionRef): SessionRef => {
|
|
507
|
-
const full = bySid.get(ref.sessionId)
|
|
508
|
-
if (!full) return ref
|
|
509
|
-
return {
|
|
510
|
-
...ref,
|
|
511
|
-
fileName: full.fileName || ref.fileName,
|
|
512
|
-
cwd: full.cwd || ref.cwd,
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
family.root = enrichSessionRef(family.root)
|
|
517
|
-
family.parents = family.parents.map(enrichSessionRef)
|
|
518
|
-
family.forks = family.forks.map(enrichSessionRef)
|
|
519
|
-
family.subagents = family.subagents.map((s) => {
|
|
520
|
-
const full = bySid.get(s.sessionId)
|
|
521
|
-
if (!full) return s
|
|
522
|
-
return {
|
|
523
|
-
...s,
|
|
524
|
-
fileName: full.fileName || s.fileName,
|
|
525
|
-
cwd: full.cwd || s.cwd,
|
|
526
|
-
} as SubagentRef
|
|
527
|
-
})
|
|
528
|
-
}
|
package/src/doctor.ts
CHANGED
|
@@ -2,21 +2,15 @@
|
|
|
2
2
|
* doctor action(u8:design 2026-09-10 §6.2/§6.3/§6.4/§7B 要点 2/4/5/8 + §6.11 U14b 段)。
|
|
3
3
|
*
|
|
4
4
|
* 从 tool-handler.ts 机械提取(max-lines 拆分轮,零行为变更):环境判定 + 根表渲染 +
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* 旧布局残留 glob 探测。SessionReadSignals(u3 信号包超集)在域内定义,tool-handler
|
|
6
|
+
* re-export 供 index.ts 生产消费。原 u8 的扫描缓存机已删除(ext-simplify-04 U3):
|
|
7
|
+
* 每次调用实扫,无缓存命中语义;TTL/mtime 判定随 metadata 缓存留在 tool-handler 侧。
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync } from 'node:fs'
|
|
10
|
-
import { readdir
|
|
10
|
+
import { readdir } from 'node:fs/promises'
|
|
11
11
|
import { join, dirname } from 'node:path'
|
|
12
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'
|
|
13
|
+
import { resolveSessionRoots, type SessionRoot, type SessionRootSignals } from './discovery/roots.js'
|
|
20
14
|
import type { SessionReadParams, ToolResult } from './tool-handler.js'
|
|
21
15
|
|
|
22
16
|
/**
|
|
@@ -32,69 +26,9 @@ export interface SessionReadSignals extends SessionRootSignals {
|
|
|
32
26
|
bundleUrl?: string
|
|
33
27
|
}
|
|
34
28
|
|
|
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
29
|
/** doctor 根表来源标签列宽(最长 `[subagent]` 10 字符 + 1 对齐间距,§5.1 表格形态)。 */
|
|
58
30
|
const DOCTOR_ROOT_LABEL_PAD = 11
|
|
59
31
|
|
|
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
32
|
/** 旧布局残留条目(§6.11 U14b doctor 侧独立 glob 探测)。 */
|
|
99
33
|
/** 旧布局残留探测单条命中(doctor 与 u14b 启动探测同源判据,§6.11)。 */
|
|
100
34
|
export interface PiLayoutLeftover {
|
|
@@ -110,6 +44,8 @@ export interface PiLayoutLeftover {
|
|
|
110
44
|
* 形态判据(与 u14b 启动探测 / u14a 脚本 0a 同源):目录下含 `agent/` 或 `sessions/`
|
|
111
45
|
* 子目录才算残留——防纯 pi 宿主下任意来源的 `~/.pi/pi/` 目录误报。读探测,每次现查
|
|
112
46
|
* 不入缓存(两次 readdir 成本可忽略,缓存只服务扫盘贵的根)。
|
|
47
|
+
*
|
|
48
|
+
* 迁移期支持(A4 登记):本探测服务于 v2 布局迁移窗口,全量迁移确认后可整段删除。
|
|
113
49
|
*/
|
|
114
50
|
async function detectPiLayoutLeftovers(
|
|
115
51
|
agentDir: string,
|
|
@@ -155,9 +91,8 @@ export async function doDoctor(
|
|
|
155
91
|
})
|
|
156
92
|
const roots = await resolveSessionRoots(signals, {
|
|
157
93
|
// subagent 根默认只 stat(§6.3 成本控制:只列路径与可扫性);includeSubagents:true
|
|
158
|
-
//
|
|
94
|
+
// 才扫。无缓存——每次实扫(原缓存机已删,ext-simplify-04 U3,§7B 要点 8 新态)。
|
|
159
95
|
subagents: params.includeSubagents === true ? 'scan' : 'stat',
|
|
160
|
-
cache: doctorRootCache,
|
|
161
96
|
})
|
|
162
97
|
const leftovers = await detectPiLayoutLeftovers(signals.agentDir)
|
|
163
98
|
return renderDoctor(roots, environment, leftovers)
|
|
@@ -206,19 +141,18 @@ function renderDoctor(
|
|
|
206
141
|
}
|
|
207
142
|
const facts = [r.exists ? '存在' : '不存在']
|
|
208
143
|
if (r.fileCount === undefined) {
|
|
209
|
-
// 仅 subagents:'stat' 的 subagent 根(doctor
|
|
210
|
-
|
|
144
|
+
// 仅 subagents:'stat' 的 subagent 根(doctor 默认形态)——其余根恒有计数(实扫);
|
|
145
|
+
// 被去重根已在上方提前返回
|
|
211
146
|
facts.push('未扫描(subagent 根默认不扫,includeSubagents:true 开启)')
|
|
212
147
|
} else {
|
|
213
148
|
facts.push(`${r.fileCount} 文件`, `扫 ${Math.round(r.scanMs ?? 0)}ms`)
|
|
214
149
|
}
|
|
215
|
-
if (r.cached === true) facts.push('缓存命中')
|
|
216
150
|
facts.push(r.source === 'main' ? 'main' : 'subagent')
|
|
217
151
|
lines.push(` ${facts.join(' · ')}`)
|
|
218
152
|
})
|
|
219
153
|
|
|
220
154
|
// 事实型诊断(§7B 要点 5):列表内首个 main 根即最高优先级 main 根(去重只移除后位根,
|
|
221
|
-
//
|
|
155
|
+
// 首位根恒有计数——实扫)。0 文件也是事实——首条 assistant 前 jsonl 不落盘
|
|
222
156
|
//(PS-14),不做归因。
|
|
223
157
|
lines.push('')
|
|
224
158
|
const firstMain = roots.find((r) => r.source === 'main')
|
package/src/extract.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* 从 tool-handler.ts 机械提取(max-lines 拆分轮,零行为变更)。
|
|
5
5
|
* design §3.3 D3 的 5 个预设 + F8/F9 规格;F7(what 校验)与 doExtract 编排
|
|
6
6
|
*(resolveSessionId/safeParse/turns 范围限定依赖 tool-handler 私有定位层)留守
|
|
7
|
-
* tool-handler,经本模块导出的 5
|
|
8
|
-
* tool-handler re-export(单测 F9 白盒 import 路径不变)。纯提取,不调 LLM。
|
|
7
|
+
* tool-handler,经本模块导出的 5 预设函数分发。纯提取,不调 LLM。
|
|
9
8
|
*/
|
|
9
|
+
import { textBlockParts } from './core/render.js'
|
|
10
10
|
import { extractToolCalls, formatToolCallSummary, basename } from './core/toolcall.js'
|
|
11
11
|
import type { Turn } from './core/turns.js'
|
|
12
12
|
import { pad } from './handler-utils.js'
|
|
@@ -29,25 +29,12 @@ const SHORT_HASH_RE = /\b[0-9a-f]{7,8}\b/g
|
|
|
29
29
|
const COMMIT_CTX_RE = /feat:|fix:|refactor:|chore:|docs:|\b(commit|commits|merged|pushed|merge)\b/i
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* 从 message.content 提取纯 text(
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*(user-messages / tool-results 的正文),不要 thinking/toolCall 占位,本地实现。
|
|
36
|
-
* content 是 unknown 做类型守卫。
|
|
32
|
+
* 从 message.content 提取纯 text(E11 归一:共享核 textBlockParts 组合调用,'\n' join
|
|
33
|
+
* 语义留在本调用点)。extract 仅需纯 text(user-messages / tool-results 的正文),
|
|
34
|
+
* 不要 thinking/toolCall 占位。
|
|
37
35
|
*/
|
|
38
36
|
function extractContentText(content: unknown): string {
|
|
39
|
-
|
|
40
|
-
if (Array.isArray(content)) {
|
|
41
|
-
const parts: string[] = []
|
|
42
|
-
for (const b of content) {
|
|
43
|
-
if (b !== null && typeof b === 'object') {
|
|
44
|
-
const o = b as Record<string, unknown>
|
|
45
|
-
if (o.type === 'text' && typeof o.text === 'string') parts.push(o.text)
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return parts.join('\n')
|
|
49
|
-
}
|
|
50
|
-
return ''
|
|
37
|
+
return textBlockParts(content).join('\n')
|
|
51
38
|
}
|
|
52
39
|
|
|
53
40
|
/** 截断到 max 字符,超出加省略号(防爆;tool-results 正文用)。 */
|