@gotcos/glasses-server 6.20.1 → 6.21.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/.env.example CHANGED
@@ -83,6 +83,13 @@ BIND_HOST=0.0.0.0
83
83
  # COS_HQ_SPECULATIVE_WARM=0 # disable background HQ warm
84
84
  # COS_BATCH_LARGE_V3=0 # explicitly use turbo instead of full HQ
85
85
  # COS_HQ_BEAM_INTERACTIVE=2 # interactive only; meetings stay at beam 5
86
+ # Control owns the machine-wide tier. Balanced is recommended: Small.en is a
87
+ # cosmetic prompt preview only, while Turbo commits authoritative live text and
88
+ # Large-v3 polishes saved work. Max reuses the resident Large-v3 worker for
89
+ # preview + commit; it is opt-in for powerful Macs and never starts a third
90
+ # Whisper worker.
91
+ # COS_WHISPER_TRANSCRIPTION_TIER=balanced # balanced | max
92
+ # COS_WHISPER_COMMIT_MODEL=turbo # turbo | large-v3 (advanced override)
86
93
  # Adaptive prompt transcription keeps three quality lanes separate:
87
94
  # Small.en provisional preview -> Large-v3-Turbo committed live text -> Large-v3 HQ polish.
88
95
  # Unset preserves the pre-6.20 Turbo behavior. `auto` uses Small.en when it has
package/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ ## 6.21.0
2
+
3
+ Transcription quality is now a machine-owned, observable two-tier policy.
4
+
5
+ - **Balanced and Max tiers.** Balanced keeps Small.en as a cosmetic prompt
6
+ preview, Large-v3-Turbo as the authoritative live commit model, and Large-v3
7
+ for saved-work polish. Max reuses the resident Large-v3 worker for preview
8
+ and commit; it never starts a third Whisper process.
9
+ - **Safe Large-v3 fallback.** A requested Max tier degrades visibly to Turbo
10
+ when the Large-v3 weights are unavailable. Immutable Turbo and Large-v3
11
+ paths remain independently addressable for fallback and HQ work; health also
12
+ warns when Max is active but its immutable Turbo recovery weights are absent.
13
+ - **Preview isolation.** Small.en previews no longer receive decoder-bias
14
+ vocabulary and still cannot write recovery state or replace committed text.
15
+ Startup reaps only an exact stale Small.en/8177 worker before spawning its
16
+ owned child; an unrelated listener is never contacted with audio or killed.
17
+ - **Truthful health.** The existing
18
+ `capabilities.transcription.live` block now reports requested/effective tier,
19
+ requested/effective commit model, downgrade reason, and preview prompt policy
20
+ without exposing local file paths.
21
+ - **Guided provisioning.** `--setup-transcription --transcription-tier
22
+ balanced|max` provisions only the models required by the selected tier plus
23
+ the immutable Turbo fallback and Large-v3 HQ model.
24
+
1
25
  ## 6.20.1
2
26
 
3
27
  Adaptive setup now survives the slow or interrupted downloads that exposed the
package/README.md CHANGED
@@ -236,20 +236,34 @@ Prompt dictation defaults to HQ. The phone owns the preference: **Fast mode
236
236
  OFF** requests HQ, and **Fast mode ON** requests turbo. The Mac performs all
237
237
  decoding; the phone does not run Whisper.
238
238
 
239
- For the recommended adaptive setup, run:
239
+ For the recommended **Balanced** setup, run:
240
240
 
241
241
  ```bash
242
- npx --yes @gotcos/glasses-server@latest --setup-transcription
242
+ npx --yes @gotcos/glasses-server@latest --setup-transcription --transcription-tier balanced
243
243
  ```
244
244
 
245
- That keeps three jobs separate: Small.en supplies provisional words on the
246
- lens, Large-v3-Turbo commits the authoritative live transcript, and Large-v3
247
- polishes saved prompts and meetings. Small.en never writes the recovery ledger.
245
+ That keeps three jobs separate: Small.en supplies provisional prompt words on
246
+ the lens, Large-v3-Turbo commits the authoritative live transcript, and
247
+ Large-v3 polishes saved prompts and meetings. Small.en never writes the
248
+ recovery ledger and receives no decoder-bias prompt.
248
249
  If its sidecar is missing or unhealthy, preview falls back to Turbo without
249
250
  changing final quality. Set `COS_WHISPER_PREVIEW_MODEL=turbo` to keep one live
250
251
  model, or `off` to disable provisional peeks. Existing installs that only
251
252
  update the server remain on Turbo until Guided Setup opts them into Small.en.
252
253
 
254
+ **Max** is an opt-in tier for powerful Macs:
255
+
256
+ ```bash
257
+ npx --yes @gotcos/glasses-server@latest --setup-transcription --transcription-tier max
258
+ ```
259
+
260
+ Max reuses the existing Large-v3 worker for preview and authoritative commit;
261
+ saved work still receives Large-v3 HQ polish. It does not start a third model
262
+ process. If Large-v3 is missing, health reports the downgrade and the server
263
+ falls back to Turbo rather than making transcription unavailable. COS Control
264
+ is the supported owner of the machine-wide tier; the per-lane environment
265
+ variables remain advanced overrides.
266
+
253
267
  The first server start downloads the real-time turbo model. True HQ additionally
254
268
  requires the full `ggml-large-v3.bin` model (about 3.1 GB):
255
269
 
package/bin/cli.cjs CHANGED
@@ -26,6 +26,20 @@ const PKG_ROOT = resolve(__dirname, '..')
26
26
  const CONFIG_DIR = join(homedir(), '.cos-glasses')
27
27
  const PREPARE_ONLY = process.argv.includes('--prepare-only')
28
28
  const SETUP_TRANSCRIPTION = process.argv.includes('--setup-transcription')
