@gotcos/glasses-server 6.1.0 → 6.2.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.2.0
4
+
5
+ Reliability release — ports the hardening the full COS Glasses app shipped in June.
6
+
7
+ - **Transfer integrity (lost-chunk detection).** The server now records every
8
+ received chunk index. A chunk lost in transit surfaces as an inline
9
+ `[… audio gap …]` marker in the gap-aware transcript instead of being
10
+ silently stitched over. Gap state survives a mid-meeting server restart;
11
+ legacy persisted sessions recover without false alarms. The Even Hub client
12
+ (1.0.153+) already retries failed uploads durably — this is the server half.
13
+ - **Vocab-echo hallucination filter.** Whisper is seeded with your profile
14
+ vocabulary; on silence/music it can echo those terms back as phantom words
15
+ ("POS Nation. Thrift Cart.") the user never said. Bare-name echoes are now
16
+ dropped session-aware (silence echo, back-to-back run, or exact repeat) on
17
+ both the meeting and dictation paths. Real sentences that mention a term are
18
+ never dropped; plain single-word terms (names, cities) never trigger it.
19
+ - **Name corrections on every path.** The `whisper_corrections` map now also
20
+ applies to iPhone-ASR candidate text and the cloud fallback, not just local
21
+ whisper.
22
+ - **SIGTERM parity.** Production stops (service managers, `kill`) now flush
23
+ active session logs exactly like Ctrl-C did.
24
+ - **`COS_G2_DEFAULT_MODEL` fix.** The documented default-model switch now
25
+ applies on the primary query path, not only the OpenAI-compat surface.
26
+
3
27
  ## 6.1.0
4
28
 
5
29
  - **Codex backend.** Chat now routes to your local **Codex CLI** (`codex-high`) in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.1.0",
3
+ "version": "6.2.0",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -138,7 +138,13 @@ app.get('/', (_req, res) => {
138
138
  })
139
139
 
140
140
  // Graceful shutdown — stop whisper-server child process
141
- process.on('SIGTERM', () => { stopWhisperServer(); process.exit(0) })
141
+ process.on('SIGTERM', () => {
142
+ // Production stops (kill, service managers) send SIGTERM — flush session logs
143
+ // exactly like SIGINT so active conversations aren't lost on shutdown.
144
+ try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
145
+ stopWhisperServer()
146
+ process.exit(0)
147
+ })
142
148
  process.on('SIGINT', () => { logActiveSessionsOnShutdown(); stopWhisperServer(); process.exit(0) })
143
149
 
144
150
  // Crash protection — log and survive instead of dying mid-meeting
@@ -11,7 +11,7 @@
11
11
  // isFullHallucination(text) — returns true if the text IS a hallucination in its
12
12
  // entirety (silence artifacts, caption training, foreign script, filler-only).
13
13
 
14
- import { getNegativeRules } from './profile.js'
14
+ import { getNegativeRules, getVocabulary, getOwnerName, loadProfileField } from './profile.js'
15
15
 
16
16
  // ── Whole-chunk silence hallucinations ─────────────────────────────────────
