SLOPSHOPPER

zh-reply

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

newcommandtoastpromptagents
v0.1.0MITupdated 2026-10-15PeterTing/claude-mod/mods/zh-reply
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · zh-reply
› fix the failing auth test and add an audit log call ╭────────────────────────────────╮ │ zh-reply │ ⏺ Read(src/auth.ts) │ zh-reply:回覆不是繁體中文,已請 Claude 重寫 │ ⎿ 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 › /zh-reply ⎿ zh-reply: 已開啟(繁體中文)。這個 session 攔到 1 次不是中文的回覆或訊息。用 /zh-reply off 關閉。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

zh-reply

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

What it does

WhereHow
The main conversation and in-process teammatesAdds 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 spawnsAdds 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 ChineseWhen 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.

What counts as Chinese

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.

When you ask for another language

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

CommandWhat it does
/zh-replyShows whether it is on and how many replies and messages it caught this session
/zh-reply offTurns it off for this session
/zh-reply onTurns it back on

Options

Set them with /plugin configure zh-reply@claude-mod in Claude Code, or claude plugin configure zh-reply@claude-mod from a shell.

OptionValuesDefault
variantzh-TW Traditional Chinese, zh-CN Simplified Chinesezh-TW
enforcerewrite asks Claude to say it again (one extra turn); remind shows a notice and reminds Claude with your next promptrewrite
scopeall the main conversation, agents and teammates; main the main conversation onlyall

Costs and limits

  • The rule adds roughly 140 tokens to the system prompt (about 600 characters). It sits after the cache boundary and does not change, so it is cached with the rest. /zh-reply off and on add or remove it, which makes the next request write the prompt cache again once.
  • Each spawned agent's task gets roughly 90 more tokens (one 90-character line).
  • A rewrite is one extra turn: Claude rereads the conversation, mostly from the prompt cache, and writes the reply again. There is at most one for each prompt you send. Use enforce: remind if you'd rather not spend that.
  • English prose outside a quote block or code block can trigger a rewrite, for example a commit message drafted in the reply, English test names under a one-line Chinese heading, or eight or more bare words such as commands left outside a code block. The rule asks Claude to put such passages in a code block.
  • Some prompts that only talk about an English version or English names ("把 README 英文版同步到最新", "use English variable names") are read as asking for English, so that one turn is not checked. The system-prompt rule still applies.
  • An agent's final report is held to the rule only by the line in its task. It is not checked or rewritten afterwards.
  • If one of its hooks fails, that step is skipped and Claude Code goes on as if the mod were not there.
  • Where an organization manages Claude Code, it may run only the plugins it lists. There this mod does nothing at all, or everything except the system-prompt rule.

Reach

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.

Develop

claude plugin validate mods/zh-reply
claude plugin test mods/zh-reply
claude --plugin-dir mods/zh-reply
Source 3 files
hooks/register.ts 209 lines
1import { 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}
209
hooks/detect.ts 216 lines
1export 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)
216
types/index.d.ts 24 lines
1/** 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