29
+ function optionValue(name) {
30
+ const index = process.argv.indexOf(name)
31
+ return index >= 0 ? process.argv[index + 1] : undefined
32
+ }
33
+ const TRANSCRIPTION_TIER_RAW = optionValue('--transcription-tier')
34
+ const TRANSCRIPTION_TIER = TRANSCRIPTION_TIER_RAW?.trim().toLowerCase() || 'balanced'
35
+ if (process.argv.includes('--transcription-tier') && !TRANSCRIPTION_TIER_RAW) {
36
+ console.error('Missing value for --transcription-tier. Use balanced or max.')
37
+ process.exit(64)
38
+ }
39
+ if (TRANSCRIPTION_TIER_RAW && !['balanced', 'max'].includes(TRANSCRIPTION_TIER)) {
40
+ console.error(`Invalid --transcription-tier "${TRANSCRIPTION_TIER_RAW}". Use balanced or max.`)
41
+ process.exit(64)
42
+ }
29
43
 
30
44
  // Record where the user ran `npx @gotcos/glasses-server` from. The server spawns
31
45
  // with cwd = PKG_ROOT (the npx cache), so without this the user's Starter-Kit COS
@@ -46,6 +60,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
46
60
  console.log(' Usage:')
47
61
  console.log(' npx --yes @gotcos/glasses-server@latest')
48
62
  console.log(' npx --yes @gotcos/glasses-server@latest --setup-transcription')
63
+ console.log(' npx --yes @gotcos/glasses-server@latest --setup-transcription --transcription-tier balanced|max')
49
64
  console.log(' npx --yes @gotcos/glasses-server@latest --prepare-only')
50
65
  console.log('')
51
66
  console.log(' Requirements:')
@@ -355,9 +370,18 @@ function upsertEnvValue(file, key, value) {
355
370
  }
356
371
 
357
372
  if (SETUP_TRANSCRIPTION) {
358
- upsertEnvValue(ENV_FILE, 'COS_WHISPER_PREVIEW_MODEL', 'small.en')
359
- process.env.COS_WHISPER_PREVIEW_MODEL = 'small.en'
360
- console.log(green(' ✓') + ' Adaptive transcription selected ' + dim('— Small.en preview · Turbo commit · Large-v3 HQ'))
373
+ const previewModel = TRANSCRIPTION_TIER === 'max' ? 'turbo' : 'small.en'
374
+ const commitModel = TRANSCRIPTION_TIER === 'max' ? 'large-v3' : 'turbo'
375
+ upsertEnvValue(ENV_FILE, 'COS_WHISPER_TRANSCRIPTION_TIER', TRANSCRIPTION_TIER)
376
+ upsertEnvValue(ENV_FILE, 'COS_WHISPER_PREVIEW_MODEL', previewModel)
377
+ upsertEnvValue(ENV_FILE, 'COS_WHISPER_COMMIT_MODEL', commitModel)
378
+ process.env.COS_WHISPER_TRANSCRIPTION_TIER = TRANSCRIPTION_TIER
379
+ process.env.COS_WHISPER_PREVIEW_MODEL = previewModel
380
+ process.env.COS_WHISPER_COMMIT_MODEL = commitModel
381
+ const laneSummary = TRANSCRIPTION_TIER === 'max'
382
+ ? 'Large-v3 preview + commit · Large-v3 HQ'
383
+ : 'Small.en preview · Turbo commit · Large-v3 HQ'
384
+ console.log(green(' ✓') + ` ${TRANSCRIPTION_TIER === 'max' ? 'Max' : 'Balanced'} transcription selected ` + dim(`— ${laneSummary}`))
361
385
  }
362
386
  // Persistent profile (identity + transcription vocabulary)
363
387
  const PROFILE_FILE = join(CONFIG_DIR, '.cos-profile.json')
@@ -446,7 +470,7 @@ const transcriptionSetupFailures = []
446
470
 
