@agfpd/voicetalk-runtime 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,11 +61,17 @@ runs the same router text-only, with no microphone at all. Configuration details
61
61
  - **Modality framing for a naive agent.** An agent has no senses — only envelope text. voicetalk marks the inbound envelope so a peer with no knowledge of the system answers in the right modality ("spoken, live — reply by voice, briefly").
62
62
  - **Voice stays out of the core.** Speech-to-text and text-to-speech live in the shared `voice-connect` service (multi-provider, one place to configure), not inside this runtime and not inside the iapeer foundation.
63
63
  - **Conversational turn-taking.** A wake-word only opens a conversation; after a reply, an open-mic window lets the next utterance continue without re-triggering. Endpointing and earcons are owned here, inside the audio session — the agent only sees the next message.
64
+ - **Wake evidence fails closed.** Configured names are never injected into Whisper's
65
+ decoder prompt: only an exact leading name heard in the unprimed transcript can open
66
+ a dialogue, and repeated-name decoder loops are discarded before IAP delivery.
64
67
 
65
68
  ## What it does not do (yet)
66
69
 
67
70
  - No barge-in: the owner cannot interrupt a spoken answer by talking over it (that needs acoustic echo cancellation plus a neural VAD). While the runtime speaks, it does not listen.
68
- - Wake detection runs on the transcript, not on an acoustic wake-word model — so a wake word costs one line of config and no training, at the price of transcribing every voiced segment. An acoustic pre-gate is the Фаза-4 latency upgrade.
71
+ - Wake detection runs on an unprimed transcript, not on an acoustic wake-word model —
72
+ so a wake word costs one line of config and no training, at the price of transcribing
73
+ every voiced segment. A bounded private ring retains wake-candidate segments for
74
+ replayable diagnostics; an acoustic pre-gate remains the stronger future upgrade.
69
75
  - Single host, single owner, local microphone and speaker. Multi-device and multi-owner are out of scope for the MVP.
70
76
 
71
77
  ## Roadmap
package/docs/README.md CHANGED
@@ -41,6 +41,11 @@ self-config hook. `voicetalk-runtime devices` lists the host's inputs;
41
41
  `voicetalk-runtime doctor` reports the whole contour (binding, wake map,
42
42
  voice-connect, microphone).
43
43
 
44
+ Wake evidence is fail-closed. The configured vocabulary is never passed as a Whisper
45
+ decoder prompt, because prompt text can be generated back on unrelated audio. A closed
46
+ dialogue opens only on an exact leading address in the unprimed transcript; repeated
47
+ token loops and name-only echoes are rejected before an IAP envelope exists.
48
+
44
49
  ## How a turn works
45
50
 
46
51
  1. The microphone runs always-on; energy endpointing cuts an utterance when the
@@ -69,7 +74,14 @@ Everything is tunable by env: `VOICETALK_AUDIO=0` (text-only, no microphone),
69
74
  `VOICETALK_FOLLOWUP_MS`, `VOICETALK_HANGOVER_MS`, `VOICETALK_VAD_MIN_RMS`,
70
75
  `VOICETALK_VAD_RATIO`, `VOICETALK_STT_LANG`, `VOICETALK_STT_ENGINE`,
71
76
  `VOICETALK_TTS_MAX_CHARS`, `VOICETALK_EARCONS=0`, `VOICETALK_COMPOSE_MS`,
72
- `VOICETALK_COMPOSE_UNFINISHED_MS`, `VOICETALK_TURN_MAX_MS`.
77
+ `VOICETALK_COMPOSE_UNFINISHED_MS`, `VOICETALK_TURN_MAX_MS`,
78
+ `VOICETALK_WAKE_CAPTURE=0` (disable the default diagnostic ring), and
79
+ `VOICETALK_WAKE_CAPTURE_MAX` (default 16).
80
+
81
+ Every transcript that resembles a configured wake is retained as a mode-0600 WAV plus
82
+ JSON verdict under `<IAPEER_ROOT>/runtimes/voicetalk/tmp/wake-captures`. The ring is
83
+ bounded and contains candidate utterances only, not continuous room audio. Replay a
84
+ disputed segment through the same paced microphone path described below.
73
85
 
