@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.
- package/package.json +2 -2
- package/src/__tests__/env.test.ts +258 -0
- package/src/__tests__/execution-tree.test.ts +8 -7
- package/src/__tests__/find.test.ts +211 -6
- package/src/__tests__/index.test.ts +157 -5
- package/src/__tests__/real-data.ts +10 -0
- package/src/__tests__/result.test.ts +2 -2
- package/src/__tests__/roots.test.ts +360 -7
- package/src/__tests__/subagents.test.ts +4 -3
- package/src/__tests__/tool-handler.test.ts +1216 -7
- package/src/discovery/env.ts +238 -0
- package/src/discovery/find.ts +363 -56
- package/src/discovery/roots.ts +236 -8
- package/src/discovery/subagents.ts +24 -6
- package/src/doctor.ts +262 -0
- package/src/extract.ts +422 -0
- package/src/handler-utils.ts +63 -0
- package/src/index.ts +46 -7
- package/src/no-match.ts +160 -0
- package/src/search-across.ts +539 -0
- package/src/tool-handler.ts +478 -681
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search 域基础设施 + u12 跨会话内容检索(design 2026-09-10 §2 目标 5 / §8.2 V8)。
|
|
3
|
+
*
|
|
4
|
+
* 从 tool-handler.ts 机械提取(max-lines 拆分轮,零行为变更)。search 单会话的
|
|
5
|
+
* pattern 编译/文本提取/命中收集/渲染与跨会话检索共享同一扫描管线(safeParse →
|
|
6
|
+
* buildTreeView → segmentTurns → collectSearchHits),拆为独立低层模块保持单向依赖
|
|
7
|
+
*(tool-handler 的 doSearch 消费本模块导出,本模块只反向 type import 公共类型)。
|
|
8
|
+
* searchAcrossSessions 导出面不变:tool-handler re-export(单测白盒 import 路径不变)。
|
|
9
|
+
*/
|
|
10
|
+
import { dirname } from 'node:path'
|
|
11
|
+
import {
|
|
12
|
+
buildSessionFileIndex,
|
|
13
|
+
type SessionFileRef,
|
|
14
|
+
type SessionMetadataEntry,
|
|
15
|
+
type SessionMetadataProvider,
|
|
16
|
+
} from './discovery/find.js'
|
|
17
|
+
import { parseSessionFile } from './core/parser.js'
|
|
18
|
+
import { segmentTurns, type Turn } from './core/turns.js'
|
|
19
|
+
import { buildTreeView } from './core/tree.js'
|
|
20
|
+
import { err, pad } from './handler-utils.js'
|
|
21
|
+
import type { SessionReadSignals } from './doctor.js'
|
|
22
|
+
import type { SessionReadParams, ToolResult } from './tool-handler.js'
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// search 辅助
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 灾难性正则形态探测(MF-5):组内含量词/`|` 且组本身又被量词修饰的 pattern
|
|
30
|
+
*(`(a+)+`、`(a*)*`、`(a|aa)+`、`(a{1,3})*` 等)对长文本指数级回溯,可挂死整个 turn(5.4MB session
|
|
31
|
+
* 全文逐 entry 匹配)。内层字符类含 `{` 以捕获 `{m,n}` 范围量词(MF-1);`(a{1,3})` 单独使用
|
|
32
|
+
*(组后无尾随量词)不命中,仍按正则执行。命中则降级为字面子串匹配(与非法正则同一兜底路径)。
|
|
33
|
+
* 保守拒绝(把合法但形似的 pattern 降级为子串)比挂死可接受。
|
|
34
|
+
*/
|
|
35
|
+
export function isCatastrophicPattern(pattern: string): boolean {
|
|
36
|
+
return (
|
|
37
|
+
/\((?:[^()\\]|\\.)*[+*?{](?:[^()\\]|\\.)*\)[+*?{]/.test(pattern) ||
|
|
38
|
+
/\((?:[^()\\]|\\.)*\|(?:[^()\\]|\\.)*\)[+*?{]/.test(pattern)
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 编译检索 pattern:先当正则,非法/灾难性则转义为字面子串(design §3.4 pattern 子串或正则)。 */
|
|
43
|
+
export function compilePattern(pattern: string): RegExp {
|
|
44
|
+
if (isCatastrophicPattern(pattern)) {
|
|
45
|
+
return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return new RegExp(pattern, 'i')
|
|
49
|
+
} catch {
|
|
50
|
+
return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 安全序列化:循环引用等异常时返回空串(catch 非空——记默认值)。 */
|
|
55
|
+
function safeStringify(v: unknown): string {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.stringify(v)
|
|
58
|
+
} catch {
|
|
59
|
+
return '' // 循环引用等致 stringify 失败,跳过该块
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** search 的可检索文本:含 text/thinking/toolResult 全量(按 scope 过滤由调用方做)。 */
|
|
64
|
+
function searchableText(content: unknown): string {
|
|
65
|
+
if (typeof content === 'string') return content
|
|
66
|
+
if (Array.isArray(content)) {
|
|
67
|
+
const parts: string[] = []
|
|
68
|
+
for (const b of content) {
|
|
69
|
+
if (b && typeof b === 'object') {
|
|
70
|
+
const o = b as Record<string, unknown>
|
|
71
|
+
if (typeof o.text === 'string') parts.push(o.text)
|
|
72
|
+
else if (typeof o.thinking === 'string') parts.push(o.thinking)
|
|
73
|
+
else {
|
|
74
|
+
const serialized = safeStringify(o)
|
|
75
|
+
if (serialized) parts.push(serialized)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return parts.join('\n')
|
|
80
|
+
}
|
|
81
|
+
return safeStringify(content)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** search 命中片段的前后上下文字符数。 */
|
|
85
|
+
const SNIPPET_CONTEXT_CHARS = 20
|
|
86
|
+
|
|
87
|
+
function snippet(text: string, idx: number, len: number): string {
|
|
88
|
+
const start = Math.max(0, idx - SNIPPET_CONTEXT_CHARS)
|
|
89
|
+
const end = Math.min(text.length, idx + len + SNIPPET_CONTEXT_CHARS)
|
|
90
|
+
return (
|
|
91
|
+
(start > 0 ? '…' : '') +
|
|
92
|
+
text.slice(start, end).replace(/\s+/g, ' ').trim() +
|
|
93
|
+
(end < text.length ? '…' : '')
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** search action 的默认命中数上限。 */
|
|
98
|
+
export const SEARCH_DEFAULT_LIMIT = 20
|
|
99
|
+
|
|
100
|
+
/** search 单条命中(turn 内 entry 定位 + 角色 + 摘要片段)。 */
|
|
101
|
+
export interface SearchHit {
|
|
102
|
+
turnIndex: number
|
|
103
|
+
entryIndex: number
|
|
104
|
+
role: string
|
|
105
|
+
matchSnippet: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** search 扫描阶段:遍历 turns 收集命中;每 turn 前检查 abort(MF-5 尽早退出)。 */
|
|
109
|
+
export function collectSearchHits(
|
|
110
|
+
turns: Turn[],
|
|
111
|
+
regex: RegExp,
|
|
112
|
+
scope: NonNullable<SessionReadParams['scope']>,
|
|
113
|
+
signal: AbortSignal | undefined,
|
|
114
|
+
): SearchHit[] {
|
|
115
|
+
const hits: SearchHit[] = []
|
|
116
|
+
for (const t of turns) {
|
|
117
|
+
// MF-5:Esc/abort 后 pi 已丢弃本 turn 结果,尽早退出避免继续扫描长 session
|
|
118
|
+
if (signal?.aborted) {
|
|
119
|
+
throw err('搜索已中断(信号 aborted)。👉 重试或换更精确的 pattern。')
|
|
120
|
+
}
|
|
121
|
+
for (let i = 0; i < t.entries.length; i++) {
|
|
122
|
+
const msg = t.entries[i].message
|
|
123
|
+
if (msg === undefined) continue
|
|
124
|
+
if (scope !== 'all' && msg.role !== scope) continue
|
|
125
|
+
const text = searchableText(msg.content)
|
|
126
|
+
const m = regex.exec(text)
|
|
127
|
+
if (m !== null) {
|
|
128
|
+
hits.push({
|
|
129
|
+
turnIndex: t.index,
|
|
130
|
+
entryIndex: i,
|
|
131
|
+
role: msg.role,
|
|
132
|
+
matchSnippet: snippet(text, m.index, m[0].length),
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return hits
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** search 收口阶段:结果文本组装(header 标注降级/scope/截断 + 逐行命中列表)。 */
|
|
141
|
+
export function formatSearchText(
|
|
142
|
+
pattern: string,
|
|
143
|
+
degraded: boolean,
|
|
144
|
+
scope: NonNullable<SessionReadParams['scope']>,
|
|
145
|
+
sliced: SearchHit[],
|
|
146
|
+
truncated: boolean,
|
|
147
|
+
): string {
|
|
148
|
+
const lines = sliced.map(
|
|
149
|
+
(h) => ` T${pad(h.turnIndex)} #${h.entryIndex} ${h.role}: ${h.matchSnippet}`,
|
|
150
|
+
)
|
|
151
|
+
return `${sliced.length} hit(s) for /${pattern}/${degraded ? '(已降级为字面子串匹配)' : ''}${
|
|
152
|
+
scope !== 'all' ? ' scope=' + scope : ''
|
|
153
|
+
}${truncated ? ` (truncated, showing first ${sliced.length})` : ''}\n${lines.join('\n')}`
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// u12 跨会话内容检索(design 2026-09-10 §2 目标 5 / §8.2 V8)
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 跨会话单次检索的候选数上限(窄化前置阈值,V8「先 find 窄化,再对结果检索」)。
|
|
162
|
+
* 与 result action 批量 ≤10 先例对齐;候选集全文检索成本 O(总字节),数量上限是
|
|
163
|
+
* 第一道闸(超限明确拒绝并指引先 find,不静默超时)。
|
|
164
|
+
*/
|
|
165
|
+
export const MULTI_SEARCH_MAX_SESSIONS = 10
|
|
166
|
+
|
|
167
|
+
/** 字节展示/预算换算基数(人话格式化与预算常量共用同一量纲)。 */
|
|
168
|
+
const BYTES_PER_KB = 1024
|
|
169
|
+
const BYTES_PER_MB = BYTES_PER_KB * BYTES_PER_KB
|
|
170
|
+
|
|
171
|
+
/** 跨会话单次检索的预算 MB 数(§8.2 V8 字节上限的量纲;换算见 SEARCH_SCAN_BYTE_BUDGET)。 */
|
|
172
|
+
const SEARCH_SCAN_BUDGET_MB = 64
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 跨会话单次检索的总扫描字节上限(第二道闸,design §2 Out-of-scope「阶段二先用
|
|
176
|
+
* 『窄化后线性扫 + 字节上限』」)。64MB ≈ 近 3 个大型主 session(P-9 实测单主 session
|
|
177
|
+
* 23MB、纯 pi 全库最坏 2.7GB)——窄化后候选 ≤10 正常远触不到;触顶即「候选集仍不够
|
|
178
|
+
* 窄」或「存在超大单文件」,按列表顺序停止并报告已扫范围(防大库拖死)。
|
|
179
|
+
* 测试经 searchAcrossSessions 的 byteBudget 注入小预算,不构造 64MB fixture。
|
|
180
|
+
*/
|
|
181
|
+
export const SEARCH_SCAN_BYTE_BUDGET = SEARCH_SCAN_BUDGET_MB * BYTES_PER_MB
|
|
182
|
+
|
|
183
|
+
/** 跨会话检索的单 session 扫描结果(零命中者也入 scanned——已扫范围对调用方可见)。 */
|
|
184
|
+
interface CrossSearchScan {
|
|
185
|
+
sessionId: string
|
|
186
|
+
source: 'main' | 'subagent'
|
|
187
|
+
path: string
|
|
188
|
+
/** 用户标题(u11 metadataProvider 尽力补全;provider 缺省/抛错留空) */
|
|
189
|
+
name?: string
|
|
190
|
+
hits: SearchHit[]
|
|
191
|
+
/** 截断前的命中总数(> hits.length 即被 limit 截断,渲染「>N hits, showing first M」) */
|
|
192
|
+
hitsTotal?: number
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 跨会话检索的未扫/跳过条目(有检测必有报告:每条未扫 id 都带原因)。 */
|
|
196
|
+
interface CrossSearchSkipped {
|
|
197
|
+
sessionId: string
|
|
198
|
+
/** not-found=库中无此 id;byte-budget=字节预算在此处耗尽(其后全部未扫);read-error=文件读取/解析失败 */
|
|
199
|
+
reason: 'not-found' | 'byte-budget' | 'read-error'
|
|
200
|
+
sizeBytes?: number
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 跨会话检索 details(程序化消费/测试断言面)。 */
|
|
204
|
+
export interface CrossSearchDetails {
|
|
205
|
+
pattern: string
|
|
206
|
+
scope: NonNullable<SessionReadParams['scope']>
|
|
207
|
+
degraded: boolean
|
|
208
|
+
byteBudget: number
|
|
209
|
+
/** 实际已扫字节和(只含解析成功文件) */
|
|
210
|
+
scannedBytes: number
|
|
211
|
+
scanned: CrossSearchScan[]
|
|
212
|
+
skipped: CrossSearchSkipped[]
|
|
213
|
+
truncated: boolean
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 跨会话检索头部/未扫段的人话字节格式(MB 保留 1 位,<1MB 显示 KB)。 */
|
|
217
|
+
function formatScanBytes(bytes: number): string {
|
|
218
|
+
if (bytes >= BYTES_PER_MB) return `${(bytes / BYTES_PER_MB).toFixed(1)}MB`
|
|
219
|
+
return `${Math.max(1, Math.round(bytes / BYTES_PER_KB))}KB`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 调用串内嵌 pattern 的转义(文案是 JSON-ish 形态,引号/反斜杠须保真可复制执行)。 */
|
|
223
|
+
function escapeCallArg(s: string): string {
|
|
224
|
+
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 窄化前置拒绝(V8:宽搜明确拒绝,不静默超时)。返回结果不抛——引导类输出与
|
|
229
|
+
* F1(find 零匹配)/F2(多匹配消歧)同形态。
|
|
230
|
+
*/
|
|
231
|
+
function formatCrossSearchRejected(candidateCount: number): ToolResult {
|
|
232
|
+
const text = [
|
|
233
|
+
`跨会话检索已拒绝:候选集 ${candidateCount} 个 session 超出单次上限 ${MULTI_SEARCH_MAX_SESSIONS} 个。`,
|
|
234
|
+
`对大候选集做全文检索的扫描量不可控(单次字节上限 ${formatScanBytes(SEARCH_SCAN_BYTE_BUDGET)})——先窄化,再检索。`,
|
|
235
|
+
`👉 先窄化候选集:session_read { action:"find", query:"<标题/cwd/时间关键词>" }`,
|
|
236
|
+
` 再对 find 结果中的完整 id 检索:session_read { action:"search", session:"<id1>,<id2>", pattern:"<关键词>" }(≤${MULTI_SEARCH_MAX_SESSIONS} 个)`,
|
|
237
|
+
].join('\n')
|
|
238
|
+
return {
|
|
239
|
+
content: [{ type: 'text', text }],
|
|
240
|
+
details: { rejected: true, candidateCount, maxSessions: MULTI_SEARCH_MAX_SESSIONS },
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 跨会话检索结果渲染:命中 session 块(完整 id + source + 标题若有 + 命中数 + 逐命中
|
|
246
|
+
* turn 索引/角色/片段 + 可直接执行的调用串)+ 已扫无命中 + 未扫(超预算,附单检指引)
|
|
247
|
+
* + not-found。结构行英文(渲染类现状风格),👉 指引中文(disambiguate/F1 同风格)。
|
|
248
|
+
*/
|
|
249
|
+
/** 头部:命中 session 数/已扫数 + pattern + 降级/scope 标注 + 已扫字节。 */
|
|
250
|
+
function formatCrossSearchHead(d: CrossSearchDetails, hitCount: number): string {
|
|
251
|
+
const scannedBytesLabel = `${formatScanBytes(d.scannedBytes)} of ${formatScanBytes(d.byteBudget)} budget`
|
|
252
|
+
return (
|
|
253
|
+
`${hitCount}/${d.scanned.length} session(s) hit for /${d.pattern}/` +
|
|
254
|
+
(d.degraded ? '(已降级为字面子串匹配)' : '') +
|
|
255
|
+
(d.scope !== 'all' ? ` scope=${d.scope}` : '') +
|
|
256
|
+
` · scanned ${scannedBytesLabel}`
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 单个命中 session 块:`序号. id · source · 标题` + 命中数标注 + 逐命中 + 可直接执行的调用串。 */
|
|
261
|
+
function formatHitSessionBlock(s: CrossSearchScan, index: number, pattern: string): string[] {
|
|
262
|
+
const lines: string[] = []
|
|
263
|
+
const parts = [`${index}. ${s.sessionId}`, s.source]
|
|
264
|
+
if (s.name !== undefined) parts.push(s.name)
|
|
265
|
+
const overflow = s.hitsTotal !== undefined && s.hitsTotal > s.hits.length
|
|
266
|
+
// 命中数是块级标注(对整个 session),不并入 ' · ' 信息段——紧跟末段拼接
|
|
267
|
+
const countLabel = overflow
|
|
268
|
+
? `(>${s.hitsTotal} hits, showing first ${s.hits.length})`
|
|
269
|
+
: `(${s.hits.length} hit${s.hits.length === 1 ? '' : 's'})`
|
|
270
|
+
lines.push(` ${parts.join(' · ')}${countLabel}`)
|
|
271
|
+
for (const h of s.hits) {
|
|
272
|
+
lines.push(` T${pad(h.turnIndex)} #${h.entryIndex} ${h.role}: ${h.matchSnippet}`)
|
|
273
|
+
}
|
|
274
|
+
lines.push(
|
|
275
|
+
` ↳ session_read { action:"search", session:"${s.sessionId}", pattern:"${escapeCallArg(pattern)}" }`,
|
|
276
|
+
)
|
|
277
|
+
return lines
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 「已扫无命中」段(空则零行)。 */
|
|
281
|
+
function formatNoHitSection(noHit: CrossSearchScan[]): string[] {
|
|
282
|
+
if (noHit.length === 0) return []
|
|
283
|
+
const lines = ['scanned, no hit:']
|
|
284
|
+
for (const s of noHit) lines.push(` ${s.sessionId} · ${s.source}`)
|
|
285
|
+
return lines
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 「超字节预算未扫」段(附每条的单检指引;空则零行)。 */
|
|
289
|
+
function formatOverBudgetSection(
|
|
290
|
+
overBudget: CrossSearchSkipped[],
|
|
291
|
+
d: CrossSearchDetails,
|
|
292
|
+
): string[] {
|
|
293
|
+
if (overBudget.length === 0) return []
|
|
294
|
+
const lines = [
|
|
295
|
+
`not scanned (byte budget ${formatScanBytes(d.byteBudget)} reached after ${formatScanBytes(d.scannedBytes)}):`,
|
|
296
|
+
]
|
|
297
|
+
for (const s of overBudget) {
|
|
298
|
+
lines.push(` - ${s.sessionId}(${formatScanBytes(s.sizeBytes ?? 0)})`)
|
|
299
|
+
lines.push(
|
|
300
|
+
` 👉 检索单个:session_read { action:"search", session:"${s.sessionId}", pattern:"${escapeCallArg(d.pattern)}" }`,
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
return lines
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 「库中无此 id」段(空则零行)。 */
|
|
307
|
+
function formatNotFoundSection(notFound: CrossSearchSkipped[]): string[] {
|
|
308
|
+
if (notFound.length === 0) return []
|
|
309
|
+
const lines = ['not found in library (confirm full id via find):']
|
|
310
|
+
for (const s of notFound) lines.push(` - ${s.sessionId}`)
|
|
311
|
+
return lines
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 「读取/解析失败跳过」段(空则零行)。 */
|
|
315
|
+
function formatReadErrorSection(readErrors: CrossSearchSkipped[]): string[] {
|
|
316
|
+
if (readErrors.length === 0) return []
|
|
317
|
+
const lines = ['skipped (read/parse failed):']
|
|
318
|
+
for (const s of readErrors) lines.push(` - ${s.sessionId}`)
|
|
319
|
+
return lines
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 跨会话检索结果渲染:命中 session 块(完整 id + source + 标题若有 + 命中数 + 逐命中
|
|
324
|
+
* turn 索引/角色/片段 + 可直接执行的调用串)+ 已扫无命中 + 未扫(超预算,附单检指引)
|
|
325
|
+
* + not-found。结构行英文(渲染类现状风格),👉 指引中文(disambiguate/F1 同风格)。
|
|
326
|
+
*
|
|
327
|
+
* 本函数只管段序组装;每段的行构造由上方 per-段 helper 承担(拆解自原单函数多分支,
|
|
328
|
+
* 行内容与顺序零变更)。
|
|
329
|
+
*/
|
|
330
|
+
function formatCrossSearchText(d: CrossSearchDetails): string {
|
|
331
|
+
const hitSessions = d.scanned.filter((s) => s.hits.length > 0)
|
|
332
|
+
|
|
333
|
+
const lines: string[] = []
|
|
334
|
+
if (hitSessions.length > 0) lines.push('hits:')
|
|
335
|
+
let index = 0
|
|
336
|
+
for (const s of d.scanned) {
|
|
337
|
+
if (s.hits.length === 0) continue
|
|
338
|
+
index += 1
|
|
339
|
+
lines.push(...formatHitSessionBlock(s, index, d.pattern))
|
|
340
|
+
}
|
|
341
|
+
lines.push(...formatNoHitSection(d.scanned.filter((s) => s.hits.length === 0)))
|
|
342
|
+
lines.push(...formatOverBudgetSection(d.skipped.filter((s) => s.reason === 'byte-budget'), d))
|
|
343
|
+
lines.push(...formatNotFoundSection(d.skipped.filter((s) => s.reason === 'not-found')))
|
|
344
|
+
lines.push(...formatReadErrorSection(d.skipped.filter((s) => s.reason === 'read-error')))
|
|
345
|
+
if (hitSessions.length === 0 && d.scanned.length > 0) {
|
|
346
|
+
lines.push('👉 无命中:换更精确 pattern,或用 find 重新窄化候选集后重试。')
|
|
347
|
+
}
|
|
348
|
+
return `${formatCrossSearchHead(d, hitSessions.length)}\n${lines.join('\n')}`
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** searchAcrossSessions 的可选参数(byteBudget 供测试注入小预算)。 */
|
|
352
|
+
interface CrossSearchOptions {
|
|
353
|
+
scope?: NonNullable<SessionReadParams['scope']>
|
|
354
|
+
limit?: number
|
|
355
|
+
signal?: AbortSignal
|
|
356
|
+
metadataProvider?: SessionMetadataProvider
|
|
357
|
+
byteBudget?: number
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 缺省值收口后的检索参数(各缺省值与旧内联 `opts?.** ?? 常量` 逐一对应)。 */
|
|
361
|
+
interface CrossSearchResolvedOptions {
|
|
362
|
+
scope: NonNullable<SessionReadParams['scope']>
|
|
363
|
+
limit: number
|
|
364
|
+
byteBudget: number
|
|
365
|
+
signal: AbortSignal | undefined
|
|
366
|
+
metadataProvider: SessionMetadataProvider | undefined
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function resolveCrossSearchOptions(opts: CrossSearchOptions | undefined): CrossSearchResolvedOptions {
|
|
370
|
+
return {
|
|
371
|
+
scope: opts?.scope ?? 'all',
|
|
372
|
+
limit: opts?.limit ?? SEARCH_DEFAULT_LIMIT,
|
|
373
|
+
byteBudget: opts?.byteBudget ?? SEARCH_SCAN_BYTE_BUDGET,
|
|
374
|
+
signal: opts?.signal,
|
|
375
|
+
metadataProvider: opts?.metadataProvider,
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** 单 session 扫描结果:命中块 + 截断标记;read-error 不入 scanned(只归 skipped)。 */
|
|
380
|
+
type CrossSearchSessionScan =
|
|
381
|
+
| { kind: 'scanned'; scan: CrossSearchScan; truncated: boolean }
|
|
382
|
+
| { kind: 'read-error' }
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* 单 session 扫描:复用单会话扫描管线(core 层只读复用,零新解析);坏文件返回
|
|
386
|
+
* read-error 不拖死整体(F6 只属单会话契约)。
|
|
387
|
+
*/
|
|
388
|
+
async function scanOneSession(
|
|
389
|
+
sessionId: string,
|
|
390
|
+
ref: SessionFileRef,
|
|
391
|
+
regex: RegExp,
|
|
392
|
+
scope: NonNullable<SessionReadParams['scope']>,
|
|
393
|
+
limit: number,
|
|
394
|
+
signal: AbortSignal | undefined,
|
|
395
|
+
): Promise<CrossSearchSessionScan> {
|
|
396
|
+
let turns: Turn[]
|
|
397
|
+
try {
|
|
398
|
+
const { entries } = await parseSessionFile(ref.path)
|
|
399
|
+
turns = segmentTurns(entries, new Set(buildTreeView(entries).leafPath))
|
|
400
|
+
} catch {
|
|
401
|
+
return { kind: 'read-error' }
|
|
402
|
+
}
|
|
403
|
+
const hits = collectSearchHits(turns, regex, scope, signal)
|
|
404
|
+
if (hits.length > limit) {
|
|
405
|
+
return {
|
|
406
|
+
kind: 'scanned',
|
|
407
|
+
truncated: true,
|
|
408
|
+
scan: {
|
|
409
|
+
sessionId,
|
|
410
|
+
source: ref.source,
|
|
411
|
+
path: ref.path,
|
|
412
|
+
hits: hits.slice(0, limit),
|
|
413
|
+
hitsTotal: hits.length,
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
kind: 'scanned',
|
|
419
|
+
truncated: false,
|
|
420
|
+
scan: { sessionId, source: ref.source, path: ref.path, hits },
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* 字节预算耗尽:从下标起整段标未扫(含更小的后续文件也不扫)——已扫范围恒为
|
|
426
|
+
* 列表前缀,报告无歧义;超大单文件走括注的单检通路(单会话 search 不受此预算)。
|
|
427
|
+
*/
|
|
428
|
+
function markRestOverBudget(
|
|
429
|
+
ids: string[],
|
|
430
|
+
startIndex: number,
|
|
431
|
+
index: Map<string, SessionFileRef>,
|
|
432
|
+
skipped: CrossSearchSkipped[],
|
|
433
|
+
): void {
|
|
434
|
+
for (const rest of ids.slice(startIndex)) {
|
|
435
|
+
skipped.push({ sessionId: rest, reason: 'byte-budget', sizeBytes: index.get(rest)?.sizeBytes })
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* 标题尽力补全(u11 provider 复用;仅对已扫 session 的所在目录,按目录去重 +
|
|
441
|
+
* 单目录 try/catch 记空——provider 缺省/抛错标题留空,检索本体不受影响)。
|
|
442
|
+
*/
|
|
443
|
+
async function attachScannedTitles(
|
|
444
|
+
scanned: CrossSearchScan[],
|
|
445
|
+
provider: SessionMetadataProvider,
|
|
446
|
+
): Promise<void> {
|
|
447
|
+
const titles = new Map<string, string>()
|
|
448
|
+
for (const dir of new Set(scanned.map((s) => dirname(s.path)))) {
|
|
449
|
+
let entries: SessionMetadataEntry[]
|
|
450
|
+
try {
|
|
451
|
+
entries = await provider(dir)
|
|
452
|
+
} catch {
|
|
453
|
+
continue // 降级:该目录标题不可用(§6.6 同款 guard),留空继续
|
|
454
|
+
}
|
|
455
|
+
for (const e of entries) {
|
|
456
|
+
if (e.name !== undefined) titles.set(e.id, e.name)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
for (const s of scanned) {
|
|
460
|
+
const name = titles.get(s.sessionId)
|
|
461
|
+
if (name !== undefined) s.name = name
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* 跨会话内容检索主体(u12 导出:byteBudget 参数供测试注入小预算,工具路径经 doSearch
|
|
467
|
+
* 走默认 SEARCH_SCAN_BYTE_BUDGET)。
|
|
468
|
+
*
|
|
469
|
+
* 流程:窄化拒绝(候选 > MULTI_SEARCH_MAX_SESSIONS,V8)→ buildSessionFileIndex(一次
|
|
470
|
+
* 根扫描,id 语义与 find 候选同源)→ 按传入顺序逐个「复用单会话扫描管线」(safeParse →
|
|
471
|
+
* buildTreeView → segmentTurns → collectSearchHits,零新解析;signal.abort 中断与单会话
|
|
472
|
+
* 一致抛错)→ 字节预算按序消耗(放不下的文件起整段停止,已扫范围 = 列表前缀,报告
|
|
473
|
+
* 每条未扫 id 与原因)→ 标题尽力补全(仅对已扫 session 的目录调 metadataProvider,
|
|
474
|
+
* 按目录去重 + 单目录 try/catch 降级留空)→ 渲染。
|
|
475
|
+
*
|
|
476
|
+
* limit 语义延续单会话(每 session 命中数上限,默认 SEARCH_DEFAULT_LIMIT);候选 ≤10
|
|
477
|
+
* 且每 session ≤limit 命中,输出规模有界。truncated = 任一 session 命中被截断。
|
|
478
|
+
*/
|
|
479
|
+
export async function searchAcrossSessions(
|
|
480
|
+
ids: string[],
|
|
481
|
+
pattern: string,
|
|
482
|
+
signals: SessionReadSignals,
|
|
483
|
+
opts?: CrossSearchOptions,
|
|
484
|
+
): Promise<ToolResult> {
|
|
485
|
+
// ① 窄化前置(V8):候选集超阈值明确拒绝(不进入扫描)
|
|
486
|
+
if (ids.length > MULTI_SEARCH_MAX_SESSIONS) {
|
|
487
|
+
return formatCrossSearchRejected(ids.length)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const { scope, limit, byteBudget, signal, metadataProvider } = resolveCrossSearchOptions(opts)
|
|
491
|
+
const degraded = isCatastrophicPattern(pattern)
|
|
492
|
+
const regex = compilePattern(pattern)
|
|
493
|
+
|
|
494
|
+
// ② 候选索引(一次根扫描;候选数上限在上方已闸,索引只做 id 全覆盖)
|
|
495
|
+
const index = await buildSessionFileIndex(signals)
|
|
496
|
+
|
|
497
|
+
// ③ 按传入顺序逐个扫描(调用方列表顺序即优先级,字节预算按序消耗)
|
|
498
|
+
const scanned: CrossSearchScan[] = []
|
|
499
|
+
const skipped: CrossSearchSkipped[] = []
|
|
500
|
+
let scannedBytes = 0
|
|
501
|
+
let truncated = false
|
|
502
|
+
for (let i = 0; i < ids.length; i++) {
|
|
503
|
+
const id = ids[i]!
|
|
504
|
+
const ref = index.get(id)
|
|
505
|
+
if (ref === undefined) {
|
|
506
|
+
skipped.push({ sessionId: id, reason: 'not-found' })
|
|
507
|
+
continue
|
|
508
|
+
}
|
|
509
|
+
if (scannedBytes + ref.sizeBytes > byteBudget) {
|
|
510
|
+
markRestOverBudget(ids, i, index, skipped)
|
|
511
|
+
break
|
|
512
|
+
}
|
|
513
|
+
const outcome = await scanOneSession(id, ref, regex, scope, limit, signal)
|
|
514
|
+
if (outcome.kind === 'read-error') {
|
|
515
|
+
skipped.push({ sessionId: id, reason: 'read-error' })
|
|
516
|
+
continue
|
|
517
|
+
}
|
|
518
|
+
if (outcome.truncated) truncated = true
|
|
519
|
+
scanned.push(outcome.scan)
|
|
520
|
+
scannedBytes += ref.sizeBytes
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ④ 标题尽力补全
|
|
524
|
+
if (metadataProvider !== undefined && scanned.length > 0) {
|
|
525
|
+
await attachScannedTitles(scanned, metadataProvider)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const details: CrossSearchDetails = {
|
|
529
|
+
pattern,
|
|
530
|
+
scope,
|
|
531
|
+
degraded,
|
|
532
|
+
byteBudget,
|
|
533
|
+
scannedBytes,
|
|
534
|
+
scanned,
|
|
535
|
+
skipped,
|
|
536
|
+
truncated,
|
|
537
|
+
}
|
|
538
|
+
return { content: [{ type: 'text', text: formatCrossSearchText(details) }], details }
|
|
539
|
+
}
|