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