Makes your first prompt complete before the main model sees it: a cheap-model gate, one round of grounded questions (one dialog each), context from your notes…

A Claude Code mod that makes your first prompt complete before the main model sees it: a cheap-model gate, one round of grounded questions (one dialog each), context from your notes folder and project, optional web best practices, the assembled prompt in your prompt box for review, and lessons learned after each task.
The full README with install, usage, settings and the notes-folder fallback is one level up: ../../README.md.
claude plugin marketplace add yahiaaktham/perfect-prompt
claude plugin install perfect-prompt@perfect-prompthooks/register.tsx 712 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Band, Pending } from '../types'
5
6// ---------------------------------------------------------------------------
7// Constants
8// ---------------------------------------------------------------------------
9
10const PLUGIN = 'perfect-prompt'
11/** First line of an assembled prompt in review mode; stripped before it reaches the model. */
12const MARKER = '<!-- perfect-prompt:v1 -->'
13/** Origins that are a person typing. */
14const HUMAN = new Set(['composer', 'bridge'])
15const TRIVIAL = /^(ok|okay|yes|no|y|n|sure|go|go ahead|continue|proceed|thanks|thank you|done|next|stop|wait|hi|hello)[.!]?$/i
16const SECRET = /(sk-[A-Za-z0-9]{10,}|ghp_[A-Za-z0-9]{10,}|AKIA[A-Z0-9]{12,}|-----BEGIN [A-Z ]*PRIVATE KEY|password\s*[:=]\s*\S+)/i
17const MAX_LESSONS = 60
18const MAX_FACT_CHARS = 3000
19const MAX_FACTS = 6
20const MAX_WEB_CHARS = 12000
21const CLASSIFY_TIMEOUT = 20000
22const MODEL_TIMEOUT = 90000
23
24type Lesson = { id: string; text: string; taskType: string; count: number; firstAt: number; lastAt: number }
25type LastEnhanced = {
26 at: number
27 sessionId: string
28 original: string
29 assembled: string
30 verdict: string
31 turns: number
32 reflected: boolean
33}
34type Question = { question: string; header: string; options: { label: string; description?: string }[]; multiSelect: boolean }
35type Draft = { questions: Question[]; assumptions: string[]; searchQueries: string[] }
36type LocalContext = { brief: string; facts: string; project: string }
37
38type Options = {
39 mode: 'review' | 'auto'
40 model: string
41 vaultPath: string
42 rulesFile: string
43 memoryIndex: string
44 webSearch: 'ask' | 'auto' | 'off'
45 maxQuestions: number
46 minWords: number
47}
48
49/** Name of the lessons file the mod appends to inside the notes folder (the store is the primary copy). */
50const LESSONS_FILE = 'perfect-prompt-lessons.md'
51
52const band = atom({ plugin: 'perfect-prompt', key: 'band' } as const, null)
53const isOff = atom({ plugin: 'perfect-prompt', key: 'isOff' } as const, false)
54const isHidden = atom({ plugin: 'perfect-prompt', key: 'isHidden' } as const, false)
55const pending = atom({ plugin: 'perfect-prompt', key: 'pending' } as const, null)
56
57let opts: Options = { mode: 'review', model: 'haiku', vaultPath: '~/.claude', rulesFile: 'CLAUDE.md', memoryIndex: 'MEMORY.md', webSearch: 'ask', maxQuestions: 2, minWords: 12 }
58let homeDir: string | undefined
59let sessionTokens = 0
60
61// ---------------------------------------------------------------------------
62// Small pure helpers (exported for tests)
63// ---------------------------------------------------------------------------
64
65export function wordCount(s: string) {
66 return s.trim().split(/\s+/).filter(Boolean).length
67}
68
69export function isTrivial(s: string) {
70 return TRIVIAL.test(s.trim())
71}
72
73export function looksSecret(s: string) {
74 return SECRET.test(s)
75}
76
77/** Pulls the first JSON object or array out of a model reply, fences and prose around it ignored. */
78export function parseJson<T>(text: string): T | undefined {
79 const cleaned = text.replace(/```(?:json)?/gi, '').trim()
80 const starts = [cleaned.indexOf('{'), cleaned.indexOf('[')].filter(i => i >= 0)
81 if (starts.length === 0) return undefined
82 const start = Math.min(...starts)
83 const endChar = cleaned[start] === '{' ? '}' : ']'
84 const end = cleaned.lastIndexOf(endChar)
85 if (end <= start) return undefined
86 try {
87 return JSON.parse(cleaned.slice(start, end + 1)) as T
88 } catch {
89 return undefined
90 }
91}
92
93function normalize(s: string) {
94 return s.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim()
95}
96
97/** Word-set overlap, 0..1; two lessons above 0.6 are the same lesson. */
98export function similarity(a: string, b: string) {
99 const A = new Set(normalize(a).split(' ').filter(w => w.length > 2))
100 const B = new Set(normalize(b).split(' ').filter(w => w.length > 2))
101 if (A.size === 0 || B.size === 0) return 0
102 let both = 0
103 for (const w of A) if (B.has(w)) both += 1
104 return both / (A.size + B.size - both)
105}
106
107/** Merges a new lesson into the list: a near-duplicate gains a count, a new one is appended, the list is capped. */
108export function mergeLesson(list: Lesson[], text: string, taskType: string, now: number): { list: Lesson[]; isNew: boolean } {
109 const hit = list.find(l => similarity(l.text, text) >= 0.6)
110 if (hit) {
111 hit.count += 1
112 hit.lastAt = now
113 return { list, isNew: false }
114 }
115 const lesson: Lesson = { id: `${now.toString(36)}${Math.random().toString(36).slice(2, 6)}`, text, taskType, count: 1, firstAt: now, lastAt: now }
116 const next = [...list, lesson]
117 if (next.length > MAX_LESSONS) next.sort((x, y) => y.count - x.count || y.lastAt - x.lastAt).splice(MAX_LESSONS)
118 return { list: next, isNew: true }
119}
120
121function clip(s: string, n: number) {
122 return s.length <= n ? s : `${s.slice(0, n)}\n[... ${s.length - n} more characters cut]`
123}
124
125function dateOf(ms: number) {
126 return new Date(ms).toISOString().slice(0, 10)
127}
128
129// ---------------------------------------------------------------------------
130// Engine helpers
131// ---------------------------------------------------------------------------
132
133async function complete($: EngineInterface, system: string, prompt: string, maxTokens: number, timeoutMs = MODEL_TIMEOUT) {
134 const r = await $.model.complete({ model: opts.model, system, prompt, maxTokens, timeoutMs })
135 sessionTokens += r.usage.input_tokens + r.usage.output_tokens + (r.usage.cache_read_input_tokens ?? 0) + (r.usage.cache_creation_input_tokens ?? 0)
136 return r.isAnswered ? r.text : undefined
137}
138
139async function home($: EngineInterface) {
140 if (homeDir) return homeDir
141 try {
142 const r = await $.process.run(['powershell', '-NoProfile', '-Command', 'Write-Output $env:USERPROFILE'], { timeoutMs: 10000 })
143 const out = r.stdout.trim()
144 if (r.exitCode === 0 && out) homeDir = out.replace(/\\/g, '/')
145 } catch {
146 // not Windows, or no PowerShell
147 }
148 if (!homeDir) {
149 try {
150 const r = await $.process.run(['sh', '-c', 'echo $HOME'], { timeoutMs: 10000 })
151 if (r.exitCode === 0 && r.stdout.trim()) homeDir = r.stdout.trim()
152 } catch {
153 // leave undefined
154 }
155 }
156 return homeDir
157}
158
159/** Expands a leading ~ to the home folder and turns backslashes into slashes. */
160async function expand($: EngineInterface, raw: string) {
161 const p = raw.replace(/\\/g, '/').replace(/\/+$/, '')
162 if (!p.startsWith('~')) return p
163 const h = await home($)
164 return h ? `${h}${p.slice(1)}` : p
165}
166
167/** The notes folder from settings (`vaultPath`), `~` expanded. It may not exist; every read of it is optional. */
168async function vaultPath($: EngineInterface) {
169 return expand($, opts.vaultPath)
170}
171
172/** A file setting: absolute paths (or ~) stand alone, anything else is relative to the notes folder. */
173async function resolveIn($: EngineInterface, file: string) {
174 const f = file.replace(/\\/g, '/')
175 if (f.startsWith('~') || f.startsWith('/') || /^[A-Za-z]:\//.test(f)) return expand($, f)
176 return `${await vaultPath($)}/${f}`
177}
178
179async function readIf($: EngineInterface, path: string, max: number) {
180 try {
181 if (!(await $.fs.exists(path))) return ''
182 return clip(await $.fs.read(path), max)
183 } catch {
184 return ''
185 }
186}
187
188async function lessons($: EngineInterface): Promise<Lesson[]> {
189 const v = await $.store.get('lessons')
190 return Array.isArray(v) ? (v as Lesson[]) : []
191}
192
193async function lastEnhanced($: EngineInterface): Promise<LastEnhanced | undefined> {
194 const v = await $.store.get('lastEnhanced')
195 return v && typeof v === 'object' ? (v as LastEnhanced) : undefined
196}
197
198async function setBand($: EngineInterface, verdict: string) {
199 const b: Band = { verdict, tokens: sessionTokens, at: await $.clock.now() }
200 await update($, band, () => b)
201}
202
203// ---------------------------------------------------------------------------
204// Step 1: the gate
205// ---------------------------------------------------------------------------
206
207const GATE_SYSTEM = `You classify one message a user typed into a coding/knowledge-work assistant (Claude Code).
208Answer with exactly one label and nothing else: UNDERSPECIFIED, CLEAR or FOLLOWUP.
209
210Step 1, read the message on its own, as if it were the first thing typed in a fresh session.
211- It asks for a new deliverable or action (make, write, build, set up, organise, find, fix, summarise, plan...) and leaves out details the assistant would have to guess or ask about (what done looks like, scope, which files or data, constraints, how to verify): UNDERSPECIFIED.
212- It asks for a new deliverable or action and gives enough to start: CLEAR.
213- Decide here whenever the message stands on its own. A long session or an earlier topic does not make a new task a follow-up.
214
215Step 2, only when the message cannot stand on its own: an answer ("yes", "the second one", "no, use pnpm"), a correction, a pronoun-led continuation ("do that for the other file too", "same for the rest"), a short question about what was just said. Then look at the previous assistant reply, and if the message answers or continues it: FOLLOWUP.`
216
217async function classifyPrompt($: EngineInterface, text: string): Promise<'UNDERSPECIFIED' | 'CLEAR' | 'FOLLOWUP'> {
218 let previous = ''
219 try {
220 const msgs = await $.session.messages()
221 const last = [...msgs].reverse().find(m => m.role === 'assistant' && m.text.trim() !== '')
222 if (last) previous = clip(last.text, 400)
223 } catch {
224 // no transcript access: judge the message alone
225 }
226 const prompt = `User message (judge this on its own first):\n<message>\n${text}\n</message>\n\nPrevious assistant reply, only to break a tie when the message cannot stand on its own (may be empty):\n<previous>\n${previous}\n</previous>\n\nLabel:`
227 const out = (await complete($, GATE_SYSTEM, prompt, 8, CLASSIFY_TIMEOUT)) ?? ''
228 const label = out.trim().toUpperCase()
229 if (label.startsWith('UNDER')) return 'UNDERSPECIFIED'
230 if (label.startsWith('CLEAR')) return 'CLEAR'
231 return 'FOLLOWUP'
232}
233
234// ---------------------------------------------------------------------------
235// Step 2: local context (notes folder + project)
236// ---------------------------------------------------------------------------
237
238const PICK_SYSTEM = `You pick which memory files are worth reading for a task. You get the task and an index: one line per file, "- [Title](file.md) - summary".
239Return JSON only: {"files": ["file.md", ...]} with at most ${MAX_FACTS} file names that hold facts, preferences, rules or project context relevant to the task. Prefer feedback and project facts over references. Return {"files": []} when nothing fits.`
240
241/**
242 * Reads what the notes folder offers (a rules file and a memory index with its fact files), then the project
243 * (CLAUDE.md, README, top-level names, git status). Every piece is optional: with no notes folder at all the
244 * context is the project alone, and with no project folder it is empty.
245 */
246async function gatherLocal($: EngineInterface, text: string): Promise<LocalContext> {
247 const rulesPath = await resolveIn($, opts.rulesFile)
248 const indexPath = await resolveIn($, opts.memoryIndex)
249 const memDir = indexPath.slice(0, indexPath.lastIndexOf('/'))
250 const rules = await readIf($, rulesPath, 6000)
251 const index = await readIf($, indexPath, 20000)
252
253 let facts = ''
254 if (index) {
255 const out = await complete($, PICK_SYSTEM, `Task:\n${text}\n\nIndex:\n${index}`, 300)
256 const picked = parseJson<{ files?: string[] }>(out ?? '')?.files ?? []
257 const names = picked.filter(f => /^[\w.-]+\.md$/.test(f)).slice(0, MAX_FACTS)
258 for (const name of names) {
259 const body = await readIf($, `${memDir}/${name}`, MAX_FACT_CHARS)
260 if (body) facts += `\n\n### ${name}\n${body}`
261 }
262 }
263
264 const cwd = await $.session.cwd().catch(() => '')
265 let project = cwd ? `Working folder: ${cwd}\n` : ''
266 if (cwd) {
267 const claudeMd = await readIf($, `${cwd}/CLAUDE.md`, 4000)
268 if (claudeMd) project += `\nCLAUDE.md (project instructions):\n${claudeMd}\n`
269 const readme = await readIf($, `${cwd}/README.md`, 1500)
270 if (readme) project += `\nREADME.md (head):\n${readme}\n`
271 try {
272 const entries = await $.fs.list(cwd)
273 project += `\nTop-level entries: ${entries.slice(0, 60).map(e => e.name + (e.kind === 'dir' ? '/' : '')).join(', ')}\n`
274 } catch {
275 // unreadable folder
276 }
277 try {
278 const git = await $.process.run(['git', 'status', '--short', '--branch'], { cwd, timeoutMs: 10000 })
279 if (git.exitCode === 0) project += `\ngit status --short --branch:\n${clip(git.stdout, 1500)}\n`
280 } catch {
281 // not a repository
282 }
283 }
284
285 const brief = [
286 rules ? `## Your rules (${opts.rulesFile}, head)\n${clip(rules, 3500)}` : '',
287 facts ? `## Memory facts picked for this task${facts}` : '',
288 project ? `## Project\n${project}` : '',
289 ]
290 .filter(Boolean)
291 .join('\n\n')
292 return { brief, facts, project }
293}
294
295// ---------------------------------------------------------------------------
296// Step 3: questions
297// ---------------------------------------------------------------------------
298
299function questionBudget() {
300 return Math.max(1, Math.min(3, Math.round(opts.maxQuestions)))
301}
302
303const DRAFT_SYSTEM = `You prepare a short clarification round before a coding/knowledge-work assistant starts a task. The user prefers short, structured text.
304Research says: one complete first prompt beats a multi-turn back-and-forth; a few targeted questions beat guessing; weak questions are vague ones. Ask only what the context below does not already answer.
305Cover, in priority order, whatever is missing: why the user wants this (the goal or what it is for; if the task states no reason, make this the first question, with options drawn from the context plus a free-text answer), what "done" looks like, scope and what is out of scope, constraints (tools, files, formats, time), how the result will be verified, who it is for.
306Return JSON only:
307{"questions":[{"question":"...?","header":"<=12 chars","options":[{"label":"1-5 words","description":"one short line"}],"multiSelect":false}],
308 "assumptions":["things you will assume if the user does not say otherwise"],
309 "searchQueries":["1 or 2 web search queries that would find best practices, research or GitHub repos for this task"]}
310Rules: at most {N} questions, 2 to 4 options each with short labels (1-5 words), no "Other" option (the dialog adds free text), headers under 12 characters, questions end with "?". Use the lessons list: they say what this user tends to leave out, so ask about those first when relevant.`
311
312async function draftQuestions($: EngineInterface, text: string, ctx: LocalContext, list: Lesson[]): Promise<Draft> {
313 const n = questionBudget()
314 const lessonText = list.length ? list.slice(-20).map(l => `- (${l.count}x) ${l.text}`).join('\n') : '- none yet'
315 const prompt = `Task the user typed:\n<task>\n${text}\n</task>\n\nLessons about this user's prompts:\n${lessonText}\n\nContext already known (do not ask about what it answers):\n<context>\n${clip(ctx.brief, 14000)}\n</context>`
316 const out = await complete($, DRAFT_SYSTEM.replace('{N}', String(n)), prompt, 1200)
317 const draft = parseJson<Partial<Draft>>(out ?? '')
318 const questions = (draft?.questions ?? [])
319 .filter(q => q && typeof q.question === 'string' && Array.isArray(q.options) && q.options.length >= 2)
320 .slice(0, n)
321 .map(q => ({
322 question: q.question.trim().endsWith('?') ? q.question.trim() : `${q.question.trim()}?`,
323 header: String(q.header ?? 'Detail').slice(0, 12),
324 options: q.options.slice(0, 4).map(o => ({ label: String(o.label).slice(0, 40), description: o.description ? String(o.description).slice(0, 120) : undefined })),
325 multiSelect: q.multiSelect === true,
326 }))
327 return {
328 questions,
329 assumptions: (draft?.assumptions ?? []).filter(a => typeof a === 'string').slice(0, 6),
330 searchQueries: (draft?.searchQueries ?? []).filter(q => typeof q === 'string').slice(0, 2),
331 }
332}
333
334const GO_Q = 'perfect-prompt: your task looks underspecified. Answer a few quick questions first?'
335const GO_YES = 'Yes, ask me'
336const GO_RAW = 'No, send as typed'
337const WEB_Q = 'Search the web for best practices, research and GitHub repos first?'
338const WEB_YES = 'Yes, search'
339const WEB_NO = 'No, skip it'
340
341/** One `$.ui.ask` dialog per question (the engine allows plugins no multi-question AskUserQuestion call). A dismissed dialog means "send as typed". */
342async function askUser($: EngineInterface, draft: Draft): Promise<{ answers: Record<string, string>; proceedRaw: boolean; web: boolean; free: string }> {
343 const none = { answers: {}, proceedRaw: true, web: false, free: '' }
344 let go: string
345 try {
346 go = await $.ui.ask(GO_Q, { options: [GO_YES, GO_RAW], header: 'Perfect' })
347 } catch {
348 return none
349 }
350 if (go !== GO_YES) return none
351 const answers: Record<string, string> = {}
352 for (const q of draft.questions) {
353 try {
354 const labels = q.options.map(o => o.label).slice(0, 4)
355 const a = await $.ui.ask(q.question, { options: labels, header: q.header, ...(q.multiSelect ? { multiSelect: true as const } : {}) })
356 if (a.trim()) answers[q.question] = a.trim()
357 } catch {
358 return none
359 }
360 }
361 let web = opts.webSearch === 'auto'
362 if (opts.webSearch === 'ask') {
363 try {
364 web = (await $.ui.ask(WEB_Q, { options: [WEB_YES, WEB_NO], header: 'Web search' })) === WEB_YES
365 } catch {
366 web = false
367 }
368 }
369 return { answers, proceedRaw: false, web, free: '' }
370}
371
372// ---------------------------------------------------------------------------
373// Step 4: web best practices
374// ---------------------------------------------------------------------------
375
376const WEB_SYSTEM = `You condense web search results into best practices for a task. Return at most 8 markdown bullets, each one concrete practice, pitfall, library or GitHub repo with its URL in parentheses. Skip marketing. If the results hold nothing useful, return "- nothing useful found".`
377
378async function searchWeb($: EngineInterface, text: string, queries: string[]) {
379 const qs = queries.length ? queries : [text.slice(0, 200)]
380 let raw = ''
381 for (const q of qs.slice(0, 2)) {
382 try {
383 const r = await $.tool.call({ tool: 'WebSearch', query: q, mode: 'standard' })
384 if ('deny' in r && r.deny) continue
385 const t = (r as { text?: string }).text ?? JSON.stringify((r as { result?: unknown }).result ?? '')
386 raw += `\n\n## Query: ${q}\n${clip(t, MAX_WEB_CHARS / 2)}`
387 } catch {
388 // a refused or failed search: go on without it
389 }
390 }
391 if (!raw.trim()) return ''
392 const out = await complete($, WEB_SYSTEM, `Task:\n${text}\n\nResults:${clip(raw, MAX_WEB_CHARS)}`, 700)
393 return out?.trim() ?? ''
394}
395
396// ---------------------------------------------------------------------------
397// Step 5: assemble
398// ---------------------------------------------------------------------------
399
400const ASSEMBLE_SYSTEM = `You write the complete first prompt a user will send to a coding/knowledge-work assistant (Claude Code), from the user's own words plus the answers and context given. You are the drafter; the user reviews before sending.
401Write in the user's voice, in plain English, as markdown with these sections in this order. Task, Context, Constraints and rules, What done looks like and Verification are always present. Why, Decisions already made and Best practices to apply appear only when there is something to put in them; never write "none" or "not stated" as a section's content, omit the section instead.
402# Task
403(the user's request, in their words, tightened)
404## Why
405(the goal, from the user's words or answers; omit when unknown)
406## Context
407(by reference: file paths, names, commands, facts that matter; no long dumps; keep every fact as given, invent nothing)
408## Constraints and rules
409(from the user's rules file, memory facts and answers)
410## What done looks like
411## Verification
412(how the assistant should check its own work: a command, a file to show, a question to ask at the end)
413## Decisions already made
414(the user's answers to the questions, as statements)
415## Best practices to apply
416(only when a web summary is given below: its bullets with their URLs)
417Rules: spec first, the request last; no padding or praise; do not add requirements the user did not give or imply; keep names, numbers and paths exactly; under 700 words unless the input needs more. Output the prompt only, no preamble.`
418
419/** The web bullets always reach the prompt when a search ran, whatever the model did with the section. */
420export function withWebSection(assembled: string, web: string) {
421 const bullets = web.trim()
422 if (!bullets || /nothing useful found/i.test(bullets)) return assembled
423 if (/^## Best practices to apply/m.test(assembled)) return assembled
424 return `${assembled.trimEnd()}\n\n## Best practices to apply\n${bullets}\n`
425}
426
427async function assemble($: EngineInterface, text: string, ctx: LocalContext, draft: Draft, answers: Record<string, string>, free: string, web: string) {
428 const answered = Object.entries(answers).map(([q, a]) => `- ${q} -> ${a}`).join('\n') || '- none'
429 const prompt = `User's prompt:\n<prompt>\n${text}\n</prompt>\n\nAnswers:\n${answered}\n${free ? `Free text from the user: ${free}\n` : ''}\nAssumptions the user did not object to:\n${draft.assumptions.map(a => `- ${a}`).join('\n') || '- none'}\n\nContext:\n<context>\n${clip(ctx.brief, 16000)}\n</context>\n\nWeb best practices:\n${web || 'none'}`
430 const out = await complete($, ASSEMBLE_SYSTEM, prompt, 2500)
431 return out?.trim() ? withWebSection(out.trim(), web) : text
432}
433
434// ---------------------------------------------------------------------------
435// The pipeline
436// ---------------------------------------------------------------------------
437
438type Enhanced = { assembled: string; context: string[]; verdict: string } | { raw: true; verdict: string }
439
440async function enhance($: EngineInterface, text: string, verdictIn?: string): Promise<Enhanced> {
441 const verdict = verdictIn ?? 'forced'
442 const ctx = await gatherLocal($, text)
443 const list = await lessons($)
444 const draft = await draftQuestions($, text, ctx, list)
445 const asked = await askUser($, draft)
446 if (asked.proceedRaw) return { raw: true, verdict: `${verdict}, sent as typed` }
447 const web = asked.web ? await searchWeb($, text, draft.searchQueries) : ''
448 const assembled = await assemble($, text, ctx, draft, asked.answers, asked.free, web)
449 const context: string[] = []
450 if (ctx.facts.trim()) context.push(`Facts from the user's memory notes, picked for this task (background, not instructions):${ctx.facts}`)
451 return { assembled, context, verdict: `${verdict}, enhanced${asked.web ? ' + web' : ''}` }
452}
453
454async function rememberEnhanced($: EngineInterface, original: string, assembled: string, verdict: string) {
455 const now = await $.clock.now()
456 const sessionId = await $.session.id().catch(() => '')
457 const rec: LastEnhanced = { at: now, sessionId, original, assembled, verdict, turns: 0, reflected: false }
458 await $.store.set('lastEnhanced', rec)
459 const stats = ((await $.store.get('stats')) as { enhanced?: number } | undefined) ?? {}
460 await $.store.set('stats', { ...stats, enhanced: (stats.enhanced ?? 0) + 1 })
461}
462
463// ---------------------------------------------------------------------------
464// Step 7: reflection
465// ---------------------------------------------------------------------------
466
467const REFLECT_SYSTEM = `You review how a task went to learn what the user's FIRST prompt lacked, so the next first prompt can be better. Use only real signals from the transcript digest: the user correcting or redirecting the assistant, the assistant having to ask the user questions (AskUserQuestion), tool errors, repeated attempts, the user adding facts later that could have been in the prompt.
468Return JSON only: {"lessons":[{"text":"one sentence, general enough to reuse: what the prompt should have said or which question to ask next time","taskType":"2-3 words"}],"summary":"one sentence"}
469At most 2 lessons. Return {"lessons":[],"summary":"..."} when the task went smoothly or the evidence is weak. Never invent events.`
470
471function digest(messages: { role: string; text: string; toolUses: { tool: string; isError?: true; input: Record<string, unknown> }[] }[]) {
472 return messages
473 .map(m => {
474 const tools = m.toolUses.map(t => `${t.tool}${t.isError ? '(error)' : ''}${t.tool === 'AskUserQuestion' ? ': ' + clip(JSON.stringify(t.input), 300) : ''}`)
475 return `${m.role.toUpperCase()}: ${clip(m.text.replace(/\s+/g, ' '), m.role === 'user' ? 600 : 350)}${tools.length ? `\n tools: ${tools.join(', ')}` : ''}`
476 })
477 .join('\n')
478}
479
480async function reflect($: EngineInterface, isForced = false): Promise<string> {
481 const last = await lastEnhanced($)
482 if (!last) return 'Nothing to reflect on: no enhanced prompt yet.'
483 if (last.reflected && !isForced) return 'Already reflected on the last enhanced prompt.'
484 const sessionId = await $.session.id().catch(() => '')
485 if (sessionId !== last.sessionId) return 'The last enhanced prompt belongs to another session; its transcript is not here.'
486 let msgs: Awaited<ReturnType<typeof $.session.messages>> = []
487 try {
488 msgs = await $.session.messages()
489 } catch {
490 return 'Transcript not readable.'
491 }
492 const key = last.assembled.slice(0, 80)
493 let start = msgs.findIndex(m => m.role === 'user' && m.text.includes(key))
494 if (start < 0) start = Math.max(0, msgs.length - 30)
495 const slice = msgs.slice(start, start + 60)
496 if (slice.length < 2) return 'Too little transcript after the enhanced prompt to learn from.'
497 const prompt = `Original prompt:\n<original>\n${clip(last.original, 1500)}\n</original>\n\nEnhanced prompt sent:\n<enhanced>\n${clip(last.assembled, 3000)}\n</enhanced>\n\nTranscript digest after it (${slice.length} messages):\n<digest>\n${clip(digest(slice), 20000)}\n</digest>`
498 const out = await complete($, REFLECT_SYSTEM, prompt, 500)
499 const parsed = parseJson<{ lessons?: { text: string; taskType?: string }[]; summary?: string }>(out ?? '')
500 const found = (parsed?.lessons ?? []).filter(l => l && typeof l.text === 'string' && l.text.trim()).slice(0, 2)
501 const now = await $.clock.now()
502 let list = await lessons($)
503 const added: string[] = []
504 for (const l of found) {
505 const r = mergeLesson(list, l.text.trim(), (l.taskType ?? 'general').trim(), now)
506 list = r.list
507 added.push(`${r.isNew ? 'new' : 'repeat'}: ${l.text.trim()}`)
508 }
509 await $.store.set('lessons', list)
510 await $.store.set('lastEnhanced', { ...last, reflected: true })
511 if (found.length) await appendLessonsFile($, found.map(l => `${l.text.trim()} [${(l.taskType ?? 'general').trim()}]`), now)
512 const line = found.length ? `perfect-prompt: ${found.length} lesson${found.length > 1 ? 's' : ''} saved` : 'perfect-prompt: no lesson this time'
513 $.ui.toast(line)
514 return `${line}\n${added.join('\n')}\n${parsed?.summary ?? ''}`.trim()
515}
516
517/** Appends the lessons to a human-readable file in the notes folder, when that folder exists. The store already holds them. */
518async function appendLessonsFile($: EngineInterface, lines: string[], now: number) {
519 try {
520 const dir = await vaultPath($)
521 if (!(await $.fs.exists(dir))) return
522 const path = `${dir}/${LESSONS_FILE}`
523 let body = (await $.fs.exists(path)) ? await $.fs.read(path) : `# Lessons: perfect-prompt\n\nWhat your first prompts tend to leave out, learned by the perfect-prompt mod after each enhanced task. Dated, append-only; the mod reads its own store, this file is the human copy.\n`
524 if (!body.endsWith('\n')) body += '\n'
525 const cwd = await $.session.cwd().catch(() => '')
526 body += lines.map(l => `- ${dateOf(now)} ${cwd ? `(${cwd.split(/[\\/]/).pop()}) ` : ''}${l}`).join('\n') + '\n'
527 await $.fs.write(path, body)
528 } catch {
529 // the notes folder is optional; the store already holds the lesson
530 }
531}
532
533// ---------------------------------------------------------------------------
534// Register
535// ---------------------------------------------------------------------------
536
537function readOptions(options: Readonly<Record<string, unknown>>) {
538 const s = (k: keyof Options, d: string) => (typeof options[k] === 'string' && (options[k] as string).trim() ? (options[k] as string).trim() : d)
539 const n = (k: keyof Options, d: number) => (typeof options[k] === 'number' && Number.isFinite(options[k]) ? (options[k] as number) : d)
540 opts = {
541 mode: s('mode', 'review') === 'auto' ? 'auto' : 'review',
542 model: s('model', 'haiku'),
543 vaultPath: s('vaultPath', '~/.claude'),
544 rulesFile: s('rulesFile', 'CLAUDE.md'),
545 memoryIndex: s('memoryIndex', 'MEMORY.md'),
546 webSearch: (['ask', 'auto', 'off'].includes(s('webSearch', 'ask')) ? s('webSearch', 'ask') : 'ask') as Options['webSearch'],
547 maxQuestions: Math.min(3, Math.max(1, n('maxQuestions', 2))),
548 minWords: Math.max(1, n('minWords', 12)),
549 }
550}
551
552export const register: Register = (on, options) => {
553 readOptions(options)
554
555 on('session.start', async ($, e, next) => {
556 await $.command.register({ name: 'pp', description: 'Perfect Prompt: /pp <text> enhances it; /pp status | off | on | lessons | forget <n> | reflect | spec', argumentHint: '[text | status | off | on | lessons | forget <n> | reflect | spec]' })
557 return next(e)
558 })
559
560 on('prompt.submit', async ($, e, next) => {
561 if (!HUMAN.has(e.origin.kind) || e.command || e.isMeta) return next(e)
562 const text = e.text.trim()
563
564 // The reviewed prompt comes back: strip the marker, attach the hidden facts, let it through.
565 if (text.startsWith(MARKER)) {
566 const stripped = text.slice(MARKER.length).trim()
567 const p = await read($, pending)
568 await update($, pending, () => null)
569 const extra = p?.context ?? []
570 if (p) await rememberEnhanced($, p.original, stripped, 'reviewed')
571 await setBand($, 'enhanced prompt sent')
572 return next({ ...e, text: stripped, context: extra.length ? [...(e.context ?? []), ...extra] : e.context })
573 }
574
575 if (await read($, isOff)) return next(e)
576 if (text.startsWith('>>')) {
577 await setBand($, 'sent raw (>>)')
578 return next({ ...e, text: text.slice(2).trim() })
579 }
580 if (wordCount(text) < opts.minWords || isTrivial(text) || looksSecret(text)) return next(e)
581
582 // A new prompt after an enhanced task: learn from that task in the background.
583 const last = await lastEnhanced($)
584 if (last && !last.reflected && last.turns > 0) $.clock.after(0, () => void reflect($).catch(() => undefined))
585
586 try {
587 const verdict = await classifyPrompt($, text)
588 if (verdict !== 'UNDERSPECIFIED') {
589 await setBand($, verdict === 'CLEAR' ? 'clear, sent as typed' : 'follow-up, sent as typed')
590 return next(e)
591 }
592 $.ui.toast('perfect-prompt: a few questions first')
593 const result = await enhance($, text, 'underspecified')
594 if ('raw' in result) {
595 await setBand($, result.verdict)
596 return next(e)
597 }
598 if (opts.mode === 'auto') {
599 await rememberEnhanced($, text, result.assembled, result.verdict)
600 await setBand($, `${result.verdict}, sent`)
601 return next({ ...e, text: result.assembled, context: result.context.length ? [...(e.context ?? []), ...result.context] : e.context })
602 }
603 const p: Pending = { context: result.context, original: text, assembled: result.assembled, at: await $.clock.now() }
604 await update($, pending, () => p)
605 const filled = await $.prompt.fill({ text: `${MARKER}\n${result.assembled}` })
606 if (!filled.isFilled) {
607 await setBand($, `${result.verdict}, box busy: sent as typed`)
608 return next(e)
609 }
610 await setBand($, `${result.verdict}: review the prompt in your box`)
611 return { drop: 'perfect-prompt: the enhanced prompt is in your prompt box. Edit it, then press Enter. (Start with >> to send a prompt raw, /pp off to pause.)' }
612 } catch (err) {
613 await setBand($, `error, sent as typed (${err instanceof Error ? err.message.slice(0, 80) : 'unknown'})`).catch(() => undefined)
614 return next(e)
615 }
616 }).catch(($, e, next) => next(e))
617
618 on('turn.complete', async ($, e, next) => {
619 if (e.agentId === undefined && e.reason === 'answer') {
620 const last = await lastEnhanced($).catch(() => undefined)
621 if (last && !last.reflected) await $.store.set('lastEnhanced', { ...last, turns: last.turns + 1 }).catch(() => undefined)
622 }
623 return next(e)
624 })
625
626 on('command.run', { command: 'pp' }, async ($, e) => {
627 const args = e.args.trim()
628 const [word, ...rest] = args.split(/\s+/)
629 const sub = (word ?? '').toLowerCase()
630 if (sub === '' || sub === 'status') {
631 const off = await read($, isOff)
632 const list = await lessons($)
633 const last = await lastEnhanced($)
634 const stats = ((await $.store.get('stats')) as { enhanced?: number } | undefined) ?? {}
635 return {
636 text: [
637 `perfect-prompt ${off ? 'OFF for this session' : 'on'} · mode ${opts.mode} · model ${opts.model} · web ${opts.webSearch} · ${questionBudget()} question(s) per round`,
638 `notes folder ${await vaultPath($)} · rules ${opts.rulesFile} · memory index ${opts.memoryIndex} · lessons ${LESSONS_FILE}`,
639 `${stats.enhanced ?? 0} prompts enhanced in all · ${list.length} lessons · ${sessionTokens} cheap-model tokens this session`,
640 last ? `last enhanced ${dateOf(last.at)}: ${last.verdict}, ${last.turns} turn(s) after it, ${last.reflected ? 'reflected' : 'not yet reflected'}` : 'no enhanced prompt yet',
641 'Usage: type normally (long new tasks get questions) · >> sends raw · /pp <text> enhances that text · /pp off | on | lessons | forget <n> | reflect | spec',
642 ].join('\n'),
643 }
644 }
645 if (sub === 'off' || sub === 'on') {
646 await update($, isOff, () => sub === 'off')
647 return { text: sub === 'off' ? 'perfect-prompt is off for this session. /pp on brings it back.' : 'perfect-prompt is on.' }
648 }
649 if (sub === 'lessons') {
650 const list = await lessons($)
651 if (list.length === 0) return { text: 'No lessons yet. They appear after an enhanced task ends and you start the next one, or after /pp reflect.' }
652 return { text: list.map((l, i) => `${i + 1}. (${l.count}x, ${dateOf(l.lastAt)}, ${l.taskType}) ${l.text}`).join('\n') }
653 }
654 if (sub === 'forget') {
655 const n = Number(rest[0])
656 const list = await lessons($)
657 if (!Number.isInteger(n) || n < 1 || n > list.length) return { text: `Give a lesson number from 1 to ${list.length}.` }
658 const [gone] = list.splice(n - 1, 1)
659 await $.store.set('lessons', list)
660 return { text: `Forgot: ${gone?.text ?? ''}` }
661 }
662 if (sub === 'reflect') return { text: await reflect($, true) }
663 if (sub === 'spec') {
664 const last = await lastEnhanced($)
665 const p = await read($, pending)
666 const body = p?.assembled ?? last?.assembled
667 if (!body) return { text: 'No assembled prompt to write yet.' }
668 const cwd = await $.session.cwd().catch(() => '')
669 const path = `${cwd}/SPEC-perfect-prompt.md`
670 await $.fs.write(path, `${body}\n`)
671 return { text: `Wrote ${path}. Start a fresh session and paste or @-mention it (the "consolidate and restart" move).` }
672 }
673 // Anything else is a prompt to enhance now.
674 if (await read($, isOff)) return { text: 'perfect-prompt is off for this session. /pp on first.' }
675 try {
676 const result = await enhance($, args, 'forced')
677 if ('raw' in result) {
678 const back = await $.prompt.fill({ text: `>> ${args}` })
679 return { text: back.isFilled ? 'Your prompt is back in the box as typed (with >> so it goes through untouched). Press Enter to send it.' : `Kept as typed. Your text: ${args}` }
680 }
681 const p: Pending = { context: result.context, original: args, assembled: result.assembled, at: await $.clock.now() }
682 await update($, pending, () => p)
683 const filled = await $.prompt.fill({ text: `${MARKER}\n${result.assembled}` })
684 await setBand($, `${result.verdict}: review the prompt in your box`)
685 return { text: filled.isFilled ? 'The enhanced prompt is in your prompt box. Edit it, then press Enter.' : 'Could not fill the prompt box (is it empty?). Run /pp spec to write the prompt to a file instead.' }
686 } catch (err) {
687 return { text: `perfect-prompt failed: ${err instanceof Error ? err.message : String(err)}` }
688 }
689 })
690
691 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
692 if (e.props.hasSurvey) return next(e)
693 const b = await read($, band)
694 const off = await read($, isOff)
695 if ((b === null && !off) || (await read($, isHidden))) return next(e)
696 const { Box, Button, Text } = $.ui.resolve(e)
697 const below = await next(e)
698 return (
699 <Box flexDirection="column">
700 <Box flexDirection="row" gap={1}>
701 <Text dimColor wrap="truncate-end">
702 {`perfect-prompt · ${off ? 'off this session' : b?.verdict ?? 'on'} · ${sessionTokens} tokens`}
703 </Text>
704 <Button key="pp-toggle" label={off ? 'On' : 'Off'} onPress={() => update($, isOff, v => !v)} />
705 <Button key="pp-hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
706 </Box>
707 {below}
708 </Box>
709 )
710 })
711}
712types/index.d.ts 24 lines1export type Band = {
2 verdict: string
3 tokens: number
4 at: number
5}
6
7export type Pending = {
8 context: string[]
9 original: string
10 assembled: string
11 at: number
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 'perfect-prompt': {
17 band: Band | null
18 isOff: boolean
19 isHidden: boolean
20 pending: Pending | null
21 }
22 }
23}
24