SLOPSHOPPER

autoimprover

/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…

panecommandtoaststatusprocess
v0.2.0.dev0MITupdated 2026-10-26ItsUkrainianCat/intentopt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · autoimprover
│ ┃ improve ✕ › fix the failing auth test and add an audit log call │ ┃ No autoimprover run yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /improve │ ⎿ autoimprover: error: give a prompt, --file PATH, --resume [ID], │ ⎿ autoimprover: usage: │ ⎿ autoimprover: /improve <prompt> improve the promp │ ⎿ autoimprover: /improve [flags] <prompt> a prompt that sta │ ⎿ autoimprover: /improve --file PATH [flags] the prompt from a │ ⎿ autoimprover: /improve --dry <prompt> the plan only: no │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · improve
No autoimprover run yet.
README

IntentOpt

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.

Try it

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.

Research and evidence

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.

Support the work

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.

Explore and contribute

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.

Source 8 files
hooks/register.js 496 lines
1// 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}
496
hooks/args.js 195 lines
1// 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}
195
hooks/box.js 45 lines
1// 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}
45
hooks/argv.js 157 lines
1// 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}
157
hooks/pane.js 61 lines
1// 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}
61
hooks/report.js 435 lines
1// 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}
435
hooks/stream.js 68 lines
1// 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}
68
hooks/tiers.js 133 lines
1// 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