SLOPSHOPPER

perfect-prompt

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…

newbandcommandtoastpromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · perfect-prompt
› 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 › /pp ⎿ perfect-prompt: perfect-prompt on · mode review · model haiku · web ask · 2 question(s) per round ⎿ perfect-prompt: notes folder ~/.claude · rules CLAUDE.md · memory index MEMORY.md · lessons perfect-prompt-lessons.md ⎿ perfect-prompt: 0 prompts enhanced in all · 0 lessons · 0 cheap-model tokens this session ⎿ perfect-prompt: no enhanced prompt yet ⎿ perfect-prompt: Usage: type normally (long new tasks get questions) · >> sends raw · /pp <text> enhances that text · /pp off | ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

perfect-prompt

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-prompt
Source 2 files
hooks/register.tsx 712 lines
1import { 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}
712
types/index.d.ts 24 lines
1export 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