@zhushanwen/pi-session-reader 0.1.0 → 0.2.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.
- package/package.json +1 -1
- package/src/__tests__/execution-tree.test.ts +792 -0
- package/src/__tests__/family.test.ts +150 -0
- package/src/__tests__/find.test.ts +342 -1
- package/src/__tests__/index.test.ts +50 -0
- package/src/__tests__/parser.test.ts +2 -1
- package/src/__tests__/real-data.ts +20 -0
- package/src/__tests__/render.test.ts +2 -1
- package/src/__tests__/subagents.test.ts +323 -14
- package/src/__tests__/tool-handler.test.ts +872 -5
- package/src/__tests__/tree.test.ts +20 -0
- package/src/__tests__/turns.test.ts +2 -1
- package/src/__tests__/workflow.test.ts +353 -0
- package/src/core/execution-tree.ts +517 -0
- package/src/core/family.ts +40 -0
- package/src/core/tree.ts +14 -5
- package/src/core/workflow.ts +330 -0
- package/src/discovery/find.ts +167 -20
- package/src/discovery/roots.ts +3 -2
- package/src/discovery/subagents.ts +113 -152
- package/src/discovery/workflows.ts +164 -0
- package/src/index.ts +22 -4
- package/src/tool-handler.ts +334 -21
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// workflow 概览解析与渲染(w5 新增,纯逻辑零 IO)
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// 本文件消费 discovery/workflows.ts 的 readRunSnapshot(返 unknown 原始快照对象),
|
|
6
|
+
// 把 unknown 类型化为 WorkflowOverview(NEW v='wf-run-v1'/'wf-run-v2' / OLD 无 v 双格式分支),
|
|
7
|
+
// 再渲染为人类可读文本。零 IO:parseRunSnapshot/renderWorkflowOverview 喂 mock 即可单测
|
|
8
|
+
//(w5 TC-wf-core-pure-logic,对齐 session-reader core/* 纯逻辑约定)。
|
|
9
|
+
//
|
|
10
|
+
// 不 import @zhushanwen/pi-subagent-workflow 的 RunSnapshot 类型——跨包类型耦合会使上游
|
|
11
|
+
// 升版连带编译期影响本扩展;且上游类型只描述 NEW,OLD 仍需自处理(TC-wf-snapshot-version-union)。
|
|
12
|
+
// session-reader 作为纯读取者,按字段存在性 + v 标记分支做「结构化快照」式解析,与上游解耦。
|
|
13
|
+
|
|
14
|
+
// ---- 类型守卫 helpers ----
|
|
15
|
+
|
|
16
|
+
/** unknown → Record<string, unknown> 守卫(非对象或 null → false)。 */
|
|
17
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
18
|
+
return typeof v === 'object' && v !== null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 字符串截断(超 max 加省略号)。概览预览用,全文走 detail。 */
|
|
22
|
+
function truncate(s: string, max: number): string {
|
|
23
|
+
return s.length <= max ? s : s.slice(0, max) + '…'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** contentPreview 截断长度(概览预览,全文走 detail)。 */
|
|
27
|
+
const CONTENT_PREVIEW_MAX = 120
|
|
28
|
+
/** step 行 call sessionId 显示截断长度(uuid 前缀段,LLM 可读)。 */
|
|
29
|
+
const SESSION_ID_PREVIEW_MAX = 12
|
|
30
|
+
|
|
31
|
+
// ---- 数据模型(对齐 m2 slice DM-WorkflowBudget/DM-WorkflowStep/DM-WorkflowOverview)----
|
|
32
|
+
|
|
33
|
+
/** wf-state budget 尽力提取(OLD budget 结构可能不全,缺字段 undefined)。 */
|
|
34
|
+
export interface WorkflowBudget {
|
|
35
|
+
/** NEW state.budget.usedTokens / OLD budget.usedTokens */
|
|
36
|
+
usedTokens?: number
|
|
37
|
+
/** NEW state.budget.usedCost */
|
|
38
|
+
usedCost?: number
|
|
39
|
+
/** NEW state.budget.totalCallCount */
|
|
40
|
+
totalCallCount?: number
|
|
41
|
+
/** NEW state.budget.maxTokens */
|
|
42
|
+
maxTokens?: number
|
|
43
|
+
/** NEW state.budget.maxCost */
|
|
44
|
+
maxCost?: number
|
|
45
|
+
/** NEW state.budget.maxTimeMs */
|
|
46
|
+
maxTimeMs?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** workflow 单步(NEW call / OLD callCache entry)。 */
|
|
50
|
+
export interface WorkflowStep {
|
|
51
|
+
/** NEW call.id / OLD callCache 顺序索引 */
|
|
52
|
+
index: number
|
|
53
|
+
/** NEW call.status / OLD 推测(有 sessionFile 或 content → 'done',否则 'pending') */
|
|
54
|
+
status: 'pending' | 'running' | 'done'
|
|
55
|
+
/** NEW call.opts.description */
|
|
56
|
+
description?: string
|
|
57
|
+
/** NEW call.opts.model */
|
|
58
|
+
model?: string
|
|
59
|
+
/** NEW call.opts.thinkingLevel */
|
|
60
|
+
thinkingLevel?: string
|
|
61
|
+
/** NEW call.attempts / OLD 无 → undefined */
|
|
62
|
+
attempts?: number
|
|
63
|
+
/** NEW call.result.durationMs / OLD value.result.durationMs */
|
|
64
|
+
durationMs?: number
|
|
65
|
+
/** call.sessionId 或 result.sessionId(LLM 跳 outline/detail 的 id 入口) */
|
|
66
|
+
sessionId?: string
|
|
67
|
+
/** call.sessionFile 或 result.sessionFile(LLM 跳 outline/detail 的绝对路径入口,OLD 多数为 undefined) */
|
|
68
|
+
sessionFile?: string
|
|
69
|
+
/** result.content 截断前 120 字(概览预览,全文走 detail) */
|
|
70
|
+
contentPreview?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** workflow run 概览(parseRunSnapshot 输出 / renderWorkflowOverview 输入)。 */
|
|
74
|
+
export interface WorkflowOverview {
|
|
75
|
+
/** WorkflowRef.runId 透传(与 family.workflows 对齐,不读 snapshot.runId 避免 OLD 不一致) */
|
|
76
|
+
runId: string
|
|
77
|
+
/** WorkflowRef.stateFile 透传 */
|
|
78
|
+
stateFile: string
|
|
79
|
+
/** NEW state.status / OLD 顶层 status */
|
|
80
|
+
status: string
|
|
81
|
+
/** 格式标记(渲染/调试用)。v2 读取面形状与 v1 一致(pi-subagent-workflow 8.x 一次性生命周期) */
|
|
82
|
+
version: 'wf-run-v1' | 'wf-run-v2' | 'legacy'
|
|
83
|
+
/** NEW spec.scriptName / spec.name / OLD name */
|
|
84
|
+
script?: string
|
|
85
|
+
/** NEW meta.startedAt / OLD startedAt(统一 string) */
|
|
86
|
+
startedAt?: string
|
|
87
|
+
/** NEW meta.completedAt */
|
|
88
|
+
completedAt?: string
|
|
89
|
+
/** NEW state.reason */
|
|
90
|
+
reason?: string
|
|
91
|
+
/** NEW state.error */
|
|
92
|
+
error?: string
|
|
93
|
+
budget: WorkflowBudget
|
|
94
|
+
steps: WorkflowStep[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- 解析 helpers(call/cache entry → WorkflowStep)----
|
|
98
|
+
|
|
99
|
+
/** 把 budget 原始对象收窄为 WorkflowBudget(各字段类型校验,非 number → undefined)。 */
|
|
100
|
+
function mapBudget(b: Record<string, unknown>): WorkflowBudget {
|
|
101
|
+
return {
|
|
102
|
+
usedTokens: typeof b.usedTokens === 'number' ? b.usedTokens : undefined,
|
|
103
|
+
usedCost: typeof b.usedCost === 'number' ? b.usedCost : undefined,
|
|
104
|
+
totalCallCount: typeof b.totalCallCount === 'number' ? b.totalCallCount : undefined,
|
|
105
|
+
maxTokens: typeof b.maxTokens === 'number' ? b.maxTokens : undefined,
|
|
106
|
+
maxCost: typeof b.maxCost === 'number' ? b.maxCost : undefined,
|
|
107
|
+
maxTimeMs: typeof b.maxTimeMs === 'number' ? b.maxTimeMs : undefined,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** NEW state.calls[] 单项 → WorkflowStep。sessionFile/sessionId 顶层优先回退 result。 */
|
|
112
|
+
function mapCallToStep(call: unknown, fallbackIndex: number): WorkflowStep {
|
|
113
|
+
if (!isRecord(call)) return { index: fallbackIndex, status: 'pending' }
|
|
114
|
+
const opts = isRecord(call.opts) ? call.opts : {}
|
|
115
|
+
const result = isRecord(call.result) ? call.result : {}
|
|
116
|
+
|
|
117
|
+
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
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
index,
|
|
140
|
+
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,
|
|
144
|
+
attempts: typeof call.attempts === 'number' ? call.attempts : undefined,
|
|
145
|
+
durationMs: typeof result.durationMs === 'number' ? result.durationMs : undefined,
|
|
146
|
+
sessionId,
|
|
147
|
+
sessionFile,
|
|
148
|
+
contentPreview: content !== undefined ? truncate(content, CONTENT_PREVIEW_MAX) : undefined,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** OLD callCache[] 单项 {key, value} → WorkflowStep。status 推测,content 优先 result 回退 value。 */
|
|
153
|
+
function mapCacheEntryToStep(entry: unknown, index: number): WorkflowStep {
|
|
154
|
+
if (!isRecord(entry)) return { index, status: 'pending' }
|
|
155
|
+
const value = isRecord(entry.value) ? entry.value : {}
|
|
156
|
+
const result = isRecord(value.result) ? value.result : {}
|
|
157
|
+
|
|
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
|
|
171
|
+
// 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
|
|
178
|
+
// OLD 无 status 字段:有 sessionFile 或非空 content → done,否则 pending
|
|
179
|
+
//(空 content 如 wf-skip-ok 的 '' 不算完成标志,对齐 TC-w5-parse-old expected status='pending';
|
|
180
|
+
// content 仍提取为 contentPreview='')
|
|
181
|
+
const hasContent = content !== undefined && content.length > 0
|
|
182
|
+
const status: WorkflowStep['status'] =
|
|
183
|
+
sessionFile !== undefined || hasContent ? 'done' : 'pending'
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
index,
|
|
187
|
+
status,
|
|
188
|
+
durationMs: typeof result.durationMs === 'number' ? result.durationMs : undefined,
|
|
189
|
+
sessionId,
|
|
190
|
+
sessionFile,
|
|
191
|
+
contentPreview: content !== undefined ? truncate(content, CONTENT_PREVIEW_MAX) : undefined,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- parseRunSnapshot(unknown → WorkflowOverview | null)----
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 把 readRunSnapshot 返回的原始快照对象类型化为 WorkflowOverview(纯逻辑零 IO)。
|
|
199
|
+
*
|
|
200
|
+
* 分支(C-parserunsnapshot-dualformat,TC-wf-snapshot-version-union):
|
|
201
|
+
* - 非对象 → null(调用方跳过,ES-wf-snapshot-unparseable)
|
|
202
|
+
* - NEW (snapshot.v === 'wf-run-v1' 或 'wf-run-v2',读取面形状一致):state.* / meta.* / spec.*
|
|
203
|
+
* - OLD (无 v,有 callCache 数组或顶层 status):顶层 status/budget/startedAt + callCache
|
|
204
|
+
* - 既非 NEW 也非 OLD → null(未来版本(如 wf-run-v3)/ 异构内容)
|
|
205
|
+
*
|
|
206
|
+
* runId/stateFile 透传参数(不读 snapshot.runId,保证与 family.workflows 一致,避免 OLD 顶层
|
|
207
|
+
* runId 可信度低的不一致)。零 any(全程 typeof/Array.isArray/isRecord 守卫收窄)。
|
|
208
|
+
*/
|
|
209
|
+
export function parseRunSnapshot(
|
|
210
|
+
snapshot: unknown,
|
|
211
|
+
runId: string,
|
|
212
|
+
stateFile: string,
|
|
213
|
+
): WorkflowOverview | null {
|
|
214
|
+
if (!isRecord(snapshot)) return null
|
|
215
|
+
|
|
216
|
+
// NEW 格式(v === 'wf-run-v1' || 'wf-run-v2',v2 读取面形状兼容 v1)
|
|
217
|
+
const v = snapshot.v
|
|
218
|
+
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
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// OLD 格式(无 v,有 callCache 数组或顶层 status 字符串)
|
|
244
|
+
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
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return null
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---- renderWorkflowOverview(WorkflowOverview → 人类可读文本)----
|
|
262
|
+
|
|
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
|
+
// 头行
|
|
282
|
+
const headParts = [`run: ${overview.runId}`, `[${overview.status}]`]
|
|
283
|
+
if (overview.script) headParts.push(`(${overview.script})`)
|
|
284
|
+
if (overview.startedAt) headParts.push(`started=${overview.startedAt}`)
|
|
285
|
+
if (overview.completedAt) headParts.push(`completed=${overview.completedAt}`)
|
|
286
|
+
if (overview.reason) headParts.push(`reason=${overview.reason}`)
|
|
287
|
+
lines.push(headParts.join(' '))
|
|
288
|
+
|
|
289
|
+
// budget 行(缺字段省略)
|
|
290
|
+
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)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// error 行
|
|
327
|
+
if (overview.error) lines.push(`error: ${overview.error}`)
|
|
328
|
+
|
|
329
|
+
return lines.join('\n')
|
|
330
|
+
}
|
package/src/discovery/find.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createReadStream, type ReadStream } from 'node:fs'
|
|
2
2
|
import { open, type FileHandle } from 'node:fs/promises'
|
|
3
3
|
import { createInterface } from 'node:readline'
|
|
4
|
+
import { basename } from 'node:path'
|
|
4
5
|
import type { SessionRef } from '../core/family.js'
|
|
5
|
-
import { listMainSessions, type SessionFileMeta } from './roots.js'
|
|
6
|
+
import { listMainSessions, listSubagentSessions, type SessionFileMeta } from './roots.js'
|
|
7
|
+
import { listRecordManifests, extractSessionIdFromFilename, type RecordManifest } from './subagents.js'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* M2 discovery 发现层:按 query 定位 session(design §3.3 D-3 + §3.4 find action)。
|
|
@@ -19,7 +21,12 @@ import { listMainSessions, type SessionFileMeta } from './roots.js'
|
|
|
19
21
|
* agentDir 注入:同 roots.ts,零 pi 依赖(仅 node:fs + 相对 import M1 core)。
|
|
20
22
|
*/
|
|
21
23
|
|
|
24
|
+
/** 候选来源标记(DM1 必填):main = agentDir/sessions/、subagent = agentDir/subagents/。 */
|
|
25
|
+
export type SessionSource = 'main' | 'subagent'
|
|
26
|
+
|
|
22
27
|
export interface MatchedSession extends SessionRef {
|
|
28
|
+
/** 候选来源(DM1 必填标记):main 或 subagent,按文件所在目录标记 */
|
|
29
|
+
source: SessionSource
|
|
23
30
|
/** 首条 user message text 截 80 字符(从全文读,不只首行) */
|
|
24
31
|
firstMessagePreview?: string
|
|
25
32
|
}
|
|
@@ -153,6 +160,8 @@ function looksLikeUuidFragment(query: string): boolean {
|
|
|
153
160
|
interface Candidate {
|
|
154
161
|
meta: SessionFileMeta
|
|
155
162
|
ref: SessionRef
|
|
163
|
+
/** 候选来源(透传到 MatchedSession.source,DM1) */
|
|
164
|
+
source: SessionSource
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
interface Matched extends Candidate {
|
|
@@ -160,6 +169,115 @@ interface Matched extends Candidate {
|
|
|
160
169
|
preview?: string
|
|
161
170
|
}
|
|
162
171
|
|
|
172
|
+
// ============================================================
|
|
173
|
+
// U5:subagent task/slug/agentName 匹配(manifest 索引 + P-fallback identity 回退)
|
|
174
|
+
// ============================================================
|
|
175
|
+
|
|
176
|
+
/** P-fallback 尾行 identity 读取窗口(同 subagents.ts,task 文本可达数 KB)。 */
|
|
177
|
+
const TAIL_READ_BYTES = 65536
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 读 subagent 文件尾部(最后 64KB)找 subagent-identity entry,返回 task/slug/agent。
|
|
181
|
+
*
|
|
182
|
+
* find 的 P-fallback 路径(场景 A:subagent 无 manifest,本机 11.5%):manifest 索引未命中时
|
|
183
|
+
* 读尾行 identity 取 task/slug/agent 做 query 子串匹配。与 subagents.ts 的 readTailIdentity
|
|
184
|
+
* 同源(64KB 窗口 + lastIndexOf 定位),但是 find 专用最小版(只取 task/slug/agent,不要
|
|
185
|
+
* rootSessionId——find 候选已有 header.id)。不导出,不碰 subagents.ts(w3 冻结)。
|
|
186
|
+
*/
|
|
187
|
+
async function readTailIdentityForMatch(
|
|
188
|
+
path: string,
|
|
189
|
+
size: number,
|
|
190
|
+
): Promise<{ task?: string; slug?: string; agent?: string } | undefined> {
|
|
191
|
+
if (size === 0) return undefined
|
|
192
|
+
let fh: FileHandle | undefined
|
|
193
|
+
try {
|
|
194
|
+
fh = await open(path, 'r')
|
|
195
|
+
const len = Math.min(TAIL_READ_BYTES, size)
|
|
196
|
+
const buf = Buffer.alloc(len)
|
|
197
|
+
await fh.read(buf, 0, len, Math.max(0, size - len))
|
|
198
|
+
const text = buf.toString('utf8')
|
|
199
|
+
const idx = text.lastIndexOf('subagent-identity')
|
|
200
|
+
if (idx < 0) return undefined
|
|
201
|
+
const lineStartSearch = text.lastIndexOf('\n', idx)
|
|
202
|
+
if (lineStartSearch < 0 && size > len) return undefined
|
|
203
|
+
const start = lineStartSearch < 0 ? 0 : lineStartSearch + 1
|
|
204
|
+
let end = text.indexOf('\n', idx)
|
|
205
|
+
if (end < 0) end = text.length
|
|
206
|
+
const line = text.slice(start, end)
|
|
207
|
+
let raw: unknown
|
|
208
|
+
try {
|
|
209
|
+
raw = JSON.parse(line)
|
|
210
|
+
} catch {
|
|
211
|
+
return undefined
|
|
212
|
+
}
|
|
213
|
+
const data = (raw as Record<string, unknown> | undefined)?.data as
|
|
214
|
+
| Record<string, unknown>
|
|
215
|
+
| undefined
|
|
216
|
+
if (!data) return undefined
|
|
217
|
+
return {
|
|
218
|
+
task: typeof data.task === 'string' ? data.task : undefined,
|
|
219
|
+
slug: typeof data.slug === 'string' ? data.slug : undefined,
|
|
220
|
+
agent: typeof data.agent === 'string' ? data.agent : undefined,
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
return undefined
|
|
224
|
+
} finally {
|
|
225
|
+
await fh?.close().catch(() => {})
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 建 sessionId→RecordManifest 索引(U5:subagent task/slug/agentName 匹配用)。
|
|
231
|
+
*
|
|
232
|
+
* listRecordManifests 一次性读全部 manifest(json 小,几百字节),用 extractSessionIdFromFilename
|
|
233
|
+
* 从 manifest.sessionFile 文件名提取 sessionId 作 key(与候选 header.id 同源真实 id)。无 subagent
|
|
234
|
+
* 候选时跳过(避免无谓 IO——TC-find-manifest-index 的 O(1) 查表前提)。
|
|
235
|
+
*/
|
|
236
|
+
async function buildManifestIndex(
|
|
237
|
+
agentDir: string,
|
|
238
|
+
hasSubagentCandidates: boolean,
|
|
239
|
+
): Promise<Map<string, RecordManifest>> {
|
|
240
|
+
if (!hasSubagentCandidates) return new Map()
|
|
241
|
+
const manifests = await listRecordManifests(agentDir)
|
|
242
|
+
const index = new Map<string, RecordManifest>()
|
|
243
|
+
for (const m of manifests) {
|
|
244
|
+
const sid = extractSessionIdFromFilename(basename(m.sessionFile))
|
|
245
|
+
if (sid) index.set(sid, m)
|
|
246
|
+
}
|
|
247
|
+
return index
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* subagent 候选元数据匹配(U5):manifest 命中走 task/slug/agentName 子串;索引未命中(P-fallback,
|
|
252
|
+
* 场景 A)读尾行 identity 回退匹配 task/slug/agent。
|
|
253
|
+
*
|
|
254
|
+
* manifest 命中但不匹配时不再回退 identity——manifest 是权威主表,task/slug/agentName 即其提供,
|
|
255
|
+
* identity 同源数据回退无新信息(探针 manifest 20/20 全有 task/slug)。manifest 缺某字段(旧 manifest)
|
|
256
|
+
* 时该字段 undefined,includes 自然 false,不影响其他字段。
|
|
257
|
+
*/
|
|
258
|
+
async function matchSubagentMetadata(
|
|
259
|
+
candidate: Candidate,
|
|
260
|
+
query: string,
|
|
261
|
+
manifestIndex: Map<string, RecordManifest>,
|
|
262
|
+
): Promise<boolean> {
|
|
263
|
+
const manifest = manifestIndex.get(candidate.ref.sessionId)
|
|
264
|
+
if (manifest) {
|
|
265
|
+
return (
|
|
266
|
+
(manifest.task?.includes(query) ?? false) ||
|
|
267
|
+
(manifest.slug?.includes(query) ?? false) ||
|
|
268
|
+
(manifest.agentName?.includes(query) ?? false)
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
// P-fallback:manifest 索引未命中 → 读尾行 identity 回退
|
|
272
|
+
const ident = await readTailIdentityForMatch(candidate.meta.path, candidate.meta.size)
|
|
273
|
+
if (!ident) return false
|
|
274
|
+
return (
|
|
275
|
+
(ident.task?.includes(query) ?? false) ||
|
|
276
|
+
(ident.slug?.includes(query) ?? false) ||
|
|
277
|
+
(ident.agent?.includes(query) ?? false)
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
163
281
|
/**
|
|
164
282
|
* 按 query 找 session(接口冻结,design §3.4 find action)。
|
|
165
283
|
*
|
|
@@ -170,29 +288,46 @@ interface Matched extends Candidate {
|
|
|
170
288
|
export async function findSessions(
|
|
171
289
|
query: string,
|
|
172
290
|
agentDir: string,
|
|
173
|
-
opts?: { cwd?: string; limit?: number },
|
|
291
|
+
opts?: { cwd?: string; limit?: number; source?: SessionSource },
|
|
174
292
|
): Promise<{ matches: MatchedSession[]; truncated: boolean }> {
|
|
175
293
|
const limit = opts?.limit ?? DEFAULT_LIMIT
|
|
176
294
|
const cwdFilter = opts?.cwd
|
|
177
|
-
const
|
|
295
|
+
const sourceFilter = opts?.source
|
|
296
|
+
|
|
297
|
+
// 0. 按来源收集文件列表(source 过滤在文件列表层:source==='main' 只扫 sessions/、
|
|
298
|
+
// 'subagent' 只扫 subagents/、undefined 两者合并——决策二性能意图:不扫被过滤目录)。
|
|
299
|
+
// 两路目录扫描相互独立 → Promise.allSettled(AGENTS.md:独立请求用 allSettled),
|
|
300
|
+
// 任一目录不存在(roots.ts 静默返回空数组)不影响另一路。
|
|
301
|
+
const sources: SessionSource[] =
|
|
302
|
+
sourceFilter === undefined ? ['main', 'subagent'] : [sourceFilter]
|
|
303
|
+
const listResults = await Promise.allSettled(
|
|
304
|
+
sources.map(async (src) => ({
|
|
305
|
+
src,
|
|
306
|
+
files: await (src === 'main' ? listMainSessions(agentDir) : listSubagentSessions(agentDir)),
|
|
307
|
+
})),
|
|
308
|
+
)
|
|
178
309
|
|
|
179
|
-
// 1.
|
|
310
|
+
// 1. 首行扫描建候选 SessionRef(cwd 过滤在此应用;按来源打 source 标记)
|
|
180
311
|
const candidates: Candidate[] = []
|
|
181
|
-
for (const
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
312
|
+
for (const r of listResults) {
|
|
313
|
+
if (r.status !== 'fulfilled') continue
|
|
314
|
+
const { src, files } = r.value
|
|
315
|
+
for (const meta of files) {
|
|
316
|
+
const headerLine = await readFirstLine(meta.path)
|
|
317
|
+
const header = parseHeader(headerLine)
|
|
318
|
+
if (!header) continue // 非 session 文件/坏 header → 跳过
|
|
319
|
+
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 })
|
|
193
330
|
}
|
|
194
|
-
if (header.parentSession) ref.parentSession = header.parentSession
|
|
195
|
-
candidates.push({ meta, ref })
|
|
196
331
|
}
|
|
197
332
|
|
|
198
333
|
// 2. 匹配
|
|
@@ -211,9 +346,21 @@ export async function findSessions(
|
|
|
211
346
|
// query 像 uuid 片段但无匹配 → uuid 写错的可能性高,不对全部候选深读首消息
|
|
212
347
|
matched = []
|
|
213
348
|
} else {
|
|
214
|
-
//
|
|
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
|
+
)
|
|
215
358
|
const keywordHits: Matched[] = []
|
|
216
359
|
for (const c of candidates) {
|
|
360
|
+
if (c.source === 'subagent' && (await matchSubagentMetadata(c, query, manifestIndex))) {
|
|
361
|
+
keywordHits.push({ ...c })
|
|
362
|
+
continue
|
|
363
|
+
}
|
|
217
364
|
const text = await readFirstUserMessageText(c.meta.path)
|
|
218
365
|
if (text && text.includes(query)) {
|
|
219
366
|
keywordHits.push({ ...c, preview: text.slice(0, PREVIEW_MAX) })
|
|
@@ -233,7 +380,7 @@ export async function findSessions(
|
|
|
233
380
|
// 5. 填 firstMessagePreview(recent/uuid 路径未读,这里对最终 limit 个补读——最多 limit 个 IO)
|
|
234
381
|
const result: MatchedSession[] = []
|
|
235
382
|
for (const m of sliced) {
|
|
236
|
-
const out: MatchedSession = { ...m.ref }
|
|
383
|
+
const out: MatchedSession = { ...m.ref, source: m.source }
|
|
237
384
|
if (m.preview !== undefined) {
|
|
238
385
|
out.firstMessagePreview = m.preview
|
|
239
386
|
} else {
|
package/src/discovery/roots.ts
CHANGED
|
@@ -19,8 +19,9 @@ export interface SessionFileMeta {
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* main sessions 扫描时整体跳过的子目录名。
|
|
22
|
-
* `workflow-state` 目录存放 workflow 运行状态文件(wf-*.jsonl,首行 `{"v":"wf-run-v1"...}
|
|
23
|
-
*
|
|
22
|
+
* `workflow-state` 目录存放 workflow 运行状态文件(wf-*.jsonl,首行 `{"v":"wf-run-v1"|"wf-run-v2"...}`,
|
|
23
|
+
* 版本随 subagent-workflow 快照格式演进,读取侧 v1/v2 兼容),非 session 文件——属 family 腿
|
|
24
|
+
* 独立处理(design §3.3 D-7),扫描 main sessions 时排除,
|
|
24
25
|
* 否则会把 wf 文件误收为 session(且 find.ts 读其首行 header 时会因 type≠session 被丢弃,
|
|
25
26
|
* 在此排除可避免这批无效首行扫描)。
|
|
26
27
|
*/
|