@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.
- package/package.json +3 -2
- package/src/__tests__/cross-package-subagent-core.test.ts +321 -0
- package/src/__tests__/execution-tree.test.ts +1 -1
- package/src/__tests__/find.test.ts +18 -4
- package/src/__tests__/hash-provider.test.ts +2 -2
- package/src/__tests__/result.test.ts +341 -0
- package/src/__tests__/roots.test.ts +2 -2
- package/src/__tests__/session-command.test.ts +2 -2
- package/src/__tests__/subagents.test.ts +3 -3
- package/src/__tests__/tool-handler.test.ts +146 -9
- package/src/__tests__/toolcall.test.ts +13 -0
- package/src/__tests__/workflow.test.ts +59 -1
- package/src/core/family.ts +43 -17
- package/src/core/parser.ts +28 -17
- package/src/core/render.ts +59 -34
- package/src/core/toolcall.ts +105 -69
- package/src/core/workflow.ts +139 -135
- package/src/discovery/find.ts +120 -74
- package/src/discovery/subagents.ts +194 -110
- package/src/discovery/workflows.ts +44 -29
- package/src/index.ts +20 -5
- package/src/result-action.ts +244 -0
- package/src/tool-handler.ts +190 -101
- package/vitest.config.ts +17 -0
package/src/core/workflow.ts
CHANGED
|
@@ -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
|
|
119
|
-
const
|
|
120
|
-
|
|
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:
|
|
142
|
-
model:
|
|
143
|
-
thinkingLevel:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
292
|
-
if (
|
|
293
|
-
if (
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/discovery/find.ts
CHANGED
|
@@ -278,26 +278,38 @@ async function matchSubagentMetadata(
|
|
|
278
278
|
)
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
395
|
+
return { items: truncated ? matched.slice(0, limit) : matched, truncated }
|
|
396
|
+
}
|
|
379
397
|
|
|
380
|
-
|
|
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
|
|
439
|
+
return { matches, truncated }
|
|
394
440
|
}
|