@zhushanwen/pi-session-reader 0.3.0 → 0.4.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.
@@ -18,6 +18,11 @@ function isRecord(v: unknown): v is Record<string, unknown> {
18
18
  return typeof v === 'object' && v !== null
19
19
  }
20
20
 
21
+ /** unknown → string 收窄(非 string → undefined)。快照可选字符串字段的统一入口。 */
22
+ function strOr(v: unknown): string | undefined {
23
+ return typeof v === 'string' ? v : undefined
24
+ }
25
+
21
26
  /** 字符串截断(超 max 加省略号)。概览预览用,全文走 detail。 */
22
27
  function truncate(s: string, max: number): string {
23
28
  return s.length <= max ? s : s.slice(0, max) + '…'
@@ -108,6 +113,22 @@ function mapBudget(b: Record<string, unknown>): WorkflowBudget {
108
113
  }
109
114
  }
110
115
 
116
+ /** call.status 字符串收窄为合法三态(未知值 → 'pending')。 */
117
+ function normalizeCallStatus(raw: string): WorkflowStep['status'] {
118
+ return raw === 'done' || raw === 'running' || raw === 'pending' ? raw : 'pending'
119
+ }
120
+
121
+ /** 「顶层字段优先、result 回退」的 session 关联字段提取(NEW call 与 OLD value 共用形态)。 */
122
+ function pickSessionRefs(
123
+ top: Record<string, unknown>,
124
+ result: Record<string, unknown>,
125
+ ): Pick<WorkflowStep, 'sessionId' | 'sessionFile'> {
126
+ return {
127
+ sessionId: strOr(top.sessionId) ?? strOr(result.sessionId),
128
+ sessionFile: strOr(top.sessionFile) ?? strOr(result.sessionFile),
129
+ }
130
+ }
131
+
111
132
  /** NEW state.calls[] 单项 → WorkflowStep。sessionFile/sessionId 顶层优先回退 result。 */
