@tduyng/pi-prewalk 0.0.0-stage → 0.0.2
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/LICENSE +178 -0
- package/README.md +143 -2
- package/package.json +42 -4
- package/src/bash-writes.ts +42 -0
- package/src/config.ts +108 -0
- package/src/index.ts +934 -0
- package/src/logic.ts +384 -0
- package/src/outcomes.ts +127 -0
package/src/logic.ts
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { tmpdir } from 'node:os'
|
|
2
|
+
import { resolve, sep } from 'node:path'
|
|
3
|
+
import type { PrewalkMode } from './config.js'
|
|
4
|
+
|
|
5
|
+
export const STATE_TYPE = 'prewalk'
|
|
6
|
+
|
|
7
|
+
export const SKILL_BLOCK_PREFIX = '<skill '
|
|
8
|
+
|
|
9
|
+
export const PLAN_TOOL_NAME = 'prewalk_plan'
|
|
10
|
+
|
|
11
|
+
export const MAX_PLAN_STEPS = 20
|
|
12
|
+
|
|
13
|
+
export const MAX_PLAN_FACTS = 100
|
|
14
|
+
|
|
15
|
+
export const PLANNING_BRIEF = `You are the planner for this task. A cheaper, weaker model implements your plan from this same context. It will not re-derive anything and it cannot recover from a vague step.
|
|
16
|
+
1. Explore the relevant code until you understand the change fully. Prefer rg/fd with narrow scope over broad reads. Use web_search for facts the codebase does not hold, never to look up the fix, and record what you learn in the plan's facts.
|
|
17
|
+
2. Call ${PLAN_TOOL_NAME} once, carrying everything the implementer would otherwise have to work out again: the facts you established (with file:line), and for each step its file, the exact anchor text to edit at, what changes, and the command that verifies it. If a step needs a judgement call the implementer cannot make, make it now or split the step.
|
|
18
|
+
3. Then land the first step's edit yourself and run its verification. Work autonomously until the task is complete. Do not stop to ask for plan approval.
|
|
19
|
+
4. A task longer than ${MAX_PLAN_STEPS} steps plans its first phase only. When the executor finishes, the task comes back to you for review in this same context, and you plan the next phase then.`
|
|
20
|
+
|
|
21
|
+
export const EDIT_TOOLS = new Set(['edit', 'write', 'multiedit', 'notebookedit'])
|
|
22
|
+
|
|
23
|
+
export const READ_ONLY_TOOLS = new Set(['read', 'grep', 'find', 'ls', 'glob'])
|
|
24
|
+
|
|
25
|
+
export type PlanStep = {
|
|
26
|
+
file: string
|
|
27
|
+
change: string
|
|
28
|
+
anchor?: string
|
|
29
|
+
verify?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type PrewalkPlan = {
|
|
33
|
+
summary: string
|
|
34
|
+
facts: string[]
|
|
35
|
+
steps: PlanStep[]
|
|
36
|
+
check: string
|
|
37
|
+
outOfScope: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ModelRef = { provider: string; id: string }
|
|
41
|
+
|
|
42
|
+
const MIN_SUFFIX = 3
|
|
43
|
+
|
|
44
|
+
export function makeSpecPredicate(models: readonly ModelRef[]): (token: string) => boolean {
|
|
45
|
+
return (token) => {
|
|
46
|
+
const lower = token.toLowerCase()
|
|
47
|
+
if (models.some((m) => modelRef(m).toLowerCase() === lower || m.id.toLowerCase() === lower)) {
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
if (lower.length < MIN_SUFFIX) return false
|
|
51
|
+
return models.filter((m) => m.id.toLowerCase().endsWith(lower)).length === 1
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type PrewalkState = {
|
|
56
|
+
executor: string
|
|
57
|
+
planner: string | null
|
|
58
|
+
swapped: boolean
|
|
59
|
+
forced: ForceRoute | null
|
|
60
|
+
inFlight: boolean
|
|
61
|
+
plan: PrewalkPlan | null
|
|
62
|
+
planDemanded: boolean
|
|
63
|
+
stepIndex: number
|
|
64
|
+
/** Failed tool results in a row on the step in flight, which is what a wall looks like. */
|
|
65
|
+
failures: number
|
|
66
|
+
/** Upgrades spent on this task, so a wall cannot ping-pong between the models. */
|
|
67
|
+
escalations: number
|
|
68
|
+
/** The last failure, trimmed, which is what the planner reads when it takes the task back. */
|
|
69
|
+
wall: string | null
|
|
70
|
+
reviewing: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const MAX_WALL_CHARS = 400
|
|
74
|
+
|
|
75
|
+
export type EscalationBar = { failures: number; upgrades: number }
|
|
76
|
+
|
|
77
|
+
export const ESCALATION_BAR: EscalationBar = { failures: 3, upgrades: 2 }
|
|
78
|
+
|
|
79
|
+
export const PLANNER_GRACE_CALLS = 3
|
|
80
|
+
|
|
81
|
+
/** Below this a prompt is a redirect rather than a task: `go on` stays, `fix tests` routes. */
|
|
82
|
+
export const MIN_PROMPT_CHARS = 8
|
|
83
|
+
|
|
84
|
+
export function shouldAsk(text: string): boolean {
|
|
85
|
+
return text.trim().length >= MIN_PROMPT_CHARS
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type PrewalkRole = 'planner' | 'executor'
|
|
89
|
+
|
|
90
|
+
export type ForceRoute = 'plan' | 'run'
|
|
91
|
+
|
|
92
|
+
export type ParsedPrewalkArgs =
|
|
93
|
+
| { kind: 'bare' }
|
|
94
|
+
| { kind: 'status' }
|
|
95
|
+
| { kind: 'stop' }
|
|
96
|
+
| { kind: 'models' }
|
|
97
|
+
| { kind: 'mode'; mode: PrewalkMode }
|
|
98
|
+
| { kind: 'role'; role: PrewalkRole; chain: string[] }
|
|
99
|
+
| { kind: 'force'; route: ForceRoute; task: string }
|
|
100
|
+
| { kind: 'start'; planner: string | null; executor: string | null; task: string }
|
|
101
|
+
|
|
102
|
+
const STOP_WORDS = new Set(['stop', 'cancel', 'clear', 'off', 'done'])
|
|
103
|
+
const STATUS_WORDS = new Set(['status', 'state', '?'])
|
|
104
|
+
const MODEL_LIST_WORDS = new Set(['models', 'list', 'ls'])
|
|
105
|
+
const MODE_WORDS = new Set(['mode', 'routing'])
|
|
106
|
+
const ROLE_WORDS = new Set<string>(['planner', 'executor'])
|
|
107
|
+
const ARM_WORDS = new Set(['on', 'auto', 'arm'])
|
|
108
|
+
const AUTO_WORDS = new Set(['auto', 'on', 'routed'])
|
|
109
|
+
const PLAN_MODE_WORDS = new Set(['plan', 'classic'])
|
|
110
|
+
const OFF_MODE_WORDS = new Set(['off', 'none', 'cheap'])
|
|
111
|
+
const ROUTE_WORDS = new Map<string, ForceRoute>([
|
|
112
|
+
['plan', 'plan'],
|
|
113
|
+
['run', 'run'],
|
|
114
|
+
['cheap', 'run']
|
|
115
|
+
])
|
|
116
|
+
|
|
117
|
+
function parseMode(text: string): PrewalkMode | null {
|
|
118
|
+
const word = splitFirstToken(text.trim().toLowerCase())[0]
|
|
119
|
+
if (OFF_MODE_WORDS.has(word)) return 'off'
|
|
120
|
+
if (AUTO_WORDS.has(word) || PLAN_MODE_WORDS.has(word)) return 'plan'
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function leadingModelSpecs(text: string, isModelSpec: (token: string) => boolean): string[] {
|
|
125
|
+
const chain: string[] = []
|
|
126
|
+
let rest = text
|
|
127
|
+
while (rest !== '') {
|
|
128
|
+
const [token, after] = splitFirstToken(rest)
|
|
129
|
+
if (!isModelSpec(token)) break
|
|
130
|
+
chain.push(token)
|
|
131
|
+
rest = after
|
|
132
|
+
}
|
|
133
|
+
return chain
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function splitFirstToken(text: string): [string, string] {
|
|
137
|
+
const space = text.search(/\s/)
|
|
138
|
+
if (space === -1) return [text, '']
|
|
139
|
+
return [text.slice(0, space), text.slice(space).trim()]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function parseArgs(args: string, isModelSpec: (token: string) => boolean): ParsedPrewalkArgs {
|
|
143
|
+
const trimmed = args.trim()
|
|
144
|
+
if (trimmed === '') return { kind: 'bare' }
|
|
145
|
+
const lower = trimmed.toLowerCase()
|
|
146
|
+
if (STOP_WORDS.has(lower)) return { kind: 'stop' }
|
|
147
|
+
if (STATUS_WORDS.has(lower)) return { kind: 'status' }
|
|
148
|
+
let rest = trimmed
|
|
149
|
+
const [head, afterHead] = splitFirstToken(trimmed)
|
|
150
|
+
if (ARM_WORDS.has(head.toLowerCase())) rest = afterHead
|
|
151
|
+
if (rest === '') return { kind: 'start', planner: null, executor: null, task: '' }
|
|
152
|
+
const [first, afterFirst] = splitFirstToken(rest)
|
|
153
|
+
if (!isModelSpec(first)) {
|
|
154
|
+
const word = first.toLowerCase()
|
|
155
|
+
if (MODEL_LIST_WORDS.has(word)) return { kind: 'models' }
|
|
156
|
+
const forced = ROUTE_WORDS.get(word)
|
|
157
|
+
if (forced !== undefined) return { kind: 'force', route: forced, task: afterFirst }
|
|
158
|
+
if (ROLE_WORDS.has(word)) {
|
|
159
|
+
const role: PrewalkRole = word === 'planner' ? 'planner' : 'executor'
|
|
160
|
+
return { kind: 'role', role, chain: leadingModelSpecs(afterFirst, isModelSpec) }
|
|
161
|
+
}
|
|
162
|
+
if (MODE_WORDS.has(word)) {
|
|
163
|
+
const mode = parseMode(afterFirst)
|
|
164
|
+
return mode === null ? { kind: 'status' } : { kind: 'mode', mode }
|
|
165
|
+
}
|
|
166
|
+
return { kind: 'start', planner: null, executor: null, task: rest }
|
|
167
|
+
}
|
|
168
|
+
const [second, afterSecond] = splitFirstToken(afterFirst)
|
|
169
|
+
if (second !== '' && isModelSpec(second)) {
|
|
170
|
+
return { kind: 'start', planner: first, executor: second, task: afterSecond }
|
|
171
|
+
}
|
|
172
|
+
return { kind: 'start', planner: null, executor: first, task: afterFirst }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type UserTextMessage = {
|
|
176
|
+
role: string
|
|
177
|
+
toolName?: string
|
|
178
|
+
content?: string | Array<{ type: string; text?: string }>
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function contextForModel<T extends UserTextMessage>(state: PrewalkState, messages: T[]): T[] {
|
|
182
|
+
if (state.plan !== null) replaceLatestPlanResult(messages, renderHandoff(state.plan))
|
|
183
|
+
return messages
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function wallBrief(plan: PrewalkPlan, stepIndex: number, failures: number): string {
|
|
187
|
+
const total = plan.steps.length
|
|
188
|
+
const step = Math.min(stepIndex + 1, total)
|
|
189
|
+
return `${NUDGE_PREFIX} ${failures} failures on step ${step}/${total}. The planner model takes this task back from the next request until its next edit: find the cause and fix it, or call ${PLAN_TOOL_NAME} with the steps that are left.`
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function reviewPrompt(plan: PrewalkPlan, stepIndex: number): string {
|
|
193
|
+
const total = plan.steps.length
|
|
194
|
+
return [
|
|
195
|
+
`${NUDGE_PREFIX} Review. The executor stopped with ${Math.min(stepIndex, total)}/${total} plan steps matched. Its edits are in the transcript above, so do not re-read files you can see there.`,
|
|
196
|
+
`1. Run \`git diff --stat\` and the final check \`${plan.check}\`.`,
|
|
197
|
+
'2. Check the work against the goal and fix any defect yourself.',
|
|
198
|
+
`3. If the goal needs work the plan did not cover, call ${PLAN_TOOL_NAME} with the remaining steps and land the first one.`,
|
|
199
|
+
'4. If the work is correct and complete, reply in one line.'
|
|
200
|
+
].join('\n')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function isScratchPath(path: string, cwd: string): boolean {
|
|
204
|
+
if (path === '') return false
|
|
205
|
+
const full = resolve(cwd, path)
|
|
206
|
+
const under = (root: string) => full === root || full.startsWith(root + sep)
|
|
207
|
+
return under(resolve(tmpdir())) && !under(resolve(cwd))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const HANDOFF_RULES = [
|
|
211
|
+
"Work the steps in order: make the edit, run that step's verify, then move to the next.",
|
|
212
|
+
'Read a file only to get the exact text you are about to edit, or when an anchor does not match. Do not re-explore; the findings above already settled what the code does.',
|
|
213
|
+
'Change nothing the steps do not name. No renames, no refactors, no extra error handling, no new files.',
|
|
214
|
+
'If a step keeps failing, report the exact error rather than switching to a different approach. Repeated failures on one step hand the task back to the planner.',
|
|
215
|
+
'When every step is done, run the final check and report what it printed.'
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
export function renderHandoff(plan: PrewalkPlan): string {
|
|
219
|
+
const lines = [
|
|
220
|
+
'[prewalk plan] The exploration above settled this plan. It is authoritative: follow it, do not redesign it.',
|
|
221
|
+
'',
|
|
222
|
+
`Goal: ${plan.summary}`
|
|
223
|
+
]
|
|
224
|
+
if (plan.facts.length > 0) {
|
|
225
|
+
lines.push('', 'Established by the exploration (do not re-derive):')
|
|
226
|
+
for (const fact of plan.facts) lines.push(`- ${fact}`)
|
|
227
|
+
}
|
|
228
|
+
lines.push('', 'Steps, in order:')
|
|
229
|
+
plan.steps.forEach((step, i) => {
|
|
230
|
+
lines.push(`${i + 1}. ${step.file}: ${step.change}`)
|
|
231
|
+
if (step.anchor) lines.push(` anchor: ${step.anchor}`)
|
|
232
|
+
if (step.verify) lines.push(` verify: ${step.verify}`)
|
|
233
|
+
})
|
|
234
|
+
if (plan.outOfScope.length > 0) {
|
|
235
|
+
lines.push('', `Out of scope, do not touch: ${plan.outOfScope.join('; ')}`)
|
|
236
|
+
}
|
|
237
|
+
lines.push('', `Final check: ${plan.check}`, '', 'How to work this plan:')
|
|
238
|
+
for (const rule of HANDOFF_RULES) lines.push(`- ${rule}`)
|
|
239
|
+
return lines.join('\n')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function replaceLatestPlanResult(messages: readonly UserTextMessage[], text: string): void {
|
|
243
|
+
const latest = messages.findLast((m) => m.toolName === PLAN_TOOL_NAME)
|
|
244
|
+
if (latest) latest.content = Array.isArray(latest.content) ? [{ type: 'text', text }] : text
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export type SwapDecision = 'ignore' | 'demand-plan' | 'swap'
|
|
248
|
+
|
|
249
|
+
export function swapDecision(
|
|
250
|
+
state: PrewalkState,
|
|
251
|
+
toolName: string,
|
|
252
|
+
inert = false,
|
|
253
|
+
demandedThisTurn = false
|
|
254
|
+
): SwapDecision {
|
|
255
|
+
if (state.swapped || state.reviewing || inert) return 'ignore'
|
|
256
|
+
const name = toolName.toLowerCase()
|
|
257
|
+
if (!EDIT_TOOLS.has(name) && name !== 'bash') return 'ignore'
|
|
258
|
+
if (state.plan === null && (!state.planDemanded || demandedThisTurn)) return 'demand-plan'
|
|
259
|
+
return 'swap'
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export const PLAN_DEMAND_REASON = `Before this edit, call ${PLAN_TOOL_NAME} once. A cheaper model takes over the moment the plan lands and implements it without re-reading what you read, so the plan carries what you know: the findings it cannot re-derive (with file:line), and for each step its file, the exact anchor text, the change, and the command that verifies it. Then repeat this edit.`
|
|
263
|
+
|
|
264
|
+
function samePath(stepFile: string, editedPath: string): boolean {
|
|
265
|
+
const a = stepFile.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
266
|
+
const b = editedPath.replace(/\\/g, '/')
|
|
267
|
+
return a === b || b.endsWith(`/${a}`) || a.endsWith(`/${b}`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function planProgress(plan: PrewalkPlan, stepIndex: number, matched: number) {
|
|
271
|
+
const total = plan.steps.length
|
|
272
|
+
const index = matched === -1 ? stepIndex : matched + 1
|
|
273
|
+
const next = plan.steps[index]
|
|
274
|
+
if (!next) {
|
|
275
|
+
return {
|
|
276
|
+
index,
|
|
277
|
+
footer: `[prewalk] ${total}/${total} steps done. Run the final check (${plan.check}) and report what it prints.`
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const head =
|
|
281
|
+
matched === -1
|
|
282
|
+
? `[prewalk] still on step ${stepIndex + 1}/${total}:`
|
|
283
|
+
: `[prewalk] step ${matched + 1}/${total} done. Next, step ${index + 1}/${total}:`
|
|
284
|
+
const parts = [`${head} ${next.file}: ${next.change}`]
|
|
285
|
+
if (next.anchor) parts.push(`anchor: ${next.anchor}`)
|
|
286
|
+
if (next.verify) parts.push(`verify: ${next.verify}`)
|
|
287
|
+
return { index, footer: parts.join('\n') }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function stepForPath(plan: PrewalkPlan, stepIndex: number, path: string): number {
|
|
291
|
+
for (let i = stepIndex; i < plan.steps.length; i++) {
|
|
292
|
+
const step = plan.steps[i]
|
|
293
|
+
if (step && samePath(step.file, path)) return i
|
|
294
|
+
}
|
|
295
|
+
return -1
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const COMMAND_TOKEN = /[^\s'"`;|&<>()]+/g
|
|
299
|
+
|
|
300
|
+
export function stepForCommand(plan: PrewalkPlan, stepIndex: number, command: string): number {
|
|
301
|
+
const tokens: string[] = command.match(COMMAND_TOKEN) ?? []
|
|
302
|
+
for (const token of tokens) {
|
|
303
|
+
const full = stepForPath(plan, stepIndex, token)
|
|
304
|
+
if (full !== -1) return full
|
|
305
|
+
}
|
|
306
|
+
for (let i = stepIndex; i < plan.steps.length; i++) {
|
|
307
|
+
const step = plan.steps[i]
|
|
308
|
+
if (!step) continue
|
|
309
|
+
const base = step.file.replace(/\\/g, '/').split('/').pop() ?? ''
|
|
310
|
+
if (base !== '' && tokens.includes(base)) return i
|
|
311
|
+
}
|
|
312
|
+
return -1
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export const NUDGE_PREFIX = '[prewalk]'
|
|
316
|
+
|
|
317
|
+
export function continuationNudge(plan: PrewalkPlan, stepIndex: number): string {
|
|
318
|
+
const total = plan.steps.length
|
|
319
|
+
const next = plan.steps[stepIndex]
|
|
320
|
+
if (!next) {
|
|
321
|
+
return `${NUDGE_PREFIX} All ${total} steps done. Run the final check (\`${plan.check}\`) and report what it prints.`
|
|
322
|
+
}
|
|
323
|
+
const verify = next.verify ? ` Run \`${next.verify}\` to verify.` : ''
|
|
324
|
+
return `${NUDGE_PREFIX} The plan is not finished (${stepIndex}/${total} steps done). Continue with step ${stepIndex + 1}/${total}: ${next.file}: ${next.change}.${verify}`
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function modelRef(model: ModelRef): string {
|
|
328
|
+
return `${model.provider}/${model.id}`
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function resolveModelSpec<T extends ModelRef>(
|
|
332
|
+
models: readonly T[],
|
|
333
|
+
spec: string
|
|
334
|
+
): { model: T } | { error: string } {
|
|
335
|
+
const lower = spec.toLowerCase()
|
|
336
|
+
const exact = models.filter((m) => modelRef(m).toLowerCase() === lower || m.id.toLowerCase() === lower)
|
|
337
|
+
if (exact.length === 1 && exact[0]) return { model: exact[0] }
|
|
338
|
+
if (exact.length > 1) return { error: ambiguous(exact, spec) }
|
|
339
|
+
const suffix = models.filter((m) => m.id.toLowerCase().endsWith(lower))
|
|
340
|
+
if (suffix.length === 1 && suffix[0]) return { model: suffix[0] }
|
|
341
|
+
if (suffix.length > 1) return { error: ambiguous(suffix, spec) }
|
|
342
|
+
return { error: `no model matching "${spec}"` }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function ambiguous(matches: readonly ModelRef[], spec: string): string {
|
|
346
|
+
const providers = [...new Set(matches.map((m) => m.provider))].join(', ')
|
|
347
|
+
return `"${spec}" matches ${matches.length} models (${providers}). Use provider/id.`
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function shortName(ref: string): string {
|
|
351
|
+
const slash = ref.indexOf('/')
|
|
352
|
+
return slash === -1 ? ref : ref.slice(slash + 1)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function badgeName(ref: string): string {
|
|
356
|
+
const slash = ref.lastIndexOf('/')
|
|
357
|
+
return slash === -1 ? ref : ref.slice(slash + 1)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function statusBadge(state: PrewalkState): string {
|
|
361
|
+
const pair = `${badgeName(state.planner ?? '?')}->${badgeName(state.executor)}`
|
|
362
|
+
if (!state.inFlight && state.plan === null) return `prewalk route ${pair}`
|
|
363
|
+
if (state.plan === null) return `prewalk ${state.swapped ? 'run' : 'plan'} ${pair}`
|
|
364
|
+
const total = state.plan.steps.length
|
|
365
|
+
if (state.reviewing) return `prewalk review ${state.stepIndex}/${total}`
|
|
366
|
+
if (!state.swapped) return `prewalk replan ${state.stepIndex}/${total}`
|
|
367
|
+
return state.stepIndex >= total ? `prewalk done ${total}/${total}` : `prewalk exec ${state.stepIndex}/${total}`
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function statusLabel(state: PrewalkState): string {
|
|
371
|
+
const planner = state.planner ? shortName(state.planner) : 'the current model'
|
|
372
|
+
const executor = shortName(state.executor)
|
|
373
|
+
if (state.plan === null) {
|
|
374
|
+
if (!state.inFlight) return `Prewalk routing between ${planner} and ${executor}, waiting for a task`
|
|
375
|
+
return state.swapped
|
|
376
|
+
? `Prewalk running this task on ${executor}, no plan written`
|
|
377
|
+
: `Prewalk planning on ${planner}, swap to ${executor} on first edit`
|
|
378
|
+
}
|
|
379
|
+
const total = state.plan.steps.length
|
|
380
|
+
return state.stepIndex >= total
|
|
381
|
+
? `Prewalk done, ${total}/${total} steps, on ${executor}`
|
|
382
|
+
: `Prewalk ${state.stepIndex}/${total} on ${executor}`
|
|
383
|
+
}
|
|
384
|
+
|
package/src/outcomes.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { SessionEntry } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { Validator } from 'ata-validator'
|
|
3
|
+
import { t } from 'ata-validator/t'
|
|
4
|
+
|
|
5
|
+
import { PLAN_TOOL_NAME, STATE_TYPE } from './logic.js'
|
|
6
|
+
|
|
7
|
+
export type RouteName = 'plan' | 'run'
|
|
8
|
+
|
|
9
|
+
export interface RouteRecord {
|
|
10
|
+
route: RouteName
|
|
11
|
+
/** True when a person chose the route, which makes it a control rather than evidence. */
|
|
12
|
+
forced: boolean
|
|
13
|
+
turns: number
|
|
14
|
+
cost: number
|
|
15
|
+
edits: number
|
|
16
|
+
/** The plan's own `check` ran in a shell call, which only a planned task can have. */
|
|
17
|
+
checked: boolean
|
|
18
|
+
models: string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const BashCall = t.object({ command: t.string() })
|
|
22
|
+
const isBashCall = new Validator(BashCall).isValidObject
|
|
23
|
+
|
|
24
|
+
const StoredPlan = t.object({ check: t.string() })
|
|
25
|
+
|
|
26
|
+
const StoredState = t.object({
|
|
27
|
+
forced: t.optional(t.union([t.literal('plan'), t.literal('run')])),
|
|
28
|
+
plan: t.optional(StoredPlan)
|
|
29
|
+
})
|
|
30
|
+
const isStoredState = new Validator(StoredState).isValidObject
|
|
31
|
+
|
|
32
|
+
const EditTools = new Set(['edit', 'write', 'multiedit', 'notebookedit'])
|
|
33
|
+
|
|
34
|
+
type Span = RouteRecord & { check: string | null }
|
|
35
|
+
|
|
36
|
+
function openSpan(route: RouteName | null, forced: boolean): Span {
|
|
37
|
+
return {
|
|
38
|
+
route: route ?? 'run',
|
|
39
|
+
forced,
|
|
40
|
+
turns: 0,
|
|
41
|
+
cost: 0,
|
|
42
|
+
edits: 0,
|
|
43
|
+
checked: false,
|
|
44
|
+
models: [],
|
|
45
|
+
check: null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every task prewalk routed, read back off the session transcript.
|
|
51
|
+
*
|
|
52
|
+
* A task is the span from one user prompt to the next. The route comes from the
|
|
53
|
+
* state entry in force when the prompt landed.
|
|
54
|
+
*/
|
|
55
|
+
export function routesOf(entries: readonly SessionEntry[]): RouteRecord[] {
|
|
56
|
+
const records: RouteRecord[] = []
|
|
57
|
+
let armed = false
|
|
58
|
+
let byHand: RouteName | null = null
|
|
59
|
+
let span: Span | null = null
|
|
60
|
+
|
|
61
|
+
const close = (): void => {
|
|
62
|
+
if (span === null) return
|
|
63
|
+
const { check: _check, ...record } = span
|
|
64
|
+
records.push(record)
|
|
65
|
+
span = null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (entry.type === 'custom' && entry.customType === STATE_TYPE && isStoredState(entry.data)) {
|
|
70
|
+
armed = true
|
|
71
|
+
if (entry.data.forced !== undefined) byHand = entry.data.forced
|
|
72
|
+
if (span !== null && entry.data.plan !== undefined) span.check = entry.data.plan.check
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
if (entry.type !== 'message') continue
|
|
76
|
+
if (entry.message.role === 'user') {
|
|
77
|
+
close()
|
|
78
|
+
span = armed ? openSpan(byHand, byHand !== null) : null
|
|
79
|
+
byHand = null
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if (entry.message.role !== 'assistant' || span === null) continue
|
|
83
|
+
|
|
84
|
+
const message = entry.message
|
|
85
|
+
span.turns += 1
|
|
86
|
+
span.cost += message.usage.cost.total
|
|
87
|
+
if (span.models.at(-1) !== message.model) span.models.push(message.model)
|
|
88
|
+
for (const block of message.content) {
|
|
89
|
+
if (block.type !== 'toolCall') continue
|
|
90
|
+
if (EditTools.has(block.name)) span.edits += 1
|
|
91
|
+
if (block.name === PLAN_TOOL_NAME) span.route = 'plan'
|
|
92
|
+
if (block.name !== 'bash' || span.check === null) continue
|
|
93
|
+
if (isBashCall(block.arguments) && block.arguments.command.includes(span.check)) {
|
|
94
|
+
span.checked = true
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
close()
|
|
99
|
+
return records
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface RouteSummary {
|
|
103
|
+
route: RouteName
|
|
104
|
+
tasks: number
|
|
105
|
+
byHand: number
|
|
106
|
+
edits: number
|
|
107
|
+
checked: number
|
|
108
|
+
totalCost: number
|
|
109
|
+
medianCost: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function summarizeRoutes(records: readonly RouteRecord[]): RouteSummary[] {
|
|
113
|
+
const routes: RouteName[] = ['plan', 'run']
|
|
114
|
+
return routes.map((route) => {
|
|
115
|
+
const forRoute = records.filter((record) => record.route === route)
|
|
116
|
+
const costs = forRoute.map((record) => record.cost).toSorted((a, b) => a - b)
|
|
117
|
+
return {
|
|
118
|
+
route,
|
|
119
|
+
tasks: forRoute.length,
|
|
120
|
+
byHand: forRoute.filter((record) => record.forced).length,
|
|
121
|
+
edits: forRoute.reduce((sum, record) => sum + record.edits, 0),
|
|
122
|
+
checked: forRoute.filter((record) => record.checked).length,
|
|
123
|
+
totalCost: forRoute.reduce((sum, record) => sum + record.cost, 0),
|
|
124
|
+
medianCost: costs.length === 0 ? 0 : (costs[Math.floor(costs.length / 2)] ?? 0)
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}
|