17
17
  const KNOWN_HALLUCINATIONS = [
@@ -312,6 +312,76 @@ export function isRepeatedThankYouOnly(text: string): boolean {
312
312
  return content.length === 0
313
313
  }
314
314
 
315
+ // ── Vocab-echo (prompt-regurgitation) hallucination ────────────────────────
316
+ // Whisper is seeded with an initial_prompt = the profile vocabulary (owner +
317
+ // brands + products + people). On silence / music / ambiguous audio it ECHOES
318
+ // that prompt, emitting the seeded terms the user never said. The brand-URL
319
+ // filters above only catch URL echoes; a bare brand-NAME echo slips through.
320
+ // This detector drops a chunk that is NOTHING but seeded vocab terms (+
321
+ // punctuation). Real speech that MENTIONS a term in a sentence keeps its
322
+ // non-vocab content words and is never dropped.
323
+ let _vocabEchoRe: RegExp | null = null
324
+
325
+ /** Bust the cached vocab matcher after a profile write. */
326
+ export function resetVocabEchoCache(): void { _vocabEchoRe = null }
327
+
328
+ function getVocabEchoMatcher(): RegExp {
329
+ if (_vocabEchoRe) return _vocabEchoRe
330
+ const raw = new Set<string>()
331
+ const owner = getOwnerName()
332
+ if (owner) raw.add(owner)
333
+ for (const v of getVocabulary()) if (v && v.trim()) raw.add(v.trim())
334
+ // Include whisper_corrections key/value variants so the echo matches whatever
335
+ // spelling whisper emits ("POS Nation" ↔ "POSNation", "Jewel 360" ↔ "Jewel360").
336
+ try {
337
+ const corrRaw = loadProfileField('whisper_corrections', '')
338
+ if (corrRaw) {
339
+ const map = JSON.parse(corrRaw) as Record<string, string>
340
+ for (const [k, val] of Object.entries(map)) { if (k) raw.add(k); if (val) raw.add(val) }
341
+ }
342
+ } catch { /* malformed corrections — ignore */ }
343
+ // Only UNAMBIGUOUS terms trigger an echo drop: multi-word phrases ("POS Nation",
344
+ // "IT Retail", "Jeremy Sokolic") and brand-shaped single tokens with an internal
345
+ // capital or digit ("POSNation", "CaratIQ", "Jewel360"). Plain single-word tokens
346
+ // ("Austin", "Miles", "Ukaoma") are common words / ambiguous and are EXCLUDED —
347
+ // they carry too much false-drop risk for an always-on list rule.
348
+ const terms = [...raw].filter(t => {
349
+ if (t.length < 2) return false
350
+ if (/\s/.test(t)) return true // multi-word phrase
351
+ return /[A-Z0-9]/.test(t.slice(1)) // single token only if brand-shaped
352
+ }).sort((a, b) => b.length - a.length)
353
+ if (terms.length === 0) { _vocabEchoRe = /(?!)/g; return _vocabEchoRe }
354
+ // Escape regex metachars; flex internal whitespace so "IT Retail" also matches
355
+ // "ITRetail". Word-bounded so terms don't match inside larger words.
356
+ const alt = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s*')).join('|')
357
+ _vocabEchoRe = new RegExp(`\\b(?:${alt})\\b`, 'gi')
358
+ return _vocabEchoRe
359
+ }
360
+
361
+ /** Count distinct seeded-vocab terms present in `text`. */
362
+ export function countVocabTerms(text: string): number {
363
+ if (!text) return 0
364
+ const re = getVocabEchoMatcher()
365
+ re.lastIndex = 0
366
+ const found = new Set<string>()
367
+ let m: RegExpExecArray | null
368
+ while ((m = re.exec(text)) !== null) {
369
+ found.add(m[0].toLowerCase().replace(/\s+/g, ''))
370
+ if (m.index === re.lastIndex) re.lastIndex++ // guard against a zero-width match loop
371
+ }
372
+ return found.size
373
+ }
374
+
375
+ /** True iff `text` is non-empty and contains NOTHING but seeded vocab terms (plus
376
+ * punctuation/whitespace) — the prompt-echo hallucination shape. Any non-vocab
377
+ * content word (incl. connectives like "and"/"the") makes it real speech → false. */
378
+ export function isVocabEchoOnly(text: string): boolean {
379
+ if (!text || !text.trim()) return false
380
+ if (countVocabTerms(text) === 0) return false
381
+ const residual = text.replace(getVocabEchoMatcher(), ' ').replace(/[^a-z0-9]/gi, '')
382
+ return residual.length === 0
383
+ }
384
+
315
385
  /** Streaming-chunk silence-drop decision. Pure + exported so the gate is testable
316
386
  * (sanitizeStreamTranscript is private). Returns a fallbackReason or null. Contract:
317
387
  * brand-URL-only -> 'brand_url' DROP ALWAYS — brand URLs are vocab-seeded,
@@ -320,6 +390,9 @@ export function isRepeatedThankYouOnly(text: string): boolean {
320
390
  * third-party URL during speech is preserved.
321
391
  * thank-you-only -> 'thankyou_silence' DROP only when isQuiet — soft real closings
322
392
  * stay (see isRepeatedThankYouOnly).
393
+ * Vocab-echo (prompt regurgitation) is handled SEPARATELY in sanitizeStreamTranscript
394
+ * because the safe rule is session-aware (drop a silence echo or a back-to-back RUN,
395
+ * but keep a single loud one-off that could be a real terse brand/name list).
323
396
  * Real speech (any chunk with content words) always returns null. */
324
397
  export function streamSilenceDropReason(text: string, isQuiet: boolean): 'brand_url' | 'url_silence' | 'thankyou_silence' | null {
325
398
  if (!text || !text.trim()) return null
@@ -24,7 +24,11 @@ export async function callModelStreaming(
24
24
  ): Promise<string> {
25
25
  const sid = getOrCreateSession(sessionId)
26
26
  const sessionModel = getSessionModel(sid)
27
- const resolvedModel = normalizeModelPreference(model) ?? sessionModel ?? DEFAULT_MODEL
27
+ // COS_G2_DEFAULT_MODEL is the documented default-model switch (CHANGELOG 6.1.0);
28
+ // it must win over the hardcoded DEFAULT_MODEL on this primary query path, not
29
+ // just the OpenAI-compat surface.
30
+ const envDefault = normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL)
31
+ const resolvedModel = normalizeModelPreference(model) ?? sessionModel ?? envDefault ?? DEFAULT_MODEL
28
32
 
29
33
  setSessionModel(sid, resolvedModel)
30
34
 
@@ -17,6 +17,8 @@ import { enhanceAudio } from './audio-enhance.js'
17
17
  import {
18
18
  stripInlineHallucinationsOneShot,
19
19
  isFullHallucination,
20
+ isVocabEchoOnly,
21
+ countVocabTerms,
20
22
  } from './hallucination-filter.js'
21
23
  import { getOpenAIKey } from './openai-key.js'
22
24
 
@@ -173,7 +175,11 @@ export async function transcribeAudioBuffer(audioBuffer: Buffer, opts: { mode?:
173
175
  }
174
176
 
175
177
  const elapsedMs = performance.now() - tStart
176
- if (!text || isFullHallucination(text)) {
178
+ // A one-shot message/dictation that is NOTHING but a list of seeded vocab terms
179
+ // (>=2 distinct) is a whisper prompt-echo, not speech — drop it like the meeting
180
+ // path does. A single terse brand mention stays (could be a real one-word message).
181
+ const vocabEcho = isVocabEchoOnly(text) && countVocabTerms(text) >= 2
182
+ if (!text || isFullHallucination(text) || vocabEcho) {
177
183
  throw new NoSpeechDetectedError(text || '')
178
184
  }
179
185
 
@@ -14,7 +14,7 @@ import { getOpenAIKey } from '../lib/openai-key.js'
14
14
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
15
15
  import { emitDisplay } from '../lib/display-bus.js'
16
16
  import { errMsg } from '../lib/utils.js'
17
- import { transcribeLocal, isWhisperLocalAvailable, type WhisperWord } from '../lib/whisper-local.js'
17
+ import { transcribeLocal, isWhisperLocalAvailable, applyCorrections, type WhisperWord } from '../lib/whisper-local.js'
18
18
  import { enhanceAudio } from '../lib/audio-enhance.js'
19
19
  import { trimSilence, isSileroAvailable } from '../lib/vad-silero.js'
20
20
  import { identifySpeaker, isEmbeddingAvailable, autoEnroll, getEmbeddingCount } from '../lib/speaker-embeddings.js'
@@ -29,6 +29,7 @@ import {
29
29
  isFullHallucination as sharedIsFullHallucination,
30
30
  clearSessionHallucinationState,
31
31
  streamSilenceDropReason,
32
+ isVocabEchoOnly,
32
33
  } from '../lib/hallucination-filter.js'
33
34
 
34
35
  // Silence-hallucination drops (2026-05-29, v5.9.73). Contract in streamSilenceDropReason:
@@ -170,6 +171,18 @@ interface TranscriptSession {
170
171
  startTime: number
171
172
  title: string
172
173
  providerCandidates?: Record<string, ProviderCandidateRecord>
174
+ // ── Transfer integrity (lost-chunk detection) ──────────────
175
+ // Every chunkIndex the server received an audio POST for — recorded at
176
+ // ingest BEFORE any text filtering, so a "received but silent" chunk counts
177
+ // as delivered (not a gap). A genuine hole (index in [0, maxChunkIndex] that
178
+ // never arrived = a chunk lost in transit) is the only thing flagged as a gap.
179
+ // Sorted, de-duplicated. See computeGapReport()/analyzeTranscriptGaps().
180
+ receivedIndices?: number[]
181
+ maxChunkIndex?: number
182
+ // Count of consecutive vocab-echo (prompt-regurgitation) chunks. Reset to 0 by
183
+ // any real-content chunk. Used to drop a RUN of echoed brand names while keeping
184
+ // a single loud one-off (which could be a real terse list). See sanitizeStreamTranscript.
185
+ vocabEchoStreak?: number
173
186
  }
174
187
 
175
188
  const sessions = new Map<string, TranscriptSession>()
@@ -238,11 +251,24 @@ function persistSession(sessionId: string): void {
238
251
  const session = sessions.get(sessionId)
239
252
  if (!session) return
240
253
  const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
254
+ // chunksIndexed preserves each chunk's original index (a plain filter()
255
+ // would collapse the sparse array and destroy gap positions on recovery).
256
+ const chunksIndexed: Array<{ i: number; c: TranscriptChunk }> = []
257
+ for (let i = 0; i < session.chunks.length; i++) {
258
+ const c = session.chunks[i]
259
+ if (c && c.text) chunksIndexed.push({ i, c })
260
+ }
241
261
  const data = JSON.stringify({
242
262
  sessionId,
243
263
  startTime: session.startTime,
244
264
  title: session.title,
245
- chunks: session.chunks.filter(c => c && c.text),
265
+ // `chunks` = legacy dense form, kept for backward compatibility with
266
+ // existing readers; `chunksIndexed` preserves original indices so gap
267
+ // detection survives recovery. Recovery prefers chunksIndexed.
268
+ chunks: chunksIndexed.map(e => e.c),
269
+ chunksIndexed,
270
+ receivedIndices: session.receivedIndices ?? [],
271
+ maxChunkIndex: session.maxChunkIndex ?? -1,
246
272
  providerCandidates: session.providerCandidates ?? {},
247
273
  })
248
274
  writeFileSync(filePath, data, 'utf-8')
@@ -259,13 +285,52 @@ function recoverSessions(): void {
259
285
  if (!file.endsWith('.json')) continue
260
286
  try {
261
287
  const data = JSON.parse(readFileSync(resolve(CHUNK_PERSIST_DIR, file), 'utf-8'))
262
- if (data.sessionId && data.chunks && data.chunks.length > 0) {
288
+ // Reconstruct the (sparse) chunk array. New format keeps original
289
+ // indices via chunksIndexed; legacy format stored a dense `chunks`.
290
+ const indexed: Array<{ i: number; c: TranscriptChunk }> | null =
291
+ Array.isArray(data.chunksIndexed) ? data.chunksIndexed : null
292
+ const legacy: TranscriptChunk[] | null = Array.isArray(data.chunks) ? data.chunks : null
293
+ const hasChunks = (indexed && indexed.length > 0) || (legacy && legacy.length > 0)
294
+ if (data.sessionId && hasChunks) {
263
295
  // Only recover sessions less than 4 hours old
264
296
  if (Date.now() - data.startTime < 4 * 60 * 60 * 1000) {
297
+ const chunks: TranscriptChunk[] = []
298
+ if (indexed) {
299
+ for (const e of indexed) {
300
+ if (e && Number.isInteger(e.i) && e.i >= 0 && e.c) chunks[e.i] = e.c
301
+ }
302
+ } else if (legacy) {
303
+ for (let k = 0; k < legacy.length; k++) if (legacy[k]) chunks[k] = legacy[k]
304
+ }
305
+ // Restore the received-index ledger. A legacy file (pre-feature)
306
+ // has no ledger and no way to know whether a chunk was truly lost,
307
+ // so deriving from stored positions would FALSELY flag a
308
+ // received-but-silent chunk as a gap. Instead, treat a legacy
309
+ // session as contiguous (0..maxStored) → it reports 100%, never a
310
+ // false alarm. New-format files carry their own ledger and are exact.
311
+ const hasLedger = Array.isArray(data.receivedIndices)
312
+ let receivedIndices: number[]
313
+ let maxChunkIndex: number
314
+ if (hasLedger) {
315
+ receivedIndices = Array.from(new Set(
316
+ (data.receivedIndices as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0),
317
+ )).sort((a, b) => a - b)
318
+ maxChunkIndex = typeof data.maxChunkIndex === 'number' && data.maxChunkIndex >= 0
319
+ ? data.maxChunkIndex
320
+ : (receivedIndices.length > 0 ? receivedIndices[receivedIndices.length - 1] : -1)
321
+ } else {
322
+ let maxStored = -1
323
+ for (let k = 0; k < chunks.length; k++) if (chunks[k]) maxStored = k
324
+ receivedIndices = []
325
+ for (let k = 0; k <= maxStored; k++) receivedIndices.push(k)
326
+ maxChunkIndex = maxStored
327
+ }
265
328
  const session: TranscriptSession = {
266
- chunks: data.chunks,
329
+ chunks,
267
330
  startTime: data.startTime,
268
331
  title: data.title || '',
332
+ receivedIndices,
333
+ maxChunkIndex,
269
334
  providerCandidates: data.providerCandidates && typeof data.providerCandidates === 'object'
270
335
  ? data.providerCandidates
271
336
  : {},
@@ -293,7 +358,8 @@ function recoverSessions(): void {
293
358
  console.log(`[session-recovery] Cleaned inline hallucinations from ${cleaned} chunks`)
294
359
  persistSession(data.sessionId) // re-persist cleaned data to disk
295
360
  }
296
- console.log(`[session-recovery] Recovered ${data.chunks.length} chunks for ${data.sessionId}`)
361
+ const gaps = computeGapReport(session).missingIndices.length
362
+ console.log(`[session-recovery] Recovered ${session.chunks.filter(c => c && c.text).length} chunks for ${data.sessionId}${gaps > 0 ? ` (${gaps} lost-chunk gap${gaps > 1 ? 's' : ''})` : ''}`)
297
363
  } else {
298
364
  // Stale — clean up
299
365
  unlinkSync(resolve(CHUNK_PERSIST_DIR, file))
@@ -410,13 +476,106 @@ export function getSession(sessionId: string): TranscriptSession {
410
476
  return session
411
477
  }
412
478
 
413
- /** Get full accumulated transcript for a session (with speaker labels) */
414
- export function getSessionTranscript(sessionId: string): string | null {
479
+ /** Insert a non-negative integer into a sorted array, keeping it sorted and
480
+ * unique. Common case (a new highest value) is O(1); out-of-order (a retry)
481
+ * is a binary-search insert. Mutates `arr`. Exported for unit tests. */
482
+ export function insertSortedUnique(arr: number[], value: number): void {
483
+ if (!Number.isInteger(value) || value < 0) return
484
+ const last = arr.length > 0 ? arr[arr.length - 1] : -1
485
+ if (value > last) { arr.push(value); return }
486
+ if (value === last) return
487
+ let lo = 0, hi = arr.length
488
+ while (lo < hi) {
489
+ const mid = (lo + hi) >> 1
490
+ if (arr[mid] < value) lo = mid + 1
491
+ else hi = mid
492
+ }
493
+ if (arr[lo] !== value) arr.splice(lo, 0, value)
494
+ }
495
+
496
+ /** Record that the server received an audio POST for this chunk index.
497
+ * Called at ingest before any text filtering, so silent/empty chunks still
498
+ * count as delivered (not a gap). */
499
+ function recordReceivedChunk(session: TranscriptSession, chunkIndex: number): void {
500
+ if (!Number.isInteger(chunkIndex) || chunkIndex < 0) return
501
+ if (!session.receivedIndices) session.receivedIndices = []
502
+ insertSortedUnique(session.receivedIndices, chunkIndex)
503
+ if (session.maxChunkIndex == null || chunkIndex > session.maxChunkIndex) {
504
+ session.maxChunkIndex = chunkIndex
505
+ }
506
+ }
507
+
508
+ export interface TranscriptGapReport {
509
+ received: number // distinct chunk indices the server got
510
+ stored: number // chunks that survived filtering (have text)
511
+ maxIndex: number // highest chunk index seen (-1 if none)
512
+ expected: number // maxIndex + 1
513
+ missingIndices: number[] // indices in [0, maxIndex] never received = lost in transit
514
+ completeness: number // received / expected (1 when nothing expected)
515
+ }
516
+
517
+ /** Pure gap math over a received-index ledger. Exported for unit tests. */
518
+ export function analyzeChunkGaps(receivedIndices: number[], maxChunkIndex: number, storedCount = 0): TranscriptGapReport {
519
+ const maxIndex = maxChunkIndex
520
+ const expected = maxIndex + 1
521
+ const recvSet = new Set(receivedIndices.filter(n => Number.isInteger(n) && n >= 0))
522
+ const missingIndices: number[] = []
523
+ for (let i = 0; i <= maxIndex; i++) {
524
+ if (!recvSet.has(i)) missingIndices.push(i)
525
+ }
526
+ const completeness = expected > 0 ? recvSet.size / expected : 1
527
+ return { received: recvSet.size, stored: storedCount, maxIndex, expected, missingIndices, completeness }
528
+ }
529
+
530
+ /** Gap report bound to a live session's ledger + stored-chunk count. */
531
+ function computeGapReport(session: TranscriptSession): TranscriptGapReport {
532
+ const received = session.receivedIndices ?? []
533
+ const maxIndex = session.maxChunkIndex ?? (received.length > 0 ? received[received.length - 1] : -1)
534
+ const stored = session.chunks.filter(c => c && c.text).length
535
+ return analyzeChunkGaps(received, maxIndex, stored)
536
+ }
537
+
538
+ /** Transfer-integrity report for a live session — null if the session is gone. */
539
+ export function analyzeTranscriptGaps(sessionId: string): TranscriptGapReport | null {
415
540
  const session = sessions.get(sessionId)
416
541
  if (!session) return null
417
- return session.chunks
418
- .map(c => c.speaker ? `[${c.speaker}]: ${c.text}` : c.text)
419
- .join('\n')
542
+ return computeGapReport(session)
543
+ }
544
+
545
+ /** Get full accumulated transcript for a session (with speaker labels).
546
+ * With { withGaps: true }, walks the index sequence and inserts an explicit
547
+ * marker wherever one or more chunks were never received — so permanently
548
+ * lost audio is visible in the saved transcript instead of silently stitched. */
549
+ export function getSessionTranscript(sessionId: string, opts: { withGaps?: boolean } = {}): string | null {
550
+ const session = sessions.get(sessionId)
551
+ if (!session) return null
552
+ const renderChunk = (c: TranscriptChunk): string => (c.speaker ? `[${c.speaker}]: ${c.text}` : c.text)
553
+ if (!opts.withGaps) {
554
+ return session.chunks.map(renderChunk).join('\n')
555
+ }
556
+ const report = computeGapReport(session)
557
+ if (report.missingIndices.length === 0) {
558
+ return session.chunks.map(renderChunk).join('\n')
559
+ }
560
+ const missing = new Set(report.missingIndices)
561
+ const lines: string[] = []
562
+ let gapRun = 0
563
+ const flushGap = (): void => {
564
+ if (gapRun > 0) {
565
+ // Marker is intentionally >40 inner chars so it can't match the
566
+ // bracket-shaped hallucination filter (/^\s*\[[^\]\n]{1,40}\]\s*$/).
567
+ lines.push(`[… audio gap — ${gapRun} chunk${gapRun > 1 ? 's' : ''} lost in transit, not received by server …]`)
568
+ gapRun = 0
569
+ }
570
+ }
571
+ for (let i = 0; i <= report.maxIndex; i++) {
572
+ if (missing.has(i)) { gapRun++; continue }
573
+ flushGap()
574
+ const c = session.chunks[i]
575
+ if (c && c.text) lines.push(renderChunk(c))
576
+ }
577
+ flushGap()
578
+ return lines.join('\n')
420
579
  }
421
580
 
422
581
  /** Get structured chunks with timing + speaker confidence (for blended meeting pipeline) */
@@ -599,6 +758,26 @@ function sanitizeStreamTranscript(sessionId: string, session: TranscriptSession,
599
758
  return { text: '', fallbackReason: dropReason }
600
759
  }
601
760
  }
761
+ // Vocab-echo (whisper regurgitating its seeded vocab prompt — phantom brand names).
762
+ // Session-aware so we never drop a real one-off: a chunk that is NOTHING but seeded
763
+ // terms is dropped only when (a) the audio is quiet (a silence echo), (b) it's the
764
+ // 2nd+ consecutive such chunk (a RUN — the "repeated multiple times" symptom), or
765
+ // (c) it exactly repeats a recent chunk. A single loud, non-repeating vocab-only
766
+ // chunk is KEPT (it could be a real terse brand/name list). Accepted trade: if a
767
+ // user genuinely dictates a brand list with NO connectives across consecutive
768
+ // chunks ("POS Nation," | "Thrift Cart," | "and IT Retail"), the middle chunk can
769
+ // be dropped — rare (whisper usually bundles a spoken list into one chunk, which
770
+ // is kept) and far less harmful than the phantom-brand spam this prevents.
771
+ if (trimmedText && STRIP_BRAND_URLS && isVocabEchoOnly(trimmedText)) {
772
+ const streak = (session.vocabEchoStreak ?? 0) + 1
773
+ session.vocabEchoStreak = streak
774
+ if (isQuiet || streak >= 2 || isCrossChunkRepeat(session, trimmedText)) {
775
+ console.log(`[hallucination] Dropped (vocab_echo, q=${isQuiet ? 1 : 0}, streak=${streak}): "${trimmedText.slice(0, 60)}"`)
776
+ return { text: '', fallbackReason: 'vocab_echo' }
777
+ }
778
+ } else if (trimmedText) {
779
+ session.vocabEchoStreak = 0
780
+ }
602
781
  if (trimmedText && isServerHallucination(trimmedText)) {
603
782
  return { text: '', fallbackReason: 'hallucination' }
604
783
  }
@@ -741,6 +920,9 @@ async function processStreamChunk(opts: {
741
920
 
742
921
  const audioSha256 = sha256Hex(audioBuffer)
743
922
  const session = getSession(sessionId)
923
+ // Transfer integrity: log this index as delivered before any text filtering,
924
+ // so a silent/hallucination-filtered chunk is NOT mistaken for a lost one.
925
+ recordReceivedChunk(session, chunkIndex)
744
926
  const alreadyCanonical = session.chunks[chunkIndex]
745
927
 
746
928
  let candidateRecordKey: string | undefined
@@ -819,11 +1001,17 @@ async function processStreamChunk(opts: {
819
1001
  backend = result.backend
820
1002
  }
821
1003
 
1004
+ // Apply deterministic name corrections (whisper_corrections map) on EVERY live
1005
+ // path: the iPhone-ASR candidate path and the cloud fallback skip
1006
+ // transcribeLocal's internal pass, so without this the lens would show names
1007
+ // uncorrected for those sources. Idempotent for the local path.
1008
+ rawText = applyCorrections(rawText)
1009
+
822
1010
  let sanitized = sanitizeStreamTranscript(sessionId, session, rawText, isQuiet)
823
1011
  if (candidate && (!sanitized.text || sanitized.fallbackReason)) {
824
1012
  fallbackReason = sanitized.fallbackReason || 'empty_candidate'
825
1013
  const result = await transcribeWithServerWhisper(audioBuffer, whisperAudio, whisperContext, isQuiet)
826
- rawText = result.text
1014
+ rawText = applyCorrections(result.text)
827
1015
  words = result.words
828
1016
  backend = result.backend
829
1017
  asrProvider = 'server-whisper'