112
133
  function mapCallToStep(call: unknown, fallbackIndex: number): WorkflowStep {
113
134
  if (!isRecord(call)) return { index: fallbackIndex, status: 'pending' }
@@ -115,36 +136,20 @@ function mapCallToStep(call: unknown, fallbackIndex: number): WorkflowStep {
115
136
  const result = isRecord(call.result) ? call.result : {}
116
137
 
117
138
  const index = typeof call.id === 'number' ? call.id : fallbackIndex
118
- const rawStatus = typeof call.status === 'string' ? call.status : ''
119
- const status: WorkflowStep['status'] =
120
- rawStatus === 'done' || rawStatus === 'running' || rawStatus === 'pending'
121
- ? rawStatus
122
- : 'pending'
123
-
124
- const sessionFile =
125
- typeof call.sessionFile === 'string'
126
- ? call.sessionFile
127
- : typeof result.sessionFile === 'string'
128
- ? result.sessionFile
129
- : undefined
130
- const sessionId =
131
- typeof call.sessionId === 'string'
132
- ? call.sessionId
133
- : typeof result.sessionId === 'string'
134
- ? result.sessionId
135
- : undefined
136
- const content = typeof result.content === 'string' ? result.content : undefined
139
+ const status = normalizeCallStatus(strOr(call.status) ?? '')
140
+ const refs = pickSessionRefs(call, result)
141
+ const content = strOr(result.content)
137
142
 
138
143
  return {
139
144
  index,
140
145
  status,
141
- description: typeof opts.description === 'string' ? opts.description : undefined,
142
- model: typeof opts.model === 'string' ? opts.model : undefined,
143
- thinkingLevel: typeof opts.thinkingLevel === 'string' ? opts.thinkingLevel : undefined,
146
+ description: strOr(opts.description),
147
+ model: strOr(opts.model),
148
+ thinkingLevel: strOr(opts.thinkingLevel),
144
149
  attempts: typeof call.attempts === 'number' ? call.attempts : undefined,
145
150
  durationMs: typeof result.durationMs === 'number' ? result.durationMs : undefined,
146
- sessionId,
147
- sessionFile,
151
+ sessionId: refs.sessionId,
152
+ sessionFile: refs.sessionFile,
148
153
  contentPreview: content !== undefined ? truncate(content, CONTENT_PREVIEW_MAX) : undefined,
149
154
  }
150
155
  }
@@ -155,45 +160,74 @@ function mapCacheEntryToStep(entry: unknown, index: number): WorkflowStep {
155
160
  const value = isRecord(entry.value) ? entry.value : {}
156
161
  const result = isRecord(value.result) ? value.result : {}
157
162
 
158
- // sessionFile: value.sessionFile 或 value.result.sessionFile(OLD 多数缺失,探针 112 文件 0)
159
- const sessionFile =
160
- typeof value.sessionFile === 'string'
161
- ? value.sessionFile
162
- : typeof result.sessionFile === 'string'
163
- ? result.sessionFile
164
- : undefined
165
- const sessionId =
166
- typeof value.sessionId === 'string'
167
- ? value.sessionId
168
- : typeof result.sessionId === 'string'
169
- ? result.sessionId
170
- : undefined
163
+ // sessionFile/sessionId: value.sessionFile 或 value.result.sessionFile(OLD 多数缺失,探针 112 文件 0)
164
+ const refs = pickSessionRefs(value, result)
171
165
  // content:真实 OLD 数据 value.content(wf-skip-ok)与测试 fixture value.result.content 并存
172
- const content =
173
- typeof result.content === 'string'
174
- ? result.content
175
- : typeof value.content === 'string'
176
- ? value.content
177
- : undefined
166
+ const content = strOr(result.content) ?? strOr(value.content)
178
167
  // OLD 无 status 字段:有 sessionFile 或非空 content → done,否则 pending
179
168
  //(空 content 如 wf-skip-ok 的 '' 不算完成标志,对齐 TC-w5-parse-old expected status='pending';
180
169
  // content 仍提取为 contentPreview='')
181
170
  const hasContent = content !== undefined && content.length > 0
182
171
  const status: WorkflowStep['status'] =
183
- sessionFile !== undefined || hasContent ? 'done' : 'pending'
172
+ refs.sessionFile !== undefined || hasContent ? 'done' : 'pending'
184
173
 
185
174
  return {
186
175
  index,
187
176
  status,
188
177
  durationMs: typeof result.durationMs === 'number' ? result.durationMs : undefined,
189
- sessionId,
190
- sessionFile,
178
+ sessionId: refs.sessionId,
179
+ sessionFile: refs.sessionFile,
191
180
  contentPreview: content !== undefined ? truncate(content, CONTENT_PREVIEW_MAX) : undefined,
192
181
  }
193
182
  }
194
183
 
195
184
  // ---- parseRunSnapshot(unknown → WorkflowOverview | null)----
196
185
 
186
+ /** NEW 格式装配(v=wf-run-v1/v2):state.* / meta.* / spec.* → WorkflowOverview。 */
187
+ function parseNewRunSnapshot(
188
+ snapshot: Record<string, unknown>,
189
+ v: 'wf-run-v1' | 'wf-run-v2',
190
+ runId: string,
191
+ stateFile: string,
192
+ ): WorkflowOverview {
193
+ const state = isRecord(snapshot.state) ? snapshot.state : {}
194
+ const meta = isRecord(snapshot.meta) ? snapshot.meta : {}
195
+ const spec = isRecord(snapshot.spec) ? snapshot.spec : {}
196
+ const callsRaw = Array.isArray(state.calls) ? state.calls : []
197
+ return {
198
+ runId,
199
+ stateFile,
200
+ status: strOr(state.status) ?? '',
201
+ version: v,
202
+ script: strOr(spec.scriptName) ?? strOr(spec.name),
203
+ startedAt: strOr(meta.startedAt),
204
+ completedAt: strOr(meta.completedAt),
205
+ reason: strOr(state.reason),
206
+ error: strOr(state.error),
207
+ budget: mapBudget(isRecord(state.budget) ? state.budget : {}),
208
+ steps: callsRaw.map((c, i) => mapCallToStep(c, i)),
209
+ }
210
+ }
211
+
212
+ /** OLD 格式装配(无 v):顶层 status/name/startedAt/budget + callCache → WorkflowOverview。 */
213
+ function parseLegacyRunSnapshot(
214
+ snapshot: Record<string, unknown>,
215
+ runId: string,
216
+ stateFile: string,
217
+ ): WorkflowOverview {
218
+ const callCacheRaw = Array.isArray(snapshot.callCache) ? snapshot.callCache : []
219
+ return {
220
+ runId,
221
+ stateFile,
222
+ status: strOr(snapshot.status) ?? '',
223
+ version: 'legacy',
224
+ script: strOr(snapshot.name),
225
+ startedAt: strOr(snapshot.startedAt),
226
+ budget: mapBudget(isRecord(snapshot.budget) ? snapshot.budget : {}),
227
+ steps: callCacheRaw.map((c, i) => mapCacheEntryToStep(c, i)),
228
+ }
229
+ }
230
+
197
231
  /**
198
232
  * 把 readRunSnapshot 返回的原始快照对象类型化为 WorkflowOverview(纯逻辑零 IO)。
199
233
  *
@@ -216,43 +250,12 @@ export function parseRunSnapshot(
216
250
  // NEW 格式(v === 'wf-run-v1' || 'wf-run-v2',v2 读取面形状兼容 v1)
217
251
  const v = snapshot.v
218
252
  if (v === 'wf-run-v1' || v === 'wf-run-v2') {
219
- const state = isRecord(snapshot.state) ? snapshot.state : {}
220
- const meta = isRecord(snapshot.meta) ? snapshot.meta : {}
221
- const spec = isRecord(snapshot.spec) ? snapshot.spec : {}
222
- const callsRaw = Array.isArray(state.calls) ? state.calls : []
223
- return {
224
- runId,
225
- stateFile,
226
- status: typeof state.status === 'string' ? state.status : '',
227
- version: v,
228
- script:
229
- typeof spec.scriptName === 'string'
230
- ? spec.scriptName
231
- : typeof spec.name === 'string'
232
- ? spec.name
233
- : undefined,
234
- startedAt: typeof meta.startedAt === 'string' ? meta.startedAt : undefined,
235
- completedAt: typeof meta.completedAt === 'string' ? meta.completedAt : undefined,
236
- reason: typeof state.reason === 'string' ? state.reason : undefined,
237
- error: typeof state.error === 'string' ? state.error : undefined,
238
- budget: mapBudget(isRecord(state.budget) ? state.budget : {}),
239
- steps: callsRaw.map((c, i) => mapCallToStep(c, i)),
240
- }
253
+ return parseNewRunSnapshot(snapshot, v, runId, stateFile)
241
254
  }
242
255
 
243
256
  // OLD 格式(无 v,有 callCache 数组或顶层 status 字符串)
244
257
  if (Array.isArray(snapshot.callCache) || typeof snapshot.status === 'string') {
245
- const callCacheRaw = Array.isArray(snapshot.callCache) ? snapshot.callCache : []
246
- return {
247
- runId,
248
- stateFile,
249
- status: typeof snapshot.status === 'string' ? snapshot.status : '',
250
- version: 'legacy',
251
- script: typeof snapshot.name === 'string' ? snapshot.name : undefined,
252
- startedAt: typeof snapshot.startedAt === 'string' ? snapshot.startedAt : undefined,
253
- budget: mapBudget(isRecord(snapshot.budget) ? snapshot.budget : {}),
254
- steps: callCacheRaw.map((c, i) => mapCacheEntryToStep(c, i)),
255
- }
258
+ return parseLegacyRunSnapshot(snapshot, runId, stateFile)
256
259
  }
257
260
 
258
261
  return null
@@ -260,71 +263,72 @@ export function parseRunSnapshot(
260
263
 
261
264
  // ---- renderWorkflowOverview(WorkflowOverview → 人类可读文本)----
262
265
 
263
- /**
264
- * 渲染 WorkflowOverview 为人类可读文本(纯逻辑零 IO)。
265
- *
266
- * 输出结构(IF-renderWorkflowOverview):
267
- * - 头行:`run: <runId> [status] (script?) started=<ISO> completed?=<ISO> reason?`
268
- * - budget 行:`budget: used=<tokens>tok $<cost> calls=<n> / max=<tokens>tok $<cost> <timeMs>ms`
269
- * (缺省字段省略,不输出 undefined 字面量)
270
- * - steps 块每行:` #<index> [status] <description> · model=<model> · <durationMs>ms ·
271
- * attempts=<n> · call=<sessionId截断> <sessionFile>`
272
- * sessionFile 缺则标 `(无 sessionFile,OLD 格式未持久化)`(TC-wf-step-sessionfile-link)
273
- * - error 行(如有 state.error)
274
- *
275
- * 每个 step 的 call sessionId/sessionFile 是 LLM 跳 outline/detail 的入口(m0 resolveSessionId
276
- * 三形态:sessionId/绝对路径/sa-id 均可深读)。多 run 场景由 doWorkflow 循环拼接多段(w6)。
277
- */
278
- export function renderWorkflowOverview(overview: WorkflowOverview): string {
279
- const lines: string[] = []
280
-
281
- // 头行
266
+ /** 头行:`run: <runId> [status] (script?) started=<ISO> completed?=<ISO> reason?`。 */
267
+ function renderOverviewHead(overview: WorkflowOverview): string {
282
268
  const headParts = [`run: ${overview.runId}`, `[${overview.status}]`]
283
269
  if (overview.script) headParts.push(`(${overview.script})`)
284
270
  if (overview.startedAt) headParts.push(`started=${overview.startedAt}`)
285
271
  if (overview.completedAt) headParts.push(`completed=${overview.completedAt}`)
286
272
  if (overview.reason) headParts.push(`reason=${overview.reason}`)
287
- lines.push(headParts.join(' '))
273
+ return headParts.join(' ')
274
+ }
275
+
276
+ /** budget max 段 `/ max=<tokens>tok $<cost> <timeMs>ms`(max 三字段全缺 → undefined 不拼)。 */
277
+ function renderBudgetMax(b: WorkflowBudget): string | undefined {
278
+ const hasMax = b.maxTokens !== undefined || b.maxCost !== undefined || b.maxTimeMs !== undefined
279
+ if (!hasMax) return undefined
280
+ const maxParts: string[] = ['/ max=']
281
+ if (b.maxTokens !== undefined) maxParts.push(`${b.maxTokens}tok`)
282
+ if (b.maxCost !== undefined) maxParts.push(`$${b.maxCost}`)
283
+ if (b.maxTimeMs !== undefined) maxParts.push(`${b.maxTimeMs}ms`)
284
+ return maxParts.join('')
285
+ }
288
286
 
289
- // budget 行(缺字段省略)
287
+ /** budget 行 `budget: used=<tokens>tok $<cost> calls=<n> / max=...`(缺省字段省略,不输出 undefined 字面量)。 */
288
+ function renderBudgetLine(b: WorkflowBudget): string {
290
289
  const budgetParts: string[] = ['budget:']
291
- if (overview.budget.usedTokens !== undefined) budgetParts.push(`used=${overview.budget.usedTokens}tok`)
292
- if (overview.budget.usedCost !== undefined) budgetParts.push(`$${overview.budget.usedCost}`)
293
- if (overview.budget.totalCallCount !== undefined) budgetParts.push(`calls=${overview.budget.totalCallCount}`)
294
- const hasMax =
295
- overview.budget.maxTokens !== undefined ||
296
- overview.budget.maxCost !== undefined ||
297
- overview.budget.maxTimeMs !== undefined
298
- if (hasMax) {
299
- const maxParts: string[] = ['/ max=']
300
- if (overview.budget.maxTokens !== undefined) maxParts.push(`${overview.budget.maxTokens}tok`)
301
- if (overview.budget.maxCost !== undefined) maxParts.push(`$${overview.budget.maxCost}`)
302
- if (overview.budget.maxTimeMs !== undefined) maxParts.push(`${overview.budget.maxTimeMs}ms`)
303
- budgetParts.push(maxParts.join(''))
304
- }
305
- lines.push(budgetParts.join(' '))
306
-
307
- // steps 块
308
- for (const step of overview.steps) {
309
- const stepParts = [` #${step.index}`, `[${step.status}]`]
310
- if (step.description) stepParts.push(step.description)
311
- const tail: string[] = []
312
- if (step.model) tail.push(`model=${step.model}`)
313
- if (step.durationMs !== undefined) tail.push(`${step.durationMs}ms`)
314
- if (step.attempts !== undefined) tail.push(`attempts=${step.attempts}`)
315
- if (step.sessionId) tail.push(`call=${truncate(step.sessionId, SESSION_ID_PREVIEW_MAX)}`)
316
- let line = stepParts.join(' ')
317
- if (tail.length > 0) line += ' · ' + tail.join(' · ')
318
- if (step.sessionFile) {
319
- line += ' ' + step.sessionFile
320
- } else {
321
- line += ' (无 sessionFile,OLD 格式未持久化)'
322
- }
323
- lines.push(line)
290
+ if (b.usedTokens !== undefined) budgetParts.push(`used=${b.usedTokens}tok`)
291
+ if (b.usedCost !== undefined) budgetParts.push(`$${b.usedCost}`)
292
+ if (b.totalCallCount !== undefined) budgetParts.push(`calls=${b.totalCallCount}`)
293
+ const maxPart = renderBudgetMax(b)
294
+ if (maxPart !== undefined) budgetParts.push(maxPart)
295
+ return budgetParts.join(' ')
296
+ }
297
+
298
+ /** steps 块单行;sessionFile 缺则标 `(无 sessionFile,OLD 格式未持久化)`(TC-wf-step-sessionfile-link)。 */
299
+ function renderStepLine(step: WorkflowStep): string {
300
+ const stepParts = [` #${step.index}`, `[${step.status}]`]
301
+ if (step.description) stepParts.push(step.description)
302
+ const tail: string[] = []
303
+ if (step.model) tail.push(`model=${step.model}`)
304
+ if (step.durationMs !== undefined) tail.push(`${step.durationMs}ms`)
305
+ if (step.attempts !== undefined) tail.push(`attempts=${step.attempts}`)
306
+ if (step.sessionId) tail.push(`call=${truncate(step.sessionId, SESSION_ID_PREVIEW_MAX)}`)
307
+ let line = stepParts.join(' ')
308
+ if (tail.length > 0) line += ' · ' + tail.join(' · ')
309
+ if (step.sessionFile) {
310
+ line += ' ' + step.sessionFile
311
+ } else {
312
+ line += ' (无 sessionFile,OLD 格式未持久化)'
324
313
  }
314
+ return line
315
+ }
325
316
 
326
- // error 行
317
+ /**
318
+ * 渲染 WorkflowOverview 为人类可读文本(纯逻辑零 IO)。
319
+ *
320
+ * 输出结构(IF-renderWorkflowOverview):头行 → budget 行 → steps 块每行
321
+ * (` #<index> [status] <description> · model=<model> · <durationMs>ms · attempts=<n> ·
322
+ * call=<sessionId截断> <sessionFile>`)→ error 行(如有 state.error)。
323
+ *
324
+ * 每个 step 的 call sessionId/sessionFile 是 LLM 跳 outline/detail 的入口(m0 resolveSessionId
325
+ * 三形态:sessionId/绝对路径/sa-id 均可深读)。多 run 场景由 doWorkflow 循环拼接多段(w6)。
326
+ */
327
+ export function renderWorkflowOverview(overview: WorkflowOverview): string {
328
+ const lines: string[] = []
329
+ lines.push(renderOverviewHead(overview))
330
+ lines.push(renderBudgetLine(overview.budget))
331
+ for (const step of overview.steps) lines.push(renderStepLine(step))
327
332
  if (overview.error) lines.push(`error: ${overview.error}`)
328
-
329
333
  return lines.join('\n')
330
334
  }
@@ -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
  }