@zhushanwen/pi-session-reader 0.2.4 → 0.3.1

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.
@@ -278,26 +278,38 @@ async function matchSubagentMetadata(
278
278
  )
279
279
  }
280
280
 
281
- /**
282
- * 按 query 找 session(接口冻结,design §3.4 find action)。
283
- *
284
- * 返回按 mtime 倒序,limit 截断(默认 20),truncated 标记是否截断。
285
- * cwd 过滤:opts.cwd 提供时只留 header.cwd === opts.cwd 的(在匹配前过滤,减少 fallback 深读量)。
286
- * 匹配为空 → `{ matches: [], truncated: false }`(F1 恢复指引在 M3 tool-adapter 层)。
287
- */
288
- export async function findSessions(
289
- query: string,
290
- agentDir: string,
291
- opts?: { cwd?: string; limit?: number; source?: SessionSource },
292
- ): Promise<{ matches: MatchedSession[]; truncated: boolean }> {
293
- const limit = opts?.limit ?? DEFAULT_LIMIT
294
- const cwdFilter = opts?.cwd
295
- const sourceFilter = opts?.source
281
+ // ============================================================
282
+ // findSessions 编排 helpers(按处理阶段拆分:收集 → 匹配 → 排序截断 → 预览补读)
283
+ // ============================================================
284
+
285
+ /** meta + header → 候选构造(ref.cwd 归一化空串 + parentSession 按需挂载)。 */
286
+ function buildCandidate(
287
+ meta: SessionFileMeta,
288
+ header: SessionHeader,
289
+ src: SessionSource,
290
+ ): Candidate {
291
+ const ref: SessionRef = {
292
+ sessionId: header.id,
293
+ // 完整绝对路径(与 parentSession 同构,便于 family 按 includes(sid) 反查)
294
+ fileName: meta.path,
295
+ mtime: meta.mtime,
296
+ sizeBytes: meta.size,
297
+ cwd: header.cwd ?? '',
298
+ }
299
+ if (header.parentSession) ref.parentSession = header.parentSession
300
+ return { meta, ref, source: src }
301
+ }
296
302
 
297
- // 0. 按来源收集文件列表(source 过滤在文件列表层:source==='main' 只扫 sessions/、
298
- // 'subagent' 只扫 subagents/、undefined 两者合并——决策二性能意图:不扫被过滤目录)。
299
- // 两路目录扫描相互独立 → Promise.allSettled(AGENTS.md:独立请求用 allSettled),
300
- // 任一目录不存在(roots.ts 静默返回空数组)不影响另一路。
303
+ /** 步骤 0+1:按来源收集文件列表(source 过滤在文件列表层)+ 逐个首行扫描建候选(cwd 过滤在此应用)。 */
304
+ async function collectCandidates(
305
+ agentDir: string,
306
+ sourceFilter: SessionSource | undefined,
307
+ cwdFilter: string | undefined,
308
+ ): Promise<Candidate[]> {
309
+ // source 过滤在文件列表层:source==='main' 只扫 sessions/、'subagent' 只扫 subagents/、
310
+ // undefined 两者合并——决策二性能意图:不扫被过滤目录。两路目录扫描相互独立 →
311
+ // Promise.allSettled(AGENTS.md:独立请求用 allSettled),任一目录不存在(roots.ts
312
+ // 静默返回空数组)不影响另一路。
301
313
  const sources: SessionSource[] =
302
314
  sourceFilter === undefined ? ['main', 'subagent'] : [sourceFilter]
303
315
  const listResults = await Promise.allSettled(
@@ -307,77 +319,84 @@ export async function findSessions(
307
319
  })),
308
320
  )
309
321
 
310
- // 1. 首行扫描建候选 SessionRef(cwd 过滤在此应用;按来源打 source 标记)
322
+ // 首行扫描建候选 SessionRef(按来源打 source 标记)
311
323
  const candidates: Candidate[] = []
