/improve and /optimize: improve a prompt with the autoimprover CLI (GEPA search, scored by running the prompt on test scenarios), then review the result and…

Intent-preserving prompt optimization, evaluated against examples.
IntentOpt is an experimental Python CLI for improving a single prompt while preserving its constraints, literals and intended task. It generates candidate rewrites, runs them against scenarios, and returns the original when the configured checks cannot support a change.
With reference examples, it can infer missing rules and score agreement with expected outputs. Without references, it uses paired output comparisons by a separate judge model. Longer runs can check a candidate on held-out examples. The verified label means that the configured held-out check passed; it is not a guarantee of generalization or safety.
The project display name is IntentOpt. The package and executable remain autoimprover for compatibility. This is a development build, not a stable release or a claim of state-of-the-art performance.
Requires Python 3.12+ and uv. From this checkout:
# Inspect a plan without making model calls or writing a run folder.
uv run --frozen autoimprover --dry "Summarize meeting notes in five bullet points."
# Inspect the supplied reference-task benchmark without running it.
uv run --frozen autoimprover bench --prompts bench/examples-set.jsonl --time 2m --dry
Live runs require the claude CLI authenticated to an account with access to the selected models. They consume subscription usage. Inspect the plan and choose a budget before running:
uv run --frozen autoimprover --file prompt.txt --examples examples.jsonl --time 2m
Prompts, examples and model replies are saved in local run folders and sent to the configured model backend during a live run. Use public or appropriately authorized data. See the CLI reference for options, model configuration, storage and cleanup.
The research question is whether example-guided rewriting and conservative acceptance gates can improve task performance without changing user intent. The implementation includes reference scoring, noise checks, held-out evaluation, categorical example balancing, reflection rounds, a GEPA deep-search tier, and a resumable call cache.
The repository contains a scripted-model test suite and three synthetic policy benchmark tasks. The build log reports a small live experiment with 34/48 hidden examples passed by returned prompts versus 23/48 by originals. Those numbers describe one historical run on three tasks; raw live-run artifacts are not published here, and subsequent changes have not been validated by that experiment. The three tasks were also used while the tool was being tuned, so they are not an untouched test set. They are a pilot observation, not an independently reproducible result. See research evidence, protocol and limitations.
Useful support includes independent evaluations, additional task datasets, reviews of failure cases, and funding for repeated model evaluations and artifact curation. The research roadmap gives concrete milestones and completion criteria. Use repository issues to discuss a scoped contribution or potential sponsorship; no funding program or delivery commitment is implied.
Run just check for formatting, lint, type checks and the offline test suite. just smoke and just bench make live model calls and are separate from that check.
hooks/register.js 496 lines1// The autoimprover mod (SPEC R19, R21; ADR-010). `/improve` and its alias `/optimize` run the
2// autoimprover CLI as a child process: by an argument list (no shell), with a typed prompt in a
3// file inside a private folder made by `mktemp -d`, the session's model as the target model, and
4// the tool's virtual environment in the person's cache folder. The child's stderr feeds the
5// status line and its one JSON object the report pane; an improved prompt goes into an empty
6// prompt box by itself as an editable draft (a typed draft is kept; "Use it" replaces it), and
7// nothing is sent until the person presses Enter. The model never sees the prompt:
8// the module hooks `session.start`, `command.run` and the drawing of its own pane, nothing else.
9// Every call on `$` is in this file; parsing, command lines and the report view are the pure
10// modules beside it.
11
12import { RUN_ID, parseArgs, usageText } from './args.js'
13import { boxPlan, boxRefused } from './box.js'
14import {
15 childEnv, cleanArgs, cliArgv, needsDryFirst, resolvePath, resumeArgs, runArgs, targetModelOf,
16 tmpTemplate, venvFolder,
17} from './argv.js'
18import { paneRows, paneTree } from './pane.js'
19import {
20 cancelledText, cleanedText, endOf, errorView, lastLines, messageOf, oneLine, parseObject,
21 renderText, runIdOf, runningView, viewOf,
22} from './report.js'
23import { Progress } from './stream.js'
24
25/**
26 * @import { CommandRunResult, EngineInterface as E, HookStream, On } from 'claude-code'
27 * @import { ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
28 * @import { RenderElement, RenderSurface, UiPressArgument } from 'claude-code'
29 */
30/** @import { Request } from './args.js' */
31/** @import { View } from './report.js' */
32/**
33 * One run of the CLI, from `/improve` to the end of its child.
34 * @typedef {{ command: string, cancelled: boolean, detached: boolean, tmp: string | null,
35 * runDir: string | null, stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult> | null
36 * }} Job
37 * @typedef {{ env: Record<string, string>, home: string | undefined }} Setup
38 */
39
40const ABOUT = 'Improve a prompt with autoimprover (scored by running it); you review the result'
41const ALIAS = 'Alias of /improve'
42const HINT = '<prompt> | --file PATH | --dry <prompt> | --resume [ID] | clean [ID] | cancel'
43// Both commands run at once when typed mid-turn, so `cancel` can stop a run while Claude works.
44const COMMAND = { argumentHint: HINT, immediate: /** @type {const} */ (true) }
45const FOCUS = /** @type {const} */ (true)
46// `uv run` may build the tool's environment first, so a plan or a clean may take this long.
47const ONE_SHOT_MS = 300_000
48
49/** The run in progress; one per session, so `cancel` knows which. @type {Job | null} */
50let current = null
51/** What the pane draws: the run in progress, or how the last one ended. @type {View | null} */
52let shown = null
53
54/** A stop before the child starts; `result` is the command's answer. */
55class Refusal extends Error {
56 /** @param {string} text @param {number} exitCode */
57 constructor(text, exitCode) {
58 super(text)
59 this.result = { text, exitCode }
60 }
61}
62
63/** @param {On} on */
64export function register(on) {
65 on('session.start', startSession)
66 on('command.run', { command: ['improve', 'optimize'] }, runCommand)
67 on('ui.render', { component: 'Pane', requestId: 'improve' }, drawPane)
68}
69
70/**
71 * Lets the session start, then registers the commands; a name another plugin holds is logged and
72 * does not stop the session.
73 * @param {E} $ @param {any} e @param {(e: any) => Promise<any>} next
74 */
75async function startSession($, e, next) {
76 const started = await next(e)
77 try {
78 await $.command.register({ name: 'improve', description: ABOUT, ...COMMAND })
79 } catch (error) {
80 $.ui.log(`autoimprover: /improve is not available: ${messageOf(error)}`)
81 }
82 try {
83 await $.command.register({ name: 'optimize', description: ALIAS, ...COMMAND })
84 } catch (error) {
85 $.ui.log(`autoimprover: /optimize is not available: ${messageOf(error)}`)
86 }
87 return started
88}
89
90/**
91 * `/improve ...` and `/optimize ...`, the boundary of every command: a refusal is the answer, any
92 * other failure is reported as exit 1 with its message rather than as a silent hook failure.
93 * @param {E} $ @param {{ command: string, args: string }} e
94 * @returns {Promise<CommandRunResult>}
95 */
96async function runCommand($, e) {
97 const { command } = e
98 const request = parseArgs(e.args)
99 try {
100 if (request.kind === 'usage') return { text: usageText(command, request.message), exitCode: 2 }
101 if (request.kind === 'help') return { text: usageText(command) }
102 if (request.kind === 'cancel') return await cancelRun(command)
103 if (request.kind === 'clean') return await cleanRun($, command, request.id)
104 if (current !== null) {
105 const id = runIdOf(current.runDir)
106 const which = id === null ? 'a run is already going' : `run ${id} is still going`
107 return { text: `${which}; /${command} cancel stops it`, exitCode: 2 }
108 }
109 return await startRun($, command, request)
110 } catch (error) {
111 if (error instanceof Refusal) return error.result
112 return { text: `autoimprover: failed: ${messageOf(error)}`, exitCode: 1 }
113 }
114}
115
116/**
117 * Ends the run in progress by ending the loop over its child, which kills that child by the
118 * engine's own handle (SPEC R21); the run folder stays, so the run can be resumed.
119 * @param {string} command
120 * @returns {Promise<CommandRunResult>}
121 */
122async function cancelRun(command) {
123 const job = current
124 if (job === null) return { text: 'no autoimprover run is going' }
125 job.cancelled = true
126 if (job.stream !== null) await job.stream.return({ code: null, signal: null })
127 return { text: cancelledText(runIdOf(job.runDir), command) }
128}
129
130/**
131 * A run or a resume: the checks, the private folder and the prompt file, the plan when one is
132 * due, then the child: detached where a pane can show how it ends, else awaited (a `-p` run) with
133 * the report as the command's text. The folder is removed however the child ends.
134 * @param {E} $ @param {string} command @param {Request & { kind: 'run' | 'resume' }} request
135 * @returns {Promise<CommandRunResult>}
136 */
137async function startRun($, command, request) {
138 /** @type {Job} */
139 const job = { command, cancelled: false, detached: false, tmp: null, runDir: null, stream: null }
140 current = job
141 try {
142 const setup = await prepare($)
143 const tmp = (job.tmp = await makeFolder($))
144 let planned = ''
145 let args
146 if (request.kind === 'resume') {
147 args = resumeArgs(await runIdFor($, request.id, `/${command} --resume ID`))
148 } else {
149 const prompt = request.prompt
150 const promptFile = prompt === null ? null : await writePrompt($, tmp, prompt)
151 const paths = await resolvePaths($, request, setup.home)
152 const given = { promptFile, paths, targetModel: await targetModel($, request) }
153 const dryArgs = runArgs(request, { ...given, dry: true })
154 if (request.dry) {
155 const view = await plan($, job, setup, dryArgs)
156 return { text: renderText(view), exitCode: view.state === 'plan' ? 0 : view.exitCode }
157 }
158 if (needsDryFirst(request, await fileSize($, paths.get('--file')))) {
159 const view = await plan($, job, setup, dryArgs)
160 if (view.exitCode !== 0) return { text: renderText(view), exitCode: view.exitCode }
161 planned = `${renderText(view)}\n`
162 }
163 args = runArgs(request, { ...given, dry: false })
164 }
165 if (job.cancelled) return { text: cancelledText(null, command), exitCode: 130 }
166 const argv = cliArgv($.plugin.root, args)
167 if ((await $.session.surfaces()).length === 0) {
168 const view = await fillBox($, await drive($, job, argv, setup.env))
169 return { text: renderText(view), exitCode: view.exitCode }
170 }
171 shown = runningView({ status: null, runDir: null, command })
172 await openPane($, null)
173 job.detached = true
174 void runDetached($, job, argv, setup.env)
175 const started = 'autoimprover is running; its report opens in a pane.'
176 return { text: `${planned}${started} /${command} cancel stops it.` }
177 } finally {
178 if (!job.detached) await finishJob($, job)
179 }
180}
181
182/**
183 * `uv` on the PATH and a folder for the tool's virtual environment, or a refusal saying which.
184 * @param {E} $
185 * @returns {Promise<Setup>}
186 */
187async function prepare($) {
188 const uv = await $.process.run(['uv', '--version']).catch((error) => {
189 const why = `uv did not start (${messageOf(error)})`
190 throw new Refusal(`autoimprover needs uv: ${why}; put it on the PATH Claude Code runs with`, 2)
191 })
192 if (uv.exitCode !== 0) {
193 throw new Refusal(`autoimprover needs uv; \`uv --version\` failed: ${lastLines(uv.stderr)}`, 2)
194 }
195 const home = await $.env.get('HOME')
196 const venv = venvFolder({ home, cacheHome: await $.env.get('XDG_CACHE_HOME') })
197 if (venv === null) {
198 throw new Refusal('HOME is not an absolute path: there is no cache folder for the tool', 2)
199 }
200 return { env: childEnv(venv), home }
201}
202
203/**
204 * A private folder made by `mktemp -d` (mode 0700): the prompt file's, and the child's cwd.
205 * @param {E} $
206 * @returns {Promise<string>}
207 */
208async function makeFolder($) {
209 const made = await $.process.run(['mktemp', '-d', tmpTemplate(await $.env.get('TMPDIR'))])
210 const folder = made.stdout.trim()
211 if (made.exitCode !== 0 || !folder.startsWith('/')) {
212 throw new Error(`mktemp -d failed: ${lastLines(made.stderr)}`)
213 }
214 return folder
215}
216
217/**
218 * The typed prompt as `<folder>/prompt.txt`, mode 0600 (SPEC R21): it reaches the CLI as a path,
219 * never as an argument or a shell line (SPEC R19).
220 * @param {E} $ @param {string} folder @param {string} prompt
221 * @returns {Promise<string>}
222 */
223async function writePrompt($, folder, prompt) {
224 const file = `${folder}/prompt.txt`
225 await $.fs.write(file, prompt)
226 const chmod = await $.process.run(['chmod', '600', '--', file])
227 if (chmod.exitCode !== 0) {
228 throw new Error(`chmod 600 ${file} failed: ${lastLines(chmod.stderr)}`)
229 }
230 return file
231}
232
233/**
234 * `--file` and `--examples` as absolute paths, since the child runs in its own folder.
235 * @param {E} $ @param {Request & { kind: 'run' }} request @param {string | undefined} home
236 * @returns {Promise<Map<string, string>>}
237 */
238async function resolvePaths($, request, home) {
239 const paths = new Map()
240 const given = request.flags.filter((f) => f.flag === '--file' || f.flag === '--examples')
241 if (given.length === 0) return paths
242 const cwd = await $.session.cwd()
243 for (const { flag, value } of given) paths.set(flag, resolvePath(value ?? '', { cwd, home }))
244 return paths
245}
246
247/**
248 * The session's model as `--target-model` (SPEC R14a, R21), unless the person chose one.
249 * @param {E} $ @param {Request & { kind: 'run' }} request
250 * @returns {Promise<string | null>}
251 */
252async function targetModel($, request) {
253 if (request.targetModel !== null) return null
254 const model = await $.session.model()
255 const target = targetModelOf(model)
256 if (target !== null) return target
257 const said = `the session's model "${oneLine(model)}" is not a model id`
258 throw new Refusal(`${said}; pass --target-model`, 2)
259}
260
261/**
262 * The size in bytes of the `--file`, when there is one.
263 * @param {E} $ @param {string | undefined} file
264 * @returns {Promise<number | null>}
265 */
266async function fileSize($, file) {
267 if (file === undefined) return null
268 const stat = await $.fs.stat(file).catch((error) => {
269 throw new Refusal(`--file: cannot read ${file}: ${messageOf(error)}`, 2)
270 })
271 return stat.size
272}
273
274/**
275 * The given run id, or the last run's (kept in the store), or a refusal showing `form`.
276 * @param {E} $ @param {string | null} id @param {string} form
277 * @returns {Promise<string>}
278 */
279async function runIdFor($, id, form) {
280 if (id !== null) return id
281 const last = await $.store.get('lastRunId')
282 if (typeof last === 'string' && RUN_ID.test(last)) return last
283 throw new Refusal(`no last run is known; give its id: ${form}`, 2)
284}
285
286/**
287 * The plan of `--dry` (SPEC R4): one short child, no model call, nothing written.
288 * @param {E} $ @param {Job} job @param {Setup} setup @param {string[]} args
289 * @returns {Promise<View>}
290 */
291async function plan($, job, setup, args) {
292 const init = { cwd: job.tmp ?? undefined, env: setup.env, timeoutMs: ONE_SHOT_MS }
293 const done = await $.process.run(cliArgv($.plugin.root, args), init)
294 return viewOf(endOf(done, job.command))
295}
296
297/**
298 * `/improve clean [ID]`: one run folder removed through the CLI, the last run's by default.
299 * @param {E} $ @param {string} command @param {string | null} id
300 * @returns {Promise<CommandRunResult>}
301 */
302async function cleanRun($, command, id) {
303 const setup = await prepare($)
304 const target = await runIdFor($, id, `/${command} clean ID`)
305 const init = { env: setup.env, timeoutMs: ONE_SHOT_MS }
306 const done = await $.process.run(cliArgv($.plugin.root, cleanArgs(target)), init)
307 const result = parseObject(done.stdout)
308 if (result === null || result.status !== 'cleaned') {
309 const view = viewOf(endOf(done, command))
310 return { text: renderText(view), exitCode: view.exitCode }
311 }
312 if ((await $.store.get('lastRunId')) === target) await $.store.delete('lastRunId')
313 return { text: cleanedText(result, target) }
314}
315
316/**
317 * Runs the child to its end: stdout collected, each stderr line to the status line, the run
318 * folder caught and its id kept for `--resume` as soon as the CLI prints it (SPEC R21).
319 * @param {E} $ @param {Job} job @param {string[]} argv @param {Record<string, string>} env
320 * @returns {Promise<View>}
321 */
322async function drive($, job, argv, env) {
323 const progress = new Progress()
324 const stream = $.process.spawn({ argv, env, cwd: job.tmp ?? undefined })
325 job.stream = stream
326 try {
327 for await (const chunk of stream) await take($, job, progress.push(chunk))
328 } catch (error) {
329 if (!job.cancelled) throw error // a cancelled stream may end by throwing: that is the cancel
330 }
331 await take($, job, progress.finish())
332 /** @type {ProcessSpawnResult | null} */
333 let end = null
334 try {
335 end = await stream.result
336 } catch (error) {
337 if (!job.cancelled) throw error // closed before its end: only a cancel does that
338 }
339 const { stdout, stderr } = progress
340 const { command, cancelled, runDir } = job
341 const [code, signal] = [end?.code ?? null, end?.signal ?? null]
342 return viewOf({ stdout, stderr, code, signal, cancelled, runDir, command })
343}
344
345/**
346 * What one chunk changed: the run folder (kept as the last run), the status line, the pane.
347 * @param {E} $ @param {Job} job @param {{ status: string | null, runDir: string | null }} seen
348 */
349async function take($, job, seen) {
350 if (seen.runDir !== null) {
351 job.runDir = seen.runDir
352 const id = runIdOf(seen.runDir)
353 if (id !== null && RUN_ID.test(id)) await $.store.set('lastRunId', id)
354 }
355 if (seen.status === null) return
356 $.ui.status(`autoimprover: ${seen.status}`)
357 if (job.detached) {
358 shown = runningView({ status: seen.status, runDir: job.runDir, command: job.command })
359 $.ui.invalidate('ui.render')
360 }
361}
362
363/**
364 * The detached loop of an interactive run. It has no caller, so a failure becomes its report.
365 * @param {E} $ @param {Job} job @param {string[]} argv @param {Record<string, string>} env
366 */
367async function runDetached($, job, argv, env) {
368 /** @type {View} */
369 let view
370 try {
371 view = await drive($, job, argv, env)
372 } catch (error) {
373 const { command, cancelled, runDir } = job
374 const end = { stdout: '', stderr: '', code: null, signal: null, cancelled, runDir, command }
375 view = errorView(1, messageOf(error), end)
376 } finally {
377 await finishJob($, job)
378 }
379 shown = view = await fillBox($, view)
380 $.ui.invalidate('ui.render')
381 $.ui.toast(view.toast)
382 if (!(await openPane($, view))) $.ui.log(renderText(view))
383}
384
385/**
386 * An improved prompt into the prompt box by itself, unless a draft the person typed is there
387 * (SPEC R21); the view then says which happened. Nothing is sent.
388 * @param {E} $ @param {View} view
389 * @returns {Promise<View>}
390 */
391async function fillBox($, view) {
392 const improved = view.improved
393 if (improved === null) return view
394 let placing = boxPlan(view, (await $.prompt.read()).text) ?? boxRefused(view, undefined)
395 if (placing.fill) {
396 const filled = await $.prompt.fill({ text: improved, mode: 'replace' })
397 if (!filled.isFilled) placing = boxRefused(view, filled.refusal)
398 }
399 return { ...view, box: placing.box, toast: placing.toast }
400}
401
402/**
403 * However the run ended: it is over, the status line is cleared, the prompt's folder is removed.
404 * @param {E} $ @param {Job} job
405 */
406async function finishJob($, job) {
407 if (current === job) current = null
408 $.ui.status(undefined)
409 if (job.tmp === null) return
410 let said = ''
411 try {
412 const removed = await $.process.run(['rm', '-rf', '--', job.tmp])
413 if (removed.exitCode !== 0) said = lastLines(removed.stderr)
414 } catch (error) {
415 said = messageOf(error)
416 }
417 if (said !== '') $.ui.log(`autoimprover: could not remove ${job.tmp}: ${said}`)
418}
419
420/**
421 * Opens the pane; with a result, tall enough for it, with the keyboard and Escape to close it.
422 * False when it is not drawn now, so the caller falls back to text.
423 * @param {E} $ @param {View | null} result
424 * @returns {Promise<boolean>}
425 */
426async function openPane($, result) {
427 try {
428 const asks = result ? { focus: FOCUS, closeOnEscape: FOCUS, rows: paneRows(result) } : {}
429 return (await $.ui.open({ id: 'improve', title: 'improve', ...asks })).isPlaced
430 } catch (error) {
431 $.ui.log(`autoimprover: the report pane did not open: ${messageOf(error)}`)
432 return false
433 }
434}
435
436/**
437 * The pane: what `shown` holds, drawn with the surface's elements, and its buttons.
438 * @param {E} $ @param {any} e
439 * @returns {RenderElement}
440 */
441function drawPane($, e) {
442 const elements = $.ui.resolve(e)
443 return paneTree(shown, elements, shown === null ? [] : buttonsOf($, elements.Button, shown))
444}
445
446/**
447 * "Use it" (puts the improved prompt into the box again, over a draft), Copy and Close for an
448 * improved prompt; Close (Hide while it runs) otherwise.
449 * @param {E} $ @param {any} Button the surface's Button element @param {View} view
450 */
451function buttonsOf($, Button, view) {
452 const prompt = view.improved
453 const close = () => closePane($)
454 const dismiss = { role: 'dismiss' }
455 if (prompt === null || view.useLabel === null) {
456 const label = view.state === 'running' ? 'Hide' : 'Close'
457 return [h(Button, { key: 'close', label, hotkey: 'x', ...dismiss, onPress: close })]
458 }
459 const use = () => useImproved($, prompt)
460 /** @param {UiPressArgument} press */
461 const copy = (press) => copyImproved($, prompt, press.surface)
462 const main = { variant: 'primary', autoFocus: true }
463 return [
464 h(Button, { key: 'use', label: view.useLabel, hotkey: 'u', ...main, onPress: use }),
465 h(Button, { key: 'copy', label: 'Copy', hotkey: 'c', onPress: copy }),
466 h(Button, { key: 'close', label: 'Close', hotkey: 'x', ...dismiss, onPress: close }),
467 ]
468}
469
470/**
471 * Puts the improved prompt into the prompt box as an editable draft, then closes the pane.
472 * @param {E} $ @param {string} prompt
473 */
474async function useImproved($, prompt) {
475 const filled = await $.prompt.fill({ text: prompt, mode: 'replace' })
476 if (!filled.isFilled) {
477 const why = filled.refusal ?? 'refused'
478 $.ui.toast(`autoimprover: the prompt box did not take it (${why}); use Copy`)
479 return
480 }
481 $.ui.toast('autoimprover: the improved prompt is in the prompt box; edit it or send it')
482 await $.ui.close({ id: 'improve' })
483}
484
485/** @param {E} $ @param {string} prompt @param {RenderSurface} surface */
486async function copyImproved($, prompt, surface) {
487 const copied = await $.ui.copy({ text: prompt, surface })
488 const said = copied.isCopied ? 'the prompt is on the clipboard' : `not copied (${copied.reason})`
489 $.ui.toast(`autoimprover: ${said}`)
490}
491
492/** @param {E} $ */
493async function closePane($) {
494 await $.ui.close({ id: 'improve' })
495}
496hooks/args.js 195 lines1// What follows `/improve` (or `/optimize`), read into a request (SPEC R21): leading flags of the
2// CLI, then the prompt, verbatim, as the rest of the text; or one of the subcommands `cancel`,
3// `clean [ID]` and `--resume [ID]`. Pure: no engine, no I/O. The prompt never becomes a command
4// line argument; the caller writes it to a file (SPEC R19).
5
6// Every flag of the CLI (src/autoimprover/cli.py, `_parser`) the mod forwards, with the number of
7// values it takes. `--help`, `--json` (always set) and `--resume` (a subcommand) are not in it.
8export const FLAGS = new Map([
9 ['--file', 1],
10 ['--examples', 1],
11 ['--kind', 1],
12 ['--time', 1],
13 ['--deep', 0],
14 ['--workers', 1],
15 ['--budget', 1],
16 ['--strictness', 1],
17 ['--allow-growth', 0],
18 ['--task-model', 1],
19 ['--judge-model', 1],
20 ['--reflect-model', 1],
21 ['--target-model', 1],
22 ['--effort', 1],
23 ['--task-effort', 1],
24 ['--judge-effort', 1],
25 ['--reflect-effort', 1],
26 ['--merge', 0],
27 ['--trust-search', 0],
28 ['--force-low-budget', 0],
29 ['--dry', 0],
30])
31
32// A run id, the only form `--resume` and `clean` take (src/autoimprover/types.py RUN_ID_PATTERN).
33export const RUN_ID = /^\d{8}-\d{6}-[0-9a-f]{8}(?:-\d+)?$/
34
35/**
36 * @typedef {{ flag: string, value: string | null }} Flag
37 * @typedef {{ kind: 'usage', message: string }
38 * | { kind: 'help' }
39 * | { kind: 'cancel' }
40 * | { kind: 'clean', id: string | null }
41 * | { kind: 'resume', id: string | null }
42 * | { kind: 'run', flags: Flag[], prompt: string | null, file: string | null,
43 * examples: string | null, dry: boolean, targetModel: string | null }} Request
44 */
45
46/**
47 * The request in `text`, everything after the command's name as typed (it may span lines).
48 * @param {string} text
49 * @returns {Request}
50 */
51export function parseArgs(text) {
52 const whole = text.trim()
53 if (whole === '') {
54 return usage('give a prompt, --file PATH, --resume [ID], clean [ID] or cancel')
55 }
56 if (whole === 'cancel') return { kind: 'cancel' }
57 if (whole === '-h' || whole === '--help') return { kind: 'help' }
58 const clean = /^clean(?:\s+(\S+))?$/.exec(whole)
59 if (clean !== null) {
60 const id = clean[1] ?? null
61 if (id !== null && !RUN_ID.test(id)) return usage(`clean takes a run id, not ${id}`)
62 return { kind: 'clean', id }
63 }
64 return parseRun(text)
65}
66
67/**
68 * @param {string} text
69 * @returns {Request}
70 */
71function parseRun(text) {
72 /** @type {Flag[]} */
73 const flags = []
74 let rest = text.trimStart()
75 while (rest.startsWith('--')) {
76 const token = /^\S+/.exec(rest)?.[0] ?? ''
77 rest = rest.slice(token.length)
78 if (token === '--') {
79 rest = rest.trimStart()
80 break
81 }
82 const [flag, inline] = splitInline(token)
83 if (flag === '--help') return { kind: 'help' }
84 if (flag === '--resume') return parseResume(flags, inline === null ? rest : `${inline}${rest}`)
85 const arity = FLAGS.get(flag)
86 if (arity === undefined) {
87 return usage(`unknown flag ${flag}; a prompt that starts with -- goes after a lone --`)
88 }
89 if (flags.some((given) => given.flag === flag)) return usage(`${flag} is given twice`)
90 if (arity === 0) {
91 if (inline !== null) return usage(`${flag} takes no value`)
92 flags.push({ flag, value: null })
93 rest = rest.trimStart()
94 continue
95 }
96 const read = inline !== null ? { value: inline, rest } : readValue(rest)
97 if (read === null || read.value === '' || read.value.startsWith('--')) {
98 return usage(`${flag} needs a value`)
99 }
100 flags.push({ flag, value: read.value })
101 rest = read.rest.trimStart()
102 }
103 const value = (/** @type {string} */ flag) => flags.find((f) => f.flag === flag)?.value ?? null
104 const prompt = rest === '' ? null : rest
105 const file = value('--file')
106 if (prompt !== null && file !== null) {
107 return usage('give the prompt as text or with --file, not both')
108 }
109 if (prompt === null && file === null) {
110 return usage('no prompt: give it after the flags, or with --file PATH')
111 }
112 return {
113 kind: 'run',
114 flags,
115 prompt,
116 file,
117 examples: value('--examples'),
118 dry: flags.some((f) => f.flag === '--dry'),
119 targetModel: value('--target-model'),
120 }
121}
122
123/**
124 * `--resume` alone (the last run) or with one run id, and nothing else.
125 * @param {Flag[]} flags
126 * @param {string} rest
127 * @returns {Request}
128 */
129function parseResume(flags, rest) {
130 const id = rest.trim()
131 if (flags.length > 0 || /\s/.test(id)) {
132 return usage('--resume continues a saved run with its own prompt and flags; give it alone')
133 }
134 if (id !== '' && !RUN_ID.test(id)) return usage(`--resume takes a run id, not ${id}`)
135 return { kind: 'resume', id: id === '' ? null : id }
136}
137
138/**
139 * `--flag=value` as [flag, value]; [token, null] without `=`.
140 * @param {string} token
141 * @returns {[string, string | null]}
142 */
143function splitInline(token) {
144 const at = token.indexOf('=')
145 return at < 0 ? [token, null] : [token.slice(0, at), token.slice(at + 1)]
146}
147
148/**
149 * A flag's value at the start of `rest`: one word, or text in double or single quotes (no
150 * escapes), so a path may hold spaces; null when it is missing or its quote never closes.
151 * @param {string} rest
152 * @returns {{ value: string, rest: string } | null}
153 */
154function readValue(rest) {
155 const text = rest.trimStart()
156 const quote = text[0]
157 if (quote === '"' || quote === "'") {
158 const end = text.indexOf(quote, 1)
159 if (end < 0) return null
160 return { value: text.slice(1, end), rest: text.slice(end + 1) }
161 }
162 const word = /^\S+/.exec(text)?.[0]
163 if (word === undefined) return null
164 return { value: word, rest: text.slice(word.length) }
165}
166
167/**
168 * @param {string} message
169 * @returns {Request}
170 */
171function usage(message) {
172 return { kind: 'usage', message }
173}
174
175/**
176 * The usage text of `/<command>`, after `problem` when there is one.
177 * @param {string} command
178 * @param {string} [problem]
179 * @returns {string}
180 */
181export function usageText(command, problem) {
182 const flags = [...FLAGS].map(([flag, arity]) => (arity === 1 ? `${flag} V` : flag))
183 const lines = [
184 `/${command} <prompt> improve the prompt (it may span lines)`,
185 `/${command} [flags] <prompt> a prompt that starts with -- goes after a lone --`,
186 `/${command} --file PATH [flags] the prompt from a file`,
187 `/${command} --dry <prompt> the plan only: no model call, nothing written`,
188 `/${command} --resume [ID] continue the last run, or run ID`,
189 `/${command} clean [ID] remove the last run's folder, or run ID's`,
190 `/${command} cancel stop the running run (it stays resumable)`,
191 `flags: ${flags.join(', ')}`,
192 ]
193 return [...(problem === undefined ? [] : [`error: ${problem}`]), 'usage:', ...lines].join('\n')
194}
195hooks/box.js 45 lines1// What the end of a run does with the prompt box (SPEC R21, amended 2026-10-05): an improved
2// prompt goes in by itself as an editable draft when the box is empty; a draft the person typed
3// is never overwritten (the pane's Use it replaces it on request); a result that is not
4// holdout-verified says so. Nothing is sent: the person presses Enter. Pure: the caller reads
5// and fills the box, and shows the words chosen here in the pane, the text and the toast.
6
7/** @import { View } from './report.js' */
8/** @typedef {{ fill: boolean, box: string, toast: string }} BoxPlan */
9
10/**
11 * Whether to fill the box with the improved prompt, and what to say; null when the run has no
12 * improved prompt (a kept original, a failure, a cancel, a plan).
13 * @param {View} view how the run ended
14 * @param {string} draft what the prompt box holds now
15 * @returns {BoxPlan | null}
16 */
17export function boxPlan(view, draft) {
18 if (view.improved === null) return null
19 if (draft.trim() === '') {
20 return said(true, `the ${what(view)} is in the prompt box: edit it and press Enter to send`)
21 }
22 return said(false, `your draft was kept; press Use it to replace it with the ${what(view)}`)
23}
24
25/**
26 * What to say when the box did not take the prompt (a dialog held the keys, or there is no box).
27 * @param {View} view
28 * @param {string | undefined} refusal the engine's reason, when it gave one
29 * @returns {BoxPlan}
30 */
31export function boxRefused(view, refusal) {
32 const why = refusal ?? 'refused'
33 return said(false, `the prompt box did not take the ${what(view)} (${why}); press Use it or Copy`)
34}
35
36/** @param {View} view */
37function what(view) {
38 return view.verified ? 'improved prompt' : 'improved prompt (NOT verified)'
39}
40
41/** @param {boolean} fill @param {string} text @returns {BoxPlan} */
42function said(fill, text) {
43 return { fill, box: text, toast: `autoimprover: ${text}` }
44}
45hooks/argv.js 157 lines1// The command lines and the environment of the child processes the mod starts (SPEC R19, R21;
2// ADR-010): `uv run --frozen --project <plugin root> autoimprover --json ...` as an argument
3// list, never a shell line; the prompt only as a file path; the virtual environment outside the
4// plugin cache. Pure: the caller passes in what it read from the engine.
5
6// A prompt longer than this many characters gets a `--dry` run first (SPEC R21).
7export const DRY_FIRST_CHARS = 2000
8
9/** @import { Flag } from './args.js' */
10/**
11 * @typedef {{ flags: Flag[], prompt: string | null, file: string | null, dry: boolean,
12 * targetModel: string | null }} RunRequest
13 */
14
15/**
16 * The whole command line of one call of the CLI: `args` after the tool's name and `--json`.
17 * @param {string} root the plugin's folder, where `pyproject.toml` and `uv.lock` are
18 * @param {readonly string[]} args
19 * @returns {string[]}
20 */
21export function cliArgv(root, args) {
22 return ['uv', 'run', '--frozen', '--project', root, 'autoimprover', '--json', ...args]
23}
24
25/**
26 * The CLI's arguments for a run (or with `dry`, its plan): the flags as given, the paths made
27 * absolute, the prompt as `--file <promptFile>` when the person typed it, and the session's
28 * model as `--target-model` unless they chose one (SPEC R14a, R21).
29 * @param {RunRequest} request
30 * @param {{ promptFile: string | null, paths: Map<string, string>, targetModel: string | null,
31 * dry: boolean }} given `paths` maps a flag (`--file`, `--examples`) to its absolute path
32 * @returns {string[]}
33 */
34export function runArgs(request, given) {
35 const args = []
36 for (const { flag, value } of request.flags) {
37 if (flag === '--dry' || flag === '--file') continue
38 args.push(flag)
39 if (value !== null) args.push(given.paths.get(flag) ?? value)
40 }
41 const file = given.promptFile ?? given.paths.get('--file') ?? request.file
42 if (file !== null) args.push('--file', file)
43 if (request.targetModel === null && given.targetModel !== null) {
44 args.push('--target-model', given.targetModel)
45 }
46 if (given.dry) args.push('--dry')
47 return args
48}
49
50/**
51 * @param {string} id
52 * @returns {string[]}
53 */
54export function resumeArgs(id) {
55 return ['--resume', id]
56}
57
58/**
59 * @param {string} id
60 * @returns {string[]}
61 */
62export function cleanArgs(id) {
63 return ['clean', id]
64}
65
66/**
67 * Whether a run starts with its plan: a prompt longer than DRY_FIRST_CHARS characters (code
68 * points, as the CLI counts them), or a `--file` of more bytes than that, since a file's
69 * characters are not read here.
70 * @param {RunRequest} request
71 * @param {number | null} fileBytes the size of the `--file`, when there is one
72 * @returns {boolean}
73 */
74export function needsDryFirst(request, fileBytes) {
75 if (request.dry) return false
76 if (request.prompt !== null) return [...request.prompt].length > DRY_FIRST_CHARS
77 return fileBytes !== null && fileBytes > DRY_FIRST_CHARS
78}
79
80/**
81 * The child's environment over the engine's: uv keeps the tool's virtual environment in the
82 * person's cache folder, never inside the plugin's (installed plugins are replaced on update).
83 * @param {string} venv
84 * @returns {Record<string, string>}
85 */
86export function childEnv(venv) {
87 return { UV_PROJECT_ENVIRONMENT: venv }
88}
89
90/**
91 * `$XDG_CACHE_HOME/autoimprover/venv`, else `$HOME/.cache/autoimprover/venv`; a relative value
92 * is ignored, as the XDG base directory specification asks. Null when neither is usable.
93 * @param {{ home: string | undefined, cacheHome: string | undefined }} env
94 * @returns {string | null}
95 */
96export function venvFolder(env) {
97 const cache = isAbsolute(env.cacheHome)
98 ? trimSlash(env.cacheHome)
99 : isAbsolute(env.home)
100 ? `${trimSlash(env.home)}/.cache`
101 : null
102 return cache === null ? null : `${cache}/autoimprover/venv`
103}
104
105/**
106 * The `mktemp -d` template of the private prompt folder: under `$TMPDIR` when it is absolute,
107 * else under /tmp.
108 * @param {string | undefined} tmpdir
109 * @returns {string}
110 */
111export function tmpTemplate(tmpdir) {
112 const base = isAbsolute(tmpdir) ? trimSlash(tmpdir) : '/tmp'
113 return `${base}/autoimprover-prompt.XXXXXX`
114}
115
116/**
117 * `path` as the CLI should read it: absolute paths as they are, `~/...` under `home`, the rest
118 * under the session's folder (the child runs in its own empty folder).
119 * @param {string} path
120 * @param {{ cwd: string, home: string | undefined }} where
121 * @returns {string}
122 */
123export function resolvePath(path, where) {
124 if (path.startsWith('/')) return path
125 if (path.startsWith('~/') && isAbsolute(where.home)) {
126 return `${trimSlash(where.home)}/${path.slice(2)}`
127 }
128 return `${trimSlash(where.cwd)}/${path}`
129}
130
131/**
132 * The session's model as `--target-model` takes it (a model id or alias, maybe with a `[1m]`
133 * style suffix); null when it does not look like one (a display name).
134 * @param {string} model
135 * @returns {string | null}
136 */
137export function targetModelOf(model) {
138 const name = model.trim()
139 return /^[A-Za-z0-9][A-Za-z0-9._:/-]*(?:\[[^\]\s]*\])?$/.test(name) ? name : null
140}
141
142/**
143 * @param {string | undefined} path
144 * @returns {path is string}
145 */
146function isAbsolute(path) {
147 return path !== undefined && path.startsWith('/')
148}
149
150/**
151 * @param {string} path
152 * @returns {string}
153 */
154function trimSlash(path) {
155 return path.length > 1 ? path.replace(/\/+$/, '') : path
156}
157hooks/pane.js 61 lines1// The report pane's tree (SPEC R21): the head rows of a view (report.js: the result, the prompt
2// box, whether it is verified, the improved prompt in full and wrapped), then the buttons the
3// caller made, then the details; and how many rows the pane asks for, so the improved prompt is
4// seen whole. Pure: the elements come from the table the caller resolved for the surface, and
5// `h` is the environment's element factory.
6
7import { rowsOf } from './report.js'
8
9/** @import { RenderElement } from 'claude-code' */
10/** @import { Row, View } from './report.js' */
11
12/** How each kind of row is drawn. @type {Record<Row['kind'], Record<string, unknown>>} */
13const STYLES = {
14 title: { bold: true },
15 heading: { bold: true },
16 hint: { color: 'yellow' },
17 dim: { dimColor: true },
18 line: {},
19 code: {},
20}
21// The width the row count assumes for wrapped text, and the most rows the pane asks for.
22const COLUMNS = 76
23const MOST_ROWS = 120
24
25/**
26 * The pane for `view` (a placeholder before the first run).
27 * @param {View | null} view
28 * @param {{ Box: any, Text: any, Code: any }} elements the surface's, as the render hook resolved
29 * @param {unknown[]} buttons
30 * @returns {RenderElement}
31 */
32export function paneTree(view, elements, buttons) {
33 const { Box, Text, Code } = elements
34 if (view === null) {
35 return /** @type {RenderElement} */ (h(Text, { dimColor: true }, 'No autoimprover run yet.'))
36 }
37 const { head, tail } = rowsOf(view, false)
38 /** @param {Row} row */
39 const draw = (row) =>
40 row.kind === 'code'
41 ? h(Code, { source: row.text, wrap: 'wrap' })
42 : h(Text, { ...STYLES[row.kind], wrap: 'wrap' }, row.text)
43 const bar = h(Box, { flexDirection: 'row', gap: 2, marginY: 1 }, ...buttons)
44 const column = { flexDirection: 'column' }
45 return /** @type {RenderElement} */ (h(Box, column, ...head.map(draw), bar, ...tail.map(draw)))
46}
47
48/**
49 * The rows the pane wants: every line of the view wrapped at COLUMNS, and the buttons.
50 * @param {View} view
51 * @returns {number}
52 */
53export function paneRows(view) {
54 const { head, tail } = rowsOf(view, false)
55 let rows = 3
56 for (const row of [...head, ...tail]) {
57 for (const line of row.text.split('\n')) rows += Math.max(1, Math.ceil(line.length / COLUMNS))
58 }
59 return Math.min(rows, MOST_ROWS)
60}
61hooks/report.js 435 lines1// What the mod shows of a run (SPEC R2, R21): the one JSON object the CLI prints on stdout,
2// turned into a small view (title, status line, scores, margin, length ratio, reason, what
3// changed, the improved prompt or null, the run folder, the resume line, the exit code) and its
4// plain-text rendering, for the pane and for surfaces that draw nothing. Pure: no engine, no I/O.
5// Model-written text is data: escape sequences and control characters are removed (SPEC R19).
6// The words that depend on the run's tier (SPEC R25) are in tiers.js.
7
8import { tierWords } from './tiers.js'
9
10// The fixed reason codes of the CLI's JSON (src/autoimprover/types.py REASON_CODES).
11export const REASON_CODES = [
12 'improved',
13 'no_reliable_improvement',
14 'already_strong',
15 'no_holdout',
16 'no_candidate_beat_seed',
17 'unconfirmed_out_of_budget',
18 'ungated_best_candidate', // ungated: best-ranked candidate, no win shown (--ungated)
19]
20
21const ERROR_TITLES = new Map([
22 [1, 'failed: internal error'],
23 [2, 'refused'],
24 [3, 'failed: backend failure'],
25 [4, 'failed: the claude session is not locked down'],
26 [130, 'interrupted'],
27])
28const LOGIN = /not logged in|please run \/login/i
29const LOGIN_HINT =
30 'claude is not logged in here: run `claude` once in a terminal to log in, then resume'
31// A terminal escape sequence (CSI, OSC, or ESC and one character); the control characters other
32// than newline and tab, the C1 controls, and the bidirectional overrides (as report.py).
33const ESCAPE =
34 /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]?|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u001b[@-Z\\-_]?/g
35const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g
36// The Code element draws at most 10,000 characters; the pane shows this many of the prompt.
37const SHOWN_CHARS = 9000
38
39/**
40 * @typedef {{ state: 'improved' | 'unchanged' | 'error' | 'cancelled' | 'running' | 'plan',
41 * title: string, status: string, reason: string | null, meaning: string | null,
42 * scores: string[], margin: string | null, lengthRatio: string | null, changes: string[],
43 * improved: string | null, shown: string | null, useLabel: string | null, verified: boolean,
44 * box: string | null, verifiedLine: string | null, mode: string | null, noise: string | null,
45 * runDir: string | null, resume: string | null, hint: string | null, plan: string[],
46 * exitCode: number, toast: string }} View
47 * @typedef {{ stdout: string, stderr: string, code: number | null, signal: string | null,
48 * cancelled: boolean, runDir: string | null, command: string }} End
49 * @typedef {{ kind: 'title' | 'line' | 'dim' | 'heading' | 'hint' | 'code', text: string }} Row
50 * @typedef {{ head: Row[], tail: Row[] }} Rows
51 */
52
53/**
54 * `text` without terminal escape sequences and control characters; newlines and tabs stay.
55 * @param {string} text
56 * @returns {string}
57 */
58export function cleanText(text) {
59 return text.replace(ESCAPE, '').replace(CONTROL, '')
60}
61
62/**
63 * `cleanText` on one line: every run of whitespace becomes one space.
64 * @param {string} text
65 * @returns {string}
66 */
67export function oneLine(text) {
68 return cleanText(text).split(/\s+/).filter(Boolean).join(' ')
69}
70
71/**
72 * The message of a thrown value.
73 * @param {unknown} error
74 * @returns {string}
75 */
76export function messageOf(error) {
77 return oneLine(error instanceof Error ? error.message : String(error))
78}
79
80/**
81 * The one JSON object on the CLI's stdout (SPEC R2), or null when there is none.
82 * @param {string} stdout
83 * @returns {Record<string, unknown> | null}
84 */
85export function parseObject(stdout) {
86 const text = stdout.trim()
87 if (!text.startsWith('{')) return null
88 try {
89 const value = JSON.parse(text)
90 return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null
91 } catch (error) {
92 if (error instanceof SyntaxError) return null
93 throw error
94 }
95}
96
97/**
98 * How a one-shot child (a plan, a clean) ended, as `viewOf` reads it.
99 * @param {{ stdout: string, stderr: string, exitCode: number }} done
100 * @param {string} command
101 * @returns {End}
102 */
103export function endOf(done, command) {
104 const { stdout, stderr, exitCode } = done
105 return { stdout, stderr, code: exitCode, signal: null, cancelled: false, runDir: null, command }
106}
107
108/**
109 * The run id: the last part of a run folder's path.
110 * @param {string | null} runDir
111 * @returns {string | null}
112 */
113export function runIdOf(runDir) {
114 const id = runDir?.replace(/\/+$/, '').split('/').pop() ?? ''
115 return id === '' ? null : id
116}
117
118/**
119 * The view of a finished child: its JSON object, else how it ended.
120 * @param {End} end
121 * @returns {View}
122 */
123export function viewOf(end) {
124 const result = parseObject(end.stdout)
125 const status = result?.status
126 if (result !== null && (status === 'improved' || status === 'unchanged')) {
127 return outcomeView(result, end)
128 }
129 if (result !== null && status === 'dry') return planView(result)
130 if (result !== null && status === 'error') {
131 const runDir = textOr(result.run_dir, end.runDir)
132 return errorView(numberOr(result.code, 1), text(result.error), { ...end, runDir })
133 }
134 if (end.cancelled) return cancelledView(end)
135 if (end.signal !== null) return errorView(130, `the run was stopped by ${end.signal}`, end)
136 const said = lastLines(end.stderr)
137 return errorView(end.code || 1, said === '' ? 'the tool printed no result' : said, end)
138}
139
140/**
141 * @param {Record<string, unknown>} result
142 * @param {End} end
143 * @returns {View}
144 */
145function outcomeView(result, end) {
146 const improved = result.status === 'improved'
147 const verified = result.verified === true
148 const code = text(result.reason_code)
149 const prompt = improved ? cleanText(text(result.prompt)) : null
150 const changes = Array.isArray(result.changes) ? result.changes : []
151 const ratio = result.length_ratio
152 const words = tierWords(result)
153 return {
154 ...blank(end, textOr(result.run_dir, end.runDir)),
155 ...words,
156 state: improved ? 'improved' : 'unchanged',
157 status: `result: ${improved ? 'improved' : 'unchanged'} (${code})`,
158 reason: oneLine(text(result.reason)),
159 meaning: words.meaning === null ? null : oneLine(words.meaning),
160 verifiedLine: words.verifiedLine === null ? null : oneLine(words.verifiedLine),
161 margin: words.margin === null ? null : oneLine(words.margin),
162 scores: words.scores.map(oneLine),
163 noise: words.noise === null ? null : oneLine(words.noise),
164 lengthRatio: improved && typeof ratio === 'number'
165 ? `length: ${ratio.toFixed(2)}x the original's tokens`
166 : null,
167 changes: changes.map((change) => oneLine(text(change)).slice(0, 300)),
168 improved: prompt,
169 shown: prompt === null ? null : shownPart(prompt),
170 useLabel: improved ? (verified ? 'Use it' : 'Use it (not verified)') : null,
171 verified,
172 exitCode: 0,
173 toast: improved ? 'autoimprover: a prompt to review' : 'autoimprover: the original is kept',
174 }
175}
176
177/**
178 * The view of `--dry` (SPEC R4): the plan, and why a real run would refuse when it would.
179 * @param {Record<string, unknown>} result
180 * @returns {View}
181 */
182export function planView(result) {
183 const refusal = typeof result.refusal === 'string' ? oneLine(result.refusal) : null
184 return {
185 ...blank(null, null),
186 state: 'plan',
187 title: 'plan (dry run: no model call made, nothing written)',
188 status: refusal === null ? 'a real run would start' : `a real run would refuse: ${refusal}`,
189 reason: refusal,
190 plan: planLines(result),
191 exitCode: refusal === null ? 0 : 2,
192 toast: 'autoimprover: plan shown',
193 }
194}
195
196/**
197 * @param {Record<string, unknown>} result
198 * @returns {string[]}
199 */
200function planLines(result) {
201 const plan = /** @type {Record<string, unknown>} */ (result.plan ?? {})
202 const models = /** @type {Record<string, unknown>} */ (plan.models ?? {})
203 const at = (/** @type {string} */ key) => text(result[key])
204 const source = result.synthesised === true ? 'synthesised by one call' : 'from --examples'
205 const lines = [
206 `models: task ${text(models.task)}, judge ${text(models.judge)}, ` +
207 `reflection ${text(models.reflect)}, target ${text(models.target)}`,
208 `budget: ${text(plan.budget)} calls; ${at('calls_before_search')} before the search, ` +
209 `${at('calls_after_search')} after it, ${at('search_calls')} for the search`,
210 `scenarios: ${at('scenarios')} (${source}); holdout ${at('holdout')}, ` +
211 `valset ${at('valset')}, dataset ${at('dataset')}`,
212 `iterations: about ${at('iterations')} to ${at('iterations_best')} (an estimate)`,
213 `clock: the search may use ${minutes(result.search_clock_s)}, ` +
214 `the final steps ${minutes(result.final_clock_s)}`,
215 ]
216 if (typeof result.keeps_original === 'string') {
217 const why = oneLine(result.keeps_original)
218 lines.push(`a real run would keep the original without a model call: ${why}`)
219 }
220 return lines
221}
222
223/**
224 * The view of a failed run: the CLI's exit code and message (SPEC R2), and what to do next.
225 * @param {number} code
226 * @param {string} message
227 * @param {End} end
228 * @returns {View}
229 */
230export function errorView(code, message, end) {
231 const said = oneLine(message)
232 const login = code === 3 && LOGIN.test(`${message}\n${end.stderr}`)
233 const id = runIdOf(end.runDir)
234 return {
235 ...blank(end, end.runDir),
236 state: 'error',
237 title: ERROR_TITLES.get(code) ?? `failed with exit code ${code}`,
238 status: `error: ${said}`,
239 reason: said,
240 hint: login ? LOGIN_HINT : null,
241 resume: id !== null && code !== 2 ? `/${end.command} --resume ${id}` : null,
242 exitCode: code,
243 toast: `autoimprover: ${ERROR_TITLES.get(code) ?? 'failed'}`,
244 }
245}
246
247/**
248 * @param {End} end
249 * @returns {View}
250 */
251function cancelledView(end) {
252 const id = runIdOf(end.runDir)
253 return {
254 ...blank(end, end.runDir),
255 state: 'cancelled',
256 title: 'cancelled',
257 status: cancelledText(id, end.command),
258 resume: id === null ? null : `/${end.command} --resume ${id}`,
259 exitCode: 130,
260 toast: 'autoimprover: run cancelled',
261 }
262}
263
264/**
265 * What `/improve cancel` answers.
266 * @param {string | null} id the run id, once the run folder exists
267 * @param {string} command
268 * @returns {string}
269 */
270export function cancelledText(id, command) {
271 return id === null
272 ? 'cancelled before the run folder existed; nothing to resume'
273 : `cancelled; resume with /${command} --resume ${id}`
274}
275
276/**
277 * The view while the child runs: its last line on stderr and the run folder once known.
278 * @param {{ status: string | null, runDir: string | null, command: string }} progress
279 * @returns {View}
280 */
281export function runningView(progress) {
282 return {
283 ...blank(null, progress.runDir),
284 state: 'running',
285 title: 'running',
286 status: progress.status ?? 'starting',
287 hint: `/${progress.command} cancel stops it; the run stays resumable`,
288 toast: 'autoimprover: running',
289 }
290}
291
292/**
293 * The rows of a view, each with how the pane draws it: `head` (the result, the prompt box, whether
294 * it is verified and the improved prompt, in full or as the pane shows it with `full` false),
295 * which the pane draws before its buttons, then `tail`, the details.
296 * @param {View} view
297 * @param {boolean} full
298 * @returns {Rows}
299 */
300export function rowsOf(view, full) {
301 /** @type {Rows} */
302 const rows = { head: [], tail: [] }
303 /** @param {Row[]} to @param {Row['kind']} kind @param {string | null} text */
304 const add = (to, kind, text) => {
305 if (text !== null) to.push({ kind, text })
306 }
307 const { head, tail } = rows
308 add(head, 'title', `autoimprover: ${view.title}`)
309 add(head, 'line', view.status)
310 add(head, 'hint', view.box)
311 add(head, view.verified ? 'line' : 'hint', view.verifiedLine)
312 if (view.improved !== null) add(head, 'heading', 'improved prompt:')
313 add(head, 'code', full ? view.improved : view.shown)
314 if (view.state === 'improved' || view.state === 'unchanged') {
315 add(tail, 'line', `reason: ${view.reason}`)
316 }
317 add(tail, 'dim', view.meaning)
318 add(tail, 'line', view.mode)
319 for (const line of [...view.plan, ...view.scores]) add(tail, 'line', line)
320 add(tail, 'line', view.noise)
321 add(tail, 'line', view.margin)
322 add(tail, 'line', view.lengthRatio)
323 if (view.changes.length > 0) add(tail, 'heading', 'what changed:')
324 for (const change of view.changes) add(tail, 'line', ` - ${change}`)
325 add(tail, 'hint', view.hint)
326 add(tail, 'dim', view.runDir === null ? null : `run folder: ${view.runDir}`)
327 add(tail, 'dim', view.resume === null ? null : `resume with: ${view.resume}`)
328 return rows
329}
330
331/**
332 * The plain-text rendering of a view, for a surface that draws no pane (SPEC R21).
333 * @param {View} view
334 * @returns {string}
335 */
336export function renderText(view) {
337 const { head, tail } = rowsOf(view, true)
338 return [...head, ...tail].map((row) => row.text).join('\n')
339}
340
341/**
342 * What `/improve clean` answers once the CLI cleaned (its `--json` object).
343 * @param {Record<string, unknown>} result
344 * @param {string} id
345 * @returns {string}
346 */
347export function cleanedText(result, id) {
348 if (numberOr(result.skipped, 0) > 0) {
349 return `run ${id} is still running (it holds its lock), so its folder was kept`
350 }
351 return numberOr(result.removed, 0) > 0
352 ? `removed the run folder of ${id}`
353 : `there was no run folder of ${id} to remove`
354}
355
356/**
357 * The last three non-blank lines of `stderr`, cleaned, on one line.
358 * @param {string} stderr
359 * @returns {string}
360 */
361export function lastLines(stderr) {
362 return stderr.split('\n').map(oneLine).filter(Boolean).slice(-3).join(' / ')
363}
364
365/**
366 * The fields a view kind does not set.
367 * @param {End | null} end
368 * @param {string | null} runDir
369 * @returns {View}
370 */
371function blank(end, runDir) {
372 return {
373 state: 'error',
374 title: '',
375 status: '',
376 reason: null,
377 meaning: null,
378 scores: [],
379 margin: null,
380 lengthRatio: null,
381 changes: [],
382 improved: null,
383 shown: null,
384 useLabel: null,
385 verified: false,
386 box: null,
387 verifiedLine: null,
388 mode: null,
389 noise: null,
390 runDir: runDir ?? end?.runDir ?? null,
391 resume: null,
392 hint: null,
393 plan: [],
394 exitCode: 1,
395 toast: '',
396 }
397}
398
399/**
400 * The improved prompt as the pane can draw it, cut with a note when it is too long.
401 * @param {string} prompt
402 * @returns {string}
403 */
404function shownPart(prompt) {
405 const points = [...prompt]
406 if (points.length <= SHOWN_CHARS) return prompt
407 const more = points.length - SHOWN_CHARS
408 const note = `${more} more characters; Use it and Copy take the whole prompt`
409 return `${points.slice(0, SHOWN_CHARS).join('')}\n... (${note})`
410}
411
412/** @param {unknown} value @returns {string} */
413function text(value) {
414 if (typeof value === 'string') return value
415 return value === undefined || value === null ? '' : String(value)
416}
417
418/** @param {unknown} value @param {string | null} fallback @returns {string | null} */
419function textOr(value, fallback) {
420 return typeof value === 'string' && value !== '' ? value : fallback
421}
422
423/** @param {unknown} value @param {number} fallback @returns {number} */
424function numberOr(value, fallback) {
425 return typeof value === 'number' && Number.isInteger(value) ? value : fallback
426}
427
428/** @param {unknown} seconds @returns {string} */
429function minutes(seconds) {
430 if (typeof seconds !== 'number') return '?'
431 const whole = Math.floor(seconds / 60)
432 const rest = Math.round(seconds - whole * 60)
433 return rest === 0 ? `${whole} min` : `${whole} min ${rest} s`
434}
435hooks/stream.js 68 lines1// The child's output as it streams in (SPEC R2, R21): stdout is collected whole (the one JSON
2// object), stderr is cut into lines for the status line, and the `run folder: <path>` line the
3// CLI prints is caught the first time it appears, so the run id is known (and kept for
4// `--resume`) while the run still goes. Pure: the caller feeds it the chunks.
5
6import { cleanText } from './report.js'
7
8// The bytes of stderr kept for the report of a run that printed no JSON (the tail matters).
9const STDERR_KEPT = 65536
10const RUN_FOLDER = /^run folder: (\/.*?)(?: \(remove it with: autoimprover clean \S+\))?$/
11
12export class Progress {
13 constructor() {
14 this.stdout = ''
15 this.stderr = ''
16 /** @type {string | null} */
17 this.runDir = null
18 this.partial = ''
19 }
20
21 /**
22 * Takes one chunk; says what it changed: the status line (the last non-blank stderr line in
23 * it) and the run folder when this chunk revealed it.
24 * @param {{ stream: 'stdout' | 'stderr', text: string }} chunk
25 * @returns {{ status: string | null, runDir: string | null }}
26 */
27 push(chunk) {
28 if (chunk.stream === 'stdout') {
29 this.stdout += chunk.text
30 return { status: null, runDir: null }
31 }
32 this.stderr = (this.stderr + chunk.text).slice(-STDERR_KEPT)
33 const lines = (this.partial + chunk.text).split('\n')
34 this.partial = lines.pop() ?? ''
35 return this.read(lines)
36 }
37
38 /**
39 * The last line, when the child ended without a newline after it.
40 * @returns {{ status: string | null, runDir: string | null }}
41 */
42 finish() {
43 const lines = [this.partial]
44 this.partial = ''
45 return this.read(lines)
46 }
47
48 /**
49 * @param {string[]} lines
50 * @returns {{ status: string | null, runDir: string | null }}
51 */
52 read(lines) {
53 let status = null
54 let runDir = null
55 for (const line of lines) {
56 const said = cleanText(line).trim()
57 if (said === '') continue
58 status = said.length > 160 ? `${said.slice(0, 157)}...` : said
59 const found = RUN_FOLDER.exec(said)?.[1] ?? null
60 if (found !== null && this.runDir === null) {
61 this.runDir = found
62 runDir = found
63 }
64 }
65 return { status, runDir }
66 }
67}
68hooks/tiers.js 133 lines1// The words of a finished run by its tier (SPEC R25; the JSON's `mode`). The CLI's JSON carries
2// its own words, `meaning`, `verified_text`, `margin_text` and `score_lines`, and the seconds of
3// the run's clock, `elapsed_s`: they are shown as given (the caller removes escape and control
4// characters). `score_lines` says, for the scores, the noise and the margin, which rule decided (a
5// score, a preference or a reference score) and where it was taken; only JSON without it (an
6// older CLI) is labelled here, as report.py labelled it before: each score by where it was taken
7// (quick and fast on the scenarios they were picked on, checked and deep on held-out scenarios)
8// and the noise between the original's two runs, all as scores. Pure: no engine, no I/O.
9
10const UNHELD = ['quick', 'fast']
11const PIPELINE = ['quick', 'fast', 'checked']
12const PICKED = 'score on the scenarios it was picked on (not held out)'
13// The least gain a rewrite needs whatever the noise: 0.1 in the quick, fast and checked tiers
14// (fast_stages.FAST_MARGIN), 0.05 after the search (runner.MIN_THRESHOLD).
15const LEAST_GAIN = 0.1
16const SEARCH_LEAST_GAIN = 0.05
17
18/**
19 * The tier-dependent fields of a finished run's view.
20 * @param {Record<string, unknown>} result the CLI's JSON object
21 * @returns {{ title: string, verifiedLine: string | null, mode: string | null,
22 * meaning: string | null, scores: string[], noise: string | null, margin: string | null }}
23 */
24export function tierWords(result) {
25 const mode = given(result.mode)
26 const improved = result.status === 'improved'
27 const verified = result.verified === true
28 const pipeline = mode !== null && PIPELINE.includes(mode)
29 const unheld = mode !== null && UNHELD.includes(mode)
30 const said = given(result.verified_text)
31 const margin = given(result.margin_text)
32 const lines = givenLines(result.score_lines)
33 const seconds = typeof result.elapsed_s === 'number' ? ` (${result.elapsed_s.toFixed(0)} s)` : ''
34 return {
35 title: !improved
36 ? 'unchanged: the original prompt is kept'
37 : verified
38 ? 'improved, verified on held-out scenarios'
39 : `improved, NOT verified${unheld ? ' (fast check)' : ''}`,
40 verifiedLine: improved && said !== null ? `verified: ${said}` : null,
41 mode: mode === null ? null : `mode: ${mode}${seconds}`,
42 meaning: given(result.meaning),
43 scores: lines === null ? scoreLines(result, unheld, pipeline) : withCalls(lines, result),
44 noise: lines === null ? noiseLine(result, pipeline, margin) : null,
45 margin: lines === null && pipeline && margin !== null ? `margin: ${margin}` : null,
46 }
47}
48
49/**
50 * The CLI's own score lines (report.py `outcome_object`, `score_lines`), or null when the JSON has
51 * none (an older CLI), or none usable. They hold the noise and the margin lines too.
52 * @param {unknown} value
53 * @returns {string[] | null}
54 */
55function givenLines(value) {
56 if (!Array.isArray(value)) return null
57 const lines = value.filter((line) => typeof line === 'string' && line.trim() !== '')
58 return lines.length > 0 ? lines : null
59}
60
61/**
62 * @param {string[]} lines the CLI's score lines
63 * @param {Record<string, unknown>} result
64 * @returns {string[]}
65 */
66function withCalls(lines, result) {
67 return typeof result.calls_used === 'number'
68 ? [...lines, `calls used: ${result.calls_used}`]
69 : lines
70}
71
72/**
73 * A text the JSON gives, or null.
74 * @param {unknown} value
75 * @returns {string | null}
76 */
77function given(value) {
78 return typeof value === 'string' && value.trim() !== '' ? value : null
79}
80
81/**
82 * The scores, each labelled where it was taken (report.py `_scores`), and the calls used.
83 * @param {Record<string, unknown>} result
84 * @param {boolean} unheld
85 * @param {boolean} pipeline
86 * @returns {string[]}
87 */
88function scoreLines(result, unheld, pipeline) {
89 const lines = []
90 /** @param {unknown} before @param {unknown} after */
91 const pair = (before, after) =>
92 typeof after === 'number'
93 ? `${fixed(before)} before, ${after.toFixed(2)} after`
94 : `${fixed(before)} for the original`
95 const { score_before, score_after, search_score_before, search_score_after } = result
96 if (typeof score_before === 'number') {
97 const where = unheld ? PICKED : 'holdout score (target model)'
98 lines.push(`${where}: ${pair(score_before, score_after)}`)
99 }
100 if (typeof search_score_before === 'number') {
101 const where = pipeline ? PICKED : 'search score (search model)'
102 lines.push(`${where}: ${pair(search_score_before, search_score_after)}`)
103 }
104 if (typeof result.calls_used === 'number') lines.push(`calls used: ${result.calls_used}`)
105 return lines
106}
107
108/**
109 * The noise between the original's two runs (report.py `_scores`): with the gain a result had
110 * to make, max(least gain, 2 x noise), when there is no margin; after the search with the
111 * margin's words, which the quick, fast and checked tiers show on a line of their own.
112 * @param {Record<string, unknown>} result
113 * @param {boolean} pipeline
114 * @param {string | null} margin the JSON's `margin_text`
115 * @returns {string | null}
116 */
117function noiseLine(result, pipeline, margin) {
118 const noise = result.noise
119 if (typeof noise !== 'number') return null
120 const runs = pipeline ? 'two runs on the scenarios it was picked on' : 'two holdout runs'
121 const said = `noise: ${noise.toFixed(2)} between the original's ${runs}`
122 if (margin === null) {
123 const bar = Math.max(pipeline ? LEAST_GAIN : SEARCH_LEAST_GAIN, 2 * noise)
124 return `${said}; a result had to gain more than ${bar.toFixed(2)}`
125 }
126 return pipeline ? said : `${said}; ${margin}`
127}
128
129/** @param {unknown} value @returns {string} */
130function fixed(value) {
131 return typeof value === 'number' ? value.toFixed(2) : '?'
132}
133