74
86
  Two seams exist for testing turn-taking without touching a live channel:
75
87
  `VOICETALK_MIC_FILE=<pcm>` replays a recording through the microphone path (paced in
package/docs/ru/README.md CHANGED
@@ -41,6 +41,11 @@ self-config-хука (`VOICETALK_WAKE_WORDS`, `VOICETALK_DEFAULT_TARGET`,
41
41
  `voicetalk-runtime doctor` — весь контур целиком (привязка, карта, voice-connect,
42
42
  микрофон).
43
43
 
44
+ Wake-сигнал работает fail-closed. Настроенные имена не передаются Whisper как decoder
45
+ prompt: такой контекст способен сам породить имя на постороннем аудио. Закрытый диалог
46
+ открывает только точное имя в начале непримированного транскрипта; циклы повторяющихся
47
+ токенов и тело из одних настроенных имён отбрасываются до появления IAP-конверта.
48
+
44
49
  ## Как устроен ход разговора
45
50
 
46
51
  1. Микрофон слушает постоянно; энергетический эндпойнтер закрывает реплику по паузе
@@ -68,7 +73,14 @@ Earcon'ы размечают состояния: восходящий — мик
68
73
  `VOICETALK_FOLLOWUP_MS`, `VOICETALK_HANGOVER_MS`, `VOICETALK_VAD_MIN_RMS`,
69
74
  `VOICETALK_VAD_RATIO`, `VOICETALK_STT_LANG`, `VOICETALK_STT_ENGINE`,
70
75
  `VOICETALK_TTS_MAX_CHARS`, `VOICETALK_EARCONS=0`, `VOICETALK_COMPOSE_MS`,
71
- `VOICETALK_COMPOSE_UNFINISHED_MS`, `VOICETALK_TURN_MAX_MS`.
76
+ `VOICETALK_COMPOSE_UNFINISHED_MS`, `VOICETALK_TURN_MAX_MS`,
77
+ `VOICETALK_WAKE_CAPTURE=0` (отключить диагностическое кольцо) и
78
+ `VOICETALK_WAKE_CAPTURE_MAX` (по умолчанию 16).
79
+
80
+ Каждый транскрипт, похожий на настроенный wake, сохраняется как WAV с правами 0600 и
81
+ JSON-вердикт в `<IAPEER_ROOT>/runtimes/voicetalk/tmp/wake-captures`. Кольцо ограничено
82
+ и хранит только сегменты-кандидаты, не непрерывную запись комнаты. Спорный сегмент
83
+ можно прогнать через тот же микрофонный путь способом ниже.
72
84
 
73
85
  Для проверки turn-taking, не трогая живой канал, есть два шва: `VOICETALK_MIC_FILE=<pcm>`
74
86
  проигрывает запись через тот же путь, что и микрофон (в реальном темпе), а
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/voicetalk-runtime",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Voice runtime-router for IAP peers.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -114,13 +114,15 @@ export type TranscribeOptions = {
114
114
  lang?: string
115
115
  /** Force an STT engine (`mlx-whisper` keeps it fully local); default = service cascade. */
116
116
  engine?: string
117
- /** Decoder-priming prompt — biases spelling of fleet names so the wake matcher sees
118
- * «Борис», not «Барис». */
119
- prompt?: string
120
117
  timeoutMs?: number
121
118
  }
122
119
 
123
- /** Transcribe a local audio file. Returns the raw transcript (may be empty). */
120
+ /** Transcribe a local audio file. Returns the raw transcript (may be empty).
121
+ *
122
+ * Deliberately no `prompt` option: this client feeds a presence detector, so configured
123
+ * wake words must never be injected into the decoder and then mistaken for acoustic
124
+ * evidence. voice-connect supports prompts for other consumers; voicetalk's door does not.
125
+ */
124
126
  export async function transcribe(audioPath: string, opts: TranscribeOptions = {}): Promise<string> {
125
127
  const ep = resolveVoiceEndpoint()
126
128
  const form = new FormData()
@@ -128,7 +130,6 @@ export async function transcribe(audioPath: string, opts: TranscribeOptions = {}
128
130
  form.append('file', new Blob([new Uint8Array(bytes)], { type: 'audio/wav' }), audioPath.split('/').pop() ?? 'audio.wav')
129
131
  if (opts.lang) form.append('language', opts.lang)
130
132
  if (opts.engine) form.append('engine', opts.engine)
131
- if (opts.prompt) form.append('prompt', opts.prompt)
132
133
  form.append('response_format', 'json')
133
134
  const res = await fetch(ep.stt, {
134
135
  method: 'POST',
@@ -30,9 +30,9 @@
30
30
  // which exists here yet. Mic gating during playback is the honest substitute — the
31
31
  // runtime simply does not listen while it talks.
32
32
 
33
- import { mkdirSync, rmSync } from 'fs'
33
+ import { copyFileSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'fs'
34
34
  import { join } from 'path'
35
- import { randomUUID } from 'crypto'
35
+ import { createHash, randomUUID } from 'crypto'
36
36
  import {
37
37
  Microphone,
38
38
  Segmenter,
@@ -45,16 +45,16 @@ import {
45
45
  import { synthesize, transcribe } from './voiceConnect.ts'
46
46
  import {
47
47
  buildAddressBook,
48
- isLikelyHallucination,
48
+ hallucinationReason,
49
49
  matchWake,
50
+ objectionToActivation,
50
51
  objectionToSwitch,
51
52
  readFleetPeers,
52
- sttPrompt,
53
53
  type Address,
54
54
  type FleetPeer,
55
55
  type VoicetalkBinding,
56
+ type WakeMatch,
56
57
  } from './wake.ts'
57
- import { writeFileSync } from 'fs'
58
58
 
59
59
  const num = (v: string | undefined, d: number) => (v && Number(v) > 0 ? Number(v) : d)
60
60
 
@@ -98,6 +98,12 @@ const STT_ENGINE = process.env.VOICETALK_STT_ENGINE?.trim() || undefined
98
98
  * real voice channel is in use by its owner. */
99
99
  const MIC_FILE = process.env.VOICETALK_MIC_FILE?.trim() || undefined
100
100
  const PLAYBACK_ENABLED = process.env.VOICETALK_PLAYBACK !== '0'
101
+ /** Keep a small, private ring of audio segments whose transcript looked like a wake.
102
+ * A false wake cannot be diagnosed from text alone: the audio is the evidence of what
103
+ * the owner actually said. Only candidate segments are retained, never room audio; files
104
+ * are mode 0600 under the runtime scratch dir and the oldest pair is evicted. */
105
+ const WAKE_CAPTURE_ENABLED = process.env.VOICETALK_WAKE_CAPTURE !== '0'
106
+ const WAKE_CAPTURE_MAX = Math.min(100, num(process.env.VOICETALK_WAKE_CAPTURE_MAX, 16))
101
107
 
102
108
  export type DeliverFn = (target: string, text: string) => Promise<{ ok: boolean; detail?: string }>
103
109
  export type LogFn = (evt: string, fields: Record<string, unknown>) => void
@@ -112,6 +118,12 @@ export type VoiceSessionOptions = {
112
118
  peers?: FleetPeer[]
113
119
  }
114
120
 
121
+ type WakeRoutingDecision = {
122
+ candidate: WakeMatch
123
+ accepted: boolean
124
+ reason?: string
125
+ }
126
+
115
127
  /** State sounds, per the earcon convention in the research: `open` = the microphone is
116
128
  * yours (rising, ≤300 ms), `end` = turn captured, processing (short descending,
117
129
  * ≤200 ms), `close` = the open-mic window expired, a name is needed again (low, soft),
@@ -126,7 +138,6 @@ export class VoiceSession {
126
138
  minRms: num(process.env.VOICETALK_VAD_MIN_RMS, 700),
127
139
  })
128
140
  private book: Address[] = []
129
- private prompt = ''
130
141
  private earcons: Earcons | null = null
131
142
  private busy = false
132
143
  private queue: Array<Segment & { speechEndedAt: number }> = []
@@ -181,7 +192,6 @@ export class VoiceSession {
181
192
  binding: this.opts.binding,
182
193
  peers: this.opts.peers ?? readFleetPeers(),
183
194
  })
184
- this.prompt = sttPrompt(this.book)
185
195
  }
186
196
 
187
197
  private writeEarcons(): Earcons {
@@ -269,21 +279,37 @@ export class VoiceSession {
269
279
  transcript = await transcribe(wav, {
270
280
  lang: STT_LANG || undefined,
271
281
  engine: STT_ENGINE,
272
- prompt: this.prompt || undefined,
273
282
  })
274
283
  } catch (err) {
275
284
  this.opts.log('stt.error', { ms: segment.ms, detail: err instanceof Error ? err.message : String(err) })
276
285
  return
277
286
  }
278
287
  const sttMs = Date.now() - started
279
- if (isLikelyHallucination(transcript)) {
288
+ const candidate = matchWake(transcript, this.book)
289
+ const hallucination = hallucinationReason(transcript)
290
+ if (hallucination) {
280
291
  this.opts.log('stt.ignored', {
281
292
  ms: segment.ms,
282
293
  sttMs,
283
294
  peak: Math.round(segment.peakRms),
284
295
  floor: Math.round(segment.noiseFloor),
296
+ reason: hallucination,
285
297
  text: transcript,
286
298
  })
299
+ if (candidate) {
300
+ this.opts.log('wake.rejected', {
301
+ target: candidate.target,
302
+ alias: candidate.alias,
303
+ exact: candidate.exact,
304
+ reason: hallucination,
305
+ text: transcript,
306
+ })
307
+ this.captureWakeCandidate(wav, segment, transcript, {
308
+ candidate,
309
+ accepted: false,
310
+ reason: hallucination,
311
+ })
312
+ }
287
313
  return
288
314
  }
289
315
  this.opts.log('stt.text', {
@@ -295,12 +321,79 @@ export class VoiceSession {
295
321
  text: transcript,
296
322
  })
297
323
 
298
- this.absorb(transcript)
324
+ const decision = this.absorb(transcript, candidate)
325
+ if (decision) this.captureWakeCandidate(wav, segment, transcript, decision)
299
326
  } finally {
300
327
  rmSync(wav, { force: true })
301
328
  }
302
329
  }
303
330
 
331
+ /** Persist the evidence for an apparent wake in a bounded private ring. Text-only logs
332
+ * cannot distinguish a spoken name from a decoder invention; this seam makes the next
333
+ * disputed activation replayable through VOICETALK_MIC_FILE without recording the room
334
+ * continuously. */
335
+ private captureWakeCandidate(
336
+ wav: string,
337
+ segment: Segment,
338
+ transcript: string,
339
+ decision: WakeRoutingDecision,
340
+ ): void {
341
+ if (!WAKE_CAPTURE_ENABLED) return
342
+ const dir = join(this.opts.tmpDir, 'wake-captures')
343
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
344
+ const stem = `wake-${stamp}-${randomUUID()}`
345
+ const audioPath = join(dir, `${stem}.wav`)
346
+ const metaPath = join(dir, `${stem}.json`)
347
+ try {
348
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
349
+ copyFileSync(wav, audioPath)
350
+ writeFileSync(
351
+ metaPath,
352
+ JSON.stringify(
353
+ {
354
+ capturedAt: new Date().toISOString(),
355
+ audio: `${stem}.wav`,
356
+ pcmSha256: createHash('sha256').update(segment.pcm).digest('hex'),
357
+ segment: {
358
+ ms: segment.ms,
359
+ peakRms: Math.round(segment.peakRms),
360
+ noiseFloor: Math.round(segment.noiseFloor),
361
+ threshold: segment.threshold,
362
+ truncated: segment.truncated,
363
+ },
364
+ transcript,
365
+ wake: {
366
+ target: decision.candidate.target,
367
+ alias: decision.candidate.alias,
368
+ exact: decision.candidate.exact,
369
+ accepted: decision.accepted,
370
+ ...(decision.reason ? { reason: decision.reason } : {}),
371
+ },
372
+ },
373
+ null,
374
+ 2,
375
+ ) + '\n',
376
+ { mode: 0o600 },
377
+ )
378
+
379
+ const records = readdirSync(dir).filter(name => /^wake-.*\.json$/.test(name)).sort()
380
+ for (const oldMeta of records.slice(0, Math.max(0, records.length - WAKE_CAPTURE_MAX))) {
381
+ const oldStem = oldMeta.slice(0, -'.json'.length)
382
+ rmSync(join(dir, oldMeta), { force: true })
383
+ rmSync(join(dir, `${oldStem}.wav`), { force: true })
384
+ }
385
+ this.opts.log('wake.capture', {
386
+ target: decision.candidate.target,
387
+ accepted: decision.accepted,
388
+ path: audioPath,
389
+ })
390
+ } catch (err) {
391
+ rmSync(audioPath, { force: true })
392
+ rmSync(metaPath, { force: true })
393
+ this.opts.log('wake.capture-error', { detail: err instanceof Error ? err.message : String(err) })
394
+ }
395
+ }
396
+
304
397
  /** Route one recognized fragment into the turn being composed.
305
398
  *
306
399
  * The decision is deliberately conservative in one direction: an unaddressed fragment
@@ -309,9 +402,30 @@ export class VoiceSession {
309
402
  * when no conversation is open. Mid-dialogue, the cheap error (one more line to the
310
403
  * peer already listening) is far cheaper than the expensive one (the conversation
311
404
  * silently handed to another agent). */
312
- private absorb(transcript: string): void {
405
+ private absorb(transcript: string, candidate = matchWake(transcript, this.book)): WakeRoutingDecision | null {
313
406
  const active = this.pending?.target ?? (this.followTarget && Date.now() < this.followUntil ? this.followTarget : null)
314
- const match = matchWake(transcript, this.book)
407
+ if (candidate) {
408
+ const decline = objectionToActivation(candidate, this.book)
409
+ if (decline) {
410
+ this.opts.log('wake.rejected', {
411
+ target: candidate.target,
412
+ alias: candidate.alias,
413
+ exact: candidate.exact,
414
+ reason: decline,
415
+ text: transcript,
416
+ })
417
+ // An open dialogue intentionally needs no wake word. If its next fragment merely
418
+ // resembles a name, preserve it as ordinary continuation rather than dropping or
419
+ // stripping a word from the owner's thought.
420
+ if (active && decline === 'inexact name match') {
421
+ this.opts.log('turn.continue', { target: active, reason: this.pending ? 'compose' : 'follow-up' })
422
+ this.append(active, transcript.trim())
423
+ }
424
+ return { candidate, accepted: false, reason: decline }
425
+ }
426
+ }
427
+
428
+ const match = candidate
315
429
  const switching = !!match && !!active && match.target !== active
316
430
 
317
431
  if (switching) {
@@ -319,14 +433,14 @@ export class VoiceSession {
319
433
  if (decline) {
320
434
  this.opts.log('wake.switch-declined', { from: active, to: match!.target, reason: decline, text: transcript })
321
435
  this.append(active!, transcript.trim())
322
- return
436
+ return { candidate: match!, accepted: false, reason: decline }
323
437
  }
324
438
  // A deliberate switch: the thought in flight belongs to the previous peer, so it
325
439
  // goes out before the new one starts.
326
440
  this.opts.log('wake.switch', { from: active, to: match!.target })
327
441
  this.flush('switch')
328
442
  this.append(match!.target, match!.body)
329
- return
443
+ return { candidate: match!, accepted: true }
330
444
  }
331
445
 
332
446
  if (match) {
@@ -336,19 +450,20 @@ export class VoiceSession {
336
450
  this.openWindow(match.target)
337
451
  this.opts.log('wake.await-body', { target: match.target, alias: match.alias })
338
452
  void this.earcon('open')
339
- return
453
+ return { candidate: match, accepted: true }
340
454
  }
341
455
  this.append(match.target, match.body)
342
- return
456
+ return { candidate: match, accepted: true }
343
457
  }
344
458
 
345
459
  if (active) {
346
460
  this.opts.log('turn.continue', { target: active, reason: this.pending ? 'compose' : 'follow-up' })
347
461
  this.append(active, transcript.trim())
348
- return
462
+ return null
349
463
  }
350
464
 
351
465
  this.opts.log('wake.no-match', { text: transcript })
466
+ return null
352
467
  }
353
468
 
354
469
  /** Add a fragment to the turn in flight and (re)arm the compose timer. */
package/src/wake.ts CHANGED
@@ -10,15 +10,13 @@
10
10
  // enrich them with their own human name («наталья» → «наталья вячеславовна»).
11
11
  //
12
12
  // Why the address is matched on the TRANSCRIPT rather than by an acoustic wake-word model
13
- // (openWakeWord et al.): an acoustic detector needs a trained model per phrase, and Фаза 3
14
- // is proving the pipeline end to end — transcript matching gives the same routing with
15
- // zero training and no per-name asset to maintain. Privacy is preserved by the property
16
- // that made an on-device detector attractive in the first place: the recognizer is the
17
- // LOCAL floor of voice-connect (mlx-whisper on this host), so no audio leaves the machine
18
- // to decide whether a name was spoken. The cost is running the recognizer on every voiced
19
- // segment; endpointing keeps that to actual speech, and an acoustic pre-gate (openWakeWord
20
- // on the curated map — cheap in parallel, one shared feature backbone) is the natural
21
- // Фаза-4 latency upgrade behind this same interface.
13
+ // (openWakeWord et al.): an acoustic detector needs a trained model per phrase, while
14
+ // transcript matching needs no per-name asset. That transcript MUST be decoded without a
15
+ // name prompt. Whisper's `initial_prompt` is generative context, not evidence: priming it
16
+ // with «дарвин, борис, линус» made unrelated audio decode as those names and woke agents
17
+ // in a quiet conversation (live defect, 23–24.07). The address layer therefore accepts
18
+ // only an exact, unprimed name at the start and rejects decoder loops before routing.
19
+ // A dedicated acoustic gate remains the stronger future upgrade behind this interface.
22
20
 
23
21
  import { readFileSync } from 'fs'
24
22
  import { homedir } from 'os'
@@ -205,9 +203,8 @@ export type WakeMatch = {
205
203
  /** The utterance with the address (and any leading filler) stripped. */
206
204
  body: string
207
205
  alias: string
208
- /** True when every word of the address matched letter for letter. A match that only
209
- * survived the one-edit tolerance is good enough to START a conversation, but not to
210
- * TAKE one away from the peer already in it — see the switch bar in voiceSession. */
206
+ /** True when every word of the address matched letter for letter. Inexact candidates
207
+ * remain observable for diagnostics, but may never activate or retarget a dialogue. */
211
208
  exact: boolean
212
209
  }
213
210
 
@@ -266,9 +263,28 @@ export function objectionToSwitch(match: WakeMatch): string | null {
266
263
  return null
267
264
  }
268
265
 
269
- /** Whisper-family recognizers emit stock phrases on near-silence («Продолжение
270
- * следует…», «Субтитры сделал…»). They are not speech; drop them before matching so a
271
- * quiet room cannot fabricate an utterance. */
266
+ /** May this candidate START a dialogue?
267
+ *
268
+ * The recognizer is allowed to suggest an inexact name so the diagnostic seam can retain
269
+ * the audio, but a suggestion is not presence evidence. The initial address must be exact.
270
+ * Likewise, a transcript made only from configured names is decoder echo, not a request:
271
+ * the live failures were «Дарвин» followed by dozens of «борис» tokens. A bare exact name
272
+ * remains valid — it opens the short awaiting-body window without sending an envelope. */
273
+ export function objectionToActivation(match: WakeMatch, book: Address[]): string | null {
274
+ if (!match.exact) return 'inexact name match'
275
+ const body = normalizeSpoken(match.body)
276
+ if (!body) return null
277
+ const addressTokens = new Set(
278
+ book.flatMap(address => address.aliases.flatMap(alias => normalizeSpoken(alias).split(' '))).filter(Boolean),
279
+ )
280
+ const words = body.split(' ')
281
+ if (words.every(word => addressTokens.has(word))) return 'body only echoes configured names'
282
+ return null
283
+ }
284
+
285
+ /** Whisper-family recognizers emit both stock captions on near-silence and low-entropy
286
+ * token loops. They are decoder output, not speech; identify them before wake matching so
287
+ * a recognizer cannot fabricate presence from its own repeated hypothesis. */
272
288
  const HALLUCINATIONS = [
273
289
  'продолжение следует',
274
290
  'субтитры',
@@ -282,16 +298,33 @@ const HALLUCINATIONS = [
282
298
  'you',
283
299
  ]
284
300
 
285
- export function isLikelyHallucination(transcript: string): boolean {
301
+ export function hallucinationReason(transcript: string): string | null {
286
302
  const n = normalizeSpoken(transcript)
287
- if (!n) return true
288
- if (n.length <= 2) return true
289
- return HALLUCINATIONS.some(h => n === h || n.startsWith(h))
303
+ if (!n) return 'empty transcript'
304
+ if (n.length <= 2) return 'transcript too short'
305
+ if (HALLUCINATIONS.some(h => n === h || n.startsWith(h))) return 'stock whisper caption'
306
+
307
+ const words = n.split(' ')
308
+ if (words.length >= 3) {
309
+ let longestRun = 1
310
+ let run = 1
311
+ for (let i = 1; i < words.length; i++) {
312
+ run = words[i] === words[i - 1] ? run + 1 : 1
313
+ longestRun = Math.max(longestRun, run)
314
+ }
315
+ // Three repeats can be a human correcting or emphasizing themselves («нет, нет,
316
+ // нет»). Five identical consecutive tokens are outside ordinary turn-taking and
317
+ // still far below the 10/70-token loops observed live.
318
+ if (longestRun >= 5) return 'repeated-token decoder loop'
319
+
320
+ const counts = new Map<string, number>()
321
+ for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1)
322
+ const dominant = Math.max(...counts.values())
323
+ if (words.length >= 6 && dominant / words.length >= 0.75) return 'low-entropy decoder loop'
324
+ }
325
+ return null
290
326
  }
291
327
 
292
- /** Names spoken to the recognizer as a decoder prompt — biases spelling toward the
293
- * fleet's own vocabulary so the matcher sees «Борис», not «Барис». */
294
- export function sttPrompt(book: Address[]): string {
295
- const names = book.flatMap(a => a.aliases).slice(0, 40)
296
- return names.length ? `Обращение к ассистенту по имени: ${names.join(', ')}.` : ''
328
+ export function isLikelyHallucination(transcript: string): boolean {
329
+ return hallucinationReason(transcript) !== null
297
330
  }