@zhushanwen/pi-session-reader 0.3.1 → 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.
package/src/extract.ts ADDED
@@ -0,0 +1,422 @@
1
+ /**
2
+ * extract 预设管线(v2 O4:跨 turn 按类型提取素材)。
3
+ *
4
+ * 从 tool-handler.ts 机械提取(max-lines 拆分轮,零行为变更)。
5
+ * design §3.3 D3 的 5 个预设 + F8/F9 规格;F7(what 校验)与 doExtract 编排
6
+ *(resolveSessionId/safeParse/turns 范围限定依赖 tool-handler 私有定位层)留守
7
+ * tool-handler,经本模块导出的 5 预设函数分发。renderExtractItems 导出面不变:
8
+ * tool-handler re-export(单测 F9 白盒 import 路径不变)。纯提取,不调 LLM。
9
+ */
10
+ import { extractToolCalls, formatToolCallSummary, basename } from './core/toolcall.js'
11
+ import type { Turn } from './core/turns.js'
12
+ import { pad } from './handler-utils.js'
13
+ import type { ToolResult } from './tool-handler.js'
14
+
15
+ /** extract 的 5 个合法 what(design §3.3 D3)。 */
16
+ export type ExtractWhat = 'user-messages' | 'commands' | 'files' | 'commits' | 'tool-results'
17
+
18
+ /** extract 结果预算(design §3.3 F9):8000 字节 ≈ 2000 token。 */
19
+ const EXTRACT_BUDGET_BYTES = 8000
20
+
21
+ /** 含 path 参数的文件类工具(design §3.3 D3 files scope)。 */
22
+ const FILE_TOOLS = new Set(['read', 'edit', 'write', 'head'])
23
+
24
+ /** git 命令关键词(commits 预设判定 bash 结果是否来自 git 命令)。 */
25
+ const GIT_CMD_RE = /\bgit\s+(log|show|commit|push|merge|cherry-pick|revert|reset|rebase|diff)\b/
26
+ /** git short hash(commits 预设,保守限定 7-8 位避免 uuid 全量误报)。 */
27
+ const SHORT_HASH_RE = /\b[0-9a-f]{7,8}\b/g
28
+ /** commit 上下文消歧关键词(commits 次路径:hash 附近出现才纳入)。 */
29
+ const COMMIT_CTX_RE = /feat:|fix:|refactor:|chore:|docs:|\b(commit|commits|merged|pushed|merge)\b/i
30
+
31
+ /**
32
+ * 从 message.content 提取纯 text(string 直取;数组拼接 text 块)。
33
+ *
34
+ * 与 render.ts 内部 extractText 同语义,但那未导出;extract 仅需纯 text
35
+ *(user-messages / tool-results 的正文),不要 thinking/toolCall 占位,本地实现。
36
+ * content 是 unknown 做类型守卫。
37
+ */
38
+ function extractContentText(content: unknown): string {
39
+ if (typeof content === 'string') return content
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 ''
51
+ }
52
+
53
+ /** 截断到 max 字符,超出加省略号(防爆;tool-results 正文用)。 */
54
+ function truncateText(s: string, max: number): string {
55
+ return s.length <= max ? s : s.slice(0, max) + '…'
56
+ }
57
+
58
+ /** turnsLabel 展示的 turn 数上限(超出折叠为 +N)。 */
59
+ const TURNS_LABEL_HEAD_COUNT = 5
60
+
61
+ /** turns 数组紧凑标签(前 5 个 + +N,避免一行过长撑爆预算)。 */
62
+ function turnsLabel(turns: number[]): string {
63
+ const head = turns.slice(0, TURNS_LABEL_HEAD_COUNT).map((n) => `T${pad(n)}`)
64
+ const suffix = turns.length > TURNS_LABEL_HEAD_COUNT ? `+${turns.length - TURNS_LABEL_HEAD_COUNT}` : ''
65
+ return head.join(',') + suffix
66
+ }
67
+
68
+ /**
69
+ * 计算工具分布(按出现次数降序),用于 F8 提示 + details.toolDistribution。
70
+ * 遍历 assistant entry 的 toolCall,复用 extractToolCalls。
71
+ */
72
+ function computeToolDistribution(
73
+ turns: Turn[],
74
+ ): Array<{ name: string; count: number }> {
75
+ const counts = new Map<string, number>()
76
+ for (const t of turns) {
77
+ for (const e of t.entries) {
78
+ if (e.message?.role !== 'assistant') continue
79
+ for (const tc of extractToolCalls(e)) {
80
+ counts.set(tc.name, (counts.get(tc.name) ?? 0) + 1)
81
+ }
82
+ }
83
+ }
84
+ return Array.from(counts.entries())
85
+ .map(([name, count]) => ({ name, count }))
86
+ .sort((a, b) => b.count - a.count)
87
+ }
88
+
89
+ /** F8 提示展示的工具分布条数上限。 */
90
+ const TOOL_DISTRIBUTION_LIMIT = 10
91
+
92
+ /** F8:commands/tool-results 的 tool 过滤零匹配 → 返回工具分布 + 👉(不抛错,design §3.3 F8)。 */
93
+ function f8ToolNoMatch(what: ExtractWhat, tool: string, turns: Turn[]): ToolResult {
94
+ const dist = computeToolDistribution(turns).slice(0, TOOL_DISTRIBUTION_LIMIT)
95
+ const distStr = dist.map((d) => `${d.name}×${d.count}`).join(', ')
96
+ const text = `what=${what} tool="${tool}" 无匹配。该 session 工具:${distStr}。👉 用存在的工具名重试。`
97
+ return { content: [{ type: 'text', text }], details: { what, tool, toolDistribution: dist } }
98
+ }
99
+
100
+ /**
101
+ * 通用预算渲染:逐项累加字节,超 EXTRACT_BUDGET_BYTES 截断(design §3.3 F9)。
102
+ *
103
+ * details.items 放实际展示的子集(截断后),count 放全集长度,测试可断言 shown/count/truncated。
104
+ * emptyHint 仅 items 为空时用(files/commits 无匹配不报错,返空 + 提示)。
105
+ *
106
+ * 预算控制:按 item 累计字节达预算即截断。**首项超大也内部截断**(对单行 slice 到剩余字节预算,
107
+ * 字节→字符 ×3 近似防 UTF8 多字节被切半),保证 body 不超预算——而非放行首项致 body 远超预算。
108
+ * 导出供 tool-handler.test 单测 F9 截断逻辑(首项截断 + 文案含 turn 范围 + 实际 token)。
109
+ *
110
+ * getTurns:从 item 提取 turn 列表(files 是 turns 数组,其余单值包数组),供 F9 文案报 turn 范围。
111
+ */
112
+
113
+ /** UTF8 单字符最大字节数(预算字节→字符的保守换算基数,防多字节字符被切半)。 */
114
+ const UTF8_MAX_BYTES_PER_CHAR = 3
115
+ /** token 估算换算基数(bytes/4 口径,与 render 层 chars/4 同近似)。 */
116
+ const CHARS_PER_TOKEN = 4
117
+
118
+ export function renderExtractItems<I>(
119
+ what: ExtractWhat,
120
+ items: I[],
121
+ renderLine: (item: I) => string,
122
+ getTurns: (item: I) => number[],
123
+ emptyHint?: string,
124
+ ): ToolResult {
125
+ if (items.length === 0) {
126
+ const text = emptyHint ?? `what=${what} 无匹配。`
127
+ return {
128
+ content: [{ type: 'text', text }],
129
+ details: { what, count: 0, shown: 0, truncated: false, items: [] },
130
+ }
131
+ }
132
+ const shown: I[] = []
133
+ const shownLines: string[] = []
134
+ let bytes = 0
135
+ let cut = false
136
+ for (const item of items) {
137
+ const line = renderLine(item)
138
+ const lineBytes = Buffer.byteLength(line, 'utf8') + 1 // +\n
139
+ if (bytes + lineBytes > EXTRACT_BUDGET_BYTES) {
140
+ // 超预算:对当前 line 内部截断到剩余预算(首项超大也截断,但保留截断后的内容)
141
+ const remainingBytes = EXTRACT_BUDGET_BYTES - bytes
142
+ const charBudget = Math.floor(remainingBytes / UTF8_MAX_BYTES_PER_CHAR) // 字节→字符 ×3 近似防 UTF8 切半
143
+ if (charBudget > 0) {
144
+ const sliced = line.slice(0, charBudget) + '…'
145
+ shown.push(item)
146
+ shownLines.push(sliced)
147
+ }
148
+ cut = true
149
+ break
150
+ }
151
+ shown.push(item)
152
+ shownLines.push(line)
153
+ bytes += lineBytes
154
+ }
155
+ const body = shownLines.join('\n')
156
+ if (!cut) {
157
+ return {
158
+ content: [{ type: 'text', text: body }],
159
+ details: {
160
+ what,
161
+ count: items.length,
162
+ shown: shown.length,
163
+ truncated: false,
164
+ items: shown,
165
+ },
166
+ }
167
+ }
168
+ // F9:超预算截断。tokens 反映 body 实际体积(非固定 2000);文案报 shown/count + turn 范围 + 实际 token
169
+ const shownTurns = shown.flatMap(getTurns)
170
+ const turnRange =
171
+ shownTurns.length > 0
172
+ ? `(T${pad(Math.min(...shownTurns))}-T${pad(Math.max(...shownTurns))})`
173
+ : ''
174
+ const actualTokens = Math.round(Buffer.byteLength(body, 'utf8') / CHARS_PER_TOKEN)
175
+ const text =
176
+ body +
177
+ `\n[what=${what} 已显示 ${shown.length}/${items.length} 项${turnRange},约 ${actualTokens} token 达预算上限。👉 用较小 turns 范围(如 T000-T005)缩小,或换 what 重试。]`
178
+ return {
179
+ content: [{ type: 'text', text }],
180
+ details: {
181
+ what,
182
+ count: items.length,
183
+ shown: shown.length,
184
+ truncated: true,
185
+ items: shown,
186
+ },
187
+ }
188
+ }
189
+
190
+ /** 预设 1:user-messages——收集 role==='user' 的全文(按 turn 排列,design §3.3 D3)。 */
191
+ export function extractUserMessages(turns: Turn[]): ToolResult {
192
+ const items: Array<{ turn: number; text: string }> = []
193
+ for (const t of turns) {
194
+ for (const e of t.entries) {
195
+ if (e.message?.role !== 'user') continue
196
+ items.push({ turn: t.index, text: extractContentText(e.message.content) })
197
+ }
198
+ }
199
+ return renderExtractItems(
200
+ 'user-messages',
201
+ items,
202
+ (it) => `T${pad(it.turn)}: ${it.text}`,
203
+ (it) => [it.turn],
204
+ )
205
+ }
206
+
207
+ /**
208
+ * 预设 2:commands——assistant 的 toolCall,带 name + D1 摘要(design §3.3 D3)。
209
+ * 可选 tool 过滤;过滤后零匹配 → F8(工具分布 + 👉,不抛错)。
210
+ * index = entry 在 turn.entries 内的位置,与 expand 的 [N] 对齐便于定位。
211
+ */
212
+ export function extractCommands(turns: Turn[], tool: string | undefined): ToolResult {
213
+ const items: Array<{ turn: number; index: number; name: string; summary: string }> = []
214
+ for (const t of turns) {
215
+ for (let ei = 0; ei < t.entries.length; ei++) {
216
+ const e = t.entries[ei]
217
+ if (e.message?.role !== 'assistant') continue
218
+ for (const tc of extractToolCalls(e)) {
219
+ if (tool !== undefined && tc.name !== tool) continue
220
+ items.push({
221
+ turn: t.index,
222
+ index: ei,
223
+ name: tc.name,
224
+ summary: formatToolCallSummary(tc),
225
+ })
226
+ }
227
+ }
228
+ }
229
+ if (tool !== undefined && items.length === 0) return f8ToolNoMatch('commands', tool, turns)
230
+ return renderExtractItems(
231
+ 'commands',
232
+ items,
233
+ (it) => `T${pad(it.turn)} #${it.index} ${it.summary}`,
234
+ (it) => [it.turn],
235
+ )
236
+ }
237
+
238
+ /**
239
+ * 预设 3:files——read/edit/write/head 的 path 去重(design §3.3 D3)。
240
+ * 同 path 多次操作合并,op 聚合成 `read+edit` 形式,turns 记录出现过的轮次。
241
+ * todo/subagent/cw 无 path 不纳入。无匹配不报错(返空 + 提示)。
242
+ */
243
+ export function extractFiles(turns: Turn[]): ToolResult {
244
+ const map = new Map<string, { ops: Set<string>; turns: Set<number> }>()
245
+ for (const t of turns) {
246
+ for (const e of t.entries) {
247
+ if (e.message?.role !== 'assistant') continue
248
+ for (const tc of extractToolCalls(e)) {
249
+ if (!FILE_TOOLS.has(tc.name)) continue
250
+ const p = tc.arguments.path
251
+ if (typeof p !== 'string') continue
252
+ let rec = map.get(p)
253
+ if (rec === undefined) {
254
+ rec = { ops: new Set(), turns: new Set() }
255
+ map.set(p, rec)
256
+ }
257
+ rec.ops.add(tc.name)
258
+ rec.turns.add(t.index)
259
+ }
260
+ }
261
+ }
262
+ const items = Array.from(map.entries()).map(([path, rec]) => ({
263
+ path,
264
+ basename: basename(path),
265
+ op: Array.from(rec.ops).sort().join('+'),
266
+ turns: Array.from(rec.turns).sort((a, b) => a - b),
267
+ }))
268
+ return renderExtractItems(
269
+ 'files',
270
+ items,
271
+ (it) => `${it.op}: ${it.path} (${turnsLabel(it.turns)})`,
272
+ (it) => it.turns,
273
+ `what=files 无匹配(该 session 无 read/edit/write/head 文件操作)。`,
274
+ )
275
+ }
276
+
277
+ /**
278
+ * 预设 4:commits——git 命令 toolResult 的 hash(design §3.3 D3 + D6 误匹配处理)。
279
+ *
280
+ * 保守策略(宁可少召回不要乱报 uuid):
281
+ * ① 主路径(高置信):只从 bash 且关联 command 含 git (log|show|commit|push|merge|...) 的
282
+ * toolResult 文本提取 7-8 位 hex;
283
+ * ② 次路径(中置信):扫所有 toolResult 文本,hash 前后各 30 字符内含
284
+ * feat:/fix:/commit/merge 等关键词的才纳入;
285
+ * ③ 去重,git-cmd 置信度优先;不扫 user/assistant 自由文本(uuid/session-id 误报太多)。
286
+ *
287
+ * 已知局限:7-8 位 hex 与 uuid v7 片段形似,靠 git 命令上下文过滤;仍可能漏报
288
+ *(git 操作未被 toolResult 捕获)或误报(git log 输出里的其他 hex)。每条标注来源 turn
289
+ * + source + context,agent 可快速辨认。完全语义判断需 LLM,本工具零 LLM 依赖。
290
+ */
291
+ /** commits 提取的 hash 前后上下文字符数(次路径关键词判定窗口)。 */
292
+ const COMMIT_CONTEXT_CHARS = 30
293
+
294
+ /** commits 预设的单条提取结果(hash + 来源 turn + 置信来源 + 上下文片段)。 */
295
+ interface CommitItem {
296
+ hash: string
297
+ turn: number
298
+ source: 'git-cmd' | 'commit-context'
299
+ context: string
300
+ }
301
+
302
+ /** 建 toolCallId → bash command 映射(用于判定 toolResult 是否来自 git 命令)。 */
303
+ function collectBashCommands(turns: Turn[]): Map<string, string> {
304
+ const bashCmds = new Map<string, string>()
305
+ for (const t of turns) {
306
+ for (const e of t.entries) {
307
+ if (e.message?.role !== 'assistant') continue
308
+ for (const tc of extractToolCalls(e)) {
309
+ if (tc.name === 'bash') {
310
+ const cmd = tc.arguments.command
311
+ if (typeof cmd === 'string') bashCmds.set(tc.id, cmd)
312
+ }
313
+ }
314
+ }
315
+ }
316
+ return bashCmds
317
+ }
318
+
319
+ /** 单条 toolResult 文本的 hash 提取:git-cmd 全收(高置信),否则按上下文关键词过滤(次路径)。 */
320
+ function matchCommitsInResult(
321
+ text: string,
322
+ turnIndex: number,
323
+ isGitBash: boolean,
324
+ high: CommitItem[],
325
+ low: CommitItem[],
326
+ ): void {
327
+ for (const m of text.matchAll(SHORT_HASH_RE)) {
328
+ const hash = m[0]
329
+ const idx = m.index ?? 0
330
+ const ctx = text
331
+ .slice(Math.max(0, idx - COMMIT_CONTEXT_CHARS), idx + hash.length + COMMIT_CONTEXT_CHARS)
332
+ .replace(/\s+/g, ' ')
333
+ .trim()
334
+ if (isGitBash) {
335
+ high.push({ hash, turn: turnIndex, source: 'git-cmd', context: ctx })
336
+ } else if (COMMIT_CTX_RE.test(ctx)) {
337
+ low.push({ hash, turn: turnIndex, source: 'commit-context', context: ctx })
338
+ }
339
+ }
340
+ }
341
+
342
+ /** 主扫描阶段:遍历 toolResult entry,分类收集高/低置信 commit 候选。 */
343
+ function collectCommitCandidates(
344
+ turns: Turn[],
345
+ bashCmds: Map<string, string>,
346
+ ): { high: CommitItem[]; low: CommitItem[] } {
347
+ const high: CommitItem[] = []
348
+ const low: CommitItem[] = []
349
+ for (const t of turns) {
350
+ for (const e of t.entries) {
351
+ if (e.message?.role !== 'toolResult') continue
352
+ const msg = e.message
353
+ const text = extractContentText(msg.content)
354
+ if (text === '') continue
355
+ const cmd = msg.toolCallId !== undefined ? bashCmds.get(msg.toolCallId) : undefined
356
+ const isGitBash = msg.toolName === 'bash' && cmd !== undefined && GIT_CMD_RE.test(cmd)
357
+ matchCommitsInResult(text, t.index, isGitBash, high, low)
358
+ }
359
+ }
360
+ return { high, low }
361
+ }
362
+
363
+ /** 收口阶段:去重,高置信优先,同 hash 保留首次。 */
364
+ function dedupeCommits(high: CommitItem[], low: CommitItem[]): CommitItem[] {
365
+ const seen = new Set<string>()
366
+ const items: CommitItem[] = []
367
+ for (const c of high) {
368
+ if (seen.has(c.hash)) continue
369
+ seen.add(c.hash)
370
+ items.push(c)
371
+ }
372
+ for (const c of low) {
373
+ if (seen.has(c.hash)) continue
374
+ seen.add(c.hash)
375
+ items.push(c)
376
+ }
377
+ return items
378
+ }
379
+
380
+ export function extractCommits(turns: Turn[]): ToolResult {
381
+ // 建 toolCallId → bash command 映射(用于判定 toolResult 是否来自 git 命令)
382
+ const bashCmds = collectBashCommands(turns)
383
+ const { high, low } = collectCommitCandidates(turns, bashCmds)
384
+ const items = dedupeCommits(high, low)
385
+
386
+ return renderExtractItems(
387
+ 'commits',
388
+ items,
389
+ (it) => `T${pad(it.turn)} ${it.hash} [${it.source}] ${it.context}`,
390
+ (it) => [it.turn],
391
+ `what=commits 无匹配(该 session 无 git commit hash,或未在 toolResult 中出现)。`,
392
+ )
393
+ }
394
+
395
+ /** tool-results 正文截断上限(防爆)。 */
396
+ const TOOL_RESULT_TEXT_MAX_CHARS = 500
397
+
398
+ /**
399
+ * 预设 5:tool-results——role==='toolResult' 文本(design §3.3 D3)。
400
+ * text 截断到 500 字防爆;可选 tool 过滤(msg.toolName);过滤零匹配 → F8。
401
+ */
402
+ export function extractToolResults(turns: Turn[], tool: string | undefined): ToolResult {
403
+ const items: Array<{ turn: number; index: number; toolName: string; text: string }> = []
404
+ for (const t of turns) {
405
+ for (let ei = 0; ei < t.entries.length; ei++) {
406
+ const e = t.entries[ei]
407
+ if (e.message?.role !== 'toolResult') continue
408
+ const tn = e.message.toolName ?? '?'
409
+ if (tool !== undefined && tn !== tool) continue
410
+ const text = truncateText(extractContentText(e.message.content), TOOL_RESULT_TEXT_MAX_CHARS)
411
+ items.push({ turn: t.index, index: ei, toolName: tn, text })
412
+ }
413
+ }
414
+ if (tool !== undefined && items.length === 0)
415
+ return f8ToolNoMatch('tool-results', tool, turns)
416
+ return renderExtractItems(
417
+ 'tool-results',
418
+ items,
419
+ (it) => `T${pad(it.turn)} #${it.index} ${it.toolName}: ${it.text}`,
420
+ (it) => [it.turn],
421
+ )
422
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * tool-handler 共享小工具(max-lines 拆分轮机械提取,零行为变更)。
3
+ *
4
+ * 从 tool-handler.ts「小工具」与「turn/turns 索引解析」段搬出:pad/err/turn 索引解析
5
+ * 被留守的 tool-handler 与拆出的 search-across.ts / extract.ts 共同消费——放独立低层
6
+ * 模块保持单向依赖(handler-utils ← 各域模块 ← tool-handler),不引入循环 import
7
+ *(result-action.ts 的 Deps 注入先例解决不了纯函数跨模块复用,低层模块更直接)。
8
+ * stripHash/formatDate/shortCwd/requireStr 仅 tool-handler 留守部分使用,不在此列。
9
+ */
10
+
11
+ /** turn 索引显示宽度(T013 三位补零)。 */
12
+ const TURN_INDEX_WIDTH = 3
13
+
14
+ export const pad = (n: number): string => String(n).padStart(TURN_INDEX_WIDTH, '0')
15
+
16
+ /** 构造带 👉 恢复指引的 Error。契约:handler 直接 throw,execute 不 catch、原样传播给 pi;
17
+ * pi 外层(pi-agent-core agent-loop.js executePreparedToolCall catch,:466-471)统一转
18
+ * isError:true 的 error tool result——返回值上的 isError 字段会被丢弃(错误被标成功)。 */
19
+ export function err(message: string): Error {
20
+ return new Error(message)
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // turn / turns 索引解析
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const TURN_RE = /^T?(\d+)$/i
28
+
29
+ export function parseTurnIndex(raw: string): number {
30
+ const m = raw.trim().match(TURN_RE)
31
+ if (!m) {
32
+ throw err(
33
+ `turn "${raw}" 格式无效(应为 T013 或 013)。👉 用合法 turn 索引重试,或 outline 重看有效范围。`,
34
+ )
35
+ }
36
+ return parseInt(m[1], 10)
37
+ }
38
+
39
+ /** turns 范围 "T013-T015" 的段数。 */
40
+ const TURNS_RANGE_PARTS = 2
41
+
42
+ export function parseTurnsRange(raw: string): { start: number; end: number } {
43
+ const parts = raw.split('-').map((s) => s.trim())
44
+ if (parts.length === 1) {
45
+ const i = parseTurnIndex(parts[0])
46
+ return { start: i, end: i }
47
+ }
48
+ if (parts.length === TURNS_RANGE_PARTS) {
49
+ const start = parseTurnIndex(parts[0])
50
+ const end = parseTurnIndex(parts[1])
51
+ if (end < start) {
52
+ throw err(
53
+ `turns 范围 "${raw}" 起始大于结束。👉 检查范围格式(如 T013-T015)重试。`,
54
+ )
55
+ }
56
+ return { start, end }
57
+ }
58
+ throw err(`turns "${raw}" 格式无效(应为 T013 或 T013-T015)。👉 用合法范围重试。`)
59
+ }
60
+
61
+ export function rangeLabel(r: { start: number; end: number }): string {
62
+ return r.start === r.end ? `T${pad(r.start)}` : `T${pad(r.start)}-T${pad(r.end)}`
63
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
2
- import { getAgentDir } from '@earendil-works/pi-coding-agent'
2
+ import { getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent'
3
3
  import { StringEnum } from '@earendil-works/pi-ai'
4
4
  import { Type } from 'typebox'
5
- import { handleSessionRead, type SessionReadParams } from './tool-handler.js'
5
+ import type { SessionMetadataProvider } from './discovery/find.js'
6
+ import { handleSessionRead, type SessionReadParams, type SessionReadSignals } from './tool-handler.js'
6
7
  import { createHashAutocompleteProvider } from './tui/hash-provider.js'
7
8
  import { createSessionCommand } from './tui/session-command.js'
8
9
 
@@ -21,16 +22,28 @@ import { createSessionCommand } from './tui/session-command.js'
21
22
 
22
23
  const SessionReadSchema = Type.Object({
23
24
  action: StringEnum(
24
- ['find', 'family', 'outline', 'expand', 'detail', 'search', 'export', 'extract', 'workflow'],
25
+ [
26
+ 'find',
27
+ 'family',
28
+ 'outline',
29
+ 'expand',
30
+ 'detail',
31
+ 'search',
32
+ 'export',
33
+ 'extract',
34
+ 'workflow',
35
+ 'result',
36
+ 'doctor',
37
+ ],
25
38
  {
26
39
  description:
27
- 'Action to perform: find (locate session), family (fork/subagent/workflow relations; recursive=true returns nested execution tree), outline (turn-level overview), expand (single-turn entries), detail (full text of turns), search (full-text grep), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps; requires session, optional runId focuses one run; step call sessionId jumps to outline/detail).',
40
+ 'Action to perform: find (locate session), family (fork/subagent/workflow relations; recursive=true returns nested execution tree), outline (turn-level overview), expand (single-turn entries), detail (full text of turns), search (full-text grep, single session or cross-session over a comma-separated id list), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps; requires session, optional runId focuses one run; step call sessionId jumps to outline/detail), result (fetch a subagent session final result text — same content as its completion notice; session = single id or comma-separated batch of at most 10, optional limit caps chars per item), doctor (show detected host environment and session-root diagnostics).',
28
41
  },
29
42
  ),
30
43
  session: Type.Optional(
31
44
  Type.String({
32
45
  description:
33
- 'Session id, uuid fragment (e.g. e6c96), subagent record id (sa-xxx, precise lookup), or absolute .jsonl path (~ or ~/ allowed). Required for family/outline/expand/detail/search/export/extract/workflow. # prefix auto-stripped.',
46
+ 'Session id, uuid fragment (e.g. e6c96), subagent record id (sa-xxx, precise lookup), or absolute .jsonl path (~ or ~/ allowed). Required for family/outline/expand/detail/search/export/extract/workflow/result. result also accepts a comma-separated list of up to 10 ids. search also accepts a comma-separated list of up to 10 full ids (from find output) for cross-session search. # prefix auto-stripped.',
34
47
  }),
35
48
  ),
36
49
  query: Type.Optional(
@@ -91,7 +104,14 @@ const SessionReadSchema = Type.Object({
91
104
  }),
92
105
  ),
93
106
  limit: Type.Optional(
94
- Type.Number({ description: 'find/search: max results. Default 20.' }),
107
+ Type.Number({
108
+ // minimum:1 在 schema 校验层拒绝 limit:0 等退化输入(不落入 find F1 的
109
+ // 「无匹配 session」措辞面);result 消费侧的 resolveResultLimit 防御保留
110
+ //(可单测绕过 schema 的调用形态)。
111
+ minimum: 1,
112
+ description:
113
+ 'find/search: max results. Default 20. result: max chars per item, default 8000 (overlong text truncated with a pointer to the full file).',
114
+ }),
95
115
  ),
96
116
  what: Type.Optional(
97
117
  StringEnum(
@@ -118,6 +138,12 @@ const SessionReadSchema = Type.Object({
118
138
  'family action: return nested execution tree (arbitrary-depth subagent↔workflow-call nesting, precise parentRecordId chain with flat-fallback for legacy records). Default false (flat family).',
119
139
  }),
120
140
  ),
141
+ includeSubagents: Type.Optional(
142
+ Type.Boolean({
143
+ description:
144
+ 'doctor action: also scan the subagent session root (adds its file count). Default false (path and existence only).',
145
+ }),
146
+ ),
121
147
  })
122
148
 
123
149
  // ---- guidelines(注入 LLM,design §3.4)----
@@ -129,12 +155,14 @@ const guidelines = [
129
155
  'family traces fork parents/children, subagent sessions, and workflow runs.',
130
156
  'extract what=<type> to pull user messages / commands / files / commits / tool results across turns (optional tool= filter for commands/tool-results).',
131
157
  "workflow action to see workflow run overviews (status/budget/steps). Each step's call sessionId can jump to outline/detail for deep reading.",
158
+ "result action to fetch a subagent's final result text (same content as its completion notice): session takes a single sa-id/uuid/path or a comma-separated batch of at most 10; optional limit caps chars per item (default 8000, truncated items carry a pointer to the full file).",
132
159
  'Errors carry a 👉 recovery hint—follow it to retry in one step.',
160
+ 'Unsure about the host environment (pure pi vs xyz-agent) or where session roots live? Run action:"doctor" first—it prints the detected environment and every candidate session root.',
133
161
  ]
134
162
 
135
163
  // ---- 工具 description(design §3.4,照搬措辞)----
136
164
 
137
- const description = `Read pi session files (conversation history) by semantic structure instead of raw bytes. Use when you need to review another session, trace a fork/subagent/workflow family, or locate a past decision. Nine actions: find (locate by name/uuid fragment), family (fork/subagent/workflow relations), outline (turn-level overview, ~1500 token), expand (single-turn entry list), detail (full text of turns), search (full-text grep across a session), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps, step call sessionId jumps to outline/detail). Progressive reading: outline → expand → detail. Do NOT use for the current session (the host provides current-session access) or to edit sessions (pi has /resume /fork).`
165
+ const description = `Read pi session files (conversation history) by semantic structure instead of raw bytes. Use when you need to review another session, trace a fork/subagent/workflow family, or locate a past decision. Eleven actions: find (locate by name/uuid fragment), family (fork/subagent/workflow relations), outline (turn-level overview, ~1500 token), expand (single-turn entry list), detail (full text of turns), search (full-text grep across a session, or cross-session over a comma-separated id list ≤10 from find output), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps, step call sessionId jumps to outline/detail), result (fetch a subagent session final result text, same content as its completion notice; single id or comma-separated batch ≤10, optional limit chars per item default 8000), doctor (show detected host environment and session-root diagnostics). Progressive reading: outline → expand → detail. Do NOT use for the current session (the host provides current-session access) or to edit sessions (pi has /resume /fork).`
138
166
 
139
167
  /**
140
168
  * 已注册过 TUI provider/command 的 pi 实例集合。
@@ -153,6 +181,13 @@ const registeredPis = new WeakSet<ExtensionAPI>()
153
181
  let currentCwdSessionDir: string | null = null
154
182
 
155
183
  export default function sessionReaderExtension(pi: ExtensionAPI): void {
184
+ // u11(design 2026-09-10 §6.6):标题元数据走 pi 的 SessionManager.listAll(dir)——
185
+ // session_info name 提取、首消息采集与并发解析由 pi 维护。注入范式同 §6.2 信号包:
186
+ // pi 类型只在本层出现(SessionInfo 对发现层 SessionMetadataEntry 结构兼容,直接透传),
187
+ // 发现层只见注入函数,零 pi 依赖。调用成本由三条调用策略约束(惰性/窄化/缓存,
188
+ // 缓存在 tool-handler 注入边界包装),listAll 实装语义「扫一层平铺目录 + 每文件
189
+ // 全量解析」决定了不得对含子目录根调用(hash-provider.ts:158 空参灾难同源教训)。
190
+ const metadataProvider: SessionMetadataProvider = async (dir) => SessionManager.listAll(dir)
156
191
  pi.registerTool({
157
192
  name: 'session_read',
158
193
  label: 'Session Reader',
@@ -164,14 +199,33 @@ export default function sessionReaderExtension(pi: ExtensionAPI): void {
164
199
  params: SessionReadParams,
165
200
  signal: AbortSignal | undefined,
166
201
  _onUpdate: unknown,
167
- _ctx: ExtensionContext,
202
+ ctx: ExtensionContext | undefined,
168
203
  ) {
169
204
  // 错误路径直接 throw:pi 契约只有 throw 才置 isError:true(tool_execution_end /
170
205
  // ToolResultMessage),handler 抛的 Error 文案(含 👉 恢复提示)原样成为
171
206
  // toolResult content,模型仍可读到。曾用 return {isError:true}——被 agent-loop
172
207
  // 丢弃,错误轮被标成功(W4 修复)。
173
208
  // signal 仅 search 消费(MF-5:长扫描可中断,Esc 不再挂死);其余 action 有界不接
174
- return handleSessionRead(params, getAgentDir(), signal)
209
+ // 信号包采集(design §7B):可选链逐层降级——ctx===undefined(存量单测五参形态)、
210
+ // sessionManager 缺字段、getSessionDir 方法缺失,任一层不成立即 liveSessionDir=undefined
211
+ //(发现层走 [default]+[legacy]+[subagent] 三根降级);方法调用抛错可选链兜不住,try/catch
212
+ // 同样降级——宿主异常不得变成工具内部 TypeError。env/bundleUrl 由 u8 补采(doctor
213
+ // 环境判定/发行形态需要,design §6.2/§6.4)。
214
+ let liveSessionDir: string | undefined
215
+ try {
216
+ liveSessionDir = ctx?.sessionManager?.getSessionDir?.()
217
+ } catch {
218
+ liveSessionDir = undefined
219
+ }
220
+ const signals: SessionReadSignals = {
221
+ agentDir: getAgentDir(),
222
+ liveSessionDir,
223
+ env: process.env,
224
+ bundleUrl: import.meta.url,
225
+ }
226
+ // u11:标题元数据 provider 随信号包注入(provider 抛错由发现层单目录降级,
227
+ // 不会变成工具错误——宿主 listAll 异常不得打断 find 的「能找到 session」底线)。
228
+ return handleSessionRead(params, signals, signal, metadataProvider)
175
229
  },
176
230
  })
177
231