@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
|
@@ -151,6 +151,33 @@ describe('parseRunSnapshot', () => {
|
|
|
151
151
|
expect(overview!.steps[1].contentPreview).toBe('OK')
|
|
152
152
|
})
|
|
153
153
|
|
|
154
|
+
it('TC-w5-parse-new-status-unknown:NEW call.status 非 done/running/pending(如 failed)→ step.status=pending', () => {
|
|
155
|
+
// call.status 出现三态外的值(如上游异常态)时收窄为 pending,其余字段照常提取
|
|
156
|
+
const fixture = {
|
|
157
|
+
...NEW_SNAPSHOT_FIXTURE,
|
|
158
|
+
state: {
|
|
159
|
+
...NEW_SNAPSHOT_FIXTURE.state,
|
|
160
|
+
calls: [
|
|
161
|
+
{
|
|
162
|
+
id: 3,
|
|
163
|
+
opts: { description: 'step-3' },
|
|
164
|
+
status: 'failed',
|
|
165
|
+
result: { content: 'partial', durationMs: 9 },
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
const overview = parseRunSnapshot(fixture, 'wf-status-runid', '/abs/wf.jsonl')
|
|
171
|
+
expect(overview).not.toBeNull()
|
|
172
|
+
expect(overview!.steps).toHaveLength(1)
|
|
173
|
+
const step = overview!.steps[0]
|
|
174
|
+
expect(step.index).toBe(3)
|
|
175
|
+
expect(step.status).toBe('pending')
|
|
176
|
+
expect(step.description).toBe('step-3')
|
|
177
|
+
expect(step.contentPreview).toBe('partial')
|
|
178
|
+
expect(step.durationMs).toBe(9)
|
|
179
|
+
})
|
|
180
|
+
|
|
154
181
|
it('TC-w5-parse-old:OLD 格式 (无 v) 尽力解析为 legacy overview,step status 推测', () => {
|
|
155
182
|
const overview = parseRunSnapshot(OLD_SNAPSHOT_FIXTURE, 'wf-old-link', '/abs/wf-old.jsonl')
|
|
156
183
|
expect(overview).not.toBeNull()
|
|
@@ -199,6 +226,37 @@ describe('extractCallSessionFiles', () => {
|
|
|
199
226
|
const files = extractCallSessionFiles(NEW_SNAPSHOT_FIXTURE)
|
|
200
227
|
expect(files).toEqual(['/abs/session.jsonl'])
|
|
201
228
|
})
|
|
229
|
+
|
|
230
|
+
it('OLD 快照(无 v,callCache)从 value 顶层与 value.result 提取 sessionFile', () => {
|
|
231
|
+
const files = extractCallSessionFiles({
|
|
232
|
+
callCache: [
|
|
233
|
+
{ key: 0, value: { sessionFile: '/abs/old-call0.jsonl' } },
|
|
234
|
+
{ key: 1, value: { result: { sessionFile: '/abs/old-call1.jsonl' } } },
|
|
235
|
+
],
|
|
236
|
+
})
|
|
237
|
+
expect(files).toEqual(['/abs/old-call0.jsonl', '/abs/old-call1.jsonl'])
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('OLD 快照 value 无 sessionFile / value 非对象 → 跳过该 call,不产出路径', () => {
|
|
241
|
+
// 对齐 OLD_SNAPSHOT_FIXTURE 形状(旧 pi 不持久化 sessionFile)+ value 非对象脏数据回退 call 本身
|
|
242
|
+
const files = extractCallSessionFiles({
|
|
243
|
+
callCache: [
|
|
244
|
+
{ key: 7, value: { content: '', usage: { input: 0 } } },
|
|
245
|
+
{ key: 8, value: 42 },
|
|
246
|
+
{ key: 9, sessionFile: '/abs/bare-call.jsonl' },
|
|
247
|
+
],
|
|
248
|
+
})
|
|
249
|
+
expect(files).toEqual(['/abs/bare-call.jsonl'])
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('快照非对象 / calls 容器缺失 / call 元素非对象 → 空数组', () => {
|
|
253
|
+
expect(extractCallSessionFiles(null)).toEqual([])
|
|
254
|
+
expect(extractCallSessionFiles('wf-run-v1')).toEqual([])
|
|
255
|
+
expect(extractCallSessionFiles({ v: 'wf-run-v1', state: null })).toEqual([])
|
|
256
|
+
expect(extractCallSessionFiles({ v: 'wf-run-v1', state: {} })).toEqual([])
|
|
257
|
+
expect(extractCallSessionFiles({ callCache: 'not-an-array' })).toEqual([])
|
|
258
|
+
expect(extractCallSessionFiles({ callCache: [null, 7, 'x'] })).toEqual([])
|
|
259
|
+
})
|
|
202
260
|
})
|
|
203
261
|
|
|
204
262
|
// ============================================================
|
|
@@ -282,7 +340,7 @@ describe('renderWorkflowOverview', () => {
|
|
|
282
340
|
describe('readRunSnapshot', () => {
|
|
283
341
|
let dir: string
|
|
284
342
|
afterEach(async () => {
|
|
285
|
-
if (dir) await rm(dir, { recursive: true, force: true }).catch(() => {})
|
|
343
|
+
if (dir) await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }).catch(() => {})
|
|
286
344
|
})
|
|
287
345
|
|
|
288
346
|
it('TC-w5-read-tail-fallback:末行半截 JSON 回退倒数第二行完整快照', async () => {
|
package/src/core/family.ts
CHANGED
|
@@ -134,24 +134,12 @@ function resolveParentSessionId(
|
|
|
134
134
|
return null
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
/**
|
|
138
|
-
|
|
139
|
-
*
|
|
140
|
-
* - headers(type=session)→ byId + childrenOf(parentSession 文件路径反查父 sessionId)
|
|
141
|
-
* - subagentIdentities(type=custom, customType=subagent-identity)→ subagentsByRoot
|
|
142
|
-
* - fileStats 原样存入 index,供 cleanedUp 判断
|
|
143
|
-
*
|
|
144
|
-
* 坏数据容错:identity 缺 rootSessionId/slug 跳过;parentSession 反查不到父(父文件
|
|
145
|
-
* 未被扫描到)该 entry 不进 childrenOf——均不报错,符合 pi 坏 session 容错(design §2)。
|
|
146
|
-
*/
|
|
147
|
-
export function buildFamilyIndex(
|
|
137
|
+
/** 阶段 1:headers → byId(SessionRef 组装,mtime/size 从 fileStats 取,取不到为 0)。 */
|
|
138
|
+
function buildById(
|
|
148
139
|
headers: Entry[],
|
|
149
|
-
subagentIdentities: Entry[],
|
|
150
140
|
fileStats: Map<string, { mtime: number; size: number }>,
|
|
151
|
-
):
|
|
141
|
+
): Map<string, SessionRef> {
|
|
152
142
|
const byId = new Map<string, SessionRef>()
|
|
153
|
-
|
|
154
|
-
// 1. headers → byId
|
|
155
143
|
for (const h of headers) {
|
|
156
144
|
const stat = fileStats.get(h.id)
|
|
157
145
|
const ref: SessionRef = {
|
|
@@ -164,8 +152,17 @@ export function buildFamilyIndex(
|
|
|
164
152
|
if (h.parentSession) ref.parentSession = h.parentSession
|
|
165
153
|
byId.set(h.id, ref)
|
|
166
154
|
}
|
|
155
|
+
return byId
|
|
156
|
+
}
|
|
167
157
|
|
|
168
|
-
|
|
158
|
+
/**
|
|
159
|
+
* 阶段 2:childrenOf——parentSession(文件路径)→ 反查父 sessionId → key 用父 sessionId。
|
|
160
|
+
* 反查不到父 → 无法建反查关系,跳过(不报错,design §2 容错)。
|
|
161
|
+
*/
|
|
162
|
+
function buildChildrenOf(
|
|
163
|
+
headers: Entry[],
|
|
164
|
+
byId: Map<string, SessionRef>,
|
|
165
|
+
): Map<string, SessionRef[]> {
|
|
169
166
|
const childrenOf = new Map<string, SessionRef[]>()
|
|
170
167
|
for (const h of headers) {
|
|
171
168
|
if (!h.parentSession) continue
|
|
@@ -177,8 +174,17 @@ export function buildFamilyIndex(
|
|
|
177
174
|
list.push(childRef)
|
|
178
175
|
childrenOf.set(parentSid, list)
|
|
179
176
|
}
|
|
177
|
+
return childrenOf
|
|
178
|
+
}
|
|
180
179
|
|
|
181
|
-
|
|
180
|
+
/**
|
|
181
|
+
* 阶段 3:subagentIdentities → subagentsByRoot。
|
|
182
|
+
* 坏数据(缺 rootSessionId/slug)跳过,不报错(design §2 容错)。
|
|
183
|
+
*/
|
|
184
|
+
function buildSubagentsByRoot(
|
|
185
|
+
subagentIdentities: Entry[],
|
|
186
|
+
fileStats: Map<string, { mtime: number; size: number }>,
|
|
187
|
+
): Map<string, SubagentRef[]> {
|
|
182
188
|
const subagentsByRoot = new Map<string, SubagentRef[]>()
|
|
183
189
|
for (const ident of subagentIdentities) {
|
|
184
190
|
if (!isSubagentIdentityData(ident.data)) continue // 坏数据(缺 rootSessionId/slug)跳过
|
|
@@ -207,7 +213,27 @@ export function buildFamilyIndex(
|
|
|
207
213
|
list.push(ref)
|
|
208
214
|
subagentsByRoot.set(ident.data.rootSessionId, list)
|
|
209
215
|
}
|
|
216
|
+
return subagentsByRoot
|
|
217
|
+
}
|
|
210
218
|
|
|
219
|
+
/**
|
|
220
|
+
* 从已读入的 session headers + subagent identity entries 建家族索引(纯逻辑,无 IO)。
|
|
221
|
+
*
|
|
222
|
+
* - headers(type=session)→ byId + childrenOf(parentSession 文件路径反查父 sessionId)
|
|
223
|
+
* - subagentIdentities(type=custom, customType=subagent-identity)→ subagentsByRoot
|
|
224
|
+
* - fileStats 原样存入 index,供 cleanedUp 判断
|
|
225
|
+
*
|
|
226
|
+
* 坏数据容错:identity 缺 rootSessionId/slug 跳过;parentSession 反查不到父(父文件
|
|
227
|
+
* 未被扫描到)该 entry 不进 childrenOf——均不报错,符合 pi 坏 session 容错(design §2)。
|
|
228
|
+
*/
|
|
229
|
+
export function buildFamilyIndex(
|
|
230
|
+
headers: Entry[],
|
|
231
|
+
subagentIdentities: Entry[],
|
|
232
|
+
fileStats: Map<string, { mtime: number; size: number }>,
|
|
233
|
+
): FamilyIndex {
|
|
234
|
+
const byId = buildById(headers, fileStats)
|
|
235
|
+
const childrenOf = buildChildrenOf(headers, byId)
|
|
236
|
+
const subagentsByRoot = buildSubagentsByRoot(subagentIdentities, fileStats)
|
|
211
237
|
return { byId, childrenOf, subagentsByRoot, fileStats }
|
|
212
238
|
}
|
|
213
239
|
|
package/src/core/parser.ts
CHANGED
|
@@ -51,6 +51,31 @@ function isMessageRole(v: unknown): v is 'user' | 'assistant' | 'toolResult' {
|
|
|
51
51
|
return v === 'user' || v === 'assistant' || v === 'toolResult'
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 归一化 message 字段(阶段 helper:role 经值守卫收窄,缺/非法 role 时
|
|
56
|
+
* 返回 undefined,调用方丢弃 message 字段——接口 role 必填)。
|
|
57
|
+
*/
|
|
58
|
+
function parseEntryMessage(raw: unknown): Entry['message'] | undefined {
|
|
59
|
+
if (raw === null || typeof raw !== 'object') return undefined
|
|
60
|
+
const m = raw as Record<string, unknown>
|
|
61
|
+
if (!isMessageRole(m.role)) return undefined
|
|
62
|
+
const message: NonNullable<Entry['message']> = { role: m.role, content: m.content }
|
|
63
|
+
if (Array.isArray(m.toolCalls)) message.toolCalls = m.toolCalls
|
|
64
|
+
// toolResult 自带的工具关联字段(O2/O3 用,additive,实测 515/515 存在)
|
|
65
|
+
if (typeof m.toolName === 'string') message.toolName = m.toolName
|
|
66
|
+
if (typeof m.toolCallId === 'string') message.toolCallId = m.toolCallId
|
|
67
|
+
return message
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 归一化剩余可选字段(阶段 helper:customType/data/parentSession/cwd/summary)。 */
|
|
71
|
+
function assignOptionalFields(obj: Record<string, unknown>, entry: Entry): void {
|
|
72
|
+
if (typeof obj.customType === 'string') entry.customType = obj.customType
|
|
73
|
+
if (obj.data !== undefined) entry.data = obj.data
|
|
74
|
+
if (typeof obj.parentSession === 'string') entry.parentSession = obj.parentSession
|
|
75
|
+
if (typeof obj.cwd === 'string') entry.cwd = obj.cwd
|
|
76
|
+
if (obj.summary !== undefined) entry.summary = obj.summary
|
|
77
|
+
}
|
|
78
|
+
|
|
54
79
|
/**
|
|
55
80
|
* 把单个已 JSON.parse 成功的原始对象归一化为 Entry。
|
|
56
81
|
* 缺必填结构字段(type/id)返回 undefined,调用方计为坏行(skippedLines++)。
|
|
@@ -74,24 +99,10 @@ function toEntry(raw: unknown): Entry | undefined {
|
|
|
74
99
|
}
|
|
75
100
|
if (typeof obj.timestamp === 'string') entry.timestamp = obj.timestamp
|
|
76
101
|
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
const m = obj.message as Record<string, unknown>
|
|
80
|
-
if (isMessageRole(m.role)) {
|
|
81
|
-
const message: NonNullable<Entry['message']> = { role: m.role, content: m.content }
|
|
82
|
-
if (Array.isArray(m.toolCalls)) message.toolCalls = m.toolCalls
|
|
83
|
-
// toolResult 自带的工具关联字段(O2/O3 用,additive,实测 515/515 存在)
|
|
84
|
-
if (typeof m.toolName === 'string') message.toolName = m.toolName
|
|
85
|
-
if (typeof m.toolCallId === 'string') message.toolCallId = m.toolCallId
|
|
86
|
-
entry.message = message
|
|
87
|
-
}
|
|
88
|
-
}
|
|
102
|
+
const message = parseEntryMessage(obj.message)
|
|
103
|
+
if (message !== undefined) entry.message = message
|
|
89
104
|
|
|
90
|
-
|
|
91
|
-
if (obj.data !== undefined) entry.data = obj.data
|
|
92
|
-
if (typeof obj.parentSession === 'string') entry.parentSession = obj.parentSession
|
|
93
|
-
if (typeof obj.cwd === 'string') entry.cwd = obj.cwd
|
|
94
|
-
if (obj.summary !== undefined) entry.summary = obj.summary
|
|
105
|
+
assignOptionalFields(obj, entry)
|
|
95
106
|
|
|
96
107
|
return entry
|
|
97
108
|
}
|
package/src/core/render.ts
CHANGED
|
@@ -330,6 +330,61 @@ function leafParsedBytes(turns: Turn[]): number {
|
|
|
330
330
|
return parsedBytes
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
/** allBranches:对每个 brief 标注所在 turn 的 forkPoint(找到才标 branch)。 */
|
|
334
|
+
function applyBranchLabels(briefs: TurnBrief[], turns: Turn[], tree: TreeView): void {
|
|
335
|
+
for (const b of briefs) {
|
|
336
|
+
const fp = findBranchForkPoint(turns[b.index], tree)
|
|
337
|
+
if (fp !== undefined) b.branch = fp
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* 降级序逐行渲染(design §3.5 算法 1 step3):level 0 全有;超预算降到 level 1
|
|
343
|
+
* 砍 assistantBrief;仍超降到 level 2 砍 toolSummary(骨架)。
|
|
344
|
+
* 直接改写 brief 的 assistantBrief/toolSummary 字段,返回行缓存。
|
|
345
|
+
*/
|
|
346
|
+
function renderDegradingLines(
|
|
347
|
+
briefs: TurnBrief[],
|
|
348
|
+
tree: TreeView,
|
|
349
|
+
perTurnCharBudget: number,
|
|
350
|
+
): string[] {
|
|
351
|
+
const lineCache: string[] = []
|
|
352
|
+
for (const b of briefs) {
|
|
353
|
+
const branchSize = b.branch !== undefined ? tree.branches.get(b.branch) : undefined
|
|
354
|
+
let line = formatLine(b, LINE_LEVEL_FULL, branchSize)
|
|
355
|
+
if (line.length > perTurnCharBudget) {
|
|
356
|
+
// 超预算:先砍 assistantBrief(level 1),仍超再砍 toolSummary(level 2,骨架)
|
|
357
|
+
b.assistantBrief = ''
|
|
358
|
+
line = formatLine(b, LINE_LEVEL_NO_ASSISTANT, branchSize)
|
|
359
|
+
if (line.length > perTurnCharBudget) {
|
|
360
|
+
b.toolSummary = ''
|
|
361
|
+
line = formatLine(b, LINE_LEVEL_SKELETON, branchSize)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
lineCache.push(line)
|
|
365
|
+
}
|
|
366
|
+
return lineCache
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** 总预算截断(从尾部丢弃):就地截短 lines 与 briefs,返回最终 totalChars 与 truncated。 */
|
|
370
|
+
function truncateToTotalBudget(
|
|
371
|
+
briefs: TurnBrief[],
|
|
372
|
+
lines: string[],
|
|
373
|
+
budget: number,
|
|
374
|
+
): { totalChars: number; truncated: number | undefined } {
|
|
375
|
+
let totalChars = lines.reduce((s, l) => s + l.length, 0)
|
|
376
|
+
if (totalChars / CHARS_PER_TOKEN <= budget) return { totalChars, truncated: undefined }
|
|
377
|
+
let kept = lines.length
|
|
378
|
+
while (kept > 0 && totalChars / CHARS_PER_TOKEN > budget) {
|
|
379
|
+
kept--
|
|
380
|
+
totalChars -= lines[kept].length
|
|
381
|
+
}
|
|
382
|
+
const truncated = lines.length - kept
|
|
383
|
+
lines.length = kept
|
|
384
|
+
briefs.length = kept
|
|
385
|
+
return { totalChars, truncated }
|
|
386
|
+
}
|
|
387
|
+
|
|
333
388
|
export function renderOutline(
|
|
334
389
|
turns: Turn[],
|
|
335
390
|
tree: TreeView,
|
|
@@ -365,46 +420,16 @@ export function renderOutline(
|
|
|
365
420
|
const briefs: TurnBrief[] = turns.map(computeBrief)
|
|
366
421
|
|
|
367
422
|
// allBranches:标注 forkPoint
|
|
368
|
-
if (allBranches)
|
|
369
|
-
for (const b of briefs) {
|
|
370
|
-
const fp = findBranchForkPoint(turns[b.index], tree)
|
|
371
|
-
if (fp !== undefined) b.branch = fp
|
|
372
|
-
}
|
|
373
|
-
}
|
|
423
|
+
if (allBranches) applyBranchLabels(briefs, turns, tree)
|
|
374
424
|
|
|
375
425
|
// 2. perTurnBudget(token → chars×4)
|
|
376
426
|
const perTurnCharBudget = (budget / turns.length) * CHARS_PER_TOKEN
|
|
377
427
|
|
|
378
|
-
// 3.
|
|
379
|
-
const lineCache
|
|
380
|
-
for (const b of briefs) {
|
|
381
|
-
const branchSize = b.branch !== undefined ? tree.branches.get(b.branch) : undefined
|
|
382
|
-
let line = formatLine(b, LINE_LEVEL_FULL, branchSize)
|
|
383
|
-
if (line.length > perTurnCharBudget) {
|
|
384
|
-
// 超预算:先砍 assistantBrief(level 1),仍超再砍 toolSummary(level 2,骨架)
|
|
385
|
-
b.assistantBrief = ''
|
|
386
|
-
line = formatLine(b, LINE_LEVEL_NO_ASSISTANT, branchSize)
|
|
387
|
-
if (line.length > perTurnCharBudget) {
|
|
388
|
-
b.toolSummary = ''
|
|
389
|
-
line = formatLine(b, LINE_LEVEL_SKELETON, branchSize)
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
lineCache.push(line)
|
|
393
|
-
}
|
|
428
|
+
// 3. 降级序渲染:level 0 全有 → level 1 砍 assistantBrief → level 2 骨架
|
|
429
|
+
const lineCache = renderDegradingLines(briefs, tree, perTurnCharBudget)
|
|
394
430
|
|
|
395
431
|
// 4. 总预算截断(从尾部丢弃)
|
|
396
|
-
|
|
397
|
-
let truncated: number | undefined
|
|
398
|
-
if (totalChars / CHARS_PER_TOKEN > budget) {
|
|
399
|
-
let kept = lineCache.length
|
|
400
|
-
while (kept > 0 && totalChars / CHARS_PER_TOKEN > budget) {
|
|
401
|
-
kept--
|
|
402
|
-
totalChars -= lineCache[kept].length
|
|
403
|
-
}
|
|
404
|
-
truncated = lineCache.length - kept
|
|
405
|
-
lineCache.length = kept
|
|
406
|
-
briefs.length = kept
|
|
407
|
-
}
|
|
432
|
+
const { totalChars, truncated } = truncateToTotalBudget(briefs, lineCache, budget)
|
|
408
433
|
|
|
409
434
|
const tokenEstimate = Math.ceil(totalChars / CHARS_PER_TOKEN)
|
|
410
435
|
return { turns: briefs, stats, tokenEstimate, truncated }
|
package/src/core/toolcall.ts
CHANGED
|
@@ -102,11 +102,111 @@ function numArg(args: Record<string, unknown>, k: string): number | undefined {
|
|
|
102
102
|
return typeof v === 'number' ? v : undefined
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// 各工具参数摘要 formatter(design §3.3 D1 表的表驱动形态,每个 case 体一个函数)
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/** 单工具参数摘要 formatter:输入归一化后的 arguments,输出摘要串(参数缺失时优雅降级)。 */
|
|
110
|
+
type ArgsFormatter = (args: Record<string, unknown>) => string
|
|
111
|
+
|
|
112
|
+
function formatBashArgs(args: Record<string, unknown>): string {
|
|
113
|
+
const cmd = strArg(args, 'command')
|
|
114
|
+
return cmd !== undefined ? `bash: ${truncate(cmd, BASH_CMD_MAX_CHARS)}` : 'bash'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function formatReadArgs(args: Record<string, unknown>): string {
|
|
118
|
+
const p = strArg(args, 'path')
|
|
119
|
+
return p !== undefined ? `read: ${basename(p)}` : 'read'
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatEditArgs(args: Record<string, unknown>): string {
|
|
123
|
+
const p = strArg(args, 'path')
|
|
124
|
+
const edits = args.edits
|
|
125
|
+
const blocks = Array.isArray(edits) ? edits.length : undefined
|
|
126
|
+
const head = p !== undefined ? `edit: ${basename(p)}` : 'edit'
|
|
127
|
+
return blocks !== undefined ? `${head} (${blocks} blocks)` : head
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatWriteArgs(args: Record<string, unknown>): string {
|
|
131
|
+
const p = strArg(args, 'path')
|
|
132
|
+
const c = strArg(args, 'content')
|
|
133
|
+
const head = p !== undefined ? `write: ${basename(p)}` : 'write'
|
|
134
|
+
if (c === undefined) return head
|
|
135
|
+
// utf8 字节转 KB 取整;小文件至少 1KB(0KB 无信息量)
|
|
136
|
+
const kb = Math.max(1, Math.round(Buffer.byteLength(c, 'utf8') / BYTES_PER_KB))
|
|
137
|
+
return `${head} (${kb}KB)`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatSubagentArgs(args: Record<string, unknown>): string {
|
|
141
|
+
const task = strArg(args, 'task')
|
|
142
|
+
return task !== undefined ? `subagent: ${truncate(task, SUBAGENT_TASK_MAX_CHARS)}` : 'subagent'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function formatHeadArgs(args: Record<string, unknown>): string {
|
|
146
|
+
const p = strArg(args, 'path')
|
|
147
|
+
// limit 可能是 number 或 string(实测 number,防御 string)
|
|
148
|
+
const lim: number | string | undefined = numArg(args, 'limit') ?? strArg(args, 'limit')
|
|
149
|
+
const head = p !== undefined ? `head: ${basename(p)}` : 'head'
|
|
150
|
+
return lim !== undefined ? `${head} (${lim})` : head
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function formatTodoArgs(args: Record<string, unknown>): string {
|
|
154
|
+
const action = strArg(args, 'action')
|
|
155
|
+
if (action === undefined) return 'todo'
|
|
156
|
+
// id 可能是 number(todo 列表 id)或 string
|
|
157
|
+
const id = args.id
|
|
158
|
+
const idStr =
|
|
159
|
+
typeof id === 'string' ? id : typeof id === 'number' ? String(id) : undefined
|
|
160
|
+
return idStr !== undefined ? `todo: ${action}(${idStr})` : `todo: ${action}`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function formatCwGateArgs(args: Record<string, unknown>): string {
|
|
164
|
+
const phase = args.phase
|
|
165
|
+
return phase !== undefined ? `cw-gate: phase=${String(phase)}` : 'cw-gate'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function formatCwInitArgs(args: Record<string, unknown>): string {
|
|
169
|
+
const slug = strArg(args, 'slug')
|
|
170
|
+
return slug !== undefined ? `cw-init: ${slug}` : 'cw-init'
|
|
171
|
+
}
|
|
172
|
+
|
|
105
173
|
/**
|
|
106
|
-
*
|
|
174
|
+
* 未知工具 fallback(原 switch default):arguments 非空 → name: <json 前50>;
|
|
175
|
+
* 空对象 → 仅 name({} 无信息量)。
|
|
176
|
+
*/
|
|
177
|
+
function formatUnknownTool(name: string, args: Record<string, unknown>): string {
|
|
178
|
+
if (Object.keys(args).length === 0) return name
|
|
179
|
+
try {
|
|
180
|
+
return `${name}: ${truncate(JSON.stringify(args), ARGS_JSON_MAX_CHARS)}`
|
|
181
|
+
} catch {
|
|
182
|
+
return name
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 工具名 → 参数摘要 formatter 表(design §3.3 D1 表;phase-start 无参数维度恒返常量)。
|
|
188
|
+
* 用 Map 而非普通对象:name 是外部输入(pi session JSONL 的 toolName),普通对象查表会
|
|
189
|
+
* 命中 Object.prototype 上的键(如 "constructor"),导致把非函数当 formatter 调用;Map 只查自身键。
|
|
190
|
+
*/
|
|
191
|
+
const TOOL_SUMMARY_FORMATTERS: ReadonlyMap<string, ArgsFormatter> = new Map([
|
|
192
|
+
['bash', formatBashArgs],
|
|
193
|
+
['read', formatReadArgs],
|
|
194
|
+
['edit', formatEditArgs],
|
|
195
|
+
['write', formatWriteArgs],
|
|
196
|
+
['subagent', formatSubagentArgs],
|
|
197
|
+
['head', formatHeadArgs],
|
|
198
|
+
['todo', formatTodoArgs],
|
|
199
|
+
['coding-workflow-gate', formatCwGateArgs],
|
|
200
|
+
['coding-workflow-init', formatCwInitArgs],
|
|
201
|
+
['coding-workflow-phase-start', () => 'cw-phase-start'],
|
|
202
|
+
])
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 按工具类型把 ToolCallInfo 映射成参数摘要串(design §3.3 D1 表,表驱动分发)。
|
|
107
206
|
*
|
|
108
|
-
*
|
|
109
|
-
* 前 50 字;arguments 为空对象时省略 JSON
|
|
207
|
+
* 参数缺失时优雅降级(只输出工具名或省略对应维度)。未知工具(表落空)走
|
|
208
|
+
* formatUnknownTool:带 arguments JSON 前 50 字;arguments 为空对象时省略 JSON
|
|
209
|
+
* (`{}` 无信息量)。
|
|
110
210
|
*
|
|
111
211
|
* 单位口径:edit 的 blocks 是参数维度(edits 数组长度),write 的 KB 是参数维度(content 字节),
|
|
112
212
|
* 这两者已含参数规模;O2/O3 的 toolResult 摘要在此基础再加结果规模时,bash/read 单独 append
|
|
@@ -114,70 +214,6 @@ function numArg(args: Record<string, unknown>, k: string): number | undefined {
|
|
|
114
214
|
*/
|
|
115
215
|
export function formatToolCallSummary(tc: ToolCallInfo): string {
|
|
116
216
|
const { name, arguments: args } = tc
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
case 'bash': {
|
|
120
|
-
const cmd = strArg(args, 'command')
|
|
121
|
-
return cmd !== undefined ? `bash: ${truncate(cmd, BASH_CMD_MAX_CHARS)}` : 'bash'
|
|
122
|
-
}
|
|
123
|
-
case 'read': {
|
|
124
|
-
const p = strArg(args, 'path')
|
|
125
|
-
return p !== undefined ? `read: ${basename(p)}` : 'read'
|
|
126
|
-
}
|
|
127
|
-
case 'edit': {
|
|
128
|
-
const p = strArg(args, 'path')
|
|
129
|
-
const edits = args.edits
|
|
130
|
-
const blocks = Array.isArray(edits) ? edits.length : undefined
|
|
131
|
-
const head = p !== undefined ? `edit: ${basename(p)}` : 'edit'
|
|
132
|
-
return blocks !== undefined ? `${head} (${blocks} blocks)` : head
|
|
133
|
-
}
|
|
134
|
-
case 'write': {
|
|
135
|
-
const p = strArg(args, 'path')
|
|
136
|
-
const c = strArg(args, 'content')
|
|
137
|
-
const head = p !== undefined ? `write: ${basename(p)}` : 'write'
|
|
138
|
-
if (c === undefined) return head
|
|
139
|
-
// utf8 字节转 KB 取整;小文件至少 1KB(0KB 无信息量)
|
|
140
|
-
const kb = Math.max(1, Math.round(Buffer.byteLength(c, 'utf8') / BYTES_PER_KB))
|
|
141
|
-
return `${head} (${kb}KB)`
|
|
142
|
-
}
|
|
143
|
-
case 'subagent': {
|
|
144
|
-
const task = strArg(args, 'task')
|
|
145
|
-
return task !== undefined ? `subagent: ${truncate(task, SUBAGENT_TASK_MAX_CHARS)}` : 'subagent'
|
|
146
|
-
}
|
|
147
|
-
case 'head': {
|
|
148
|
-
const p = strArg(args, 'path')
|
|
149
|
-
// limit 可能是 number 或 string(实测 number,防御 string)
|
|
150
|
-
const lim: number | string | undefined = numArg(args, 'limit') ?? strArg(args, 'limit')
|
|
151
|
-
const head = p !== undefined ? `head: ${basename(p)}` : 'head'
|
|
152
|
-
return lim !== undefined ? `${head} (${lim})` : head
|
|
153
|
-
}
|
|
154
|
-
case 'todo': {
|
|
155
|
-
const action = strArg(args, 'action')
|
|
156
|
-
if (action === undefined) return 'todo'
|
|
157
|
-
// id 可能是 number(todo 列表 id)或 string
|
|
158
|
-
const id = args.id
|
|
159
|
-
const idStr =
|
|
160
|
-
typeof id === 'string' ? id : typeof id === 'number' ? String(id) : undefined
|
|
161
|
-
return idStr !== undefined ? `todo: ${action}(${idStr})` : `todo: ${action}`
|
|
162
|
-
}
|
|
163
|
-
case 'coding-workflow-gate': {
|
|
164
|
-
const phase = args.phase
|
|
165
|
-
return phase !== undefined ? `cw-gate: phase=${String(phase)}` : 'cw-gate'
|
|
166
|
-
}
|
|
167
|
-
case 'coding-workflow-init': {
|
|
168
|
-
const slug = strArg(args, 'slug')
|
|
169
|
-
return slug !== undefined ? `cw-init: ${slug}` : 'cw-init'
|
|
170
|
-
}
|
|
171
|
-
case 'coding-workflow-phase-start':
|
|
172
|
-
return 'cw-phase-start'
|
|
173
|
-
default: {
|
|
174
|
-
// 未知工具:arguments 非空 → name: <json 前50>;空对象 → 仅 name({} 无信息量)
|
|
175
|
-
if (Object.keys(args).length === 0) return name
|
|
176
|
-
try {
|
|
177
|
-
return `${name}: ${truncate(JSON.stringify(args), ARGS_JSON_MAX_CHARS)}`
|
|
178
|
-
} catch {
|
|
179
|
-
return name
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
217
|
+
const formatter = TOOL_SUMMARY_FORMATTERS.get(name)
|
|
218
|
+
return formatter !== undefined ? formatter(args) : formatUnknownTool(name, args)
|
|
183
219
|
}
|