@zhushanwen/pi-session-reader 0.5.1 → 0.6.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 +5 -2
- package/src/__tests__/cross-package-subagent-core.test.ts +6 -5
- package/src/__tests__/family.test.ts +0 -7
- package/src/__tests__/find.test.ts +5 -5
- package/src/__tests__/real-data.ts +1 -1
- package/src/__tests__/render.test.ts +36 -1
- package/src/__tests__/result.test.ts +20 -23
- package/src/__tests__/roots.test.ts +46 -80
- package/src/__tests__/session-command.test.ts +23 -0
- package/src/__tests__/subagents.test.ts +4 -3
- package/src/__tests__/tool-handler.test.ts +419 -141
- package/src/__tests__/workflow.test.ts +1 -7
- package/src/core/execution-tree.ts +1 -0
- package/src/core/family.ts +12 -9
- package/src/core/render.ts +40 -12
- package/src/core/workflow.ts +7 -20
- package/src/discovery/find.ts +42 -134
- package/src/discovery/roots.ts +16 -87
- package/src/discovery/session-header.ts +103 -0
- package/src/discovery/subagents.ts +23 -94
- package/src/doctor.ts +11 -77
- package/src/extract.ts +6 -19
- package/src/handler-utils.ts +28 -5
- package/src/no-match.ts +3 -2
- package/src/result-action.ts +28 -33
- package/src/search-across.ts +5 -4
- package/src/tool-handler.ts +151 -176
- package/src/tui/hash-provider.ts +7 -3
- package/src/tui/session-command.ts +22 -10
|
@@ -30,7 +30,6 @@ const NEW_SNAPSHOT_FIXTURE = {
|
|
|
30
30
|
opts: {
|
|
31
31
|
prompt: 'Reply with exactly: PROBE-OK',
|
|
32
32
|
model: 'deepseek-router/ds-pro',
|
|
33
|
-
thinkingLevel: 'high',
|
|
34
33
|
description: 'step-0',
|
|
35
34
|
},
|
|
36
35
|
status: 'done',
|
|
@@ -64,7 +63,7 @@ const V2_SNAPSHOT_FIXTURE = {
|
|
|
64
63
|
calls: [
|
|
65
64
|
{
|
|
66
65
|
id: 0,
|
|
67
|
-
opts: { prompt: 'task 0', model: 'default',
|
|
66
|
+
opts: { prompt: 'task 0', model: 'default', description: 'v2-step-0' },
|
|
68
67
|
status: 'done',
|
|
69
68
|
attempts: 1,
|
|
70
69
|
sessionId: '019v2a',
|
|
@@ -126,12 +125,10 @@ describe('parseRunSnapshot', () => {
|
|
|
126
125
|
expect(step.status).toBe('done')
|
|
127
126
|
expect(step.description).toBe('step-0')
|
|
128
127
|
expect(step.model).toBe('deepseek-router/ds-pro')
|
|
129
|
-
expect(step.thinkingLevel).toBe('high')
|
|
130
128
|
expect(step.attempts).toBe(1)
|
|
131
129
|
expect(step.durationMs).toBe(1234)
|
|
132
130
|
expect(step.sessionId).toBe('019xxx')
|
|
133
131
|
expect(step.sessionFile).toBe('/abs/session.jsonl')
|
|
134
|
-
expect(step.contentPreview).toBe('PROBE-OK')
|
|
135
132
|
})
|
|
136
133
|
|
|
137
134
|
it('TC-w5-parse-new-v2:v2 快照(pi-subagent-workflow 8.x 写入)解析非 null,calls sessionFile 读出', () => {
|
|
@@ -148,7 +145,6 @@ describe('parseRunSnapshot', () => {
|
|
|
148
145
|
expect(overview!.steps[0].description).toBe('v2-step-0')
|
|
149
146
|
expect(overview!.steps[1].sessionFile).toBe('/abs/v2-call1.jsonl')
|
|
150
147
|
expect(overview!.steps[1].sessionId).toBe('019v2b')
|
|
151
|
-
expect(overview!.steps[1].contentPreview).toBe('OK')
|
|
152
148
|
})
|
|
153
149
|
|
|
154
150
|
it('TC-w5-parse-new-status-unknown:NEW call.status 非 done/running/pending(如 failed)→ step.status=pending', () => {
|
|
@@ -174,7 +170,6 @@ describe('parseRunSnapshot', () => {
|
|
|
174
170
|
expect(step.index).toBe(3)
|
|
175
171
|
expect(step.status).toBe('pending')
|
|
176
172
|
expect(step.description).toBe('step-3')
|
|
177
|
-
expect(step.contentPreview).toBe('partial')
|
|
178
173
|
expect(step.durationMs).toBe(9)
|
|
179
174
|
})
|
|
180
175
|
|
|
@@ -193,7 +188,6 @@ describe('parseRunSnapshot', () => {
|
|
|
193
188
|
expect(step.index).toBe(0) // callCache 顺序索引
|
|
194
189
|
expect(step.status).toBe('pending') // content='' 空串不算完成标志 → pending
|
|
195
190
|
expect(step.sessionFile).toBeUndefined() // OLD 未持久化
|
|
196
|
-
expect(step.contentPreview).toBe('') // value.content='' 仍提取
|
|
197
191
|
})
|
|
198
192
|
|
|
199
193
|
it('TC-w5-parse-corrupt-nonobject:非对象输入(null/undefined/string/number/array)返回 null', () => {
|
|
@@ -59,6 +59,7 @@ export interface ExecutionTreeNode {
|
|
|
59
59
|
agentName?: string
|
|
60
60
|
slug?: string
|
|
61
61
|
task?: string
|
|
62
|
+
/** 仅 details 可见,不进文本渲染(formatExecutionTreeText 渲染 status/slug/agentName/task,与 SubagentRef.model 同口径) */
|
|
62
63
|
model?: string
|
|
63
64
|
/** subagent 终态 completed/failed/running(manifest.status) */
|
|
64
65
|
status?: string
|
package/src/core/family.ts
CHANGED
|
@@ -6,11 +6,14 @@ import type { Entry } from './parser.js'
|
|
|
6
6
|
* M1 范围:纯逻辑层。buildFamilyIndex 接收已读入的 entry 数组(不做文件 IO),
|
|
7
7
|
* 文件扫描/首行读取归 M2 discovery 层(roots.ts/find.ts/subagents.ts)。
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* 占位约定(原「M2 回填补全」双阶段约定已随 enrichRefs 删除退役,ext-simplify-04 U4/E2
|
|
10
|
+
* ——回填的 fileName/cwd 零文本读者;SessionRef 类型与 find 路径共享,find 侧两字段有
|
|
11
|
+
* 真实消费者,字段本身保留):
|
|
12
|
+
* - SessionRef.fileName / subagent cwd:family 路径恒为占位空串(session header 推不出
|
|
13
|
+
* 路径 / identity entry 无 cwd),不再回填
|
|
11
14
|
* - SessionRef.mtime/sizeBytes:从 fileStats 取(M1 key=sessionId),取不到为 0
|
|
12
15
|
* - SubagentRef.sessionId:identity entry 不含 subagent session 的 id → 占位用 entry.id
|
|
13
|
-
*
|
|
16
|
+
* (M2 buildFamilyFromFs 以 header.id 替换为真实 id)
|
|
14
17
|
* - fileStats 的 key:sessionId(M2 实现沿用此 key,cleanedUp 按 sessionId 命中),逻辑通用
|
|
15
18
|
*
|
|
16
19
|
* 隔代关联规则(design §3.3 D-7 Q1)见 resolveFamily 注释。
|
|
@@ -35,11 +38,11 @@ export interface SubagentRef extends SessionRef {
|
|
|
35
38
|
task?: string
|
|
36
39
|
/** agent 类型名(manifest.agentName,P-fallback 时 identity.data.agent;同语义异名) */
|
|
37
40
|
agentName?: string
|
|
38
|
-
/** 模型 id(仅 manifest 有;P-fallback 时 undefined) */
|
|
41
|
+
/** 模型 id(仅 manifest 有;P-fallback 时 undefined)。仅 details 可见,不进文本渲染(D2②:展示面只取 status/agentName/task) */
|
|
39
42
|
model?: string
|
|
40
43
|
/** 终态 completed/failed/running(仅 manifest 有;P-fallback 时 undefined) */
|
|
41
44
|
status?: string
|
|
42
|
-
/** subagent session.jsonl 绝对路径(manifest.sessionFile 或 alive 文件 meta.path
|
|
45
|
+
/** subagent session.jsonl 绝对路径(manifest.sessionFile 或 alive 文件 meta.path)。仅 details 可见,不进文本渲染 */
|
|
43
46
|
sessionFile?: string
|
|
44
47
|
}
|
|
45
48
|
|
|
@@ -118,16 +121,16 @@ function isSubagentIdentityData(v: unknown): v is SubagentIdentityData {
|
|
|
118
121
|
* 文件名格式 `<timestamp>_<sessionId>.jsonl`,故路径字符串含父 sessionId。
|
|
119
122
|
* 遍历已知 sessionId 做子串匹配反查。
|
|
120
123
|
*
|
|
121
|
-
* 兼容 parentSession 直接就是 sessionId 的简化场景(测试 fixture 常用)。
|
|
122
124
|
* 假设 sessionId 互不为子串(pi 用 UUID,满足);M2 可优化为 fileName→sessionId
|
|
123
125
|
* 索引反查(O(1)),当前遍历 O(N),家族索引文件数通常几十到几百,可接受。
|
|
126
|
+
*(原「parentSession 直接是 sessionId」快路径已删除,ext-simplify-04 A4——生产数据
|
|
127
|
+
* 恒为文件路径,该分支只服务测试 fixture 简化形态。)
|
|
124
128
|
*/
|
|
125
129
|
function resolveParentSessionId(
|
|
126
130
|
parentSession: string | undefined,
|
|
127
131
|
byId: Map<string, SessionRef>,
|
|
128
132
|
): string | null {
|
|
129
133
|
if (!parentSession) return null
|
|
130
|
-
if (byId.has(parentSession)) return parentSession // 直接是 sessionId(简化场景)
|
|
131
134
|
for (const sid of byId.keys()) {
|
|
132
135
|
if (parentSession.includes(sid)) return sid
|
|
133
136
|
}
|
|
@@ -144,7 +147,7 @@ function buildById(
|
|
|
144
147
|
const stat = fileStats.get(h.id)
|
|
145
148
|
const ref: SessionRef = {
|
|
146
149
|
sessionId: h.id,
|
|
147
|
-
fileName: '', //
|
|
150
|
+
fileName: '', // 占位空串:family 路径不回填(enrichRefs 已删除,ext-simplify-04 U4)
|
|
148
151
|
mtime: stat?.mtime ?? 0,
|
|
149
152
|
sizeBytes: stat?.size ?? 0,
|
|
150
153
|
cwd: h.cwd ?? '',
|
|
@@ -198,7 +201,7 @@ function buildSubagentsByRoot(
|
|
|
198
201
|
fileName: '', // M1 占位
|
|
199
202
|
mtime: stat?.mtime ?? 0,
|
|
200
203
|
sizeBytes: stat?.size ?? 0,
|
|
201
|
-
cwd: '', // identity entry 无 cwd;
|
|
204
|
+
cwd: '', // identity entry 无 cwd;family 路径不回填(enrichRefs 已删除,ext-simplify-04 U4)
|
|
202
205
|
// fileStats key=sessionId(buildFamilyFromFs 以 header.id 写入),cleanedUp 按 sessionId 命中
|
|
203
206
|
cleanedUp: !fileStats.has(ident.id),
|
|
204
207
|
// U4 富字段:从 identity data 读(manifest 主/P-fallback 由 buildFamilyFromFs 组装时决定)
|
package/src/core/render.ts
CHANGED
|
@@ -17,7 +17,8 @@ const ASSISTANT_BRIEF_MAX_CHARS = 80
|
|
|
17
17
|
const ENTRY_BRIEF_MAX_CHARS = 100
|
|
18
18
|
/** turn 索引显示宽度(T013 三位补零)。 */
|
|
19
19
|
const TURN_INDEX_WIDTH = 3
|
|
20
|
-
/** outline 默认 token 预算(OutlineOptions.budget
|
|
20
|
+
/** outline 默认 token 预算(OutlineOptions.budget 缺省值)。生产路径(doOutline/doExport)
|
|
21
|
+
* 唯一取值:outline 是设计定档的 ~1500 token 全貌 TOC,无按调用方调节的需求。 */
|
|
21
22
|
const OUTLINE_DEFAULT_BUDGET_TOKENS = 2000
|
|
22
23
|
/** token 估算换算基数(chars/4,与 tokenEstimate 口径一致)。 */
|
|
23
24
|
const CHARS_PER_TOKEN = 4
|
|
@@ -55,6 +56,11 @@ export interface TurnBrief {
|
|
|
55
56
|
|
|
56
57
|
export interface OutlineResult {
|
|
57
58
|
turns: TurnBrief[]
|
|
59
|
+
/**
|
|
60
|
+
* 渲染行(E7/D3 行渲染统一):formatLine 产出、降级与总预算截断后的最终展示行——
|
|
61
|
+
* 预算度量与工具层展示共用同一份(度量 = 展示 by construction),展示侧不得重建行格式。
|
|
62
|
+
*/
|
|
63
|
+
lines: string[]
|
|
58
64
|
/** skippedLines:JSON 解析失败/缺结构字段的行数(parser 检测,工具层覆盖精确值;render 层无 ParseResult 恒 0) */
|
|
59
65
|
stats: { totalTurns: number; totalEntries: number; totalBytes: number; parsedBytes: number; skippedLines: number }
|
|
60
66
|
/** chars / 4 近似(与 design P-outline 口径一致) */
|
|
@@ -64,7 +70,9 @@ export interface OutlineResult {
|
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
export interface OutlineOptions {
|
|
67
|
-
/**
|
|
73
|
+
/** 测试注入缝(E10 定性):非模型可见参数——session_read schema 无 budget 入参,模型
|
|
74
|
+
* 无法也无需调节;存在动机 = 测试注入小预算触发降级档用例(砍 assistantBrief / 骨架档)。
|
|
75
|
+
* 生产路径不传,走 OUTLINE_DEFAULT_BUDGET_TOKENS 默认。 */
|
|
68
76
|
budget?: number
|
|
69
77
|
allBranches?: boolean
|
|
70
78
|
/** 默认 turn;entry = 每 entry 一行不聚合(D-1 兜底,坏 session 调试) */
|
|
@@ -101,6 +109,26 @@ function blockThinking(block: ContentBlock): string {
|
|
|
101
109
|
return ''
|
|
102
110
|
}
|
|
103
111
|
|
|
112
|
+
/**
|
|
113
|
+
* E11 共享核:白名单提取 message.content 中 `type==='text'` 块的 text 字段(块 → string[])。
|
|
114
|
+
* string content 直取为单元素数组;非 string 非数组返回空数组。join 语义(''/'\n'/' ')
|
|
115
|
+
* 与空态语义(空串 vs undefined)留在各调用点组合——S7 警告适用:join 差异是行为敏感点,
|
|
116
|
+
* 不可在共享核合并。消费方:result-action('' join)/ extract('\n' join)/ find(' ' join
|
|
117
|
+
* + 空返 undefined)。
|
|
118
|
+
*/
|
|
119
|
+
export function textBlockParts(content: unknown): string[] {
|
|
120
|
+
if (typeof content === 'string') return [content]
|
|
121
|
+
if (!Array.isArray(content)) return []
|
|
122
|
+
const parts: string[] = []
|
|
123
|
+
for (const b of content) {
|
|
124
|
+
if (b !== null && typeof b === 'object') {
|
|
125
|
+
const o = b as Record<string, unknown>
|
|
126
|
+
if (o.type === 'text' && typeof o.text === 'string') parts.push(o.text)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return parts
|
|
130
|
+
}
|
|
131
|
+
|
|
104
132
|
/** 拼接 text 块文本(排除 thinking / toolCall / tool_use / tool_result 块)。 */
|
|
105
133
|
function extractText(content: unknown): string {
|
|
106
134
|
if (isStringContent(content)) return content
|
|
@@ -276,7 +304,7 @@ function findBranchForkPoint(turn: Turn, tree: TreeView): string | undefined {
|
|
|
276
304
|
}
|
|
277
305
|
|
|
278
306
|
// ---------------------------------------------------------------------------
|
|
279
|
-
//
|
|
307
|
+
// 行渲染(预算度量与展示同一份渲染行,E7/D3 行渲染统一)
|
|
280
308
|
// ---------------------------------------------------------------------------
|
|
281
309
|
|
|
282
310
|
function formatHHMM(timestamp?: string): string {
|
|
@@ -292,10 +320,11 @@ export function formatBytesMarker(bytes: number): string {
|
|
|
292
320
|
}
|
|
293
321
|
|
|
294
322
|
/**
|
|
295
|
-
*
|
|
323
|
+
* 渲染单行为字符串(E7 行渲染统一:预算度量与展示同一份渲染行,OutlineResult.lines 的行工厂)。
|
|
296
324
|
* v2 O1:L1 行含 assistantBrief(补 assistant 结论行让 outline 单独可决策,不再逼反复 expand)。
|
|
297
325
|
* level: 0=全有(toolSummary + assistantBrief)/ 1=砍 assistantBrief / 2=再砍 toolSummary(骨架)。
|
|
298
326
|
* userBrief + omittedBytes 骨架永保。assistantBrief 格式 `→ <结论>`,在 toolSummary 后、omitted 前。
|
|
327
|
+
* 旁支标记 `[旁支 N entries]`(branchSize = 该 forkPoint 下旁支子树 entry 数)。
|
|
299
328
|
*/
|
|
300
329
|
function formatLine(b: TurnBrief, level: LineLevel, branchSize?: number): string {
|
|
301
330
|
const parts: string[] = []
|
|
@@ -341,7 +370,8 @@ function applyBranchLabels(briefs: TurnBrief[], turns: Turn[], tree: TreeView):
|
|
|
341
370
|
/**
|
|
342
371
|
* 降级序逐行渲染(design §3.5 算法 1 step3):level 0 全有;超预算降到 level 1
|
|
343
372
|
* 砍 assistantBrief;仍超降到 level 2 砍 toolSummary(骨架)。
|
|
344
|
-
* 直接改写 brief 的 assistantBrief/toolSummary
|
|
373
|
+
* 直接改写 brief 的 assistantBrief/toolSummary 字段,返回行缓存(E7 后随结果
|
|
374
|
+
* 返回为 OutlineResult.lines,预算度量与展示同一份)。
|
|
345
375
|
*/
|
|
346
376
|
function renderDegradingLines(
|
|
347
377
|
briefs: TurnBrief[],
|
|
@@ -408,7 +438,7 @@ export function renderOutline(
|
|
|
408
438
|
}
|
|
409
439
|
|
|
410
440
|
if (turns.length === 0) {
|
|
411
|
-
return { turns: [], stats, tokenEstimate: 0 }
|
|
441
|
+
return { turns: [], lines: [], stats, tokenEstimate: 0 }
|
|
412
442
|
}
|
|
413
443
|
|
|
414
444
|
// granularity:entry —— 每 entry 一行,不聚合 turn(D-1 兜底)
|
|
@@ -428,11 +458,11 @@ export function renderOutline(
|
|
|
428
458
|
// 3. 降级序渲染:level 0 全有 → level 1 砍 assistantBrief → level 2 骨架
|
|
429
459
|
const lineCache = renderDegradingLines(briefs, tree, perTurnCharBudget)
|
|
430
460
|
|
|
431
|
-
// 4.
|
|
461
|
+
// 4. 总预算截断(从尾部丢弃,lines 与 briefs 同步截短)
|
|
432
462
|
const { totalChars, truncated } = truncateToTotalBudget(briefs, lineCache, budget)
|
|
433
463
|
|
|
434
464
|
const tokenEstimate = Math.ceil(totalChars / CHARS_PER_TOKEN)
|
|
435
|
-
return { turns: briefs, stats, tokenEstimate, truncated }
|
|
465
|
+
return { turns: briefs, lines: lineCache, stats, tokenEstimate, truncated }
|
|
436
466
|
}
|
|
437
467
|
|
|
438
468
|
/** granularity:entry 模式:每 entry 一行 TurnBrief,不聚合,仅总预算截断。 */
|
|
@@ -486,6 +516,7 @@ function renderEntryGranularity(
|
|
|
486
516
|
}
|
|
487
517
|
return {
|
|
488
518
|
turns: briefs,
|
|
519
|
+
lines,
|
|
489
520
|
stats,
|
|
490
521
|
tokenEstimate: Math.ceil(totalChars / CHARS_PER_TOKEN),
|
|
491
522
|
truncated,
|
|
@@ -568,7 +599,7 @@ function stripThinking(content: unknown): unknown {
|
|
|
568
599
|
|
|
569
600
|
/**
|
|
570
601
|
* L3 detail 默认摘要态的 toolResult entry(v2 O3:toolResult 不再整条消失,给中间态)。
|
|
571
|
-
* includeToolResult:true 时 renderDetail
|
|
602
|
+
* includeToolResult:true 时 renderDetail 直接 push 原 Entry(全文态真实路径),否则返回此摘要。
|
|
572
603
|
*/
|
|
573
604
|
export interface ToolResultSummaryEntry {
|
|
574
605
|
type: 'toolResultSummary'
|
|
@@ -580,8 +611,6 @@ export interface ToolResultSummaryEntry {
|
|
|
580
611
|
headLines: string
|
|
581
612
|
/** 结果文本总行数 */
|
|
582
613
|
totalLines: number
|
|
583
|
-
/** 原 toolResult entry(includeToolResult:true 时 renderDetail 改用此返回全文) */
|
|
584
|
-
fullEntry: Entry
|
|
585
614
|
}
|
|
586
615
|
|
|
587
616
|
export function renderDetail(
|
|
@@ -620,7 +649,6 @@ export function renderDetail(
|
|
|
620
649
|
summary: formatToolResultSummary(msg.toolName, tc, text),
|
|
621
650
|
headLines,
|
|
622
651
|
totalLines: lines.length,
|
|
623
|
-
fullEntry: e,
|
|
624
652
|
})
|
|
625
653
|
}
|
|
626
654
|
continue
|
package/src/core/workflow.ts
CHANGED
|
@@ -11,25 +11,20 @@
|
|
|
11
11
|
// 升版连带编译期影响本扩展;且上游类型只描述 NEW,OLD 仍需自处理(TC-wf-snapshot-version-union)。
|
|
12
12
|
// session-reader 作为纯读取者,按字段存在性 + v 标记分支做「结构化快照」式解析,与上游解耦。
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
import { isRecord } from '@zhushanwen/pi-ext-guards'
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
18
|
-
return typeof v === 'object' && v !== null
|
|
19
|
-
}
|
|
16
|
+
// ---- 类型守卫 helpers ----
|
|
20
17
|
|
|
21
18
|
/** unknown → string 收窄(非 string → undefined)。快照可选字符串字段的统一入口。 */
|
|
22
19
|
function strOr(v: unknown): string | undefined {
|
|
23
20
|
return typeof v === 'string' ? v : undefined
|
|
24
21
|
}
|
|
25
22
|
|
|
26
|
-
/** 字符串截断(超 max
|
|
23
|
+
/** 字符串截断(超 max 加省略号)。step 行渲染用。 */
|
|
27
24
|
function truncate(s: string, max: number): string {
|
|
28
25
|
return s.length <= max ? s : s.slice(0, max) + '…'
|
|
29
26
|
}
|
|
30
27
|
|
|
31
|
-
/** contentPreview 截断长度(概览预览,全文走 detail)。 */
|
|
32
|
-
const CONTENT_PREVIEW_MAX = 120
|
|
33
28
|
/** step 行 call sessionId 显示截断长度(uuid 前缀段,LLM 可读)。 */
|
|
34
29
|
const SESSION_ID_PREVIEW_MAX = 12
|
|
35
30
|
|
|
@@ -61,8 +56,6 @@ export interface WorkflowStep {
|
|
|
61
56
|
description?: string
|
|
62
57
|
/** NEW call.opts.model */
|
|
63
58
|
model?: string
|
|
64
|
-
/** NEW call.opts.thinkingLevel */
|
|
65
|
-
thinkingLevel?: string
|
|
66
59
|
/** NEW call.attempts / OLD 无 → undefined */
|
|
67
60
|
attempts?: number
|
|
68
61
|
/** NEW call.result.durationMs / OLD value.result.durationMs */
|
|
@@ -71,8 +64,6 @@ export interface WorkflowStep {
|
|
|
71
64
|
sessionId?: string
|
|
72
65
|
/** call.sessionFile 或 result.sessionFile(LLM 跳 outline/detail 的绝对路径入口,OLD 多数为 undefined) */
|
|
73
66
|
sessionFile?: string
|
|
74
|
-
/** result.content 截断前 120 字(概览预览,全文走 detail) */
|
|
75
|
-
contentPreview?: string
|
|
76
67
|
}
|
|
77
68
|
|
|
78
69
|
/** workflow run 概览(parseRunSnapshot 输出 / renderWorkflowOverview 输入)。 */
|
|
@@ -83,7 +74,7 @@ export interface WorkflowOverview {
|
|
|
83
74
|
stateFile: string
|
|
84
75
|
/** NEW state.status / OLD 顶层 status */
|
|
85
76
|
status: string
|
|
86
|
-
/**
|
|
77
|
+
/** details 格式标记(随 details.runs 透传给程序化消费方,文本渲染不读)。v2 读取面形状与 v1 一致(pi-subagent-workflow 8.x 一次性生命周期) */
|
|
87
78
|
version: 'wf-run-v1' | 'wf-run-v2' | 'legacy'
|
|
88
79
|
/** NEW spec.scriptName / spec.name / OLD name */
|
|
89
80
|
script?: string
|
|
@@ -138,19 +129,16 @@ function mapCallToStep(call: unknown, fallbackIndex: number): WorkflowStep {
|
|
|
138
129
|
const index = typeof call.id === 'number' ? call.id : fallbackIndex
|
|
139
130
|
const status = normalizeCallStatus(strOr(call.status) ?? '')
|
|
140
131
|
const refs = pickSessionRefs(call, result)
|
|
141
|
-
const content = strOr(result.content)
|
|
142
132
|
|
|
143
133
|
return {
|
|
144
134
|
index,
|
|
145
135
|
status,
|
|
146
136
|
description: strOr(opts.description),
|
|
147
137
|
model: strOr(opts.model),
|
|
148
|
-
thinkingLevel: strOr(opts.thinkingLevel),
|
|
149
138
|
attempts: typeof call.attempts === 'number' ? call.attempts : undefined,
|
|
150
139
|
durationMs: typeof result.durationMs === 'number' ? result.durationMs : undefined,
|
|
151
140
|
sessionId: refs.sessionId,
|
|
152
141
|
sessionFile: refs.sessionFile,
|
|
153
|
-
contentPreview: content !== undefined ? truncate(content, CONTENT_PREVIEW_MAX) : undefined,
|
|
154
142
|
}
|
|
155
143
|
}
|
|
156
144
|
|
|
@@ -162,11 +150,11 @@ function mapCacheEntryToStep(entry: unknown, index: number): WorkflowStep {
|
|
|
162
150
|
|
|
163
151
|
// sessionFile/sessionId: value.sessionFile 或 value.result.sessionFile(OLD 多数缺失,探针 112 文件 0)
|
|
164
152
|
const refs = pickSessionRefs(value, result)
|
|
165
|
-
// content:真实 OLD 数据 value.content(wf-skip-ok)与测试 fixture value.result.content
|
|
153
|
+
// content:真实 OLD 数据 value.content(wf-skip-ok)与测试 fixture value.result.content 并存,
|
|
154
|
+
// 仅用于 status 推测(A4 死字段删除:contentPreview 已删,不再随 step 输出)
|
|
166
155
|
const content = strOr(result.content) ?? strOr(value.content)
|
|
167
156
|
// OLD 无 status 字段:有 sessionFile 或非空 content → done,否则 pending
|
|
168
|
-
//(空 content 如 wf-skip-ok 的 '' 不算完成标志,对齐 TC-w5-parse-old expected status='pending'
|
|
169
|
-
// content 仍提取为 contentPreview='')
|
|
157
|
+
//(空 content 如 wf-skip-ok 的 '' 不算完成标志,对齐 TC-w5-parse-old expected status='pending')
|
|
170
158
|
const hasContent = content !== undefined && content.length > 0
|
|
171
159
|
const status: WorkflowStep['status'] =
|
|
172
160
|
refs.sessionFile !== undefined || hasContent ? 'done' : 'pending'
|
|
@@ -177,7 +165,6 @@ function mapCacheEntryToStep(entry: unknown, index: number): WorkflowStep {
|
|
|
177
165
|
durationMs: typeof result.durationMs === 'number' ? result.durationMs : undefined,
|
|
178
166
|
sessionId: refs.sessionId,
|
|
179
167
|
sessionFile: refs.sessionFile,
|
|
180
|
-
contentPreview: content !== undefined ? truncate(content, CONTENT_PREVIEW_MAX) : undefined,
|
|
181
168
|
}
|
|
182
169
|
}
|
|
183
170
|
|
package/src/discovery/find.ts
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import { createReadStream, type ReadStream } from 'node:fs'
|
|
2
|
-
import {
|
|
2
|
+
import { stat } from 'node:fs/promises'
|
|
3
3
|
import { createInterface } from 'node:readline'
|
|
4
4
|
import { basename, dirname } from 'node:path'
|
|
5
5
|
import type { SessionRef } from '../core/family.js'
|
|
6
|
+
import { textBlockParts } from '../core/render.js'
|
|
6
7
|
import {
|
|
7
8
|
resolveSessionRoots,
|
|
8
9
|
type SessionFileMeta,
|
|
9
10
|
type SessionRoot,
|
|
10
11
|
type SessionRootSignals,
|
|
11
12
|
} from './roots.js'
|
|
12
|
-
|
|
13
|
+
// 首行 header 读取/解析在 discovery/session-header.ts(D5 单源)。
|
|
14
|
+
import {
|
|
15
|
+
parseSessionHeader,
|
|
16
|
+
readSessionHeaderFirstLine,
|
|
17
|
+
type SessionHeader,
|
|
18
|
+
} from './session-header.js'
|
|
19
|
+
import {
|
|
20
|
+
listRecordManifests,
|
|
21
|
+
extractSessionIdFromFilename,
|
|
22
|
+
readTailIdentity,
|
|
23
|
+
type RecordManifest,
|
|
24
|
+
} from './subagents.js'
|
|
13
25
|
|
|
14
26
|
/**
|
|
15
27
|
* M2 discovery 发现层:按 query 定位 session(design §3.3 D-3 + §3.4 find action)。
|
|
@@ -59,8 +71,8 @@ export interface SessionMetadataEntry {
|
|
|
59
71
|
cwd: string
|
|
60
72
|
/** 用户标题(session_info entry 的 name);旧 session 缺失 */
|
|
61
73
|
name?: string
|
|
62
|
-
/** pi 返回 Date
|
|
63
|
-
modified: Date
|
|
74
|
+
/** pi 返回 Date(A4 收窄:原 `Date | number` 宽联合只服务测试替身,替身改传 Date) */
|
|
75
|
+
modified: Date
|
|
64
76
|
/** 首消息全文(title 命中时填充 firstMessagePreview,免二次读文件) */
|
|
65
77
|
firstMessage?: string
|
|
66
78
|
}
|
|
@@ -74,30 +86,6 @@ export type SessionMetadataProvider = (dir: string) => Promise<SessionMetadataEn
|
|
|
74
86
|
|
|
75
87
|
const DEFAULT_LIMIT = 20
|
|
76
88
|
const PREVIEW_MAX = 80
|
|
77
|
-
/** readFirstLine 单次读取 buffer 上限。session header(id/cwd/parentSession)远小于此。 */
|
|
78
|
-
const HEADER_READ_BYTES = 8192
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* 读文件首行(header)。用定长 buffer 一次 read(避免 stream 开销),
|
|
82
|
-
* 空文件/读失败返回 undefined。header 超 8KB 的极端情况会截断致 parse 失败——
|
|
83
|
-
* session header(id+cwd)实测 < 300 字节,8KB 足够 27 倍余量。
|
|
84
|
-
*/
|
|
85
|
-
async function readFirstLine(path: string): Promise<string | undefined> {
|
|
86
|
-
let fh: FileHandle | undefined
|
|
87
|
-
try {
|
|
88
|
-
fh = await open(path, 'r')
|
|
89
|
-
const buf = Buffer.alloc(HEADER_READ_BYTES)
|
|
90
|
-
const { bytesRead } = await fh.read(buf, 0, HEADER_READ_BYTES, 0)
|
|
91
|
-
if (bytesRead === 0) return undefined
|
|
92
|
-
const content = buf.subarray(0, bytesRead).toString('utf8')
|
|
93
|
-
const nl = content.indexOf('\n')
|
|
94
|
-
return nl === -1 ? content : content.slice(0, nl)
|
|
95
|
-
} catch {
|
|
96
|
-
return undefined
|
|
97
|
-
} finally {
|
|
98
|
-
await fh?.close().catch(() => {})
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
89
|
|
|
102
90
|
/** 从单行 JSON 提取 message entry 的 user role 文本,非 user message 行返回 undefined。 */
|
|
103
91
|
function extractUserText(line: string): string | undefined {
|
|
@@ -120,22 +108,12 @@ function extractUserText(line: string): string | undefined {
|
|
|
120
108
|
/**
|
|
121
109
|
* 从 message content 提取可读文本。
|
|
122
110
|
* 兼容 pi 两种形态:string content(直接用)与 array content(拼 type:text 项的 text)。
|
|
111
|
+
* E11 归一:共享核 textBlockParts 组合调用,' ' join 与零 text 块返 undefined 的语义
|
|
112
|
+
* 留在本调用点(find 匹配把 undefined 当「无文本」信号,空串会被 includes('') 误吸)。
|
|
123
113
|
*/
|
|
124
114
|
function extractTextFromContent(content: unknown): string | undefined {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const parts: string[] = []
|
|
128
|
-
for (const item of content) {
|
|
129
|
-
if (typeof item === 'object' && item !== null) {
|
|
130
|
-
const it = item as Record<string, unknown>
|
|
131
|
-
if (it.type === 'text' && typeof it.text === 'string') {
|
|
132
|
-
parts.push(it.text)
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return parts.length > 0 ? parts.join(' ') : undefined
|
|
137
|
-
}
|
|
138
|
-
return undefined
|
|
115
|
+
const parts = textBlockParts(content)
|
|
116
|
+
return parts.length > 0 ? parts.join(' ') : undefined
|
|
139
117
|
}
|
|
140
118
|
|
|
141
119
|
/**
|
|
@@ -163,30 +141,6 @@ async function readFirstUserMessageText(path: string): Promise<string | undefine
|
|
|
163
141
|
}
|
|
164
142
|
}
|
|
165
143
|
|
|
166
|
-
interface SessionHeader {
|
|
167
|
-
id: string
|
|
168
|
-
cwd?: string
|
|
169
|
-
parentSession?: string
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** 解析 header 首行为 SessionHeader。非 session 行/缺 id → null。 */
|
|
173
|
-
function parseHeader(line: string | undefined): SessionHeader | null {
|
|
174
|
-
if (!line) return null
|
|
175
|
-
let raw: unknown
|
|
176
|
-
try {
|
|
177
|
-
raw = JSON.parse(line)
|
|
178
|
-
} catch {
|
|
179
|
-
return null
|
|
180
|
-
}
|
|
181
|
-
if (typeof raw !== 'object' || raw === null) return null
|
|
182
|
-
const obj = raw as Record<string, unknown>
|
|
183
|
-
if (obj.type !== 'session' || typeof obj.id !== 'string') return null
|
|
184
|
-
const header: SessionHeader = { id: obj.id }
|
|
185
|
-
if (typeof obj.cwd === 'string') header.cwd = obj.cwd
|
|
186
|
-
if (typeof obj.parentSession === 'string') header.parentSession = obj.parentSession
|
|
187
|
-
return header
|
|
188
|
-
}
|
|
189
|
-
|
|
190
144
|
/**
|
|
191
145
|
* uuid 归一化键(design 2026-09-10 §6.7 子决策 1):小写 + 去连字符。
|
|
192
146
|
* 修复 §3.3 的两个盲区——大写 uuid(String.includes 大小写敏感)与去连字符 uuid
|
|
@@ -230,59 +184,6 @@ interface Matched extends Candidate {
|
|
|
230
184
|
// U5:subagent task/slug/agentName 匹配(manifest 索引 + P-fallback identity 回退)
|
|
231
185
|
// ============================================================
|
|
232
186
|
|
|
233
|
-
/** P-fallback 尾行 identity 读取窗口(同 subagents.ts,task 文本可达数 KB)。 */
|
|
234
|
-
const TAIL_READ_BYTES = 65536
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* 读 subagent 文件尾部(最后 64KB)找 subagent-identity entry,返回 task/slug/agent。
|
|
238
|
-
*
|
|
239
|
-
* find 的 P-fallback 路径(场景 A:subagent 无 manifest,本机 11.5%):manifest 索引未命中时
|
|
240
|
-
* 读尾行 identity 取 task/slug/agent 做 query 子串匹配。与 subagents.ts 的 readTailIdentity
|
|
241
|
-
* 同源(64KB 窗口 + lastIndexOf 定位),但是 find 专用最小版(只取 task/slug/agent,不要
|
|
242
|
-
* rootSessionId——find 候选已有 header.id)。不导出,不碰 subagents.ts(w3 冻结)。
|
|
243
|
-
*/
|
|
244
|
-
async function readTailIdentityForMatch(
|
|
245
|
-
path: string,
|
|
246
|
-
size: number,
|
|
247
|
-
): Promise<{ task?: string; slug?: string; agent?: string } | undefined> {
|
|
248
|
-
if (size === 0) return undefined
|
|
249
|
-
let fh: FileHandle | undefined
|
|
250
|
-
try {
|
|
251
|
-
fh = await open(path, 'r')
|
|
252
|
-
const len = Math.min(TAIL_READ_BYTES, size)
|
|
253
|
-
const buf = Buffer.alloc(len)
|
|
254
|
-
await fh.read(buf, 0, len, Math.max(0, size - len))
|
|
255
|
-
const text = buf.toString('utf8')
|
|
256
|
-
const idx = text.lastIndexOf('subagent-identity')
|
|
257
|
-
if (idx < 0) return undefined
|
|
258
|
-
const lineStartSearch = text.lastIndexOf('\n', idx)
|
|
259
|
-
if (lineStartSearch < 0 && size > len) return undefined
|
|
260
|
-
const start = lineStartSearch < 0 ? 0 : lineStartSearch + 1
|
|
261
|
-
let end = text.indexOf('\n', idx)
|
|
262
|
-
if (end < 0) end = text.length
|
|
263
|
-
const line = text.slice(start, end)
|
|
264
|
-
let raw: unknown
|
|
265
|
-
try {
|
|
266
|
-
raw = JSON.parse(line)
|
|
267
|
-
} catch {
|
|
268
|
-
return undefined
|
|
269
|
-
}
|
|
270
|
-
const data = (raw as Record<string, unknown> | undefined)?.data as
|
|
271
|
-
| Record<string, unknown>
|
|
272
|
-
| undefined
|
|
273
|
-
if (!data) return undefined
|
|
274
|
-
return {
|
|
275
|
-
task: typeof data.task === 'string' ? data.task : undefined,
|
|
276
|
-
slug: typeof data.slug === 'string' ? data.slug : undefined,
|
|
277
|
-
agent: typeof data.agent === 'string' ? data.agent : undefined,
|
|
278
|
-
}
|
|
279
|
-
} catch {
|
|
280
|
-
return undefined
|
|
281
|
-
} finally {
|
|
282
|
-
await fh?.close().catch(() => {})
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
187
|
/**
|
|
287
188
|
* 建 sessionId→RecordManifest 索引(U5:subagent task/slug/agentName 匹配用)。
|
|
288
189
|
*
|
|
@@ -325,8 +226,11 @@ async function matchSubagentMetadata(
|
|
|
325
226
|
(manifest.agentName?.includes(query) ?? false)
|
|
326
227
|
)
|
|
327
228
|
}
|
|
328
|
-
// P-fallback:manifest 索引未命中 → 读尾行 identity
|
|
329
|
-
|
|
229
|
+
// P-fallback:manifest 索引未命中 → 读尾行 identity 回退。E8(ext-simplify-04):
|
|
230
|
+
// 复用 subagents.ts 的 readTailIdentity 单实现,取 task/slug/agent 子集。行为差异 =
|
|
231
|
+
// 缺 rootSessionId 的畸形 identity 行从「可匹配」变「不匹配」(m0 契约外数据,
|
|
232
|
+
// design §3.3 E8 登记为可接受)。
|
|
233
|
+
const ident = await readTailIdentity(candidate.meta.path, candidate.meta.size)
|
|
330
234
|
if (!ident) return false
|
|
331
235
|
return (
|
|
332
236
|
(ident.task?.includes(query) ?? false) ||
|
|
@@ -362,9 +266,9 @@ function buildCandidate(
|
|
|
362
266
|
*
|
|
363
267
|
* u11 起直接消费 resolveSessionRoots 的根列表(单次实扫,files 与根归属信息同批产出——
|
|
364
268
|
* 标题窄化策略需要「扫描结果无子目录的候选根」这一根级事实,薄包装的扁平列表给不出)。
|
|
365
|
-
* 对只含 agentDir 的信号包,根集合 = [default]+[legacy]+[subagent]
|
|
366
|
-
* listMainSessions/listSubagentSessions
|
|
367
|
-
* realpath 去重语义);传 liveSessionDir 时按 §6.1 追加 [live] 根(realpath 去重保优先级)。
|
|
269
|
+
* 对只含 agentDir 的信号包,根集合 = [default]+[legacy]+[subagent],与已删除薄包装
|
|
270
|
+
* listMainSessions/listSubagentSessions(ext-simplify-04 U4/A3)原并集逐文件一致(含
|
|
271
|
+
* workflow-state 跳过与 realpath 去重语义);传 liveSessionDir 时按 §6.1 追加 [live] 根(realpath 去重保优先级)。
|
|
368
272
|
*/
|
|
369
273
|
async function collectCandidates(
|
|
370
274
|
roots: SessionRoot[],
|
|
@@ -376,7 +280,7 @@ async function collectCandidates(
|
|
|
376
280
|
if (root.dedupedInto !== undefined) continue // 被去重根未实扫(files 恒空),不产候选
|
|
377
281
|
if (sourceFilter !== undefined && root.source !== sourceFilter) continue
|
|
378
282
|
for (const meta of root.files) {
|
|
379
|
-
const header =
|
|
283
|
+
const header = parseSessionHeader(await readSessionHeaderFirstLine(meta.path))
|
|
380
284
|
if (!header) continue // 非 session 文件/坏 header → 跳过
|
|
381
285
|
if (cwdFilter !== undefined && (header.cwd ?? '') !== cwdFilter) continue
|
|
382
286
|
candidates.push(buildCandidate(meta, header, root.source))
|
|
@@ -409,11 +313,6 @@ async function pathExistsDir(path: string): Promise<boolean> {
|
|
|
409
313
|
}
|
|
410
314
|
}
|
|
411
315
|
|
|
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
316
|
/**
|
|
418
317
|
* 平铺目录判据(§6.6 调用策略 ②):本根实扫出的全部 .jsonl 都直接位于根本身
|
|
419
318
|
*(即扫描结果无子目录)。listAll(dir) 实装语义是「只扫一层平铺目录」——平铺根意味着
|
|
@@ -467,7 +366,7 @@ async function loadTitleIndex(ctx: MetadataContext): Promise<Map<string, Session
|
|
|
467
366
|
}
|
|
468
367
|
for (const e of entries) {
|
|
469
368
|
const prev = index.get(e.id)
|
|
470
|
-
if (prev === undefined ||
|
|
369
|
+
if (prev === undefined || e.modified.getTime() > prev.modified.getTime()) index.set(e.id, e)
|
|
471
370
|
}
|
|
472
371
|
}
|
|
473
372
|
return index
|
|
@@ -590,7 +489,7 @@ async function loadRecentTitles(
|
|
|
590
489
|
}
|
|
591
490
|
for (const e of entries) {
|
|
592
491
|
const prev = titles.get(e.id)
|
|
593
|
-
if (prev === undefined ||
|
|
492
|
+
if (prev === undefined || e.modified.getTime() > prev.modified.getTime()) titles.set(e.id, e)
|
|
594
493
|
}
|
|
595
494
|
}
|
|
596
495
|
return titles
|
|
@@ -658,6 +557,12 @@ async function fillFirstMessagePreviews(
|
|
|
658
557
|
* 去重)并作标题窄化目标;缺省 = 现状三根降级行为。
|
|
659
558
|
* - opts.metadataProvider:标题元数据注入(仅 keyword 路径 + recent 截断后补充消费);
|
|
660
559
|
* 缺省 = undefined = 现状行为(标题检索不可用,首条 user 匹配不受影响)。
|
|
560
|
+
*
|
|
561
|
+
* E1 预解析根复用(ext-simplify-04 §3 D1):opts.roots = 调用方预解析的根列表——同一
|
|
562
|
+
* 次 doFind 内多段查询(分组 main/subagent 两路)复用同一次根解析,目录扫描不随查询
|
|
563
|
+
* 段数翻倍。缺省 = 本函数自解析(既有测试与单段调用方零改动)。调用方须保证传入的
|
|
564
|
+
* roots 来自无 options 的 resolveSessionRoots 实扫(预解析是复用同一次实扫——根扫描
|
|
565
|
+
* 无缓存,doctor 缓存机已删除,ext-simplify-04 U3)。
|
|
661
566
|
*/
|
|
662
567
|
export async function findSessions(
|
|
663
568
|
query: string,
|
|
@@ -668,18 +573,21 @@ export async function findSessions(
|
|
|
668
573
|
source?: SessionSource
|
|
669
574
|
liveSessionDir?: string
|
|
670
575
|
metadataProvider?: SessionMetadataProvider
|
|
576
|
+
/** 预解析根列表(E1/D1):提供则跳过内部 resolveSessionRoots,直接消费 */
|
|
577
|
+
roots?: SessionRoot[]
|
|
671
578
|
},
|
|
672
579
|
): Promise<{ matches: MatchedSession[]; truncated: boolean }> {
|
|
673
580
|
const limit = opts?.limit ?? DEFAULT_LIMIT
|
|
674
581
|
const cwdFilter = opts?.cwd
|
|
675
582
|
const sourceFilter = opts?.source
|
|
676
583
|
|
|
677
|
-
// 0. 根解析(单次实扫,无 options
|
|
584
|
+
// 0. 根解析(单次实扫,无 options——根扫描无缓存);
|
|
585
|
+
// E1:调用方已预解析(opts.roots)时复用,不再自扫
|
|
678
586
|
const signals: SessionRootSignals =
|
|
679
587
|
opts?.liveSessionDir !== undefined && opts.liveSessionDir.length > 0
|
|
680
588
|
? { agentDir, liveSessionDir: opts.liveSessionDir }
|
|
681
589
|
: { agentDir }
|
|
682
|
-
const roots = await resolveSessionRoots(signals)
|
|
590
|
+
const roots = opts?.roots ?? (await resolveSessionRoots(signals))
|
|
683
591
|
// 0+1. 逐根首行扫描建候选
|
|
684
592
|
const candidates = await collectCandidates(roots, sourceFilter, cwdFilter)
|
|
685
593
|
// 2. 三路匹配(recent / uuid 片段 / 名称关键词+U5 元数据+u11 标题)
|