@gotcos/glasses-server 6.10.0 → 6.12.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
@@ -58,8 +58,10 @@ BIND_HOST=0.0.0.0
58
58
 
59
59
  # ── VOICE (optional) ────────────────────────────────────────────────────
60
60
  # Local transcription is FREE via whisper.cpp (brew install whisper-cpp; the
61
- # model auto-downloads on first run). If whisper.cpp is absent, voice falls
62
- # back to the OpenAI API (~$0.006/min), which needs this key.
61
+ # model auto-downloads on first run). Voice is local-only by default. Merely
62
+ # configuring a key never uploads audio. To allow OpenAI Whisper only after a
63
+ # local failure, set BOTH the exact opt-in and a key:
64
+ # COS_OPENAI_WHISPER_FALLBACK=1
63
65
  # OPENAI_API_KEY=sk-...
64
66
 
65
67
  # ── FULL COS PIPELINE (optional — leave unset for standalone) ────────────
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.12.0
4
+
5
+ Local-first transcription policy and capability-safe recovery diagnostics for
6
+ COS Glasses build 210+.
7
+
8
+ - **Local means local.** Prompt, one-shot, and meeting transcription now remain
9
+ on local Whisper by default. Finding an OpenAI key is not permission to upload
10
+ audio. Cloud Whisper is reachable only when the exact
11
+ `COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a resolved key are both present.
12
+ - **Every cloud chokepoint is fenced.** Both one-shot/prompt finalization and
13
+ continuous meeting transcription recheck the policy immediately before any
14
+ OpenAI request, preventing a future call-site regression from bypassing the
15
+ top-level selection logic.
16
+ - **Failure stays recoverable.** A local ASR outage returns a typed retryable
17
+ `503` instead of silently switching providers. Durable prompt chunks and raw
18
+ meeting audio remain available for retry; meeting receipt and batch-audio
19
+ retention behavior is unchanged.
20
+ - **Clients can tell policy from health.** `/api/health` and `/api/models`
21
+ publish additive `capabilities.transcription` fields. Public installs also
22
+ advertise every privileged recovery control as unsupported, allowing newer
23
+ phone Recovery Centers to hide controls instead of reporting false outages.
24
+ - **Backward compatible.** Existing routes and response fields remain in place.
25
+ Older apps keep their current query, prompt, meeting, image, and display
26
+ paths; cloud fallback remains available to operators who explicitly enable it.
27
+
28
+ ## 6.11.0
29
+
30
+ Local-first meeting recovery for COS Glasses build 209+.
31
+
32
+ - **Record through network loss.** The server advertises a versioned
33
+ `localFirstMeetings` capability with its stable instance ID. Compatible
34
+ clients can keep audio locally, reconnect to the same server, and reconcile
35
+ the exact sparse set of chunks it durably received.
36
+ - **Durable means acknowledged.** Raw meeting WAVs and the received-index
37
+ ledger are committed atomically before a chunk receives success. Storage
38
+ failures return typed retryable errors; capacity exhaustion returns `507`
39
+ instead of silently discarding audio.
40
+ - **Long meetings stay alive.** Active-session retention is measured from the
41
+ last durable activity, not the meeting start time, so recordings longer than
42
+ four hours are not mistaken for abandoned sessions.
43
+ - **Safe reconnect and close.** Authenticated session-status responses expose
44
+ exact compressed receive ranges, retention, and closed/saved state. Durable
45
+ tombstones prevent a late or replaying client from recreating a completed
46
+ meeting after a restart.
47
+ - **Idempotent finalization.** Repeating `POST /api/meeting/save` for an already
48
+ saved session returns the original versioned receipt and filename without
49
+ creating a second meeting.
50
+ - **Backward compatible.** Existing live transcription, meeting save, prompt
51
+ recovery, durable queries, and older clients retain their prior routes and
52
+ fields. The new capability, receipt fields, and status route are additive.
53
+
3
54
  ## 6.10.0
4
55
 
5
56
  Opt-in server-owned durable query jobs for COS Glasses build 204+.
package/README.md CHANGED
@@ -27,7 +27,7 @@ without silently losing completed replies.
27
27
  - **Claude Code CLI** (Opus/Fable/Sonnet) — https://claude.ai/download, then `claude login`
28
28
  _or_ **Codex CLI** (GPT Frontier/Balanced) — https://developers.openai.com/codex/, then `codex login`
29
29
  - **Even G2 glasses** + the **COS Glasses** app from the Even Hub
30
- - _Optional:_ `brew install whisper-cpp` for free local voice (otherwise OpenAI API)
30
+ - `brew install whisper-cpp` for free local voice (the launcher can download the model)
31
31
  - _Optional:_ `brew install ffmpeg` for phone/output image attachments (text chat remains available without it)
32
32
  - _Optional:_ **Tailscale** so your phone reaches your Mac from anywhere
33
33
 
@@ -72,7 +72,14 @@ The built-in IP allowlist blocks public-internet traffic regardless.
72
72
  compatible app builds, their warm transcript also appears live while speaking;
73
73
  final HQ transcription remains authoritative.
74
74
  - Live voice capture + transcription during meetings
75
- - Local whisper.cpp transcription (free) with OpenAI fallback (optional)
75
+ - With COS Glasses build 209+ and server 6.11.0+, meetings continue recording
76
+ locally through a network interruption. Reconnecting reconciles the exact
77
+ chunks already stored by the Mac, uploads only missing audio, and finalizes
78
+ through an idempotent save receipt without duplicating the meeting.
79
+ - Local whisper.cpp transcription (free and local-only by default). OpenAI
80
+ Whisper fallback is optional and requires both the exact
81
+ `COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a configured key; a key alone never
82
+ uploads audio.
76
83
  - Tasks / calendar / people context **if** you run the
