@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/index.ts
ADDED
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
ModelRoute,
|
|
5
|
+
ModelRouteRequest,
|
|
6
|
+
ToolDefinition
|
|
7
|
+
} from '@earendil-works/pi-coding-agent'
|
|
8
|
+
import {
|
|
9
|
+
contextForModel,
|
|
10
|
+
continuationNudge,
|
|
11
|
+
ESCALATION_BAR,
|
|
12
|
+
makeSpecPredicate,
|
|
13
|
+
modelRef,
|
|
14
|
+
parseArgs,
|
|
15
|
+
planProgress,
|
|
16
|
+
PLAN_DEMAND_REASON,
|
|
17
|
+
PLANNING_BRIEF,
|
|
18
|
+
PLAN_TOOL_NAME,
|
|
19
|
+
READ_ONLY_TOOLS,
|
|
20
|
+
resolveModelSpec,
|
|
21
|
+
shortName,
|
|
22
|
+
STATE_TYPE,
|
|
23
|
+
statusBadge,
|
|
24
|
+
statusLabel,
|
|
25
|
+
stepForCommand,
|
|
26
|
+
stepForPath,
|
|
27
|
+
swapDecision,
|
|
28
|
+
MAX_PLAN_FACTS,
|
|
29
|
+
MAX_PLAN_STEPS,
|
|
30
|
+
MAX_WALL_CHARS,
|
|
31
|
+
NUDGE_PREFIX,
|
|
32
|
+
SKILL_BLOCK_PREFIX,
|
|
33
|
+
shouldAsk,
|
|
34
|
+
isScratchPath,
|
|
35
|
+
PLANNER_GRACE_CALLS,
|
|
36
|
+
renderHandoff,
|
|
37
|
+
reviewPrompt,
|
|
38
|
+
wallBrief,
|
|
39
|
+
type ForceRoute,
|
|
40
|
+
type PrewalkState
|
|
41
|
+
} from './logic.js'
|
|
42
|
+
import { bashWritesFiles } from './bash-writes.js'
|
|
43
|
+
import { readEffectivePrewalkConfig, writePrewalkConfig, type PrewalkConfig, type PrewalkMode } from './config.js'
|
|
44
|
+
|
|
45
|
+
const MODE_LABEL: Record<PrewalkMode, string> = {
|
|
46
|
+
plan: 'every task plans',
|
|
47
|
+
off: 'the cheap model, no plan'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One decision: the model the task starts on. */
|
|
51
|
+
type Routed = { route: ForceRoute }
|
|
52
|
+
|
|
53
|
+
const ROUTER_PROVIDER = 'prewalk'
|
|
54
|
+
const ROUTER_ID = 'auto'
|
|
55
|
+
const ROUTER_REF = `${ROUTER_PROVIDER}/${ROUTER_ID}`
|
|
56
|
+
|
|
57
|
+
import { type Infer, Validator } from 'ata-validator'
|
|
58
|
+
import { t } from 'ata-validator/t'
|
|
59
|
+
|
|
60
|
+
type SessionModel = NonNullable<ExtensionContext['model']>
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* pi's api id for a virtual catalog entry. pi keeps that constant and its guard private, so a chain
|
|
64
|
+
* naming `prewalk/auto` would otherwise resolve and route() would return a virtual model as its
|
|
65
|
+
* target, which pi rejects with "which is not a physical model".
|
|
66
|
+
*/
|
|
67
|
+
const VIRTUAL_API = 'pi-virtual'
|
|
68
|
+
|
|
69
|
+
/** The catalog prewalk routes to: everything except the virtual entries, which pi cannot dispatch to. */
|
|
70
|
+
function physicalModels(ctx: ExtensionContext): readonly SessionModel[] {
|
|
71
|
+
return ctx.modelRegistry.getAvailable().filter((model) => model.api !== VIRTUAL_API)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A chain that resolves to nothing is a configuration problem, so prewalk stops routing rather than
|
|
76
|
+
* ending the turn. The model already in play wins.
|
|
77
|
+
*/
|
|
78
|
+
function heldModel(
|
|
79
|
+
ctx: ExtensionContext,
|
|
80
|
+
inPlay: ModelRouteRequest['previous'],
|
|
81
|
+
reason: string,
|
|
82
|
+
level: ModelRouteRequest['thinkingLevel']
|
|
83
|
+
): ModelRoute {
|
|
84
|
+
ctx.ui.notify(`${reason}; this task runs on the model already in play`, 'error')
|
|
85
|
+
const held = inPlay?.model ?? physicalModels(ctx)[0]
|
|
86
|
+
if (!held) throw new Error('prewalk: no model is available to hold the task on')
|
|
87
|
+
return { model: held, thinkingLevel: inPlay?.thinkingLevel ?? level }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const PLAN_STEP_SCHEMA = t.object({
|
|
91
|
+
file: t.string({ maxLength: 4_000 }),
|
|
92
|
+
change: t.string({ maxLength: 8_000 }),
|
|
93
|
+
anchor: t.optional(t.string({ maxLength: 8_000 })),
|
|
94
|
+
verify: t.optional(t.string({ maxLength: 2_000 }))
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
/** The shape a `prewalk` session entry holds, so the read that restores it is bounded. */
|
|
98
|
+
const STORED_ENTRY_SCHEMA = t.object({
|
|
99
|
+
executor: t.optional(t.string()),
|
|
100
|
+
planner: t.optional(t.string()),
|
|
101
|
+
swapped: t.optional(t.boolean()),
|
|
102
|
+
inFlight: t.optional(t.boolean()),
|
|
103
|
+
reviewing: t.optional(t.boolean()),
|
|
104
|
+
planDemanded: t.optional(t.boolean()),
|
|
105
|
+
forced: t.optional(t.union([t.literal('plan'), t.literal('run')])),
|
|
106
|
+
stepIndex: t.optional(t.integer({ minimum: 0 })),
|
|
107
|
+
failures: t.optional(t.integer({ minimum: 0 })),
|
|
108
|
+
escalations: t.optional(t.integer({ minimum: 0 })),
|
|
109
|
+
wall: t.optional(t.union([t.string({ maxLength: MAX_WALL_CHARS }), t.null()])),
|
|
110
|
+
plan: t.optional(
|
|
111
|
+
t.object({
|
|
112
|
+
summary: t.string({ maxLength: 8_000 }),
|
|
113
|
+
facts: t.array(t.string(), { maxItems: MAX_PLAN_FACTS }),
|
|
114
|
+
steps: t.array(PLAN_STEP_SCHEMA, { maxItems: MAX_PLAN_STEPS }),
|
|
115
|
+
check: t.string({ maxLength: 2_000 }),
|
|
116
|
+
outOfScope: t.array(t.string(), { maxItems: MAX_PLAN_FACTS })
|
|
117
|
+
})
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
const isStoredEntrySchema = new Validator(STORED_ENTRY_SCHEMA).isValidObject
|
|
121
|
+
|
|
122
|
+
const TOOL_INPUT_SCHEMA = t.object({
|
|
123
|
+
path: t.optional(t.string()),
|
|
124
|
+
file: t.optional(t.string()),
|
|
125
|
+
command: t.optional(t.string())
|
|
126
|
+
})
|
|
127
|
+
const isToolInputSchema = new Validator(TOOL_INPUT_SCHEMA).isValidObject
|
|
128
|
+
|
|
129
|
+
const PLAN_PARAMETERS = t.object({
|
|
130
|
+
summary: t.string({ description: 'One-line description of what the plan accomplishes.' }),
|
|
131
|
+
facts: t.array(t.string(), {
|
|
132
|
+
maxItems: MAX_PLAN_FACTS,
|
|
133
|
+
description:
|
|
134
|
+
'Established during exploration: findings the executor cannot re-derive (file:line, constraints, commands). Each string is one fact.'
|
|
135
|
+
}),
|
|
136
|
+
steps: t.array(
|
|
137
|
+
t.object({
|
|
138
|
+
file: t.string({ description: 'Repo-relative file path, e.g. src/index.ts.' }),
|
|
139
|
+
change: t.string({ description: 'What to change in this file.' }),
|
|
140
|
+
anchor: t.optional(
|
|
141
|
+
t.string({
|
|
142
|
+
description:
|
|
143
|
+
'Exact text to match for the edit. Prevents the executor from rewriting the wrong site.'
|
|
144
|
+
})
|
|
145
|
+
),
|
|
146
|
+
verify: t.optional(
|
|
147
|
+
t.string({
|
|
148
|
+
description: 'Command to run after this step, e.g. pnpm vitest run src/foo.test.ts.'
|
|
149
|
+
})
|
|
150
|
+
)
|
|
151
|
+
}),
|
|
152
|
+
{ maxItems: MAX_PLAN_STEPS, description: `Steps in order. No more than ${MAX_PLAN_STEPS}.` }
|
|
153
|
+
),
|
|
154
|
+
check: t.string({ description: 'Command to run after every step is done, e.g. pnpm check.' }),
|
|
155
|
+
outOfScope: t.array(t.string(), {
|
|
156
|
+
maxItems: MAX_PLAN_FACTS,
|
|
157
|
+
description: 'Files or areas the executor must not touch.'
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
const isPlanParameters = new Validator(PLAN_PARAMETERS).isValidObject
|
|
161
|
+
type PlanParams = Infer<typeof PLAN_PARAMETERS>
|
|
162
|
+
|
|
163
|
+
type PlanToolDefinition = ToolDefinition<typeof PLAN_PARAMETERS> & { defaultActive?: boolean }
|
|
164
|
+
|
|
165
|
+
export default function prewalkExtension(pi: ExtensionAPI): void {
|
|
166
|
+
let state: PrewalkState | null = null
|
|
167
|
+
let config: PrewalkConfig = readEffectivePrewalkConfig()
|
|
168
|
+
|
|
169
|
+
const saveConfig = (ctx: ExtensionContext): void => {
|
|
170
|
+
if (writePrewalkConfig(config)) return
|
|
171
|
+
ctx.ui.notify(
|
|
172
|
+
'prewalk: the config change applies to this session only; the file could not be written.',
|
|
173
|
+
'warning'
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
let stallNudged = false
|
|
177
|
+
let completionNudged = false
|
|
178
|
+
let reviewSpent = false
|
|
179
|
+
let postPlanCalls: number | null = null
|
|
180
|
+
let demandedThisTurn = false
|
|
181
|
+
let models: readonly SessionModel[] = []
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The prompt that opened a task, set only by a prompt typed while the session was idle, or by
|
|
185
|
+
* `/prewalk` with a task. `route()` consumes it, so one prompt routes once. pi's
|
|
186
|
+
* `reason: "user"` cannot carry this: it also fires for a steer and a follow-up, which are
|
|
187
|
+
* turns inside the task already in flight.
|
|
188
|
+
*/
|
|
189
|
+
let prompt: string | null = null
|
|
190
|
+
let steer: string | null = null
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A prompt too short to be a task is not a task boundary. It holds whatever is in play rather
|
|
194
|
+
* than opening a task on the cheap tier with nothing planned to execute.
|
|
195
|
+
*/
|
|
196
|
+
function recordPrompt(text: string): void {
|
|
197
|
+
if (shouldAsk(text)) prompt = text
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function persist(): void {
|
|
201
|
+
pi.appendEntry(STATE_TYPE, state)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function updateStatus(ctx: ExtensionContext): void {
|
|
205
|
+
if (!ctx.hasUI) return
|
|
206
|
+
if (!state) {
|
|
207
|
+
ctx.ui.setStatus('prewalk', undefined)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
ctx.ui.setStatus('prewalk', ctx.ui.theme.fg('accent', statusBadge(state)))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function chainModel(ctx: ExtensionContext, chain: readonly string[]): SessionModel | null {
|
|
214
|
+
for (const spec of chain) {
|
|
215
|
+
const result = resolveModelSpec(physicalModels(ctx), spec)
|
|
216
|
+
if ('error' in result) continue
|
|
217
|
+
return result.model
|
|
218
|
+
}
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function executorModel(ctx: ExtensionContext): SessionModel | null {
|
|
223
|
+
return chainModel(ctx, config.executor)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function reconstructState(ctx: ExtensionContext): Promise<void> {
|
|
227
|
+
state = null
|
|
228
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
229
|
+
if (entry.type !== 'custom' || entry.customType !== STATE_TYPE) continue
|
|
230
|
+
const data: unknown = entry.data
|
|
231
|
+
if (!isStoredEntrySchema(data)) {
|
|
232
|
+
state = null
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
const plan = data.plan ?? null
|
|
236
|
+
state = {
|
|
237
|
+
executor: data.executor ?? '',
|
|
238
|
+
planner: data.planner ?? null,
|
|
239
|
+
swapped: data.swapped === true && plan !== null,
|
|
240
|
+
inFlight: data.inFlight === true,
|
|
241
|
+
plan,
|
|
242
|
+
planDemanded: data.planDemanded === true,
|
|
243
|
+
forced: data.forced ?? null,
|
|
244
|
+
stepIndex: data.stepIndex ?? 0,
|
|
245
|
+
failures: data.failures ?? 0,
|
|
246
|
+
escalations: data.escalations ?? 0,
|
|
247
|
+
wall: data.wall ?? null,
|
|
248
|
+
reviewing: data.reviewing === true
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
models = physicalModels(ctx)
|
|
252
|
+
config = readEffectivePrewalkConfig()
|
|
253
|
+
prompt = null
|
|
254
|
+
steer = null
|
|
255
|
+
stallNudged = false
|
|
256
|
+
completionNudged = false
|
|
257
|
+
reviewSpent = false
|
|
258
|
+
postPlanCalls = null
|
|
259
|
+
updateStatus(ctx)
|
|
260
|
+
await claimRouter(ctx, ctx.model)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function findModel(ctx: ExtensionContext, spec: string): SessionModel | null {
|
|
264
|
+
const result = resolveModelSpec(physicalModels(ctx), spec)
|
|
265
|
+
return 'error' in result ? null : result.model
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function resolveModel(ctx: ExtensionContext, spec: string): SessionModel | null {
|
|
269
|
+
const result = resolveModelSpec(physicalModels(ctx), spec)
|
|
270
|
+
if ('error' in result) {
|
|
271
|
+
ctx.ui.notify(`prewalk: ${result.error}`, 'error')
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
return result.model
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Every task routes through the router, so a session model that is not the router is a
|
|
279
|
+
* session where nothing routes, for the rest of its life, and it happens without a word:
|
|
280
|
+
* one press of the model cycle key leaves prewalk silent. Armed means prewalk owns the
|
|
281
|
+
* selection, so it takes it back and says so. `/prewalk off` is how a physical model is
|
|
282
|
+
* kept, and it is the mode that asks nothing and routes nothing.
|
|
283
|
+
*/
|
|
284
|
+
async function claimRouter(ctx: ExtensionContext, selected: SessionModel | undefined): Promise<void> {
|
|
285
|
+
if (state === null || config.mode === 'off' || selected === undefined) return
|
|
286
|
+
if (selected.provider === ROUTER_PROVIDER && selected.id === ROUTER_ID) return
|
|
287
|
+
if (!(await selectRouter(ctx))) return
|
|
288
|
+
ctx.ui.notify(
|
|
289
|
+
`prewalk: ${modelRef(selected)} is not ${ROUTER_REF}, so no task would route. Back on it; /prewalk off keeps the model.`,
|
|
290
|
+
'warning'
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The session's model is the router, so nothing has to be remembered about the swap. Arming only
|
|
296
|
+
* has to name the two chains and select the router.
|
|
297
|
+
*/
|
|
298
|
+
async function selectRouter(ctx: ExtensionContext): Promise<boolean> {
|
|
299
|
+
const router = ctx.modelRegistry.find(ROUTER_PROVIDER, ROUTER_ID)
|
|
300
|
+
if (!router) {
|
|
301
|
+
ctx.ui.notify(`prewalk: ${ROUTER_REF} is not registered`, 'error')
|
|
302
|
+
return false
|
|
303
|
+
}
|
|
304
|
+
return pi.setModel(router)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function ensureState(ctx: ExtensionContext): PrewalkState | null {
|
|
308
|
+
if (state) return state
|
|
309
|
+
const executor = executorModel(ctx)
|
|
310
|
+
if (!executor) return null
|
|
311
|
+
const planner = chainModel(ctx, config.planner)
|
|
312
|
+
state = {
|
|
313
|
+
executor: modelRef(executor),
|
|
314
|
+
planner: planner ? modelRef(planner) : null,
|
|
315
|
+
swapped: false,
|
|
316
|
+
inFlight: false,
|
|
317
|
+
forced: null,
|
|
318
|
+
plan: null,
|
|
319
|
+
planDemanded: false,
|
|
320
|
+
stepIndex: 0,
|
|
321
|
+
failures: 0,
|
|
322
|
+
escalations: 0,
|
|
323
|
+
wall: null,
|
|
324
|
+
reviewing: false
|
|
325
|
+
}
|
|
326
|
+
persist()
|
|
327
|
+
updateStatus(ctx)
|
|
328
|
+
setPlanTool()
|
|
329
|
+
return state
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function disarm(ctx: ExtensionContext): void {
|
|
333
|
+
state = null
|
|
334
|
+
prompt = null
|
|
335
|
+
persist()
|
|
336
|
+
updateStatus(ctx)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function startTask(ctx: ExtensionContext, route_: ForceRoute): void {
|
|
340
|
+
if (!state) return
|
|
341
|
+
// A steer typed at the end of a task belongs to that task, not to the next one.
|
|
342
|
+
steer = null
|
|
343
|
+
reviewSpent = false
|
|
344
|
+
postPlanCalls = null
|
|
345
|
+
state = {
|
|
346
|
+
...state,
|
|
347
|
+
swapped: route_ !== 'plan',
|
|
348
|
+
inFlight: true,
|
|
349
|
+
plan: null,
|
|
350
|
+
planDemanded: route_ !== 'plan',
|
|
351
|
+
stepIndex: 0,
|
|
352
|
+
failures: 0,
|
|
353
|
+
escalations: 0,
|
|
354
|
+
wall: null,
|
|
355
|
+
reviewing: false
|
|
356
|
+
}
|
|
357
|
+
persist()
|
|
358
|
+
updateStatus(ctx)
|
|
359
|
+
setPlanTool()
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The executor takes over on the next request, which `route()` reads off `swapped`. */
|
|
363
|
+
function markSwapped(ctx: ExtensionContext): void {
|
|
364
|
+
if (!state || state.swapped) return
|
|
365
|
+
postPlanCalls = null
|
|
366
|
+
state = { ...state, swapped: true, failures: 0, wall: null }
|
|
367
|
+
persist()
|
|
368
|
+
updateStatus(ctx)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function startReview(ctx: ExtensionContext): void {
|
|
372
|
+
if (!state?.plan) return
|
|
373
|
+
reviewSpent = true
|
|
374
|
+
const text = reviewPrompt(state.plan, state.stepIndex)
|
|
375
|
+
state = { ...state, swapped: false, reviewing: true, inFlight: true, failures: 0, wall: null }
|
|
376
|
+
persist()
|
|
377
|
+
updateStatus(ctx)
|
|
378
|
+
setPlanTool()
|
|
379
|
+
pi.sendUserMessage(text, { deliverAs: 'steer' })
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function arm(
|
|
383
|
+
ctx: ExtensionContext,
|
|
384
|
+
args: { planner: string | null; executor: string | null; task: string; forced?: ForceRoute }
|
|
385
|
+
): Promise<void> {
|
|
386
|
+
const executorSpec = args.executor ?? config.executor[0] ?? state?.executor
|
|
387
|
+
if (!executorSpec) {
|
|
388
|
+
ctx.ui.notify(
|
|
389
|
+
'prewalk: no executor named yet.\n\nUsage: /prewalk executor <model> [more fallbacks], or /prewalk <planner> <executor> [task]',
|
|
390
|
+
'error'
|
|
391
|
+
)
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
const executor = resolveModel(ctx, executorSpec)
|
|
395
|
+
if (!executor) return
|
|
396
|
+
const executorRef = modelRef(executor)
|
|
397
|
+
|
|
398
|
+
const plannerSpec = args.planner ?? config.planner[0] ?? state?.planner
|
|
399
|
+
if (!plannerSpec) {
|
|
400
|
+
ctx.ui.notify('prewalk: no planner named yet.\n\nUsage: /prewalk planner <model> [more fallbacks]', 'error')
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
const planner = resolveModel(ctx, plannerSpec)
|
|
404
|
+
if (!planner) return
|
|
405
|
+
const plannerRef = modelRef(planner)
|
|
406
|
+
if (plannerRef === executorRef) {
|
|
407
|
+
ctx.ui.notify(
|
|
408
|
+
`prewalk: planner and executor are both ${shortName(executorRef)}; name a different planner`,
|
|
409
|
+
'error'
|
|
410
|
+
)
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
state = {
|
|
414
|
+
executor: executorRef,
|
|
415
|
+
planner: plannerRef,
|
|
416
|
+
swapped: false,
|
|
417
|
+
inFlight: false,
|
|
418
|
+
forced: args.forced ?? null,
|
|
419
|
+
plan: null,
|
|
420
|
+
planDemanded: false,
|
|
421
|
+
stepIndex: 0,
|
|
422
|
+
failures: 0,
|
|
423
|
+
escalations: 0,
|
|
424
|
+
wall: null,
|
|
425
|
+
reviewing: false
|
|
426
|
+
}
|
|
427
|
+
// A named role replaces its chain, a bare re-arm keeps the fallbacks the file already holds.
|
|
428
|
+
if (args.planner !== null || args.executor !== null) {
|
|
429
|
+
config = {
|
|
430
|
+
...config,
|
|
431
|
+
planner: args.planner === null ? config.planner : [plannerRef],
|
|
432
|
+
executor: args.executor === null ? config.executor : [executorRef]
|
|
433
|
+
}
|
|
434
|
+
saveConfig(ctx)
|
|
435
|
+
}
|
|
436
|
+
persist()
|
|
437
|
+
updateStatus(ctx)
|
|
438
|
+
setPlanTool()
|
|
439
|
+
if (!(await selectRouter(ctx))) return
|
|
440
|
+
|
|
441
|
+
if (args.task) {
|
|
442
|
+
recordPrompt(args.task)
|
|
443
|
+
pi.sendUserMessage(args.task, ctx.isIdle() ? undefined : { deliverAs: 'followUp' })
|
|
444
|
+
ctx.ui.notify(
|
|
445
|
+
`prewalk: task started on ${shortName(args.forced === 'run' ? executorRef : plannerRef)}`,
|
|
446
|
+
'info'
|
|
447
|
+
)
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (args.forced) {
|
|
452
|
+
const on = args.forced === 'run' ? shortName(executorRef) : shortName(plannerRef)
|
|
453
|
+
ctx.ui.notify(`prewalk: the next task ${args.forced === 'run' ? 'runs on' : 'plans on'} ${on}`, 'info')
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
ctx.ui.notify(
|
|
458
|
+
`prewalk: ${MODE_LABEL[config.mode]}. Plan on ${shortName(plannerRef)}, run on ${shortName(executorRef)}. /prewalk off stops it`,
|
|
459
|
+
'info'
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function chainReport(ctx: ExtensionContext, role: 'planner' | 'executor', chain: readonly string[]): string {
|
|
464
|
+
const lines = chain.map((spec) => {
|
|
465
|
+
const model = findModel(ctx, spec)
|
|
466
|
+
const mark = model === null ? 'unavailable' : modelRef(model) === ROUTER_REF ? 'router' : 'ready'
|
|
467
|
+
return ` ${spec} ${mark} ${model === null ? '' : priceLine(model)}`
|
|
468
|
+
})
|
|
469
|
+
return `${role}: ${chain.length === 0 ? '(none named)' : ''}\n${lines.join('\n')}`
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Both chains with what resolves and what it costs, and the mode, which is what `/prewalk models` is for. */
|
|
473
|
+
function configReport(ctx: ExtensionContext): string {
|
|
474
|
+
const chains = `${chainReport(ctx, 'planner', config.planner)}\n${chainReport(ctx, 'executor', config.executor)}`
|
|
475
|
+
return `${chains}\nmode: ${MODE_LABEL[config.mode]}`
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function setPlanTool(): void {
|
|
479
|
+
const enabled = state !== null && config.mode === 'plan'
|
|
480
|
+
const active = pi.getActiveTools()
|
|
481
|
+
if (active.length === 0) return
|
|
482
|
+
if (active.includes(PLAN_TOOL_NAME) === enabled) return
|
|
483
|
+
pi.setActiveTools(enabled ? [...active, PLAN_TOOL_NAME] : active.filter((name) => name !== PLAN_TOOL_NAME))
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Nothing is in play: no task, no state, and no model on the request. Work that has not been
|
|
488
|
+
* planned belongs to the planner, so a session with neither starts there rather than on the
|
|
489
|
+
* cheap tier.
|
|
490
|
+
*/
|
|
491
|
+
function coldStart(ctx: ExtensionContext): SessionModel {
|
|
492
|
+
const planned = chainModel(ctx, config.planner)
|
|
493
|
+
if (planned) return planned
|
|
494
|
+
const first = physicalModels(ctx)[0]
|
|
495
|
+
if (!first) throw new Error('prewalk: no planner in the chain resolves, and no model is available')
|
|
496
|
+
return first
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* One decision, and the phase answers it: a task plans and the cheap tier runs the steps of
|
|
501
|
+
* the plan it was given. The score that used to pick between the two read 0.548 AUC against
|
|
502
|
+
* how long a turn ran, so it did not separate, and it is gone.
|
|
503
|
+
*/
|
|
504
|
+
function decideRoute(): Routed {
|
|
505
|
+
const forced = state?.forced ?? null
|
|
506
|
+
if (forced !== null && state) state = { ...state, forced: null }
|
|
507
|
+
if (forced !== null) return { route: forced }
|
|
508
|
+
if (config.mode === 'off') return { route: 'run' }
|
|
509
|
+
return { route: 'plan' }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* A recorded wall is the executor saying it cannot finish this alone, which is the one place
|
|
514
|
+
* where a stuck cheap model hands the task back without asking. One failed tool result is the
|
|
515
|
+
* evidence, and the task buys two upgrades, because each swap re-reads the whole prefix at the
|
|
516
|
+
* new model's rate.
|
|
517
|
+
*/
|
|
518
|
+
async function escalationRoute(
|
|
519
|
+
ctx: ExtensionContext,
|
|
520
|
+
inPlay: ModelRouteRequest['previous'],
|
|
521
|
+
level: ModelRouteRequest['thinkingLevel']
|
|
522
|
+
): Promise<ModelRoute | null> {
|
|
523
|
+
if (config.mode !== 'plan' || state === null) return null
|
|
524
|
+
if (state.swapped === false || state.wall === null) return null
|
|
525
|
+
if (state.escalations >= ESCALATION_BAR.upgrades) return null
|
|
526
|
+
const planned = chainModel(ctx, config.planner)
|
|
527
|
+
if (!planned)
|
|
528
|
+
return heldModel(ctx, inPlay, 'prewalk: no planner in the chain resolves, or has an API key', level)
|
|
529
|
+
state = {
|
|
530
|
+
...state,
|
|
531
|
+
planner: modelRef(planned),
|
|
532
|
+
swapped: false,
|
|
533
|
+
escalations: state.escalations + 1,
|
|
534
|
+
planDemanded: true
|
|
535
|
+
}
|
|
536
|
+
persist()
|
|
537
|
+
updateStatus(ctx)
|
|
538
|
+
setPlanTool()
|
|
539
|
+
ctx.ui.notify(
|
|
540
|
+
`prewalk: the executor is stuck; this task is on ${shortName(modelRef(planned))} until its next edit`,
|
|
541
|
+
'info'
|
|
542
|
+
)
|
|
543
|
+
return { model: planned, thinkingLevel: level }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* A question typed while the executor is mid-task wants the frontier model, and pi can carry
|
|
548
|
+
* it under any request reason, so this runs whatever the reason says instead of only reading
|
|
549
|
+
* continuations: the task goes back to the planner until its next edit hands it over again.
|
|
550
|
+
* The steer is spent the first time it is read, whether or not the planner is already holding
|
|
551
|
+
* the question, so it cannot fire on a later swap. No routing call: the phase answers it.
|
|
552
|
+
*/
|
|
553
|
+
async function steerRoute(
|
|
554
|
+
ctx: ExtensionContext,
|
|
555
|
+
executor: SessionModel | null,
|
|
556
|
+
level: ModelRouteRequest['thinkingLevel']
|
|
557
|
+
): Promise<ModelRoute | null> {
|
|
558
|
+
if (steer === null) return null
|
|
559
|
+
const text = steer
|
|
560
|
+
steer = null
|
|
561
|
+
if (executor === null || state === null) return null
|
|
562
|
+
if (!shouldAsk(text)) return null
|
|
563
|
+
const planned = chainModel(ctx, config.planner)
|
|
564
|
+
if (!planned) return null
|
|
565
|
+
state = { ...state, planner: modelRef(planned), swapped: false }
|
|
566
|
+
persist()
|
|
567
|
+
updateStatus(ctx)
|
|
568
|
+
setPlanTool()
|
|
569
|
+
ctx.ui.notify(`prewalk: that question is on ${shortName(modelRef(planned))} until the next edit`, 'info')
|
|
570
|
+
return { model: planned, thinkingLevel: level }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** What a request costs and the context it has to work in, the two columns a role choice turns on. */
|
|
574
|
+
function priceLine(model: SessionModel): string {
|
|
575
|
+
const price = model.cost.input === 0 ? 'free' : `$${model.cost.input}/M in`
|
|
576
|
+
return `${Math.round(model.contextWindow / 1000)}k ctx ${price}`
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* pi has no model picker an extension can open, so the dialog carries those columns. Sorted by
|
|
581
|
+
* price, since the whole point of the executor is what it costs.
|
|
582
|
+
*/
|
|
583
|
+
function modelChoices(ctx: ExtensionContext): { label: string; ref: string }[] {
|
|
584
|
+
return physicalModels(ctx)
|
|
585
|
+
.toSorted((a, b) => a.cost.input - b.cost.input)
|
|
586
|
+
.map((model) => ({ label: `${modelRef(model)} ${priceLine(model)}`, ref: modelRef(model) }))
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
pi.registerVirtualModel({
|
|
590
|
+
provider: ROUTER_PROVIDER,
|
|
591
|
+
id: ROUTER_ID,
|
|
592
|
+
name: 'Prewalk',
|
|
593
|
+
thinkingLevels: ['off', 'low', 'medium', 'high'],
|
|
594
|
+
async route(request, ctx) {
|
|
595
|
+
const level = request.thinkingLevel
|
|
596
|
+
const inPlay = request.previous
|
|
597
|
+
|
|
598
|
+
if (request.reason === 'retry' && request.failed) {
|
|
599
|
+
return { model: request.failed.model, thinkingLevel: request.failed.thinkingLevel ?? level }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const executor = ensureState(ctx)?.swapped === true ? executorModel(ctx) : null
|
|
603
|
+
if (request.reason === 'continuation') {
|
|
604
|
+
const escalated = await escalationRoute(ctx, inPlay, level)
|
|
605
|
+
if (escalated) return escalated
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (state?.reviewing === true && (prompt === null || request.reason !== 'user')) {
|
|
609
|
+
const reviewer = chainModel(ctx, config.planner)
|
|
610
|
+
if (reviewer) return { model: reviewer, thinkingLevel: level }
|
|
611
|
+
}
|
|
612
|
+
if (prompt === null || request.reason !== 'user') {
|
|
613
|
+
const asked = await steerRoute(ctx, executor, level)
|
|
614
|
+
if (asked) return asked
|
|
615
|
+
if (request.reason !== 'user' && executor) return { model: executor, thinkingLevel: level }
|
|
616
|
+
if (inPlay) return { model: inPlay.model, thinkingLevel: inPlay.thinkingLevel ?? level }
|
|
617
|
+
return { model: coldStart(ctx), thinkingLevel: level }
|
|
618
|
+
}
|
|
619
|
+
prompt = null
|
|
620
|
+
|
|
621
|
+
const decided = decideRoute()
|
|
622
|
+
startTask(ctx, decided.route)
|
|
623
|
+
if (!state) return { model: coldStart(ctx), thinkingLevel: level }
|
|
624
|
+
if (decided.route === 'run') {
|
|
625
|
+
const run = executorModel(ctx)
|
|
626
|
+
if (!run) {
|
|
627
|
+
return heldModel(
|
|
628
|
+
ctx,
|
|
629
|
+
inPlay,
|
|
630
|
+
'prewalk: no executor in the chain resolves, or has an API key',
|
|
631
|
+
level
|
|
632
|
+
)
|
|
633
|
+
}
|
|
634
|
+
return { model: run, thinkingLevel: level }
|
|
635
|
+
}
|
|
636
|
+
const planned = chainModel(ctx, config.planner)
|
|
637
|
+
if (!planned) {
|
|
638
|
+
return heldModel(ctx, inPlay, 'prewalk: no planner in the chain resolves, or has an API key', level)
|
|
639
|
+
}
|
|
640
|
+
state = { ...state, planner: modelRef(planned) }
|
|
641
|
+
persist()
|
|
642
|
+
return { model: planned, thinkingLevel: level }
|
|
643
|
+
}
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
pi.on('before_agent_start', async () => setPlanTool())
|
|
647
|
+
pi.on('turn_start', async () => {
|
|
648
|
+
demandedThisTurn = false
|
|
649
|
+
})
|
|
650
|
+
pi.on('session_start', async (_event, ctx) => reconstructState(ctx))
|
|
651
|
+
pi.on('session_tree', async (_event, ctx) => reconstructState(ctx))
|
|
652
|
+
pi.on('model_select', async (event, ctx) => claimRouter(ctx, event.model))
|
|
653
|
+
|
|
654
|
+
pi.on('agent_end', async (_event, ctx) => {
|
|
655
|
+
if (!state || state.swapped || !state.plan || state.stepIndex > 0) return
|
|
656
|
+
markSwapped(ctx)
|
|
657
|
+
stallNudged = true
|
|
658
|
+
const first = state.plan.steps[0]
|
|
659
|
+
const nudge = first
|
|
660
|
+
? `${NUDGE_PREFIX} Begin step 1: ${first.file}: ${first.change}. ${first.verify ? `Run \`${first.verify}\` to verify.` : ''}`
|
|
661
|
+
: `${NUDGE_PREFIX} Begin the plan.`
|
|
662
|
+
pi.sendMessage(
|
|
663
|
+
{ customType: 'prewalk-stall-nudge', content: nudge, display: false, details: undefined },
|
|
664
|
+
{ deliverAs: 'steer' }
|
|
665
|
+
)
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
pi.on('agent_settled', async (event, ctx) => {
|
|
669
|
+
if (!state) return
|
|
670
|
+
if (state.inFlight) {
|
|
671
|
+
state = { ...state, inFlight: false }
|
|
672
|
+
persist()
|
|
673
|
+
setPlanTool()
|
|
674
|
+
updateStatus(ctx)
|
|
675
|
+
}
|
|
676
|
+
if (event.aborted) return
|
|
677
|
+
if (!state.swapped || !state.plan || ctx.hasPendingMessages()) return
|
|
678
|
+
const done = state.stepIndex >= state.plan.steps.length
|
|
679
|
+
if (config.review && !reviewSpent && (done || stallNudged)) {
|
|
680
|
+
startReview(ctx)
|
|
681
|
+
return
|
|
682
|
+
}
|
|
683
|
+
if (completionNudged || stallNudged) return
|
|
684
|
+
if (done) completionNudged = true
|
|
685
|
+
stallNudged = true
|
|
686
|
+
pi.sendUserMessage(continuationNudge(state.plan, state.stepIndex), { deliverAs: 'steer' })
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
pi.on('input', async (event) => {
|
|
690
|
+
const text = event.text.trimStart()
|
|
691
|
+
if (text.startsWith('/') || text.startsWith(SKILL_BLOCK_PREFIX) || text.startsWith(NUDGE_PREFIX)) return
|
|
692
|
+
if (event.source === 'extension') return
|
|
693
|
+
stallNudged = false
|
|
694
|
+
// A steer joins the task in flight and a follow-up waits for that turn to settle, so the
|
|
695
|
+
// steer never opens a task here and the follow-up routes as one when it arrives. Extension
|
|
696
|
+
// messages are continuations or injected context, not a new task, so they leave the task alone.
|
|
697
|
+
if (event.streamingBehavior === 'steer') {
|
|
698
|
+
steer = event.text
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
recordPrompt(event.text)
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
pi.on('tool_call', async (event, ctx) => {
|
|
705
|
+
if (!state) return
|
|
706
|
+
const input = isToolInputSchema(event.input) ? event.input : undefined
|
|
707
|
+
const inert =
|
|
708
|
+
event.toolName.toLowerCase() === 'bash'
|
|
709
|
+
? !bashWritesFiles(input?.command ?? '', ctx.cwd)
|
|
710
|
+
: isScratchPath(input?.path ?? input?.file ?? '', ctx.cwd)
|
|
711
|
+
const decision = swapDecision(state, event.toolName, inert, demandedThisTurn)
|
|
712
|
+
if (decision === 'demand-plan') {
|
|
713
|
+
demandedThisTurn = true
|
|
714
|
+
state = { ...state, planDemanded: true }
|
|
715
|
+
persist()
|
|
716
|
+
return { block: true, reason: PLAN_DEMAND_REASON }
|
|
717
|
+
}
|
|
718
|
+
if (decision === 'swap') {
|
|
719
|
+
markSwapped(ctx)
|
|
720
|
+
return
|
|
721
|
+
}
|
|
722
|
+
if (postPlanCalls === null || state.swapped || state.reviewing || event.toolName === PLAN_TOOL_NAME) return
|
|
723
|
+
postPlanCalls += 1
|
|
724
|
+
if (postPlanCalls >= PLANNER_GRACE_CALLS) markSwapped(ctx)
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
pi.on('tool_call', async (event) => {
|
|
728
|
+
if (!state || event.toolName !== PLAN_TOOL_NAME || state.swapped) return
|
|
729
|
+
if (!isPlanParameters(event.input)) return
|
|
730
|
+
const plan = event.input
|
|
731
|
+
stallNudged = false
|
|
732
|
+
completionNudged = false
|
|
733
|
+
reviewSpent = false
|
|
734
|
+
postPlanCalls = 0
|
|
735
|
+
state = { ...state, plan, planDemanded: true, reviewing: false, stepIndex: 0, failures: 0, wall: null }
|
|
736
|
+
persist()
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
pi.on('tool_result', async (event, ctx) => {
|
|
740
|
+
if (!state?.swapped) return
|
|
741
|
+
const plan = state.plan
|
|
742
|
+
const blocks = Array.isArray(event.content)
|
|
743
|
+
? event.content.map((b) => (b.type === 'text' ? (b.text ?? '') : ''))
|
|
744
|
+
: [String(event.content ?? '')]
|
|
745
|
+
const last = blocks[blocks.length - 1] ?? ''
|
|
746
|
+
if (event.isError) {
|
|
747
|
+
const failures = state.failures + 1
|
|
748
|
+
const wall = failures >= ESCALATION_BAR.failures ? last.trim().slice(0, MAX_WALL_CHARS) : state.wall
|
|
749
|
+
const changed = wall !== state.wall
|
|
750
|
+
state = { ...state, failures, wall }
|
|
751
|
+
if (changed) persist()
|
|
752
|
+
if (plan === null || failures !== ESCALATION_BAR.failures) return
|
|
753
|
+
if (state.escalations >= ESCALATION_BAR.upgrades || config.mode !== 'plan') return
|
|
754
|
+
blocks[blocks.length - 1] = `${last}\n${wallBrief(plan, state.stepIndex, failures)}`
|
|
755
|
+
return { content: [{ type: 'text', text: blocks.join('\n') }] }
|
|
756
|
+
}
|
|
757
|
+
if (state.failures > 0 || state.wall !== null) state = { ...state, failures: 0, wall: null }
|
|
758
|
+
if (plan === null) return
|
|
759
|
+
if (!isToolInputSchema(event.input)) return
|
|
760
|
+
const tool = event.toolName.toLowerCase()
|
|
761
|
+
const path = event.input.path ?? event.input.file ?? ''
|
|
762
|
+
const command = event.input.command ?? ''
|
|
763
|
+
const matched = READ_ONLY_TOOLS.has(tool)
|
|
764
|
+
? -1
|
|
765
|
+
: path === ''
|
|
766
|
+
? stepForCommand(plan, state.stepIndex, command)
|
|
767
|
+
: stepForPath(plan, state.stepIndex, path)
|
|
768
|
+
if (matched === -1) return
|
|
769
|
+
const { index, footer } = planProgress(plan, state.stepIndex, matched)
|
|
770
|
+
if (index !== state.stepIndex) {
|
|
771
|
+
state = { ...state, stepIndex: index, failures: 0, wall: null }
|
|
772
|
+
stallNudged = false
|
|
773
|
+
persist()
|
|
774
|
+
updateStatus(ctx)
|
|
775
|
+
}
|
|
776
|
+
blocks[blocks.length - 1] = `${last}\n${footer}`
|
|
777
|
+
return { content: [{ type: 'text', text: blocks.join('\n') }] }
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
pi.on('context', async (event) => {
|
|
781
|
+
if (!state) return
|
|
782
|
+
return { messages: contextForModel(state, event.messages) }
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
const planTool: PlanToolDefinition = {
|
|
786
|
+
name: PLAN_TOOL_NAME,
|
|
787
|
+
label: 'Commit Plan',
|
|
788
|
+
defaultActive: false,
|
|
789
|
+
description: PLANNING_BRIEF,
|
|
790
|
+
promptSnippet: 'Commit the implementation plan before your first edit',
|
|
791
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
792
|
+
exposure: 'deferred',
|
|
793
|
+
namespace: {
|
|
794
|
+
name: 'prewalk',
|
|
795
|
+
description:
|
|
796
|
+
'Commit the implementation plan before your first edit, when a prewalk has the session planning.'
|
|
797
|
+
},
|
|
798
|
+
promptGuidelines: [
|
|
799
|
+
`While a prewalk has you planning, search for ${PLAN_TOOL_NAME} and call it exactly once, after you finish exploring and before your first edit.`,
|
|
800
|
+
'The executor will not re-read what you read. Each step must carry enough detail to land the edit cold: the file, the exact anchor text to find, what changes, and the verify command.',
|
|
801
|
+
'If a step requires a judgement the executor cannot make, resolve it now or split the step.'
|
|
802
|
+
],
|
|
803
|
+
parameters: PLAN_PARAMETERS,
|
|
804
|
+
async execute(_toolCallId, params: PlanParams, _signal, _onUpdate, _ctx) {
|
|
805
|
+
const text = renderHandoff({
|
|
806
|
+
summary: params.summary,
|
|
807
|
+
facts: params.facts,
|
|
808
|
+
steps: params.steps,
|
|
809
|
+
check: params.check,
|
|
810
|
+
outOfScope: params.outOfScope
|
|
811
|
+
})
|
|
812
|
+
return {
|
|
813
|
+
content: [{ type: 'text', text }],
|
|
814
|
+
details: { summary: params.summary, steps: params.steps.length }
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
pi.registerTool(planTool)
|
|
819
|
+
|
|
820
|
+
pi.registerCommand('prewalk', {
|
|
821
|
+
description: 'Route each task to a planning model or a cheap one, by the phase or by hand',
|
|
822
|
+
getArgumentCompletions: (prefix: string) => {
|
|
823
|
+
const lastSpace = prefix.lastIndexOf(' ')
|
|
824
|
+
const head = lastSpace === -1 ? '' : prefix.slice(0, lastSpace + 1)
|
|
825
|
+
const p = prefix.slice(lastSpace + 1).toLowerCase()
|
|
826
|
+
const parsedHead = parseArgs(head, makeSpecPredicate(models))
|
|
827
|
+
if (parsedHead.kind !== 'bare' && parsedHead.kind !== 'start') return null
|
|
828
|
+
if (parsedHead.kind === 'start' && (parsedHead.task !== '' || parsedHead.planner)) return null
|
|
829
|
+
const items: { value: string; label: string; description?: string }[] = []
|
|
830
|
+
if (head === '') {
|
|
831
|
+
if (state?.executor) {
|
|
832
|
+
items.push({
|
|
833
|
+
value: state.executor,
|
|
834
|
+
label: `${shortName(state.executor)} (last used)`,
|
|
835
|
+
description: 're-arm with this executor'
|
|
836
|
+
})
|
|
837
|
+
}
|
|
838
|
+
for (const [value, description] of [
|
|
839
|
+
['models', 'both chains, what resolves, what it costs'],
|
|
840
|
+
['mode plan', 'plan each task, no routing call'],
|
|
841
|
+
['mode off', 'the cheap model, no plan and no call'],
|
|
842
|
+
['planner', 'set the planner chain'],
|
|
843
|
+
['executor', 'set the executor chain'],
|
|
844
|
+
['plan', 'next task plans, by hand'],
|
|
845
|
+
['run', 'next task runs on the cheap model'],
|
|
846
|
+
['off', 'disarm prewalk']
|
|
847
|
+
] as const) {
|
|
848
|
+
items.push({ value, label: value, description })
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
const slot = parsedHead.kind === 'start' && parsedHead.executor ? 'executor' : 'planner or executor'
|
|
852
|
+
for (const m of models) {
|
|
853
|
+
items.push({ value: modelRef(m), label: m.id, description: `${m.provider} · ${slot}` })
|
|
854
|
+
}
|
|
855
|
+
const filtered = items.filter(
|
|
856
|
+
(item) => item.value.toLowerCase().includes(p) || item.label.toLowerCase().includes(p)
|
|
857
|
+
)
|
|
858
|
+
return filtered.length > 0 ? filtered.slice(0, 30).map((i) => ({ ...i, value: head + i.value })) : null
|
|
859
|
+
},
|
|
860
|
+
handler: async (args, ctx) => {
|
|
861
|
+
const parsed = parseArgs(args, makeSpecPredicate(physicalModels(ctx)))
|
|
862
|
+
|
|
863
|
+
if (parsed.kind === 'status') {
|
|
864
|
+
const report = configReport(ctx)
|
|
865
|
+
ctx.ui.notify(state ? `${statusLabel(state)}\n${report}` : `prewalk is not armed.\n${report}`, 'info')
|
|
866
|
+
return
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (parsed.kind === 'stop') {
|
|
870
|
+
if (!state) {
|
|
871
|
+
ctx.ui.notify('No prewalk active', 'info')
|
|
872
|
+
return
|
|
873
|
+
}
|
|
874
|
+
disarm(ctx)
|
|
875
|
+
ctx.ui.notify('Prewalk disarmed', 'info')
|
|
876
|
+
return
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (parsed.kind === 'models') {
|
|
880
|
+
ctx.ui.notify(configReport(ctx), 'info')
|
|
881
|
+
return
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (parsed.kind === 'mode') {
|
|
885
|
+
config = { ...config, mode: parsed.mode }
|
|
886
|
+
saveConfig(ctx)
|
|
887
|
+
ctx.ui.notify(`prewalk: ${MODE_LABEL[parsed.mode]}`, 'info')
|
|
888
|
+
return
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (parsed.kind === 'role') {
|
|
892
|
+
if (parsed.chain.length === 0) {
|
|
893
|
+
const choices = modelChoices(ctx)
|
|
894
|
+
const picked =
|
|
895
|
+
choices.length === 0
|
|
896
|
+
? undefined
|
|
897
|
+
: await ctx.ui.select(
|
|
898
|
+
`prewalk: pick the ${parsed.role} model`,
|
|
899
|
+
choices.map((choice) => choice.label)
|
|
900
|
+
)
|
|
901
|
+
const choice = choices.find((candidate) => candidate.label === picked)
|
|
902
|
+
if (choice === undefined) return
|
|
903
|
+
parsed.chain.push(choice.ref)
|
|
904
|
+
}
|
|
905
|
+
config = { ...config, [parsed.role]: parsed.chain }
|
|
906
|
+
saveConfig(ctx)
|
|
907
|
+
// The badge reads the armed pair, so a chain that moves under a live session moves it too.
|
|
908
|
+
if (state !== null) {
|
|
909
|
+
const head = chainModel(ctx, parsed.chain)
|
|
910
|
+
state = { ...state, [parsed.role]: head ? modelRef(head) : parsed.chain[0] }
|
|
911
|
+
persist()
|
|
912
|
+
updateStatus(ctx)
|
|
913
|
+
}
|
|
914
|
+
ctx.ui.notify(`prewalk: ${parsed.role} chain is ${parsed.chain.map(shortName).join(' -> ')}`, 'info')
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (parsed.kind === 'force') {
|
|
919
|
+
await arm(ctx, { planner: null, executor: null, task: parsed.task, forced: parsed.route })
|
|
920
|
+
return
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (parsed.kind === 'bare') {
|
|
924
|
+
if (state) {
|
|
925
|
+
ctx.ui.notify(statusLabel(state), 'info')
|
|
926
|
+
return
|
|
927
|
+
}
|
|
928
|
+
await arm(ctx, { planner: null, executor: null, task: '' })
|
|
929
|
+
return
|
|
930
|
+
}
|
|
931
|
+
await arm(ctx, parsed)
|
|
932
|
+
}
|
|
933
|
+
})
|
|
934
|
+
}
|