@zhushanwen/pi-session-reader 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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 { listMainSessions, listSubagentSessions, type SessionFileMeta } from './roots.js'
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
- * - 名称关键词:首消息预览含 query(fallback,仅在 uuid 片段零匹配且 query 非 uuid 特征时
16
- * 对候选深读首消息——D-5:不为定位付全文解析成本)
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
- * query 是否具备 uuid 片段特征(仅十六进制字符与连字符)。
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-]+$/i.test(query)
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
- /** 步骤 0+1:按来源收集文件列表(source 过滤在文件列表层)+ 逐个首行扫描建候选(cwd 过滤在此应用)。 */
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
- agentDir: string,
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 r of listResults) {
325
- if (r.status !== 'fulfilled') continue
326
- const { src, files } = r.value
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, src))
382
+ candidates.push(buildCandidate(meta, header, root.source))
332
383
  }
333
384
  }
334
385
  return candidates
335
386
  }
336
387
 
337
- /** 步骤 2 关键词层(U5 扩展):manifest 元数据(+ P-fallback identity 回退)与首消息预览并列匹配,命中任一即入选。 */
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
- agentDir: string,
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
- // - main / subagent 元数据未命中:首消息预览 query 子串匹配(m0 现状路径不变)
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
- keywordHits.push({ ...c, preview: text.slice(0, PREVIEW_MAX) })
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 片段(sessionId 或文件路径含 query)/ 名称关键词+U5 元数据。 */
525
+ /** 步骤 2 三路匹配:recent / uuid 片段(两级:精确 + 归一化,§6.7 子决策 1)/ 名称关键词+U5 元数据+u11 标题。 */
365
526
  async function matchCandidates(
366
527
  candidates: Candidate[],
367
528
  query: string,
368
- agentDir: string,
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
- // 先 uuid 片段匹配(sessionId 或文件路径含 query)——cheap,已有 header
375
- const uuidHits = candidates.filter(
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 (uuidHits.length > 0) {
379
- return uuidHits.map((c) => ({ ...c }))
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 片段但无匹配 → uuid 写错的可能性高,不对全部候选深读首消息
557
+ // query 像 uuid 片段但精确 + 归一化两级均无匹配 → uuid 写错的可能性高,
558
+ // 不对全部候选深读首消息
383
559
  return []
384
560
  }
385
- return matchByKeywords(candidates, query, agentDir)
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
- /** 步骤 5:填 firstMessagePreview(recent/uuid 路径未读,对最终 limit 个补读——最多 limit 个 IO)。 */
399
- async function fillFirstMessagePreviews(sliced: Matched[]): Promise<MatchedSession[]> {
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
- if (m.preview !== undefined) {
404
- out.firstMessagePreview = m.preview
405
- } else {
406
- const text = await readFirstUserMessageText(m.meta.path)
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?: { cwd?: string; limit?: number; source?: SessionSource },
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+1. 按来源收集文件列表 → 首行扫描建候选
431
- const candidates = await collectCandidates(agentDir, sourceFilter, cwdFilter)
432
- // 2. 三路匹配(recent / uuid 片段 / 名称关键词+U5 元数据)
433
- const matched = await matchCandidates(candidates, query, agentDir)
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(items)
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
+ }