SLOPSHOPPER

narrator

Click ⏸︎ narrator off in the footer, or /narrator, and after each turn Claude tells you out loud what it did, in your language, with the best free voice your…

spinnerguardcommandtoastpromptmodel
★ 210v0.7.0MITupdated 2026-10-22midudev/better-claude/plugins/narrator
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · narrator
› 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 › /narrator ⎿ narrator: Narrator on: Claude will tell you out loud what it did after each turn. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ · narrator
README

Narrator

A Claude Code mod: click ⏸︎ narrator off at the bottom right of the prompt, or type /narrator, and after each turn Claude tells you out loud what it did, in your language, with the best free voice your system has.

❯ _
  ? for shortcuts                                   ⏵ narrator on

The switch reads like the footer's mode labels (⏵⏵ auto mode on), in quiet colors: ⏵ narrator on in green, ⏸︎ narrator off in gray. A click switches it (in fullscreen, where the terminal reports clicks); /narrator does it anywhere.

While it speaks the switch reads ⏹︎ narrating in blue, a lighter blue sweeping across it from left to right, and a click stops it, leaving the narrator on. When narrations of other windows wait their turn behind it, it reads ⏭︎ narrating, and a click skips to the next one.

With a language set (see Settings) the switch shows it after its words: ⏵ narrator on · ES, ⏸︎ narrator off · ES, ⏹︎ narrating · ES.

Where the footer is short the switch gives up on or off, then the language, then narrator (or narrating), and stays a ⏵, ⏸︎, ⏹︎ or ⏭︎ in its color, so the row never breaks in two. Beside Sub Meter, the meter shrinks first.

When a turn ends, Haiku (at low effort, so it is quick) reads your request, the tools Claude used and its final message, and writes one or two short spoken sentences in first person, said to you ("I added the narrator and the tests pass"), ending with Claude's question when it asked you one ("Do I open a PR?"), in the language you wrote in, or the one you set (see Settings). Haiku also names that language, and the mod picks the best voice installed for it; where there is none, the English one. When the request's words tell its language, Haiku is told to speak it, and a narration in another language is not said: the answer itself is read instead. A short answer already says it, so it is read out as it is, with no Haiku call: a path by its file's name (hooks/register.tsx: "register") and no commit hashes, in the language its words or your request tell, else the one the last narration was in (on Windows and Linux, Haiku narrates it while neither tells; macOS tells the language from the text). With a language set, a short answer is read as it is only when its words or your request tell it is in that language; any other, Haiku says in it. /recap is spoken too.

SystemVoice
macOSAVSpeechSynthesizer: Siri's natural voices first, then premium, enhanced and compact ones, in your region (say cannot reach Siri's)
WindowsWindows.Media.SpeechSynthesis, the OneCore voices Narrator uses; the desktop SAPI voices where WinRT does not load
WSLWindows' voices, through powershell.exe; Linux's where that does not run
Linuxspd-say (speech-dispatcher, with whatever module it is set up with: espeak-ng, RHVoice, Piper...), else espeak-ng or espeak

A new prompt cuts the narration short. It stays on in your next sessions until you switch it off. A session nobody is at (claude -p, as a script or a bot that runs Claude Code uses) is not narrated.

/narrator         switch on or off
/narrator on|off
/narrator stop    quiet now, stay on
/narrator again   say the last narration again
/narrator read    read the last answer in full
/narrator voices  list the installed voices, by language
/narrator test [voice]  say a short sample with a voice

/narrator again says the last narration again, as it was, with no model call. /narrator read reads Claude's last answer in full instead of its summary, as plain text (no code blocks, a path by its file's name), even with the narrator off; a long one takes a while, and /narrator stop, a click on the switch or a new prompt cuts it short.

/narrator voices lists the voices your system has, one line per language (es-ES: Eddy, Mónica); on macOS it leaves out the novelty ones (Bahh, Bells...). On Linux it shows speech-dispatcher's table, whose NAME column holds the voices of its output module, and eSpeak's.

/narrator test says a short English sample with the voice in /config (or, if it is empty, the one the system picks), and tells you which voice spoke. Pass a voice to try it without changing /config: /narrator test Microsoft Helena. A name several languages share (Eddy) can take its language, as /narrator voices shows it: /narrator test Eddy (es-MX). If the system has no voice by that name, the preview says so and names the one it used instead.

Better voices

  • macOS: System Settings → Accessibility → Spoken Content → System voice → Manage Voices, and download a Siri voice or a Premium one for your language.
  • Windows: Settings → Time & language → Speech → Add voices.
  • Linux: sudo apt install speech-dispatcher espeak-ng; for natural voices, set speech-dispatcher up with RHVoice or Piper.

Install

/plugin marketplace add midudev/better-claude
/plugin install narrator@better-claude

Settings

