SLOPSHOPPER

translate-docs-th

แปลข้อความ ไฟล์ คอมเมนต์ และไฟล์ภาษา (i18n) ด้วย Claude และสร้าง README กับ API Docs จากโค้ด

newcommandmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · translate-docs-th
› 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 › /translate ⎿ translate-docs-th: 🌐 Translate & Docs TH (Mod 19) ⎿ translate-docs-th: ======================================== ⎿ translate-docs-th: แปล (ใช้ Claude): ⎿ translate-docs-th: /translate <ข้อความ> ไทย -> อังกฤษ, ภาษาอื่น -> ไทย ⎿ translate-docs-th: /translate to <ภาษา> <ข้อความ> เช่น /translate to ja สวัสดี ⎿ translate-docs-th: /translate file <ไฟล์> [ภาษา] แปลทั้งไฟล์ -> ไฟล์ใหม่ เช่น README.en.md ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Mod 19: Translate & Docs TH

ใช้ Claude แปลข้อความ ไฟล์ทั้งไฟล์ เฉพาะคอมเมนต์ในโค้ด (โค้ดไม่เปลี่ยน) และไฟล์ภาษา i18n แบบ JSON หลายภาษาในครั้งเดียว (ตรวจว่า key ครบเท่าต้นฉบับ) สร้าง API.md จาก export และ JSDoc ในโค้ด และให้ Claude ร่าง README จากโครงสร้างโปรเจกต์ ทุกคำสั่งจะสร้างเป็นไฟล์ใหม่ ไม่เขียนทับไฟล์เดิม

คำสั่ง

คำสั่งทำอะไร
/translate <ข้อความ>ไทย → อังกฤษ, ภาษาอื่น → ไทย
/translate to ja <ข้อความ>แปลเป็นภาษาที่เลือก
/translate file README.md enแปลทั้งไฟล์ → README.en.md
/translate comments src/app.ts enแปลเฉพาะคอมเมนต์ → src/app.en.ts
/translate i18n locales/th.json en jaแปลไฟล์ภาษา → locales/en.json, locales/ja.json
/docgen api [โฟลเดอร์]สร้าง API.md จาก export + JSDoc (ไม่ใช้ AI)
/docgen readmeให้ Claude ร่าง README.generated.md
/docgen usagetoken ที่ใช้ไปใน session นี้
`/translate live on\off`ล่ามสด: คำตอบภาษาอังกฤษของ Claude มีคำแปลไทยต่อท้ายให้อ่านอัตโนมัติ (ใช้ token เพิ่ม)

ภาษาที่รองรับ: th en ja zh ko vi lo my km ms id fr de es

ติดตั้ง

/plugin install translate-docs-th --marketplace tksoft11/claude-code-mods-th

ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)

โค้ดสอนอะไร

เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. เรียก model ด้วย $.model.complete และถ้า model ที่ขอใช้ไม่ได้ จะลองใหม่ด้วย model ของ session
  2. แปลไฟล์ JSON โดยแตะเฉพาะค่า แล้วตรวจ key ด้วยการแตก key ซ้อนให้แบน
  3. ดึง API จากโค้ดด้วย regex (export + JSDoc)
  4. คำสั่ง /translate ที่มี subcommand
  5. คำสั่ง /docgen

หมายเหตุ

  • การแปลและ /docgen readme เรียก model จริง (ค่าเริ่มต้นใช้ haiku) คิดค่าใช้จ่ายตามบัญชีของคุณ ทุกคำสั่งจะบอกจำนวน token ที่ใช้
  • ใช้ชื่อคำสั่ง /docgen ไม่ใช่ /docs เพราะชนกับคำสั่ง /docs ที่มีอยู่แล้วในบางเครื่อง
  • แปลคอมเมนต์แล้ว ถ้าจำนวนบรรทัดไม่เท่าต้นฉบับจะขึ้นเตือน ให้ตรวจด้วย diff ก่อนใช้
  • /docgen readme ร่างจาก package.json และรายชื่อไฟล์เท่านั้น ส่วนที่ไม่รู้จะใส่ TODO ไว้ ควรตรวจและเติมเองก่อนใช้