312
324
  for (const r of listResults) {
313
325
  if (r.status !== 'fulfilled') continue
314
326
  const { src, files } = r.value
315
327
  for (const meta of files) {
316
- const headerLine = await readFirstLine(meta.path)
317
- const header = parseHeader(headerLine)
328
+ const header = parseHeader(await readFirstLine(meta.path))
318
329
  if (!header) continue // 非 session 文件/坏 header → 跳过
319
330
  if (cwdFilter !== undefined && (header.cwd ?? '') !== cwdFilter) continue
320
- const ref: SessionRef = {
321
- sessionId: header.id,
322
- // 完整绝对路径(与 parentSession 同构,便于 family 按 includes(sid) 反查)
323
- fileName: meta.path,
324
- mtime: meta.mtime,
325
- sizeBytes: meta.size,
326
- cwd: header.cwd ?? '',
327
- }
328
- if (header.parentSession) ref.parentSession = header.parentSession
329
- candidates.push({ meta, ref, source: src })
331
+ candidates.push(buildCandidate(meta, header, src))
332
+ }
333
+ }
334
+ return candidates
335
+ }
336
+
337
+ /** 步骤 2 关键词层(U5 扩展):manifest 元数据(+ P-fallback identity 回退)与首消息预览并列匹配,命中任一即入选。 */
338
+ async function matchByKeywords(
339
+ candidates: Candidate[],
340
+ query: string,
341
+ agentDir: string,
342
+ ): Promise<Matched[]> {
343
+ const manifestIndex = await buildManifestIndex(
344
+ agentDir,
345
+ candidates.some((c) => c.source === 'subagent'),
346
+ )
347
+ const keywordHits: Matched[] = []
348
+ for (const c of candidates) {
349
+ // - subagent 候选:先查 manifest 索引(命中走元数据子串,未命中 P-fallback 读尾行 identity);
350
+ // 元数据命中即入选(preview 留空,第 5 步补读首消息),未命中仍可走首消息 fallback
351
+ // - main / subagent 元数据未命中:首消息预览 query 子串匹配(m0 现状路径不变)
352
+ if (c.source === 'subagent' && (await matchSubagentMetadata(c, query, manifestIndex))) {
353
+ keywordHits.push({ ...c })
354
+ continue
355
+ }
356
+ const text = await readFirstUserMessageText(c.meta.path)
357
+ if (text && text.includes(query)) {
358
+ keywordHits.push({ ...c, preview: text.slice(0, PREVIEW_MAX) })
330
359
  }
331
360
  }
361
+ return keywordHits
362
+ }
332
363
 
333
- // 2. 匹配
334
- let matched: Matched[]
364
+ /** 步骤 2 三路匹配:recent / uuid 片段(sessionId 或文件路径含 query)/ 名称关键词+U5 元数据。 */
365
+ async function matchCandidates(
366
+ candidates: Candidate[],
367
+ query: string,
368
+ agentDir: string,
369
+ ): Promise<Matched[]> {
335
370
  if (query === 'recent') {
336
371
  // recent:不经片段匹配,全部候选按 mtime 倒序后截 limit
337
- matched = candidates.map((c) => ({ ...c }))
338
- } else {
339
- // 先 uuid 片段匹配(sessionId 或文件路径含 query)——cheap,已有 header
340
- const uuidHits = candidates.filter(
341
- (c) => c.ref.sessionId.includes(query) || c.meta.path.includes(query),
342
- )
343
- if (uuidHits.length > 0) {
344
- matched = uuidHits.map((c) => ({ ...c }))
345
- } else if (looksLikeUuidFragment(query)) {
346
- // query 像 uuid 片段但无匹配 → uuid 写错的可能性高,不对全部候选深读首消息
347
- matched = []
348
- } else {
349
- // 关键词层(U5 扩展):subagent manifest 元数据 task/slug/agentName(+ P-fallback identity
350
- // 回退)与 main/subagent 首消息预览并列匹配,命中任一即入选(TC-find-match-priority)。
351
- // - subagent 候选:先查 manifest 索引(命中走元数据子串,未命中 P-fallback 读尾行 identity);
352
- // 元数据命中即入选(preview 留空,第 5 步补读首消息),未命中仍可走首消息 fallback
353
- // - main / subagent 元数据未命中:首消息预览 query 子串匹配(m0 现状路径不变)
354
- const manifestIndex = await buildManifestIndex(
355
- agentDir,
356
- candidates.some((c) => c.source === 'subagent'),
357
- )
358
- const keywordHits: Matched[] = []
359
- for (const c of candidates) {
360
- if (c.source === 'subagent' && (await matchSubagentMetadata(c, query, manifestIndex))) {
361
- keywordHits.push({ ...c })
362
- continue
363
- }
364
- const text = await readFirstUserMessageText(c.meta.path)
365
- if (text && text.includes(query)) {
366
- keywordHits.push({ ...c, preview: text.slice(0, PREVIEW_MAX) })
367
- }
368
- }
369
- matched = keywordHits
370
- }
372
+ return candidates.map((c) => ({ ...c }))
373
+ }
374
+ // 先 uuid 片段匹配(sessionId 或文件路径含 query)——cheap,已有 header
375
+ const uuidHits = candidates.filter(
376
+ (c) => c.ref.sessionId.includes(query) || c.meta.path.includes(query),
377
+ )
378
+ if (uuidHits.length > 0) {
379
+ return uuidHits.map((c) => ({ ...c }))
380
+ }
381
+ if (looksLikeUuidFragment(query)) {
382
+ // query 像 uuid 片段但无匹配 → uuid 写错的可能性高,不对全部候选深读首消息
383
+ return []
371
384
  }
385
+ return matchByKeywords(candidates, query, agentDir)
386
+ }
372
387
 
