@gotcos/glasses-server 6.18.7 → 6.19.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
@@ -139,6 +139,17 @@ BIND_HOST=0.0.0.0
139
139
  # keeps working if the default ever flips to Metal-on.
140
140
  # COS_BATCH_HQ_FORCE_CPU=1
141
141
 
142
+ # ── UNSAVED-CAPTURE QUARANTINE (6.19.0) ─────────────────────────────────
143
+ # Meeting audio whose save never landed is QUARANTINED, never deleted. It
144
+ # surfaces on /api/health (unsaved_captures) and, with COS Control 0.3.1+,
145
+ # as an "Unsaved captures" row in the status card. One authenticated call
146
+ # recovers a capture into a durable meeting scribe:
147
+ # curl -X POST -H "x-cos-token: $COS_API_TOKEN" \
148
+ # http://127.0.0.1:3141/api/meeting/orphans/<sessionId>/recover
149
+ # Quarantined audio expires on this retention clock (hours, clamped 1-720).
150
+ # 72 covers a long weekend away from the Mac.
151
+ # COS_UNSAVED_AUDIO_RETENTION_HOURS=72
152
+
142
153
  # ── LIVE CUES (optional — requires the FULL COS PIPELINE above) ──────────
143
154
  # Live meeting coaching cues on the lens: transcript window -> Composer
144
155
  # planner -> Qdrant -> LightRAG -> Composer insight -> coaching_nudge.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,55 @@
1
+ ## 6.19.0
2
+
3
+ Meeting audio is evidence. This release stops the server from ever deleting an
4
+ unsaved capture, and makes batch status stop lying about finished work.
5
+
6
+ - **Unsaved-capture quarantine (the 2026-08-01 data-loss fix).** The
7
+ session-audio purge (boot sweep + 60s interval + non-saved session close)
8
+ DELETED any directory not tracked in memory once the 4h idle retention
9
+ passed — an offline meeting whose deferred save never landed lost its
10
+ full-fidelity audio within a minute. Two real meetings were destroyed this
11
+ way on 2026-08-01; only speaker-enrollment fragments survived. Audio-bearing
12
+ directories are now MOVED to `data/unsaved-audio/` with a manifest, never
13
+ deleted in place. Empty directories are still cleaned. A failed quarantine
14
+ move leaves the source untouched. Quarantine expires on
15
+ `COS_UNSAVED_AUDIO_RETENTION_HOURS` (default 72, clamped 1–720) — the only
16
+ place quarantined audio is ever deleted.
17
+ - **Unsaved captures are visible.** `/api/health` gains `unsaved_captures`
18
+ (count + compact items, same exposure level as `meeting_sync`). The
19
+ authenticated `GET /api/meeting/orphans` returns full detail.
20
+ - **Miles-triggered recovery, surface-only by decision (2026-08-02).**
21
+ `POST /api/meeting/orphans/:sessionId/recover` batch-transcribes the
22
+ quarantined WAVs (same segment/enhance/Metal-preempt contract as HQ polish,
23
+ under a new `orphan_recovery` maintenance lease), writes a durable scribe,
24
+ and hands off to operations when the COS pipeline is configured. Idempotent
25
+ via the save-receipt short-circuit; the server never drives recovery on its
26
+ own, and audio stays in quarantine until the retention clock — a failed
27
+ recovery is retryable.
28
+ - **Rejected HQ batches release their status.** A terminal batch outcome
29
+ (rejected quality, pipeline failure, accepted-but-unpersisted) now writes
30
+ `_batch_terminal.json` next to the retained WAVs. `meeting_sync` reports
31
+ those as `retained` — never as active work — so a rejected batch no longer
32
+ shows "HQ polish · N chunks" with `blocksRestart: true` for the 12h WAV
33
+ retention after the work already finished (observed on
34
+ meeting_1785695339502_mvqm0p, reason `repetitive-output`). A retry clears
35
+ the terminal record; live progress always wins. `meeting_sync.retained` is
36
+ additive — older consumers ignore it.
37
+ - Deferred by design: the realtime-model fallback port (W3) ships in its own
38
+ release. The app-side module has diverged ~1,100 lines from this repo's
39
+ whisper path; transplanting it alongside the data-loss fix would couple the
40
+ release's safest change to its riskiest. No default flips either way.
41
+
42
+ ## 6.18.8
43
+
44
+ - **Prompt draft peeks for live ASR.** `POST /api/prompt-drafts/:draftId/peek`
45
+ runs Turbo locally, emits `prompt_transcript` with `provisional: true` +
46
+ `peekGen`, and does **not** advance the recovery ledger. Drop-on-busy,
47
+ `learnInline=false`, and `affectsCircuit: false` so peeks cannot trip or
48
+ success-reset the Whisper breaker.
49
+ - **Interactive HQ keeps short utterance heads.** Compose HQ skips light
50
+ enhance (highpass was truncating "device, just for…") and omits CLI `--vad`
51
+ as defense-in-depth. Meeting/batch HQ paths unchanged.
52
+
1
53
  ## 6.18.7