Source 1 files
hooks/register.tsx 351 lines
1/**
2 * ============================================================================
3 * MOD 19: Translate & Docs TH - แปลภาษาและทำเอกสารจากโค้ด (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: งานที่ Dev ไม่อยากทำเอง - แปลคอมเมนต์ ทำไฟล์ภาษา (i18n) เขียน README และ API Docs
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. /translate แปลข้อความ ด้วย Claude ($.model.complete) ไม่ใช่ดิกชันนารีคำต่อคำ
9 * 2. /translate file, /translate comments แปลทั้งไฟล์ หรือแปลเฉพาะคอมเมนต์ (โค้ดไม่เปลี่ยน)
10 * 3. /translate i18n แปลไฟล์ภาษา JSON หลายภาษา ตรวจว่า key ครบเท่าต้นฉบับ
11 * 4. /docgen api สร้าง API.md จาก export และ JSDoc ในโค้ด (ไม่ใช้ AI ได้ผลตรงตามโค้ด)
12 * 5. /docgen readme ให้ Claude ร่าง README จากโครงสร้างโปรเจกต์
13 * 6. /translate live on - ล่ามสด: คำตอบของ Claude ที่เป็นภาษาอังกฤษ แปลเป็นไทยให้อ่านใต้คำตอบอัตโนมัติ
14 *
15 * หลักการ: ไม่เขียนทับไฟล์เดิมเลย ผลลัพธ์เป็นไฟล์ใหม่เสมอ (เช่น app.en.ts, README.generated.md)
16 * ค่าใช้จ่าย: การแปลเรียก model จริง (คิดเงินตามบัญชีของคุณ) Mod จะบอกจำนวน token ทุกครั้ง
17 */
18
19import type { EngineInterface, FsEntry, Register } from 'claude-code'
20
21const LANGS: Record<string, string> = {
22  th: 'Thai', en: 'English', ja: 'Japanese', zh: 'Simplified Chinese', ko: 'Korean', vi: 'Vietnamese',
23  lo: 'Lao', my: 'Burmese', km: 'Khmer', ms: 'Malay', id: 'Indonesian', fr: 'French', de: 'German', es: 'Spanish',
24}
25const DEFAULT_MODEL = 'haiku' // ถูกและเร็ว เหมาะกับงานแปล
26
27let projectRoot = ''
28let tokensUsed = 0
29let live = false
30
31// ล่ามสด: แปลเฉพาะคำตอบที่เป็นภาษาอังกฤษเป็นหลัก และยาวพอจะคุ้มค่า token
32function mostlyEnglish(text: string): boolean {
33  const letters = text.replace(/```[\s\S]*?```/g, '').match(/[A-Za-z฀-๿]/g) ?? []
34  const latin = letters.filter(c => /[A-Za-z]/.test(c)).length
35  return letters.length >= 40 && latin / letters.length > 0.7
36}
37
38const sep = () => (projectRoot.includes('\\') ? '\\' : '/')
39const resolve = (p: string) => (/^([A-Za-z]:|[\\/])/.test(p) ? p : `${projectRoot}${sep()}${p.replace(/[\\/]/g, sep())}`)
40const rel = (p: string) => (p.toLowerCase().startsWith(projectRoot.toLowerCase()) ? p.slice(projectRoot.length + 1) : p)
41
42// ข้อความมีตัวอักษรไทย -> แปลเป็นอังกฤษ, ไม่มี -> แปลเป็นไทย
43function guessTarget(text: string): string {
44  return /[฀-๿]/.test(text) ? 'en' : 'th'
45}
46
47// app.ts + en -> app.en.ts
48function withLang(path: string, lang: string): string {
49  const dot = path.lastIndexOf('.')
50  return dot > path.lastIndexOf(sep()) ? `${path.slice(0, dot)}.${lang}${path.slice(dot)}` : `${path}.${lang}`
51}
52
53// ==========================================================================
54// บทที่ 1: เรียก Claude ด้วย $.model.complete (ไม่มีประวัติแชต เป็นคำขอเดี่ยวๆ)
55// ==========================================================================
56// ถ้า model ที่ขอใช้ไม่ได้ (เช่น องค์กรไม่อนุญาต) ลองใหม่ด้วย model ของ session
57async function ask($: EngineInterface, system: string, prompt: string, maxTokens = 4000): Promise<string> {
58  const models = [DEFAULT_MODEL, await $.session.model()]
59  let lastError = ''
60  for (const model of models) {
61    try {
62      const r = await $.model.complete({ model, system, prompt, maxTokens, timeoutMs: 120_000 })
63      tokensUsed += (r.usage?.input_tokens ?? 0) + (r.usage?.output_tokens ?? 0)
64      if (r.isAnswered) return r.text.trim()
65      lastError = r.reason
66    } catch (err) {
67      lastError = err instanceof Error ? err.message : String(err)
68    }
69  }
70  throw new Error(`เรียก model ไม่สำเร็จ (${lastError})`)
71}
72
73// ตัด ``` ที่ model อาจห่อมาให้ออก
74function unfence(text: string): string {
75  const m = /^```[\w-]*\n([\s\S]*?)\n```$/.exec(text.trim())
76  return m?.[1] ?? text
77}
78
79const TRANSLATOR = 'You are a professional translator for software teams. Translate faithfully and naturally. Keep code, identifiers, URLs, placeholders like {name} or %s, and Markdown formatting unchanged. Output only the translation, with no notes.'
80
81async function translateText($: EngineInterface, text: string, to: string, from?: string): Promise<string> {
82  const source = from ? `from ${LANGS[from] ?? from} ` : ''
83  return ask($, TRANSLATOR, `Translate ${source}into ${LANGS[to] ?? to}:\n\n${text}`)
84}
85
86// ==========================================================================
87// บทที่ 2: แปลไฟล์ i18n แบบ JSON - แปลเฉพาะค่า ไม่แตะ key แล้วตรวจว่า key ครบ
88// ==========================================================================
89function flatKeys(obj: unknown, prefix = ''): string[] {
90  if (obj === null || typeof obj !== 'object') return [prefix]
91  return Object.entries(obj as Record<string, unknown>).flatMap(([k, v]) => flatKeys(v, prefix ? `${prefix}.${k}` : k))
92}
93
94async function translateI18n($: EngineInterface, json: string, to: string): Promise<{ text: string; missing: string[] }> {
95  const source = JSON.parse(json) as unknown
96  const out = unfence(
97    await ask(
98      $,
99      `${TRANSLATOR} You receive a JSON object of UI strings. Translate only the string values. Keep every key, the nesting and placeholders exactly. Output valid JSON only.`,
100      `Target language: ${LANGS[to] ?? to}\n\n${json}`,
101      8000,
102    ),
103  )
104  const translated = JSON.parse(out) as unknown // ถ้า model ตอบ JSON ผิดรูปแบบ จะ throw ตรงนี้
105  const have = new Set(flatKeys(translated))
106  return { text: JSON.stringify(translated, null, 2) + '\n', missing: flatKeys(source).filter(k => !have.has(k)) }
107}
108
109// ==========================================================================
110// บทที่ 3: API Docs จากโค้ด - ใช้ regex หา export + JSDoc (ไม่ใช้ AI ได้ผลตรงตามโค้ด)
111// ==========================================================================
112interface ApiItem {
113  kind: string
114  name: string
115  signature: string
116  doc: string
117  file: string
118  line: number
119}
120
121function extractApi(file: string, text: string): ApiItem[] {
122  const items: ApiItem[] = []
123  const lines = text.split('\n')
124  lines.forEach((line, i) => {
125    const m = /^export\s+(?:default\s+)?(?:declare\s+)?(async\s+function|function|class|interface|type|const|enum)\s+([A-Za-z_$][\w$]*)/.exec(line)
126    if (!m) return
127    // JSDoc = คอมเมนต์ /** ... */ ที่อยู่เหนือบรรทัด export
128    let doc = ''
129    if (lines[i - 1]?.trim().endsWith('*/')) {
130      let j = i - 1
131      while (j > 0 && !lines[j]?.trim().startsWith('/**')) j--
132      doc = lines
133        .slice(j, i)
134        .map(l => l.trim().replace(/^\/\*\*|\*\/$|^\*\s?/g, '').trim())
135        .filter(Boolean)
136        .join(' ')
137    }
138    items.push({ kind: (m[1] ?? '').replace('async ', ''), name: m[2] ?? '', signature: line.trim().replace(/\s*[{=]\s*$/, '').slice(0, 160), doc, file, line: i + 1 })
139  })
140  return items
141}
142
143async function walk($: EngineInterface, root: string, match: RegExp, limit = 500): Promise<string[]> {
144  const files: string[] = []
145  const queue = [root]
146  while (queue.length && files.length < limit) {
147    const dir = queue.shift() ?? root
148    let entries: FsEntry[] = []
149    try {
150      entries = await $.fs.list(dir)
151    } catch {
152      continue
153    }
154    for (const e of entries) {
155      const path = `${dir}${sep()}${e.name}`
156      if (e.kind === 'dir' && !['node_modules', '.git', 'dist', 'build', '.next', 'coverage'].includes(e.name) && !e.name.startsWith('.')) queue.push(path)
157      else if (e.kind === 'file' && match.test(e.name) && !/\.(test|spec|d)\.[jt]sx?$/.test(e.name)) files.push(path)
158    }
159  }
160  return files
161}
162
163function apiMarkdown(items: ApiItem[]): string {
164  const byFile = new Map<string, ApiItem[]>()
165  for (const it of items) byFile.set(it.file, [...(byFile.get(it.file) ?? []), it])
166  const out = ['# API Reference', '', `สร้างอัตโนมัติจากโค้ด ${items.length} รายการ`, '']
167  for (const [file, list] of byFile) {
168    out.push(`## \`${file}\``, '')
169    for (const it of list) out.push(`### ${it.name} _(${it.kind})_`, '', '```ts', it.signature, '```', '', it.doc || '_ยังไม่มีคำอธิบาย (เพิ่ม JSDoc เหนือบรรทัด export)_', '')
170  }
171  return out.join('\n')
172}
173
174const HELP = `🌐 Translate & Docs TH (Mod 19)
175========================================
176แปล (ใช้ Claude):
177  /translate <ข้อความ>                      ไทย -> อังกฤษ, ภาษาอื่น -> ไทย
178  /translate to <ภาษา> <ข้อความ>             เช่น /translate to ja สวัสดี
179  /translate file <ไฟล์> [ภาษา]              แปลทั้งไฟล์ -> ไฟล์ใหม่ เช่น README.en.md
180  /translate comments <ไฟล์> [ภาษา]          แปลเฉพาะคอมเมนต์ โค้ดไม่เปลี่ยน -> app.en.ts
181  /translate i18n <ไฟล์ json> <ภาษา...>       เช่น /translate i18n locales/th.json en ja
182  ภาษา: ${Object.keys(LANGS).join(' ')}
183
184เอกสาร:
185  /docgen api [โฟลเดอร์]     สร้าง API.md จาก export + JSDoc (ไม่ใช้ AI)
186  /docgen readme             ให้ Claude ร่าง README.generated.md
187  /docgen usage              token ที่ใช้ไปใน session นี้
188
189ล่ามสด:
190  /translate live on|off     แปลคำตอบภาษาอังกฤษของ Claude เป็นไทยให้อ่านอัตโนมัติ (ใช้ token เพิ่ม)`
191
192export const register: Register = on => {
193  on('session.start', async ($, e, next) => {
194    projectRoot = e.cwd.replace(/[\\/]$/, '')
195    tokensUsed = 0
196    live = ((await $.store.get('live')) as boolean | undefined) ?? false
197    await $.command.register({ name: 'translate', description: 'แปลข้อความ ไฟล์ คอมเมนต์ และไฟล์ภาษาด้วย Claude (/translate help)', argumentHint: '<ข้อความ>|to|file|comments|i18n' })
198    await $.command.register({ name: 'docgen', description: 'สร้าง API Docs และ README จากโค้ด (/docgen help)', argumentHint: 'api|readme|usage' })
199    return next(e)
200  })
201
202  // ==========================================================================
203  // บทที่ 3.5: ล่ามสด - หลัง Claude ตอบเสร็จ แปลเป็นไทยแล้วเขียนลงแชต (โมเดลหลักไม่เห็น)
204  // ==========================================================================
205  on('turn.complete', async ($, e, next) => {
206    const r = await next(e)
207    if (!live || e.agentId || e.reason !== 'answer' || !mostlyEnglish(e.answer)) return r
208    // ไม่ await: ไม่ให้การแปลถ่วงรอบถัดไป
209    void translateText($, e.answer.slice(0, 12_000), 'th', 'en')
210      .then(th => {
211        try {
212          $.ui.log(`🌐 แปลไทย:\n${th}`)
213        } catch {}
214      })
215      .catch(() => undefined)
216    return r
217  })
218
219  // ==========================================================================
220  // บทที่ 4: คำสั่ง /translate
221  // ==========================================================================
222  on('command.run', { command: 'translate' }, async ($, e) => {
223    const args = e.args.trim()
224    const [sub = '', ...rest] = args.split(/\s+/)
225    const before = tokensUsed
226    const cost = () => `\n(ใช้ ${(tokensUsed - before).toLocaleString('en-US')} tokens)`
227
228    try {
229      if (!args || sub === 'help') return { text: HELP }
230
231      if (sub === 'live') {
232        if (rest[0] !== 'on' && rest[0] !== 'off') return { text: `ล่ามสด: ${live ? 'เปิด' : 'ปิด'} (/translate live on|off)` }
233        live = rest[0] === 'on'
234        await $.store.set('live', live)
235        return { text: live ? '🌐 เปิดล่ามสด: คำตอบภาษาอังกฤษของ Claude จะมีคำแปลไทยต่อท้าย (ใช้ token เพิ่มทุกคำตอบ)' : 'ปิดล่ามสดแล้ว' }
236      }
237
238      if (sub === 'to') {
239        const [lang = '', ...words] = rest
240        if (!LANGS[lang] || words.length === 0) return { text: `วิธีใช้: /translate to <${Object.keys(LANGS).join('|')}> <ข้อความ>` }
241        return { text: `🌐 → ${LANGS[lang]}\n${await translateText($, words.join(' '), lang)}${cost()}` }
242      }
243
244      if (sub === 'file' || sub === 'comments') {
245        const [file = '', langArg] = rest
246        const path = resolve(file)
247        const text = await $.fs.read(path).catch(() => undefined)
248        if (text === undefined) return { text: `❌ ไม่พบไฟล์ ${file}` }
249        if (text.length > 60_000) return { text: '❌ ไฟล์ยาวเกิน 60,000 ตัวอักษร แบ่งไฟล์ก่อนแปล' }
250        const lang = langArg && LANGS[langArg] ? langArg : guessTarget(text)
251        const result =
252          sub === 'file'
253            ? await ask($, TRANSLATOR, `Translate this whole file into ${LANGS[lang]}:\n\n${text}`, 16_000)
254            : await ask(
255                $,
256                `${TRANSLATOR} You receive a source code file. Translate ONLY the comments (and docstrings) into the target language. Every other character, including code, strings, whitespace and line breaks, must stay exactly the same. Output the full file only.`,
257                `Target language: ${LANGS[lang]}\n\n${text}`,
258                16_000,
259              )
260        const out = withLang(path, lang)
261        await $.fs.write(out, unfence(result).replace(/\n?$/, '\n'))
262        // นับบรรทัดโดยไม่สนบรรทัดว่างท้ายไฟล์ (model มักตัดออก)
263        const lineNote = sub === 'comments' && unfence(result).trimEnd().split('\n').length !== text.trimEnd().split('\n').length ?'\n⚠️ จำนวนบรรทัดไม่เท่าต้นฉบับ ตรวจด้วย diff ก่อนใช้' : ''
264        return { text: `✅ แปลแล้ว → ${rel(out)} (ไฟล์เดิมไม่ถูกแก้)${lineNote}${cost()}` }
265      }
266
267      if (sub === 'i18n') {
268        const [file = '', ...langs] = rest
269        const targets = langs.filter(l => LANGS[l])
270        if (!file || targets.length === 0) return { text: 'วิธีใช้: /translate i18n locales/th.json en ja' }
271        const path = resolve(file)
272        const json = await $.fs.read(path).catch(() => undefined)
273        if (json === undefined) return { text: `❌ ไม่พบไฟล์ ${file}` }
274        const dir = path.slice(0, path.lastIndexOf(sep()))
275        const lines: string[] = []
276        for (const lang of targets) {
277          const out = `${dir}${sep()}${lang}.json`
278          if (await $.fs.exists(out)) {
279            lines.push(`⏭️ ${rel(out)} มีอยู่แล้ว ข้าม (ลบหรือเปลี่ยนชื่อก่อนถ้าต้องการแปลใหม่)`)
280            continue
281          }
282          const { text, missing } = await translateI18n($, json, lang)
283          await $.fs.write(out, text)
284          lines.push(missing.length ? `⚠️ ${rel(out)} ขาด key: ${missing.slice(0, 10).join(', ')}` : `✅ ${rel(out)} key ครบ`)
285        }
286        return { text: [`🌐 แปลไฟล์ภาษา ${rel(path)}`, ...lines].join('\n') + cost() }
287      }
288
289      // ไม่มี subcommand = แปลข้อความทั้งหมดที่พิมพ์
290      const lang = guessTarget(args)
291      return { text: `🌐 → ${LANGS[lang]}\n${await translateText($, args, lang)}${cost()}` }
292    } catch (err) {
293      return { text: `❌ แปลไม่สำเร็จ: ${err instanceof Error ? err.message : String(err)}` }
294    }
295  })
296
297  // ==========================================================================
298  // บทที่ 5: คำสั่ง /docgen
299  // ==========================================================================
300  on('command.run', { command: 'docgen' }, async ($, e) => {
301    const [sub = 'help', arg] = e.args.trim().split(/\s+/).filter(Boolean)
302    const before = tokensUsed
303
304    if (sub === 'usage') return { text: `🧮 session นี้ Mod 19 ใช้ไป ${tokensUsed.toLocaleString('en-US')} tokens` }
305
306    if (sub === 'api') {
307      const root = arg ? resolve(arg) : projectRoot
308      // ข้ามไฟล์ที่ /translate สร้าง (เช่น cart.en.ts) จะได้ไม่ซ้ำกับต้นฉบับ
309      const translated = new RegExp(`\\.(${Object.keys(LANGS).join('|')})\\.[jt]sx?$|\\.(${Object.keys(LANGS).join('|')})\\.mjs$`)
310      const files = (await walk($, root, /\.(ts|tsx|js|jsx|mjs)$/)).filter(f => !translated.test(f))
311      const items: ApiItem[] = []
312      for (const f of files) items.push(...extractApi(rel(f).replace(/\\/g, '/'), await $.fs.read(f).catch(() => '')))
313      if (items.length === 0) return { text: `ไม่พบ export ใน ${rel(root) || '.'}` }
314      const out = `${projectRoot}${sep()}API.md`
315      await $.fs.write(out, apiMarkdown(items))
316      const undocumented = items.filter(i => !i.doc).length
317      return { text: `📚 สร้าง ${rel(out)} แล้ว: ${items.length} รายการจาก ${files.length} ไฟล์${undocumented ? ` (ยังไม่มีคำอธิบาย ${undocumented} รายการ)` : ''}` }
318    }
319
320    if (sub === 'readme') {
321      try {
322        const pkg = await $.fs.read(`${projectRoot}${sep()}package.json`).catch(() => '')
323        const files = (await walk($, projectRoot, /./, 200)).map(rel)
324        const existing = await $.fs.read(`${projectRoot}${sep()}README.md`).catch(() => '')
325        const draft = await ask(
326          $,
327          'You write clear, accurate README files. Use only facts present in the material given; where something is unknown, leave a TODO marker instead of inventing it. Write in Thai, keep commands and code in English.',
328          `Write a README.md for this project.\n\npackage.json:\n${pkg.slice(0, 4000) || '(none)'}\n\nFiles:\n${files.slice(0, 200).join('\n')}\n\nCurrent README (may be empty):\n${existing.slice(0, 4000)}`,
329          4000,
330        )
331        const out = `${projectRoot}${sep()}README.generated.md`
332        await $.fs.write(out, unfence(draft).replace(/\n?$/, '\n'))
333        return { text: `📝 ร่าง README ไว้ที่ ${rel(out)} (README.md เดิมไม่ถูกแก้) ตรวจก่อนใช้\n(ใช้ ${(tokensUsed - before).toLocaleString('en-US')} tokens)` }
334      } catch (err) {
335        return { text: `❌ สร้าง README ไม่สำเร็จ: ${err instanceof Error ? err.message : String(err)}` }
336      }
337    }
338
339    return { text: HELP }
340  })
341}
342
343/**
344 * ========================================================================
345 * การบ้าน / ต่อยอด
346 * ========================================================================
347 * 1. ใส่ cache: true ให้ system prompt ของ TRANSLATOR เมื่อแปลหลายไฟล์ติดกัน จะประหยัด token (ดู Mod 05)
348 * 2. แปลไฟล์ภาษาเฉพาะ key ที่ยังไม่มี แทนการแปลใหม่ทั้งไฟล์
349 * 3. ให้ /docgen api ใช้ AI ช่วยเขียนคำอธิบายเฉพาะรายการที่ยังไม่มี JSDoc
350 */
351