SLOPSHOPPER

sharpprompt

Lets clear prompts through untouched and rewrites rough ones into the prompt box for you to check before sending.

newbandguardcommandpromptmodel
★ 1v0.2.0MITupdated 2026-10-09ondrhn/sharpprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sharpprompt
› fix the failing auth test and add an audit log call ⏺ 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 › /sharpprompt ⎿ sharpprompt: On for this session, mode fill. ⎿ sharpprompt: Last prompt: classified error in 2520 ms, sent as typed ⎿ sharpprompt: Last rewrite: none yet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

sharpprompt

Rough prompt in. Clear prompt in your box. You press Enter.

A small black creature at a desk rewrites a crumpled note with the chat beside it, while most notes fly straight past into the prompt box

License: MIT Claude Code 2.1.293+

What is this

sharpprompt is a Claude Code mod that looks at each prompt you type before it goes out, and lets most of them through untouched. When one is rough (vague, missing what you want back, leaning on "that thing above"), it rewrites it with the conversation in view and puts the rewrite in your prompt box. You press Enter, edit it, or take your own text back; it never sends anything for you.

What it looks like

From a real session. The conversation had just covered hooks/gate.ts, and this was typed:

that question thing u mentioned, is it gonna break when replies end with a code block or smth, look into it

The prompt was held back, and the box filled with:

Will `endsWithQuestion()` in hooks/gate.ts break when Claude's reply ends with a code block or something similar?
Look into it and tell me which endings it gets wrong, with an example for each. Don't change anything yet.

Above the box:

sharpprompt rewrote: that question thing u mentioned, is it gonna break when replies end wi...
Enter sends it, or edit it in the box. [ back to mine ]

"u mentioned" became the function and file the conversation was about.

Recording: a rough prompt is typed, the rewrite appears in the prompt box with a note above it, Enter sends it and Claude starts on the fix

Recorded with vhs on Claude Code 2.1.295.

How it works

Three stations on a conveyor: a turnstile with a stopwatch, a scale that sends clear notes out a door, and a desk where rough notes are rewritten; a red belt underneath carries stalled notes through as typed

  1. A gate with no model lets through slash commands, # lines, prompts under 40 characters, pastes over 20,000, anything you did not type (task notifications, other sessions, plugins), and your answer to a question Claude just asked. It takes about 17 microseconds per prompt on a desktop CPU (mean of 100,000 calls; see tests/gate.bench.test.ts).
  2. A small model (Haiku by default) labels the rest clear or rough, with a 2.5 second limit. Clear goes out as typed.
  3. A rough prompt is rewritten by your session's own model, forked from the conversation so it can resolve references, and put in your box. If the model thinks the draft is fine, nothing changes.
  4. Since 0.2: when the draft leaves out something the conversation does not answer and that would change the work (which file, which of two behaviours, a format), the rewriter may ask, at most two questions, each in its own dialog with its recommended answer first. Your answers are added to the rewrite. Close a dialog and your prompt goes out as typed. It also reads, from the transcript only, which files this session read or changed and the last command that failed, so "that file" and "fix that" can be named.

Anything that fails sends your prompt as typed.

Install

Inside Claude Code:

/plugin marketplace add ondrhn/sharpprompt
/plugin install sharpprompt@ondrhn

Or from a clone:

git clone https://github.com/ondrhn/sharpprompt ~/sharpprompt
claude --plugin-dir ~/sharpprompt

To load the clone in every session, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.

Commands

/sharpprompt, or /sharp for short:

statuson or off, the mode, what happened to the last prompt
on, offfor this session
`mode fill\replace\context\off`for this session; the default is set in /config
undoputs your last original back in the box
statswhat sharpprompt has done so far (see Measurements)
try <prompt>shows the rewrite without sending anything

Ask before sending and Session facts in /config turn the 0.2 helpers off. The Rewrite language setting in /config keeps the rewrite in the language you wrote in (same, the default) or always writes it in English (en), for when you type in another language and want Claude to get a clear English prompt.

Modes: fill (default) puts the rewrite in the box. replace sends the rewrite and shows your original above the box with a way back. context sends your prompt as typed and gives Claude the rewrite beside it as a note.

What it reaches

The creature sits in a glass booth with only your prompt and the chat; files, keys, the network, a shell and a mailbox stand outside, each crossed out in red

claude plugin validate --strict . on this repository:

Validating marketplace manifest: .claude-plugin/marketplace.json

Validating plugin: .claude-plugin/plugin.json

  ❯ types ./types/index.d.ts declares on $: nothing (no EngineInterface member)
  ❯ types ./types/index.d.ts declares state: sharpprompt.pending, sharpprompt.isOff, sharpprompt.mode, sharpprompt.lastDecision

Validating hooks: hooks/hooks.json

  ❯ ./register.tsx hooks: session.start, command.run{command=sharpprompt}, command.run{command=sharp}, prompt.submit, tool.call, turn.complete, ui.render{component=AbovePrompt}
  ❯ ./register.tsx answers its own command: command.run{command=sharpprompt}
  ❯ ./register.tsx answers its own command: command.run{command=sharp}
  ❯ ./register.tsx gating hook with .catch: prompt.submit
  ❯ ./register.tsx gating hook with .catch: tool.call
  ❯ ./register.tsx calls: $.clock.after (via deliver), $.clock.now (via decide, rewrite), $.clock.sleep (via race), $.command.register, $.model.classify (via classify), $.model.complete (via rewrite), $.model.fork (via rewrite), $.prompt.fill (via deliver, restoreOriginal), $.prompt.read (via deliver), $.session.messages (via lastReply, recent, rewriteOptions), $.session.model (via record, rewrite), $.session.surface, $.state.get, $.state.set, $.store.get (via bump, exemplarsOf, push, readList, runCommand), $.store.set (via bump, deliver, push, settlePending), $.ui.ask (via askUser), $.ui.resolve
  ❯ ./register.tsx state writes: sharpprompt.isOff, sharpprompt.lastDecision, sharpprompt.mode, sharpprompt.pending
  ❯ ./register.tsx state reads: sharpprompt.isOff, sharpprompt.lastDecision, sharpprompt.mode, sharpprompt.pending

✔ Validation passed

That is the whole list: the model, the prompt box, the question dialog, the session's messages and model, its own state and store, the clock, its two commands and the band above the box. A prompt rewriter sees everything you type, so you should be able to check that it cannot send it anywhere.

Rules

The rewriter gets a short rule set chosen by your session's model family:

  • Every model: keep your intent and invent nothing, give the reason when the conversation shows it, name the deliverable and when it is done, keep a question a question, and never ask the model to show its reasoning (Fable and Opus 5.5 can refuse that).
  • Fable: one brief instruction over a list, ask for progress updates on long tasks, state the assumption when the request is ambiguous.
  • Opus: no extra "verify your work" step, hold the scope, say how long a document should be.
  • Sonnet: spell out how far an instruction reaches, say when you want only the change.
  • Haiku: ask for a real check before calling a code change done.

Each rule is in docs/rules with a link to the Anthropic page it comes from. Two are ours rather than Anthropic's and say so: no role lines ("You are an expert..."), and a length limit (twice your prompt or 60 words, whichever is more, at most 180). docs/shapes has what a good prompt of each kind carries (fix, investigate, build, refactor, research, review, write, ask), with one example each.

Limits

  • The rewrite runs on your session's model, so it costs what a short question to that model costs; the conversation part comes from the prompt cache while it is warm. /sharp stats shows the tokens per rough prompt. The Helper model setting picks only the classifier and the fallback used before the conversation has a first reply.
  • Claude Code's fork has no time limit, so sharpprompt stops waiting after 5 seconds and sends your prompt as typed. The fork still finishes in the background and is billed, with no cap on its output. /sharp stats counts these.
  • VS Code and headless runs (claude -p, the SDK) have no box to fill, so fill acts as context there. Mods do not run in a Desktop session that uses WSL.
  • Taking your own text back is two keys: r puts it in the box, Enter sends it. sharpprompt does not send it for you because a prompt a plugin sends shows under the plugin's name in the transcript and skips @file expansion. The band's buttons answer to their keys once the band has focus: ctrl+x tab, or a click.
  • A rough prompt waits for the classifier and the rewrite before anything happens. In the sessions measured so far the box filled 3.6 to 6.2 seconds after Enter.

Measurements

versiondatepromptsgate (mean)classifier p50rewrite p50timeouts
0.1.08 Oct 202619 typed, 8 rewritten17 us1.0 s (n=9)3.1 s (n=6)0