77
84
  [COS Starter Kit](https://www.gotcos.com) (`COS_SCRIPTS_DIR`); otherwise it is
78
85
  glasses + AI only
@@ -81,7 +88,8 @@ The built-in IP allowlist blocks public-internet traffic regardless.
81
88
 
82
89
  Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
83
90
  optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
84
- `COS_API_TOKEN` (auto if unset), `OPENAI_API_KEY` (cloud voice fallback),
91
+ `COS_API_TOKEN` (auto if unset), `COS_OPENAI_WHISPER_FALLBACK=1` plus
92
+ `OPENAI_API_KEY` (explicit cloud voice fallback),
85
93
  `COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=1` (build 204+
86
94
  server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
87
95
  location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
@@ -101,13 +109,24 @@ BIND_HOST=0.0.0.0 npm run start:server
101
109
  - *Phone can't connect* — check `BIND_HOST=0.0.0.0`, the same Tailscale account on both devices, and the correct `100.x` IP + token.
102
110
  - *Safari connects but the app does not* — confirm `npx @gotcos/glasses-server@latest` is 6.6.0+, then use the app's server reconnect/edit control to verify the current URL and token. Do not run a second source or `npx` server alongside it.
103
111
  - *AI queries fail* — run `claude --version` / `codex --version`, then `claude login` / `codex login`.
104
- - *Voice getting billed?* — install `whisper-cpp` for free local transcription.
112
+ - *Voice getting billed?* — voice is local-only by default in 6.12.0+. Confirm
113
+ `/api/health` reports `capabilities.transcription.mode: "local-only"`. Remove
114
+ `COS_OPENAI_WHISPER_FALLBACK` (or set it to `0`) to disable an earlier opt-in.
115
+ - *Local voice unavailable?* — install `whisper-cpp`, restart the server, and
116
+ confirm `/api/health` reports `features.whisper: true`. A typed retryable 503
117
+ keeps compatible prompt/meeting audio available for retry instead of silently
118
+ sending it to OpenAI.
105
119
  - *Photos unavailable?* — install `ffmpeg`, restart the server, and confirm `/api/health` reports `features.mediaProcessingReady: true`.
106
120
  - *Prompt recovery unavailable?* — update with `npx @gotcos/glasses-server@latest`, then confirm `/api/health` reports `features.promptRecovery: true`.
107
121
  - *Durable query recovery unavailable?* — build 204+ requires server 6.10.0+ and
108
122
  `COS_DURABLE_QUERY_JOBS=1`. Restart once, then confirm `/api/health` reports
109
123
  `features.durableQueryJobs: true`, protocol `1`, and state `ready`. To roll
110
124
  back, remove the flag; accepted jobs still drain while new prompts use legacy streaming.
125
+ - *Offline meeting recovery unavailable?* — build 209+ requires server 6.11.0+.
126
+ Restart once, then confirm `/api/health` reports
127
+ `features.localFirstMeetings: true` and
128
+ `capabilities.localFirstMeetings.protocolVersion: 1`. Older app builds keep
129
+ using their existing live-transcription and meeting-save paths.
111
130
 
112
131
  ## License
113
132
 
package/bin/cli.cjs CHANGED
@@ -133,7 +133,8 @@ if (!existsSync(PROFILE_FILE) && existsSync(PROFILE_EXAMPLE)) {
133
133
  }
134
134
  if (!process.env.COS_PROFILE_PATH) process.env.COS_PROFILE_PATH = PROFILE_FILE
135
135
 
136
- // Step 5: local Whisper detection + model download (free voice; OpenAI fallback otherwise)
136
+ // Step 5: local Whisper detection + model download. Voice stays local-only by
137
+ // default; cloud fallback requires an explicit flag plus a configured key.
137
138
  const WHISPER_KNOWN_PATHS = ['/opt/homebrew/bin/whisper-cli', '/usr/local/bin/whisper-cli']
138
139
  const WHISPER_MODEL_DIR = join(homedir(), '.local/share/whisper-models')
139
140
  const WHISPER_MODEL_PATH = join(WHISPER_MODEL_DIR, 'ggml-large-v3-turbo.bin')
@@ -161,10 +162,10 @@ if (whisperCliPath && hasValidModel) {
161
162
  if (existsSync(WHISPER_MODEL_PATH)) { try { unlinkSync(WHISPER_MODEL_PATH) } catch {} }
162
163
  if (existsSync(WHISPER_MODEL_PARTIAL)) { try { unlinkSync(WHISPER_MODEL_PARTIAL) } catch {} }
163
164
  console.log(yellow(' ⚠') + ' whisper.cpp installed but model missing')
164
- console.log(' ' + dim('Downloading ggml-large-v3-turbo (~1.5 GB). Ctrl-C to skip (uses OpenAI API instead).'))
165
+ console.log(' ' + dim('Downloading ggml-large-v3-turbo (~1.5 GB). Ctrl-C to skip (voice remains unavailable by default).'))
165
166
  console.log(' ' + dim('Skip permanently: SKIP_WHISPER_DOWNLOAD=1 npx @gotcos/glasses-server'))
166
167
  if (process.env.SKIP_WHISPER_DOWNLOAD === '1') {
167
- console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — voice will use OpenAI API')
168
+ console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — local voice unavailable')
168
169
  } else {
169
170
  try {
170
171
  mkdirSync(WHISPER_MODEL_DIR, { recursive: true })
@@ -175,14 +176,19 @@ if (whisperCliPath && hasValidModel) {
175
176
  console.log(green(' ✓') + ' Model downloaded ' + dim('— voice = local (FREE)'))
176
177
  } catch (err) {
177
178
  try { unlinkSync(WHISPER_MODEL_PARTIAL) } catch {}
178
- console.log(red(' ✗') + ' Model download failed ' + dim('— voice will use OpenAI API'))
179
+ console.log(red(' ✗') + ' Model download failed ' + dim('— local voice unavailable'))
179
180
  console.log(' ' + dim('Error: ' + (err.message || err).toString().slice(0, 120)))
180
181
  }
181
182
  }
182
183
  } else {
183
- console.log(yellow(' ⚠') + ' whisper.cpp not installed ' + dim('— voice will use OpenAI API ($0.006/min)'))
184
+ console.log(yellow(' ⚠') + ' whisper.cpp not installed ' + dim('— local voice unavailable'))
184
185
  console.log(' Free local voice: ' + bold('brew install whisper-cpp') + dim(' (no Homebrew? https://brew.sh)'))
185
186
  }
187
+ if (process.env.COS_OPENAI_WHISPER_FALLBACK === '1') {
188
+ console.log(yellow(' ⚠') + ' Explicit OpenAI Whisper fallback requested ' + dim('— activates only if a key resolves; see /api/health'))
189
+ } else {
190
+ console.log(green(' ✓') + ' Transcription policy: local-only ' + dim('— a key alone never uploads audio'))
191
+ }
186
192
 
187
193
  // Step 6: image capability — ffmpeg validates, strips metadata, normalizes,
188
194
  // and builds the exact 288x144 G2 variant. It is optional so text/voice remain
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.10.0",
3
+ "version": "6.12.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": {
@@ -0,0 +1,53 @@
1
+ export const LOCAL_FIRST_MEETINGS_PROTOCOL_VERSION = 1 as const
2
+ export const LOCAL_FIRST_MEETING_IDLE_RETENTION_MS = 4 * 60 * 60 * 1000
3
+
4
+ export interface LocalFirstMeetingsCapability {
5
+ protocolVersion: typeof LOCAL_FIRST_MEETINGS_PROTOCOL_VERSION
6
+ serverInstanceId: string
7
+ idempotentSave: true
8
+ sessionStatus: true
9
+ retentionMs: number
10
+ }
11
+
12
+ export type MeetingSessionState = 'active' | 'closed' | 'saved' | 'missing'
13
+ export type IndexRange = [start: number, end: number]
14
+
15
+ export function localFirstMeetingsCapability(serverInstanceId: string | null): LocalFirstMeetingsCapability | null {
16
+ if (!serverInstanceId) return null
17
+ return {
18
+ protocolVersion: LOCAL_FIRST_MEETINGS_PROTOCOL_VERSION,
19
+ serverInstanceId,
20
+ idempotentSave: true,
21
+ sessionStatus: true,
22
+ retentionMs: LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
23
+ }
24
+ }
25
+
26
+ /** Exact, compact representation of a sparse received-index ledger. */
27
+ export function compressIndexRanges(indices: readonly number[]): IndexRange[] {
28
+ const sorted = Array.from(new Set(
29
+ indices.filter(value => Number.isInteger(value) && value >= 0),
30
+ )).sort((a, b) => a - b)
31
+ if (sorted.length === 0) return []
32
+
33
+ const ranges: IndexRange[] = []
34
+ let start = sorted[0]
35
+ let end = start
36
+ for (let index = 1; index < sorted.length; index++) {
37
+ const value = sorted[index]
38
+ if (value === end + 1) {
39
+ end = value
40
+ continue
41
+ }
42
+ ranges.push([start, end])
43
+ start = value
44
+ end = value
45
+ }
46
+ ranges.push([start, end])
47
+ return ranges
48
+ }
49
+
50
+ export function retainedUntilIso(lastActivityAt: number | null): string | null {
51
+ if (lastActivityAt == null || !Number.isFinite(lastActivityAt)) return null
52
+ return new Date(lastActivityAt + LOCAL_FIRST_MEETING_IDLE_RETENTION_MS).toISOString()
53
+ }
@@ -21,6 +21,7 @@ import {
21
21
  countVocabTerms,
22
22
  } from './hallucination-filter.js'
23
23
  import { getOpenAIKey, tryGetOpenAIKey } from './openai-key.js'
24
+ import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from './transcription-policy.js'
24
25
 
25
26
  export { OpenAIWhisperBudgetExhaustedError, estimateAudioSeconds }
26
27
 
@@ -61,17 +62,30 @@ export class NoSpeechDetectedError extends Error {
61
62
  // ceiling (anything longer is a dictation, not a query — use meetings instead).
62
63
  const HQ_MAX_SECONDS = 60
63
64
 
65
+ function unavailableAfterLocalFailure(): TranscriptionUnavailableError | null {
66
+ const fallback = getTranscriptionPolicySnapshot()
67
+ if (fallback.openaiFallbackReady) return null
68
+ return fallback.openaiFallbackConfigured
69
+ ? new TranscriptionUnavailableError('openai_key_missing', 'Local transcription is unavailable and the explicitly configured OpenAI fallback has no key')
70
+ : new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
71
+ }
72
+
64
73
  /** Transcribe via OpenAI Whisper API (cloud fallback).
65
74
  * Budget-gated: throws OpenAIWhisperBudgetExhaustedError if today's $5 cap is spent.
66
75
  * Ledger only ticks on SUCCESSFUL responses so retries that never reach the API
67
76
  * aren't double-counted. */
68
77
  async function transcribeCloud(audioBuffer: Buffer): Promise<string> {
69
- assertOpenAIWhisperBudget()
78
+ // Defense in depth: every cloud chokepoint rechecks the explicit two-factor
79
+ // opt-in. A key alone is never authority to upload user audio.
80
+ if (!isOpenAIWhisperFallbackReady()) {
81
+ throw unavailableAfterLocalFailure()!
82
+ }
70
83
 
71
84
  if (!tryGetOpenAIKey()) {
72
- throw new TranscriptionUnavailableError('openai_key_missing', 'OpenAI key missing; local audio is preserved for retry')
85
+ throw new TranscriptionUnavailableError('openai_key_missing', 'OpenAI key missing; retry after local Whisper recovers')
73
86
  }
74
87
 
88
+ assertOpenAIWhisperBudget()
75
89
  const key = getOpenAIKey()
76
90
  const audioSeconds = estimateAudioSeconds(audioBuffer)
77
91
 
@@ -157,10 +171,14 @@ export async function transcribeAudioBuffer(
157
171
  backend = `fast-local-${result.backend}`
158
172
  actualQuality = 'fast'
159
173
  } catch (localErr: any) {
160
- if (policy === 'local-only') {
161
- throw new TranscriptionUnavailableError('local_asr_unavailable', `Local transcription unavailable; audio is preserved for retry (${localErr.message})`)
174
+ const unavailable = policy === 'local-only'
175
+ ? new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
176
+ : unavailableAfterLocalFailure()
177
+ if (unavailable) {
178
+ console.warn(`[transcribe] Fast local unavailable; preserving audio for retry: ${localErr.message}`)
179
+ throw unavailable
162
180
  }
163
- console.warn(`[transcribe] Fast local also failed, falling back to cloud: ${localErr.message}`)
181
+ console.warn(`[transcribe] Fast local also failed; using explicitly enabled OpenAI fallback: ${localErr.message}`)
164
182
  text = await transcribeCloud(audioBuffer)
165
183
  backend = 'cloud'
166
184
  actualQuality = 'cloud'
@@ -173,17 +191,24 @@ export async function transcribeAudioBuffer(
173
191
  backend = `fast-local-${result.backend}`
174
192
  actualQuality = 'fast'
175
193
  } catch (localErr: any) {
176
- if (policy === 'local-only') {
177
- throw new TranscriptionUnavailableError('local_asr_unavailable', `Local transcription unavailable; audio is preserved for retry (${localErr.message})`)
194
+ const unavailable = policy === 'local-only'
195
+ ? new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
196
+ : unavailableAfterLocalFailure()
197
+ if (unavailable) {
198
+ console.warn(`[transcribe] Local unavailable; preserving audio for retry: ${localErr.message}`)
199
+ throw unavailable
178
200
  }
179
- console.warn(`[transcribe] Local whisper failed (${getWhisperBackend()}), falling back to cloud: ${localErr.message}`)
201
+ console.warn(`[transcribe] Local whisper failed (${getWhisperBackend()}); using explicitly enabled OpenAI fallback: ${localErr.message}`)
180
202
  text = await transcribeCloud(audioBuffer)
181
203
  backend = 'cloud'
182
204
  actualQuality = 'cloud'
183
205
  }
184
206
  } else {
185
- if (policy === 'local-only') {
186
- throw new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription unavailable; audio is preserved for retry')
207
+ const unavailable = policy === 'local-only'
208
+ ? new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
209
+ : unavailableAfterLocalFailure()
210
+ if (unavailable) {
211
+ throw unavailable
187
212
  }
188
213
  text = await transcribeCloud(audioBuffer)
189
214
  backend = 'cloud'
@@ -0,0 +1,29 @@
1
+ import { getKeyStatus } from './openai-key.js'
2
+
3
+ /**
4
+ * Cloud transcription is a two-factor opt-in. Merely having an OpenAI key on
5
+ * the machine must never route voice away from local Whisper.
6
+ */
7
+ export const OPENAI_WHISPER_FALLBACK_ENV = 'COS_OPENAI_WHISPER_FALLBACK'
8
+
9
+ export interface TranscriptionPolicySnapshot {
10
+ mode: 'local-only' | 'local-then-openai'
11
+ localRequired: true
12
+ openaiFallbackConfigured: boolean
13
+ openaiFallbackReady: boolean
14
+ }
15
+
16
+ export function getTranscriptionPolicySnapshot(): TranscriptionPolicySnapshot {
17
+ const openaiFallbackConfigured = process.env[OPENAI_WHISPER_FALLBACK_ENV] === '1'
18
+ const openaiFallbackReady = openaiFallbackConfigured && getKeyStatus().hasKey
19
+ return {
20
+ mode: openaiFallbackReady ? 'local-then-openai' : 'local-only',
21
+ localRequired: true,
22
+ openaiFallbackConfigured,
23
+ openaiFallbackReady,
24
+ }
25
+ }
26
+
27
+ export function isOpenAIWhisperFallbackReady(): boolean {
28
+ return getTranscriptionPolicySnapshot().openaiFallbackReady
29
+ }
@@ -636,7 +636,7 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
636
636
  }
637
637
 
638
638
  if (!serverAvailable && (serverStarting || serverRestarting)) {
639
- throw new Error('whisper-server starting — use preserved/cloud fallback')
639
+ throw new Error('whisper-server starting — preserve audio for retry')
640
640
  }
641
641
 
642
642
  // Try whisper-server first (fastest: ~50-100ms, includes DTW word timestamps)
@@ -690,13 +690,15 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
690
690
  restartWhisperServer()
691
691
  }
692
692
 
693
- // Throw so caller uses cloud fallback CLI is intentionally skipped for real-time
694
- throw new Error('whisper-server unavailable use cloud fallback')
693
+ // Throw so the caller applies the configured recovery policy. CLI is
694
+ // intentionally skipped for real-time transcription.
695
+ throw new Error('whisper-server unavailable — apply configured recovery policy')
695
696
  }
696
697
 
697
698
  /**
698
699
  * Auto-restart whisper-server after circuit breaker triggers.
699
- * Non-blocking — runs in background while callers use cloud fallback.
700
+ * Non-blocking — runs in background while callers preserve audio or apply the
701
+ * explicitly configured fallback policy.
700
702
  */
701
703
  async function restartWhisperServer(): Promise<void> {
702
704
  if (serverRestarting) return
@@ -734,7 +736,7 @@ async function restartWhisperServer(): Promise<void> {
734
736
  // Without this, the counter stays >= threshold but serverRestarting is false,
735
737
  // so every subsequent call would re-trigger restart in a tight loop
736
738
  serverConsecutiveFailures = 0
737
- console.error('[whisper-local] Server restart failed — reset counter, will retry after next 3 failures. Using cloud fallback.')
739
+ console.error('[whisper-local] Server restart failed — reset counter, will retry after next 3 failures. Caller recovery policy remains active.')
738
740
  }
739
741
  } catch (err: any) {
740
742
  serverConsecutiveFailures = 0 // Same reset — allow future retry cycle
@@ -5,6 +5,7 @@ import { resolve } from 'node:path'
5
5
  import { COS_SCRIPTS_DIR, COS_MODE, PYTHON_BIN } from '../lib/python-bridge.js'
6
6
  import { serverMetrics } from '../lib/server-metrics.js'
7
7
  import { getServerInstanceId } from '../lib/server-instance-id.js'
8
+ import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
8
9
  import { isSileroAvailable } from '../lib/vad-silero.js'
9
10
  import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
10
11
  import { isWhisperLocalAvailable, getWhisperHealth } from '../lib/whisper-local.js'
@@ -18,6 +19,7 @@ import { isMediaProcessingReady } from '../lib/image-safety.js'
18
19
  import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
19
20
  import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
20
21
  import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
22
+ import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
21
23
 
22
24
  export const healthRouter = Router()
23
25
 
@@ -138,6 +140,14 @@ healthRouter.get('/health', async (_req, res) => {
138
140
  // work can decide whether to prompt for a key.
139
141
  const keyStatus = getKeyStatus()
140
142
  const durableJobs = durableQueryJobStatus()
143
+ const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
144
+ const transcription = getTranscriptionPolicySnapshot()
145
+ const recovery = {
146
+ status: false,
147
+ restartWhisper: false,
148
+ restartServer: false,
149
+ managed: false,
150
+ }
141
151
  const features = {
142
152
  claude: claudeAvailable,
143
153
  codex: codexAvailable,
@@ -151,6 +161,8 @@ healthRouter.get('/health', async (_req, res) => {
151
161
  g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
152
162
  durableQueryJobs: durableJobs.enabled,
153
163
  durableQueryJobsProtocol: durableJobs.protocolVersion,
164
+ localFirstMeetings: localFirstMeetings !== null,
165
+ transcriptionPolicy: transcription.mode,
154
166
  }
155
167
  const voice = {
156
168
  hasKey: keyStatus.hasKey,
@@ -170,6 +182,11 @@ healthRouter.get('/health', async (_req, res) => {
170
182
  whisper_health,
171
183
  openai_whisper_budget,
172
184
  codex_models,
185
+ capabilities: {
186
+ transcription,
187
+ recovery,
188
+ ...(localFirstMeetings ? { localFirstMeetings } : {}),
189
+ },
173
190
  // /api/health is intentionally unauthenticated for setup diagnostics.
174
191
  // Publish capability only; job counts, retention identities, subscriber
175
192
  // counts, and the storage fingerprint remain internal.
@@ -187,6 +204,8 @@ healthRouter.get('/health', async (_req, res) => {
187
204
  healthRouter.get('/models', async (req, res) => {
188
205
  const catalog = await getCodexModelCatalog(req.query.refresh === '1')
189
206
  const durableJobs = durableQueryJobStatus()
207
+ const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
208
+ const transcription = getTranscriptionPolicySnapshot()
190
209
  res.json({
191
210
  ...catalog,
192
211
  serverInstanceId: getServerInstanceId(),
@@ -195,6 +214,14 @@ healthRouter.get('/models', async (req, res) => {
195
214
  enabled: durableJobs.enabled,
196
215
  protocolVersion: durableJobs.protocolVersion,
197
216
  },
217
+ transcription,
218
+ recovery: {
219
+ status: false,
220
+ restartWhisper: false,
221
+ restartServer: false,
222
+ managed: false,
223
+ },
224
+ ...(localFirstMeetings ? { localFirstMeetings } : {}),
198
225
  },
199
226
  })
200
227
  })
@@ -31,6 +31,7 @@ import {
31
31
  getSessionProviderCandidates,
32
32
  getSessionStartTime,
33
33
  getSessionTranscript,
34
+ getMeetingSessionStatus,
34
35
  hasSessionAudio,
35
36
  moveSessionAudioToPending,
36
37
  type IndexedTranscriptChunk,
@@ -38,6 +39,7 @@ import {
38
39
  type TranscriptChunk,
39
40
  type TranscriptGapReport,
40
41
  } from './transcribe-stream.js'
42
+ import { getServerInstanceId } from '../lib/server-instance-id.js'
41
43
 
42
44
  interface MeetingSessionSource {
43
45
  getTranscript(sessionId: string): string | null
@@ -98,6 +100,8 @@ function publicSaveResponse(saved: SavedMeeting, replayed = false): Record<strin
98
100
  ? Math.floor(integrity.completeness * 1_000) / 10
99
101
  : 100
100
102
  return {
103
+ receiptVersion: 1,
104
+ serverInstanceId: getServerInstanceId(),
101
105
  saved: true,
102
106
  // Keep the build199 string field without leaking an absolute host path.
103
107
  filepath: `recordings/${saved.month}/${saved.filename}`,
@@ -125,6 +129,33 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
125
129
  const router = Router()
126
130
  const savingSessions = new Set<string>()
127
131
 
132
+ router.get('/meeting/sessions/:sessionId/status', (req, res) => {
133
+ const sessionId = String(req.params.sessionId ?? '')
134
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
135
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
136
+ return
137
+ }
138
+ const serverInstanceId = getServerInstanceId()
139
+ if (!serverInstanceId) {
140
+ res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
141
+ return
142
+ }
143
+ const saved = store.findBySessionId(sessionId)
144
+ const live = getMeetingSessionStatus(sessionId)
145
+ res.set('Cache-Control', 'private, no-store')
146
+ res.json({
147
+ sessionId,
148
+ state: saved ? 'saved' : live.state,
149
+ serverInstanceId,
150
+ receivedRanges: live.receivedRanges,
151
+ receivedCount: live.receivedCount,
152
+ maxChunkIndex: live.maxChunkIndex,
153
+ lastActivityAt: live.lastActivityAt,
154
+ retainedUntil: saved ? null : live.retainedUntil,
155
+ saveReceipt: saved ? publicSaveResponse(saved) : null,
156
+ })
157
+ })
158
+
128
159
  router.post('/meeting/save', async (req, res) => {
129
160
  let lockedSessionId: string | null = null
130
161
  try {
@@ -1,5 +1,6 @@
1
1
  // POST /api/transcribe-stream — Streaming transcription for continuous meeting capture
2
- // Uses local Whisper (50ms) with OpenAI API fallback.
2
+ // Uses local Whisper. OpenAI API fallback is disabled by default and requires
3
+ // both COS_OPENAI_WHISPER_FALLBACK=1 and a configured key.
3
4
  // Streams speaker-labeled transcript chunks for live meeting capture.
4
5
 
5
6
  import { Router } from 'express'
@@ -10,11 +11,13 @@ import { resolve } from 'node:path'
10
11
  import { fileURLToPath } from 'node:url'
11
12
  import { getVocabulary, getOwnerName } from '../lib/profile.js'
12
13
  import { getOpenAIKey } from '../lib/openai-key.js'
14
+ import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from '../lib/transcription-policy.js'
15
+ import { TranscriptionUnavailableError } from '../lib/transcribe-audio.js'
13
16
 
14
17
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
15
18
  import { emitDisplay } from '../lib/display-bus.js'
16
19
  import { errMsg } from '../lib/utils.js'
17
- import { transcribeLocal, isWhisperLocalAvailable, applyCorrections, type WhisperWord } from '../lib/whisper-local.js'
20
+ import { transcribeLocal, applyCorrections, type WhisperWord } from '../lib/whisper-local.js'
18
21
  import { enhanceAudio } from '../lib/audio-enhance.js'
19
22
  import { trimSilence, isSileroAvailable } from '../lib/vad-silero.js'
20
23
  import { identifySpeaker, isEmbeddingAvailable, autoEnroll, getEmbeddingCount } from '../lib/speaker-embeddings.js'
@@ -32,6 +35,13 @@ import {
32
35
  isVocabEchoOnly,
33
36
  } from '../lib/hallucination-filter.js'
34
37
  import { dataPath } from '../lib/data-dir.js'
38
+ import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
39
+ import {
40
+ LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
41
+ compressIndexRanges,
42
+ retainedUntilIso,
43
+ type IndexRange,
44
+ } from '../lib/local-first-meetings-contract.js'
35
45
 
36
46
  function ensurePrivateDirectory(path: string): void {
37
47
  if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
@@ -200,6 +210,8 @@ interface TranscriptSession {
200
210
  // Sorted, de-duplicated. See computeGapReport()/analyzeTranscriptGaps().
201
211
  receivedIndices?: number[]
202
212
  maxChunkIndex?: number
213
+ /** Persisted idle-retention clock. Meeting date/duration still use startTime. */
214
+ lastActivityAt: number
203
215
  // Count of consecutive vocab-echo (prompt-regurgitation) chunks. Reset to 0 by
204
216
  // any real-content chunk. Used to drop a RUN of echoed brand names while keeping
205
217
  // a single loud one-off (which could be a real terse list). See sanitizeStreamTranscript.
@@ -207,22 +219,60 @@ interface TranscriptSession {
207
219
  }
208
220
 
209
221
  const sessions = new Map<string, TranscriptSession>()
210
- const CLOSED_SESSION_TTL_MS = 4 * 60 * 60 * 1000
222
+ const CLOSED_SESSION_TTL_MS = LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
211
223
  const CLOSED_SESSIONS_FILE = dataPath('closed-transcript-sessions.json')
212
224
 
225
+ interface ClosedTranscriptSession {
226
+ closedAt: number
227
+ lastActivityAt: number
228
+ receivedIndices: number[]
229
+ maxChunkIndex: number
230
+ reason: 'saved' | 'expired' | 'closed'
231
+ }
232
+
233
+ const closedSessionRecords = new Map<string, ClosedTranscriptSession>()
234
+
213
235
  // Incremental chunk persistence — survive server restarts
214
236
  const CHUNK_PERSIST_DIR = dataPath('active-sessions')
215
237
  ensurePrivateDirectory(CHUNK_PERSIST_DIR)
216
238
 
217
- function readClosedSessions(): Record<string, number> {
239
+ function readClosedSessions(): Record<string, ClosedTranscriptSession> {
218
240
  if (!existsSync(CLOSED_SESSIONS_FILE)) return {}
219
241
  try {
220
242
  const parsed = JSON.parse(readFileSync(CLOSED_SESSIONS_FILE, 'utf-8')) as unknown
221
243
  if (!parsed || typeof parsed !== 'object') return {}
222
- return Object.fromEntries(
223
- Object.entries(parsed as Record<string, unknown>)
224
- .filter(([id, ts]) => /^[A-Za-z0-9:_-]{3,96}$/.test(id) && typeof ts === 'number'),
225
- ) as Record<string, number>
244
+ const normalized: Record<string, ClosedTranscriptSession> = {}
245
+ for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
246
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(id)) continue
247
+ if (typeof value === 'number' && Number.isFinite(value)) {
248
+ normalized[id] = {
249
+ closedAt: value,
250
+ lastActivityAt: value,
251
+ receivedIndices: [],
252
+ maxChunkIndex: -1,
253
+ reason: 'closed',
254
+ }
255
+ continue
256
+ }
257
+ if (!value || typeof value !== 'object') continue
258
+ const raw = value as Record<string, unknown>
259
+ const closedAt = typeof raw.closedAt === 'number' && Number.isFinite(raw.closedAt) ? raw.closedAt : null
260
+ if (closedAt == null) continue
261
+ const lastActivityAt = typeof raw.lastActivityAt === 'number' && Number.isFinite(raw.lastActivityAt)
262
+ ? raw.lastActivityAt
263
+ : closedAt
264
+ const receivedIndices = Array.isArray(raw.receivedIndices)
265
+ ? Array.from(new Set(
266
+ raw.receivedIndices.filter((entry): entry is number => Number.isInteger(entry) && (entry as number) >= 0),
267
+ )).sort((a, b) => a - b)
268
+ : []
269
+ const maxChunkIndex = typeof raw.maxChunkIndex === 'number' && Number.isInteger(raw.maxChunkIndex)
270
+ ? raw.maxChunkIndex
271
+ : (receivedIndices.at(-1) ?? -1)
272
+ const reason = raw.reason === 'saved' || raw.reason === 'expired' ? raw.reason : 'closed'
273
+ normalized[id] = { closedAt, lastActivityAt, receivedIndices, maxChunkIndex, reason }
274
+ }
275
+ return normalized
226
276
  } catch {
227
277
  try {
228
278
  renameSync(CLOSED_SESSIONS_FILE, `${CLOSED_SESSIONS_FILE}.corrupt.${Date.now()}`)
@@ -234,25 +284,31 @@ function readClosedSessions(): Record<string, number> {
234
284
  function persistClosedSessions(): void {
235
285
  const now = Date.now()
236
286
  const merged = readClosedSessions()
237
- for (const id of deletedSessions) merged[id] = now
238
- for (const [id, closedAt] of Object.entries(merged)) {
239
- if (now - closedAt > CLOSED_SESSION_TTL_MS) delete merged[id]
287
+ for (const id of deletedSessions) {
288
+ merged[id] = closedSessionRecords.get(id) ?? {
289
+ closedAt: now,
290
+ lastActivityAt: now,
291
+ receivedIndices: [],
292
+ maxChunkIndex: -1,
293
+ reason: 'closed',
294
+ }
295
+ }
296
+ for (const [id, record] of Object.entries(merged)) {
297
+ if (now - record.closedAt > CLOSED_SESSION_TTL_MS) delete merged[id]
240
298
  }
241
299
  try {
242
- const tmp = `${CLOSED_SESSIONS_FILE}.tmp`
243
- writeFileSync(tmp, JSON.stringify(merged, null, 2), { encoding: 'utf-8', mode: 0o600 })
244
- renameSync(tmp, CLOSED_SESSIONS_FILE)
245
- try { chmodSync(CLOSED_SESSIONS_FILE, 0o600) } catch {}
246
- } catch { /* best-effort tombstones */ }
300
+ durableAtomicWriteFileSync(CLOSED_SESSIONS_FILE, JSON.stringify(merged, null, 2), { mode: 0o600 })
301
+ } catch { /* best-effort tombstones; saved receipts remain authoritative */ }
247
302
  }
248
303
 
249
304
  function recoverClosedSessions(): void {
250
305
  const now = Date.now()
251
306
  const closed = readClosedSessions()
252
307
  let dirty = false
253
- for (const [id, closedAt] of Object.entries(closed)) {
254
- if (now - closedAt <= CLOSED_SESSION_TTL_MS) {
255
- deletedSessions.add(id)
308
+ for (const [id, record] of Object.entries(closed)) {
309
+ if (now - record.closedAt <= CLOSED_SESSION_TTL_MS) {
310
+ rememberDeletedSession(id)
311
+ closedSessionRecords.set(id, record)
256
312
  } else {
257
313
  delete closed[id]
258
314
  dirty = true
@@ -260,20 +316,17 @@ function recoverClosedSessions(): void {
260
316
  }
261
317
  if (dirty) {
262
318
  try {
263
- const tmp = `${CLOSED_SESSIONS_FILE}.tmp`
264
- writeFileSync(tmp, JSON.stringify(closed, null, 2), { encoding: 'utf-8', mode: 0o600 })
265
- renameSync(tmp, CLOSED_SESSIONS_FILE)
266
- try { chmodSync(CLOSED_SESSIONS_FILE, 0o600) } catch {}
319
+ durableAtomicWriteFileSync(CLOSED_SESSIONS_FILE, JSON.stringify(closed, null, 2), { mode: 0o600 })
267
320
  } catch {}
268
321
  }
269
322
  }
270
323
 
271
- /** Persist a session's chunks to disk (called after each new chunk) */
272
- function persistSession(sessionId: string): void {
273
- try {
274
- const session = sessions.get(sessionId)
275
- if (!session) return
276
- const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
324
+ /** Persist a session before acknowledging any chunk. Throws on failure so a
325
+ * client never interprets a non-durable index as accepted. */
326
+ function persistSessionRequired(sessionId: string): void {
327
+ const session = sessions.get(sessionId)
328
+ if (!session) throw makeHttpError(404, 'session not found', 'session_not_found')
329
+ const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
277
330
  // chunksIndexed preserves each chunk's original index (a plain filter()
278
331
  // would collapse the sparse array and destroy gap positions on recovery).
279
332
  const chunksIndexed: Array<{ i: number; c: TranscriptChunk }> = []
@@ -281,9 +334,10 @@ function persistSession(sessionId: string): void {
281
334
  const c = session.chunks[i]
282
335
  if (c && c.text) chunksIndexed.push({ i, c })
283
336
  }
284
- const data = JSON.stringify({
337
+ const data = JSON.stringify({
285
338
  sessionId,
286
339
  startTime: session.startTime,
340
+ lastActivityAt: session.lastActivityAt,
287
341
  title: session.title,
288
342
  // `chunks` = legacy dense form, kept for backward compatibility with
289
343
  // existing readers; `chunksIndexed` preserves original indices so gap
@@ -294,9 +348,16 @@ function persistSession(sessionId: string): void {
294
348
  maxChunkIndex: session.maxChunkIndex ?? -1,
295
349
  providerCandidates: session.providerCandidates ?? {},
296
350
  })
297
- writeFileSync(filePath, data, { encoding: 'utf-8', mode: 0o600 })
298
- try { chmodSync(filePath, 0o600) } catch { /* best effort on recovered installs */ }
299
- } catch { /* non-critical — don't break transcription for persistence */ }
351
+ try {
352
+ durableAtomicWriteFileSync(filePath, data, { mode: 0o600 })
353
+ } catch (error) {
354
+ console.error(`[transcribe-stream] Durable session write failed for ${sessionId}: ${errMsg(error)}`)
355
+ throw makeHttpError(503, 'meeting session persistence unavailable', 'session_persistence_failed')
356
+ }
357
+ }
358
+
359
+ function persistSessionBestEffort(sessionId: string): void {
360
+ try { persistSessionRequired(sessionId) } catch { /* recovery cleanup is non-admission work */ }
300
361
  }
301
362
 
302
363
  /** Recover sessions from disk on server restart */
@@ -315,9 +376,14 @@ function recoverSessions(): void {
315
376
  Array.isArray(data.chunksIndexed) ? data.chunksIndexed : null
316
377
  const legacy: TranscriptChunk[] | null = Array.isArray(data.chunks) ? data.chunks : null
317
378
  const hasChunks = (indexed && indexed.length > 0) || (legacy && legacy.length > 0)
318
- if (data.sessionId && hasChunks) {
319
- // Only recover sessions less than 4 hours old
320
- if (Date.now() - data.startTime < 4 * 60 * 60 * 1000) {
379
+ const persistedStat = statSync(resolve(CHUNK_PERSIST_DIR, file))
380
+ const lastActivityAt = typeof data.lastActivityAt === 'number' && Number.isFinite(data.lastActivityAt)
381
+ ? data.lastActivityAt
382
+ : (Number.isFinite(persistedStat.mtimeMs) ? persistedStat.mtimeMs : data.startTime)
383
+ if (data.sessionId && (hasChunks || Array.isArray(data.receivedIndices) || Number.isFinite(lastActivityAt))) {
384
+ // Active retention is idle-based. Long meetings are not purged merely
385
+ // because their original start time is old.
386
+ if (Date.now() - lastActivityAt < LOCAL_FIRST_MEETING_IDLE_RETENTION_MS) {
321
387
  const chunks: TranscriptChunk[] = []
322
388
  if (indexed) {
323
389
  for (const e of indexed) {
@@ -353,6 +419,7 @@ function recoverSessions(): void {
353
419
  chunks,
354
420
  startTime: data.startTime,
355
421
  title: data.title || '',
422
+ lastActivityAt,
356
423
  receivedIndices,
357
424
  maxChunkIndex,
358
425
  providerCandidates: data.providerCandidates && typeof data.providerCandidates === 'object'
@@ -380,12 +447,32 @@ function recoverSessions(): void {
380
447
  recoveredIds.add(data.sessionId)
381
448
  if (cleaned > 0) {
382
449
  console.log(`[session-recovery] Cleaned inline hallucinations from ${cleaned} chunks`)
383
- persistSession(data.sessionId) // re-persist cleaned data to disk
450
+ persistSessionBestEffort(data.sessionId) // re-persist cleaned data to disk
384
451
  }
385
452
  const gaps = computeGapReport(session).missingIndices.length
386
453
  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' : ''})` : ''}`)
387
454
  } else {
388
- // Stale clean up
455
+ // A stale unsaved session is a real closed state, not a missing
456
+ // session that a late/zombie client may silently recreate. Keep
457
+ // its exact receive ledger for one tombstone horizon after boot.
458
+ const receivedIndices = Array.isArray(data.receivedIndices)
459
+ ? Array.from(new Set(
460
+ (data.receivedIndices as unknown[])
461
+ .filter((value): value is number => Number.isInteger(value) && (value as number) >= 0),
462
+ )).sort((left, right) => left - right)
463
+ : []
464
+ const maxChunkIndex = typeof data.maxChunkIndex === 'number' && Number.isInteger(data.maxChunkIndex)
465
+ ? data.maxChunkIndex
466
+ : (receivedIndices.at(-1) ?? -1)
467
+ closedSessionRecords.set(data.sessionId, {
468
+ closedAt: Date.now(),
469
+ lastActivityAt,
470
+ receivedIndices,
471
+ maxChunkIndex,
472
+ reason: 'expired',
473
+ })
474
+ rememberDeletedSession(data.sessionId)
475
+ persistClosedSessions()
389
476
  unlinkSync(resolve(CHUNK_PERSIST_DIR, file))
390
477
  }
391
478
  }
@@ -410,6 +497,12 @@ function recoverSessions(): void {
410
497
  // Declared BEFORE deleteSession to avoid TDZ hazard (deleteSession references these).
411
498
  const deletedSessions = new Set<string>()
412
499
  const DELETED_SESSION_CAP = 50 // keep last 50 deleted IDs, trim older on overflow
500
+ function rememberDeletedSession(sessionId: string): void {
501
+ deletedSessions.add(sessionId)
502
+ if (deletedSessions.size <= DELETED_SESSION_CAP) return
503
+ const entries = Array.from(deletedSessions)
504
+ for (const id of entries.slice(0, entries.length - DELETED_SESSION_CAP)) deletedSessions.delete(id)
505
+ }
413
506
  export function isSessionDeleted(sessionId: string): boolean {
414
507
  return deletedSessions.has(sessionId)
415
508
  }
@@ -418,19 +511,12 @@ export function isSessionDeleted(sessionId: string): boolean {
418
511
  recoverClosedSessions()
419
512
  recoverSessions()
420
513
 
421
- // Auto-cleanup sessions older than 4 hours
514
+ // Auto-cleanup sessions idle for the advertised retention horizon.
422
515
  setInterval(() => {
423
- const cutoff = Date.now() - 4 * 60 * 60 * 1000
516
+ const cutoff = Date.now() - LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
424
517
  for (const [id, session] of sessions) {
425
- if (session.startTime < cutoff) {
426
- sessions.delete(id)
427
- sessionAudioBytes.delete(id)
428
- sessionAudioWrites.delete(id)
429
- clearSessionHallucinationState(id)
430
- // Clean up persisted file too
431
- try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${id}.json`)) } catch {}
432
- // Clean up session audio
433
- try { rmSync(resolve(SESSION_AUDIO_DIR, id), { recursive: true, force: true }) } catch {}
518
+ if (session.lastActivityAt < cutoff) {
519
+ closeTranscriptSession(id, 'expired')
434
520
  }
435
521
  }
436
522
  // Purge orphaned session-audio dirs (no matching active session)
@@ -493,7 +579,8 @@ setInterval(() => {
493
579
  export function getSession(sessionId: string): TranscriptSession {
494
580
  let session = sessions.get(sessionId)
495
581
  if (!session) {
496
- session = { chunks: [], startTime: Date.now(), title: '', providerCandidates: {} }
582
+ const now = Date.now()
583
+ session = { chunks: [], startTime: now, lastActivityAt: now, title: '', providerCandidates: {} }
497
584
  sessions.set(sessionId, session)
498
585
  }
499
586
  if (!session.providerCandidates) session.providerCandidates = {}
@@ -677,19 +764,81 @@ export function getSessionProviderCandidates(sessionId: string): Record<string,
677
764
  return sessions.get(sessionId)?.providerCandidates ?? {}
678
765
  }
679
766
 
767
+ export interface MeetingSessionStatusSnapshot {
768
+ state: 'active' | 'closed' | 'missing'
769
+ receivedRanges: IndexRange[]
770
+ receivedCount: number
771
+ maxChunkIndex: number
772
+ lastActivityAt: string | null
773
+ retainedUntil: string | null
774
+ }
775
+
776
+ export function getMeetingSessionStatus(sessionId: string): MeetingSessionStatusSnapshot {
777
+ const active = sessions.get(sessionId)
778
+ if (active) {
779
+ const received = active.receivedIndices ?? []
780
+ return {
781
+ state: 'active',
782
+ receivedRanges: compressIndexRanges(received),
783
+ receivedCount: received.length,
784
+ maxChunkIndex: active.maxChunkIndex ?? (received.at(-1) ?? -1),
785
+ lastActivityAt: new Date(active.lastActivityAt).toISOString(),
786
+ retainedUntil: retainedUntilIso(active.lastActivityAt),
787
+ }
788
+ }
789
+ const closed = closedSessionRecords.get(sessionId)
790
+ if (closed) {
791
+ return {
792
+ state: 'closed',
793
+ receivedRanges: compressIndexRanges(closed.receivedIndices),
794
+ receivedCount: closed.receivedIndices.length,
795
+ maxChunkIndex: closed.maxChunkIndex,
796
+ lastActivityAt: new Date(closed.lastActivityAt).toISOString(),
797
+ retainedUntil: new Date(closed.closedAt + CLOSED_SESSION_TTL_MS).toISOString(),
798
+ }
799
+ }
800
+ return {
801
+ state: 'missing',
802
+ receivedRanges: [],
803
+ receivedCount: 0,
804
+ maxChunkIndex: -1,
805
+ lastActivityAt: null,
806
+ retainedUntil: null,
807
+ }
808
+ }
809
+
810
+ function closeTranscriptSession(
811
+ sessionId: string,
812
+ reason: ClosedTranscriptSession['reason'],
813
+ options: { preserveAudio?: boolean } = {},
814
+ ): void {
815
+ const session = sessions.get(sessionId)
816
+ const now = Date.now()
817
+ const receivedIndices = [...(session?.receivedIndices ?? [])]
818
+ const maxChunkIndex = session?.maxChunkIndex ?? (receivedIndices.at(-1) ?? -1)
819
+ closedSessionRecords.set(sessionId, {
820
+ closedAt: now,
821
+ lastActivityAt: session?.lastActivityAt ?? now,
822
+ receivedIndices,
823
+ maxChunkIndex,
824
+ reason,
825
+ })
826
+ finishClosingTranscriptSession(sessionId, options)
827
+ }
828
+
680
829
  /** Delete session after save */
681
830
  export function deleteSession(sessionId: string, options: { preserveAudio?: boolean } = {}): void {
831
+ closeTranscriptSession(sessionId, 'saved', options)
832
+ }
833
+
834
+ function finishClosingTranscriptSession(sessionId: string, options: { preserveAudio?: boolean }): void {
682
835
  sessions.delete(sessionId)
683
836
  sessionAudioBytes.delete(sessionId)
837
+ sessionAudioWrites.delete(sessionId)
684
838
  // Clean up inline hallucination tracking (was leaking until 4-hour interval fired)
685
839
  clearSessionHallucinationState(sessionId)
686
840
  // Track as deleted so orphan heartbeats get 410 Gone (prevents zombie client spam)
687
- deletedSessions.add(sessionId)
688
- if (deletedSessions.size > DELETED_SESSION_CAP) {
689
- // Trim oldest entries to prevent unbounded growth
690
- const arr = Array.from(deletedSessions)
691
- for (const id of arr.slice(0, arr.length - DELETED_SESSION_CAP)) deletedSessions.delete(id)
692
- }
841
+ rememberDeletedSession(sessionId)
693
842
  persistClosedSessions()
694
843
  // Clean up persisted file
695
844
  try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)) } catch {}
@@ -768,14 +917,27 @@ async function persistRawSessionAudioChunk(sessionId: string, chunkIndex: number
768
917
  ensurePrivateDirectory(sessionDir)
769
918
  const chunkPath = resolve(sessionDir, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`)
770
919
  const existingSize = existsSync(chunkPath) ? statSync(chunkPath).size : 0
771
- const currentBytes = sessionAudioBytes.get(sessionId) ?? 0
920
+ let currentBytes = sessionAudioBytes.get(sessionId)
921
+ if (currentBytes == null) {
922
+ currentBytes = 0
923
+ try {
924
+ for (const filename of readdirSync(sessionDir)) {
925
+ if (!/^chunk_\d{4}\.wav$/.test(filename)) continue
926
+ currentBytes += statSync(resolve(sessionDir, filename)).size
927
+ }
928
+ } catch (error) {
929
+ throw makeHttpError(503, `meeting audio inventory failed: ${errMsg(error)}`, 'session_audio_persistence_failed')
930
+ }
931
+ }
772
932
  const nextBytes = currentBytes - existingSize + audioBuffer.length
773
- if (nextBytes > MAX_SESSION_AUDIO_BYTES && !existsSync(chunkPath)) {
774
- if (chunkIndex % 50 === 0) console.warn(`[session-audio] Session ${sessionId} hit 500MB cap — skipping WAV saves`)
775
- return
933
+ if (nextBytes > MAX_SESSION_AUDIO_BYTES) {
934
+ throw makeHttpError(507, 'meeting audio capacity exceeded', 'meeting_audio_capacity_exceeded')
935
+ }
936
+ try {
937
+ durableAtomicWriteFileSync(chunkPath, audioBuffer, { mode: 0o600 })
938
+ } catch (error) {
939
+ throw makeHttpError(503, `meeting audio persistence failed: ${errMsg(error)}`, 'session_audio_persistence_failed')
776
940
  }
777
- const writeJob = writeFile(chunkPath, audioBuffer, { mode: 0o600 })
778
- await trackSessionAudioWrite(sessionId, writeJob)
779
941
  sessionAudioBytes.set(sessionId, Math.max(0, nextBytes))
780
942
  }
781
943
 
@@ -885,18 +1047,26 @@ function canonicalChunkResponse(
885
1047
  }
886
1048
 
887
1049
  async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Buffer, whisperContext: string, isQuiet: boolean): Promise<{ text: string; words?: WhisperWord[]; backend: string }> {
888
- if (isWhisperLocalAvailable()) {
889
- try {
890
- const result = await transcribeLocal(whisperAudio, whisperContext || undefined, isQuiet)
891
- return { text: result.text, words: result.words, backend: `local-${result.backend}` }
892
- } catch (err: unknown) {
893
- console.warn(`[transcribe-stream] Local Whisper failed, falling back to cloud: ${errMsg(err)}`)
894
- const text = await transcribeViaCloud(whisperAudio)
895
- return { text, words: undefined, backend: 'cloud' }
1050
+ // The worker owns reconciliation of stale health. Always attempt local ASR
1051
+ // once; an availability snapshot must not divert meeting audio to cloud.
1052
+ try {
1053
+ const result = await transcribeLocal(whisperAudio, whisperContext || undefined, isQuiet)
1054
+ return { text: result.text, words: result.words, backend: `local-${result.backend}` }
1055
+ } catch (err: unknown) {
1056
+ if (!isOpenAIWhisperFallbackReady()) {
1057
+ const fallback = getTranscriptionPolicySnapshot()
1058
+ console.warn(`[transcribe-stream] Local Whisper unavailable; preserving chunk for retry: ${errMsg(err)}`)
1059
+ throw new TranscriptionUnavailableError(
1060
+ fallback.openaiFallbackConfigured ? 'openai_key_missing' : 'local_asr_unavailable',
1061
+ fallback.openaiFallbackConfigured
1062
+ ? 'Local transcription is unavailable; audio is preserved for retry and the configured OpenAI fallback has no key'
1063
+ : 'Local transcription is unavailable; audio is preserved for retry',
1064
+ )
896
1065
  }
1066
+ console.warn(`[transcribe-stream] Local Whisper failed; using explicitly enabled OpenAI fallback: ${errMsg(err)}`)
1067
+ const text = await transcribeViaCloud(whisperAudio)
1068
+ return { text, words: undefined, backend: 'cloud' }
897
1069
  }
898
- const text = await transcribeViaCloud(whisperAudio)
899
- return { text, words: undefined, backend: 'cloud' }
900
1070
  }
901
1071
 
902
1072
  function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex: number, clientSpeaker: string): { speaker: string; similarity: number } {
@@ -995,9 +1165,6 @@ async function processStreamChunk(opts: {
995
1165
  if (opts.startTimeOverride && session.chunks.filter(Boolean).length === 0) {
996
1166
  session.startTime = opts.startTimeOverride
997
1167
  }
998
- // Transfer integrity: log this index as delivered before any text filtering,
999
- // so a silent/hallucination-filtered chunk is NOT mistaken for a lost one.
1000
- recordReceivedChunk(session, chunkIndex)
1001
1168
  const alreadyCanonical = session.chunks[chunkIndex]
1002
1169
 
1003
1170
  let candidateRecordKey: string | undefined
@@ -1020,21 +1187,24 @@ async function processStreamChunk(opts: {
1020
1187
  // Do not let late duplicate/replayed candidates replace canonical raw audio.
1021
1188
  // Batch re-transcription relies on chunk_000N.wav matching the accepted chunk.
1022
1189
  if (alreadyCanonical?.canonical) {
1190
+ session.lastActivityAt = Date.now()
1191
+ recordReceivedChunk(session, chunkIndex)
1023
1192
  if (candidate && candidateRecordKey) {
1024
1193
  session.providerCandidates![candidateRecordKey].accepted =
1025
1194
  alreadyCanonical.asrProvider === 'iphone-whisperkit-beta' && alreadyCanonical.audioSha256 === audioSha256
1026
1195
  session.providerCandidates![candidateRecordKey].fallbackReason =
1027
1196
  session.providerCandidates![candidateRecordKey].accepted ? undefined : 'canonical_exists'
1028
- persistSession(sessionId)
1029
1197
  }
1198
+ persistSessionRequired(sessionId)
1030
1199
  return canonicalChunkResponse(alreadyCanonical, sessionId, chunkIndex)
1031
1200
  }
1032
1201
 
1033
1202
  await persistRawSessionAudioChunk(sessionId, chunkIndex, audioBuffer)
1034
-
1035
- if (candidate) {
1036
- persistSession(sessionId)
1037
- }
1203
+ // Commit the received-index ledger only after the canonical raw WAV is
1204
+ // durable. A failure is typed non-2xx and a retry remains safe.
1205
+ session.lastActivityAt = Date.now()
1206
+ recordReceivedChunk(session, chunkIndex)
1207
+ persistSessionRequired(sessionId)
1038
1208
 
1039
1209
  const pcmData = audioBuffer.subarray(44)
1040
1210
  let sumSq = 0
@@ -1105,8 +1275,8 @@ async function processStreamChunk(opts: {
1105
1275
  if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
1106
1276
  session.providerCandidates[candidateRecordKey].accepted = false
1107
1277
  session.providerCandidates[candidateRecordKey].fallbackReason = sanitized.fallbackReason || fallbackReason || 'empty'
1108
- persistSession(sessionId)
1109
1278
  }
1279
+ persistSessionRequired(sessionId)
1110
1280
  return { text: '', speaker: clientSpeaker, chunkIndex, elapsed, sessionId, backend, asrProvider, fallbackReason: sanitized.fallbackReason || fallbackReason }
1111
1281
  }
1112
1282
 
@@ -1132,8 +1302,9 @@ async function processStreamChunk(opts: {
1132
1302
  finalExisting.asrProvider === 'iphone-whisperkit-beta' && finalExisting.audioSha256 === audioSha256
1133
1303
  session.providerCandidates[candidateRecordKey].fallbackReason =
1134
1304
  session.providerCandidates[candidateRecordKey].accepted ? undefined : 'canonical_exists'
1135
- persistSession(sessionId)
1136
1305
  }
1306
+ session.lastActivityAt = Date.now()
1307
+ persistSessionRequired(sessionId)
1137
1308
  return canonicalChunkResponse(finalExisting, sessionId, chunkIndex)
1138
1309
  }
1139
1310
  session.chunks[chunkIndex] = chunk
@@ -1144,7 +1315,8 @@ async function processStreamChunk(opts: {
1144
1315
  }
1145
1316
  }
1146
1317
  const tPersist = performance.now()
1147
- persistSession(sessionId)
1318
+ session.lastActivityAt = Date.now()
1319
+ persistSessionRequired(sessionId)
1148
1320
  console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
1149
1321
 
1150
1322
  emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
@@ -1154,6 +1326,14 @@ async function processStreamChunk(opts: {
1154
1326
  }
1155
1327
 
1156
1328
  function sendStreamError(res: { status: (code: number) => { json: (body: unknown) => unknown } }, err: unknown): unknown {
1329
+ if (err instanceof TranscriptionUnavailableError) {
1330
+ console.warn(`[transcribe-stream] ${err.message}`)
1331
+ return res.status(err.status).json({
1332
+ error: err.message,
1333
+ reason: err.reason,
1334
+ retryable: true,
1335
+ })
1336
+ }
1157
1337
  if (err instanceof OpenAIWhisperBudgetExhaustedError) {
1158
1338
  console.error(`[transcribe-stream] ${err.message}`)
1159
1339
  return res.status(503).json({
@@ -1207,7 +1387,8 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (
1207
1387
  : undefined
1208
1388
  if (startTime && session.chunks.filter(Boolean).length === 0) session.startTime = startTime
1209
1389
  if (typeof body.title === 'string') session.title = body.title.slice(0, 160)
1210
- persistSession(sessionId)
1390
+ session.lastActivityAt = Date.now()
1391
+ persistSessionRequired(sessionId)
1211
1392
  res.json({ sessionId, startTime: session.startTime, chunks: session.chunks.filter(Boolean).length })
1212
1393
  } catch (err: unknown) {
1213
1394
  sendStreamError(res, err)
@@ -1325,9 +1506,18 @@ transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) =>
1325
1506
  * A hung whisper-server + long meeting is the exact scenario this guards against —
1326
1507
  * chunks stay empty on budget-exceeded instead of silently billing per chunk. */
1327
1508
  async function transcribeViaCloud(audioBuffer: Buffer): Promise<string> {
1328
- assertOpenAIWhisperBudget()
1509
+ if (!isOpenAIWhisperFallbackReady()) {
1510
+ const fallback = getTranscriptionPolicySnapshot()
1511
+ throw new TranscriptionUnavailableError(
1512
+ fallback.openaiFallbackConfigured ? 'openai_key_missing' : 'local_asr_unavailable',
1513
+ fallback.openaiFallbackConfigured
1514
+ ? 'Local transcription is unavailable; audio is preserved for retry and the configured OpenAI fallback has no key'
1515
+ : 'Local transcription is unavailable; audio is preserved for retry',
1516
+ )
1517
+ }
1329
1518
 
1330
1519
  const key = getOpenAIKey()
1520
+ assertOpenAIWhisperBudget()
1331
1521
  const audioSeconds = estimateAudioSeconds(audioBuffer)
1332
1522
 
1333
1523
  const isWav = audioBuffer.length >= 4 && audioBuffer.toString('ascii', 0, 4) === 'RIFF'
@@ -9,6 +9,7 @@ import {
9
9
  resolveTranscribeMode,
10
10
  NoSpeechDetectedError,
11
11
  OpenAIWhisperBudgetExhaustedError,
12
+ TranscriptionUnavailableError,
12
13
  } from '../lib/transcribe-audio.js'
13
14
 
14
15
  export const transcribeRouter = Router()
@@ -50,6 +51,14 @@ transcribeRouter.post('/transcribe', async (req, res) => {
50
51
  cap_usd: err.capUsd,
51
52
  })
52
53
  }
54
+ if (err instanceof TranscriptionUnavailableError) {
55
+ console.warn(`[transcribe] ${err.message}`)
56
+ return res.status(err.status).json({
57
+ error: err.message,
58
+ reason: err.reason,
59
+ retryable: true,
60
+ })
61
+ }
53
62
  res.status(500).json({ error: err.message })
54
63
  }
55
64
  })