@gotcos/glasses-server 6.15.5 → 6.16.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
@@ -70,11 +70,15 @@ BIND_HOST=0.0.0.0
70
70
 
71
71
  # ── VOICE (optional) ────────────────────────────────────────────────────
72
72
  # Local transcription is FREE via whisper.cpp (brew install whisper-cpp; the
73
- # model auto-downloads on first run). Voice is local-only by default. Merely
73
+ # real-time turbo model auto-downloads on first run). Full HQ dictation also
74
+ # needs ggml-large-v3.bin as documented in README. Voice is local-only by default. Merely
74
75
  # configuring a key never uploads audio. To allow OpenAI Whisper only after a
75
76
  # local failure, set BOTH the exact opt-in and a key:
76
77
  # COS_OPENAI_WHISPER_FALLBACK=1
77
78
  # OPENAI_API_KEY=sk-...
79
+ # COS_HQ_SPECULATIVE_WARM=0 # disable background HQ warm
80
+ # COS_BATCH_LARGE_V3=0 # explicitly use turbo instead of full HQ
81
+ # COS_HQ_BEAM_INTERACTIVE=2 # interactive only; meetings stay at beam 5
78
82
 
79
83
  # Spoken reply playback defaults to local Kokoro on Apple silicon Macs. The
80
84
  # first run creates a private venv and downloads the model. Local mode fails
package/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## 6.16.0
2
+
3
+ - **Truthful HQ results.** An HQ request is reported as HQ only when the full
4
+ local large-v3 decoder actually ran. Turbo, real-time server, long-audio, and
5
+ decode-error fallbacks now retain the requested mode while returning their
6
+ actual quality, backend, degradation flag, and bounded reason code.
7
+ - **HQ capability health.** `/api/health` and `/api/models` add a path-free
8
+ `capabilities.transcription.hq` block with availability, model, backend, and
9
+ a user-safe missing-prerequisite reason. Generic Whisper liveness no longer
10
+ implies that large-v3 HQ is installed.
11
+ - **Phone-visible fallback telemetry.** One-shot transcription and prompt-draft
12
+ finalize responses expose the same additive quality fields. Draft finalize
13
+ aggregates the records it actually used and reuses a successful degraded
14
+ warm result instead of paying for an identical second turbo decode.
15
+ - **Default unchanged.** Absent an explicit Fast request, prompt dictation still
16
+ requests HQ. `COS_HQ_SPECULATIVE_WARM=0` remains the immediate warm-path
17
+ rollback, and meeting batch beam/isolation behavior is unchanged.
18
+
1
19
  ## 6.15.5
2
20
 
3
21
  - **Speculative HQ warm (no EHPK).** While a prompt-draft chunk is acknowledged,
package/README.md CHANGED
@@ -106,6 +106,10 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
106
106
  Whisper fallback is optional and requires both the exact
