SLOPSHOPPER

prompt-polish

提示词优化:点击输入框上方按钮或在草稿末尾输入 ;; 触发 · 结合会话上下文与记忆补全草稿,并按任务类型追加工作方式要求 · 结果写回输入框,发送前可编辑或还原

newbandtoastpromptmodeltimer
v0.1.0MITupdated 2026-10-06Albert-ycc/prompt-polish
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-polish
› 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 [ ✨ 优化提示词 ] 在草稿末尾输入 ;; 快速优化 · 结果写回输入框,发送前可编辑或还原 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ ✨ 优化提示词 ] 在草稿末尾输入 ;; 快速优化 · 结果写回输入框,发送前可编辑或还原
README

prompt-polish · 提示词优化

一个 Claude Code mod。它在输入框上方加一个「✨ 优化提示词」按钮,把随手写的草稿改写成完整、可执行的提示词,写回输入框;你看过、改过之后再发送。

它做什么

  • 补全草稿:借用当前会话的上下文(系统提示、CLAUDE.md、对话),再附上记忆索引,把草稿补成完整的提示词。改写时遵守下面几条原则:
  • 只补「缺了会让执行方做出明显不同结果」的信息,其余合并成一行默认假设;
  • 记忆里管产出边界的规则(不加没提的功能、以哪份原始材料为准、产物长什么样),落到这次任务上写成一句;管操作手法的通用规则不转述;
  • 执行方自己能查到的不列为待澄清;剩下的最多 3 条,按影响排序,不替你编默认答案;
  • 路径、文件名、项目名只能照抄见过的原文。
  • 判断任务类型:识别为四类之一,在末尾追加对应的工作方式要求。
  • 随时还原:结果写回输入框,可以一键还原原稿,也可以重新优化。
类型适用场景追加在末尾的要求
开放探讨方向、方案、该不该做还没想清楚先别急着动手做:先给出判断和推荐做法,再列出最可能改变判断的关键问题(最多 3 个),确认方向后再开始
明确执行目标和做法基本清楚的具体活直接执行到完成,过程中不用确认;完成后给结论和验证证据
调研分析查资料、做对比、研究现状、分析原因结论先行,事实注明来源,区分「查证过的」和「推测的」
快速问答一两句话能答的事实或概念问题不追加

结尾话术全文在 hooks/register.tsx 的 ENDINGS 里。如果模型给出的类型对不上这四类,就不追加结尾,输入框上方显示「未识别」。

安装

需要支持 mod(TypeScript hooks 插件)的 Claude Code。已在 Claude Code 2.1.289 上测试通过。

  1. 克隆到本地任意目录:
   git clone https://github.com/Albert-ycc/prompt-polish.git ~/.claude/mods/prompt-polish
  1. 先在单次会话里试用:
   claude --plugin-dir ~/.claude/mods/prompt-polish
  1. 想让每个会话都加载,就在 ~/.claude/settings.json 的 env 里指向它,路径写绝对路径;然后重启 Claude Code:
   {
     "env": {
       "CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/prompt-polish"
     }
   }

使用

两种方式触发:

  • 点击输入框上方的「✨ 优化提示词」;
  • 在草稿末尾输入 ;;(中文输入法打出的 ;; 也可以),输入后立即开始优化。

另外,回车发送时也会检查:只要消息末尾是 ;; 或 ;;(忽略末尾空白),并且前面有正文,这条消息就会被拦下、转去优化,不会发出去。有些情况下输入框不转发编辑事件,例如桌面端新会话的第一条消息,这时靠回车触发。消息只有 ;; 时照常发出。

完成后,输入框上方会显示识别出的类型和实际读到的上下文来源,并提供「↩ 还原原文」和「✨ 再优化」两个按钮。另外三种情况:

  • 输入框暂时写不进去(例如有弹窗挡着):结果复制到剪贴板,并给出提示,原草稿不动。
  • 优化过程中点了「取消」,或者发出了别的消息:这次结果会被丢弃,不会覆盖输入框。已经发出的模型请求会继续跑完。

