@gotcos/glasses-server 6.6.0 → 6.8.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.
@@ -0,0 +1,279 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ rmSync,
7
+ statSync,
8
+ } from 'node:fs'
9
+ import path from 'node:path'
10
+ import { createHash, randomBytes } from 'node:crypto'
11
+ import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
12
+ import { dataPath } from './data-dir.js'
13
+
14
+ export type PromptDraftStatus = 'recording' | 'finalized' | 'error' | 'cancelled' | 'expired'
15
+
16
+ export interface PromptDraftTranscriptRecord {
17
+ text: string
18
+ hash: string
19
+ requestedMode: 'hq' | 'fast'
20
+ actualQuality: 'hq' | 'fast' | 'cloud'
21
+ backend: string
22
+ degraded: boolean
23
+ acceptedDegraded?: boolean
24
+ }
25
+
26
+ export interface PromptDraftMeta {
27
+ v: 2
28
+ draftId: string
29
+ createdAt: string
30
+ updatedAt: string
31
+ expiresAt: string
32
+ status: PromptDraftStatus
33
+ receivedChunkIndexes: number[]
34
+ chunkBytes: Record<string, number>
35
+ chunkHashes: Record<string, string>
36
+ warmTranscripts: Record<string, PromptDraftTranscriptRecord>
37
+ finalTranscripts: Record<string, PromptDraftTranscriptRecord>
38
+ /** Compatibility mirror for pre-v2 clients and draft fixtures. */
39
+ chunkTranscripts?: Record<string, string>
40
+ finalizedText?: string
41
+ lastError?: string
42
+ }
43
+
44
+ // Public installs run from an ephemeral npx cache. Persist draft audio under
45
+ // ~/.cos-glasses/data so package upgrades cannot erase a recoverable recording.
46
+ const DATA_DIR = process.env.COS_PROMPT_DRAFT_DIR
47
+ ? path.resolve(process.env.COS_PROMPT_DRAFT_DIR)
48
+ : dataPath('prompt-drafts')
49
+ const META_NAME = 'meta.json'
50
+ const TTL_MS = 72 * 60 * 60 * 1000
51
+ const locks = new Map<string, Promise<unknown>>()
52
+
53
+ function ensureDir(dir: string): void {
54
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
55
+ }
56
+
57
+ function nowIso(): string {
58
+ return new Date().toISOString()
59
+ }
60
+
61
+ function expiresFromNowIso(): string {
62
+ return new Date(Date.now() + TTL_MS).toISOString()
63
+ }
64
+
65
+ function normalizeDraftId(draftId: string): string {
66
+ const clean = String(draftId || '').replace(/[^a-zA-Z0-9_-]/g, '')
67
+ if (!clean) throw new Error('invalid draft id')
68
+ return clean
69
+ }
70
+
71
+ function draftDir(draftId: string): string {
72
+ return path.join(DATA_DIR, normalizeDraftId(draftId))
73
+ }
74
+
75
+ function metaPath(draftId: string): string {
76
+ return path.join(draftDir(draftId), META_NAME)
77
+ }
78
+
79
+ function chunkPath(draftId: string, chunkIndex: number): string {
80
+ if (!Number.isInteger(chunkIndex) || chunkIndex < 0) throw new Error('invalid chunk index')
81
+ return path.join(draftDir(draftId), `chunk-${String(chunkIndex).padStart(5, '0')}.wav`)
82
+ }
83
+
84
+ function writeMeta(meta: PromptDraftMeta): PromptDraftMeta {
85
+ ensureDir(draftDir(meta.draftId))
86
+ atomicWriteFileSync(metaPath(meta.draftId), `${JSON.stringify(meta, null, 2)}\n`, { mode: 0o600 })
87
+ return meta
88
+ }
89
+
90
+ export function createPromptDraft(requestedId?: string): PromptDraftMeta {
91
+ ensureDir(DATA_DIR)
92
+ const candidate = requestedId ? normalizeDraftId(requestedId) : ''
93
+ const draftId = candidate && !existsSync(metaPath(candidate)) ? candidate : randomBytes(8).toString('hex')
94
+ const now = nowIso()
95
+ return writeMeta({
96
+ v: 2,
97
+ draftId,
98
+ createdAt: now,
99
+ updatedAt: now,
100
+ expiresAt: expiresFromNowIso(),
101
+ status: 'recording',
102
+ receivedChunkIndexes: [],
103
+ chunkBytes: {},
104
+ chunkHashes: {},
105
+ warmTranscripts: {},
106
+ finalTranscripts: {},
107
+ chunkTranscripts: {},
108
+ })
109
+ }
110
+
111
+ export function loadPromptDraftMeta(draftId: string): PromptDraftMeta | null {
112
+ const loaded = loadJsonOrQuarantine<PromptDraftMeta & { v?: number }>(metaPath(draftId))
113
+ if (loaded.status === 'missing') return null
114
+ if (loaded.status === 'corrupt') {
115
+ console.warn(`[prompt-draft] corrupt metadata quarantined: ${loaded.quarantinedAs}`)
116
+ return null
117
+ }
118
+ const data = loaded.data
119
+ if (data.v !== 2) {
120
+ const legacy = data.chunkTranscripts ?? {}
121
+ data.v = 2
122
+ data.chunkHashes = data.chunkHashes ?? {}
123
+ data.warmTranscripts = data.warmTranscripts ?? {}
124
+ data.finalTranscripts = data.finalTranscripts ?? {}
125
+ for (const [index, text] of Object.entries(legacy)) {
126
+ data.warmTranscripts[index] ??= {
127
+ text,
128
+ hash: data.chunkHashes[index] ?? '',
129
+ requestedMode: 'hq',
130
+ actualQuality: 'fast',
131
+ backend: 'legacy-unknown',
132
+ degraded: true,
133
+ }
134
+ }
135
+ writeMeta(data)
136
+ }
137
+ data.chunkHashes ??= {}
138
+ data.warmTranscripts ??= {}
139
+ data.finalTranscripts ??= {}
140
+ data.chunkTranscripts ??= {}
141
+ return data
142
+ }
143
+
144
+ function touchMeta(meta: PromptDraftMeta): PromptDraftMeta {
145
+ meta.updatedAt = nowIso()
146
+ meta.expiresAt = expiresFromNowIso()
147
+ return meta
148
+ }
149
+
150
+ async function withDraftLock<T>(draftId: string, fn: () => Promise<T> | T): Promise<T> {
151
+ const key = normalizeDraftId(draftId)
152
+ const previous = locks.get(key) ?? Promise.resolve()
153
+ let release!: () => void
154
+ const current = new Promise<void>((resolve) => { release = resolve })
155
+ const chained = previous.then(() => current)
156
+ locks.set(key, chained)
157
+ await previous.catch(() => {})
158
+ try {
159
+ return await fn()
160
+ } finally {
161
+ release()
162
+ if (locks.get(key) === chained) locks.delete(key)
163
+ }
164
+ }
165
+
166
+ export async function savePromptDraftChunk(draftId: string, chunkIndex: number, audioBuffer: Buffer): Promise<PromptDraftMeta> {
167
+ return withDraftLock(draftId, () => {
168
+ const meta = loadPromptDraftMeta(draftId)
169
+ if (!meta) throw new Error('draft not found')
170
+ ensureDir(draftDir(draftId))
171
+ const hash = createHash('sha256').update(audioBuffer).digest('hex')
172
+ const key = String(chunkIndex)
173
+ if (meta.chunkHashes[key] === hash && existsSync(chunkPath(draftId, chunkIndex))) {
174
+ return writeMeta(touchMeta(meta))
175
+ }
176
+ atomicWriteFileSync(chunkPath(draftId, chunkIndex), audioBuffer, { mode: 0o600 })
177
+ if (!meta.receivedChunkIndexes.includes(chunkIndex)) {
178
+ meta.receivedChunkIndexes.push(chunkIndex)
179
+ meta.receivedChunkIndexes.sort((a, b) => a - b)
180
+ }
181
+ meta.chunkBytes[key] = audioBuffer.length
182
+ meta.chunkHashes[key] = hash
183
+ if (!meta.chunkTranscripts) meta.chunkTranscripts = {}
184
+ delete meta.chunkTranscripts[key]
185
+ delete meta.warmTranscripts[key]
186
+ delete meta.finalTranscripts[key]
187
+ if (meta.status === 'error') meta.status = 'recording'
188
+ return writeMeta(touchMeta(meta))
189
+ })
190
+ }
191
+
192
+ export function readPromptDraftChunks(draftId: string): Array<{ chunkIndex: number; audioBuffer: Buffer }> {
193
+ const meta = loadPromptDraftMeta(draftId)
194
+ if (!meta) throw new Error('draft not found')
195
+ return meta.receivedChunkIndexes
196
+ .slice()
197
+ .sort((a, b) => a - b)
198
+ .map((chunkIndex) => ({ chunkIndex, audioBuffer: readFileSync(chunkPath(draftId, chunkIndex)) }))
199
+ }
200
+
201
+ export async function markPromptDraftFinalized(draftId: string, text: string): Promise<PromptDraftMeta> {
202
+ return withDraftLock(draftId, () => {
203
+ const meta = loadPromptDraftMeta(draftId)
204
+ if (!meta) throw new Error('draft not found')
205
+ meta.status = 'finalized'
206
+ meta.finalizedText = text
207
+ meta.lastError = undefined
208
+ return writeMeta(touchMeta(meta))
209
+ })
210
+ }
211
+
212
+ export async function markPromptDraftChunkTranscript(
213
+ draftId: string,
214
+ chunkIndex: number,
215
+ record: PromptDraftTranscriptRecord | string,
216
+ purpose: 'warm' | 'final' = 'warm',
217
+ ): Promise<PromptDraftMeta> {
218
+ return withDraftLock(draftId, () => {
219
+ const meta = loadPromptDraftMeta(draftId)
220
+ if (!meta) throw new Error('draft not found')
221
+ const key = String(chunkIndex)
222
+ const normalized: PromptDraftTranscriptRecord = typeof record === 'string'
223
+ ? { text: record, hash: meta.chunkHashes[key] ?? '', requestedMode: 'hq', actualQuality: 'fast', backend: 'legacy', degraded: true }
224
+ : record
225
+ if (meta.chunkHashes[key] && normalized.hash && meta.chunkHashes[key] !== normalized.hash) return meta
226
+ if (purpose === 'final') meta.finalTranscripts[key] = normalized
227
+ else meta.warmTranscripts[key] = normalized
228
+ if (!meta.chunkTranscripts) meta.chunkTranscripts = {}
229
+ meta.chunkTranscripts[key] = normalized.text
230
+ return writeMeta(touchMeta(meta))
231
+ })
232
+ }
233
+
234
+ export async function markPromptDraftError(draftId: string, error: string): Promise<PromptDraftMeta | null> {
235
+ return withDraftLock(draftId, () => {
236
+ const meta = loadPromptDraftMeta(draftId)
237
+ if (!meta) return null
238
+ meta.status = 'error'
239
+ meta.lastError = error
240
+ return writeMeta(touchMeta(meta))
241
+ })
242
+ }
243
+
244
+ export function getMissingChunkIndexes(meta: PromptDraftMeta): number[] {
245
+ if (meta.receivedChunkIndexes.length === 0) return []
246
+ const max = Math.max(...meta.receivedChunkIndexes)
247
+ const received = new Set(meta.receivedChunkIndexes)
248
+ const missing: number[] = []
249
+ for (let i = 0; i <= max; i++) {
250
+ if (!received.has(i)) missing.push(i)
251
+ }
252
+ return missing
253
+ }
254
+
255
+ export function prunePromptDrafts(): number {
256
+ ensureDir(DATA_DIR)
257
+ let pruned = 0
258
+ for (const entry of readdirSync(DATA_DIR, { withFileTypes: true })) {
259
+ if (!entry.isDirectory()) continue
260
+ const dir = path.join(DATA_DIR, entry.name)
261
+ const meta = loadPromptDraftMeta(entry.name)
262
+ const expiredByMeta = meta ? new Date(meta.expiresAt).getTime() <= Date.now() : false
263
+ let expiredByMtime = false
264
+ try {
265
+ expiredByMtime = Date.now() - statSync(dir).mtimeMs > TTL_MS
266
+ } catch {
267
+ expiredByMtime = true
268
+ }
269
+ if (expiredByMeta || expiredByMtime) {
270
+ try {
271
+ rmSync(dir, { recursive: true, force: true })
272
+ pruned++
273
+ } catch (err: any) {
274
+ console.warn(`[prompt-draft] prune failed for ${entry.name}: ${err.message}`)
275
+ }
276
+ }
277
+ }
278
+ return pruned
279
+ }
@@ -1,8 +1,8 @@
1
1
  import {
2
- isWhisperLocalAvailable,
3
2
  transcribeLocal,
4
3
  transcribeHighQuality,
5
4
  getWhisperBackend,
5
+ applyCorrections,
6
6
  } from './whisper-local.js'