447
471
  if (SETUP_TRANSCRIPTION && (!whisperCliPath || !whisperServerPath)) {
448
472
  console.log('')
449
- console.log(red(' ✗ Adaptive transcription needs whisper.cpp'))
473
+ console.log(red(' ✗ Transcription setup needs whisper.cpp'))
450
474
  console.log(' Install it first: ' + bold('brew install whisper-cpp'))
451
475
  console.log(' Then rerun this setup command. No model download was started.')
452
476
  console.log('')
@@ -457,7 +481,9 @@ if (SETUP_TRANSCRIPTION && process.env.SKIP_WHISPER_DOWNLOAD !== '1') {
457
481
  mkdirSync(WHISPER_MODEL_DIR, { recursive: true })
458
482
  const targets = [
459
483
  [WHISPER_MODEL_PATH, WHISPER_MODEL_PARTIAL, WHISPER_MODEL_MIN_BYTES, WHISPER_MODEL_EXPECTED_BYTES],
460
- [WHISPER_SMALL_MODEL_PATH, WHISPER_SMALL_MODEL_PARTIAL, WHISPER_SMALL_MODEL_MIN_BYTES, WHISPER_SMALL_MODEL_EXPECTED_BYTES],
484
+ ...(TRANSCRIPTION_TIER === 'balanced'
485
+ ? [[WHISPER_SMALL_MODEL_PATH, WHISPER_SMALL_MODEL_PARTIAL, WHISPER_SMALL_MODEL_MIN_BYTES, WHISPER_SMALL_MODEL_EXPECTED_BYTES]]
486
+ : []),
461
487
  [WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_PARTIAL, WHISPER_LARGE_MODEL_MIN_BYTES, WHISPER_LARGE_MODEL_EXPECTED_BYTES],
462
488
  ]
463
489
  const missingBytes = targets.reduce((sum, [path, , minimum, expected]) =>
@@ -472,7 +498,7 @@ if (SETUP_TRANSCRIPTION && process.env.SKIP_WHISPER_DOWNLOAD !== '1') {
472
498
  const requiredGB = ((missingBytes + safetyMarginBytes) / 1_000_000_000).toFixed(1)
473
499
  const availableGB = (availableBytes / 1_000_000_000).toFixed(1)
474
500
  console.log('')
475
- console.log(red(' ✗ Not enough free disk space for adaptive transcription'))
501
+ console.log(red(' ✗ Not enough free disk space for transcription setup'))
476
502
  console.log(` Need about ${requiredGB} GB; ${availableGB} GB is available after reusable partial downloads.`)
477
503
  console.log(' Free space, then rerun this setup command. Existing models were not removed.')
478
504
  console.log('')
@@ -550,10 +576,13 @@ if (whisperCliPath && wantsSmallPreview) {
550
576
 
551
577
  if (whisperCliPath && SETUP_TRANSCRIPTION) {
552
578
  if (isValidWhisperModel(WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_MIN_BYTES)) {
553
- console.log(green(' ✓') + ' Large-v3 HQ model ready ' + dim('— saved meetings use full polish'))
579
+ const largeRole = TRANSCRIPTION_TIER === 'max'
580
+ ? 'live preview + commit and saved-meeting polish'
581
+ : 'saved meetings use full polish'
582
+ console.log(green(' ✓') + ' Large-v3 model ready ' + dim(`— ${largeRole}`))
554
583
  } else {
555
584
  if (existsSync(WHISPER_LARGE_MODEL_PATH)) { try { unlinkSync(WHISPER_LARGE_MODEL_PATH) } catch {} }
556
- console.log(yellow(' ⚠') + ' HQ polish model missing')
585
+ console.log(yellow(' ⚠') + ' Large-v3 model missing')
557
586
  console.log(' ' + dim('Downloading ggml-large-v3 (~3.1 GB).'))
558
587
  if (process.env.SKIP_WHISPER_DOWNLOAD === '1') {
559
588
  console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — HQ safely reports unavailable and live Turbo continues')
@@ -566,12 +595,12 @@ if (whisperCliPath && SETUP_TRANSCRIPTION) {
566
595
  expectedBytes: WHISPER_LARGE_MODEL_EXPECTED_BYTES,
567
596
  timeoutMs: 7_200_000,
568
597
  })
569
- console.log(green(' ✓') + ' Large-v3 HQ model downloaded')
598
+ console.log(green(' ✓') + ' Large-v3 model downloaded')
570
599
  } catch (err) {
571
600
  console.log(red(' ✗') + ' Large-v3 download failed ' + dim('— live Turbo remains available'))
572
601
  console.log(' ' + dim('Error: ' + (err.message || err).toString().slice(0, 120)))
573
602
  console.log(' ' + dim('Partial download retained; rerun setup to resume it.'))
574
- transcriptionSetupFailures.push('Large-v3 HQ model')
603
+ transcriptionSetupFailures.push(TRANSCRIPTION_TIER === 'max' ? 'Large-v3 Max/HQ model' : 'Large-v3 HQ model')
575
604
  }
576
605
  }
577
606
  }
@@ -580,13 +609,13 @@ if (whisperCliPath && SETUP_TRANSCRIPTION) {
580
609
  if (PREPARE_ONLY && SETUP_TRANSCRIPTION) {
581
610
  console.log('')
582
611
  if (transcriptionSetupFailures.length > 0) {
583
- console.log(red(' ✗ Adaptive transcription setup incomplete'))
612
+ console.log(red(' ✗ Transcription setup incomplete'))
584
613
  console.log(' Missing: ' + transcriptionSetupFailures.join(', '))
585
614
  console.log(' Rerun this command; retained downloads resume from the last byte received.')
586
615
  console.log('')
587
616
  process.exit(1)
588
617
  }
589
- console.log(green(' ✓ Adaptive transcription setup complete'))
618
+ console.log(green(' ✓ Transcription setup complete'))
590
619
  console.log(' Return to COS Control and Restart, install, or update the managed server.')
591
620
  console.log('')
592
621
  process.exit(0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.20.1",
3
+ "version": "6.21.0",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -298,7 +298,7 @@ async function gracefulShutdown(): Promise<void> {
298
298
  try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
299
299
  stopCodexModelCatalogRefresh()
300
300
  stopWhisperServer()
301
- stopWhisperPreviewServer()
301
+ await stopWhisperPreviewServer()
302
302
  stopLocalTtsServer()
303
303
  clearTimeout(forceExit)
304
304
  process.exit(0)
@@ -107,22 +107,76 @@ function resolveWhisperBin(name: string): string {
107
107
  }
108
108
  const WHISPER_CLI = resolveWhisperBin('whisper-cli')
109
109
  const WHISPER_SERVER = resolveWhisperBin('whisper-server')
110
- const MODEL_PATH = join(process.env.HOME ?? homedir(), '.local/share/whisper-models/ggml-large-v3-turbo.bin')
110
+ const MODEL_DIR = join(process.env.HOME ?? homedir(), '.local/share/whisper-models')
111
+ export const WHISPER_TURBO_MODEL_PATH = join(MODEL_DIR, 'ggml-large-v3-turbo.bin')
112
+ export const WHISPER_LARGE_V3_MODEL_PATH = join(MODEL_DIR, 'ggml-large-v3.bin')
113
+
114
+ export type WhisperCommitRequest = 'turbo' | 'large-v3'
115
+ export type WhisperCommitModel = 'large-v3-turbo' | 'large-v3'
116
+ export type WhisperTranscriptionTier = 'balanced' | 'max'
117
+
118
+ export interface WhisperCommitCapability {
119
+ requestedTier: WhisperTranscriptionTier
120
+ effectiveTier: WhisperTranscriptionTier
121
+ requestedModel: WhisperCommitRequest
122
+ effectiveModel: WhisperCommitModel
123
+ ready: boolean
124
+ configured: boolean
125
+ degraded: boolean
126
+ reason: 'large_v3_model_missing' | 'turbo_model_missing' | null
127
+ promptPolicy: 'full-vocabulary'
128
+ }
129
+
130
+ export function normalizeWhisperTranscriptionTier(raw?: string): WhisperTranscriptionTier {
131
+ return raw?.trim().toLowerCase() === 'max' ? 'max' : 'balanced'
132
+ }
133
+
134
+ export function normalizeWhisperCommitRequest(raw?: string): WhisperCommitRequest {
135
+ const value = raw?.trim().toLowerCase()
136
+ if (value === 'large-v3' || value === 'large_v3' || value === 'ggml-large-v3.bin' || value === 'max') {
137
+ return 'large-v3'
138
+ }
139
+ return 'turbo'
140
+ }
141
+
142
+ function requestedTranscriptionTier(): WhisperTranscriptionTier {
143
+ return normalizeWhisperTranscriptionTier(process.env.COS_WHISPER_TRANSCRIPTION_TIER)
144
+ }
145
+
146
+ function requestedCommitModel(): WhisperCommitRequest {
147
+ const explicit = process.env.COS_WHISPER_COMMIT_MODEL
148
+ return explicit
149
+ ? normalizeWhisperCommitRequest(explicit)
150
+ : requestedTranscriptionTier() === 'max' ? 'large-v3' : 'turbo'
151
+ }
152
+
153
+ const COMMIT_REQUEST = requestedCommitModel()
154
+ const REQUESTED_TRANSCRIPTION_TIER = requestedTranscriptionTier()
155
+ const COMMIT_MODEL_PATH = COMMIT_REQUEST === 'large-v3' && existsSync(WHISPER_LARGE_V3_MODEL_PATH)
156
+ ? WHISPER_LARGE_V3_MODEL_PATH
157
+ : WHISPER_TURBO_MODEL_PATH
158
+ const COMMIT_MODEL: WhisperCommitModel = COMMIT_MODEL_PATH === WHISPER_LARGE_V3_MODEL_PATH
159
+ ? 'large-v3'
160
+ : 'large-v3-turbo'
161
+
162
+ if (COMMIT_REQUEST === 'large-v3' && COMMIT_MODEL !== 'large-v3') {
163
+ console.warn('[whisper-local] Max requested but Large-v3 weights are missing; committed transcription is using Turbo.')
164
+ }
111
165
 
112
166
  // Post-meeting batch transcription uses the full 32-layer Whisper large-v3
113
167
  // instead of turbo's 4-layer decoder. Bake-off on 2026-04-16 showed the full
114
168
  // decoder captures +43% more speech content with zero known-hallucinations on
115
169
  // a real 23.6 min G2 recording. See wk16_2026/asr-bakeoff/report.md.
116
170
  //
117
- // Streaming path stays on turbo (whisper-server + VAD) for latency. Only the
118
- // post-meeting HQ re-transcription uses large-v3 runs fire-and-forget after
119
- // meeting save, so the ~4x wall-time cost is invisible to the user.
171
+ // Balanced live commit stays on Turbo. Max may opt the persistent live worker
172
+ // into Large-v3 on measured hardware. Post-meeting HQ remains a separate batch
173
+ // policy and never inherits the live commit selection.
120
174
  //
121
175
  // DISABLE: set COS_BATCH_LARGE_V3=0 to revert HQ path to turbo. Missing-weights
122
176
  // case is defensive: if ggml-large-v3.bin isn't on disk we log a warning and
123
177
  // fall back to turbo automatically — no broken batch runs.
124
- const BATCH_MODEL_LARGE_V3 = join(process.env.HOME ?? homedir(), '.local/share/whisper-models/ggml-large-v3.bin')
125
- const BATCH_MODEL_TURBO = MODEL_PATH
178
+ const BATCH_MODEL_LARGE_V3 = WHISPER_LARGE_V3_MODEL_PATH
179
+ const BATCH_MODEL_TURBO = WHISPER_TURBO_MODEL_PATH
126
180
  const BATCH_LARGE_V3_ENABLED = process.env.COS_BATCH_LARGE_V3 !== '0'
127
181
 
128
182
  // Silero VAD (ggml) — whisper-server --vad strips silence/noise windows BEFORE the
@@ -286,7 +340,9 @@ function isCosWhisperServerCommand(command: string): boolean {
286
340
  const executablePath = firstToken?.[1] ?? firstToken?.[2] ?? firstToken?.[3] ?? ''
287
341
  const executable = basename(executablePath) === 'whisper-server'
288
342
  const configuredPort = new RegExp(`(?:^|\\s)--port(?:=|\\s+)${WHISPER_SERVER_PORT}(?:\\s|$)`).test(command)
289
- return executable && configuredPort && command.includes(MODEL_PATH)
343
+ const supportedModel = [WHISPER_TURBO_MODEL_PATH, WHISPER_LARGE_V3_MODEL_PATH]
344
+ .some(modelPath => command.includes(modelPath))
345
+ return executable && configuredPort && supportedModel
290
346
  }
291
347
 
292
348
  function collectDescendants(processes: ProcessEntry[], roots: Iterable<number>): Set<number> {
@@ -399,7 +455,9 @@ async function proveWhisperPortClear(): Promise<void> {
399
455
 
400
456
  // Check CLI availability at import time
401
457
  try {
402
- cliAvailable = existsSync(WHISPER_CLI) && existsSync(MODEL_PATH)
458
+ // CLI fallback/HQ semantics are always anchored to the immutable Turbo
459
+ // weights. A Max live selection must never silently rewrite the HQ fallback.
460
+ cliAvailable = existsSync(WHISPER_CLI) && existsSync(WHISPER_TURBO_MODEL_PATH)
403
461
  if (cliAvailable) {
404
462
  console.log(`[whisper-local] whisper-cli available at ${WHISPER_CLI}`)
405
463
  }
@@ -441,7 +499,7 @@ export async function startWhisperServer(): Promise<void> {
441
499
  }
442
500
 
443
501
  async function startWhisperServerAttempt(preflightCompleted = false): Promise<void> {
444
- if (!existsSync(WHISPER_SERVER) || !existsSync(MODEL_PATH)) {
502
+ if (!existsSync(WHISPER_SERVER) || !existsSync(COMMIT_MODEL_PATH)) {
445
503
  serverStartupState = 'unavailable'
446
504
  serverLastError = 'whisper-server or model not found'
447
505
  console.log('[whisper-local] whisper-server or model not found — using CLI fallback')
@@ -461,8 +519,8 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
461
519
  // Assemble startup args. VAD only attaches if the ggml model is actually on
462
520
  // disk — missing-file is logged, not fatal (server still boots without VAD).
463
521
  const serverArgs = [
464
- '-m', MODEL_PATH,
465
- '-t', '16', // M3 Ultra has 24P+8E cores 16 threads for short audio chunks
522
+ '-m', COMMIT_MODEL_PATH,
523
+ '-t', '16', // Metal runs the encoder; measured 4/8/16-thread results favor keeping 16 across Apple silicon.
466
524
  '-l', 'en',
467
525
  '-fa', // Flash attention — faster self-attention on Apple Silicon
468
526
  '--no-speech-thold', '0.7', // Reject silence more aggressively (default 0.6)
@@ -599,7 +657,7 @@ export function getWhisperHealth(): {
599
657
  } {
600
658
  return {
601
659
  server: serverAvailable,
602
- serverConfigured: existsSync(WHISPER_SERVER) && existsSync(MODEL_PATH),
660
+ serverConfigured: existsSync(WHISPER_SERVER) && existsSync(COMMIT_MODEL_PATH),
603
661
  cli: cliAvailable,
604
662
  consecutiveFailures: serverConsecutiveFailures,
605
663
  restarting: serverRestarting || serverStarting,
@@ -610,6 +668,38 @@ export function getWhisperHealth(): {
610
668
  }
611
669
  }
612
670
 
671
+ /** Path-free truth for Control and companion capability rendering. The
672
+ * requested preset may degrade to Turbo when Large-v3 weights are absent, but
673
+ * the process never reports Max as effective unless the resident worker is
674
+ * actually using Large-v3. */
675
+ export function getWhisperCommitCapability(): WhisperCommitCapability {
676
+ const turboPresent = existsSync(WHISPER_TURBO_MODEL_PATH)
677
+ const largePresent = existsSync(WHISPER_LARGE_V3_MODEL_PATH)
678
+ const configured = existsSync(WHISPER_SERVER) && existsSync(COMMIT_MODEL_PATH)
679
+ const requestedTier: WhisperTranscriptionTier = COMMIT_REQUEST === 'large-v3'
680
+ ? 'max'
681
+ : REQUESTED_TRANSCRIPTION_TIER
682
+ const effectiveTier: WhisperTranscriptionTier = COMMIT_MODEL === 'large-v3' ? 'max' : 'balanced'
683
+ const reason = COMMIT_REQUEST === 'large-v3' && !largePresent
684
+ ? 'large_v3_model_missing' as const
685
+ // Turbo is the immutable recovery model even when Max is active. Missing
686
+ // fallback weights are therefore degraded health, not a clean Max state.
687
+ : !turboPresent
688
+ ? 'turbo_model_missing' as const
689
+ : null
690
+ return {
691
+ requestedTier,
692
+ effectiveTier,
693
+ requestedModel: COMMIT_REQUEST,
694
+ effectiveModel: COMMIT_MODEL,
695
+ ready: serverAvailable,
696
+ configured,
697
+ degraded: requestedTier !== effectiveTier || reason !== null,
698
+ reason,
699
+ promptPolicy: 'full-vocabulary',
700
+ }
701
+ }
702
+
613
703
  /** Public, path-free truth about whether an HQ request can actually run the
614
704
  * full large-v3 decoder. Keep this separate from generic Whisper liveness: the
615
705
  * persistent turbo server may be healthy while HQ weights or the CLI are not. */
@@ -931,13 +1021,23 @@ function buildPrompt(context?: string, isQuiet?: boolean): string {
931
1021
  * which can receive a null C string after VAD returns no speech and crash the
932
1022
  * native server before an HTTP response exists.
933
1023
  */
934
- async function transcribeViaServer(audioBuffer: Buffer, context?: string, isQuiet?: boolean): Promise<{ text: string; words?: WhisperWord[] }> {
1024
+ async function transcribeViaServer(
1025
+ audioBuffer: Buffer,
1026
+ context?: string,
1027
+ isQuiet?: boolean,
1028
+ promptPolicy: 'full-vocabulary' | 'none' = 'full-vocabulary',
1029
+ ): Promise<{ text: string; words?: WhisperWord[] }> {
935
1030
  const formData = new FormData()
936
1031
  // Convert Buffer to Uint8Array to satisfy Blob's BlobPart type constraint
937
1032
  const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/wav' })
938
1033
  formData.append('file', blob, 'recording.wav')
939
1034
  formData.append('response_format', 'json')
940
- formData.append('prompt', buildPrompt(context, isQuiet))
1035
+ // Cosmetic preview decodes must not receive decoder bias. Omitting the field
1036
+ // (instead of sending an empty string) keeps the wire contract unambiguous
1037
+ // and lets diagnostics truthfully report promptPolicy: "none".
1038
+ if (promptPolicy === 'full-vocabulary') {
1039
+ formData.append('prompt', buildPrompt(context, isQuiet))
1040
+ }
941
1041
  // Anti-hallucination handled by client-side filter + context filtering.
942
1042
  // Whisper-level entropy/logprob thresholds were too aggressive — silently dropped
943
1043
  // legitimate speech from quiet sources (laptop speakers through G2 mic).
@@ -972,7 +1072,7 @@ async function transcribeViaCLI(audioBuffer: Buffer, context?: string, isQuiet?:
972
1072
 
973
1073
  return await new Promise<string>((resolve, reject) => {
974
1074
  const proc = spawn(WHISPER_CLI, [
975
- '-m', MODEL_PATH,
1075
+ '-m', WHISPER_TURBO_MODEL_PATH,
976
1076
  '-f', tmpWav,
977
1077
  '-t', '12',
978
1078
  '-l', 'en',
@@ -1080,7 +1180,10 @@ export async function transcribeLocal(
1080
1180
  audioBuffer: Buffer,
1081
1181
  context?: string,
1082
1182
  isQuiet?: boolean,
1083
- opts?: { affectsCircuit?: boolean },
1183
+ opts?: {
1184
+ affectsCircuit?: boolean
1185
+ promptPolicy?: 'full-vocabulary' | 'none'
1186
+ },
1084
1187
  ): Promise<{ text: string; backend: 'server' | 'cli'; words?: WhisperWord[] }> {
1085
1188
  const start = Date.now()
1086
1189
  const affectsCircuit = opts?.affectsCircuit !== false
@@ -1096,7 +1199,7 @@ export async function transcribeLocal(
1096
1199
  // Try whisper-server first (fastest: ~50-100ms, includes DTW word timestamps)
1097
1200
  if (serverAvailable) {
1098
1201
  try {
1099
- const result = await transcribeViaServer(audioBuffer, context, isQuiet)
1202
+ const result = await transcribeViaServer(audioBuffer, context, isQuiet, opts?.promptPolicy)
1100
1203
  const text = applyCorrections(result.text)
1101
1204
  const words = result.words?.map(w => ({ ...w, word: applyCorrections(w.word) }))
1102
1205
  const elapsed = Date.now() - start
@@ -1,19 +1,24 @@
1
- // Adaptive provisional transcription. This sidecar is deliberately isolated
2
- // from the authoritative large-v3-turbo server in whisper-local.ts:
3
- // small.en -> cosmetic prompt preview only
4
- // turbo -> committed live transcript (unchanged)
5
- // large-v3 -> HQ save/polish (unchanged)
1
+ // Adaptive provisional transcription:
2
+ // Balanced -> isolated Small.en cosmetic preview + Turbo live commit
3
+ // Max -> resident Large-v3 worker reused for preview + live commit
4
+ // polish -> Large-v3 save pass (unchanged)
6
5
 
7
- import { spawn } from 'node:child_process'
6
+ import { execFile, spawn } from 'node:child_process'
8
7
  import type { ChildProcess } from 'node:child_process'
9
8
  import { existsSync } from 'node:fs'
10
9
  import { homedir } from 'node:os'
11
- import { join } from 'node:path'
12
- import { applyCorrections, getWhisperHealth, transcribeLocal } from './whisper-local.js'
13
- import { getOwnerName, getVocabulary } from './profile.js'
10
+ import { basename, join } from 'node:path'
11
+ import {
12
+ applyCorrections,
13
+ getWhisperCommitCapability,
14
+ getWhisperHealth,
15
+ transcribeLocal,
16
+ type WhisperCommitModel,
17
+ type WhisperTranscriptionTier,
18
+ } from './whisper-local.js'
14
19
 
15
20
  export type WhisperPreviewRequest = 'auto' | 'small.en' | 'turbo' | 'off'
16
- export type WhisperPreviewModel = 'small.en' | 'large-v3-turbo' | null
21
+ export type WhisperPreviewModel = 'small.en' | WhisperCommitModel | null
17
22
  export type WhisperPreviewReason =
18
23
  | 'disabled'
19
24
  | 'small_model_missing'
@@ -22,6 +27,8 @@ export type WhisperPreviewReason =
22
27
  | 'preview_start_failed'
23
28
  | 'preview_sidecar_unavailable'
24
29
  | 'turbo_unavailable'
30
+ | 'large_v3_model_missing'
31
+ | 'turbo_model_missing'
25
32
  | null
26
33
 
27
34
  export interface WhisperPreviewCapability {
@@ -31,7 +38,14 @@ export interface WhisperPreviewCapability {
31
38
  backend: 'whisper-preview-server' | 'whisper-server' | null
32
39
  degraded: boolean
33
40
  reason: WhisperPreviewReason
34
- committedModel: 'large-v3-turbo'
41
+ previewDegraded: boolean
42
+ commitDegraded: boolean
43
+ commitReason: 'large_v3_model_missing' | 'turbo_model_missing' | null
44
+ committedModel: WhisperCommitModel
45
+ requestedCommitModel: 'turbo' | 'large-v3'
46
+ requestedTier: WhisperTranscriptionTier
47
+ effectiveTier: WhisperTranscriptionTier
48
+ promptPolicy: 'none'
35
49
  }
36
50
 
37
51
  const MODEL_DIR = join(process.env.HOME ?? homedir(), '.local/share/whisper-models')
@@ -42,6 +56,8 @@ const WHISPER_SERVER = ['/opt/homebrew/bin/whisper-server', '/usr/local/bin/whis
42
56
  .find(existsSync) ?? '/opt/homebrew/bin/whisper-server'
43
57
  const PREVIEW_PORT = 8177
44
58
  const PREVIEW_URL = `http://127.0.0.1:${PREVIEW_PORT}`
59
+ const PROCESS_PROBE_TIMEOUT_MS = 2_000
60
+ const LSOF_BIN = existsSync('/usr/sbin/lsof') ? '/usr/sbin/lsof' : 'lsof'
45
61
 
46
62
  let previewProcess: ChildProcess | null = null
47
63
  let previewAvailable = false
@@ -49,6 +65,79 @@ let previewStarting = false
49
65
  let previewFailure: WhisperPreviewReason = null
50
66
  let warnedInvalidChoice = false
51
67
 
68
+ interface ProcessEntry {
69
+ pid: number
70
+ ppid: number
71
+ command: string
72
+ }
73
+
74
+ function runProcessProbe(file: string, args: string[]): Promise<string> {
75
+ return new Promise((resolve, reject) => {
76
+ execFile(file, args, {
77
+ encoding: 'utf8', timeout: PROCESS_PROBE_TIMEOUT_MS,
78
+ maxBuffer: 1024 * 1024, killSignal: 'SIGKILL',
79
+ }, (error, stdout) => error ? reject(error) : resolve(String(stdout)))
80
+ })
81
+ }
82
+
83
+ async function previewListeningPids(): Promise<number[]> {
84
+ try {
85
+ const output = await runProcessProbe(LSOF_BIN, ['-nP', `-iTCP:${PREVIEW_PORT}`, '-sTCP:LISTEN', '-t'])
86
+ return output.split(/\s+/).map(Number).filter(pid => Number.isInteger(pid) && pid > 0)
87
+ } catch (error: any) {
88
+ if (error?.code === 1 || error?.status === 1) return []
89
+ throw new Error(`unable to inspect preview port ${PREVIEW_PORT}`)
90
+ }
91
+ }
92
+
93
+ async function listProcesses(): Promise<ProcessEntry[]> {
94
+ const output = await runProcessProbe('/bin/ps', ['-axww', '-o', 'pid=,ppid=,command='])
95
+ return output.split('\n').flatMap(line => {
96
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/)
97
+ return match ? [{ pid: Number(match[1]), ppid: Number(match[2]), command: match[3] }] : []
98
+ })
99
+ }
100
+
101
+ function isCosPreviewCommand(command: string): boolean {
102
+ const firstToken = command.trim().match(/^(?:"([^"]+)"|'([^']+)'|(\S+))/)
103
+ const executablePath = firstToken?.[1] ?? firstToken?.[2] ?? firstToken?.[3] ?? ''
104
+ return basename(executablePath) === 'whisper-server'
105
+ && new RegExp(`(?:^|\\s)--port(?:=|\\s+)${PREVIEW_PORT}(?:\\s|$)`).test(command)
106
+ && command.includes(WHISPER_SMALL_EN_MODEL_PATH)
107
+ }
108
+
109
+ /** Reap only a listener proven to be our exact Small.en/8177 command. An
110
+ * unrelated local service is never contacted with audio or terminated. */
111
+ async function reclaimPreviewPort(): Promise<'clear' | 'reaped' | 'foreign'> {
112
+ const listeners = await previewListeningPids()
113
+ if (listeners.length === 0) return 'clear'
114
+ const processes = await listProcesses()
115
+ const byPid = new Map(processes.map(entry => [entry.pid, entry]))
116
+ if (!listeners.every(pid => isCosPreviewCommand(byPid.get(pid)?.command ?? ''))) return 'foreign'
117
+
118
+ const targets = new Set(listeners)
119
+ let changed = true
120
+ while (changed) {
121
+ changed = false
122
+ for (const entry of processes) {
123
+ if (!targets.has(entry.pid) && targets.has(entry.ppid)) {
124
+ targets.add(entry.pid)
125
+ changed = true
126
+ }
127
+ }
128
+ }
129
+ for (const pid of [...targets].reverse()) {
130
+ try { process.kill(pid, 'SIGKILL') } catch (error: any) {
131
+ if (error?.code !== 'ESRCH') throw error
132
+ }
133
+ }
134
+ for (let attempt = 0; attempt < 20; attempt++) {
135
+ if ((await previewListeningPids()).length === 0) return 'reaped'
136
+ await new Promise(resolve => setTimeout(resolve, 100))
137
+ }
138
+ throw new Error(`verified Small.en worker still owns port ${PREVIEW_PORT}`)
139
+ }
140
+
52
141
  export function normalizeWhisperPreviewRequest(raw?: string): WhisperPreviewRequest {
53
142
  const value = raw?.trim().toLowerCase()
54
143
  // Backward compatibility is deliberate: simply updating the server keeps
@@ -64,6 +153,11 @@ export function normalizeWhisperPreviewRequest(raw?: string): WhisperPreviewRequ
64
153
  function requestedPreviewModel(): WhisperPreviewRequest {
65
154
  const raw = process.env.COS_WHISPER_PREVIEW_MODEL
66
155
  ?? process.env.COS_WHISPER_REALTIME_MODEL // migration alias for early private installs
156
+ if (!raw && process.env.COS_WHISPER_TRANSCRIPTION_TIER) {
157
+ return process.env.COS_WHISPER_TRANSCRIPTION_TIER.trim().toLowerCase() === 'max'
158
+ ? 'turbo'
159
+ : 'small.en'
160
+ }
67
161
  const normalized = normalizeWhisperPreviewRequest(raw)
68
162
  if (raw && normalized === 'auto' && !['auto', 'adaptive'].includes(raw.trim().toLowerCase()) && !warnedInvalidChoice) {
69
163
  warnedInvalidChoice = true
@@ -74,17 +168,24 @@ function requestedPreviewModel(): WhisperPreviewRequest {
74
168
 
75
169
  function selectedPreviewModel(requested = requestedPreviewModel()): WhisperPreviewModel {
76
170
  if (requested === 'off') return null
77
- if (requested === 'turbo') return 'large-v3-turbo'
78
- if (requested === 'small.en') return existsSync(WHISPER_SMALL_EN_MODEL_PATH) ? 'small.en' : 'large-v3-turbo'
79
- return existsSync(WHISPER_SMALL_EN_MODEL_PATH) ? 'small.en' : 'large-v3-turbo'
171
+ const primary = getWhisperCommitCapability().effectiveModel
172
+ if (requested === 'turbo') return primary
173
+ if (requested === 'small.en') return existsSync(WHISPER_SMALL_EN_MODEL_PATH) ? 'small.en' : primary
174
+ return existsSync(WHISPER_SMALL_EN_MODEL_PATH) ? 'small.en' : primary
80
175
  }
81
176
 
82
177
  export function getWhisperPreviewCapability(): WhisperPreviewCapability {
178
+ const commit = getWhisperCommitCapability()
83
179
  const requested = requestedPreviewModel()
84
180
  if (requested === 'off') {
85
181
  return {
86
182
  requested, effectiveModel: null, ready: false, backend: null,
87
- degraded: false, reason: 'disabled', committedModel: 'large-v3-turbo',
183
+ degraded: commit.degraded, reason: commit.reason ?? 'disabled',
184
+ previewDegraded: false, commitDegraded: commit.degraded, commitReason: commit.reason,
185
+ committedModel: commit.effectiveModel,
186
+ requestedCommitModel: commit.requestedModel,
187
+ requestedTier: commit.requestedTier, effectiveTier: commit.effectiveTier,
188
+ promptPolicy: 'none',
88
189
  }
89
190
  }
90
191
 
@@ -94,25 +195,39 @@ export function getWhisperPreviewCapability(): WhisperPreviewCapability {
94
195
  if (selected === 'small.en' && previewAvailable) {
95
196
  return {
96
197
  requested, effectiveModel: 'small.en', ready: true,
97
- backend: 'whisper-preview-server', degraded: false, reason: null,
98
- committedModel: 'large-v3-turbo',
198
+ backend: 'whisper-preview-server', degraded: commit.degraded, reason: commit.reason,
199
+ previewDegraded: false, commitDegraded: commit.degraded, commitReason: commit.reason,
200
+ committedModel: commit.effectiveModel,
201
+ requestedCommitModel: commit.requestedModel,
202
+ requestedTier: commit.requestedTier, effectiveTier: commit.effectiveTier,
203
+ promptPolicy: 'none',
99
204
  }
100
205
  }
101
206
 
102
207
  const smallWasExpected = requested === 'small.en' || (requested === 'auto' && smallPresent)
103
- const reason: WhisperPreviewReason = requested === 'small.en' && !smallPresent
208
+ const previewDegraded = smallWasExpected
209
+ const reason: WhisperPreviewReason = commit.reason
210
+ ? commit.reason
211
+ : requested === 'small.en' && !smallPresent
104
212
  ? 'small_model_missing'
105
213
  : smallWasExpected
106
214
  ? (previewFailure ?? (previewStarting ? null : 'preview_sidecar_unavailable'))
107
215
  : turboReady ? null : 'turbo_unavailable'
108
216
  return {
109
217
  requested,
110
- effectiveModel: turboReady ? 'large-v3-turbo' : selected,
218
+ effectiveModel: turboReady ? commit.effectiveModel : selected,
111
219
  ready: turboReady,
112
220
  backend: turboReady ? 'whisper-server' : null,
113
- degraded: smallWasExpected,
221
+ degraded: previewDegraded || commit.degraded,
114
222
  reason,
115
- committedModel: 'large-v3-turbo',
223
+ previewDegraded,
224
+ commitDegraded: commit.degraded,
225
+ commitReason: commit.reason,
226
+ committedModel: commit.effectiveModel,
227
+ requestedCommitModel: commit.requestedModel,
228
+ requestedTier: commit.requestedTier,
229
+ effectiveTier: commit.effectiveTier,
230
+ promptPolicy: 'none',
116
231
  }
117
232
  }
118
233
 
@@ -138,12 +253,19 @@ export async function startWhisperPreviewServer(): Promise<void> {
138
253
  previewFailure = null
139
254
  try {
140
255
  try {
141
- const occupied = await endpointReady('/health')
142
- if (occupied.ok) {
256
+ const portState = await reclaimPreviewPort()
257
+ if (portState === 'foreign') {
143
258
  previewFailure = 'preview_port_busy'
144
259
  return
145
260
  }
146
- } catch { /* clear port is expected */ }
261
+ if (portState === 'reaped') {
262
+ console.log('[whisper-preview] reaped a stale Small.en worker before restart')
263
+ }
264
+ } catch (error) {
265
+ previewFailure = 'preview_start_failed'
266
+ console.warn(`[whisper-preview] port preflight failed: ${error instanceof Error ? error.message : error}`)
267
+ return
268
+ }
147
269
 
148
270
  const args = [
149
271
  '-m', WHISPER_SMALL_EN_MODEL_PATH,
@@ -192,28 +314,44 @@ export async function startWhisperPreviewServer(): Promise<void> {
192
314
  }
193
315
  }
194
316
 
195
- export function stopWhisperPreviewServer(): void {
317
+ function waitForPreviewClose(child: ChildProcess, timeoutMs: number): Promise<boolean> {
318
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
319
+ return new Promise(resolve => {
320
+ let settled = false
321
+ const finish = (closed: boolean) => {
322
+ if (settled) return
323
+ settled = true
324
+ clearTimeout(timeout)
325
+ child.off('close', onClose)
326
+ resolve(closed)
327
+ }
328
+ const onClose = () => finish(true)
329
+ const timeout = setTimeout(() => finish(false), timeoutMs)
330
+ child.once('close', onClose)
331
+ })
332
+ }
333
+
334
+ export async function stopWhisperPreviewServer(): Promise<void> {
196
335
  const child = previewProcess
197
336
  previewProcess = null
198
337
  previewAvailable = false
199
338
  previewStarting = false
200
339
  if (child) {
201
340
  try { child.kill('SIGTERM') } catch { /* already exited */ }
341
+ if (!await waitForPreviewClose(child, 2_000)) {
342
+ try { child.kill('SIGKILL') } catch { /* already exited */ }
343
+ await waitForPreviewClose(child, 1_000)
344
+ }
202
345
  }
203
346
  }
204
347
 
205
- function previewPrompt(): string {
206
- const vocabulary = getVocabulary()
207
- return vocabulary.length > 0
208
- ? [getOwnerName(), ...vocabulary].join(', ')
209
- : `${getOwnerName()}. COS Glasses. Even G2.`
210
- }
211
-
212
348
  async function transcribeViaPreviewServer(audioBuffer: Buffer): Promise<string> {
213
349
  const formData = new FormData()
214
350
  formData.append('file', new Blob([new Uint8Array(audioBuffer)], { type: 'audio/wav' }), 'recording.wav')
215
351
  formData.append('response_format', 'json')
216
- formData.append('prompt', previewPrompt())
352
+ // Preview text is cosmetic and the same audio is authoritatively decoded by
353
+ // the commit lane. Small.en is disproportionately suggestible on short
354
+ // windows, so never bias this provisional decode with profile vocabulary.
217
355
  formData.append('suppress_non_speech', 'true')
218
356
  const response = await endpointReady('/inference', { method: 'POST', body: formData }, 5_000)
219
357
  if (!response.ok) throw new Error(`preview server ${response.status}`)
@@ -226,7 +364,7 @@ async function transcribeViaPreviewServer(audioBuffer: Buffer): Promise<string>
226
364
  * non-circuit Turbo decode and can never write committed transcript state. */
227
365
  export async function transcribeWhisperPreview(audioBuffer: Buffer): Promise<{
228
366
  text: string
229
- model: 'small.en' | 'large-v3-turbo'
367
+ model: 'small.en' | WhisperCommitModel
230
368
  backend: 'whisper-preview-server' | 'whisper-server'
231
369
  }> {
232
370
  if (previewAvailable) {
@@ -238,6 +376,9 @@ export async function transcribeWhisperPreview(audioBuffer: Buffer): Promise<{
238
376
  console.warn(`[whisper-preview] small.en preview failed; falling back to Turbo: ${error instanceof Error ? error.message : error}`)
239
377
  }
240
378
  }
241
- const result = await transcribeLocal(audioBuffer, undefined, undefined, { affectsCircuit: false })
242
- return { text: result.text, model: 'large-v3-turbo', backend: 'whisper-server' }
379
+ const result = await transcribeLocal(audioBuffer, undefined, undefined, {
380
+ affectsCircuit: false,
381
+ promptPolicy: 'none',
382
+ })
383
+ return { text: result.text, model: getWhisperCommitCapability().effectiveModel, backend: 'whisper-server' }
243
384
  }