2
54
 
3
55
  - **Phone Restart no longer leaves the server Stopped.** Control LaunchAgent
package/README.md CHANGED
@@ -123,6 +123,12 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
123
123
  locally through a network interruption. Reconnecting reconciles the exact
124
124
  chunks already stored by the Mac, uploads only missing audio, and finalizes
125
125
  through an idempotent save receipt without duplicating the meeting.
126
+ - Since 6.19.0, meeting audio whose save never lands is quarantined for 72 hours
127
+ (`COS_UNSAVED_AUDIO_RETENTION_HOURS`) instead of being cleaned up, surfaces on
128
+ `/api/health` as `unsaved_captures`, and can be recovered into a durable
129
+ meeting scribe with one authenticated call
130
+ (`POST /api/meeting/orphans/:sessionId/recover`; list via
131
+ `GET /api/meeting/orphans`).
126
132
  - Local whisper.cpp transcription (free and local-only by default). OpenAI
127
133
  Whisper fallback is optional and requires both the exact
128
134
  `COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a configured key; a key alone never
@@ -21,6 +21,7 @@
21
21
  "COS_LIVE_CUES_AUTO",
22
22
  "COS_BATCH_HQ_METAL",
23
23
  "COS_BATCH_HQ_FORCE_CPU",
24
+ "COS_UNSAVED_AUDIO_RETENTION_HOURS",
24
25
  "COS_CLAUDE_TRUST_MODE",
25
26
  "COS_CODEX_SANDBOX",
26
27
  "COS_SCRIPTS_DIR"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.18.7",
3
+ "version": "6.19.0",
4
4
  "description": "COS Glasses \u2014 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": {
@@ -15,6 +15,7 @@ import { secureExistingPrivateFile } from './secure-user-config.js'
15
15
 
16
16
  import type { Exchange } from './conversation.js'
17
17
  import { resolveExchangePairModel } from './conversation.js'
18
+ import type { ModelPreference } from '../../shared/model-preference.js'
18
19
 
19
20
  import { dataPath } from './data-dir.js'
20
21
  const ARCHIVE_DIR = dataPath('archive')
@@ -60,6 +61,8 @@ export interface SessionToArchive {
60
61
  contextBreaks: number[]
61
62
  createdAt: number
62
63
  lastActivity: number
64
+ /** Optional session-level model fallback for pre-stamp exchange pairs. */
65
+ modelPreference?: ModelPreference | null
63
66
  }
64
67
 
65
68
  // ── Directory management ────────────────────────────────────
@@ -876,6 +876,7 @@ export function getActiveSessions(): SessionToArchive[] {
876
876
  contextBreaks: session.contextBreaks,
877
877
  createdAt: session.createdAt,
878
878
  lastActivity: session.lastActivity,
879
+ modelPreference: session.modelPreference,
879
880
  })
880
881
  }
881
882
  return result
@@ -46,6 +46,7 @@ export type MaintenanceWorkKind =
46
46
  | 'meeting_batch_finalization'
47
47
  | 'prompt_draft_write'
48
48
  | 'prompt_draft_warm'
49
+ | 'prompt_draft_peek'
49
50
  | 'prompt_draft_finalize'
50
51
  // Live Cues pipeline (planner + memory hops + insight). Held for the
51
52
  // pipeline's in-flight duration so an Update Server drain cannot unload the
@@ -53,6 +54,14 @@ export type MaintenanceWorkKind =
53
54
  // budget must stay under COS Control's 90s drain timeout (main.swift:1853)
54
55
  // or every drain that catches a cue in flight hard-fails to Repair.
55
56
  | 'live_cue_pipeline'
57
+ // Miles-triggered recovery of a quarantined unsaved capture (6.19.0):
58
+ // batch-transcribes retained WAVs into a durable scribe. Held for the whole
59
+ // background run so an Update Server drain waits for it like any batch.
60
+ // Long runs are made visible: the active-recovery registry renders a
61
+ // meeting_sync row with blocksRestart so COS Control warns BEFORE
62
+ // committing a drain into a decode that outlives its 90s timeout
63
+ // (main.swift:1963 waitForRestartProof).
64
+ | 'orphan_recovery'
56
65
 
57
66
  export type MaintenanceWorkPhase = 'queued' | 'active'
58
67
  export type MaintenanceOperationScope = 'same_boot' | 'cross_boot'
@@ -5,9 +5,11 @@
5
5
  import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs'
6
6
  import { basename, join } from 'node:path'
7
7
  import { dataPath } from './data-dir.js'
8
+ import { listActiveRecoveries } from './unsaved-audio-quarantine.js'
8
9
 
9
10
  export const BATCH_PROGRESS_FILENAME = '_batch_progress.json'
10
11
  export const BATCH_PENDING_MARKER = '_batch_pending.marker'
12
+ export const BATCH_TERMINAL_FILENAME = '_batch_terminal.json'
11
13
 
12
14
  export type MeetingBatchPhase =
13
15
  | 'queued'
@@ -44,6 +46,75 @@ export interface MeetingSyncSnapshot {
44
46
  label: string
45
47
  blocksRestart: boolean
46
48
  meetings: MeetingSyncMeeting[]
49
+ /** Batches that reached a terminal outcome but whose WAVs are deliberately
50
+ * retained for retry (rejected quality, failed persist). Additive field —
51
+ * older consumers ignore it. Never counts toward active/blocksRestart. */
52
+ retained: MeetingSyncRetainedMeeting[]
53
+ }
54
+
55
+ export type MeetingBatchOutcome = 'accepted' | 'rejected' | 'failed'
56
+
57
+ export interface MeetingBatchTerminal {
58
+ schemaVersion: 1
59
+ meetingId: string
60
+ outcome: MeetingBatchOutcome
61
+ reason?: string
62
+ at: string
63
+ }
64
+
65
+ export interface MeetingSyncRetainedMeeting {
66
+ meetingId: string
67
+ outcome: MeetingBatchOutcome
68
+ reason: string | null
69
+ chunkFiles: number
70
+ at: string
71
+ label: string
72
+ }
73
+
74
+ /** Record the batch's terminal outcome next to its retained WAVs. Before this
75
+ * file existed (≤6.18.8), a rejected batch's dir kept rendering as active
76
+ * "HQ polish · N chunks" with blocksRestart:true for the full 12h retention —
77
+ * the status conflated "work running" with "evidence retained". */
78
+ export function writeMeetingBatchTerminal(
79
+ audioDir: string,
80
+ input: { outcome: MeetingBatchOutcome; reason?: string; meetingId?: string },
81
+ ): void {
82
+ const payload: MeetingBatchTerminal = {
83
+ schemaVersion: 1,
84
+ meetingId: input.meetingId ?? basename(audioDir),
85
+ outcome: input.outcome,
86
+ ...(input.reason ? { reason: input.reason } : {}),
87
+ at: new Date().toISOString(),
88
+ }
89
+ try {
90
+ writeFileSync(join(audioDir, BATCH_TERMINAL_FILENAME), `${JSON.stringify(payload)}\n`, {
91
+ encoding: 'utf8',
92
+ mode: 0o600,
93
+ })
94
+ } catch {
95
+ // Status only — never fail the pipeline for a status write.
96
+ }
97
+ }
98
+
99
+ /** A retry invalidates the previous terminal state. */
100
+ export function clearMeetingBatchTerminal(audioDir: string): void {
101
+ const path = join(audioDir, BATCH_TERMINAL_FILENAME)
102
+ try {
103
+ if (existsSync(path)) unlinkSync(path)
104
+ } catch { /* ignore */ }
105
+ }
106
+
107
+ function readTerminalFile(dir: string): MeetingBatchTerminal | null {
108
+ const path = join(dir, BATCH_TERMINAL_FILENAME)
109
+ if (!existsSync(path)) return null
110
+ try {
111
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as MeetingBatchTerminal
112
+ if (raw?.schemaVersion !== 1) return null
113
+ if (raw.outcome !== 'accepted' && raw.outcome !== 'rejected' && raw.outcome !== 'failed') return null
114
+ return raw
115
+ } catch {
116
+ return null
117
+ }
47
118
  }
48
119
 
49
120
  function pendingBatchRoot(): string {
@@ -111,6 +182,11 @@ export function clearMeetingBatchProgress(audioDir: string): void {
111
182
  } catch { /* ignore */ }
112
183
  }
113
184
 
185
+ /** Public read for surfaces outside this module (orphan recovery progress). */
186
+ export function readMeetingBatchProgress(dir: string): MeetingBatchProgress | null {
187
+ return readProgressFile(dir)
188
+ }
189
+
114
190
  function readProgressFile(dir: string): MeetingBatchProgress | null {
115
191
  const path = join(dir, BATCH_PROGRESS_FILENAME)
116
192
  if (!existsSync(path)) return null
@@ -140,8 +216,9 @@ export function getMeetingSyncSnapshot(
140
216
  root: string = pendingBatchRoot(),
141
217
  ): MeetingSyncSnapshot {
142
218
  const meetings: MeetingSyncMeeting[] = []
219
+ const retained: MeetingSyncRetainedMeeting[] = []
143
220
  if (!existsSync(root)) {
144
- return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
221
+ return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings, retained }
145
222
  }
146
223
 
147
224
  let dirs: string[] = []
@@ -154,7 +231,7 @@ export function getMeetingSyncSnapshot(
154
231
  }
155
232
  })
156
233
  } catch {
157
- return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
234
+ return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings, retained }
158
235
  }
159
236
 
160
237
  for (const name of dirs) {
@@ -165,6 +242,47 @@ export function getMeetingSyncSnapshot(
165
242
  chunkFiles = readdirSync(dir).filter(f => f.endsWith('.wav')).length
166
243
  } catch { /* ignore */ }
167
244
 
245
+ // A terminal outcome ends the meeting's ACTIVE life. Its WAVs stay for
246
+ // retry, reported as retained — never as running work. The gate is
247
+ // progress==null ONLY: the pending marker is refreshed every segment and
248
+ // every 60s during the run, so it is always fresh the moment a terminal
249
+ // is written — gating on marker freshness left the phantom alive for the
250
+ // first 15 minutes, exactly the post-meeting Update Server window. A
251
+ // genuine retry clears the terminal first (runMeetingBatchPipeline) and
252
+ // immediately writes queued progress, so progress presence is the true
253
+ // live signal.
254
+ const terminal = readTerminalFile(dir)
255
+ if (terminal && progress == null) {
256
+ const reasonSuffix = terminal.reason ? `: ${terminal.reason}` : ''
257
+ retained.push({
258
+ meetingId: terminal.meetingId || name,
259
+ outcome: terminal.outcome,
260
+ reason: terminal.reason ?? null,
261
+ chunkFiles,
262
+ at: terminal.at,
263
+ label: `Retained (${terminal.outcome}${reasonSuffix}) · ${chunkFiles} chunk${chunkFiles === 1 ? '' : 's'}`,
264
+ })
265
+ continue
266
+ }
267
+
268
+ // Backfill: a dir with WAVs, no progress, no fresh marker, and NO terminal
269
+ // file is a batch that ended before 6.19.0 existed (or whose terminal
270
+ // write failed). Pre-6.19.0 semantics rendered these as phantom active
271
+ // work with blocksRestart for the rest of the 12h retention — and the
272
+ // first boot after an upgrade is exactly when the user watches COS
273
+ // Control. Classify them as retained with an honest unknown outcome.
274
+ if (progress == null && !markerFresh(dir) && chunkFiles > 0) {
275
+ retained.push({
276
+ meetingId: name,
277
+ outcome: 'failed',
278
+ reason: 'pre-terminal batch (ended before 6.19.0 or terminal write lost)',
279
+ chunkFiles,
280
+ at: new Date(0).toISOString(),
281
+ label: `Retained (unknown outcome) · ${chunkFiles} chunk${chunkFiles === 1 ? '' : 's'}`,
282
+ })
283
+ continue
284
+ }
285
+
168
286
  const active = markerFresh(dir) || progress != null
169
287
  if (!active && chunkFiles === 0) continue
170
288
 
@@ -198,8 +316,36 @@ export function getMeetingSyncSnapshot(
198
316
  meetings.push({ ...row, label: labelFor(row) })
199
317
  }
200
318
 
319
+ // Active orphan recoveries decode in the quarantine root, which this scan
320
+ // never visits — surface them as active rows or COS Control shows "Idle"
321
+ // with blocksRestart:false while a 20-90 minute decode holds the
322
+ // maintenance lease, and an Update Server drain walks blind into its 90s
323
+ // timeout and hard-fails to Repair. Same contract as meeting_batch_finalization.
324
+ for (const recovery of listActiveRecoveries()) {
325
+ const progress = readProgressFile(recovery.dirPath)
326
+ const percent = progress && progress.segmentsTotal > 0
327
+ ? clampPercent(progress.segmentsDone, progress.segmentsTotal)
328
+ : null
329
+ const row: Omit<MeetingSyncMeeting, 'label'> = {
330
+ meetingId: recovery.sessionId,
331
+ phase: progress?.phase ?? 'queued',
332
+ percent,
333
+ segmentsDone: progress && progress.segmentsTotal > 0 ? progress.segmentsDone : null,
334
+ segmentsTotal: progress && progress.segmentsTotal > 0 ? progress.segmentsTotal : null,
335
+ chunkFiles: progress?.chunkFiles ?? 0,
336
+ updatedAt: progress?.updatedAt ?? null,
337
+ }
338
+ meetings.push({
339
+ ...row,
340
+ label: `Recovering unsaved capture${percent != null ? ` ${percent}%` : ''} · do not update/restart`,
341
+ })
342
+ }
343
+
201
344
  if (meetings.length === 0) {
202
- return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
345
+ const label = retained.length > 0
346
+ ? `Idle · ${retained.length} retained batch${retained.length === 1 ? '' : 'es'}`
347
+ : 'Idle'
348
+ return { active: false, percent: null, label, blocksRestart: false, meetings, retained }
203
349
  }
204
350
 
205
351
  const withPercent = meetings.filter(m => m.percent != null)
@@ -217,5 +363,6 @@ export function getMeetingSyncSnapshot(
217
363
  label,
218
364
  blocksRestart: true,
219
365
  meetings,
366
+ retained,
220
367
  }
221
368
  }
@@ -9,7 +9,9 @@ import { transcribeHighQuality, type WhisperWord } from './whisper-local.js'
9
9
  import { isMetalBatchPreempted } from './whisper-metal-gate.js'
10
10
  import {
11
11
  clearMeetingBatchProgress,
12
+ clearMeetingBatchTerminal,
12
13
  writeMeetingBatchProgress,
14
+ writeMeetingBatchTerminal,
13
15
  } from './meeting-batch-progress.js'
14
16
  import type { IndexedTranscriptChunk } from '../routes/transcribe-stream.js'
15
17
  import {
@@ -165,7 +167,7 @@ function mapWordsToSpeakers(
165
167
  })
166
168
  }
167
169
 
168
- async function transcribeSegments(
170
+ export async function transcribeSegments(
169
171
  audioDir: string,
170
172
  segments: BatchSegment[],
171
173
  entries: IndexedTranscriptChunk[],
@@ -237,6 +239,16 @@ async function transcribeSegments(
237
239
 
238
240
  let batchQueueTail: Promise<void> = Promise.resolve()
239
241
 
242
+ /** Chain arbitrary HQ-decoder work onto the same serialization tail the batch
243
+ * pipeline uses. Orphan recovery MUST go through this: transcribeSegments has
244
+ * no internal queue, so calling it directly would run a second (or third)
245
+ * 16-thread large-v3 decoder in parallel with a live post-meeting batch. */
246
+ export function enqueueSerializedHqWork<T>(work: () => Promise<T>): Promise<T> {
247
+ const job = batchQueueTail.then(work)
248
+ batchQueueTail = job.then(() => undefined, () => undefined)
249
+ return job
250
+ }
251
+
240
252
  /** Serialize 16-thread HQ decoders across meetings on a public user's Mac. */
241
253
  export function runMeetingBatchPipeline(
242
254
  audioDir: string,
@@ -246,6 +258,8 @@ export function runMeetingBatchPipeline(
246
258
  // Lease immediately, including time spent behind another HQ decoder. Without
247
259
  // this, the two-hour cleanup could delete a queued meeting before it starts.
248
260
  refreshPendingLease(audioDir)
261
+ // A retry invalidates any prior terminal outcome — live signals must win.
262
+ clearMeetingBatchTerminal(audioDir)
249
263
  writeMeetingBatchProgress(audioDir, {
250
264
  phase: 'queued',
251
265
  segmentsDone: 0,
@@ -278,7 +292,12 @@ async function runMeetingBatchPipelineNow(
278
292
  return { transcriptionQuality: 'streaming' }
279
293
  }
280
294
  const segments = segmentTranscriptChunks(entries)
281
- if (segments.length === 0) return { transcriptionQuality: 'streaming' }
295
+ if (segments.length === 0) {
296
+ // Terminal too: WAVs exist but nothing is transcribable. Without this,
297
+ // the dir re-creates the exact phantom-active state W2 removes.
298
+ writeMeetingBatchTerminal(audioDir, { outcome: 'failed', reason: 'no_segments' })
299
+ return { transcriptionQuality: 'streaming' }
300
+ }
282
301
 
283
302
  writeMeetingBatchProgress(audioDir, {
284
303
  phase: 'hq_polish',
@@ -302,12 +321,20 @@ async function runMeetingBatchPipelineNow(
302
321
  + `${qualityReport.streamingWordCount} live words, `
303
322
  + `${(qualityReport.duplicateWordRatio * 100).toFixed(1)}% duplicate`,
304
323
  )
324
+ // Terminal: the batch RAN and lost. WAVs stay for retry, but status must
325
+ // stop reporting active work (pre-6.19.0 this looked like 12h of
326
+ // "HQ polish · N chunks" with blocksRestart:true after the work ended).
327
+ writeMeetingBatchTerminal(audioDir, { outcome: 'rejected', reason: qualityReport.reason })
305
328
  return { transcriptionQuality: 'streaming', qualityReport }
306
329
  }
307
330
 
308
331
  return { transcriptionQuality: 'batch', batchTranscript, batchSegments, qualityReport }
309
332
  } catch (error) {
310
333
  console.error(`[meeting-batch] Pipeline failed: ${error instanceof Error ? error.message : String(error)}`)
334
+ writeMeetingBatchTerminal(audioDir, {
335
+ outcome: 'failed',
336
+ reason: error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200),
337
+ })
311
338
  return { transcriptionQuality: 'streaming' }
312
339
  }
313
340
  }
@@ -114,7 +114,7 @@ async function projectPublicConversationTerminal(
114
114
  request.globalMsgNum,
115
115
  request.attachmentRefs,
116
116
  request.messageEra,
117
- request.model,
117
+ normalizeModelPreference(request.model),
118
118
  )
119
119
  reconcileExchangeByJobIdentity(
120
120
  request.sessionId,
@@ -124,7 +124,7 @@ async function projectPublicConversationTerminal(
124
124
  request.globalMsgNum,
125
125
  mergeMediaAttachmentRefs(outputAttachments, existingOutputAttachments),
126
126
  request.messageEra,
127
- request.model,
127
+ normalizeModelPreference(request.model),
128
128
  )
129
129
  flushConversationToDisk()
130
130
  }
@@ -13,7 +13,6 @@ import {
13
13
  estimateAudioSeconds,
14
14
  OpenAIWhisperBudgetExhaustedError,
15
15
  } from './openai-whisper-budget.js'
16
- import { enhanceAudio } from './audio-enhance.js'
17
16
  import {
18
17
  stripInlineHallucinationsOneShot,
19
18
  isFullHallucination,
@@ -63,12 +62,6 @@ export class NoSpeechDetectedError extends Error {
63
62
  // ceiling (anything longer is a dictation, not a query — use meetings instead).
64
63
  const HQ_MAX_SECONDS = 60
65
64
 
66
- /** Short interactive clips use light enhance (highpass only). Override via env. */
67
- function hqEnhanceLightMaxSeconds(): number {
68
- const value = Number.parseInt(process.env.COS_HQ_ENHANCE_LIGHT_MAX_SEC || '15', 10)
69
- return Number.isFinite(value) && value >= 0 ? value : 15
70
- }
71
-
72
65
  function unavailableAfterLocalFailure(): TranscriptionUnavailableError | null {
73
66
  const fallback = getTranscriptionPolicySnapshot()
74
67
  if (fallback.openaiFallbackReady) return null
@@ -146,10 +139,16 @@ export function resolveTranscribeMode(raw: unknown): TranscribeMode {
146
139
 
147
140
  export async function transcribeAudioBuffer(
148
141
  audioBuffer: Buffer,
149
- opts: { mode?: TranscribeMode; policy?: TranscriptionBackendPolicy } = {},
142
+ opts: {
143
+ mode?: TranscribeMode
144
+ policy?: TranscriptionBackendPolicy
145
+ /** When false, turbo/server failures do not move the shared meeting circuit breaker. */
146
+ affectsCircuit?: boolean
147
+ } = {},
150
148
  ): Promise<TranscribeAudioResult> {
151
149
  const requestedMode = opts.mode ?? 'hq'
152
150
  const policy = opts.policy ?? 'automatic'
151
+ const affectsCircuit = opts.affectsCircuit !== false
153
152
  const audioSeconds = estimateAudioSeconds(audioBuffer)
154
153
  const effectiveMode: TranscribeMode =
155
154
  requestedMode === 'hq' && audioSeconds > HQ_MAX_SECONDS ? 'fast' : requestedMode
@@ -166,14 +165,14 @@ export async function transcribeAudioBuffer(
166
165
 
167
166
  if (effectiveMode === 'hq') {
168
167
  try {
169
- const lightMax = hqEnhanceLightMaxSeconds()
170
- const enhanceProfile = audioSeconds < lightMax ? 'light' as const : 'full' as const
171
- const enhanced = await enhanceAudio(audioBuffer, { profile: enhanceProfile })
172
- const result = await transcribeHighQuality(enhanced)
168
+ // A0 (2026-07-30): ffmpeg enhance light (highpass=f=80) was measured dropping
169
+ // leading speech on compose ("device just for your awareness"). Meeting batch
170
+ // still enhances in meeting-batch-transcribe.ts this path is prompt/interactive only.
171
+ const result = await transcribeHighQuality(audioBuffer, undefined, { priority: 'interactive' })
173
172
  text = result.text
174
173
  actualQuality = result.actualQuality
175
174
  if (result.actualQuality === 'hq') {
176
- backend = enhanceProfile === 'light' ? 'hq-large-v3-light' : 'hq-large-v3'
175
+ backend = 'hq-large-v3'
177
176
  } else {
178
177
  backend = result.backend === 'whisper-cli' ? 'fast-cli-turbo' : 'fast-local-server'
179
178
  degradationReason = result.degradationReason ?? 'hq_unavailable'
@@ -182,7 +181,7 @@ export async function transcribeAudioBuffer(
182
181
  console.warn(`[transcribe] HQ path failed, falling back to fast: ${hqErr.message}`)
183
182
  degradationReason = 'hq_decode_failed'
184
183
  try {
185
- const result = await transcribeLocal(audioBuffer)
184
+ const result = await transcribeLocal(audioBuffer, undefined, undefined, { affectsCircuit })
186
185
  text = result.text
187
186
  backend = `fast-local-${result.backend}`
188
187
  actualQuality = 'fast'
@@ -202,7 +201,7 @@ export async function transcribeAudioBuffer(
202
201
  }
203
202
  } else if (effectiveMode === 'fast') {
204
203
  try {
205
- const result = await transcribeLocal(audioBuffer)
204
+ const result = await transcribeLocal(audioBuffer, undefined, undefined, { affectsCircuit })
206
205
  text = result.text
207
206
  backend = `fast-local-${result.backend}`
208
207
  actualQuality = 'fast'