@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
package/src/discovery/find.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { createReadStream, type ReadStream } from 'node:fs'
|
|
2
|
-
import { open, type FileHandle } from 'node:fs/promises'
|
|
2
|
+
import { open, stat, type FileHandle } from 'node:fs/promises'
|
|
3
3
|
import { createInterface } from 'node:readline'
|
|
4
|
-
import { basename } from 'node:path'
|
|
4
|
+
import { basename, dirname } from 'node:path'
|
|
5
5
|
import type { SessionRef } from '../core/family.js'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
resolveSessionRoots,
|
|
8
|
+
type SessionFileMeta,
|
|
9
|
+
type SessionRoot,
|
|
10
|
+
type SessionRootSignals,
|
|
11
|
+
} from './roots.js'
|
|
7
12
|
import { listRecordManifests, extractSessionIdFromFilename, type RecordManifest } from './subagents.js'
|
|
8
13
|
|
|
9
14
|
/**
|
|
@@ -12,13 +17,17 @@ import { listRecordManifests, extractSessionIdFromFilename, type RecordManifest
|
|
|
12
17
|
* 匹配三路(D-3):
|
|
13
18
|
* - uuid 片段子串:sessionId 含 query,或文件路径含 query
|
|
14
19
|
* - "recent" 特殊值:按 mtime 倒序返回最近 N 个(不经片段匹配)
|
|
15
|
-
* -
|
|
16
|
-
*
|
|
20
|
+
* - 名称关键词:标题(u11,metadataProvider 注入)或首消息预览含 query(fallback,仅在
|
|
21
|
+
* uuid 片段零匹配且 query 非 uuid 特征时——D-5:不为定位付全文解析成本)
|
|
17
22
|
*
|
|
18
23
|
* 首行扫描策略(D-5):先全量首行扫描拿 header(id/cwd/parentSession),不做全文解析;
|
|
19
24
|
* 首消息预览仅在需要时(recent/uuid 匹配的最终结果 + 关键词 fallback)对候选单独深读。
|
|
20
25
|
*
|
|
21
26
|
* agentDir 注入:同 roots.ts,零 pi 依赖(仅 node:fs + 相对 import M1 core)。
|
|
27
|
+
* u11(design 2026-09-10 §6.6):标题元数据经 metadataProvider 注入(index.ts 包 pi 的
|
|
28
|
+
* SessionManager.listAll),本层只见注入函数;三条调用策略——①惰性(仅 keyword 路径调)
|
|
29
|
+
* ②窄化(仅平铺目录:未剥层 liveSessionDir 本身 + 无子目录候选根)③TTL 缓存(注入侧
|
|
30
|
+
* tool-handler 包装,本层无缓存状态)。
|
|
22
31
|
*/
|
|
23
32
|
|
|
24
33
|
/** 候选来源标记(DM1 必填):main = agentDir/sessions/、subagent = agentDir/subagents/。 */
|
|
@@ -29,8 +38,40 @@ export interface MatchedSession extends SessionRef {
|
|
|
29
38
|
source: SessionSource
|
|
30
39
|
/** 首条 user message text 截 80 字符(从全文读,不只首行) */
|
|
31
40
|
firstMessagePreview?: string
|
|
41
|
+
/**
|
|
42
|
+
* session 标题(u11:session_info entry 的 name,来自 metadataProvider)。仅在元数据
|
|
43
|
+
* 可达(平铺目录 + provider 未抛错)时出现;标题检索降级时留空——「能找到 session」
|
|
44
|
+
* 不受影响,只损失标题维度(§6.6 降级路径)。
|
|
45
|
+
*/
|
|
46
|
+
name?: string
|
|
32
47
|
}
|
|
33
48
|
|
|
49
|
+
// ============================================================
|
|
50
|
+
// u11 标题元数据(design 2026-09-10 §6.6):发现层契约类型,结构对齐 pi SessionInfo
|
|
51
|
+
// 消费子集(index.ts 直接传 listAll 返回值,结构兼容即透传,本层零 pi 依赖)
|
|
52
|
+
// ============================================================
|
|
53
|
+
|
|
54
|
+
/** 单目录元数据条目(pi SessionInfo 的消费子集:path/id/cwd/name/modified/firstMessage)。 */
|
|
55
|
+
export interface SessionMetadataEntry {
|
|
56
|
+
/** 绝对路径(与候选 meta.path 同源) */
|
|
57
|
+
path: string
|
|
58
|
+
id: string
|
|
59
|
+
cwd: string
|
|
60
|
+
/** 用户标题(session_info entry 的 name);旧 session 缺失 */
|
|
61
|
+
name?: string
|
|
62
|
+
/** pi 返回 Date,测试替身可传 number */
|
|
63
|
+
modified: Date | number
|
|
64
|
+
/** 首消息全文(title 命中时填充 firstMessagePreview,免二次读文件) */
|
|
65
|
+
firstMessage?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 标题元数据 provider(index.ts 构造:`(dir) => SessionManager.listAll(dir)`)。
|
|
70
|
+
* 实装语义(§6.6 两条前提):只扫一层平铺目录、每文件全量解析——调用方须遵守
|
|
71
|
+
* 三条调用策略(惰性/窄化/缓存,缓存由 tool-handler 注入侧包装)。
|
|
72
|
+
*/
|
|
73
|
+
export type SessionMetadataProvider = (dir: string) => Promise<SessionMetadataEntry[]>
|
|
74
|
+
|
|
34
75
|
const DEFAULT_LIMIT = 20
|
|
35
76
|
const PREVIEW_MAX = 80
|
|
36
77
|
/** readFirstLine 单次读取 buffer 上限。session header(id/cwd/parentSession)远小于此。 */
|
|
@@ -147,14 +188,28 @@ function parseHeader(line: string | undefined): SessionHeader | null {
|
|
|
147
188
|
}
|
|
148
189
|
|
|
149
190
|
/**
|
|
150
|
-
*
|
|
191
|
+
* uuid 归一化键(design 2026-09-10 §6.7 子决策 1):小写 + 去连字符。
|
|
192
|
+
* 修复 §3.3 的两个盲区——大写 uuid(String.includes 大小写敏感)与去连字符 uuid
|
|
193
|
+
*(与带连字符 id 不构成子串)原先都 0 命中,且被 looksLikeUuidFragment(/i 大小写
|
|
194
|
+
* 不敏感)误判为「像 uuid 片段」跳过关键词回退,彻底查不到。
|
|
195
|
+
*/
|
|
196
|
+
export function normalizeUuidKey(s: string): string {
|
|
197
|
+
return s.toLowerCase().replace(/-/g, '')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* query 是否具备 uuid 片段特征(归一化后仅十六进制字符)。
|
|
151
202
|
* 用于 uuid 片段零匹配时决定是否走名称关键词 fallback:纯十六进制 query(如 e6c96、019fe635)
|
|
152
203
|
* 几乎不会出现在自然语言首消息里,深读首消息徒劳,跳过;含非十六进制字符的 query(如 plugin、
|
|
153
204
|
* 重构)才走 fallback。边界词(如 abc,恰好全十六进制)会被判 uuid 特征不走 fallback——
|
|
154
205
|
* 可接受(abc 作为首消息关键词罕见,且 uuid 片段匹配已先尝试)。
|
|
206
|
+
*
|
|
207
|
+
* u9 起用 norm(query) 判定(§6.7 子决策 1 / §11.5):/i 已忽略大小写、去连字符不扩
|
|
208
|
+
* 字符类,与旧判定 `/^[0-9a-f-]+$/i` 等价(唯一差异:纯连字符 query 归一化为空串后
|
|
209
|
+
* 不再判 uuid 特征——精确子串层已先处理它,此处语义更准确)。
|
|
155
210
|
*/
|
|
156
211
|
function looksLikeUuidFragment(query: string): boolean {
|
|
157
|
-
return /^[0-9a-f
|
|
212
|
+
return /^[0-9a-f]+$/.test(normalizeUuidKey(query))
|
|
158
213
|
}
|
|
159
214
|
|
|
160
215
|
interface Candidate {
|
|
@@ -167,6 +222,8 @@ interface Candidate {
|
|
|
167
222
|
interface Matched extends Candidate {
|
|
168
223
|
/** 名称关键词匹配路径已读出的预览;recent/uuid 路径 undefined,后续按需补读 */
|
|
169
224
|
preview?: string
|
|
225
|
+
/** 标题命中时携带(u11);透传到 MatchedSession.name */
|
|
226
|
+
name?: string
|
|
170
227
|
}
|
|
171
228
|
|
|
172
229
|
// ============================================================
|
|
@@ -300,89 +357,210 @@ function buildCandidate(
|
|
|
300
357
|
return { meta, ref, source: src }
|
|
301
358
|
}
|
|
302
359
|
|
|
303
|
-
/**
|
|
360
|
+
/**
|
|
361
|
+
* 步骤 0+1:逐根首行扫描建候选(source 过滤在根层,cwd 过滤在候选层)。
|
|
362
|
+
*
|
|
363
|
+
* u11 起直接消费 resolveSessionRoots 的根列表(单次实扫,files 与根归属信息同批产出——
|
|
364
|
+
* 标题窄化策略需要「扫描结果无子目录的候选根」这一根级事实,薄包装的扁平列表给不出)。
|
|
365
|
+
* 对只含 agentDir 的信号包,根集合 = [default]+[legacy]+[subagent],与旧薄包装
|
|
366
|
+
* listMainSessions/listSubagentSessions 的并集逐文件一致(含 workflow-state 跳过与
|
|
367
|
+
* realpath 去重语义);传 liveSessionDir 时按 §6.1 追加 [live] 根(realpath 去重保优先级)。
|
|
368
|
+
*/
|
|
304
369
|
async function collectCandidates(
|
|
305
|
-
|
|
370
|
+
roots: SessionRoot[],
|
|
306
371
|
sourceFilter: SessionSource | undefined,
|
|
307
372
|
cwdFilter: string | undefined,
|
|
308
373
|
): Promise<Candidate[]> {
|
|
309
|
-
// source 过滤在文件列表层:source==='main' 只扫 sessions/、'subagent' 只扫 subagents/、
|
|
310
|
-
// undefined 两者合并——决策二性能意图:不扫被过滤目录。两路目录扫描相互独立 →
|
|
311
|
-
// Promise.allSettled(AGENTS.md:独立请求用 allSettled),任一目录不存在(roots.ts
|
|
312
|
-
// 静默返回空数组)不影响另一路。
|
|
313
|
-
const sources: SessionSource[] =
|
|
314
|
-
sourceFilter === undefined ? ['main', 'subagent'] : [sourceFilter]
|
|
315
|
-
const listResults = await Promise.allSettled(
|
|
316
|
-
sources.map(async (src) => ({
|
|
317
|
-
src,
|
|
318
|
-
files: await (src === 'main' ? listMainSessions(agentDir) : listSubagentSessions(agentDir)),
|
|
319
|
-
})),
|
|
320
|
-
)
|
|
321
|
-
|
|
322
|
-
// 首行扫描建候选 SessionRef(按来源打 source 标记)
|
|
323
374
|
const candidates: Candidate[] = []
|
|
324
|
-
for (const
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
for (const meta of files) {
|
|
375
|
+
for (const root of roots) {
|
|
376
|
+
if (root.dedupedInto !== undefined) continue // 被去重根未实扫(files 恒空),不产候选
|
|
377
|
+
if (sourceFilter !== undefined && root.source !== sourceFilter) continue
|
|
378
|
+
for (const meta of root.files) {
|
|
328
379
|
const header = parseHeader(await readFirstLine(meta.path))
|
|
329
380
|
if (!header) continue // 非 session 文件/坏 header → 跳过
|
|
330
381
|
if (cwdFilter !== undefined && (header.cwd ?? '') !== cwdFilter) continue
|
|
331
|
-
candidates.push(buildCandidate(meta, header,
|
|
382
|
+
candidates.push(buildCandidate(meta, header, root.source))
|
|
332
383
|
}
|
|
333
384
|
}
|
|
334
385
|
return candidates
|
|
335
386
|
}
|
|
336
387
|
|
|
337
|
-
|
|
388
|
+
// ============================================================
|
|
389
|
+
// u11 标题检索(design 2026-09-10 §6.6):惰性触发 + 平铺窄化 + 两条 guard
|
|
390
|
+
// ============================================================
|
|
391
|
+
|
|
392
|
+
/** 匹配层的元数据上下文(全部来自注入;metadataProvider 缺省 = 现状行为零变化)。 */
|
|
393
|
+
interface MetadataContext {
|
|
394
|
+
agentDir: string
|
|
395
|
+
/** 本查询刚完成的实扫根列表(窄化判据「扫描结果无子目录的候选根」的数据源) */
|
|
396
|
+
roots: SessionRoot[]
|
|
397
|
+
/** 未剥层 liveSessionDir 原始信号(窄化目标 ①;缺省 = 三根降级形态,全部回退) */
|
|
398
|
+
liveSessionDir?: string
|
|
399
|
+
metadataProvider?: SessionMetadataProvider
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** stat 目录是否存在(guard:仅对存在的根/目录调用 provider)。 */
|
|
403
|
+
async function pathExistsDir(path: string): Promise<boolean> {
|
|
404
|
+
try {
|
|
405
|
+
await stat(path)
|
|
406
|
+
return true
|
|
407
|
+
} catch {
|
|
408
|
+
return false
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** 条目 modified 的可比数值(pi 返回 Date,测试替身可传 number)。 */
|
|
413
|
+
function modifiedOf(e: SessionMetadataEntry): number {
|
|
414
|
+
return typeof e.modified === 'number' ? e.modified : e.modified.getTime()
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* 平铺目录判据(§6.6 调用策略 ②):本根实扫出的全部 .jsonl 都直接位于根本身
|
|
419
|
+
*(即扫描结果无子目录)。listAll(dir) 实装语义是「只扫一层平铺目录」——平铺根意味着
|
|
420
|
+
* listAll 能看到全部文件;含子目录根(如纯 pi 的 encodeCwd 布局)跳过,标题检索对其中
|
|
421
|
+
* 候选不可用(枚举全部子目录 × 全量解析成本不可接受),候选不退出 keyword 匹配。
|
|
422
|
+
*/
|
|
423
|
+
function isFlatRoot(root: SessionRoot): boolean {
|
|
424
|
+
return root.files.every((f) => dirname(f.path) === root.path)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* 加载标题元数据索引(u11)。仅 keyword 路径调用(惰性触发:uuid 精确/归一化两级零命中
|
|
429
|
+
* 且 query 非纯 hex 才到达此处,§6.6 调用策略 ①)。
|
|
430
|
+
*
|
|
431
|
+
* 窄化目标(策略 ②,按序):未剥层 liveSessionDir 本身(xyz-agent 下即平铺主根;纯 pi 下
|
|
432
|
+
* 即当前 cwd 目录,小)+ 实扫结果无子目录的候选根。两条 guard:
|
|
433
|
+
* - 仅对存在的根/目录调用(root.exists / stat liveSessionDir),且永传非空串——pi 的
|
|
434
|
+
* listAll 对空串 falsy 走默认全盘分支(数千项 / 秒级,hash-provider.ts 空参灾难先例);
|
|
435
|
+
* - 单目录 try/catch:provider 抛错记空继续,不中断其他目录(策略降级路径)。
|
|
436
|
+
*
|
|
437
|
+
* 多目录结果合并语义(§11.3b):按 id 去重,重复取新 modified。
|
|
438
|
+
*/
|
|
439
|
+
async function loadTitleIndex(ctx: MetadataContext): Promise<Map<string, SessionMetadataEntry>> {
|
|
440
|
+
const index = new Map<string, SessionMetadataEntry>()
|
|
441
|
+
const provider = ctx.metadataProvider
|
|
442
|
+
if (provider === undefined) return index
|
|
443
|
+
|
|
444
|
+
const targets: string[] = []
|
|
445
|
+
const seen = new Set<string>()
|
|
446
|
+
const push = (dir: string): void => {
|
|
447
|
+
if (dir.length === 0 || seen.has(dir)) return // guard:永传非空串;同字面路径只调一次
|
|
448
|
+
seen.add(dir)
|
|
449
|
+
targets.push(dir)
|
|
450
|
+
}
|
|
451
|
+
if (ctx.liveSessionDir !== undefined && ctx.liveSessionDir.length > 0) {
|
|
452
|
+
if (await pathExistsDir(ctx.liveSessionDir)) push(ctx.liveSessionDir)
|
|
453
|
+
}
|
|
454
|
+
for (const root of ctx.roots) {
|
|
455
|
+
if (root.dedupedInto !== undefined) continue
|
|
456
|
+
if (!root.exists) continue // guard:仅对存在的根调用
|
|
457
|
+
if (!isFlatRoot(root)) continue // 策略 ②:含子目录根跳过 listAll
|
|
458
|
+
push(root.path)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const dir of targets) {
|
|
462
|
+
let entries: SessionMetadataEntry[]
|
|
463
|
+
try {
|
|
464
|
+
entries = await provider(dir)
|
|
465
|
+
} catch {
|
|
466
|
+
continue // guard:单目录抛错记空继续 → 该目录候选回退首条 user 匹配,标题留空
|
|
467
|
+
}
|
|
468
|
+
for (const e of entries) {
|
|
469
|
+
const prev = index.get(e.id)
|
|
470
|
+
if (prev === undefined || modifiedOf(e) > modifiedOf(prev)) index.set(e.id, e)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return index
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** 步骤 2 关键词层(U5 扩展 + u11 标题):manifest 元数据(subagent)→ 标题(u11)→ 首消息预览,命中任一即入选。 */
|
|
338
477
|
async function matchByKeywords(
|
|
339
478
|
candidates: Candidate[],
|
|
340
479
|
query: string,
|
|
341
|
-
|
|
480
|
+
ctx: MetadataContext,
|
|
342
481
|
): Promise<Matched[]> {
|
|
343
482
|
const manifestIndex = await buildManifestIndex(
|
|
344
|
-
agentDir,
|
|
483
|
+
ctx.agentDir,
|
|
345
484
|
candidates.some((c) => c.source === 'subagent'),
|
|
346
485
|
)
|
|
486
|
+
// 惰性触发点(§6.6 策略 ①):仅 keyword 路径构建标题索引;uuid/recent 匹配路径不经过
|
|
487
|
+
// 本函数。provider 缺省时 loadTitleIndex 直接返回空 Map(现状行为零变化)。
|
|
488
|
+
const titles = await loadTitleIndex(ctx)
|
|
347
489
|
const keywordHits: Matched[] = []
|
|
348
490
|
for (const c of candidates) {
|
|
349
491
|
// - subagent 候选:先查 manifest 索引(命中走元数据子串,未命中 P-fallback 读尾行 identity);
|
|
350
492
|
// 元数据命中即入选(preview 留空,第 5 步补读首消息),未命中仍可走首消息 fallback
|
|
351
|
-
// -
|
|
493
|
+
// - 标题命中(u11):name 含 query 即入选,标题检索是首消息维度的**增量**能力(§6.6);
|
|
494
|
+
// 命中候选免深读首消息(preview 取元数据 firstMessage),免读文件
|
|
495
|
+
// - 元数据/标题未命中:首消息预览 query 子串匹配(m0 现状路径不变——含子目录根、
|
|
496
|
+
// provider 缺省/抛错形态都落到这里,成本与召回均无变化)
|
|
352
497
|
if (c.source === 'subagent' && (await matchSubagentMetadata(c, query, manifestIndex))) {
|
|
353
498
|
keywordHits.push({ ...c })
|
|
354
499
|
continue
|
|
355
500
|
}
|
|
501
|
+
const meta = titles.get(c.ref.sessionId)
|
|
502
|
+
if (meta?.name !== undefined && meta.name.includes(query)) {
|
|
503
|
+
keywordHits.push({
|
|
504
|
+
...c,
|
|
505
|
+
name: meta.name,
|
|
506
|
+
preview: meta.firstMessage !== undefined && meta.firstMessage !== ''
|
|
507
|
+
? meta.firstMessage.slice(0, PREVIEW_MAX)
|
|
508
|
+
: undefined,
|
|
509
|
+
})
|
|
510
|
+
continue
|
|
511
|
+
}
|
|
356
512
|
const text = await readFirstUserMessageText(c.meta.path)
|
|
357
513
|
if (text && text.includes(query)) {
|
|
358
|
-
|
|
514
|
+
// 标题索引有该候选但不命中 query 时仍携带 name(渲染层标题优先展示,§5.1 形态)
|
|
515
|
+
keywordHits.push({
|
|
516
|
+
...c,
|
|
517
|
+
...(meta?.name !== undefined ? { name: meta.name } : {}),
|
|
518
|
+
preview: text.slice(0, PREVIEW_MAX),
|
|
519
|
+
})
|
|
359
520
|
}
|
|
360
521
|
}
|
|
361
522
|
return keywordHits
|
|
362
523
|
}
|
|
363
524
|
|
|
364
|
-
/** 步骤 2 三路匹配:recent / uuid
|
|
525
|
+
/** 步骤 2 三路匹配:recent / uuid 片段(两级:精确 + 归一化,§6.7 子决策 1)/ 名称关键词+U5 元数据+u11 标题。 */
|
|
365
526
|
async function matchCandidates(
|
|
366
527
|
candidates: Candidate[],
|
|
367
528
|
query: string,
|
|
368
|
-
|
|
529
|
+
ctx: MetadataContext,
|
|
369
530
|
): Promise<Matched[]> {
|
|
370
531
|
if (query === 'recent') {
|
|
371
532
|
// recent:不经片段匹配,全部候选按 mtime 倒序后截 limit
|
|
372
533
|
return candidates.map((c) => ({ ...c }))
|
|
373
534
|
}
|
|
374
|
-
//
|
|
375
|
-
|
|
535
|
+
// 第一级 uuid 片段匹配(sessionId 或文件路径含 query)——cheap,已有 header。
|
|
536
|
+
// 零命中才进入第二级(互斥回退,非合并排序,§6.7 子决策 1)。
|
|
537
|
+
const exactHits = candidates.filter(
|
|
376
538
|
(c) => c.ref.sessionId.includes(query) || c.meta.path.includes(query),
|
|
377
539
|
)
|
|
378
|
-
if (
|
|
379
|
-
return
|
|
540
|
+
if (exactHits.length > 0) {
|
|
541
|
+
return exactHits.map((c) => ({ ...c }))
|
|
542
|
+
}
|
|
543
|
+
// 第二级归一化匹配:norm(sessionId).includes(norm(query))(小写 + 去连字符)。
|
|
544
|
+
// 只比 sessionId 不比 path——path 含时间戳等非 id 数字,norm 后误吸面大。
|
|
545
|
+
// 到达此层与第一级互斥(精确命中已返回),即归一化命中恒排在精确命中之后;
|
|
546
|
+
// keyword 命中只在归一化零命中时发生,层序自然成立。nq 为空串(纯连字符
|
|
547
|
+
// query)时跳过——includes('') 恒 true 会误吸全部候选。
|
|
548
|
+
const nq = normalizeUuidKey(query)
|
|
549
|
+
if (nq.length > 0) {
|
|
550
|
+
const normHits = candidates.filter((c) => normalizeUuidKey(c.ref.sessionId).includes(nq))
|
|
551
|
+
if (normHits.length > 0) {
|
|
552
|
+
// mtime 倒序由 sortByMtimeAndTruncate 统一处理
|
|
553
|
+
return normHits.map((c) => ({ ...c }))
|
|
554
|
+
}
|
|
380
555
|
}
|
|
381
556
|
if (looksLikeUuidFragment(query)) {
|
|
382
|
-
// query 像 uuid
|
|
557
|
+
// query 像 uuid 片段但精确 + 归一化两级均无匹配 → uuid 写错的可能性高,
|
|
558
|
+
// 不对全部候选深读首消息
|
|
383
559
|
return []
|
|
384
560
|
}
|
|
385
|
-
|
|
561
|
+
// 惰性触发(§6.6 策略 ①):到此层 ⟺ uuid 精确 = 0 且归一化 = 0 且 query 非纯 hex,
|
|
562
|
+
// 即 keyword 路径——只有这里才允许调 metadataProvider。
|
|
563
|
+
return matchByKeywords(candidates, query, ctx)
|
|
386
564
|
}
|
|
387
565
|
|
|
388
566
|
/** 步骤 3+4:mtime 倒序 + limit 截断(truncated 标记是否截断)。 */
|
|
@@ -395,17 +573,74 @@ function sortByMtimeAndTruncate(
|
|
|
395
573
|
return { items: truncated ? matched.slice(0, limit) : matched, truncated }
|
|
396
574
|
}
|
|
397
575
|
|
|
398
|
-
/**
|
|
399
|
-
async function
|
|
576
|
+
/** recent 路径的目录级标题元数据(按目录去重 + 单目录抛错记空 + 同 id 取新 modified)。 */
|
|
577
|
+
async function loadRecentTitles(
|
|
578
|
+
sliced: Matched[],
|
|
579
|
+
provider: SessionMetadataProvider,
|
|
580
|
+
): Promise<Map<string, SessionMetadataEntry>> {
|
|
581
|
+
const titles = new Map<string, SessionMetadataEntry>()
|
|
582
|
+
// 候选所在目录天然平铺(文件直在其下);按目录去重合并调用,多目录同 id 取新 modified
|
|
583
|
+
const dirs = [...new Set(sliced.map((m) => dirname(m.meta.path)))]
|
|
584
|
+
for (const dir of dirs) {
|
|
585
|
+
let entries: SessionMetadataEntry[]
|
|
586
|
+
try {
|
|
587
|
+
entries = await provider(dir)
|
|
588
|
+
} catch {
|
|
589
|
+
continue // guard:抛错记空 → 该目录候选回退 readFirstUserMessageText
|
|
590
|
+
}
|
|
591
|
+
for (const e of entries) {
|
|
592
|
+
const prev = titles.get(e.id)
|
|
593
|
+
if (prev === undefined || modifiedOf(e) > modifiedOf(prev)) titles.set(e.id, e)
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return titles
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** 标题优先级:本层已命中 name > 元数据 name(u11);两者皆无→ undefined(不挂字段)。 */
|
|
600
|
+
function resolveName(m: Matched, meta: SessionMetadataEntry | undefined): string | undefined {
|
|
601
|
+
if (m.name !== undefined) return m.name
|
|
602
|
+
return meta?.name
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* 预览优先级:本层已读 preview > 元数据 firstMessage(免深读)> 实读文件首条 user message。
|
|
607
|
+
* 最后一条兼作 readFirstUserMessageText 的唯一调用点(最多 limit 次 IO)。
|
|
608
|
+
*/
|
|
609
|
+
async function resolvePreview(
|
|
610
|
+
m: Matched,
|
|
611
|
+
meta: SessionMetadataEntry | undefined,
|
|
612
|
+
): Promise<string | undefined> {
|
|
613
|
+
if (m.preview !== undefined) return m.preview
|
|
614
|
+
if (meta?.firstMessage !== undefined && meta.firstMessage !== '') {
|
|
615
|
+
return meta.firstMessage.slice(0, PREVIEW_MAX)
|
|
616
|
+
}
|
|
617
|
+
const text = await readFirstUserMessageText(m.meta.path)
|
|
618
|
+
return text ? text.slice(0, PREVIEW_MAX) : undefined
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* 步骤 5:填 firstMessagePreview / name(recent/uuid 路径未读,对最终 limit 个补读——最多 limit 个 IO)。
|
|
623
|
+
*
|
|
624
|
+
* u11 recent 补充(§6.6 策略 ① 括注):recent 路径不参与标题匹配,但**仅对 limit 截断后
|
|
625
|
+
* 的少数候选**按其所在目录补元数据(标题 + 免深读的 preview)。provider 缺省 = 现状行为;
|
|
626
|
+
* provider 抛错按目录记空回退读文件(与 loadTitleIndex 同款 guard)。
|
|
627
|
+
*/
|
|
628
|
+
async function fillFirstMessagePreviews(
|
|
629
|
+
sliced: Matched[],
|
|
630
|
+
recentMetadataProvider?: SessionMetadataProvider,
|
|
631
|
+
): Promise<MatchedSession[]> {
|
|
632
|
+
const titles =
|
|
633
|
+
recentMetadataProvider !== undefined && sliced.length > 0
|
|
634
|
+
? await loadRecentTitles(sliced, recentMetadataProvider)
|
|
635
|
+
: new Map<string, SessionMetadataEntry>()
|
|
400
636
|
const result: MatchedSession[] = []
|
|
401
637
|
for (const m of sliced) {
|
|
638
|
+
const meta = titles.get(m.ref.sessionId)
|
|
402
639
|
const out: MatchedSession = { ...m.ref, source: m.source }
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (text) out.firstMessagePreview = text.slice(0, PREVIEW_MAX)
|
|
408
|
-
}
|
|
640
|
+
const name = resolveName(m, meta)
|
|
641
|
+
if (name !== undefined) out.name = name
|
|
642
|
+
const preview = await resolvePreview(m, meta)
|
|
643
|
+
if (preview !== undefined) out.firstMessagePreview = preview
|
|
409
644
|
result.push(out)
|
|
410
645
|
}
|
|
411
646
|
return result
|
|
@@ -417,24 +652,96 @@ async function fillFirstMessagePreviews(sliced: Matched[]): Promise<MatchedSessi
|
|
|
417
652
|
* 返回按 mtime 倒序,limit 截断(默认 20),truncated 标记是否截断。
|
|
418
653
|
* cwd 过滤:opts.cwd 提供时只留 header.cwd === opts.cwd 的(在匹配前过滤,减少 fallback 深读量)。
|
|
419
654
|
* 匹配为空 → `{ matches: [], truncated: false }`(F1 恢复指引在 M3 tool-adapter 层)。
|
|
655
|
+
*
|
|
656
|
+
* u11(design 2026-09-10 §6.6):
|
|
657
|
+
* - opts.liveSessionDir:未剥层 live 信号,透传 resolveSessionRoots([live] 根,realpath
|
|
658
|
+
* 去重)并作标题窄化目标;缺省 = 现状三根降级行为。
|
|
659
|
+
* - opts.metadataProvider:标题元数据注入(仅 keyword 路径 + recent 截断后补充消费);
|
|
660
|
+
* 缺省 = undefined = 现状行为(标题检索不可用,首条 user 匹配不受影响)。
|
|
420
661
|
*/
|
|
421
662
|
export async function findSessions(
|
|
422
663
|
query: string,
|
|
423
664
|
agentDir: string,
|
|
424
|
-
opts?: {
|
|
665
|
+
opts?: {
|
|
666
|
+
cwd?: string
|
|
667
|
+
limit?: number
|
|
668
|
+
source?: SessionSource
|
|
669
|
+
liveSessionDir?: string
|
|
670
|
+
metadataProvider?: SessionMetadataProvider
|
|
671
|
+
},
|
|
425
672
|
): Promise<{ matches: MatchedSession[]; truncated: boolean }> {
|
|
426
673
|
const limit = opts?.limit ?? DEFAULT_LIMIT
|
|
427
674
|
const cwdFilter = opts?.cwd
|
|
428
675
|
const sourceFilter = opts?.source
|
|
429
676
|
|
|
430
|
-
// 0
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
677
|
+
// 0. 根解析(单次实扫,无 options——find 不读 doctor 缓存,§7B 要点 8 PS-14)
|
|
678
|
+
const signals: SessionRootSignals =
|
|
679
|
+
opts?.liveSessionDir !== undefined && opts.liveSessionDir.length > 0
|
|
680
|
+
? { agentDir, liveSessionDir: opts.liveSessionDir }
|
|
681
|
+
: { agentDir }
|
|
682
|
+
const roots = await resolveSessionRoots(signals)
|
|
683
|
+
// 0+1. 逐根首行扫描建候选
|
|
684
|
+
const candidates = await collectCandidates(roots, sourceFilter, cwdFilter)
|
|
685
|
+
// 2. 三路匹配(recent / uuid 片段 / 名称关键词+U5 元数据+u11 标题)
|
|
686
|
+
const matched = await matchCandidates(candidates, query, {
|
|
687
|
+
agentDir,
|
|
688
|
+
roots,
|
|
689
|
+
liveSessionDir: opts?.liveSessionDir,
|
|
690
|
+
metadataProvider: opts?.metadataProvider,
|
|
691
|
+
})
|
|
434
692
|
// 3+4. mtime 倒序 + limit 截断
|
|
435
693
|
const { items, truncated } = sortByMtimeAndTruncate(matched, limit)
|
|
436
|
-
// 5. 填 firstMessagePreview(对最终 limit
|
|
437
|
-
const matches = await fillFirstMessagePreviews(
|
|
694
|
+
// 5. 填 firstMessagePreview(对最终 limit 个补读);recent 路径仅对截断后少数候选补标题元数据
|
|
695
|
+
const matches = await fillFirstMessagePreviews(
|
|
696
|
+
items,
|
|
697
|
+
query === 'recent' ? opts?.metadataProvider : undefined,
|
|
698
|
+
)
|
|
438
699
|
|
|
439
700
|
return { matches, truncated }
|
|
440
701
|
}
|
|
702
|
+
|
|
703
|
+
// ============================================================
|
|
704
|
+
// u12 跨会话内容检索(design 2026-09-10 §2 目标 5 / §8.2 V8):候选索引
|
|
705
|
+
// ============================================================
|
|
706
|
+
|
|
707
|
+
/** 跨会话检索的候选文件引用(id/路径/大小/来源,字节预算与渲染所需的最小集)。 */
|
|
708
|
+
export interface SessionFileRef {
|
|
709
|
+
sessionId: string
|
|
710
|
+
path: string
|
|
711
|
+
sizeBytes: number
|
|
712
|
+
mtime: number
|
|
713
|
+
source: SessionSource
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* 一次根扫描建 sessionId → 文件引用索引(u12,跨会话 search 的候选来源)。
|
|
718
|
+
*
|
|
719
|
+
* id 取自首行 header(复用 collectCandidates 的首行扫描——与 find 候选、resolveSessionId
|
|
720
|
+
* 同一 id 语义,agent 从 find 输出复制的完整 id 恒能命中;不按文件名反推,避免「文件改名
|
|
721
|
+
* 后与 header id 失配」的误报面)。workflow-state 跳过、坏 header 跳过、被去重根不产候选
|
|
722
|
+
* 均随 collectCandidates 继承。同 id 多根出现([live] 与 [default] 不去重,V2 语义)时取
|
|
723
|
+
* mtime 新者(与 loadTitleIndex 合并语义一致)。
|
|
724
|
+
*
|
|
725
|
+
* 本函数不做数量限制——窄化前置(候选数阈值拒绝)与字节预算在调用方(tool-handler
|
|
726
|
+
* searchAcrossSessions),索引只负责「一次实扫、id 全覆盖」。
|
|
727
|
+
*/
|
|
728
|
+
export async function buildSessionFileIndex(
|
|
729
|
+
signals: SessionRootSignals,
|
|
730
|
+
): Promise<Map<string, SessionFileRef>> {
|
|
731
|
+
const roots = await resolveSessionRoots(signals)
|
|
732
|
+
const candidates = await collectCandidates(roots, undefined, undefined)
|
|
733
|
+
const index = new Map<string, SessionFileRef>()
|
|
734
|
+
for (const c of candidates) {
|
|
735
|
+
const prev = index.get(c.ref.sessionId)
|
|
736
|
+
if (prev === undefined || c.ref.mtime > prev.mtime) {
|
|
737
|
+
index.set(c.ref.sessionId, {
|
|
738
|
+
sessionId: c.ref.sessionId,
|
|
739
|
+
path: c.ref.fileName,
|
|
740
|
+
sizeBytes: c.ref.sizeBytes,
|
|
741
|
+
mtime: c.ref.mtime,
|
|
742
|
+
source: c.source,
|
|
743
|
+
})
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return index
|
|
747
|
+
}
|