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

一个 Claude Code mod。它在输入框上方加一个「✨ 优化提示词」按钮,把随手写的草稿改写成完整、可执行的提示词,写回输入框;你看过、改过之后再发送。
| 类型 | 适用场景 | 追加在末尾的要求 |
|---|---|---|
| 开放探讨 | 方向、方案、该不该做还没想清楚 | 先别急着动手做:先给出判断和推荐做法,再列出最可能改变判断的关键问题(最多 3 个),确认方向后再开始 |
| 明确执行 | 目标和做法基本清楚的具体活 | 直接执行到完成,过程中不用确认;完成后给结论和验证证据 |
| 调研分析 | 查资料、做对比、研究现状、分析原因 | 结论先行,事实注明来源,区分「查证过的」和「推测的」 |
| 快速问答 | 一两句话能答的事实或概念问题 | 不追加 |
结尾话术全文在 hooks/register.tsx 的 ENDINGS 里。如果模型给出的类型对不上这四类,就不追加结尾,输入框上方显示「未识别」。
需要支持 mod(TypeScript hooks 插件)的 Claude Code。已在 Claude Code 2.1.289 上测试通过。
git clone https://github.com/Albert-ycc/prompt-polish.git ~/.claude/mods/prompt-polish
claude --plugin-dir ~/.claude/mods/prompt-polish
~/.claude/settings.json 的 env 里指向它,路径写绝对路径;然后重启 Claude Code: {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/prompt-polish"
}
}
两种方式触发:
;;(中文输入法打出的 ;; 也可以),输入后立即开始优化。另外,回车发送时也会检查:只要消息末尾是 ;; 或 ;;(忽略末尾空白),并且前面有正文,这条消息就会被拦下、转去优化,不会发出去。有些情况下输入框不转发编辑事件,例如桌面端新会话的第一条消息,这时靠回车触发。消息只有 ;; 时照常发出。
完成后,输入框上方会显示识别出的类型和实际读到的上下文来源,并提供「↩ 还原原文」和「✨ 再优化」两个按钮。另外三种情况:
/clear 之后,会话里还没有上下文,改用 Opus 单独发一次补全请求(上限 4000 tokens,超时 120 秒),并附上下面的本地文件。<配置目录>/projects/<目录名>/memory/MEMORY.md。目录名是把项目路径里所有非字母数字的字符换成 -,例如 /work/my proj 对应 -work-my-proj。两份是同一个文件时只读一次。HOME、CLAUDE_CONFIG_DIR。配置目录默认是 ~/.claude。| 想改的 | 改哪里 |
|---|---|
| 结尾话术 | 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。
hooks/register.tsx 302 lines1import { 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}
302types/index.d.ts 14 lines1export 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