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