读取哪些数据

  • 会话上下文:优先复用当前会话已有的上下文(fork 一次,禁止调用工具)。新会话发第一句或 /clear 之后,会话里还没有上下文,改用 Opus 单独发一次补全请求(上限 4000 tokens,超时 120 秒),并附上下面的本地文件。
  • 本地文件(只读):
  • 当前会话项目和家目录项目的记忆索引,路径为 <配置目录>/projects/<目录名>/memory/MEMORY.md。目录名是把项目路径里所有非字母数字的字符换成 -,例如 /work/my proj 对应 -work-my-proj。两份是同一个文件时只读一次。
  • 没有会话上下文时,额外读用户级 CLAUDE.md,以及从项目根往上各层的 CLAUDE.md。
  • 环境变量:HOME、CLAUDE_CONFIG_DIR。配置目录默认是 ~/.claude。
  • 模型请求都通过 Claude Code 自身的模型通道发出,不访问其他网络服务。

自定义

想改的改哪里
结尾话术hooks/register.tsx 的 ENDINGS
增减任务类型同时改 ENDINGS、TYPE_HINTS、types/index.d.ts 里的 PolishType,以及 instruction() 输出格式里的「四选一」
改写原则hooks/register.tsx 的 instruction()
没有会话上下文时用的模型hooks/register.tsx 里 $.model.complete 的 model: 'opus'

开发

claude plugin test .       # 运行 test/ 下的测试
claude plugin validate .   # 校验清单,列出 mod 用到的接口和环境变量

Claude Code 加载 mod 时,会把 SDK 类型生成到 .claude-plugin/types/,供编辑器做类型检查。这个目录已经写进 .gitignore,不提交到仓库。刚克隆下来时,先用 claude --plugin-dir <路径> 启动一次,生成这批类型;在那之前,编辑器提示找不到 tsconfig 或 claude-code 模块,属于正常现象,不影响测试和校验。

设计依据

改写原则的来源和对照实验结果见 docs/design.md。

License

MIT