107
107
  `COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a configured key; a key alone never
108
108
  uploads audio.
109
+ - HQ prompt dictation is requested by default; the phone's **Fast mode** switch
110
+ opts into turbo. Server 6.16.0 reports whether full local large-v3 actually
111
+ ran, and compatible companions alert once if an HQ request used Fast or
112
+ Cloud instead of silently claiming HQ.
109
113
  - Local-first spoken reply playback through Kokoro on Apple silicon. The first
110
114
  use creates a private Python environment and downloads its model without
111
115
  blocking the API. Selecting Local fails closed; `local_first` can fall back
@@ -137,6 +141,32 @@ Telegram activity export is disabled by default even when a private COS
137
141
  pipeline contains `.telegram_config.json`; enable it only with the explicit
138
142
  `COS_TELEGRAM_NOTIFICATIONS=1` opt-in.
139
143
 
144
+ ## HQ dictation
145
+
146
+ Prompt dictation defaults to HQ. The phone owns the preference: **Fast mode
147
+ OFF** requests HQ, and **Fast mode ON** requests turbo. The Mac performs all
148
+ decoding; the phone does not run Whisper.
149
+
150
+ The first server start downloads the real-time turbo model. True HQ additionally
151
+ requires the full `ggml-large-v3.bin` model (about 3.1 GB):
152
+
153
+ ```bash
154
+ mkdir -p "$HOME/.local/share/whisper-models"
155
+ curl -fL --progress-bar \
156
+ https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin \
157
+ -o "$HOME/.local/share/whisper-models/ggml-large-v3.bin.partial"
158
+ mv "$HOME/.local/share/whisper-models/ggml-large-v3.bin.partial" \
159
+ "$HOME/.local/share/whisper-models/ggml-large-v3.bin"
160
+ ```
161
+
162
+ Restart the server, then confirm
163
+ `capabilities.transcription.hq.hqAvailable: true` at `/api/health`. The response
164
+ does not expose local paths. If the CLI or model is unavailable, dictation stays
165
+ usable on Fast and reports the downgrade truthfully. Set
166
+ `COS_HQ_SPECULATIVE_WARM=0` to disable background HQ warm immediately; set
167
+ `COS_BATCH_LARGE_V3=0` to explicitly use turbo. Interactive HQ uses beam 2 by
168
+ default (`COS_HQ_BEAM_INTERACTIVE`); meeting batch remains beam 5.
169
+
140
170
  ## Run from source
141
171
 
142
172
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.15.5",
3
+ "version": "6.16.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": {
@@ -20,6 +20,7 @@ export interface PromptDraftTranscriptRecord {
20
20
  actualQuality: 'hq' | 'fast' | 'cloud'
21
21
  backend: string
22
22
  degraded: boolean
23
+ degradationReason?: string
23
24
  acceptedDegraded?: boolean
24
25
  }
25
26
 
@@ -34,6 +34,7 @@ export interface TranscribeAudioResult {
34
34
  requestedMode: TranscribeMode
35
35
  actualQuality: 'hq' | 'fast' | 'cloud'
36
36
  degraded: boolean
37
+ degradationReason?: string
37
38
  elapsedMs: number
38
39
  audioBytes: number
39
40
  }
@@ -160,6 +161,7 @@ export async function transcribeAudioBuffer(
160
161
  let text: string
161
162
  let backend: string
162
163
  let actualQuality: 'hq' | 'fast' | 'cloud'
164
+ let degradationReason: string | undefined = effectiveMode !== requestedMode ? 'audio_too_long' : undefined
163
165
  const tStart = performance.now()
164
166
 
165
167
  if (effectiveMode === 'hq') {
@@ -169,10 +171,16 @@ export async function transcribeAudioBuffer(
169
171
  const enhanced = await enhanceAudio(audioBuffer, { profile: enhanceProfile })
170
172
  const result = await transcribeHighQuality(enhanced)
171
173
  text = result.text
172
- backend = enhanceProfile === 'light' ? 'hq-large-v3-light' : 'hq-large-v3'
173
- actualQuality = 'hq'
174
+ actualQuality = result.actualQuality
175
+ if (result.actualQuality === 'hq') {
176
+ backend = enhanceProfile === 'light' ? 'hq-large-v3-light' : 'hq-large-v3'
177
+ } else {
178
+ backend = result.backend === 'whisper-cli' ? 'fast-cli-turbo' : 'fast-local-server'
179
+ degradationReason = result.degradationReason ?? 'hq_unavailable'
180
+ }
174
181
  } catch (hqErr: any) {
175
182
  console.warn(`[transcribe] HQ path failed, falling back to fast: ${hqErr.message}`)
183
+ degradationReason = 'hq_decode_failed'
176
184
  try {
177
185
  const result = await transcribeLocal(audioBuffer)
178
186
  text = result.text
@@ -268,6 +276,7 @@ export async function transcribeAudioBuffer(
268
276
  requestedMode,
269
277
  actualQuality,
270
278
  degraded: requestedMode === 'hq' && actualQuality !== 'hq',
279
+ ...(degradationReason ? { degradationReason } : {}),
271
280
  elapsedMs,
272
281
  audioBytes: audioBuffer.length,
273
282
  }
@@ -42,6 +42,49 @@ export interface WhisperSegment {
42
42
  words?: WhisperWord[]
43
43
  }
44
44
 
45
+ export type HighQualityUnavailableReason =
46
+ | 'hq_disabled'
47
+ | 'whisper_cli_missing'
48
+ | 'turbo_model_missing'
49
+ | 'large_v3_model_missing'
50
+
51
+ export interface HighQualityTranscriptionCapability {
52
+ hqAvailable: boolean
53
+ model: 'large-v3' | null
54
+ backend: 'whisper-cli' | null
55
+ reason: HighQualityUnavailableReason | null
56
+ }
57
+
58
+ export interface HighQualityTranscriptionResult {
59
+ text: string
60
+ words?: WhisperWord[]
61
+ model: 'large-v3' | 'turbo'
62
+ backend: 'whisper-cli' | 'whisper-server'
63
+ actualQuality: 'hq' | 'fast'
64
+ degradationReason?: HighQualityUnavailableReason
65
+ }
66
+
67
+ export function classifyHighQualityTranscriptionCapability(input: {
68
+ enabled: boolean
69
+ cliPresent: boolean
70
+ turboReady: boolean
71
+ largeV3Present: boolean
72
+ }): HighQualityTranscriptionCapability {
73
+ if (!input.enabled) {
74
+ return { hqAvailable: false, model: null, backend: null, reason: 'hq_disabled' }
75
+ }
76
+ if (!input.cliPresent) {
77
+ return { hqAvailable: false, model: null, backend: null, reason: 'whisper_cli_missing' }
78
+ }
79
+ if (!input.turboReady) {
80
+ return { hqAvailable: false, model: null, backend: null, reason: 'turbo_model_missing' }
81
+ }
82
+ if (!input.largeV3Present) {
83
+ return { hqAvailable: false, model: null, backend: null, reason: 'large_v3_model_missing' }
84
+ }
85
+ return { hqAvailable: true, model: 'large-v3', backend: 'whisper-cli', reason: null }
86
+ }
87
+
45
88
  interface WhisperJsonResponse {
46
89
  text?: unknown
47
90
  }
@@ -554,6 +597,20 @@ export function getWhisperHealth(): {
554
597
  }
555
598
  }
556
599
 
600
+ /** Public, path-free truth about whether an HQ request can actually run the
601
+ * full large-v3 decoder. Keep this separate from generic Whisper liveness: the
602
+ * persistent turbo server may be healthy while HQ weights or the CLI are not. */
603
+ export function getHighQualityTranscriptionCapability(): HighQualityTranscriptionCapability {
604
+ // cliAvailable also proves the turbo model exists. That fallback is part of
605
+ // the current decoder contract and is initialized once at process startup.
606
+ return classifyHighQualityTranscriptionCapability({
607
+ enabled: BATCH_LARGE_V3_ENABLED,
608
+ cliPresent: existsSync(WHISPER_CLI),
609
+ turboReady: cliAvailable,
610
+ largeV3Present: existsSync(BATCH_MODEL_LARGE_V3),
611
+ })
612
+ }
613
+
557
614
  /**
558
615
  * Reconcile a cached unavailable flag with the daemon's live health endpoint.
559
616
  * Only successful inference resets the failure count: /health can be responsive
@@ -641,10 +698,18 @@ export async function transcribeHighQuality(
641
698
  audioBuffer: Buffer,
642
699
  context?: string,
643
700
  opts: { priority?: 'interactive' | 'batch' } = {},
644
- ): Promise<{ text: string; words?: WhisperWord[] }> {
701
+ ): Promise<HighQualityTranscriptionResult> {
645
702
  if (!cliAvailable) {
646
703
  // Fall back to server (no beam search available via HTTP API)
647
- return transcribeLocal(audioBuffer, context)
704
+ const fallback = await transcribeLocal(audioBuffer, context)
705
+ return {
706
+ text: fallback.text,
707
+ words: fallback.words,
708
+ model: 'turbo',
709
+ backend: fallback.backend === 'server' ? 'whisper-server' : 'whisper-cli',
710
+ actualQuality: 'fast',
711
+ degradationReason: existsSync(WHISPER_CLI) ? 'turbo_model_missing' : 'whisper_cli_missing',
712
+ }
648
713
  }
649
714
 
650
715
  const start = Date.now()
@@ -769,7 +834,17 @@ export async function transcribeHighQuality(
769
834
  `${words ? `, ${words.length} words` : ''}): ` +
770
835
  `"${corrected.slice(0, 80)}${corrected.length > 80 ? '...' : ''}"`,
771
836
  )
772
- return words ? { text: corrected, words } : { text: corrected }
837
+ const metadata = useLargeV3
838
+ ? { model: 'large-v3' as const, backend: 'whisper-cli' as const, actualQuality: 'hq' as const }
839
+ : {
840
+ model: 'turbo' as const,
841
+ backend: 'whisper-cli' as const,
842
+ actualQuality: 'fast' as const,
843
+ degradationReason: BATCH_LARGE_V3_ENABLED
844
+ ? 'large_v3_model_missing' as const
845
+ : 'hq_disabled' as const,
846
+ }
847
+ return words ? { text: corrected, words, ...metadata } : { text: corrected, ...metadata }
773
848
  } finally {
774
849
  try { unlinkSync(tmpWav) } catch { /* cleanup */ }
775
850
  if (captureBatchWords) {
@@ -8,7 +8,11 @@ import { getServerInstanceId } from '../lib/server-instance-id.js'
8
8
  import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
9
9
  import { isSileroAvailable } from '../lib/vad-silero.js'
10
10
  import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
11
- import { isWhisperLocalAvailable, getWhisperHealth } from '../lib/whisper-local.js'
11
+ import {
12
+ isWhisperLocalAvailable,
13
+ getWhisperHealth,
14
+ getHighQualityTranscriptionCapability,
15
+ } from '../lib/whisper-local.js'
12
16
  import { getOpenAIWhisperBudgetState } from '../lib/openai-whisper-budget.js'
13
17
  import { getKeyStatus } from '../lib/openai-key.js'
14
18
  import {
@@ -150,6 +154,7 @@ healthRouter.get('/health', async (_req, res) => {
150
154
  const durableJobs = durableQueryJobStatus()
151
155
  const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
152
156
  const transcription = getTranscriptionPolicySnapshot()
157
+ const transcriptionHq = getHighQualityTranscriptionCapability()
153
158
  const recovery = managedRuntimeCapability()
154
159
  const maintenance = maintenanceLifecycle.snapshot()
155
160
  const tts_local = getLocalTtsHealth()
@@ -213,7 +218,7 @@ healthRouter.get('/health', async (_req, res) => {
213
218
  tts_local,
214
219
  codex_models,
215
220
  capabilities: {
216
- transcription,
221
+ transcription: { ...transcription, hq: transcriptionHq },
217
222
  recovery,
218
223
  maintenance: {
219
224
  state: maintenance.state,
@@ -242,6 +247,7 @@ healthRouter.get('/models', async (req, res) => {
242
247
  const durableJobs = durableQueryJobStatus()
243
248
  const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
244
249
  const transcription = getTranscriptionPolicySnapshot()
250
+ const transcriptionHq = getHighQualityTranscriptionCapability()
245
251
  res.json({
246
252
  ...catalog,
247
253
  serverInstanceId: getServerInstanceId(),
@@ -250,7 +256,7 @@ healthRouter.get('/models', async (req, res) => {
250
256
  enabled: durableJobs.enabled,
251
257
  protocolVersion: durableJobs.protocolVersion,
252
258
  },
253
- transcription,
259
+ transcription: { ...transcription, hq: transcriptionHq },
254
260
  cliDebug: CLI_DEBUG_CAPABILITY,
255
261
  recovery: managedRuntimeCapability(),
256
262
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
@@ -199,6 +199,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
199
199
  actualQuality: warm?.actualQuality ?? (mode === 'hq' ? 'hq' : 'fast'),
200
200
  backend: warm?.backend ?? 'shared-inflight',
201
201
  degraded: warm?.degraded ?? false,
202
+ ...(warm?.degradationReason ? { degradationReason: warm.degradationReason } : {}),
202
203
  }, 'final')
203
204
  }
204
205
  }
@@ -221,6 +222,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
221
222
  const record: PromptDraftTranscriptRecord = {
222
223
  text, hash, requestedMode: result.requestedMode, actualQuality: result.actualQuality,
223
224
  backend: result.backend, degraded: result.degraded,
225
+ ...(result.degradationReason ? { degradationReason: result.degradationReason } : {}),
224
226
  }
225
227
  await markPromptDraftChunkTranscript(draftId, chunkIndex, record, purpose)
226
228
  console.log(`[prompt-draft] chunk ${draftId}/${chunkIndex}: ${result.elapsedMs.toFixed(1)}ms | ${result.backend} | ${purpose}/${mode} | ${text.length} chars`)
@@ -247,6 +249,7 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
247
249
  const meta = loadPromptDraftMeta(draftId)
248
250
  if (!meta) throw Object.assign(new Error('draft not found'), { status: 404 })
249
251
  const texts: string[] = []
252
+ const transcriptRecords: PromptDraftTranscriptRecord[] = []
250
253
  for (const chunk of readPromptDraftChunks(draftId)) {
251
254
  try {
252
255
  const hash = audioHash(chunk.audioBuffer)
@@ -259,10 +262,27 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
259
262
  }
260
263
  const current = loadPromptDraftMeta(draftId)
261
264
  const cached = current?.finalTranscripts?.[String(chunk.chunkIndex)] ?? current?.warmTranscripts?.[String(chunk.chunkIndex)]
262
- const reusable = Boolean(cached && cached.hash === hash && (mode === 'fast' ? cached.actualQuality === 'fast' : cached.actualQuality === 'hq'))
265
+ // Reuse the exact requested-mode decode even when HQ truthfully degraded
266
+ // to turbo. Finalize's automatic policy would make the same local choice
267
+ // again after a successful turbo result, so a second decode adds latency
268
+ // without improving quality. Legacy records stay excluded because their
269
+ // decoder provenance was reconstructed during migration.
270
+ const reusable = Boolean(
271
+ cached
272
+ && cached.hash === hash
273
+ && cached.requestedMode === mode
274
+ && !cached.backend.startsWith('legacy'),
275
+ )
263
276
  const raw = reusable ? cached!.text : await transcribeChunk(draftId, chunk.chunkIndex, chunk.audioBuffer, mode, 'final')
264
277
  const text = sanitizeTranscript(draftId, raw, !reusable)
265
- if (text.trim()) texts.push(text.trim())
278
+ if (text.trim()) {
279
+ texts.push(text.trim())
280
+ const latest = loadPromptDraftMeta(draftId)
281
+ const used = latest?.finalTranscripts?.[String(chunk.chunkIndex)]
282
+ ?? latest?.warmTranscripts?.[String(chunk.chunkIndex)]
283
+ ?? cached
284
+ if (used && used.hash === hash) transcriptRecords.push(used)
285
+ }
266
286
  } catch (err) {
267
287
  if (err instanceof NoSpeechDetectedError) continue
268
288
  throw err
@@ -275,7 +295,28 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
275
295
  }
276
296
  const finalText = await cleanOutboundDictation(text, { ...autoClean, signal })
277
297
  const finalized = await markPromptDraftFinalized(draftId, finalText)
278
- return { draftId, text: finalText, recovered: true, chunkCount: finalized.receivedChunkIndexes.length, missingChunks: getMissingChunkIndexes(finalized), expiresAt: finalized.expiresAt }
298
+ const qualities = transcriptRecords.map(record => record.actualQuality)
299
+ const actualQuality: 'hq' | 'fast' | 'cloud' = qualities.includes('cloud')
300
+ ? 'cloud'
301
+ : qualities.length > 0 && qualities.every(quality => quality === 'hq')
302
+ ? 'hq'
303
+ : 'fast'
304
+ const degraded = mode === 'hq' && (actualQuality !== 'hq' || transcriptRecords.some(record => record.degraded))
305
+ const backends = [...new Set(transcriptRecords.map(record => record.backend))]
306
+ const degradationReason = transcriptRecords.find(record => record.degradationReason)?.degradationReason
307
+ return {
308
+ draftId,
309
+ text: finalText,
310
+ recovered: true,
311
+ chunkCount: finalized.receivedChunkIndexes.length,
312
+ missingChunks: getMissingChunkIndexes(finalized),
313
+ expiresAt: finalized.expiresAt,
314
+ requestedMode: mode,
315
+ actualQuality,
316
+ degraded,
317
+ backend: backends.length === 1 ? backends[0] : 'mixed',
318
+ ...(degradationReason ? { degradationReason } : {}),
319
+ }
279
320
  }
280
321
 
281
322
  const prunedAtBoot = maintenanceAdmissionsOpen() ? prunePromptDrafts() : 0
@@ -44,7 +44,15 @@ transcribeRouter.post('/transcribe', async (req, res) => {
44
44
 
45
45
  const result = await transcribeAudioBuffer(audioBuffer, { mode: resolveMode(req) })
46
46
  console.log(`[perf] /transcribe: ${result.elapsedMs.toFixed(1)}ms | mode=${result.mode} | ${result.backend} | ${result.audioBytes}b | ${result.text.length} chars`)
47
- res.json({ text: result.text, backend: result.backend, mode: result.mode })
47
+ res.json({
48
+ text: result.text,
49
+ backend: result.backend,
50
+ mode: result.mode,
51
+ requestedMode: result.requestedMode,
52
+ actualQuality: result.actualQuality,
53
+ degraded: result.degraded,
54
+ ...(result.degradationReason ? { degradationReason: result.degradationReason } : {}),
55
+ })
48
56
  } catch (err: any) {
49
57
  if (err instanceof MaintenanceLifecycleError) {
50
58
  if (err.retryAfterSeconds != null) res.setHeader('Retry-After', String(err.retryAfterSeconds))