7
7
  import { getVocabulary, getOwnerName } from './profile.js'
8
8
  import { applyFuzzyCorrections } from './fuzzy-correct.js'
@@ -20,7 +20,7 @@ import {
20
20
  isVocabEchoOnly,
21
21
  countVocabTerms,
22
22
  } from './hallucination-filter.js'
23
- import { getOpenAIKey } from './openai-key.js'
23
+ import { getOpenAIKey, tryGetOpenAIKey } from './openai-key.js'
24
24
 
25
25
  export { OpenAIWhisperBudgetExhaustedError, estimateAudioSeconds }
26
26
 
@@ -30,10 +30,23 @@ export interface TranscribeAudioResult {
30
30
  text: string
31
31
  backend: string
32
32
  mode: TranscribeMode
33
+ requestedMode: TranscribeMode
34
+ actualQuality: 'hq' | 'fast' | 'cloud'
35
+ degraded: boolean
33
36
  elapsedMs: number
34
37
  audioBytes: number
35
38
  }
36
39
 
40
+ export type TranscriptionBackendPolicy = 'automatic' | 'local-only'
41
+
42
+ export class TranscriptionUnavailableError extends Error {
43
+ readonly status = 503
44
+ constructor(readonly reason: 'local_asr_unavailable' | 'local_asr_restarting' | 'openai_key_missing', message?: string) {
45
+ super(message ?? reason)
46
+ this.name = 'TranscriptionUnavailableError'
47
+ }
48
+ }
49
+
37
50
  export class NoSpeechDetectedError extends Error {
38
51
  readonly reason = 'no_speech'
39
52
 
@@ -55,6 +68,10 @@ const HQ_MAX_SECONDS = 60
55
68
  async function transcribeCloud(audioBuffer: Buffer): Promise<string> {
56
69
  assertOpenAIWhisperBudget()
57
70
 
71
+ if (!tryGetOpenAIKey()) {
72
+ throw new TranscriptionUnavailableError('openai_key_missing', 'OpenAI key missing; local audio is preserved for retry')
73
+ }
74
+
58
75
  const key = getOpenAIKey()
59
76
  const audioSeconds = estimateAudioSeconds(audioBuffer)
60
77
 
@@ -106,8 +123,12 @@ export function resolveTranscribeMode(raw: unknown): TranscribeMode {
106
123
  return String(raw ?? '').toLowerCase() === 'fast' ? 'fast' : 'hq'
107
124
  }
108
125
 
109
- export async function transcribeAudioBuffer(audioBuffer: Buffer, opts: { mode?: TranscribeMode } = {}): Promise<TranscribeAudioResult> {
126
+ export async function transcribeAudioBuffer(
127
+ audioBuffer: Buffer,
128
+ opts: { mode?: TranscribeMode; policy?: TranscriptionBackendPolicy } = {},
129
+ ): Promise<TranscribeAudioResult> {
110
130
  const requestedMode = opts.mode ?? 'hq'
131
+ const policy = opts.policy ?? 'automatic'
111
132
  const audioSeconds = estimateAudioSeconds(audioBuffer)
112
133
  const effectiveMode: TranscribeMode =
113
134
  requestedMode === 'hq' && audioSeconds > HQ_MAX_SECONDS ? 'fast' : requestedMode
@@ -118,39 +139,63 @@ export async function transcribeAudioBuffer(audioBuffer: Buffer, opts: { mode?:
118
139
 
119
140
  let text: string
120
141
  let backend: string
142
+ let actualQuality: 'hq' | 'fast' | 'cloud'
121
143
  const tStart = performance.now()
122
144
 
123
- if (effectiveMode === 'hq' && isWhisperLocalAvailable()) {
145
+ if (effectiveMode === 'hq') {
124
146
  try {
125
147
  const enhanced = await enhanceAudio(audioBuffer)
126
148
  const result = await transcribeHighQuality(enhanced)
127
149
  text = result.text
128
150
  backend = 'hq-large-v3'
151
+ actualQuality = 'hq'
129
152
  } catch (hqErr: any) {
130
153
  console.warn(`[transcribe] HQ path failed, falling back to fast: ${hqErr.message}`)
131
154
  try {
132
155
  const result = await transcribeLocal(audioBuffer)
133
156
  text = result.text
134
157
  backend = `fast-local-${result.backend}`
158
+ actualQuality = 'fast'
135
159
  } 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})`)
162
+ }
136
163
  console.warn(`[transcribe] Fast local also failed, falling back to cloud: ${localErr.message}`)
137
164
  text = await transcribeCloud(audioBuffer)
138
165
  backend = 'cloud'
166
+ actualQuality = 'cloud'
139
167
  }
140
168
  }
141
- } else if (effectiveMode === 'fast' && isWhisperLocalAvailable()) {
169
+ } else if (effectiveMode === 'fast') {
142
170
  try {
143
171
  const result = await transcribeLocal(audioBuffer)
144
172
  text = result.text
145
173
  backend = `fast-local-${result.backend}`
174
+ actualQuality = 'fast'
146
175
  } 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})`)
178
+ }
147
179
  console.warn(`[transcribe] Local whisper failed (${getWhisperBackend()}), falling back to cloud: ${localErr.message}`)
148
180
  text = await transcribeCloud(audioBuffer)
149
181
  backend = 'cloud'
182
+ actualQuality = 'cloud'
150
183
  }
151
184
  } else {
185
+ if (policy === 'local-only') {
186
+ throw new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription unavailable; audio is preserved for retry')
187
+ }
152
188
  text = await transcribeCloud(audioBuffer)
153
189
  backend = 'cloud'
190
+ actualQuality = 'cloud'
191
+ }
192
+
193
+ if (text && text.length > 0) {
194
+ try {
195
+ text = applyCorrections(text)
196
+ } catch (corrErr: any) {
197
+ console.warn(`[transcribe] applyCorrections failed (non-fatal): ${corrErr.message}`)
198
+ }
154
199
  }
155
200
 
156
201
  if (text && text.length > 0) {
@@ -187,6 +232,9 @@ export async function transcribeAudioBuffer(audioBuffer: Buffer, opts: { mode?:
187
232
  text: text.trim(),
188
233
  backend,
189
234
  mode: effectiveMode,
235
+ requestedMode,
236
+ actualQuality,
237
+ degraded: requestedMode === 'hq' && actualQuality !== 'hq',
190
238
  elapsedMs,
191
239
  audioBytes: audioBuffer.length,
192
240
  }
@@ -147,6 +147,8 @@ let serverProcess: ReturnType<typeof spawn> | null = null
147
147
  let serverConsecutiveFailures = 0
148
148
  const SERVER_FAILURE_THRESHOLD = 3 // After 3 consecutive failures, auto-restart
149
149
  let serverRestarting = false // Prevents concurrent restart attempts
150
+ let serverStarting = false // Initial model load is not a circuit failure
151
+ let serverHealthProbe: Promise<boolean> | null = null
150
152
 
151
153
  // Check CLI availability at import time
152
154
  try {
@@ -163,6 +165,16 @@ try {
163
165
  * Called from index.ts at server boot. Non-blocking.
164
166
  */
165
167
  export async function startWhisperServer(): Promise<void> {
168
+ if (serverStarting) return
169
+ serverStarting = true
170
+ try {
171
+ await startWhisperServerAttempt()
172
+ } finally {
173
+ serverStarting = false
174
+ }
175
+ }
176
+
177
+ async function startWhisperServerAttempt(): Promise<void> {
166
178
  if (!existsSync(WHISPER_SERVER) || !existsSync(MODEL_PATH)) {
167
179
  console.log('[whisper-local] whisper-server or model not found — using CLI fallback')
168
180
  return
@@ -310,11 +322,39 @@ export function getWhisperHealth(): {
310
322
  server: serverAvailable,
311
323
  cli: cliAvailable,
312
324
  consecutiveFailures: serverConsecutiveFailures,
313
- restarting: serverRestarting,
325
+ restarting: serverRestarting || serverStarting,
314
326
  circuitOpen: serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD,
315
327
  }
316
328
  }
317
329
 
330
+ /**
331
+ * Reconcile a cached unavailable flag with the daemon's live health endpoint.
332
+ * Only successful inference resets the failure count: /health can be responsive
333
+ * while the model worker is still hung, and that case must retain the existing
334
+ * three-strike controlled restart.
335
+ */
336
+ async function reconcileWhisperServerHealth(): Promise<boolean> {
337
+ if (serverAvailable) return true
338
+ if (serverRestarting || serverStarting) return false
339
+ if (serverHealthProbe) return serverHealthProbe
340
+
341
+ serverHealthProbe = (async () => {
342
+ try {
343
+ const res = await fetch(`${WHISPER_SERVER_URL}/health`, { signal: AbortSignal.timeout(1_000) })
344
+ if (!res.ok) return false
345
+ serverAvailable = true
346
+ console.log(`[whisper-local] Health endpoint recovered; retrying inference after ${serverConsecutiveFailures} failure(s)`)
347
+ return true
348
+ } catch {
349
+ return false
350
+ }
351
+ })().finally(() => {
352
+ serverHealthProbe = null
353
+ })
354
+
355
+ return serverHealthProbe
356
+ }
357
+
318
358
  /**
319
359
  * High-quality transcription for batch/post-meeting use.
320
360
  * Uses the full Whisper large-v3 (32-layer) decoder + Silero VAD + beam search.
@@ -591,6 +631,14 @@ export function resetDecoderCaches(): void {
591
631
  export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQuiet?: boolean): Promise<{ text: string; backend: 'server' | 'cli'; words?: WhisperWord[] }> {
592
632
  const start = Date.now()
593
633
 
634
+ if (!serverAvailable) {
635
+ await reconcileWhisperServerHealth()
636
+ }
637
+
638
+ if (!serverAvailable && (serverStarting || serverRestarting)) {
639
+ throw new Error('whisper-server starting — use preserved/cloud fallback')
640
+ }
641
+
594
642
  // Try whisper-server first (fastest: ~50-100ms, includes DTW word timestamps)
595
643
  if (serverAvailable) {
596
644
  try {
@@ -112,6 +112,8 @@ healthRouter.get('/health', async (_req, res) => {
112
112
  voice: keyStatus.hasKey,
113
113
  cos_pipeline: COS_MODE,
114
114
  whisper: isWhisperLocalAvailable(),
115
+ promptRecovery: true,
116
+ meetingFinalization: true,
115
117
  iphoneAsrCandidates: process.env.COS_IOS_ASR_CANDIDATES === '1',
116
118
  mediaProcessingReady: await isMediaProcessingReady(),
117
119
  g2LensVariant: G2_LENS_VARIANT_CAPABILITY,