Source 2 files
hooks/register.tsx 302 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelApiError, ModelForkResult, Register, RenderSurface } from 'claude-code'
3
4import type { Phase, PolishType } from '../types'
5
6// Claude Code 按项目存自动记忆:<配置目录>/projects/<项目路径里非字母数字全换成 -> /memory/MEMORY.md
7const projectKey = (dir: string) => dir.replace(/[^a-zA-Z0-9]/g, '-')
8
9async function claudeDirs($: EngineInterface) {
10  const home = (await $.env.get('HOME')) ?? ''
11  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
12  return { home, config }
13}
14
15// 记忆索引:当前会话项目的一份,加家目录项目的一份(同一份只读一次),让模型知道你有哪些项目、资料在哪
16async function readMemory($: EngineInterface) {
17  const { home, config } = await claudeDirs($)
18  const root = await $.session.root().catch(() => '')
19  const dirs = [...new Set([root, home].filter(Boolean))]
20  const texts = await Promise.all(
21    dirs.map(d => $.fs.read(`${config}/projects/${projectKey(d)}/memory/MEMORY.md`).catch(() => '')),
22  )
23  return texts.filter(Boolean).join('\n\n')
24}
25
26// 新会话第一句没有上下文可借时,按 Claude Code 自己的读法补上:用户级 CLAUDE.md,加从项目根往上各层的 CLAUDE.md
27async function readProfile($: EngineInterface) {
28  const { config } = await claudeDirs($)
29  const user = await $.fs.read(`${config}/CLAUDE.md`).then(t => `# ${config}/CLAUDE.md\n\n${t}`, () => '')
30  const project = await $.fs.ancestors({ names: ['CLAUDE.md'] }).catch(() => [])
31  return [user, ...project.map(f => `# ${f.dir}/CLAUDE.md\n\n${f.content}`)].filter(Boolean).join('\n\n')
32}
33
34// 在草稿末尾连敲两个分号触发优化(中文输入法打出的 ;; 也算)
35const TRIGGER = /(;;|;;)$/
36
37// 刚敲进字、光标在末尾、草稿以触发符结尾,才算触发;删字或在中间编辑不算
38export const isTrigger = (box: { text: string; cursor: number }, inputText: string) =>
39  inputText !== '' && box.cursor === box.text.length && TRIGGER.test(box.text)
40
41export const stripTrigger = (text: string) => text.replace(TRIGGER, '')
42
43// 按类型追加的固定结尾;想改措辞、加类型,改这里(同时改 types/index.d.ts 的 PolishType)
44const ENDINGS: Record<PolishType, string> = {
45  开放探讨:
46    '这件事我还没完全想清楚。先别急着动手做:先给出你的判断和推荐做法,说清理由;再列出最可能改变这个判断的关键问题(最多 3 个,按影响大小排序,用业务场景来问,不出技术选型题)。我确认方向后再开始做。',
47  明确执行:
48    '目标已经明确,直接执行到完成,过程中不用找我确认;完成后给结论和验证证据(实际运行输出、截图或读过的正文)。',
49  调研分析:
50    '结论先行。事实性内容注明来源,区分「查证过的」和「推测的」,不确定的地方直接说不确定。',
51  快速问答: '',
52}
53
54const TYPE_HINTS: Record<PolishType, string> = {
55  开放探讨: '方向、方案、该不该做、怎么做还没想清楚,需要先讨论',
56  明确执行: '目标和做法基本清楚的具体活(改代码、出文档、画图、整理文件等)',
57  调研分析: '查资料、做对比、研究现状、分析原因',
58  快速问答: '一两句话就能答的事实或概念问题',
59}
60
61const TYPES = Object.keys(ENDINGS) as PolishType[]
62
63const phase = atom({ plugin: 'prompt-polish', key: 'phase' } as const, { kind: 'idle' } as Phase)
64
65// 模型接口的英文错误类别 → 中文
66const API_ERRORS: Record<ModelApiError, string> = {
67  authentication_failed: '认证失败',
68  oauth_org_not_allowed: '当前组织不允许这种登录方式',
69  account_on_hold: '账号被暂停',
70  verification_required: '账号需要验证',
71  billing_error: '账单问题',
72  rate_limit: '请求频率受限',
73  overloaded: '服务过载',
74  invalid_request: '请求无效',
75  model_not_found: '模型不存在',
76  server_error: '服务器内部错误',
77  unknown: '未知错误',
78  max_output_tokens: '回复超出长度上限',
79  cloud_credential_error: '云端凭据错误',
80}
81
82const whyNoReply = (reply: Extract<ModelForkResult, { isAnswered: false }>) => {
83  switch (reply.reason) {
84    case 'api-error':
85      return `接口报错,${API_ERRORS[reply.error] ?? reply.error}(HTTP ${reply.status ?? '无响应'})`
86    case 'empty-reply':
87      return '模型回复为空'
88    case 'aborted':
89      return '超时或被中断'
90    case 'nothing-to-fork':
91      return '当前会话还没有上下文'
92  }
93}
94
95const instruction = (draft: string, memory: string) => `你现在的任务不是执行,而是替我(用户)改写一段准备发给 Claude Code 的提示词。不要调用任何工具,不要回答或执行这段提示词本身,只输出改写结果。
96
97改写原则:
981. 忠于原意:只把我已经表达的、或从上下文能确定的意思说完整、说专业;不添加我没提出的新需求、新功能。
992. 逐项检查这五项:要解决的真实问题与背景、目标、产出物形态、范围与约束、完成标准。每项先在心里判断「清楚 / 部分清楚 / 缺失」(不输出判断过程)。只补那些「缺了会让执行方做出明显不同结果」的项;其余能合理假设的,合并成一行「默认:……」,不展开。不要补原文没提到的具体约束、细节要求或验收指标。
1003. 结合你对我的了解:涉及具体项目时,点明项目名和应先读的本地资料路径,放在说清我要解决什么之后。路径、文件名、项目名和系统名只能照抄你在上下文或下面的记忆索引里确实见过的原文,没见过就不写,不要拼接或推测归属。记忆和上下文里的规则分两类处理:管这次产出边界的(不加我没提的功能、不编造内容、以哪份原始材料为准、产物长什么样、截图怎么截、该用哪个 skill 或流程),落到这次任务上写成具体的一句;管操作手法的通用规则(写入方式、工具用法、操作禁忌之类)不要转述,执行方会自己加载。它们和草稿原话冲突时,以草稿原话为准。
1014. 我没说清、上下文也推不出来的点:先判断执行方自己能不能查到(读本地文件、代码、文档、系统就能查到的,不列,改成在正文里让执行方先去查)。剩下的只保留「答案会改变产出结果」的,最多 3 条,按影响从大到小排,放在提示词末尾「我还没想清楚的:」下面。不要借待澄清项引出草稿没提的新功能、新状态或新范围。不要替我写默认答案;只有草稿原话或执行方能查到的原文里已经给出答案时,才在该条后注明「(草稿已说明:……)」。没有这样的点时,这一段整段不写。
1025. 原文开头的派单前缀(如「后端:」「产品:」)、所有 @文件引用、链接、路径原样保留。
1036. 用我的第一人称口吻,中文,技术术语保留英文;篇幅以说清楚为准,最长不超过 500 字。
1047. 不要写结尾的工作方式要求(如「先问我」「直接执行」),那部分由程序按类型追加。
105
106同时判断这段提示词属于哪一类,只能选一个:
107${TYPES.map(t => `- ${t}:${TYPE_HINTS[t]}`).join('\n')}
108
109严格按以下格式输出,前后不要任何其他文字:
110<类型>${TYPES.join(' / ')} 四选一</类型>
111<提示词>
112改写后的提示词
113</提示词>
114${memory ? `\n我的记忆索引(每行一条记忆的摘要):\n<<<\n${memory}\n>>>\n` : ''}
115我的原始提示词:
116<<<
117${draft}
118>>>`
119
120const parse = (reply: string) => {
121  const text = reply.match(/<提示词>\s*([\s\S]*?)\s*<\/提示词>/)?.[1]
122  if (!text) return undefined
123  const label = reply.match(/<类型>\s*([\s\S]*?)\s*<\/类型>/)?.[1] ?? ''
124  const type = TYPES.find(t => label.includes(t)) ?? null
125  return { type, text }
126}
127
128// 每次开始优化、取消或你发出消息都 +1,晚到的旧结果直接作废,不会覆盖你新写的草稿
129let generation = 0
130
131function fail($: EngineInterface, message: string) {
132  return update($, phase, (): Phase => ({ kind: 'error', message }))
133}
134
135// surface 只用于写不进输入框时复制到剪贴板;打字触发时不知道界面,留空即用会话的默认界面
136// given:回车拦截时输入框已被清空,草稿由调用方直接传入
137async function polish($: EngineInterface, surface?: RenderSurface, given?: string) {
138  const draft = given ?? (await $.prompt.read().then(box => box.text, () => undefined))
139  if (draft === undefined) {
140    await fail($, '读不到输入框里的草稿,原草稿未改动')
141    return
142  }
143  if (!draft.trim()) {
144    $.ui.toast('输入框是空的,先写下你想做的事')
145    return
146  }
147
148  const mine = ++generation
149  await update($, phase, (): Phase => ({ kind: 'working', seconds: 0 }))
150  // 每秒刷新一次已用秒数,让你知道它还在跑
151  const tick = $.clock.every(1000, () => {
152    void update($, phase, (now): Phase => (now.kind === 'working' ? { ...now, seconds: now.seconds + 1 } : now))
153  })
154
155  try {
156    const memory = await readMemory($)
157    const ask = instruction(draft, memory)
158
159    // 优先借当前会话的完整上下文(系统提示、CLAUDE.md、对话),新会话第一句时退回读本地文件
160    // 「依据」只列实际读到内容的来源
161    const sources = (...parts: (string | false)[]) => parts.filter(Boolean).join(' + ') || '仅草稿'
162    let source = sources('当前会话上下文', memory !== '' && '记忆索引')
163    let reply = await $.model.fork({ prompt: ask })
164    if (!reply.isAnswered && reply.reason === 'nothing-to-fork') {
165      const profile = await readProfile($)
166      source = sources(profile !== '' && '全局指令', memory !== '' && '记忆索引')
167      reply = await $.model.complete({
168        model: 'opus',
169        system: `下面是用户的个人工作指令,用来了解用户是谁、在做哪些项目、有哪些工作习惯:\n\n${profile}`,
170        prompt: ask,
171        maxTokens: 4000,
172        timeoutMs: 120_000,
173      })
174    }
175
176    if (mine !== generation) return
177
178    if (!reply.isAnswered) {
179      await fail($, `模型没给出结果:${whyNoReply(reply)},原草稿未改动`)
180      return
181    }
182
183    const parsed = parse(reply.text)
184    if (!parsed) {
185      await fail($, '模型返回的格式不对,没能解析,原草稿未改动')
186      return
187    }
188
189    const ending = parsed.type ? ENDINGS[parsed.type] : ''
190    const polished = ending ? `${parsed.text}\n\n${ending}` : parsed.text
191
192    // 等待期间你可能又改了草稿:以覆盖前那一刻的内容作为「原文」,还原时不丢字
193    const latest = await $.prompt.read().then(box => box.text || draft, () => draft)
194    const filled = await $.prompt.fill({ text: polished, mode: 'replace' })
195    if (!filled.isFilled) {
196      await $.ui.copy({ text: polished, surface })
197      await fail($, '输入框暂时写不进去(可能有弹窗挡着),优化结果已复制到剪贴板')
198      return
199    }
200
201    await update($, phase, (): Phase => ({ kind: 'done', type: parsed.type, source, original: latest }))
202  } catch (error) {
203    if (mine === generation) {
204      await fail($, `优化失败,原草稿未改动(英文原文:${error instanceof Error ? error.message : String(error)})`)
205    }
206  } finally {
207    tick.cancel()
208  }
209}
210
211export const register: Register = on => {
212  // 你发出任何消息,按钮条回到初始状态,进行中的优化作废
213  on('prompt.submit', async ($, e, next) => {
214    // 回车时草稿末尾带 ;;:拦下不发,优化后填回输入框。
215    // 桌面端新会话发出第一条消息前不转发输入框编辑,第一条消息只能走这条路
216    const typed = e.text.trimEnd()
217    const byPerson = ['composer', 'sdk', 'bridge', 'unclassified'].includes(e.origin.kind)
218    if (byPerson && TRIGGER.test(typed) && stripTrigger(typed).trim()) {
219      $.clock.after(0, () => void polish($, undefined, stripTrigger(typed)))
220      return { drop: '这条没有发出去:末尾带 ;;,正在优化提示词,结果会填回输入框' }
221    }
222
223    generation += 1
224    await update($, phase, () => ({ kind: 'idle' }))
225
226    return next(e)
227  })
228
229  // 打字触发:去掉触发符后立刻开始优化(放到定时器里跑,不卡输入框)
230  on('prompt.edit', async ($, e, next) => {
231    const box = await next(e)
232    if (!isTrigger(box, e.inputText)) {
233      return box
234    }
235
236    const text = stripTrigger(box.text)
237    $.clock.after(0, () => void polish($))
238
239    return { ...box, text, cursor: text.length }
240  })
241
242  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
243    if (e.props.hasSurvey) {
244      return next(e)
245    }
246
247    const now = await read($, phase)
248    const { Box, Button, Text } = $.ui.resolve(e)
249
250    if (now.kind === 'working') {
251      return (
252        <Box gap={1}>
253          <Text>⏳ 正在优化提示词… 已用 {now.seconds} 秒</Text>
254          <Button
255            key="cancel"
256            label="取消"
257            onPress={() => {
258              generation += 1
259              void update($, phase, () => ({ kind: 'idle' }))
260            }}
261          />
262        </Box>
263      )
264    }
265
266    if (now.kind === 'done') {
267      return (
268        <Box gap={1}>
269          <Text dimColor>
270            已优化 · 类型:{now.type ?? '未识别(未加结尾)'} · 依据:{now.source}
271          </Text>
272          <Button
273            key="restore"
274            label="↩ 还原原文"
275            onPress={async () => {
276              await $.prompt.fill({ text: now.original, mode: 'replace' })
277              await update($, phase, () => ({ kind: 'idle' }))
278            }}
279          />
280          <Button key="again" label="✨ 再优化" onPress={press => void polish($, press.surface)} />
281        </Box>
282      )
283    }
284
285    return (
286      <Box gap={1}>
287        <Button
288          key="polish"
289          label="✨ 优化提示词"
290          variant="primary"
291          onPress={press => void polish($, press.surface)}
292        />
293        {now.kind === 'error' ? (
294          <Text color="red">{now.message}</Text>
295        ) : (
296          <Text dimColor>在草稿末尾输入 ;; 快速优化 · 结果写回输入框,发送前可编辑或还原</Text>
297        )}
298      </Box>
299    )
300  })
301}
302
types/index.d.ts 14 lines
1export type PolishType = '开放探讨' | '明确执行' | '调研分析' | '快速问答'
2
3export type Phase =
4  | { kind: 'idle' }
5  | { kind: 'working'; seconds: number }
6  | { kind: 'done'; type: PolishType | null; source: string; original: string }
7  | { kind: 'error'; message: string }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'prompt-polish': { phase: Phase }
12  }
13}
14