In /config:

  • Voice: a voice's name (Mónica, Microsoft Helena, or Eddy (es-MX) for one language's) to always speak with. Empty: the best one for the language. /narrator voices lists them and /narrator test tries one.
  • Language: a language code (es, en, pt) to always narrate in, whatever language you write in; a region is dropped (es-MX: es, the voice's region still comes from the system). It is checked against the runtime's language list: an unknown value (zz, a typo, any other text) is ignored, and the narrator goes back to automatic. The switch then shows it (⏵ narrator on · ES). Empty: the language you write in, and the switch shows none.
  • Speed: how fast it speaks, as a multiple of the voice's normal pace, from 0.5 to 2: 1.3 by default, a little quicker; 1 is the voice's own. The engine's voice, the last resort, keeps its own pace.
  • Read short answers as they are: an answer up to this many characters (200 by default) is read out as it is, with no Haiku call to retell it. 0: Haiku narrates every answer.
  • Model that writes the narration: haiku by default.
  • Speak command: a program of your own to say each narration with, in place of the system's voice, on macOS and Linux (WSL too). The shell runs it with the narration as its last argument, so my-tts --voice Mónica runs my-tts --voice Mónica "<narration>"; it can also read NARRATOR_TEXT and NARRATOR_LANG, the narration and its language. It waits its turn in the line like any voice, and a new prompt ends it. The Voice and Speed settings are the program's to read (NARRATOR_VOICE, NARRATOR_SPEED). If it fails, the engine's own voice says the narration. On Windows it is left aside. Empty: the system's voice.

Several windows

Every window shares one line, a folder in your temp directory: when two sessions finish together, their narrations are said one after the other, in the order they were ready. On Windows the line is Windows' own, and WSL's windows share it. One that would wait behind three others, or for two minutes, is dropped. A window that closes never holds the line. A new prompt, /narrator stop or switching it off cuts a narration short, also while it waits its turn; a click on ⏭︎ narrating cuts the one being said and lets the next speak.

How it works

One hooks module (hooks/register.tsx): a ui.render hook on the footer's SessionMode draws the switch as a Button, as wide as the terminal's width leaves room for beside the mode label and the hint (which a hook on PromptHint measures), turn.start and tool.call note the request and each tool call of the main loop, and turn.complete (and command.run on /recap) asks the model for the narration and hands it to a speaker (hooks/speaker.ts): JavaScript for Automation on macOS, PowerShell on Windows, a shell wrapper on Linux. Each writes a . while it speaks, so the mod can end it the moment you send a new prompt, and waiting <n> when the number of narrations waiting behind it changes, which the switch shows. Where none works, the engine's own $.audio.speak.

Development

claude plugin validate .   # from this folder
claude plugin test .       # runs hooks/register.test.tsx against the engine

MIT © Miguel Ángel Durán (midudev)

Source 4 files
hooks/register.tsx 725 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode, RenderViewport, Timer } from 'claude-code'
3
4import { LINUX, MACOS, MACOS_VOICES, QUEUE, WINDOWS, WINDOWS_VOICES, dispatcherRate, espeakWords, powershell } from './speaker'
5import { cut, finalQuestion, guessLanguage, parseNarration, plain, withQuestion } from './text'
6
7type Platform = 'macos' | 'windows' | 'linux' | 'other'
8/** A program that speaks, and what it is told through its environment. */
9type Command = { argv: string[]; env: Record<string, string>; program: string; isFallback?: boolean }
10/** How a speaker spoke: the voice it used, and whether that is not the one asked for (none matched); or that the line dropped it. */
11type Spoke = { voice: string; isFallback: boolean; isSkipped?: boolean }
12/**
13 * One narration: the model call that writes it, then the program that speaks it, until silenced;
14 * once that speaks, how many narrations of other windows wait their turn behind it.
15 */
16type Narration = { isActive: boolean; abort: AbortController; isSpeaking?: boolean; waiting?: number }
17/** The synthesizer found on Linux, the languages it has voices for and their names, lowercased (empty: it did not say). */
18type LinuxSpeech = { tool?: 'spd-say' | 'espeak-ng' | 'espeak'; languages: Set<string>; names: Set<string> }
19
20// The switch at the right of the prompt footer, in the style of the mode labels (⏵⏵ auto mode on),
21// each in a quiet color, with the words it gives up first where the row is short. While it speaks,
22// a click goes on to the next narration in the line, or, with none waiting, stops it. U+FE0E keeps
23// the pause, skip and stop signs text glyphs, never emojis.
24const SWITCH_ON = { icon: '\u23F5', words: ['narrator on', 'narrator'], color: 'success' } as const
25const SWITCH_OFF = { icon: '\u23F8\uFE0E', words: ['narrator off', 'narrator'], color: 'inactive' } as const
26const SWITCH_SKIP = { icon: '\u23ED\uFE0E', words: ['narrating'], color: 'permission' } as const
27const SWITCH_STOP = { icon: '\u23F9\uFE0E', words: ['narrating'], color: 'permission' } as const
28// While it speaks a lighter blue, the theme's own shimmer of it, sweeps the switch from left to
29// right: three cells wide, a cell a tenth of a second (the footer redraws ten times a second at
30// most), and a pause of four steps between sweeps.
31const SHIMMER = 'permissionShimmer'
32const SWEEP_MS = 100
33const SWEEP_WIDTH = 3
34const SWEEP_PAUSE = 4
35// The terminal footer's row: two columns of padding at each end, the mode label at the left
36// (`⏵⏵ accept edits on ·`, the longest but bypass's) and the hint after it, two columns at least
37// between that and what is drawn here.
38const FOOTER_PADDING = 4
39const FOOTER_GAP = 2
40const MODE_LABEL = 20
41const SEPARATOR = ' · '
42// Room left, when nothing of another plugin is beneath, for one that may wrap the switch and draw
43// before it: Sub Meter's shortest meter (`week 100%`) and its separator.
44const AROUND = 9 + SEPARATOR.length
45// A little quicker than the voice's normal pace, unless the settings say otherwise.
46const SPEED = 1.3
47const MAX_ACTIONS = 40
48const PREVIEW = 'Hello! This is a voice preview for Narrator.'
49// The tool inputs that say best what a call did, in order of preference.
50const DETAILS = ['description', 'file_path', 'notebook_path', 'path', 'pattern', 'url', 'query', 'skill', 'command', 'prompt']
51const LANGUAGE_CODE = /^[a-z]{2,3}(?:[-_][a-z0-9]{2,8})*$/i
52const DISPLAY_NAMES = new Intl.DisplayNames(['en'], { type: 'language', fallback: 'none' })
53// What /recap prints when it wrote no recap: nothing to say out loud.
54const NO_RECAP = /^(Nothing to recap yet\b|Recap cancelled\.|Couldn't generate a recap\.|\/recap only runs\b|API Error:)/
55
56/** The English name of a language code, or undefined where the runtime's language data does not know it. */
57function languageName (code: string) {
58  try {
59    return DISPLAY_NAMES.of(code)
60  } catch {
61    return undefined
62  }
63}
64
65/** The language the narrator is told to speak: the one set, else the one the user wrote in. */
66function spoken (language: string) {
67  if (!language) return "Speak the language the user wrote the request in; if that is unclear, the language of Claude's final message."
68  return `Always speak ${languageName(language) ?? language} (${language}), whatever language the request or Claude's final message is in.`
69}
70
71const NARRATOR = (language: string) => `You are the voice of Claude Code, a coding assistant. After each of its turns you speak to the user, out loud, as Claude: in the first person, to them. You are Claude talking to the person, not a narrator talking about them.
72
73You get the user's request, the actions Claude took and Claude's final message. Say what a colleague would say in passing: what was done and how it turned out, plus anything the user has to do or decide now. Leave out how it was done. If Claude only answered a question, give the gist of the answer.
74
75Talk to the user, never about them: address them in the second person ("you"), in the language you speak, never as "the user". And you are Claude: say "I", never "Claude" or "the assistant". When you are given Claude's closing question, it is read right after your words, as Claude wrote it: do not ask it, report it or mention it. Otherwise, if Claude's final message asks the user something, end with that question, asked to them directly as a question ("Do I run the quick test?"), not reported ("I ask whether to run it") and with no label before it.
76
77${spoken(language)}
78
79Rules:
80- A speech synthesizer reads it aloud: plain sentences only. No markdown, lists, emojis or code, and no file paths, URLs, hashes or version strings read out character by character: name files and commands the way a person says them ("the register file", "the tests").
81- Short: one sentence, two only for a large change; never more than 30 words, plus Claude's question when it asked one.
82- No preamble ("Sure", "In summary"), no question of your own, nothing Claude did not say or do.
83
84Reply in exactly this form: the first line holds only the ISO 639-1 code of the language you speak (es, en, pt, fr...), and the narration starts on the second line.`
85
86const enabled = atom({ plugin: 'narrator', key: 'isEnabled' } as const, false)
87const speech = atom({ plugin: 'narrator', key: 'speech' } as const, null)
88const sweepStep = atom({ plugin: 'narrator', key: 'sweepStep' } as const, 0)
89
90/** An answer this short is read out as it is: no model call to write a narration of it. */
91const SHORT_ANSWER = 200
92
93let settings = { voice: '', model: 'haiku', speed: SPEED, shortAnswer: SHORT_ANSWER, language: '', command: '' }
94// The main loop's turn so far: the prompt that started it and each tool call it made.
95let request = ''
96let actions: string[] = []
97let current: Narration | undefined
98let platform: Promise<Platform> | undefined
99let wsl: Promise<boolean> | undefined
100let linuxSpeech: Promise<LinuxSpeech> | undefined
101let hasWarned = false
102// The hint as last drawn on the terminal, in columns.
103let hintWidth = 0
104let lastHint = ''
105// The language the last narration was said in: a short answer whose words do not tell is read in it.
106let spokenLanguage = ''
107// The last narration said, for `/narrator again`, and the main loop's last answer with its request, for `/narrator read`.
108let lastSaid: { text: string; language: string } | undefined
109let lastAnswer: { answer: string; request: string } | undefined
110// The sweep across the switch, running while it speaks.
111let sweep: Timer | undefined
112let isNarratorEnabled = false
113let lastCellsText = ''
114let lastCells: string[] = []
115
116/**
117 * A language setting as its ISO 639 code (`es-MX` is `es`), checked against the language data the
118 * runtime ships (`Intl.DisplayNames`), so a typo or any other text is not a language: empty, automatic.
119 */
120export function languageOf (value: unknown) {
121  const code = String(value ?? '').trim()
122  const language = LANGUAGE_CODE.test(code) ? code.split(/[-_]/)[0]!.toLowerCase() : ''
123  return language && languageName(language) ? language : ''
124}
125
126const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
127
128/** One tool call as a line the narrator reads: its tool, then the input that says most. */
129function describe (e: object) {
130  const input = e as Record<string, unknown>
131  const detail = DETAILS.map(key => input[key]).find(value => typeof value === 'string' && value.trim() !== '')
132  return detail === undefined ? String(input.tool) : `${String(input.tool)}: ${cut(String(detail).replace(/\s+/g, ' '), 200)}`
133}
134
135/** What the narrator is told about the turn, and Claude's closing question, which is read after it as it is. */
136function brief (answer: string, question: string) {
137  const did = actions.length > 0 ? actions.map(action => `- ${action}`).join('\n') : '(no tools: Claude only answered)'
138  return [
139    `The user's request:\n<request>\n${cut(request, 2000) || '(none: Claude went on by itself)'}\n</request>`,
140    `What Claude did, in order:\n${did}`,
141    `Claude's final message to the user:\n<message>\n${cut(answer, 6000)}\n</message>`,
142    ...(question ? [`Claude's closing question, read right after your words as it is (do not ask, report or mention it):\n<question>\n${question}\n</question>`] : [])
143  ].join('\n\n')
144}
145
146/** The system the session runs on, asked once. */
147function platformOf ($: EngineInterface) {
148  platform ??= (async (): Promise<Platform> => {
149    if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
150    const name = (await $.process.run(['uname', '-s']).catch(() => undefined))?.stdout.trim()
151    return name === 'Darwin' ? 'macos' : name === 'Linux' ? 'linux' : 'other'
152  })()
153  return platform
154}
155
156/** Whether the session runs under WSL: Linux, on a Windows desktop whose voices are better. */
157function wslOf ($: EngineInterface) {
158  wsl ??= (async () => (await platformOf($)) === 'linux' && (await $.env.get('WSL_DISTRO_NAME')) !== undefined)()
159  return wsl
160}
161
162/** The language codes listed in a synthesizer's voice table, by their main part (`es` for es-419). */
163function languagesIn (table: string) {
164  const languages = new Set<string>()
165  for (const line of table.split('\n').slice(1)) {
166    const code = line.trim().split(/\s+/).slice(1).find(word => LANGUAGE_CODE.test(word))
167    if (code) languages.add(code.split(/[-_]/)[0]!.toLowerCase())
168  }
169  return languages
170}
171
172/** The voice names in `spd-say -L`'s table, lowercased: what comes before each row's language. */
173function namesIn (table: string) {
174  const names = new Set<string>()
175  for (const line of table.split('\n').slice(1)) {
176    const words = line.trim().split(/\s+/)
177    const at = words.findIndex((word, i) => i > 0 && LANGUAGE_CODE.test(word))
178    if (at > 0) names.add(words.slice(0, at).join(' ').toLowerCase())
179  }
180  return names
181}
182
183/** Which synthesizer Linux has, speech-dispatcher first, and its languages, asked once. */
184function linuxSpeechOf ($: EngineInterface) {
185  linuxSpeech ??= (async (): Promise<LinuxSpeech> => {
186    const found = (await $.process.run(['sh', '-c', 'command -v spd-say espeak-ng espeak']).catch(() => undefined))?.stdout ?? ''
187    const paths = found.split('\n').map(path => path.trim())
188    const tool = (['spd-say', 'espeak-ng', 'espeak'] as const).find(name => paths.some(path => path.endsWith(`/${name}`)))
189    if (!tool) return { languages: new Set(), names: new Set() }
190    const listed = await $.process.run(tool === 'spd-say' ? ['spd-say', '-L'] : [tool, '--voices'], { timeoutMs: 5000 }).catch(() => undefined)
191    const table = listed?.stdout ?? ''
192    return { tool, languages: languagesIn(table), names: tool === 'spd-say' ? namesIn(table) : new Set() }
193  })()
194  return linuxSpeech
195}
196
197/** A speaker that first waits its turn in the line every window shares (PowerShell waits in its own). */
198const inLine = (command: Command): Command => ({ ...command, argv: ['sh', '-c', QUEUE, 'narrator-line', ...command.argv] })
199
200/** The programs that can say `text` here, best first; `ticket`, when it was ready, is its place in the line. */
201async function speakersFor ($: EngineInterface, text: string, language: string, voice: string, ticket: string): Promise<Command[]> {
202  const env = { NARRATOR_TEXT: text, NARRATOR_LANG: language, NARRATOR_VOICE: voice, NARRATOR_SPEED: String(settings.speed), NARRATOR_TICKET: ticket }
203  const system = await platformOf($)
204  // A speak command of the person's own, run by the shell with the narration as its last argument.
205  if (settings.command && system !== 'windows') {
206    const argv = ['sh', '-c', LINUX, 'narrator', 'sh', '-c', `exec ${settings.command} "$1"`, 'narrator-command', text]
207    return [inLine({ argv, env: { ...env, NARRATOR_SPEAKER: settings.command }, program: settings.command })]
208  }
209  if (system === 'macos') return [inLine({ argv: ['osascript', '-l', 'JavaScript', '-e', MACOS], env, program: 'osascript' })]
210  if (system === 'windows') return [{ argv: powershell(WINDOWS), env, program: 'powershell' }]
211  if (system !== 'linux') return []
212
213  const speakers: Command[] = []
214  if (await wslOf($)) {
215    const inherited = await $.env.get('WSLENV')
216    const crossing = [inherited, 'NARRATOR_TEXT', 'NARRATOR_LANG', 'NARRATOR_VOICE', 'NARRATOR_SPEED', 'NARRATOR_TICKET'].filter(Boolean).join(':')
217    speakers.push({ argv: powershell(WINDOWS, 'powershell.exe'), env: { ...env, WSLENV: crossing }, program: 'powershell.exe' })
218  }
219  const { tool, languages, names } = await linuxSpeechOf($)
220  // A language the synthesizer has no voice for is read by its English voice.
221  const lang = language && (languages.size === 0 || languages.has(language) ? language : 'en')
222  // At speed 1 no rate is given: the synthesizer keeps the one it is set up with.
223  const isPaced = settings.speed !== 1
224  if (tool === 'spd-say') {
225    const options = [...(lang ? ['-l', lang] : []), ...(voice ? ['-y', voice] : []), ...(isPaced ? ['-r', String(dispatcherRate(settings.speed))] : [])]
226    // speech-dispatcher speaks a voice it does not have with its default one, and says nothing.
227    const isFallback = voice !== '' && names.size > 0 && !names.has(voice.toLowerCase())
228    speakers.push(inLine({ argv: ['sh', '-c', LINUX, 'narrator', 'spd-say', '-w', ...options, '--', text], env: { ...env, NARRATOR_STOP: 'spd-say -S' }, program: 'spd-say', isFallback }))
229  } else if (tool) {
230    const selectedVoice = voice || lang
231    const options = [...(selectedVoice ? ['-v', selectedVoice] : []), ...(isPaced ? ['-s', String(espeakWords(settings.speed))] : [])]
232    speakers.push(inLine({ argv: ['sh', '-c', LINUX, 'narrator', tool, ...options, '--', text], env, program: tool }))
233  }
234  return speakers
235}
236
237async function commandOutput ($: EngineInterface, argv: string[]) {
238  const result = await $.process.run(argv, { timeoutMs: 10_000 })
239  if (result.exitCode !== 0) throw new Error(result.stderr.trim() || `exit code ${result.exitCode}`)
240  return result.stdout.trim()
241}
242
243/** `name<TAB>language` rows as one line per language: `es-ES: Eddy, Mónica`. A voice installed in two qualities is listed once: its name picks the best. */
244function formatNamedVoices (output: string) {
245  const byLanguage = new Map<string, Set<string>>()
246  for (const line of output.split(/\r?\n/).map(row => row.trim()).filter(Boolean)) {
247    const [name = '', language = '?'] = line.split('\t')
248    byLanguage.set(language, (byLanguage.get(language) ?? new Set()).add(name))
249  }
250  return [...byLanguage].sort(([a], [b]) => a.localeCompare(b)).map(([language, names]) => `${language}: ${[...names].join(', ')}`)
251}
252
253/** Lists voices exposed by this system's speech synthesizers. */
254async function listVoices ($: EngineInterface) {
255  const system = await platformOf($)
256  if (system === 'macos') {
257    const output = await commandOutput($, ['osascript', '-l', 'JavaScript', '-e', MACOS_VOICES])
258    const voices = formatNamedVoices(output)
259    return voices.length ? `Installed voices:\n${voices.join('\n')}` : 'Narrator: no installed voices found.'
260  }
261  if (system === 'windows') {
262    const output = await commandOutput($, powershell(WINDOWS_VOICES))
263    const voices = formatNamedVoices(output)
264    return voices.length ? `Installed voices:\n${voices.join('\n')}` : 'Narrator: no installed voices found.'
265  }
266  if (system !== 'linux') return 'Narrator: voice discovery is not supported on this system.'
267
268  const sections: string[] = []
269  const failures: string[] = []
270  if (await wslOf($)) {
271    try {
272      const output = await commandOutput($, powershell(WINDOWS_VOICES, 'powershell.exe'))
273      const voices = formatNamedVoices(output)
274      if (voices.length) sections.push(`Windows voices:\n${voices.join('\n')}`)
275    } catch (error) {
276      failures.push(`Windows: ${message(error)}`)
277    }
278  }
279  const found = await commandOutput($, ['sh', '-c', 'for tool in spd-say espeak-ng espeak; do command -v "$tool" || true; done'])
280  const paths = found.split('\n').map(path => path.trim())
281  const has = (name: string) => paths.some(path => path.endsWith(`/${name}`))
282  if (has('spd-say')) {
283    try {
284      const output = await commandOutput($, ['spd-say', '-L'])
285      if (output) sections.push(`Speech Dispatcher voices (its output module's; pass a NAME to /narrator test):\n${output}`)
286    } catch (error) {
287      failures.push(`Speech Dispatcher: ${message(error)}`)
288    }
289  }
290  const espeak = has('espeak-ng') ? 'espeak-ng' : has('espeak') ? 'espeak' : undefined
291  if (espeak) {
292    try {
293      const output = await commandOutput($, [espeak, '--voices'])
294      if (output) sections.push(`${espeak} voices:\n${output}`)
295    } catch (error) {
296      failures.push(`${espeak}: ${message(error)}`)
297    }
298  }
299  if (!sections.length && failures.length) return `Narrator: could not list voices. ${failures.join('; ')}`
300  if (!sections.length) return 'Narrator: no installed voices found.'
301  if (failures.length) sections.push(`Some voices could not be listed: ${failures.join('; ')}`)
302  return sections.join('\n\n')
303}
304
305/** Shows on the switch whether this session's narration is speaking, and how many wait behind it; while it does, sweeps it. */
306async function showSpeech ($: EngineInterface) {
307  const speaking = current?.isSpeaking ? { waiting: current.waiting ?? 0 } : null
308  await update($, speech, () => speaking)
309  if (speaking && !sweep) sweep = $.clock.every(SWEEP_MS, () => void update($, sweepStep, step => step + 1))
310  if (!speaking && sweep) {
311    sweep.cancel()
312    sweep = undefined
313    await update($, sweepStep, () => 0)
314  }
315}
316
317/** Follows what a speaker says of itself: `voice` once it speaks, `waiting <n>` while others wait their turn. */
318async function follow ($: EngineInterface, narration: Narration, said: string) {
319  const isSpeaking = narration.isSpeaking === true || /^voice /m.test(said)
320  const told = [...said.matchAll(/^waiting (\d+)\r?$/gm)].at(-1)?.[1]
321  const waiting = told === undefined ? narration.waiting ?? 0 : Number(told)
322  if (isSpeaking === (narration.isSpeaking === true) && waiting === (narration.waiting ?? 0)) return
323  narration.isSpeaking = isSpeaking
324  narration.waiting = waiting
325  await showSpeech($)
326}
327
328/** Runs one speaker till it ends or the narration is silenced; how it spoke, if it did. */
329async function play ($: EngineInterface, narration: Narration, command: Command): Promise<Spoke | undefined> {
330  let said = ''
331  let failure = ''
332  try {
333    for await (const { text } of $.process.spawn(command)) {
334      said = (said + text.replace(/^\.\r?$/gm, '')).slice(-4000)
335      if (!narration.isActive) break
336      await follow($, narration, said)
337    }
338  } catch (error) {
339    failure = message(error)
340  } finally {
341    if (narration.isSpeaking) {
342      narration.isSpeaking = false
343      await showSpeech($)
344    }
345  }
346  const voice = /^voice (.+?)\r?$/m.exec(said)?.[1]
347  // Behind others for too long: the line dropped it, and nothing else should say it now.
348  if (/^skipped\r?$/m.test(said)) {
349    $.ui.log('narrator: not said, the line of narrations from other sessions was too long', { to: 'debug' })
350    return { voice: '', isFallback: false, isSkipped: true }
351  }
352  failure ||= /^error.*$/m.exec(said)?.[0] ?? (voice ? '' : `${command.program} said nothing`)
353  // Under WSL the Windows side outlives its end of the pipe: end it by its id.
354  const pid = /^pid (\d+)/m.exec(said)?.[1]
355  if (!narration.isActive && pid && command.program === 'powershell.exe') {
356    await $.process.run(['taskkill.exe', '/PID', pid, '/T', '/F']).catch(() => undefined)
357  }
358  if (!narration.isActive) return undefined
359  $.ui.log(failure ? `narrator: ${command.program}: ${failure}` : `narrator: spoke with ${voice}`, { to: 'debug' })
360  return failure === '' ? { voice: voice!, isFallback: command.isFallback === true || /^no voice /m.test(said) } : undefined
361}
362
363/** Says `text` with the best speaker that works here, the engine's own synthesizer last; how, if it did. */
364async function speak ($: EngineInterface, narration: Narration, text: string, language: string, voice = settings.voice): Promise<Spoke | undefined> {
365  // Every window shares one line, so two sessions that finish together are said one after the other.
366  const ticket = String(await $.clock.now()).padStart(13, '0')
367  for (const command of await speakersFor($, text, language, voice, ticket)) {
368    const spoke = await play($, narration, command)
369    if (spoke || !narration.isActive) return spoke
370  }
371  try {
372    await $.audio.speak(text, voice ? { voice } : undefined)
373    return { voice: voice || "Claude Code's own voice", isFallback: false }
374  } catch (error) {
375    $.ui.log(`narrator: ${message(error)}`, { to: 'debug' })
376    if (hasWarned) return undefined
377    hasWarned = true
378    const system = await platformOf($)
379    $.ui.toast(system === 'linux'
380      ? 'Narrator: no voice to speak with. Install speech-dispatcher (spd-say) or espeak-ng.'
381      : `Narrator: no voice to speak with (${message(error)}).`)
382    return undefined
383  }
384}
385
386/** Says a short sample with `voice` (empty: the one picked for English), and what came of it. */
387async function testVoice ($: EngineInterface, voice: string) {
388  silence()
389  const narration: Narration = { isActive: true, abort: new AbortController() }
390  current = narration
391  try {
392    const spoke = await speak($, narration, PREVIEW, 'en', voice)
393    if (!narration.isActive) return 'Narrator: preview stopped.'
394    if (!spoke) return 'Narrator: could not play the voice preview.'
395    if (spoke.isSkipped) return 'Narrator: not previewed, other sessions are speaking. Try again in a moment.'
396    if (voice && spoke.isFallback) return `Narrator: there is no voice "${voice}", so the preview used ${spoke.voice}. /narrator voices lists the installed ones.`
397    return `Narrator: previewed ${voice || spoke.voice}.`
398  } catch (error) {
399    $.ui.log(`narrator: voice preview: ${message(error)}`, { to: 'debug' })
400    return `Narrator: could not play the voice preview. ${message(error)}`
401  } finally {
402    narration.isActive = false
403    if (current === narration) current = undefined
404  }
405}
406
407/** Stops what is being said, and the narration being written. */
408function silence () {
409  if (!current) return
410  current.isActive = false
411  current.abort.abort()
412  current = undefined
413}
414
415/** Tells, out loud, what the turn that ended with `answer` did. */
416async function narrate ($: EngineInterface, answer: string) {
417  silence()
418  const narration: Narration = { isActive: true, abort: new AbortController() }
419  current = narration
420  try {
421    // A short answer already says it: read it out, with no model call to retell it, once its language
422    // is known (macOS tells it from the text). With a language set, only when its words or the request
423    // tell it is in that language: any other, the model says in it.
424    const direct = plain(answer)
425    const ownLanguage = guessLanguage(answer) || guessLanguage(`${request}\n${answer}`)
426    const directLanguage = ownLanguage || spokenLanguage
427    const isReadable = settings.language ? ownLanguage === settings.language : directLanguage !== '' || (await platformOf($)) === 'macos'
428    if (direct !== '' && direct.length <= settings.shortAnswer && isReadable) {
429      spokenLanguage = directLanguage
430      lastSaid = { text: direct, language: directLanguage }
431      await speak($, narration, direct, directLanguage)
432      return
433    }
434    // The language to speak: the setting, else the request's when its words tell it. The model is told
435    // it in so many words: left to tell it from a request in one language and tool calls in another, it
436    // sometimes narrates in the wrong one.
437    const asked = settings.language || guessLanguage(request)
438    // Claude's closing question is said as Claude wrote it, after the narration: the model, asked to
439    // pass it on, sometimes reports it ("I ask whether to do it") or drops it. Only when the answer is in
440    // the language to speak: a question in another one is left to the model to ask in it.
441    const answerLanguage = guessLanguage(answer)
442    const question = asked !== '' && answerLanguage !== '' && answerLanguage !== asked ? '' : finalQuestion(answer)
443    const reply = await $.model.complete(
444      // A short summary: no deep thinking, so it is said right after the turn.
445      { model: settings.model, system: NARRATOR(asked), prompt: brief(answer, question), maxTokens: 150, effort: 'low', timeoutMs: 30_000 },
446      { signal: narration.abort.signal }
447    ).catch(() => undefined)
448    if (!narration.isActive) return
449    const told = reply?.isAnswered ? parseNarration(reply.text) : undefined
450    // A narration in another language than the request's is not said, whatever code the model wrote:
451    // its words tell. The setting is left to the model, as the answer may be in neither.
452    const toldLanguage = told ? guessLanguage(told.text) || told.language : ''
453    const isOtherLanguage = !settings.language && asked !== '' && toldLanguage !== '' && toldLanguage !== asked
454    if (isOtherLanguage) $.ui.log(`narrator: the model narrated in ${toldLanguage}, not ${asked}: reading the answer instead`, { to: 'debug' })
455    // No narration written, or not in the request's language: the answer itself, as far as it reads aloud.
456    const { text, language } = told?.text && !isOtherLanguage ? told : { text: cut(direct, 250), language: directLanguage }
457    const said = withQuestion(text, question)
458    if (!said) return
459    // The voice follows the words, not the code the model wrote: a narration in English is never read
460    // by a Spanish voice.
461    const saidLanguage = guessLanguage(said) || language
462    if (saidLanguage) spokenLanguage = saidLanguage
463    lastSaid = { text: said, language: saidLanguage }
464    await speak($, narration, said, saidLanguage)
465  } catch (error) {
466    $.ui.log(`narrator: ${message(error)}`, { to: 'debug' })
467  } finally {
468    if (current === narration) current = undefined
469  }
470}
471
472/** Says `text` again, as it is, in place of whatever is being said: the last narration, or an answer read in full. */
473async function retell ($: EngineInterface, text: string, language: string) {
474  silence()
475  const narration: Narration = { isActive: true, abort: new AbortController() }
476  current = narration
477  try {
478    await speak($, narration, text, language)
479  } catch (error) {
480    $.ui.log(`narrator: ${message(error)}`, { to: 'debug' })
481  } finally {
482    if (current === narration) current = undefined
483  }
484}
485
486/** `/narrator again`: the last narration, said again with no model call. */
487function sayAgain ($: EngineInterface) {
488  if (!lastSaid) return 'Narrator: nothing said yet.'
489  void retell($, lastSaid.text, lastSaid.language)
490  return 'Narrator: saying it again.'
491}
492
493/** `/narrator read`: the last answer in full, as plain text, in the language its words or its request tell. */
494function readAnswer ($: EngineInterface) {
495  if (!lastAnswer) return 'Narrator: no answer to read yet.'
496  const { answer, request: asked } = lastAnswer
497  const text = plain(answer)
498  if (!text) return 'Narrator: the last answer has nothing to read aloud.'
499  void retell($, text, guessLanguage(answer) || guessLanguage(`${asked}\n${answer}`) || spokenLanguage || settings.language)
500  return 'Narrator: reading the last answer.'
501}
502
503/** A click on the switch: while it speaks, on to the next narration in the line or quiet; else on or off. */
504function press ($: EngineInterface) {
505  if (current?.isSpeaking) return silence()
506  void setEnabled($)
507}
508
509/** Switches the narrator on or off (no `value`: the other way), and keeps it for the next sessions. */
510async function setEnabled ($: EngineInterface, value?: boolean) {
511  let isEnabled = false
512  await update($, enabled, now => (isEnabled = value ?? !now))
513  isNarratorEnabled = isEnabled
514  if (!isEnabled) silence()
515  await $.store.set('enabled', isEnabled)
516  return isEnabled
517}
518
519/** Whether a drawing beneath shows nothing: no text, no control (the engine's own node draws `modes`, counted apart). */
520function isBlank (node: RenderNode | undefined): boolean {
521  if (node === undefined || node === null) return true
522  if (typeof node === 'string') return node.trim() === ''
523  if (node.type === 'engine') return true
524  return (node.type === 'Box' || node.type === 'Text') && (node.children ?? []).every(isBlank)
525}
526
527/** Columns a text takes: one per character, none for a variation selector. */
528const widthOf = (text: string) => [...text.replace(/[\uFE0E\uFE0F]/g, '')].length
529
530/** A text's cells: one per character, a variation selector kept with the one before it. */
531function cellsOf (text: string) {
532  if (text === lastCellsText) return lastCells
533  const cells: string[] = []
534  for (const char of text) {
535    if (/[\uFE0E\uFE0F]/.test(char) && cells.length > 0) cells[cells.length - 1] += char
536    else cells.push(char)
537  }
538  lastCellsText = text
539  lastCells = cells
540  return cells
541}
542
543/** A label cut where the sweep is at `step`: before it, under it (empty between sweeps) and after it. */
544function sweptAt (label: string, step: number) {
545  const cells = cellsOf(label)
546  const at = (step % (cells.length + SWEEP_WIDTH + SWEEP_PAUSE)) - SWEEP_WIDTH
547  const start = Math.min(Math.max(at, 0), cells.length)
548  const end = Math.min(Math.max(at + SWEEP_WIDTH, 0), cells.length)
549  return [cells.slice(0, start), cells.slice(start, end), cells.slice(end)].map(part => part.join(''))
550}
551
552/** Columns a drawing beneath takes on one row; the engine's own node draws `modes`, joined by ` & `. */
553function widthOfNode (node: RenderNode | undefined, modes: readonly string[]): number {
554  if (node === undefined || node === null) return 0
555  if (typeof node === 'string') return widthOf(node)
556  if (node.type === 'engine') return widthOf(modes.join(' & '))
557  return (node.children ?? []).reduce((sum, child) => sum + widthOfNode(child, modes), 0)
558}
559
560/** Whether a drawing beneath holds another plugin's: the engine stamps each hook's root with its plugin. */
561function holdsPlugin (node: RenderNode | undefined): boolean {
562  if (node === undefined || node === null || typeof node === 'string' || node.type === 'engine') return false
563  return 'part' in node || (node.children ?? []).some(holdsPlugin)
564}
565
566/** Columns the footer leaves at its right end on the terminal, beside the mode label and the hint; elsewhere, no limit. */
567function roomOf (e: { surface: string; viewport?: RenderViewport }) {
568  if (e.surface !== 'terminal' || e.viewport === undefined) return Infinity
569  return e.viewport.columns - FOOTER_PADDING - FOOTER_GAP - MODE_LABEL - (hintWidth > 0 ? 1 + hintWidth : 0)
570}
571
572export const register: Register = (on, options) => {
573  // Whether someone is at the session. Until session.start says otherwise (a mod loaded in the middle
574  // of a session does not see it), someone is.
575  let isInteractive = true
576  isNarratorEnabled = false
577  lastHint = ''
578  const speed = Number(options.speed)
579  const shortAnswer = Number(options.shortAnswer ?? SHORT_ANSWER)
580  settings = {
581    voice: String(options.voice ?? '').trim(),
582    model: String(options.model || 'haiku'),
583    speed: speed > 0 ? speed : SPEED,
584    shortAnswer: shortAnswer >= 0 ? shortAnswer : SHORT_ANSWER,
585    language: languageOf(options.language),
586    command: String(options.command ?? '').trim()
587  }
588
589  on('session.start', async ($, e, next) => {
590    // A session nobody is at (`claude -p`: a script, a bot that runs Claude Code) is not narrated.
591    isInteractive = e.isInteractive
592    const asked = String(options.language ?? '').trim()
593    if (asked && !settings.language) $.ui.log(`narrator: "${cut(asked, 40)}" is not a language code (es, en, pt...): speaking the language you write in`, { to: 'debug' })
594    await $.command.register({
595      name: 'narrator',
596      description: 'Claude tells you out loud what it did after each turn · no argument: switch on or off',
597      argumentHint: '[on | off | stop | again | read | voices | test [voice]]',
598      immediate: true
599    })
600    const isEnabled = (await $.store.get('enabled')) === true
601    isNarratorEnabled = isEnabled
602    await update($, enabled, () => isEnabled)
603    return next(e)
604  })
605
606  on('session.end', ($, e, next) => {
607    silence()
608    return next(e)
609  })
610
611  on('command.run', { command: 'narrator' }, async ($, e) => {
612    const arg = e.args.trim().toLowerCase()
613    if (arg === 'voices') {
614      try {
615        return { text: await listVoices($) }
616      } catch (error) {
617        return { text: `Narrator: could not list voices. ${message(error)}` }
618      }
619    }
620    const test = /^test(?:\s+([\s\S]+))?$/i.exec(e.args.trim())
621    if (test) {
622      const named = (test[1] ?? '').trim().replace(/^(['"])(.*)\1$/, '$2').trim()
623      return { text: await testVoice($, named || settings.voice) }
624    }
625    if (arg === 'stop') {
626      silence()
627      return { text: 'Narrator: quiet.' }
628    }
629    if (arg === 'again') return { text: sayAgain($) }
630    if (arg === 'read') return { text: readAnswer($) }
631    if (arg !== '' && arg !== 'on' && arg !== 'off') return { text: 'Usage: /narrator [on | off | stop | again | read | voices | test [voice]]' }
632    const isEnabled = await setEnabled($, arg === '' ? undefined : arg === 'on')
633    return { text: isEnabled ? 'Narrator on: Claude will tell you out loud what it did after each turn.' : 'Narrator off.' }
634  })
635
636  // A new prompt means the person has moved on: stop talking.
637  on('prompt.submit', ($, e, next) => {
638    silence()
639    return next(e)
640  }).catch(($, e, next) => next(e))
641
642  on('turn.start', ($, e, next) => {
643    if (e.agentId === undefined) {
644      if (e.text) request = e.text
645      actions = []
646    }
647    return next(e)
648  })
649
650  on('tool.call', async ($, e, next) => {
651    const result = await next(e)
652    if (isInteractive && isNarratorEnabled && e.agentId === undefined && actions.length < MAX_ACTIONS) {
653      actions.push(describe(e) + (result.deny !== undefined ? ' (denied)' : result.isError ? ' (failed)' : ''))
654    }
655    return result
656  }).catch(($, e, next) => next(e))
657
658  on('turn.complete', async ($, e, next) => {
659    const result = await next(e)
660    // Kept on or off, so `/narrator read` can read it whenever it is asked.
661    if (e.agentId === undefined && e.reason === 'answer' && e.answer.trim()) lastAnswer = { answer: e.answer, request }
662    if (isInteractive && isNarratorEnabled && e.agentId === undefined && e.reason === 'answer' && e.answer.trim()) void narrate($, e.answer)
663    return result
664  })
665
666  on('command.run', { command: 'recap' }, async ($, e, next) => {
667    silence()
668    const result = await next(e)
669    if (isInteractive && isNarratorEnabled && result.text?.trim() && !NO_RECAP.test(result.text)) {
670      actions = []
671      void narrate($, result.text)
672    }
673    return result
674  }).catch(($, e, next) => next(e))
675
676  // The switch, last in the footer's right end: a click turns the narrator on or off, and while it
677  // speaks, skips to the next narration in the line or stops it.
678  // A language that is set shows after the words (`⏵ narrator on · ES`); automatic shows none.
679  // Where the row is short it gives up "on" or "off", then the language, then "narrator" (or
680  // "narrating"): its sign and color still tell.
681  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
682    const [isEnabled, speaking] = await Promise.all([read($, enabled), read($, speech)])
683    const step = speaking ? await read($, sweepStep) : 0
684    // Beneath: the engine's mode labels, or another plugin's drawing there.
685    const beneath = await next(e)
686    const hasBeneath = e.props.modes.length > 0 || !isBlank(beneath)
687    const { Box, Button, Text } = $.ui.resolve(e)
688    const { icon, words, color } = speaking ? (speaking.waiting > 0 ? SWITCH_SKIP : SWITCH_STOP) : isEnabled ? SWITCH_ON : SWITCH_OFF
689    const room = roomOf(e) -
690      (hasBeneath ? widthOfNode(beneath, e.props.modes) + SEPARATOR.length : 0) -
691      (holdsPlugin(beneath) ? 0 : AROUND)
692    const tag = settings.language ? `${SEPARATOR}${settings.language.toUpperCase()}` : ''
693    const labels = [...words.map(word => `${icon} ${word}${tag}`), `${icon} ${words.at(-1)}`]
694    const label = labels.find(text => widthOf(text) <= room) ?? icon
695    const [before, under, after] = speaking ? sweptAt(label, step) : [label, '', '']
696
697    return (
698      <Box flexDirection="row">
699        {hasBeneath ? beneath : null}
700        {hasBeneath ? <Text dimColor>{SEPARATOR}</Text> : null}
701        <Button key="narrator" plain onPress={() => press($)}>
702          <Text color={color}>
703            {before || null}
704            {under ? <Text color={SHIMMER}>{under}</Text> : null}
705            {after || null}
706          </Text>
707        </Button>
708      </Box>
709    )
710  })
711
712  // The hint line beside the mode label: its width tells the switch how much room is left.
713  on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
714    if (e.surface === 'terminal' && e.props.hint !== lastHint) {
715      lastHint = e.props.hint
716      const width = widthOf(e.props.hint)
717      if (width !== hintWidth) {
718        hintWidth = width
719        $.ui.invalidate('ui.render')
720      }
721    }
722    return next(e)
723  })
724}
725
hooks/speaker.ts 422 lines
1// The programs that speak a narration: one a system, each the best free voice that system has. Each
2// reads NARRATOR_TEXT, what to say; NARRATOR_LANG, its language as an ISO 639-1 code (empty: the
3// program tells, or uses its default); and NARRATOR_VOICE, a voice's name (empty: the best one for
4// the language, English's when the language has none; `Name (es-ES)` picks a name's voice in one
5// language); NARRATOR_SPEED, how fast (1: the voice's normal pace); and NARRATOR_TICKET, when it was
6// ready, its place in the line every window's narrations wait in (see QUEUE). Each writes
7// `no voice <name>` when it has no voice by that name, `voice <name>` once it speaks, or a line
8// starting `error`, then a `.` every tenth of a second while it speaks, so the one reading it is
9// woken to end it, and `waiting <n>` each time the number of narrations waiting behind it in the
10// line changes; while it waits in the line, a `.` every 0.3 s, and `skipped` if the line drops it.
11
12/**
13 * macOS: JavaScript for Automation, run by `osascript`, with AVSpeechSynthesizer, which reaches the
14 * Siri voices (`say` does not). An empty NARRATOR_LANG is told from the text.
15 */
16export const MACOS = String.raw`
17ObjC.import('AVFoundation')
18ObjC.import('NaturalLanguage')
19
20function run () {
21  const env = $.NSProcessInfo.processInfo.environment
22  const read = key => { const value = env.objectForKey(key); return value.isNil() ? '' : ObjC.unwrap(value) }
23  const say = text => $.NSFileHandle.fileHandleWithStandardOutput.writeData($(text + '\n').dataUsingEncoding($.NSUTF8StringEncoding))
24  const text = read('NARRATOR_TEXT')
25  const wanted = read('NARRATOR_VOICE').toLowerCase()
26  const speed = Number(read('NARRATOR_SPEED')) || 1
27  if (!text) return
28
29  const found = $.NLLanguageRecognizer.dominantLanguageForString(text)
30  const language = read('NARRATOR_LANG').toLowerCase().split('-')[0] || (found.isNil() ? 'en' : ObjC.unwrap(found))
31
32  const all = $.AVSpeechSynthesisVoice.speechVoices
33  const voices = []
34  for (let i = 0; i < all.count; i++) {
35    const voice = all.objectAtIndex(i)
36    voices.push({ voice, id: ObjC.unwrap(voice.identifier), name: ObjC.unwrap(voice.name), lang: ObjC.unwrap(voice.language), quality: Number(voice.quality) })
37  }
38
39  // The regions to prefer for a language: the person's own (es-MX before es-ES), then the usual one.
40  const regionsFor = language => {
41    const regions = []
42    const preferred = $.NSLocale.preferredLanguages
43    for (let i = 0; i < preferred.count; i++) {
44      const [lang, region] = ObjC.unwrap(preferred.objectAtIndex(i)).split('-')
45      if (lang === language && region) regions.push(region)
46    }
47    const country = $.NSLocale.currentLocale.countryCode
48    if (!country.isNil()) regions.push(ObjC.unwrap(country))
49    regions.push({ de: 'DE', en: 'US', es: 'ES', fr: 'FR', it: 'IT', ja: 'JP', pt: 'BR', zh: 'CN' }[language] || '')
50    return regions
51  }
52  // Siri's natural voices first, then premium over enhanced over compact, then the preferred region.
53  // The novelty voices (Bahh, Grandpa...) come last.
54  const best = language => {
55    const regions = regionsFor(language)
56    const near = v => { const at = regions.indexOf(v.lang.split('-')[1]); return at < 0 ? 0 : 50 - at * 10 }
57    const score = v =>
58      (v.id.includes('.siri.') ? 1000 : 0) + v.quality * 100 + near(v) - (v.id.includes('.super-compact.') ? 1 : 0) -
59      (/\.(speech\.synthesis|eloquence)\./.test(v.id) ? 10000 : 0)
60    return voices.filter(v => v.lang.split('-')[0] === language).sort((a, b) => score(b) - score(a))[0]
61  }
62  // A name several languages share (Eddy) reads in the narration's language, else its best voice.
63  const isNamed = v => [v.id, v.name, v.name + ' (' + v.lang + ')'].some(name => name.toLowerCase() === wanted)
64  const inLanguage = v => (v.lang.split('-')[0] === language ? 1 : 0)
65  const named = wanted && voices.filter(isNamed).sort((a, b) => inLanguage(b) - inLanguage(a) || b.quality - a.quality)[0]
66  if (wanted && !named) say('no voice ' + read('NARRATOR_VOICE'))
67  const picked = named || best(language) || best('en')
68
69  const synth = $.AVSpeechSynthesizer.alloc.init
70  const utterance = $.AVSpeechUtterance.speechUtteranceWithString(text)
71  if (picked) utterance.voice = picked.voice
72  // The voice's normal pace is a rate of 0.5; measured, each 0.1 above it speaks 0.6 times faster and
73  // each 0.1 below 0.088 slower, the same for Siri's voices and the compact ones.
74  utterance.rate = Math.min(Math.max(speed >= 1 ? 0.5 + (speed - 1) / 6 : 0.5 - (1 - speed) / 0.88, 0), 1)
75  say('voice ' + (picked ? picked.id : 'default'))
76  synth.speakUtterance(utterance)
77
78  const loop = $.NSRunLoop.currentRunLoop
79  let started = false
80  for (let i = 0; i < 6000; i++) {
81    loop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.1))
82    say('.')
83    if (synth.speaking) started = true
84    else if (started || i > 30) break
85  }
86  if (!started) say('error: the synthesizer did not speak')
87}
88`
89
90/** The voices AVSpeechSynthesizer can use, with their language codes, but the novelty ones (Bahh, Bells...). */
91export const MACOS_VOICES = String.raw`
92ObjC.import('AVFoundation')
93const all = $.AVSpeechSynthesisVoice.speechVoices
94const rows = []
95for (let i = 0; i < all.count; i++) {
96  const voice = all.objectAtIndex(i)
97  if (ObjC.unwrap(voice.identifier).includes('.speech.synthesis.')) continue
98  rows.push(ObjC.unwrap(voice.name) + '\t' + ObjC.unwrap(voice.language))
99}
100rows.sort()
101$.NSFileHandle.fileHandleWithStandardOutput.writeData($(rows.join('\n')).dataUsingEncoding($.NSUTF8StringEncoding))
102`
103
104/**
105 * Windows (and WSL, through `powershell.exe`): the OneCore voices of Windows.Media.SpeechSynthesis,
106 * the ones Narrator uses, played from memory; where WinRT cannot load, the desktop SAPI voices of
107 * System.Speech. It also writes `pid <n>`, so WSL, whose end of the pipe cannot end it, can. It
108 * waits in a line of its own, Windows', which the windows of WSL share.
109 */
110export const WINDOWS = String.raw`
111$ErrorActionPreference = 'Stop'
112$text = $env:NARRATOR_TEXT
113$language = "$env:NARRATOR_LANG".ToLower().Split('-')[0]
114$wanted = "$env:NARRATOR_VOICE"
115# Read with a dot whatever the region's decimal mark.
116$speed = 0.0
117$null = [double]::TryParse("$env:NARRATOR_SPEED", [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$speed)
118if ($speed -le 0) { $speed = 1.0 }
119if (-not $text) { exit 0 }
120$region = (Get-Culture).Name
121function Say($line) { [Console]::Out.WriteLine($line); [Console]::Out.Flush() }
122Say "pid $PID"
123
124# Among voices given as Name, Language and Item: the one named (or listed as "Name (es-ES)"); else
125# the language's, the person's region first; else English's, en-US first.
126function Pick($voices) {
127  if ($wanted) {
128    $named = @($voices | Where-Object { "$($_.Name) ($($_.Language))" -eq $wanted -or $_.Name -eq $wanted -or $_.Name -like "*$wanted*" })
129    if ($named.Count -gt 0) { return $named[0] }
130    Say "no voice $wanted"
131  }
132  foreach ($lang in @($language, 'en')) {
133    if (-not $lang) { continue }
134    $matching = @($voices | Where-Object { $_.Language.ToLower().Split('-')[0] -eq $lang })
135    if ($matching.Count -eq 0) { continue }
136    $preferred = if ($lang -eq $language) { $region } else { 'en-US' }
137    $near = @($matching | Where-Object { $_.Language -eq $preferred })
138    if ($near.Count -gt 0) { return $near[0] }
139    return $matching[0]
140  }
141  return $null
142}
143
144$script:isSpeaking = $false
145
146function Speak-OneCore {
147  Add-Type -AssemblyName System.Runtime.WindowsRuntime
148  $null = [Windows.Media.SpeechSynthesis.SpeechSynthesizer, Windows.Media.SpeechSynthesis, ContentType = WindowsRuntime]
149  $synth = New-Object Windows.Media.SpeechSynthesis.SpeechSynthesizer
150  $voices = @([Windows.Media.SpeechSynthesis.SpeechSynthesizer]::AllVoices | ForEach-Object { [pscustomobject]@{ Name = $_.DisplayName; Language = $_.Language; Item = $_ } })
151  $picked = Pick $voices
152  if ($picked) { $synth.Voice = $picked.Item }
153  # A multiple of the voice's pace, from 0.5 to 6 (Windows 10 1803 on).
154  if ($synth.Options) { $synth.Options.SpeakingRate = [Math]::Min([Math]::Max($speed, 0.5), 6) }
155  $asTask = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -like 'IAsyncOperation*' } | Select-Object -First 1
156  $task = $asTask.MakeGenericMethod([Windows.Media.SpeechSynthesis.SpeechSynthesisStream]).Invoke($null, @($synth.SynthesizeTextToStreamAsync($text)))
157  $null = $task.Wait(-1)
158  $wav = New-Object System.IO.MemoryStream
159  [System.IO.WindowsRuntimeStreamExtensions]::AsStreamForRead($task.Result.GetInputStreamAt(0)).CopyTo($wav)
160  $bytes = $wav.ToArray()
161  # How long it plays: the data after the 44-byte header, at the header's bytes per second.
162  $seconds = ($bytes.Length - 44) / [Math]::Max([BitConverter]::ToInt32($bytes, 28), 1)
163  $wav.Position = 0
164  $player = New-Object System.Media.SoundPlayer($wav)
165  $player.Load()
166  $name = if ($picked) { $picked.Name } else { $synth.Voice.DisplayName }
167  $script:isSpeaking = $true
168  Say "voice $name"
169  $player.Play()
170  $end = (Get-Date).AddSeconds($seconds + 0.3)
171  while ((Get-Date) -lt $end) { Start-Sleep -Milliseconds 100; Tick }
172}
173
174function Speak-Sapi {
175  Add-Type -AssemblyName System.Speech
176  $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
177  $voices = @($synth.GetInstalledVoices() | Where-Object { $_.Enabled } | ForEach-Object { [pscustomobject]@{ Name = $_.VoiceInfo.Name; Language = $_.VoiceInfo.Culture.Name; Item = $_ } })
178  $picked = Pick $voices
179  if ($picked) { $synth.SelectVoice($picked.Name) }
180  # From -10 to 10, each end about three times slower or faster than the voice's pace.
181  $synth.Rate = [int][Math]::Max(-10, [Math]::Min(10, [Math]::Round(10 * [Math]::Log($speed) / [Math]::Log(3))))
182  $script:isSpeaking = $true
183  Say "voice $($synth.Voice.Name)"
184  $spoken = $synth.SpeakAsync($text)
185  while (-not $spoken.IsCompleted) { Start-Sleep -Milliseconds 100; Tick }
186}
187
188# The line every window waits in, as QUEUE's on macOS and Linux: a ticket (NARRATOR_TICKET, then
189# this process's id) in a folder of the user's own, the oldest live one first, and a mutex for the
190# one that speaks, which Windows lets go of when its process ends. False: dropped, not to be said.
191$script:ticket = $null
192$script:seat = $null
193$script:line = $null
194function Enter-Line {
195  if ("$env:NARRATOR_TICKET" -notmatch '^\d+$') { return $true }
196  $dir = Join-Path ([IO.Path]::GetTempPath()) 'claude-narrator-queue'
197  $mine = "$($env:NARRATOR_TICKET)-$PID"
198  $script:line = $dir
199  try {
200    $null = New-Item -ItemType Directory -Force -Path $dir
201    $script:ticket = Join-Path $dir $mine
202    $null = New-Item -ItemType File -Force -Path $script:ticket
203    $script:seat = New-Object System.Threading.Mutex($false, 'Local\claude-narrator-line')
204  } catch { return $true }
205  $start = Get-Date
206  while ($true) {
207    $ahead = 0
208    try {
209      foreach ($file in @(Get-ChildItem -LiteralPath $dir -File | Sort-Object Name)) {
210        if ($file.Name -eq $mine) { break }
211        $owner = $null
212        if ($file.Name -match '^\d+-(\d+)$') { $owner = Get-Process -Id ([int]$Matches[1]) -ErrorAction SilentlyContinue }
213        # Gone: not a ticket, its process has ended, or one that started after it was written took its id.
214        $isGone = -not $owner
215        if (-not $isGone) { try { $isGone = $owner.StartTime -gt $file.CreationTime } catch { $isGone = $true } }
216        if ($isGone) { Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue; continue }
217        $ahead++
218      }
219    } catch { return $true }
220    if ($ahead -eq 0) {
221      try { if ($script:seat.WaitOne(0)) { return $true } } catch [System.Threading.AbandonedMutexException] { return $true }
222    }
223    if ($ahead -ge 3 -or ((Get-Date) - $start).TotalSeconds -ge 120) { Exit-Line; return $false }
224    Say '.'
225    Start-Sleep -Milliseconds 300
226  }
227}
228function Exit-Line {
229  if ($script:seat) { try { $script:seat.ReleaseMutex() } catch {} }
230  if ($script:ticket) { Remove-Item -LiteralPath $script:ticket -Force -ErrorAction SilentlyContinue }
231}
232
233# A dot while it speaks, every tenth of a second; every third, how many narrations wait their turn,
234# each time that changes.
235$script:ticks = 0
236$script:told = 0
237function Tick {
238  Say '.'
239  $script:ticks++
240  if (-not $script:ticket -or $script:ticks % 3) { return }
241  $mine = Split-Path -Leaf $script:ticket
242  $waiting = 0
243  try {
244    foreach ($file in @(Get-ChildItem -LiteralPath $script:line -File)) {
245      if ($file.Name -eq $mine -or $file.Name -notmatch '^\d+-(\d+)$') { continue }
246      $owner = Get-Process -Id ([int]$Matches[1]) -ErrorAction SilentlyContinue
247      try { if ($owner -and $owner.StartTime -le $file.CreationTime) { $waiting++ } } catch {}
248    }
249  } catch { return }
250  if ($waiting -ne $script:told) { Say "waiting $waiting"; $script:told = $waiting }
251}
252
253if (-not (Enter-Line)) { Say 'skipped'; exit 0 }
254try {
255  try {
256    Speak-OneCore
257  } catch {
258    if ($script:isSpeaking) { exit 0 }
259    try { Speak-Sapi } catch { if (-not $script:isSpeaking) { Say "error: $($_.Exception.Message)" } }
260  }
261} finally {
262  Exit-Line
263}
264`
265
266/** Lists the Windows voices Narrator can select, including desktop SAPI voices where available. */
267export const WINDOWS_VOICES = String.raw`
268$ErrorActionPreference = 'Stop'
269$voices = @()
270# WinRT and SAPI are independent catalogs; list whichever APIs are available.
271try {
272  Add-Type -AssemblyName System.Runtime.WindowsRuntime
273  $null = [Windows.Media.SpeechSynthesis.SpeechSynthesizer, Windows.Media.SpeechSynthesis, ContentType = WindowsRuntime]
274  $voices += [Windows.Media.SpeechSynthesis.SpeechSynthesizer]::AllVoices | ForEach-Object { [pscustomobject]@{ Name = $_.DisplayName; Language = $_.Language } }
275} catch {}
276try {
277  Add-Type -AssemblyName System.Speech
278  $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
279  $voices += $synth.GetInstalledVoices() | Where-Object { $_.Enabled } | ForEach-Object { [pscustomobject]@{ Name = $_.VoiceInfo.Name; Language = $_.VoiceInfo.Culture.Name } }
280} catch {}
281$voices | Sort-Object Language, Name -Unique | ForEach-Object { $_.Name + [char]9 + $_.Language }
282`
283
284/**
285 * Linux: runs the synthesizer given as its arguments (spd-say, which speaks with whatever voice
286 * speech-dispatcher is set up with, or espeak-ng), writing a `.` while it runs. Ended, or once
287 * nobody reads, it ends the synthesizer and runs NARRATOR_STOP, which silences speech-dispatcher.
288 * It also runs a speak command of the person's own, on macOS too: NARRATOR_SPEAKER then names it.
289 */
290export const LINUX = String.raw`
291"$@" &
292pid=$!
293stop () {
294  pkill -TERM -P "$pid" 2>/dev/null
295  kill "$pid" 2>/dev/null
296  if [ -n "$NARRATOR_STOP" ]; then $NARRATOR_STOP >/dev/null 2>&1; fi
297  exit 0
298}
299trap stop TERM INT HUP PIPE
300speaker=$NARRATOR_SPEAKER
301[ -n "$speaker" ] || speaker=$1
302echo "voice $speaker"
303while kill -0 "$pid" 2>/dev/null; do
304  echo . 2>/dev/null || stop
305  sleep 0.1
306done
307wait "$pid" || echo "error: $speaker failed"
308`
309
310const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
311
312/**
313 * speech-dispatcher's rate for a speed, from -100 to 100, as its espeak-ng module reads it: 0 is 170
314 * words a minute, -100 is 80 and 100 is 449.
315 */
316export const dispatcherRate = (speed: number) => Math.round(clamp(((speed - 1) * 170 * 100) / (speed >= 1 ? 279 : 90), -100, 100))
317
318/** espeak's words a minute for a speed: 175 at its normal pace, within the 80 to 450 it takes. */
319export const espeakWords = (speed: number) => Math.round(clamp(175 * speed, 80, 450))
320
321const encodedCache = new Map<string, string>()
322
323/** A PowerShell script as `-EncodedCommand` takes it: UTF-16LE, base64. */
324export function encodedCommand (script: string) {
325  const cached = encodedCache.get(script)
326  if (cached !== undefined) return cached
327  const bytes = new Uint8Array(script.length * 2)
328  for (let i = 0; i < script.length; i++) {
329    const code = script.charCodeAt(i)
330    bytes[i * 2] = code & 0xff
331    bytes[i * 2 + 1] = code >> 8
332  }
333  const encoded = bytes.toBase64()
334  encodedCache.set(script, encoded)
335  return encoded
336}
337
338/** How PowerShell runs one of these scripts: no profile, no prompts, no script policy in the way. */
339export const powershell = (script: string, program = 'powershell') => [program, '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodedCommand(script)]
340
341/**
342 * The line every speaker waits in on macOS and Linux, shared by every window: it runs the speaker
343 * given as its arguments once its turn comes. Each narration takes a ticket (NARRATOR_TICKET, then
344 * this shell's id) in a folder of the user's own, and speaks once its ticket is the oldest of the
345 * live ones and it holds the seat, a link no two can make at once, so one speaks at a time even when
346 * a ticket came in late. A ticket whose process is gone, or older than a quarter of an hour (its id
347 * may have been reused), is thrown away, so a window that was closed never holds the line; the shell
348 * takes its ticket and the seat away as the speech ends, or as it is ended. A narration that would
349 * wait behind three others, or for two minutes, is not said: it prints `skipped`. While its speaker
350 * speaks it prints `waiting <n>` each time the number of narrations waiting their turn changes.
351 */
352export const QUEUE = String.raw`
353case $NARRATOR_TICKET in '' | *[!0-9]*) exec "$@" ;; esac
354dir=$TMPDIR
355[ -n "$dir" ] || dir=/tmp
356dir="$dir/claude-narrator-queue-$(id -u)"
357here=$PWD
358mkdir -p "$dir" 2>/dev/null && cd "$dir" 2>/dev/null || exec "$@"
359mine=$NARRATOR_TICKET-$$
360: > "$mine" 2>/dev/null || { cd "$here"; exec "$@"; }
361pid=
362leave () {
363  if [ -n "$pid" ]; then kill "$pid" 2>/dev/null; fi
364  rm -f "$dir/$mine"
365  if [ "$(readlink "$dir/seat" 2>/dev/null)" = "$$" ]; then rm -f "$dir/seat"; fi
366  exit 0
367}
368trap leave TERM INT HUP PIPE
369# A ticket's two parts: the time it was ready, and its process.
370parts () { IFS=-; set -- $1; IFS=' '; born=$1; owner=$2; }
371start=$(date +%s)
372while :; do
373  now=$(date +%s)
374  ahead=0
375  for f in *-*; do
376    [ -e "$f" ] || continue
377    [ "$f" = "$mine" ] && break
378    # Gone: not a ticket, its process has ended, or older than any speech lasts (a reused process id).
379    case $f in *[!0-9-]* | -* | *- | *-*-*) rm -f "$f"; continue ;; esac
380    parts "$f"
381    if ! kill -0 "$owner" 2>/dev/null || [ $((now - born / 1000)) -gt 900 ]; then rm -f "$f"; continue; fi
382    ahead=$((ahead + 1))
383  done
384  # First in line, it takes the seat: one speaks at a time, even when a ticket came in late.
385  if [ "$ahead" -eq 0 ]; then
386    holder=$(readlink seat 2>/dev/null)
387    if [ -n "$holder" ] && ! kill -0 "$holder" 2>/dev/null; then rm -f seat; fi
388    ln -s "$$" seat 2>/dev/null && break
389  fi
390  if [ "$ahead" -ge 3 ] || [ $((now - start)) -ge 120 ]; then
391    rm -f "$mine"
392    echo skipped
393    exit 0
394  fi
395  echo . 2>/dev/null || leave
396  sleep 0.3
397done
398cd "$here" 2>/dev/null
399"$@" &
400pid=$!
401# While it speaks, how many narrations wait their turn, each time that changes.
402cd "$dir" 2>/dev/null || { wait "$pid"; pid=; leave; }
403told=0
404while kill -0 "$pid" 2>/dev/null; do
405  now=$(date +%s)
406  waiting=0
407  for f in *-*; do
408    case $f in "$mine" | *[!0-9-]* | -* | *- | *-*-*) continue ;; esac
409    parts "$f"
410    if kill -0 "$owner" 2>/dev/null && [ $((now - born / 1000)) -le 900 ]; then waiting=$((waiting + 1)); fi
411  done
412  if [ "$waiting" -ne "$told" ]; then
413    echo "waiting $waiting" 2>/dev/null || leave
414    told=$waiting
415  fi
416  sleep 0.3
417done
418wait "$pid"
419pid=
420leave
421`
422
hooks/text.ts 127 lines
1// What the narrator says, as text a synthesizer reads well, and the language it is in.
2
3// The first line of the narrator's reply: the language's code, maybe quoted or bracketed, after a
4// label ("Language: es") or beside a stray number ("1. es").
5const LANGUAGE_LINE = /^[^\p{L}\n]*(?:(?:language|lang|idioma)[^\p{L}\n]+)?([a-z]{2,3})(?:[-_][a-z0-9]{2,8})?[^\p{L}\n]*\n([\s\S]*)$/iu
6// A first line with no letters at all ("1", "--"): no narration, and never to be read out.
7const BARE_LINE = /^[^\p{L}\n]*\n([\s\S]*)$/u
8// The code before a colon, on the narration's own line ("es: He añadido…").
9const INLINE_CODE = /^[^\p{L}\n]*([a-z]{2,3})(?:[-_][a-z0-9]{2,8})?\s*:\s+(\S[\s\S]*)$/iu
10const DISPLAY_NAMES = new Intl.DisplayNames(['en'], { type: 'language', fallback: 'none' })
11// Common words, to tell the language of a text no model narrated.
12const WORDS: Record<string, Set<string>> = {
13  en: new Set(['the', 'and', 'is', 'to', 'of', 'it', 'you', 'that', 'with', 'for', 'this', 'are']),
14  es: new Set(['el', 'la', 'que', 'de', 'y', 'en', 'los', 'las', 'es', 'para', 'por', 'una', 'del', 'he']),
15  pt: new Set(['o', 'que', 'não', 'em', 'um', 'uma', 'os', 'você', 'está', 'do', 'da', 'foi']),
16  fr: new Set(['le', 'la', 'les', 'et', 'est', 'des', 'une', 'pour', 'que', 'dans', 'vous', 'pas']),
17  de: new Set(['der', 'die', 'das', 'und', 'ist', 'nicht', 'ich', 'mit', 'für', 'sie', 'den', 'ein']),
18  it: new Set(['il', 'che', 'di', 'è', 'per', 'una', 'con', 'non', 'sono', 'gli', 'della', 'ho'])
19}
20
21// A commit's hash, which a synthesizer reads letter by letter: hex digits, a number and a letter among them.
22const HASH = /\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,40}\b/g
23const EXTENSION = /(.)(?:\.[a-z][a-z0-9]{0,4})+$/i
24
25/** How a person names a file or folder: by its last part, without the extension (`hooks/register.tsx`: register). */
26const fileName = (path: string) => (path.split(/[\\/]/).filter(Boolean).pop() ?? '').replace(EXTENSION, '$1')
27
28/** Whether a word with a slash in it is a path, not `and/or` or a date: from the root, home or here, or with an extension or two slashes, its last part a name. */
29const isPath = (word: string) =>
30  /[a-z]/i.test(fileName(word)) && (/^(?:~|\.{1,2})?\//.test(word) || EXTENSION.test(word) || word.split('/').length > 2)
31
32/** `text` cut to `size` characters, at a sentence's end when there is one in its second half. */
33export function cut (text: string, size: number) {
34  if (text.length <= size) return text
35  const head = text.slice(0, size)
36  const end = Math.max(head.lastIndexOf('. '), head.lastIndexOf('! '), head.lastIndexOf('? '))
37  return end > size / 2 ? head.slice(0, end + 1) : `${head.trimEnd()}…`
38}
39
40/**
41 * Markdown as a synthesizer should read it: no code blocks, marks, links' addresses or bullets, a
42 * path said by its file's name, and no hashes.
43 */
44export function plain (markdown: string) {
45  return markdown
46    .replace(/```[\s\S]*?(```|$)/g, ' ')
47    // Code that is a path or a file is said by its name; any other, as it is.
48    .replace(/`([^`]*)`/g, (code, inside: string) => (/^[^\s/\\]*([/\\][^\s/\\]*)+$|^[\w-]+\.[a-z][a-z0-9]{0,4}$/i.test(inside) ? fileName(inside) : inside))
49    .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
50    .replace(/https?:\/\/\S+/g, ' ')
51    .replace(/^\s{0,3}(#{1,6}|[-*+•]|\d+[.)]|>)\s+/gm, '')
52    .replace(/[\w.@~-]*\/[\w.@~/-]*[\w@~/-]/g, word => (isPath(word) ? fileName(word) : word))
53    .replace(HASH, ' ')
54    .replace(/\(\s*\)/g, ' ')
55    .replace(/[*_~|#>]+/g, ' ')
56    .replace(/\s+/g, ' ')
57    .replace(/ ([.,;:!??؟])/g, '$1')
58    .replace(/^[\s-]+/, '')
59    .trim()
60}
61
62/** The language of `text` by its most common words, or '' when too few say. */
63export function guessLanguage (text: string) {
64  const words = text.toLowerCase().match(/\p{L}+/gu) ?? []
65  let best = ''
66  let most = 2
67  for (const [language, common] of Object.entries(WORDS)) {
68    let count = 0
69    for (const word of words) if (common.has(word)) count++
70    if (count > most) [best, most] = [language, count]
71  }
72  return best
73}
74
75/** The sentences of a text: each ends at a full stop, an exclamation or a question mark. */
76const sentences = (text: string) => text.split(/(?<=[.!?…؟])\s+|(?<=[。!?])\s*/).filter(Boolean)
77const isQuestion = (sentence: string) => /[??؟]$/.test(sentence)
78
79/**
80 * Claude's closing question to the user, as it reads aloud ("¿Lo hago?", "Want me to open a PR?"): the
81 * questions that end its last line. Empty when the answer does not end with one, or with a long one.
82 */
83export function finalQuestion (answer: string) {
84  const last = plain(answer.trim().split('\n').filter(line => line.trim()).pop() ?? '')
85  const asked: string[] = []
86  for (const sentence of sentences(last).reverse()) {
87    if (!isQuestion(sentence)) break
88    asked.unshift(sentence)
89  }
90  const question = asked.join(' ')
91  return question.length <= 200 ? question : ''
92}
93
94/** The narration, then Claude's closing question as Claude wrote it: once, in place of any question the narration ends with. */
95export function withQuestion (narration: string, question: string) {
96  if (!narration || !question || narration.includes(question)) return narration
97  const cleaned = narration.replace(/(?:[;:]\s*[^.!?;:]*|,\s*¿[^?]*)[??؟]$/, '.')
98  const said = sentences(cleaned)
99  while (said.length > 0 && isQuestion(said.at(-1)!)) said.pop()
100  return said.length > 0 ? `${said.join(' ')} ${question}` : question
101}
102
103/** Whether `code` names a language the runtime knows ("es", "en"), not any two letters ("ok"). */
104function isLanguageCode (code: string) {
105  try {
106    return DISPLAY_NAMES.of(code.toLowerCase()) !== undefined
107  } catch {
108    return false
109  }
110}
111
112/**
113 * The narrator's reply as its language and what to say. The code line is never said: also when the
114 * model writes it in another shape (a label, a stray number, after a colon), or writes a line with no
115 * letters in its place. A reply without the code is guessed.
116 */
117export function parseNarration (reply: string) {
118  const trimmed = reply.trim()
119  const found = LANGUAGE_LINE.exec(trimmed + '\n')
120  if (found && isLanguageCode(found[1]!)) return { language: found[1]!.toLowerCase(), text: plain(found[2]!) }
121  const inline = INLINE_CODE.exec(trimmed)
122  if (inline && isLanguageCode(inline[1]!)) return { language: inline[1]!.toLowerCase(), text: plain(inline[2]!) }
123  const bare = BARE_LINE.exec(trimmed + '\n')
124  const text = plain(bare ? bare[1]! : trimmed)
125  return { language: guessLanguage(text), text }
126}
127
types/index.d.ts 13 lines
1declare module 'claude-code' {
2  interface PluginState {
3    narrator: {
4      /** Whether Claude tells, out loud, what it did after each turn. */
5      isEnabled: boolean
6      /** What this session is saying, null when it is not: `waiting`, how many narrations wait their turn behind it. */
7      speech: { waiting: number } | null
8      /** How far the sweep across the switch has gone while it speaks, a step a tenth of a second. */
9      sweepStep: number
10    }
11  }
12}
13