Makes Claude and its agents reply in Chinese (Traditional by default), and asks for a rewrite when a reply comes back in another language.

Makes Claude reply in Chinese, Traditional (Taiwan usage) by default, and holds its subagents and teammates to the same rule. When a reply still comes back in another language, it asks Claude to say it again in Chinese.
/plugin install zh-reply --marketplace PeterTing/claude-mod
| Where | How |
|---|---|
| The main conversation and in-process teammates | Adds one rule to the end of the system prompt: reply in Chinese; keep code, commands, paths, identifiers and quoted text as they are; put a passage meant to stay in another language (a commit message, an English doc draft) in a code block; and write files, comments, commit messages and docs in the language the project already uses. |
| Agents Claude spawns | Adds one line to the end of each agent's task asking for Chinese, with the same carve-out for files, comments, commit messages and docs. Agents that another plugin spawns, workflow agents and resumed agents are left alone. |
| Messages Claude sends (SendMessage) | A message to another agent or session, from the main conversation or from an agent, that is not in Chinese is sent back once with a note to rewrite it. The same sender sending the same text to the same recipient again goes through. Messages other plugins send are left alone. |
| A reply that is not in Chinese | When the main conversation's reply is mostly English, Japanese or Korean, or written in the other Chinese script, it asks Claude to say it again in Chinese. It asks at most once for each prompt you send, so it cannot loop. |
Only prose counts. Code blocks, inline code, quoted lines (>), links, URLs, HTML tags and paths are left out. English counts by its function words (the, and, is, to, of…), and paths, flags, file names, identifiers and numbers are not counted as words. So file lists, commands and English status words inside a Chinese reply don't make it English, and neither do English test names or error messages as long as the reply has Chinese sentences of its own around them. A reply with no Chinese passes when it has fewer than six plain English words, or fewer than eight with no function word among them.
A prompt that asks to be answered in another language or in the other Chinese script ("請用英文…", "英文就好", "給我英文版", "請用簡體中文回答", "reply in English", "Please use English"), or asks for a translation into one ("翻譯成簡體中文", "translate this into Japanese"), is left to that language: its turn is not checked, so a reply in what you asked for is never rewritten. Asking for the configured script ("請用繁體中文回答") or for plain Chinese ("explain this in Chinese", "翻譯成中文") keeps the check on, since that is exactly when a reply drifting into the other script matters. A prompt that only mentions a language ("這個字的簡體寫法是什麼?", "英文寫的 log 看不懂") is not a request, and neither is "不要用英文", "別再用英文了" or "don't reply in English".
A request covers the turn your prompt starts. A prompt you type while Claude is still working keeps the running turn's request unless it makes one of its own. A turn you did not start, such as a finished background task or a teammate's message, is checked as usual.
| Command | What it does |
|---|---|
/zh-reply | Shows whether it is on and how many replies and messages it caught this session |
/zh-reply off | Turns it off for this session |
/zh-reply on | Turns it back on |
Set them with /plugin configure zh-reply@claude-mod in Claude Code, or claude plugin configure zh-reply@claude-mod from a shell.
| Option | Values | Default |
|---|---|---|
variant | zh-TW Traditional Chinese, zh-CN Simplified Chinese | zh-TW |
enforce | rewrite asks Claude to say it again (one extra turn); remind shows a notice and reminds Claude with your next prompt | rewrite |
scope | all the main conversation, agents and teammates; main the main conversation only | all |
/zh-reply off and on add or remove it, which makes the next request write the prompt cache again once.enforce: remind if you'd rather not spend that.L2 in this repository's reach levels: it adds text to the system prompt and to spawned agents' tasks, sends one prompt for a rewrite, and sends an agent's message back once. It reads no files, runs no programs, makes no network requests and reads no environment variables.
Its state lasts for the session only: the on/off switch, a count of what it caught, the id of the rewrite turn, whether it already asked for a rewrite since your last prompt, whether your last prompt asked for another language, whether a reminder is pending, and the text of up to 20 agent messages it sent back, kept so that an identical resend goes through.
claude plugin validate mods/zh-reply
claude plugin test mods/zh-reply
claude --plugin-dir mods/zh-replyhooks/register.ts 209 lines1import { atom, read, update } from 'claude-code'
2import type { PromptOrigin, Register } from 'claude-code'
3
4import type { ZhReplyRefused } from '../types'
5import { asksForAnotherLanguage, judge } from './detect'
6import type { Variant, Verdict } from './detect'
7
8const PLUGIN = 'zh-reply'
9const SECTION = 'zh-reply:language'
10/** How many sender→recipient pairs the resend escape remembers. */
11const REFUSED_MAX = 20
12
13const isOff = atom({ plugin: 'zh-reply', key: 'isOff' } as const, false)
14const caught = atom({ plugin: 'zh-reply', key: 'caught' } as const, 0)
15const rewriteTurn = atom({ plugin: 'zh-reply', key: 'rewriteTurn' } as const, null as string | null)
16const wantsOther = atom({ plugin: 'zh-reply', key: 'wantsOther' } as const, false)
17const remindNext = atom({ plugin: 'zh-reply', key: 'remindNext' } as const, false)
18const awaitingPerson = atom({ plugin: 'zh-reply', key: 'awaitingPerson' } as const, false)
19const refused = atom({ plugin: 'zh-reply', key: 'refused' } as const, {} as ZhReplyRefused)
20
21type Miss = Exclude<Verdict, { ok: true }>['reason']
22
23type Texts = {
24 rule: string
25 spawn: string
26 rewrite: (miss: Miss) => string
27 refuse: (miss: Miss) => string
28 remind: string
29 toastRewrite: string
30 toastRemind: string
31 command: string
32 status: (off: boolean, caught: number) => string
33}
34
35const TEXTS: Record<Variant, Texts> = {
36 'zh-TW': {
37 rule:
38 'Language: Always reply to the user in Traditional Chinese (繁體中文, Taiwan usage). This includes progress updates, questions, summaries, and messages you send to other agents. Keep code, commands, file paths, identifiers, error messages and quoted text in their original language, and put a passage meant to stay in another language (a commit message, an English doc draft) in a code block. This rule covers what you say, not what you write into files: write file contents, code comments, commit messages and docs in the language the project already uses. Use another language only when the user explicitly asks for it.',
39 spawn:
40 '(請用繁體中文回覆,給其他 agent 的訊息也一樣;程式碼、指令、路徑和引用的原文維持原樣。寫進檔案的內容、程式註解、commit message 和文件,沿用專案原本的語言。)',
41 rewrite: miss =>
42 miss === 'simplified'
43 ? '上一則回覆用了簡體字。請用繁體中文(台灣用語)把同樣的內容重新說一次;程式碼、指令、路徑和引用的原文維持原樣。'
44 : '上一則回覆沒有用繁體中文。請用繁體中文把同樣的內容重新說一次;程式碼、指令、路徑和引用的原文維持原樣。',
45 refuse: miss =>
46 miss === 'simplified'
47 ? '這則訊息用了簡體字,沒有送出。請改用繁體中文重寫後再送。'
48 : '這則訊息不是繁體中文,沒有送出。請改用繁體中文重寫後再送;程式碼、指令和路徑維持原樣。',
49 remind: '(zh-reply)上一則回覆沒有用繁體中文。請之後都用繁體中文回覆。',
50 toastRewrite: 'zh-reply:回覆不是繁體中文,已請 Claude 重寫',
51 toastRemind: 'zh-reply:回覆不是繁體中文,下一則訊息會提醒 Claude',
52 command: '強制用繁體中文回覆:/zh-reply off 關閉、/zh-reply on 開啟',
53 status: (off, n) =>
54 `${off ? '已關閉' : '已開啟'}(繁體中文)。這個 session 攔到 ${n} 次不是中文的回覆或訊息。用 /zh-reply ${off ? 'on 開啟' : 'off 關閉'}。`,
55 },
56 'zh-CN': {
57 rule:
58 'Language: Always reply to the user in Simplified Chinese (简体中文). This includes progress updates, questions, summaries, and messages you send to other agents. Keep code, commands, file paths, identifiers, error messages and quoted text in their original language, and put a passage meant to stay in another language (a commit message, an English doc draft) in a code block. This rule covers what you say, not what you write into files: write file contents, code comments, commit messages and docs in the language the project already uses. Use another language only when the user explicitly asks for it.',
59 spawn:
60 '(请用简体中文回复,发给其他 agent 的消息也一样;代码、命令、路径和引用的原文保持原样。写进文件的内容、代码注释、commit message 和文档,沿用项目原本的语言。)',
61 rewrite: miss =>
62 miss === 'traditional'
63 ? '上一条回复用了繁体字。请用简体中文把同样的内容重新说一遍;代码、命令、路径和引用的原文保持原样。'
64 : '上一条回复没有用简体中文。请用简体中文把同样的内容重新说一遍;代码、命令、路径和引用的原文保持原样。',
65 refuse: miss =>
66 miss === 'traditional'
67 ? '这条消息用了繁体字,没有发出。请改用简体中文重写后再发。'
68 : '这条消息不是简体中文,没有发出。请改用简体中文重写后再发;代码、命令和路径保持原样。',
69 remind: '(zh-reply)上一条回复没有用简体中文。请之后都用简体中文回复。',
70 toastRewrite: 'zh-reply:回复不是简体中文,已请 Claude 重写',
71 toastRemind: 'zh-reply:回复不是简体中文,下一条消息会提醒 Claude',
72 command: '强制用简体中文回复:/zh-reply off 关闭、/zh-reply on 开启',
73 status: (off, n) =>
74 `${off ? '已关闭' : '已开启'}(简体中文)。这个 session 拦到 ${n} 次不是中文的回复或消息。用 /zh-reply ${off ? 'on 开启' : 'off 关闭'}。`,
75 },
76}
77
78/** Every rewrite prompt this plugin can send, to know the turn it starts. */
79const REWRITES = (['zh-TW', 'zh-CN'] as const).flatMap(v =>
80 (['not-chinese', 'simplified', 'traditional'] as const).map(miss => TEXTS[v].rewrite(miss)),
81)
82
83/** A prompt the person typed, or one another plugin sent in their name. */
84const isPerson = (origin: PromptOrigin): boolean => {
85 switch (origin.kind) {
86 case 'composer':
87 case 'bridge':
88 case 'sdk':
89 case 'channel':
90 case 'slack-ping':
91 case 'scheduled-trigger':
92 return true
93 case 'plugin':
94 return origin.name !== PLUGIN && origin.asUser === true
95 default:
96 return false
97 }
98}
99
100export const register: Register = (on, options) => {
101 const variant: Variant = options.variant === 'zh-CN' ? 'zh-CN' : 'zh-TW'
102 const isRemindMode = options.enforce === 'remind'
103 const isMainOnly = options.scope === 'main'
104 const text = TEXTS[variant]
105
106 on('session.start', async ($, e, next) => {
107 await $.command.register({ name: 'zh-reply', description: text.command })
108 return next(e)
109 })
110
111 on('command.run', { command: 'zh-reply' }, async ($, e) => {
112 const arg = e.args.trim().toLowerCase()
113 if (arg === 'off' || arg === 'on') {
114 await update($, isOff, () => arg === 'off')
115 }
116 return { text: text.status(await read($, isOff), await read($, caught)) }
117 })
118
119 on('prompt.compose', async ($, e, next) => {
120 const composed = await next(e)
121 if ((isMainOnly && e.traits.includes('teammate')) || (await read($, isOff))) return composed
122 return { sections: [...composed.sections, { id: SECTION, text: text.rule, scope: 'session' as const }] }
123 })
124
125 on('agent.spawn', async ($, e, next) => {
126 const isOthers = next.origin.plugin !== 'engine'
127 // A workflow's or a resumed agent's task cannot be rewritten.
128 const isFixed = e.workflow !== undefined || e.resume !== undefined
129 if (isMainOnly || isOthers || isFixed || (await read($, isOff))) return next(e)
130 return next({ ...e, prompt: `${e.prompt}\n\n${text.spawn}` })
131 })
132
133 on('session.send', async ($, e, next) => {
134 const isAgent = e.agentId !== undefined
135 if ((isMainOnly && isAgent) || e.origin.kind === 'plugin' || (await read($, isOff))) return next(e)
136 const verdict = judge(e.text, variant)
137 if (verdict.ok) return next(e)
138
139 const key = `${e.agentId ?? 'main'}→${e.to}`
140 if ((await read($, refused))[key] === e.text) {
141 await update($, refused, ({ [key]: _, ...rest }) => rest)
142 return next(e)
143 }
144 await update($, refused, all => {
145 const kept = Object.entries(all).filter(([k]) => k !== key).slice(-(REFUSED_MAX - 1))
146 return { ...Object.fromEntries(kept), [key]: e.text }
147 })
148 await update($, caught, n => n + 1)
149 return { isDelivered: false as const, reason: text.refuse(verdict.reason) }
150 })
151
152 on('prompt.submit', async ($, e, next) => {
153 if (e.isMeta || !isPerson(e.origin)) {
154 const isOwn = e.origin.kind === 'plugin' && e.origin.name === PLUGIN
155 // A turn the person did not start (a finished task, an agent's message) is checked as usual.
156 if (e.turnId === undefined && !isOwn) {
157 await update($, wantsOther, () => false)
158 }
159 return next(e)
160 }
161 await update($, awaitingPerson, () => false)
162 // A slash command's text is its whole line, arguments included.
163 const asked = asksForAnotherLanguage(e.text, variant)
164 // A prompt typed while a turn runs keeps that turn's request unless it makes its own.
165 if (asked || e.turnId === undefined) {
166 await update($, wantsOther, () => asked)
167 }
168 if (e.command !== undefined || asked || !(await read($, remindNext)) || (await read($, isOff))) {
169 return next(e)
170 }
171 await update($, remindNext, () => false)
172 return next({ ...e, context: [...(e.context ?? []), text.remind] })
173 })
174
175 on('turn.start', async ($, e, next) => {
176 if (e.agentId === undefined && REWRITES.some(rewrite => e.text.includes(rewrite))) {
177 await update($, rewriteTurn, () => e.turnId)
178 }
179 return next(e)
180 })
181
182 on('turn.complete', async ($, e, next) => {
183 if (e.agentId !== undefined) return next(e)
184 if (e.turnId === (await read($, rewriteTurn))) {
185 await update($, rewriteTurn, () => null)
186 return next(e)
187 }
188 if (e.reason !== 'answer' || (await read($, wantsOther)) || (await read($, isOff))) return next(e)
189
190 const verdict = judge(e.answer, variant)
191 if (verdict.ok) return next(e)
192
193 await update($, caught, n => n + 1)
194 if (isRemindMode) {
195 await update($, remindNext, () => true)
196 $.ui.toast(text.toastRemind)
197 return next(e)
198 }
199 // At most one rewrite for each prompt of the person's, whatever becomes of its text.
200 if (await read($, awaitingPerson)) return next(e)
201 await update($, awaitingPerson, () => true)
202 // Not awaited: the prompt starts its own turn only once this one has ended.
203 // turn.start marks that turn so its answer is not counted again.
204 $.prompt.submit({ text: text.rewrite(verdict.reason) }).catch(() => undefined)
205 $.ui.toast(text.toastRewrite)
206 return next(e)
207 })
208}
209hooks/detect.ts 216 lines1export type Variant = 'zh-TW' | 'zh-CN'
2
3export type Verdict =
4 | { ok: true; reason: 'chinese' | 'too-short' }
5 | { ok: false; reason: 'not-chinese' | 'simplified' | 'traditional' }
6
7export type Counts = {
8 han: number
9 kana: number
10 hangul: number
11 /** Plain English words: letters only, not paths, flags, identifiers or file names. */
12 words: number
13 /** The plain words that only English prose has: the, and, is, to, of… */
14 functionWords: number
15 simplified: number
16 traditional: number
17}
18
19/**
20 * A reply with no Chinese counts as English from this many plain words with a
21 * function word among them, or from LONG_WORDS plain words without one.
22 */
23const MIN_WORDS = 6
24const LONG_WORDS = 8
25
26const FUNCTION_WORDS = new Set(
27 (
28 'a i the an and or but is are was were be been being to of in on for with that this these those it its as at by ' +
29 'from not no you your we our my me they their them he she his her will would can could should have has had do ' +
30 'does did so if then than there here what which who when where how why all any some also just now into about ' +
31 'after before because only more most other such each both'
32 ).split(' '),
33)
34
35/** A token that is code rather than prose: a path, flag, file name, identifier, number or address. */
36const TECHNICAL = /[/\\_@#=:]|\.[A-Za-z]|^-|[a-z][A-Z]|\d/
37const WORD = /^[A-Za-z](?:[A-Za-z'-]*[A-Za-z])?$/
38
39/**
40 * Characters written one way in Simplified Chinese and another in Traditional,
41 * as Simplified then Traditional. Only pairs where neither form is used in the
42 * other script's ordinary text (no 后/後, 里/裡, 台/臺, 只/隻, 准/準).
43 */
44const PAIRS =
45 '这這个個们們说說为為时時会會对對过過还還没沒问問题題来來实實现現应應该該从從让讓给給开開关關' +
46 '进進运運动動东東车車门門样樣见見长長头頭话話认認识識书書写寫学學习習经經济濟国國业業产產务務' +
47 '处處数數据據库庫网網页頁线線设設计計测測试試错錯误誤报報换換码碼档檔级級项項状狀态態环環变變' +
48 '节節点點击擊显顯统統结結构構组組织織权權户戶号號录錄删刪输輸帮幫请請谢謝读讀区區间間闭閉华華' +
49 '体體简簡两兩无無与與吗嗎觉覺发發将將当當战戰机機电電脑腦软軟证證验驗览覽链鏈释釋检檢复復杂雜' +
50 '类類难難续續缓緩储儲获獲选選择擇优優质質预預历歷册冊导導编編译譯语語补補'
51
52const SIMPLIFIED = new Set<string>()
53const TRADITIONAL = new Set<string>()
54for (let i = 0; i < PAIRS.length; i += 2) {
55 SIMPLIFIED.add(PAIRS.charAt(i))
56 TRADITIONAL.add(PAIRS.charAt(i + 1))
57}
58
59const FENCE = /^\s*(`{3,}|~{3,})/
60const QUOTE = /^\s*>/
61
62/**
63 * The prose of a reply: what the model wrote in its own words. Code blocks,
64 * inline code, quoted lines, link targets, URLs, HTML tags and paths are
65 * dropped, since they stay in their original language.
66 */
67export const prose = (text: string): string => {
68 const kept: string[] = []
69 let fence: string | null = null
70 for (const line of text.split('\n')) {
71 const opens = FENCE.exec(line)?.[1]
72 if (fence !== null) {
73 if (opens !== undefined && opens.charAt(0) === fence.charAt(0) && opens.length >= fence.length) {
74 fence = null
75 }
76 continue
77 }
78 if (opens !== undefined) {
79 fence = opens
80 continue
81 }
82 if (!QUOTE.test(line)) {
83 kept.push(line)
84 }
85 }
86 return kept
87 .join('\n')
88 .replace(/`[^`\n]*`/g, ' ')
89 .replace(/\]\([^)\s]*\)/g, ']')
90 .replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, ' ')
91 .replace(/<[^>\n]+>/g, ' ')
92 .replace(/(^|[\s(])(?:~|\.{1,2})?(?:\/[\w.@+-]+)+\/?/g, '$1 ')
93}
94
95const tally = (text: string, pattern: RegExp): number => text.match(pattern)?.length ?? 0
96
97export const count = (text: string): Counts => {
98 let simplified = 0
99 let traditional = 0
100 for (const char of text) {
101 if (SIMPLIFIED.has(char)) simplified += 1
102 else if (TRADITIONAL.has(char)) traditional += 1
103 }
104
105 let words = 0
106 let functionWords = 0
107 for (const run of text.match(/[\x21-\x7E]+/g) ?? []) {
108 const token = run.replace(/^[(\[{"'`*<|>]+/, '').replace(/[.,;:!?)\]}"'`*>|]+$/, '')
109 if (TECHNICAL.test(token) || !WORD.test(token)) continue
110 const lower = token.toLowerCase()
111 if (token.length === 1 && !FUNCTION_WORDS.has(lower)) continue
112 words += 1
113 if (FUNCTION_WORDS.has(lower)) functionWords += 1
114 }
115
116 return {
117 han: tally(text, /\p{Script=Han}/gu),
118 kana: tally(text, /[\p{Script=Hiragana}\p{Script=Katakana}]/gu),
119 hangul: tally(text, /\p{Script=Hangul}/gu),
120 words,
121 functionWords,
122 simplified,
123 traditional,
124 }
125}
126
127/**
128 * Whether a reply is written in the Chinese variant asked for. English counts
129 * by its function words, so file lists, commands, identifiers and status
130 * words inside a Chinese reply do not make it English.
131 */
132export const judge = (text: string, variant: Variant): Verdict => {
133 const c = count(prose(text))
134
135 if (c.han + c.kana + c.hangul === 0) {
136 const isEnglish = c.words >= LONG_WORDS || (c.words >= MIN_WORDS && c.functionWords >= 1)
137 return isEnglish ? { ok: false, reason: 'not-chinese' } : { ok: true, reason: 'too-short' }
138 }
139 if (c.hangul >= 5 && c.hangul > c.han) return { ok: false, reason: 'not-chinese' }
140 if (c.kana >= 5 && c.kana * 4 >= c.han) return { ok: false, reason: 'not-chinese' }
141 if (c.functionWords >= 3 && c.han < c.functionWords * 2) return { ok: false, reason: 'not-chinese' }
142
143 if (variant === 'zh-TW' && c.simplified >= 3 && c.simplified > c.traditional) {
144 return { ok: false, reason: 'simplified' }
145 }
146 if (variant === 'zh-CN' && c.traditional >= 3 && c.traditional > c.simplified) {
147 return { ok: false, reason: 'traditional' }
148 }
149 return { ok: true, reason: 'chinese' }
150}
151
152const SIMPLIFIED_NAMES = '簡體中文|简体中文|簡體字|简体字|簡體|简体|簡中|简中'
153const TRADITIONAL_NAMES = '繁體中文|繁体中文|繁體字|繁体字|繁體|繁体|繁中'
154const FOREIGN_NAMES = '英文|英語|英语|日文|日語|日语|韓文|韩文|韓語|韩语|法文|德文|西班牙文'
155const FOREIGN_EN = 'English|Japanese|Korean|French|German|Spanish'
156const EN_REPLY_VERBS = 'reply|answer|respond|write|say|explain|speak|talk|rewrite|summari[sz]e|describe'
157/** What follows a language in a request: a verb of saying or writing (not 寫法, 寫的, 說明), a recipient, or the end of a clause. */
158const ZH_FOLLOW =
159 '(?:回答|回覆|回复|作答|重寫|重写|撰寫|撰写|講|讲|解釋|解释|寫(?![法的])|写(?![法的])|說(?![法的明])|说(?![法的明])|跟|和|對|对|給|给|向|[,,。.!!]|$)'
160
161type Patterns = { translate: RegExp; replyIn: RegExp }
162
163/**
164 * The request patterns for one configured variant. Its own script, and plain
165 * "中文" or "Chinese", are not another language: a reply in them is checked.
166 */
167const build = (variant: Variant): Patterns => {
168 const other = variant === 'zh-TW' ? 'Simplified Chinese' : 'Traditional Chinese'
169 const langs = `${variant === 'zh-TW' ? SIMPLIFIED_NAMES : TRADITIONAL_NAMES}|${FOREIGN_NAMES}`
170 const enLangs = `${other}|${FOREIGN_EN}`
171 return {
172 translate: new RegExp(
173 `(?:翻譯成|翻译成|譯成|译成|轉成|转成)\\s*(?:${langs})` +
174 `|\\btranslat\\w*\\b[^.?!\\n]{0,40}\\b(?:in|into|to)\\s+(?:${enLangs})\\b`,
175 'gi',
176 ),
177 replyIn: new RegExp(
178 [
179 `(?:用|改用|以|寫成|写成)\\s*(?:${langs})\\s*${ZH_FOLLOW}`,
180 `(?:${langs})\\s*(?:回答|回覆|回复|作答|版|就好|就可以)`,
181 `(?:${langs})\\s*(?:說明|说明)\\s*一?下`,
182 `(?:講|讲|說|说)\\s*(?:${langs})`,
183 `(?:寫|写|撰寫|撰写)\\s*(?:一[封篇段份個个])?\\s*(?:${langs})`,
184 `\\b(?:${EN_REPLY_VERBS})\\b[^.?!\\n]{0,40}\\b(?:in|into)\\s+(?:${enLangs})\\b`,
185 `\\buse\\s+(?:${enLangs})\\b`,
186 `\\b(?:${enLangs})\\s+(?:version|summary|email|draft|reply|translation)\\b`,
187 `\\b(?:${FOREIGN_EN})\\s*,?\\s*please\\b`,
188 ].join('|'),
189 'gim',
190 ),
191 }
192}
193
194const PATTERNS: Record<Variant, Patterns> = { 'zh-TW': build('zh-TW'), 'zh-CN': build('zh-CN') }
195
196/** "不要用英文", "別再用英文", "don't reply in English": the request is negated. */
197const NEGATED = /(?:不要|別|别|勿|不)(?:再|要|會|会)?(?:用|使用|改用|以)?\s*$|\b(?:don't|do not|never|not)\s+(?:\w+\s+)?$/i
198
199const asks = (text: string, pattern: RegExp): boolean => {
200 for (const match of text.matchAll(pattern)) {
201 const at = match.index ?? 0
202 if (!NEGATED.test(text.slice(Math.max(0, at - 16), at))) return true
203 }
204 return false
205}
206
207/**
208 * Whether a prompt asks for its reply in a language other than the configured
209 * script (another language, or the other Chinese script), or for a
210 * translation into one. Such a turn is not checked, so a reply in what the
211 * person asked for is never rewritten. Asking for the configured script, or
212 * for plain Chinese, leaves the turn checked.
213 */
214export const asksForAnotherLanguage = (text: string, variant: Variant): boolean =>
215 asks(text, PATTERNS[variant].translate) || asks(text, PATTERNS[variant].replyIn)
216types/index.d.ts 24 lines1/** The last refused text per sender and recipient (`<sender>→<recipient>`), at most 20. */
2export type ZhReplyRefused = Readonly<Record<string, string>>
3
4declare module 'claude-code' {
5 interface PluginState {
6 'zh-reply': {
7 /** `/zh-reply off` turned it off for this session. */
8 isOff: boolean
9 /** Replies and messages caught in another language this session. */
10 caught: number
11 /** The turn this plugin's rewrite prompt started; its answer is not judged again. */
12 rewriteTurn: string | null
13 /** The person's last prompt asked for another language or script, or a translation. */
14 wantsOther: boolean
15 /** remind mode: remind the model with the person's next prompt. */
16 remindNext: boolean
17 /** A rewrite was asked for since the person's last prompt; no other is until they write again. */
18 awaitingPerson: boolean
19 /** Agent messages sent back once, so an identical resend goes through. */
20 refused: ZhReplyRefused
21 }
22 }
23}
24