Benchmark, 30 cases, the prompt as typed against the rewrite: no measurable difference on Fable 5.1 (26 paired cases, report) or on Sonnet 5.5 (30 paired cases, report). A blind judge found none either. With Turkish drafts rewritten into English (Sonnet 5.5), task success and judge scores stayed the same while output tokens and time went down, mostly because the answers came back in English (report). On a second corpus of 30 cases built so the model lacks something only the user knows (an unstated requirement, a vague pointer back into a long conversation, the session's last test run), the rewrite, with its questions answered by a model that knows what the user meant, raised passing checks from 13 to 25 on Sonnet 5.5 and from 12 to 25 on Fable 5.1, all of it in the first two kinds of case (Sonnet, Fable).

Each version gets a file in docs/measurements with the method and the raw table, and a row here. On ordinary prompts the rewrite made no measurable difference. When the prompt leaves out something only you know, the rewrite's questions recovered about half of it (hidden requirement) or all of it (a vague pointer back into the conversation); see the v2 reports. /sharp stats keeps the same numbers for your own sessions, on your machine only, and compares turns that started from your text with turns that started from a rewrite.

Tests: 76, in tests/, run with claude plugin test ..

FAQ

Does it send anything for me?

No. In the default mode the rewrite waits in your box until you press Enter. Only replace mode, which you turn on yourself, sends the rewrite in place of your text.

Why did it leave my prompt alone?

Most prompts pass: short ones, commands, answers to Claude's questions, and anything the classifier calls clear. /sharp status says which reason applied to the last one.

What does a rewrite cost?

One classifier call on the helper model for each prompt that passes the gate, and for a rough prompt one fork on your session's model. In the 0.1.0 measurements that fork used about 2,500 input tokens outside the cache, 64,000 read from the cache and 230 output tokens. /sharp stats shows your own numbers.

Can I turn it off for one prompt?

Start the prompt with raw:. The prefix is removed and the rest goes out as typed. /sharp off turns it off for the session.

Does it work in VS Code?

The hooks run, but VS Code has no box for the mod to fill and no band, so the rewrite goes to Claude as a note beside your prompt (context mode) and your prompt goes out as typed.

Development

Types come from the Claude Code build you run. Open an interactive session with the plugin once (claude --plugin-dir .) and the engine writes them to .claude-plugin/types/ (git-ignored); tsconfig.json extends the one written there. After a Claude Code update, open it again.

Rules and shapes live in docs/; node scripts/build-bank.mjs compiles them into hooks/bank.ts, because a mod cannot read files at run time. --check fails when the two drift.

Checks: claude plugin validate --strict ., claude plugin test ., tsc -p ., node scripts/build-bank.mjs --check.

Benchmark: node --experimental-strip-types --no-warnings --import ./scripts/ts-resolve.mjs scripts/bench.mjs --help; the corpus and how to run it are in docs/bench.

License

MIT. See LICENSE.

Source 9 files
hooks/register.tsx 456 lines
1import type { EngineInterface, ModelUsage, PromptSubmitInput, PromptSubmitResult, Register } from 'claude-code'
2import type { SharppromptDecision, SharppromptMode, SharppromptPending, SharppromptRewrite, SharppromptVerdict } from '../types'
3import { contextNote, describe, DROP_NOTE, EDIT_OVERLAP, HELP, overlap, parseCommand } from './flow'
4import { sessionFacts } from './facts'
5import { endsWithQuestion, gate } from './gate'
6import { applyAnswers, askInput, recommended, splitReply, type Question } from './questions'
7import { countKey, lastRewriteLine, MAX_RECORDS, summary, tokens, wordCount, type ClassifyRecord, type Counts, type RewriteRecord, type TurnRecord } from './stats'
8import { clean, completePrompt, familyOf, forkPrompt, type Exemplar, type Recent, type RewriteOptions } from './rewrite'
9
10// The engine checks that $ never leaves this file, so everything that calls
11// it lives here and gate.ts stays pure.
12
13const isOff = { plugin: 'sharpprompt', key: 'isOff' } as const
14const lastDecision = { plugin: 'sharpprompt', key: 'lastDecision' } as const
15const pending = { plugin: 'sharpprompt', key: 'pending' } as const
16const modeOverride = { plugin: 'sharpprompt', key: 'mode' } as const
17
18// The turn now running and how its prompt got there; tool calls counted on
19// the main thread. Module state: a reload mid-turn loses one turn's record.
20let outgoing: TurnRecord['prompt'] | null = null
21let toolCalls = 0
22
23type Options = Readonly<Record<string, unknown>>
24
25export const CLASSIFY_MS = 2_500
26
27// The labels the classifier picks from; the wording is what it judges by.
28const CLEAR = 'clear and specific'
29const ROUGH = 'rough: vague or missing what to deliver'
30export const REWRITE_MS = 5_000
31
32// Tokens a fork spent after it lost its race: it finished in the background
33// and was billed anyway. Read out by the stats in a later step.
34export const late = { forks: 0, usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } }
35
36function addLate(u: ModelUsage) {
37  late.forks++
38  late.usage.input_tokens += u.input_tokens
39  late.usage.output_tokens += u.output_tokens
40  late.usage.cache_read_input_tokens += u.cache_read_input_tokens
41  late.usage.cache_creation_input_tokens += u.cache_creation_input_tokens
42}
43
44const TIMEOUT: unique symbol = Symbol('timeout')
45
46// fork and classify take no time limit, so we race them against the clock.
47// The loser keeps running: a fork that lost still finishes in the background
48// and is billed (README, Limits).
49async function race<T>($: EngineInterface, work: Promise<T>, ms: number): Promise<T | typeof TIMEOUT> {
50  const stop = new AbortController()
51  // A sleep that fails for its own reasons must not read as a timeout: the
52  // work then runs unraced, and the engine's hook budget is the backstop.
53  const timer: Promise<typeof TIMEOUT> = $.clock.sleep(ms, { signal: stop.signal }).then(
54    () => TIMEOUT,
55    () => new Promise<never>(() => {}),
56  )
57  try {
58    return await Promise.race([work, timer])
59  } finally {
60    stop.abort()
61  }
62}
63
64async function classify($: EngineInterface, text: string, model: string): Promise<SharppromptVerdict> {
65  try {
66    const label = await race($, $.model.classify(text, [CLEAR, ROUGH], { model }), CLASSIFY_MS)
67    if (label === TIMEOUT) return 'timeout'
68    if (label === CLEAR) return 'clear'
69    if (label === ROUGH) return 'rough'
70    return 'error'
71  } catch {
72    return 'error'
73  }
74}
75
76async function recent($: EngineInterface): Promise<Recent[]> {
77  const rows = await $.session.messages()
78  return rows
79    .filter(r => r.text.trim() !== '')
80    .slice(-4)
81    .map(r => ({ role: r.role, text: r.text }))
82}
83
84async function exemplarsOf($: EngineInterface): Promise<Exemplar[]> {
85  const v = await $.store.get('exemplars')
86  return Array.isArray(v) ? (v as Exemplar[]) : []
87}
88
89// Fork first: it reads the conversation from the prompt cache. With nothing
90// to fork yet (first turn, after /clear) a plain completion with the last few
91// messages pasted in. Both race the clock; losing means no rewrite.
92async function rewrite($: EngineInterface, draft: string, helper: string, opts: RewriteOptions = {}): Promise<SharppromptRewrite> {
93  const started = await $.clock.now()
94  const family = familyOf(await $.session.model())
95  const examples = await exemplarsOf($)
96  const took = async () => (await $.clock.now()) - started
97
98  const forking = $.model.fork({ prompt: forkPrompt(draft, family, examples, opts) })
99  const forked = await race($, forking, REWRITE_MS)
100  if (forked === TIMEOUT) {
101    void forking.then(r => ('usage' in r ? addLate(r.usage) : undefined), () => {})
102    return { outcome: 'timeout', via: 'fork', ms: await took() }
103  }
104
105  let reply = forked
106  let via: 'fork' | 'complete' = 'fork'
107  if (!reply.isAnswered && reply.reason === 'nothing-to-fork') {
108    via = 'complete'
109    reply = await $.model.complete({
110      model: helper,
111      prompt: completePrompt(draft, family, await recent($), examples, opts),
112      maxTokens: 600,
113      effort: 'low',
114      timeoutMs: REWRITE_MS,
115    })
116    if (!reply.isAnswered && reply.reason === 'aborted') return { outcome: 'timeout', via, ms: await took() }
117  }
118  if (!reply.isAnswered) return { outcome: 'error', via, ms: await took(), detail: reply.reason }
119
120  const { body, questions } = opts.ask ? splitReply(reply.text) : { body: reply.text, questions: [] }
121  const c = clean(body, draft)
122  const ms = await took()
123  if ('rejected' in c) return { outcome: c.rejected, via, ms, usage: reply.usage }
124  return { outcome: 'rewritten', via, ms, usage: reply.usage, text: c.rewritten, ...(questions.length ? { questions } : {}) }
125}
126
127async function lastReply($: EngineInterface): Promise<string> {
128  const rows = await $.session.messages()
129  for (let i = rows.length - 1; i >= 0; i--) {
130    const row = rows[i]
131    if (row?.role === 'assistant' && row.text.trim() !== '') return row.text
132  }
133  return ''
134}
135
136const helperModel = (options: Options) => (typeof options.optimizerModel === 'string' ? options.optimizerModel : 'haiku')
137// Both v0.2 helpers are on unless the user turned them off.
138async function rewriteOptions($: EngineInterface, options: Options): Promise<RewriteOptions> {
139  const facts = options.sessionFacts === false ? '' : sessionFacts(await $.session.messages())
140  return {
141    language: options.rewriteLanguage === 'en' ? 'en' : 'same',
142    ask: options.askBeforeSend !== false,
143    ...(facts ? { facts } : {}),
144  }
145}
146
147// The engine shows a plugin's questions one dialog at a time ($.ui.ask), so
148// the rewriter asks at most two. null when the person closes either dialog
149// or it cannot be shown: the prompt then goes out as typed.
150async function askUser($: EngineInterface, questions: readonly Question[]): Promise<Record<string, string> | null> {
151  const answers: Record<string, string> = {}
152  try {
153    for (const [i, q] of askInput(questions).questions.entries()) {
154      const header = questions.length > 1 ? `${q.header.slice(0, 8)} ${i + 1}/${questions.length}` : q.header
155      answers[q.question] = await $.ui.ask(q.question, { options: q.options.map(o => o.label), header })
156    }
157    return answers
158  } catch {
159    return null
160  }
161}
162
163async function modeOf($: EngineInterface, options: Options): Promise<SharppromptMode> {
164  const o = (await $.state.get(modeOverride)).value
165  if (o) return o
166  const m = options.mode
167  return m === 'replace' || m === 'context' || m === 'off' ? m : 'fill'
168}
169
170// What sharpprompt would do with this prompt. Anything without a rewritten
171// text means the prompt goes out exactly as typed.
172async function decide($: EngineInterface, e: PromptSubmitInput, options: Options, mode: SharppromptMode): Promise<SharppromptDecision> {
173  const off = mode === 'off' || (await $.state.get(isOff)).value === true
174  const g = gate({
175    text: e.text,
176    origin: e.origin,
177    minChars: typeof options.minChars === 'number' ? options.minChars : 40,
178    isOff: off,
179  })
180  if (g.pass) return { verdict: 'skip', reason: g.reason, text: g.text }
181
182  if (endsWithQuestion(await lastReply($))) return { verdict: 'skip', reason: 'answer', text: e.text }
183
184  const t0 = await $.clock.now()
185  const verdict = await classify($, e.text, helperModel(options))
186  const classifyMs = (await $.clock.now()) - t0
187  if (verdict !== 'rough') return { verdict, text: e.text, classifyMs }
188  return { verdict, text: e.text, classifyMs, rewrite: await rewrite($, e.text, helperModel(options), await rewriteOptions($, options)) }
189}
190
191// A prompt sent while our suggestion is waiting is the user's answer to it,
192// not a new draft. When they edited it first, the edit is kept as an example
193// of how they like their prompts.
194async function settlePending($: EngineInterface, text: string): Promise<'as-is' | 'edited' | null> {
195  const p = (await $.state.get(pending)).value
196  if (!p || p.kind !== 'filled') return null
197  await $.state.set(pending, null)
198  if (text === p.rewritten) return 'as-is'
199  if (overlap(text, p.rewritten) < EDIT_OVERLAP) return null
200  const list = await exemplarsOf($)
201  await $.store.set('exemplars', [...list, { original: p.rewritten, sent: text }].slice(-20))
202  return 'edited'
203}
204
205async function bump($: EngineInterface, add: Counts) {
206  const v = await $.store.get('counts')
207  const counts: Counts = v && typeof v === 'object' ? { ...(v as Counts) } : {}
208  for (const [k, n] of Object.entries(add)) counts[k] = (counts[k] ?? 0) + n
209  await $.store.set('counts', counts)
210}
211
212async function push<T>($: EngineInterface, key: 'rewrites' | 'turns' | 'classified', item: T) {
213  const v = await $.store.get(key)
214  const list = Array.isArray(v) ? (v as T[]) : []
215  await $.store.set(key, [...list, item].slice(-MAX_RECORDS))
216}
217
218async function readList<T>($: EngineInterface, key: 'rewrites' | 'turns' | 'classified'): Promise<T[]> {
219  const v = await $.store.get(key)
220  return Array.isArray(v) ? (v as T[]) : []
221}
222
223async function record($: EngineInterface, d: SharppromptDecision) {
224  await bump($, { [countKey(d)]: 1 })
225  if ('classifyMs' in d && d.classifyMs !== undefined) {
226    const item: ClassifyRecord = { verdict: d.verdict, ms: d.classifyMs, words: wordCount(d.text) }
227    await push($, 'classified', item)
228  }
229  if (!('rewrite' in d) || !d.rewrite) return
230  const r = d.rewrite
231  const item: RewriteRecord = {
232    outcome: r.outcome,
233    via: r.via,
234    ms: r.ms,
235    classifyMs: d.classifyMs,
236    usage: tokens(r.usage),
237    words: r.text ? [wordCount(d.text), wordCount(r.text)] : [wordCount(d.text)],
238    model: await $.session.model(),
239  }
240  await push($, 'rewrites', item)
241}
242
243async function deliver($: EngineInterface, e: PromptSubmitInput, rewritten: string, mode: SharppromptMode, next: (e: PromptSubmitInput) => Promise<PromptSubmitResult>): Promise<PromptSubmitResult> {
244  // VS Code and headless sessions draw no box and no band: there the rewrite
245  // can only ride along as context.
246  const surface = await $.session.surface()
247  const drawn = surface === 'terminal' || surface === 'desktop'
248  const how = mode === 'fill' && !drawn ? 'context' : mode
249
250  if (how === 'context') {
251    outgoing = 'context'
252    return next({ ...e, context: [...(e.context ?? []), contextNote(rewritten)] })
253  }
254
255  if (how === 'replace') {
256    await $.store.set('lastOriginal', e.text)
257    await $.state.set(pending, { kind: 'replaced', original: e.text, rewritten })
258    outgoing = 'rewritten'
259    return next({ ...e, text: rewritten })
260  }
261
262  const filled = await $.prompt.fill({ text: rewritten, mode: 'replace' })
263  if (!filled.isFilled) {
264    outgoing = 'typed'
265    return next(e)
266  }
267  await $.store.set('lastOriginal', e.text)
268  await $.state.set(pending, { kind: 'filled', original: e.text, rewritten })
269  // Since Claude Code 2.1.295 a dropped prompt is put back in the box after
270  // our fill, so the box reads rewrite + original. The engine does that before
271  // the next timer tick, so one tick later we set the rewrite again. On 2.1.293
272  // the box already holds the rewrite alone and nothing happens.
273  $.clock.after(0, async () => {
274    const p = (await $.state.get(pending)).value
275    if (!p || p.kind !== 'filled' || p.rewritten !== rewritten) return
276    if ((await $.prompt.read()).text !== rewritten) await $.prompt.fill({ text: rewritten, mode: 'replace' })
277  })
278  return { drop: DROP_NOTE }
279}
280
281// Puts the user's own text back in the box. Sent as it stands, it goes out
282// untouched (prompt.submit checks it against pending); edited, it is a new
283// prompt. We never send it for them: a prompt a plugin submits shows in the
284// transcript under the plugin's name and skips @file expansion. pending is
285// written before the fill so an Enter right after it already sees it.
286async function restoreOriginal($: EngineInterface, p: SharppromptPending): Promise<boolean> {
287  await $.state.set(pending, { ...p, kind: 'restored' })
288  await bump($, { 'answer:original': 1 })
289  const r = await $.prompt.fill({ text: p.original, mode: 'replace' })
290  if (!r.isFilled) await $.state.set(pending, null)
291  return r.isFilled
292}
293
294// True when this is the user's own text we put back, sent unchanged.
295async function isRestored($: EngineInterface, text: string): Promise<boolean> {
296  const p = (await $.state.get(pending)).value
297  if (!p || p.kind !== 'restored') return false
298  await $.state.set(pending, null)
299  return text === p.original
300}
301
302async function runCommand($: EngineInterface, args: string, options: Options): Promise<string> {
303  const c = parseCommand(args)
304  switch (c.kind) {
305    case 'status': {
306      const mode = await modeOf($, options)
307      const off = (await $.state.get(isOff)).value === true
308      const last = (await readList<RewriteRecord>($, 'rewrites')).at(-1)
309      return `${off ? 'Off' : 'On'} for this session, mode ${mode}.\nLast prompt: ${describe((await $.state.get(lastDecision)).value ?? null)}\n${lastRewriteLine(last)}`
310    }
311    case 'on':
312      await $.state.set(isOff, false)
313      return 'On for this session.'
314    case 'off':
315      await $.state.set(isOff, true)
316      return 'Off for this session. Prompts go out as typed.'
317    case 'mode':
318      await $.state.set(modeOverride, c.mode)
319      return `Mode is ${c.mode} for this session.`
320    case 'undo': {
321      const original = await $.store.get('lastOriginal')
322      if (typeof original !== 'string') return 'Nothing to undo.'
323      const ok = await restoreOriginal($, { kind: 'restored', original, rewritten: '' })
324      return ok ? 'Your original is back in the prompt box; sent as it stands, it goes out untouched.' : `Could not reach the prompt box. Your original was:\n${original}`
325    }
326    case 'stats': {
327      const v = await $.store.get('counts')
328      const counts = v && typeof v === 'object' ? (v as Counts) : {}
329      return summary(counts, await readList<RewriteRecord>($, 'rewrites'), await readList<TurnRecord>($, 'turns'), await readList<ClassifyRecord>($, 'classified'))
330    }
331    case 'try': {
332      const verdict = await classify($, c.text, helperModel(options))
333      const r = await rewrite($, c.text, helperModel(options), await rewriteOptions($, options))
334      const asks = r.questions?.length ? `\n\nWould ask:\n${r.questions.map(q => `- ${q.question} ${q.options.map(o => o.label).join(' / ')}`).join('\n')}` : ''
335      return `classify: ${verdict}\nrewrite: ${r.outcome} via ${r.via} in ${r.ms} ms${r.text ? `\n\n${r.text}` : ''}${asks}`
336    }
337    case 'help':
338      return `${c.error ? `${c.error}\n` : ''}${HELP}`
339  }
340}
341
342// Whatever happens in here, the prompt goes out. A thrown error lands in
343// .catch, which sends it untouched.
344export const register: Register = (on, options) => {
345  on('session.start', async ($, e, next) => {
346    await $.command.register({ name: 'sharpprompt', description: 'Prompt rewriting: status, on, off, mode, undo, stats, try', argumentHint: '[status|on|off|mode|undo|stats|try]' })
347    await $.command.register({ name: 'sharp', description: 'Short for /sharpprompt', argumentHint: '[status|on|off|mode|undo|stats|try]' })
348    return next(e)
349  })
350
351  on('command.run', { command: 'sharpprompt' }, async ($, e) => ({ text: await runCommand($, e.args, options) }))
352  on('command.run', { command: 'sharp' }, async ($, e) => ({ text: await runCommand($, e.args, options) }))
353
354  on('prompt.submit', async ($, e, next) => {
355    const typedByUser = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
356    outgoing = null
357    toolCalls = 0
358    if (typedByUser) {
359      if (await isRestored($, e.text)) {
360        const d: SharppromptDecision = { verdict: 'skip', reason: 'back-to-mine', text: e.text }
361        await $.state.set(lastDecision, d)
362        await record($, d)
363        outgoing = 'typed'
364        return next(e)
365      }
366      const answered = await settlePending($, e.text)
367      if (answered) {
368        await $.state.set(lastDecision, { verdict: 'skip', reason: 'suggested', text: e.text })
369        await bump($, { [`answer:${answered}`]: 1 })
370        outgoing = answered === 'as-is' ? 'rewritten' : 'edited'
371        return next(e)
372      }
373    }
374    const mode = await modeOf($, options)
375    const d = await decide($, e, options, mode)
376    await $.state.set(lastDecision, d)
377    if (typedByUser) await record($, d)
378    let rewritten = 'rewrite' in d ? d.rewrite?.text : undefined
379    const questions = ('rewrite' in d ? d.rewrite?.questions : undefined) ?? []
380    if (rewritten && questions.length) {
381      const surface = await $.session.surface()
382      if (surface === 'terminal' || surface === 'desktop') {
383        const answers = await askUser($, questions)
384        if (!answers) {
385          await bump($, { 'ask:dismissed': 1 })
386          if (typedByUser) outgoing = 'typed'
387          return next(e)
388        }
389        await bump($, { 'ask:answered': 1 })
390        rewritten = applyAnswers(rewritten, questions, answers)
391      } else {
392        rewritten = recommended(rewritten, questions)
393      }
394    }
395    if (rewritten) return deliver($, e, rewritten, mode, next)
396    if (typedByUser) outgoing = 'typed'
397    return next(d.text !== e.text ? { ...e, text: d.text } : e)
398  }).catch(($, e, next) => next(e))
399
400  on('tool.call', ($, e, next) => {
401    if (!e.agentId) toolCalls++
402    return next(e)
403  }).catch(($, e, next) => next(e))
404
405  // One record per turn a typed prompt started, for /sharp stats. Subagent
406  // turns and turns nobody typed are left out.
407  on('turn.complete', async ($, e, next) => {
408    if (!e.agentId && outgoing) {
409      const turn: TurnRecord = {
410        prompt: outgoing,
411        durationMs: e.durationMs,
412        tools: toolCalls,
413        asked: endsWithQuestion(e.answer),
414        usage: tokens(e.usage),
415        aborted: e.isAborted,
416      }
417      outgoing = null
418      await push($, 'turns', turn)
419    }
420    if (late.forks > 0) {
421      const add = { 'late:forks': late.forks, 'late:input': late.usage.input_tokens + late.usage.cache_read_input_tokens + late.usage.cache_creation_input_tokens, 'late:output': late.usage.output_tokens }
422      late.forks = 0
423      late.usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }
424      await bump($, add)
425    }
426    return next(e)
427  })
428
429  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
430    const p = (await $.state.get(pending)).value
431    if (!p || p.kind === 'restored' || e.props.hasSurvey) return next(e)
432    const { Box, Button, Text } = $.ui.resolve(e)
433    const was = p.original.length > 70 ? `${p.original.slice(0, 70)}...` : p.original
434    if (p.kind === 'replaced') {
435      return (
436        <Box flexDirection="column">
437          <Text dimColor>sharpprompt sent a rewrite of: {was}</Text>
438          <Box>
439            <Button key="undo" hotkey="u" label="put my original in the box" onPress={() => restoreOriginal($, p)} />
440            <Button key="dismiss" hotkey="x" label="ok" role="dismiss" onPress={() => $.state.set(pending, null)} />
441          </Box>
442        </Box>
443      )
444    }
445    return (
446      <Box flexDirection="column">
447        <Text dimColor>sharpprompt rewrote: {was}</Text>
448        <Box>
449          <Text dimColor>Enter sends it, or edit it in the box. </Text>
450          <Button key="raw" hotkey="r" label="back to mine" onPress={() => restoreOriginal($, p)} />
451        </Box>
452      </Box>
453    )
454  })
455}
456
hooks/flow.ts 77 lines
1import type { SharppromptDecision, SharppromptMode } from '../types'
2
3// Pure helpers for delivery and the slash command.
4
5export const MODES: readonly SharppromptMode[] = ['fill', 'replace', 'context', 'off']
6
7const bag = (s: string) => new Set(s.toLowerCase().split(/\W+/).filter(Boolean))
8
9// Word overlap of two texts, 0 to 1. Above EDIT_OVERLAP a prompt sent while
10// our suggestion sits in the box counts as the suggestion, edited.
11export function overlap(a: string, b: string): number {
12  const x = bag(a)
13  const y = bag(b)
14  if (x.size === 0 && y.size === 0) return 1
15  let both = 0
16  for (const w of x) if (y.has(w)) both++
17  return both / (x.size + y.size - both)
18}
19
20export const EDIT_OVERLAP = 0.4
21
22export function contextNote(rewritten: string): string {
23  return `The sharpprompt plugin read the user's request above as the following. Use it only where it matches what the user wrote:\n${rewritten}`
24}
25
26export const DROP_NOTE = 'sharpprompt put a clearer version in the box. Enter sends it, ctrl+x tab then r puts yours back.'
27
28export type Command =
29  | { kind: 'status' }
30  | { kind: 'on' }
31  | { kind: 'off' }
32  | { kind: 'mode'; mode: SharppromptMode }
33  | { kind: 'undo' }
34  | { kind: 'stats' }
35  | { kind: 'try'; text: string }
36  | { kind: 'help'; error?: string }
37
38export function parseCommand(args: string): Command {
39  const [word = '', ...rest] = args.trim().split(/\s+/)
40  const tail = args.trim().slice(word.length).trim()
41  switch (word.toLowerCase()) {
42    case '':
43    case 'status':
44      return { kind: 'status' }
45    case 'on':
46      return { kind: 'on' }
47    case 'off':
48      return { kind: 'off' }
49    case 'undo':
50      return { kind: 'undo' }
51    case 'stats':
52      return { kind: 'stats' }
53    case 'try':
54      return tail ? { kind: 'try', text: tail } : { kind: 'help', error: 'try needs a prompt to try' }
55    case 'mode': {
56      const m = rest[0] as SharppromptMode | undefined
57      return m && MODES.includes(m) ? { kind: 'mode', mode: m } : { kind: 'help', error: `mode is one of ${MODES.join(', ')}` }
58    }
59    default:
60      return { kind: 'help', error: `unknown: ${word}` }
61  }
62}
63
64export const HELP = `/sharpprompt (or /sharp) status | on | off | mode fill|replace|context|off | undo | stats | try <prompt>
65Start a prompt with raw: to send it untouched.`
66
67export function describe(d: SharppromptDecision | null): string {
68  if (!d) return 'no prompt seen yet'
69  if (d.verdict === 'skip') return `passed untouched (${d.reason})`
70  const words = (t: string) => t.split(/\s+/).filter(Boolean).length
71  const classify = d.classifyMs === undefined ? '' : ` in ${d.classifyMs} ms`
72  if (!('rewrite' in d) || !d.rewrite) return `classified ${d.verdict}${classify}, sent as typed`
73  const r = d.rewrite
74  const size = r.text ? `, ${words(d.text)} -> ${words(r.text)} words` : `, ${words(d.text)} words`
75  return `classified rough${classify}, rewrite ${r.outcome} via ${r.via} in ${r.ms} ms${size}`
76}
77
hooks/facts.ts 46 lines
1import type { SessionMessage } from 'claude-code'
2
3// What this session has touched, read from the transcript alone (no disk):
4// files read or changed, the last command that failed, and the gist of
5// Claude's last reply. Kept under about 300 tokens.
6
7const FILE_TOOLS: Record<string, 'read' | 'changed'> = { Read: 'read', Edit: 'changed', Write: 'changed', MultiEdit: 'changed', NotebookEdit: 'changed' }
8const MAX_CHARS = 1200
9
10const tail = (s: string, n: number) => (s.length > n ? `...${s.slice(-n)}` : s)
11const head = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}...` : s)
12
13export function sessionFacts(rows: readonly SessionMessage[]): string {
14  const read = new Set<string>()
15  const changed = new Set<string>()
16  let failed: { command: string; output: string } | null = null
17  let lastReply = ''
18
19  for (const row of rows) {
20    if (row.role === 'assistant' && row.text.trim()) lastReply = row.text.trim()
21    for (const use of row.toolUses ?? []) {
22      const kind = FILE_TOOLS[use.tool]
23      const path = typeof use.input?.file_path === 'string' ? use.input.file_path : typeof use.input?.notebook_path === 'string' ? use.input.notebook_path : null
24      if (kind && path) {
25        if (kind === 'changed') changed.add(path)
26        else read.add(path)
27      }
28      if (use.tool === 'Bash' && typeof use.input?.command === 'string') {
29        const out = use.text ?? ''
30        // "0 failed" is a pass; "2 failed", FAIL, a traceback or an error line is not.
31        const bad = use.isError === true || /\b(FAIL|FAILED|Traceback)\b|\w*Error\b|\b[1-9]\d* failed\b/.test(out)
32        failed = bad ? { command: use.input.command, output: out } : null
33      }
34    }
35  }
36  for (const p of changed) read.delete(p)
37
38  const lines: string[] = []
39  if (changed.size) lines.push(`Files changed this session: ${[...changed].slice(-8).join(', ')}`)
40  if (read.size) lines.push(`Files read this session: ${[...read].slice(-8).join(', ')}`)
41  if (failed) lines.push(`Last command failed: ${head(failed.command, 120)}\nIts output ended with:\n${tail(failed.output.trim(), 400)}`)
42  if (lastReply) lines.push(`Claude's last reply began: ${head(lastReply.replace(/\s+/g, ' '), 240)}`)
43  const text = lines.join('\n')
44  return text.length > MAX_CHARS ? head(text, MAX_CHARS) : text
45}
46
hooks/gate.ts 58 lines
1import type { PromptOrigin } from 'claude-code'
2import type { SharppromptSkip } from '../types'
3
4// The cheap part of the decision: no model, no awaits. Anything this lets
5// through still has to be called rough by the classifier before we touch it.
6
7export type GateInput = {
8  text: string
9  origin: PromptOrigin
10  minChars: number
11  isOff: boolean
12}
13
14export type GateDecision =
15  | { pass: true; reason: SharppromptSkip; text: string }
16  | { pass: false }
17
18export const MAX_CHARS = 20_000
19
20const RAW_PREFIX = /^raw:\s?/i
21const HARNESS_TAG = /<(task-notification|system-reminder|command-[a-z-]+|local-command-[a-z-]+)[\s>]/
22
23// Only what a person typed (or sent from their phone) is ours to look at.
24const TYPED: ReadonlySet<PromptOrigin['kind']> = new Set(['composer', 'bridge'])
25
26export function gate(input: GateInput): GateDecision {
27  const { text, origin, minChars, isOff } = input
28  const pass = (reason: SharppromptSkip, out = text): GateDecision => ({ pass: true, reason, text: out })
29
30  if (!TYPED.has(origin.kind)) return pass('not-typed')
31  if (RAW_PREFIX.test(text)) return pass('raw', text.replace(RAW_PREFIX, ''))
32  if (isOff) return pass('off')
33
34  const trimmed = text.trim()
35  if (trimmed.startsWith('/')) return pass('command')
36  if (trimmed.startsWith('#')) return pass('heading')
37  if (trimmed.length < minChars) return pass('too-short')
38  if (trimmed.length > MAX_CHARS) return pass('too-long')
39  if (HARNESS_TAG.test(trimmed)) return pass('harness-tag')
40
41  return { pass: false }
42}
43
44// When Claude's last reply ended on a question, the next prompt is almost
45// always the answer to it, and rewriting an answer only gets in the way.
46// A code block at the very end ("Want me to run it?" then the command) is
47// skipped, so the question before it counts and a "?" inside it does not.
48export function endsWithQuestion(reply: string): boolean {
49  let text = reply.trimEnd()
50  while (text.endsWith('```')) {
51    const open = text.lastIndexOf('```', text.length - 4)
52    if (open < 0) break
53    text = text.slice(0, open).trimEnd()
54  }
55  const tail = text.replace(/[*_`)\]"'”’»\s]+$/u, '')
56  return tail.endsWith('?') || tail.endsWith('?')
57}
58
hooks/questions.ts 70 lines
1// The questions a rewrite may come back with, and how answers turn into
2// sentences of the prompt. No $ here.
3
4export type QuestionOption = { label: string; adds: string }
5export type Question = { question: string; header: string; options: QuestionOption[] }
6
7// Each question is its own dialog, so two at most.
8export const MAX_QUESTIONS = 2
9
10// The rewriter writes its rewrite, then optionally a QUESTIONS: line with a
11// JSON array. Anything malformed means no questions, never a broken rewrite.
12export function splitReply(reply: string): { body: string; questions: Question[] } {
13  const at = reply.search(/\n\s*QUESTIONS:\s*/)
14  if (at < 0) return { body: reply, questions: [] }
15  const body = reply.slice(0, at)
16  const json = reply.slice(at).replace(/^\s*QUESTIONS:\s*/, '').trim()
17  let raw: unknown
18  try {
19    raw = JSON.parse(json.replace(/^```(?:json)?\s*|\s*```$/g, ''))
20  } catch {
21    return { body, questions: [] }
22  }
23  if (!Array.isArray(raw)) return { body, questions: [] }
24  const questions: Question[] = []
25  for (const q of raw) {
26    if (questions.length === MAX_QUESTIONS) break
27    if (!q || typeof q.question !== 'string' || !Array.isArray(q.options)) continue
28    const options = q.options
29      .filter((o: unknown): o is QuestionOption => !!o && typeof (o as QuestionOption).label === 'string' && typeof (o as QuestionOption).adds === 'string')
30      .slice(0, 4)
31      .map((o: QuestionOption) => ({ label: o.label.trim().slice(0, 60), adds: o.adds.trim() }))
32    if (options.length < 2) continue
33    const header = typeof q.header === 'string' && q.header.trim() ? q.header.trim().slice(0, 12) : 'Question'
34    questions.push({ question: q.question.trim(), header, options })
35  }
36  return { body, questions }
37}
38
39// The AskUserQuestion input: the first option is the rewriter's
40// recommendation and says so.
41export function askInput(questions: readonly Question[]) {
42  return {
43    questions: questions.map(q => ({
44      question: q.question,
45      header: q.header,
46      multiSelect: false,
47      options: q.options.map((o, i) => ({ label: i === 0 ? `${o.label} (recommended)` : o.label, description: o.adds })),
48    })),
49  }
50}
51
52// Answers keyed by question text, as AskUserQuestion returns them. A picked
53// option adds its sentence; anything else typed under "Other" is added as
54// the user wrote it. An unanswered question adds the recommended option.
55export function applyAnswers(rewrite: string, questions: readonly Question[], answers: Readonly<Record<string, unknown>>): string {
56  const lines = questions.map(q => {
57    const a = typeof answers[q.question] === 'string' ? (answers[q.question] as string).trim() : ''
58    const picked = q.options.find((o, i) => a === o.label || a === `${o.label} (recommended)` || (i === 0 && a === ''))
59    if (picked) return picked.adds
60    return `${q.header}: ${a}.`
61  })
62  return [rewrite.trim(), ...lines].join(' ')
63}
64
65// With no one to ask (VS Code, headless), each question takes its
66// recommended option.
67export function recommended(rewrite: string, questions: readonly Question[]): string {
68  return applyAnswers(rewrite, questions, {})
69}
70
hooks/stats.ts 100 lines
1import type { SharppromptDecision } from '../types'
2
3// What we keep per turn and how /sharp stats sums it up. Everything stays in
4// $.store on the user's machine.
5
6export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
7
8export type TurnRecord = {
9  // How the prompt that started the turn got there.
10  prompt: 'typed' | 'rewritten' | 'edited' | 'context'
11  durationMs: number
12  tools: number
13  // Claude's final text ended on a question: it needed more from the user.
14  asked: boolean
15  usage?: Tokens
16  aborted: boolean
17}
18
19export type RewriteRecord = {
20  outcome: string
21  via: 'fork' | 'complete'
22  ms: number
23  classifyMs?: number
24  usage?: Tokens
25  // Words in the draft and in the rewrite, when there was one.
26  words?: [number, number?]
27  // The session model the fork ran on.
28  model?: string
29}
30
31// Every classified prompt, so short-but-rough patterns show once n grows.
32export type ClassifyRecord = { verdict: string; ms: number; words: number }
33
34export type Counts = Record<string, number>
35
36export const MAX_RECORDS = 500
37
38export const wordCount = (t: string) => t.split(/\s+/).filter(Boolean).length
39
40export function lastRewriteLine(r: RewriteRecord | undefined): string {
41  if (!r) return 'Last rewrite: none yet'
42  const w = r.words ? `, ${r.words[0]}${r.words[1] === undefined ? '' : ` -> ${r.words[1]}`} words` : ''
43  const c = r.classifyMs === undefined ? '' : `classify ${r.classifyMs} ms, `
44  return `Last rewrite: ${r.outcome}, ${c}${r.via} ${r.ms} ms${w}`
45}
46
47export function tokens(u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } | undefined): Tokens | undefined {
48  if (!u) return undefined
49  return { input: u.input_tokens, output: u.output_tokens, cacheRead: u.cache_read_input_tokens, cacheWrite: u.cache_creation_input_tokens }
50}
51
52// The counter a decision bumps: why it passed, or what the classifier said.
53export function countKey(d: SharppromptDecision): string {
54  if (d.verdict === 'skip') return `skip:${d.reason}`
55  return `verdict:${d.verdict}`
56}
57
58export function pct(values: readonly number[], p: number): number | undefined {
59  if (values.length === 0) return undefined
60  const sorted = [...values].sort((a, b) => a - b)
61  return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]
62}
63
64const avg = (xs: readonly number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : undefined)
65const fmt = (x: number | undefined, unit = '') => (x === undefined ? '-' : `${Math.round(x)}${unit}`)
66const spread = (xs: readonly number[]) => (xs.length ? `${fmt(pct(xs, 50))} / ${fmt(pct(xs, 95))} ms (n=${xs.length})` : '-')
67const share = (n: number, of: number) => (of ? `${Math.round((100 * n) / of)}%` : '-')
68
69function turnLine(label: string, turns: readonly TurnRecord[]): string {
70  const done = turns.filter(t => !t.aborted)
71  return `${label}: n=${turns.length}, tools ${fmt(avg(done.map(t => t.tools)))}, ${fmt(avg(done.map(t => t.durationMs / 1000)), ' s')}, Claude asked back ${share(done.filter(t => t.asked).length, done.length)}, output ${fmt(avg(done.map(t => t.usage?.output ?? 0)), ' tok')}`
72}
73
74export function summary(counts: Counts, rewrites: readonly RewriteRecord[], turns: readonly TurnRecord[], classified: readonly ClassifyRecord[] = []): string {
75  const c = (k: string) => counts[k] ?? 0
76  const skips = Object.entries(counts)
77    .filter(([k]) => k.startsWith('skip:'))
78    .sort((a, b) => b[1] - a[1])
79    .map(([k, v]) => `${k.slice(5)} ${v}`)
80  const seen = Object.entries(counts).filter(([k]) => k.startsWith('skip:') || k.startsWith('verdict:')).reduce((a, [, v]) => a + v, 0)
81  const ok = rewrites.filter(r => r.outcome === 'rewritten')
82  const forks = rewrites.filter(r => r.via === 'fork' && r.outcome !== 'timeout')
83  const rough = c('verdict:rough')
84  const spent = rewrites.map(r => r.usage).filter((u): u is Tokens => !!u)
85
86  return [
87    `Prompts seen: ${seen}. Passed by the gate: ${skips.join(', ') || 'none'}.`,
88    `Classified: clear ${c('verdict:clear')}, rough ${rough}, timeout ${c('verdict:timeout')}, error ${c('verdict:error')}.`,
89    `Rewrites: ${ok.length} made, ${rewrites.length - ok.length} not (${['keep', 'same', 'too-long', 'empty', 'timeout', 'error'].map(o => `${o} ${rewrites.filter(r => r.outcome === o).length}`).join(', ')}).`,
90    `What you did with them: sent as is ${c('answer:as-is')}, edited ${c('answer:edited')}, took yours back ${c('answer:original')}.`,
91    `Wait (p50 / p95): classify ${spread(classified.map(x => x.ms))}, fork ${spread(forks.map(r => r.ms))}, fallback completion ${spread(rewrites.filter(r => r.via === 'complete').map(r => r.ms))}.`,
92    `Cost per rough prompt: ${fmt(avg(spent.map(u => u.input)))} in, ${fmt(avg(spent.map(u => u.cacheRead)))} from cache, ${fmt(avg(spent.map(u => u.output)))} out. Forks that lost the race and finished anyway: ${c('late:forks')}, ${c('late:output')} output tokens, ${c('late:input')} input.`,
93    turnLine('Turns after a typed prompt', turns.filter(t => t.prompt === 'typed')),
94    turnLine('Turns after a rewrite', turns.filter(t => t.prompt !== 'typed')),
95    turns.length < 30 ? 'Too few turns to compare yet; these numbers are not evidence.' : '',
96  ]
97    .filter(Boolean)
98    .join('\n')
99}
100
hooks/rewrite.ts 135 lines
1import { RULES, SHAPES, type Family } from './bank'
2
3// Everything about the rewrite that needs no $: which rules apply, the text
4// we send the model, and what we accept back.
5
6export const MAX_WORDS = 180
7export const MIN_ROOM = 60
8export const KEEP = 'KEEP'
9
10export type Exemplar = { original: string; sent: string }
11
12// 'same' keeps the user's language; 'en' asks for English whatever the
13// draft is written in (the rewriteLanguage setting).
14export type RewriteOptions = {
15  language?: 'same' | 'en'
16  // May the rewriter ask about gaps before the prompt goes out?
17  ask?: boolean
18  // What the session touched, from the transcript (see facts.ts).
19  facts?: string
20  // How many of the last messages the fallback completion sees (default 4).
21  // The benchmark passes them all, standing in for the fork it cannot use.
22  window?: number
23}
24
25const ASK = `If the draft leaves out something that the conversation does not answer and that would change the work (which file or function, which of two behaviours, a format or limit), you may ask about it, at most 2 questions. Do not ask what you can reasonably infer, and never ask whether to add tests (the answer is no). If you ask, leave those points out of the rewrite, because the answers will be added after it. Then, after the rewrite, write a line QUESTIONS: followed by a JSON array, each item {"question": "...?", "header": "<12 characters", "options": [{"label": "<1-5 words>", "adds": "<the sentence to add to the prompt if picked>"}]}, 2 to 4 options each, your recommended option first. If nothing needs asking, write no QUESTIONS line.`
26
27const ENGLISH = `Write the rewrite in English, whatever language the draft is in; this overrides the rules about the user's language. Keep file names, function names, commands and quoted text exactly as written. If the draft is not in English, do not reply ${'KEEP'}: translate it while you make it clear.`
28
29export function familyOf(model: string): Family {
30  const m = model.toLowerCase()
31  for (const f of ['fable', 'opus', 'sonnet', 'haiku'] as const) {
32    if (m.includes(f)) return f
33  }
34  return 'common'
35}
36
37const words = (s: string) => s.split(/\s+/).filter(Boolean).length
38
39const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}...` : s)
40
41function rulesFor(family: Family): string {
42  const list = family === 'common' ? RULES.common : [...RULES.common, ...RULES[family]]
43  return list.map(r => `- ${r.text}`).join('\n')
44}
45
46function shapes(): string {
47  return SHAPES.map(s =>
48    [
49      `${s.name}: ${s.summary} A good one carries: ${s.fields}`,
50      `  Before: ${s.before}`,
51      `  After: ${s.after}`,
52    ].join('\n'),
53  ).join('\n')
54}
55
56// The user's own edits of earlier suggestions: what they changed our
57// rewrite into is the best signal of how they like their prompts.
58function exemplars(list: readonly Exemplar[]): string {
59  if (list.length === 0) return ''
60  const items = list
61    .slice(-20)
62    .map(x => `<example>\nSuggested: ${clip(x.original, 400)}\nUser sent instead: ${clip(x.sent, 400)}\n</example>`)
63    .join('\n')
64  return `\nWhen this user edited a suggestion before sending, this is what they changed it to. Match their taste:\n<examples>\n${items}\n</examples>\n`
65}
66
67// How long a rewrite of this draft may be: twice the draft or 60 words,
68// whichever is more, never past 180. The model gets this number, not 180:
69// told 180, it wrote 77 and 125 words for 16 and 14 word drafts.
70export function wordLimit(draft: string): number {
71  return Math.min(Math.max(2 * words(draft), MIN_ROOM), MAX_WORDS)
72}
73
74function instructions(family: Family, list: readonly Exemplar[], limit: number, opts: RewriteOptions): string {
75  return `Rules:
76${rulesFor(family)}
77${opts.language === 'en' ? `\n${ENGLISH}\n` : ''}${opts.ask ? `\n${ASK}\n` : ''}
78Task shapes, to see what a good prompt of each kind carries. Pick the closest one, or none:
79${shapes()}
80${exemplars(list)}
81If the draft is already clear enough to act on, reply with exactly ${KEEP}.
82Give the rewritten prompt and nothing else, at most ${limit} words.`
83}
84
85// For $.model.fork: the model sees the whole conversation before this, so
86// "the file above" can be resolved without us quoting anything.
87export function forkPrompt(draft: string, family: Family, list: readonly Exemplar[] = [], opts: RewriteOptions = {}): string {
88  return `This message is from the sharpprompt plugin, not the user, and is not a task to carry out. The user has typed the draft below as their next message to you and has not sent it yet. Rewrite it so it is clear for you to act on in this conversation, keeping their intent and voice.
89${factsBlock(opts)}
90<draft>
91${draft}
92</draft>
93
94${instructions(family, list, wordLimit(draft), opts)}`
95}
96
97export type Recent = { role: 'user' | 'assistant'; text: string }
98
99function factsBlock(opts: RewriteOptions): string {
100  return opts.facts ? `\nWhat this session has touched, from its transcript:\n<session_facts>\n${opts.facts}\n</session_facts>\n` : ''
101}
102
103// For $.model.complete when there is nothing to fork yet: no history, so we
104// hand over the last few messages ourselves, cut short.
105export function completePrompt(draft: string, family: Family, recent: readonly Recent[], list: readonly Exemplar[] = [], opts: RewriteOptions = {}): string {
106  const convo = recent
107    .slice(-(opts.window ?? 4))
108    .map(m => `<${m.role}>${clip(m.text, 600)}</${m.role}>`)
109    .join('\n')
110  return `A user of Claude Code has typed the draft below as their next message and has not sent it yet. Rewrite it so it is clear for Claude to act on, keeping their intent and voice.
111${convo ? `\nThe last messages of the conversation, cut short:\n<conversation>\n${convo}\n</conversation>\n` : ''}${factsBlock(opts)}
112<draft>
113${draft}
114</draft>
115
116${instructions(family, list, wordLimit(draft), opts)}`
117}
118
119export type Cleaned = { rewritten: string } | { rejected: 'keep' | 'empty' | 'too-long' | 'same' }
120
121// What the model sent back, made safe to put in the prompt box: fences and
122// wrapping quotes off, and turned down when it is no rewrite at all.
123export function clean(reply: string, draft: string): Cleaned {
124  let t = reply.trim()
125  t = t.replace(/^```[a-z]*\n([\s\S]*?)\n```$/i, '$1').trim()
126  t = t.replace(/^<draft>\s*([\s\S]*?)\s*<\/draft>$/i, '$1').trim()
127  if (/^["“].*["”]$/s.test(t)) t = t.slice(1, -1).trim()
128  if (t === '') return { rejected: 'empty' }
129  if (t === KEEP || t.replace(/[.!]$/, '') === KEEP) return { rejected: 'keep' }
130  // A little slack so a near miss still reaches the user.
131  if (words(t) > wordLimit(draft) + 15) return { rejected: 'too-long' }
132  if (t === draft.trim()) return { rejected: 'same' }
133  return { rewritten: t }
134}
135
hooks/bank.ts 171 lines
1// Generated by scripts/build-bank.mjs from docs/rules and docs/shapes.
2// Edit those files, not this one.
3
4export type Family = 'common' | 'fable' | 'opus' | 'sonnet' | 'haiku'
5export type Rule = { id: string; text: string }
6export type Shape = { name: string; summary: string; fields: string; context: string; before: string; after: string }
7
8export const RULES: Record<Family, readonly Rule[]> = {
9  "common": [
10    {
11      "id": "keep-intent",
12      "text": "Keep what the user asked for. Do not add tasks, change the goal, or answer the prompt yourself. Resolve references to the conversation (\"the second item above\", \"that file\", \"same as before\") into the concrete names they point to, and invent nothing the conversation does not show: no file names, numbers, deadlines or requirements. Keep the user's own uncertainty (\"I guess\", \"maybe\") where they wrote it, and never add guesses of your own."
13    },
14    {
15      "id": "give-the-reason",
16      "text": "When the conversation or the prompt shows why the user wants this or who it is for, say it in one plain sentence. If the reason is not knowable, leave it out rather than guess."
17    },
18    {
19      "id": "name-the-deliverable",
20      "text": "Make the output explicit: what should exist or be answered when Claude is done, and how the user will tell it is done (tests pass, a file exists, a question answered). Use a check that already exists (the tests, a command) as the done signal; do not add work the user did not ask for, such as new tests. Use a short numbered list only when the order of steps matters."
21    },
22    {
23      "id": "question-stays-a-question",
24      "text": "If the user is asking a question, describing a problem or thinking out loud, the rewrite asks for an assessment and says not to change anything yet. Never turn a question into an order to fix."
25    },
26    {
27      "id": "state-what-is-out",
28      "text": "When the prompt or conversation makes clear what must not be touched, say it in one sentence (files, behaviour, scope). Do not invent restrictions."
29    },
30    {
31      "id": "ideas-mean-ideas",
32      "text": "If the user asks for ideas, options or a plan, the rewrite asks for that and says to wait before building anything."
33    },
34    {
35      "id": "no-reasoning-echo",
36      "text": "Never ask Claude to show, write out or explain its thinking or reasoning, and never add \"think step by step\". Recent models may refuse such requests (the reasoning_extraction category). Asking for a short explanation of the answer is fine."
37    },
38    {
39      "id": "calm-language",
40      "text": "No capital-letter emphasis, no \"CRITICAL\" or \"MUST\", no threats or bribes, no urgency. Current models follow plain instructions and over-apply shouted ones."
41    },
42    {
43      "id": "no-role-filler",
44      "text": "Do not open with a role (\"You are an expert ...\"). The prompt is a user turn inside Claude Code, which already has its system prompt; a role line there only adds length."
45    },
46    {
47      "id": "stay-short",
48      "text": "The rewrite is at most twice the length of the original or 60 words, whichever is more, and never more than 180 words. Write it in the user's language and voice (or in English when the rewriteLanguage setting says en), first person, as if they had typed it carefully. No headings, no XML tags, no preamble."
49    },
50    {
51      "id": "latest-reference",
52      "text": "When a reference in the draft (\"that function\", \"the file\", \"the test\") fits more than one thing in the conversation, take the most recent one and name it, with its file when there is one."
53    }
54  ],
55  "fable": [
56    {
57      "id": "fable-brief-is-enough",
58      "text": "Prefer one short instruction over a list of cases. Fable follows brief instructions well and over-prescriptive prompts can make its output worse."
59    },
60    {
61      "id": "fable-ask-for-updates",
62      "text": "If the task is long and the user wants to follow along, ask for a line before starting and a short recap at the end. Fable 5.1 writes few updates between tool calls unless asked. Do not ask it to keep updates brief."
63    },
64    {
65      "id": "fable-ambiguity",
66      "text": "Where the request can be read two ways and the conversation does not settle it, ask Claude to take the most direct reading and state that assumption, rather than build for both."
67    },
68    {
69      "id": "fable-bug-phrasing",
70      "text": "Turn \"does this compile?\" into \"are there any bugs in this?\". The compile-check phrasing trips safety classifiers more often."
71    }
72  ],
73  "opus": [
74    {
75      "id": "opus-no-verify-step",
76      "text": "Do not add \"verify your work\" or \"add a final verification step\". Opus 5 verifies on its own and extra verification instructions waste tokens."
77    },
78    {
79      "id": "opus-hold-scope",
80      "text": "For a narrow task, say plainly to deliver what was asked at that scope and to mention, not do, anything beyond it. Opus 5 can widen a task on its own."
81    },
82    {
83      "id": "opus-length",
84      "text": "If the user wants a document or reply of a certain size, say so. Opus 5 writes longer replies and documents by default, and effort does not reliably shorten them."
85    }
86  ],
87  "sonnet": [
88    {
89      "id": "sonnet-literal-scope",
90      "text": "Spell out how far an instruction reaches (\"every file under src/, not just this one\"). Sonnet 5 reads prompts literally and does not generalise an instruction from one item to the rest."
91    },
92    {
93      "id": "sonnet-no-extras",
94      "text": "When the user wants only the change itself, say not to add tests, docs or files that were not asked for. Sonnet 5.5 tends to add them."
95    }
96  ],
97  "haiku": [
98    {
99      "id": "haiku-run-a-check",
100      "text": "For a code change that can be run, built or type-checked, ask for a real check (the tests, the type-checker, the build) before calling it done. Haiku 5.5 at low effort sometimes reports a change as done without checking it."
101    }
102  ]
103}
104
105export const SHAPES: readonly Shape[] = [
106  {
107    "name": "fix",
108    "summary": "Something is broken and should work again.",
109    "fields": "what goes wrong and where it shows, what should happen instead, what must stay as it is, how to tell it is fixed.",
110    "context": "the last test run showed test_login_redirect failing after the session refactor in auth/session.py.",
111    "before": "login broken again after your change, fix",
112    "after": "test_login_redirect started failing after the session refactor in auth/session.py. Find out why and fix it so a correct password lands on the dashboard again. Keep the refactor's new structure. Done when test_login_redirect and the rest of the auth tests pass."
113  },
114  {
115    "name": "investigate",
116    "summary": "Find out why something happens, without changing it yet.",
117    "fields": "the observation, where to look, what a useful answer looks like, a clear note that nothing should change yet.",
118    "context": "the user pasted a chart showing nightly job runtime going from 4 to 19 minutes on 2 October.",
119    "before": "why is the nightly thing so slow now",
120    "after": "The nightly job went from about 4 to 19 minutes on 2 October. Find out what changed around then that explains it and tell me the likely cause with the evidence. Don't change any code yet; I want the diagnosis first."
121  },
122  {
123    "name": "build",
124    "summary": "Add something new.",
125    "fields": "what it does for whom, where it lives, inputs and outputs, what is out of scope, how to tell it works.",
126    "context": "the project is a FastAPI service; the user has been discussing exporting reports.",
127    "before": "add csv export for reports",
128    "after": "Add a CSV export for reports so analysts can open them in a spreadsheet: a GET endpoint next to the existing report routes that returns the same rows as the JSON report, one column per field. No new dependencies. Done when the new endpoint returns the same rows as the JSON report for an existing report and the current tests still pass."
129  },
130  {
131    "name": "refactor",
132    "summary": "Change how code is organised without changing what it does.",
133    "fields": "the code in question, the target structure, the reason, the behaviour that must not change, how to check that.",
134    "context": "the conversation showed billing.py at 1,400 lines with tax, invoice and discount logic mixed together.",
135    "before": "split up billing its a mess",
136    "after": "Split billing.py (about 1,400 lines) into separate modules for tax, invoices and discounts, so each can be changed without reading the others. Behaviour must stay exactly the same: no renamed public functions, no logic changes. Done when the existing billing tests pass unchanged."
137  },
138  {
139    "name": "research",
140    "summary": "Learn something from outside the codebase.",
141    "fields": "the question, why it matters here, what sources count, the form of the answer.",
142    "context": "the team is choosing a queue for a Python service and already runs Redis.",
143    "before": "celery vs rq which one",
144    "after": "We need a task queue for our Python service and already run Redis. Compare Celery and RQ for that setup: setup effort, retries and scheduling, monitoring, and how actively each is maintained. Give me a recommendation with the two or three reasons that decide it, and link your sources."
145  },
146  {
147    "name": "review",
148    "summary": "Look over work and report problems.",
149    "fields": "what to review, what kind of problems matter, what to leave alone, the form of the findings.",
150    "context": "the user just finished a branch adding rate limiting to the API gateway.",
151    "before": "review my changes",
152    "after": "Review the rate limiting changes on this branch for bugs: wrong limits, race conditions, requests that slip through, and errors that would reach users. Skip style and naming. List each finding with the file and line and why it is a problem, most serious first. Don't change the code."
153  },
154  {
155    "name": "write",
156    "summary": "Produce prose: docs, a message, a description.",
157    "fields": "who reads it, what they should do or know after, length, tone, what it must include.",
158    "context": "the conversation just merged a migration that renames the users table to accounts.",
159    "before": "write something for the team about the migration",
160    "after": "Write a short Slack message for the backend team about the migration we just merged that renames the users table to accounts. They need to know what breaks in their local setups and the one command to fix it. Five sentences at most, plain tone."
161  },
162  {
163    "name": "ask",
164    "summary": "A question that wants an answer, not a change.",
165    "fields": "the question, the context it is about, how deep the answer should go.",
166    "context": "the conversation has been about a React app using useEffect for data fetching.",
167    "before": "is useeffect fine for this or no",
168    "after": "Is useEffect a reasonable way to fetch the dashboard data in this app, or would a data-fetching library serve better here? Give me a short answer with the main trade-off; don't change anything."
169  }
170]
171
types/index.d.ts 59 lines
1export type SharppromptMode = 'fill' | 'replace' | 'context' | 'off'
2
3// Why the cheap gate let a prompt through untouched. 'suggested' is a prompt
4// sent while our suggestion was in the box: the user's answer to it.
5export type SharppromptSkip =
6  | 'off'
7  | 'not-typed'
8  | 'raw'
9  | 'command'
10  | 'heading'
11  | 'too-short'
12  | 'too-long'
13  | 'harness-tag'
14  | 'answer'
15  | 'suggested'
16  | 'back-to-mine'
17
18// What the classifier said; 'timeout' and 'error' send the prompt as typed.
19export type SharppromptVerdict = 'clear' | 'rough' | 'timeout' | 'error'
20
21// How a rewrite went. Only 'rewritten' carries text; every other outcome
22// sends the prompt as typed.
23export type SharppromptQuestion = { question: string; header: string; options: { label: string; adds: string }[] }
24
25export type SharppromptRewrite = {
26  outcome: 'rewritten' | 'keep' | 'same' | 'empty' | 'too-long' | 'timeout' | 'error'
27  via: 'fork' | 'complete'
28  ms: number
29  text?: string
30  detail?: string
31  // Gaps the rewriter wanted to ask about; their answers are added to text.
32  questions?: SharppromptQuestion[]
33  usage?: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
34}
35
36export type SharppromptDecision =
37  | { verdict: 'skip'; reason: SharppromptSkip; text: string }
38  | { verdict: SharppromptVerdict; text: string; classifyMs?: number; rewrite?: SharppromptRewrite }
39
40// A rewrite the user has not acted on yet: in the box (filled) or already
41// sent (replaced), shown in the band; or the user's own text put back in the
42// box (restored), which goes out untouched if sent as it stands.
43export type SharppromptPending = {
44  kind: 'filled' | 'replaced' | 'restored'
45  original: string
46  rewritten: string
47}
48
49declare module 'claude-code' {
50  interface PluginState {
51    sharpprompt: {
52      pending: SharppromptPending | null
53      isOff: boolean
54      mode: SharppromptMode | null
55      lastDecision: SharppromptDecision | null
56    }
57  }
58}
59