373
- // 3. mtime 倒序
388
+ /** 步骤 3+4:mtime 倒序 + limit 截断(truncated 标记是否截断)。 */
389
+ function sortByMtimeAndTruncate(
390
+ matched: Matched[],
391
+ limit: number,
392
+ ): { items: Matched[]; truncated: boolean } {
374
393
  matched.sort((a, b) => b.ref.mtime - a.ref.mtime)
375
-
376
- // 4. 截断
377
394
  const truncated = matched.length > limit
378
- const sliced = truncated ? matched.slice(0, limit) : matched
395
+ return { items: truncated ? matched.slice(0, limit) : matched, truncated }
396
+ }
379
397
 
380
- // 5. 填 firstMessagePreview(recent/uuid 路径未读,这里对最终 limit 个补读——最多 limit 个 IO)
398
+ /** 步骤 5:填 firstMessagePreview(recent/uuid 路径未读,对最终 limit 个补读——最多 limit 个 IO)。 */
399
+ async function fillFirstMessagePreviews(sliced: Matched[]): Promise<MatchedSession[]> {
381
400
  const result: MatchedSession[] = []
382
401
  for (const m of sliced) {
383
402
  const out: MatchedSession = { ...m.ref, source: m.source }
@@ -389,6 +408,33 @@ export async function findSessions(
389
408
  }
390
409
  result.push(out)
391
410
  }
411
+ return result
412
+ }
413
+
414
+ /**
415
+ * 按 query 找 session(接口冻结,design §3.4 find action)。
416
+ *
417
+ * 返回按 mtime 倒序,limit 截断(默认 20),truncated 标记是否截断。
418
+ * cwd 过滤:opts.cwd 提供时只留 header.cwd === opts.cwd 的(在匹配前过滤,减少 fallback 深读量)。
419
+ * 匹配为空 → `{ matches: [], truncated: false }`(F1 恢复指引在 M3 tool-adapter 层)。
420
+ */
421
+ export async function findSessions(
422
+ query: string,
423
+ agentDir: string,
424
+ opts?: { cwd?: string; limit?: number; source?: SessionSource },
425
+ ): Promise<{ matches: MatchedSession[]; truncated: boolean }> {
426
+ const limit = opts?.limit ?? DEFAULT_LIMIT
427
+ const cwdFilter = opts?.cwd
428
+ const sourceFilter = opts?.source
429
+
430
+ // 0+1. 按来源收集文件列表 → 首行扫描建候选
431
+ const candidates = await collectCandidates(agentDir, sourceFilter, cwdFilter)
432
+ // 2. 三路匹配(recent / uuid 片段 / 名称关键词+U5 元数据)
433
+ const matched = await matchCandidates(candidates, query, agentDir)
434
+ // 3+4. mtime 倒序 + limit 截断
435
+ const { items, truncated } = sortByMtimeAndTruncate(matched, limit)
436
+ // 5. 填 firstMessagePreview(对最终 limit 个补读)
437
+ const matches = await fillFirstMessagePreviews(items)
392
438
 
393
- return { matches: result, truncated }
439
+ return { matches, truncated }
394
440
  }