@gotcos/glasses-server 6.15.3 → 6.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -1
- package/CHANGELOG.md +57 -0
- package/README.md +30 -0
- package/package.json +1 -1
- package/server/index.ts +48 -23
- package/server/lib/api-auth.ts +5 -1
- package/server/lib/audio-enhance.ts +15 -8
- package/server/lib/claude-bridge.ts +48 -18
- package/server/lib/claude-tool-access.ts +73 -7
- package/server/lib/codex-bridge.ts +60 -27
- package/server/lib/maintenance-lifecycle.ts +40 -0
- package/server/lib/prompt-draft-store.ts +1 -0
- package/server/lib/provider-process-lifecycle.ts +139 -0
- package/server/lib/provider-proof.ts +28 -10
- package/server/lib/transcribe-audio.ts +20 -3
- package/server/lib/whisper-local.ts +170 -20
- package/server/routes/health.ts +27 -3
- package/server/routes/openai-compat.ts +54 -27
- package/server/routes/prompt-drafts.ts +122 -9
- package/server/routes/provider-proof.ts +40 -2
- package/server/routes/transcribe.ts +9 -1
|
@@ -52,9 +52,18 @@ export const promptDraftsRouter = Router()
|
|
|
52
52
|
const MAX_CHUNK_BYTES = 25 * 1024 * 1024
|
|
53
53
|
const MAX_DRAFT_BYTES = 256 * 1024 * 1024
|
|
54
54
|
const MAX_CHUNKS = 600
|
|
55
|
+
/** Purpose-scoped keys (legacy). Prefer modeQualityJobs for HQ warm↔finalize dedupe. */
|
|
55
56
|
const chunkTranscriptJobs = new Map<string, Promise<string>>()
|
|
57
|
+
/** Shared decode per draft/chunk/mode/hash — warm:hq and final:hq await the same promise. */
|
|
58
|
+
const modeQualityJobs = new Map<string, Promise<string>>()
|
|
56
59
|
const finalizeJobs = new Map<string, Promise<any>>()
|
|
57
60
|
let warmTail: Promise<void> = Promise.resolve()
|
|
61
|
+
let hqWarmTail: Promise<void> = Promise.resolve()
|
|
62
|
+
|
|
63
|
+
/** Speculative HQ warm while speaking. Set COS_HQ_SPECULATIVE_WARM=0 to restore Fast-only warm. */
|
|
64
|
+
function speculativeHqWarmEnabled(): boolean {
|
|
65
|
+
return !['0', 'false', 'off'].includes((process.env.COS_HQ_SPECULATIVE_WARM ?? '1').toLowerCase())
|
|
66
|
+
}
|
|
58
67
|
|
|
59
68
|
const autoCleanBreaker = createBreaker({ label: 'dictation-autoclean' })
|
|
60
69
|
const autoCleanCountFile = () => process.env.COS_DICTATION_AUTOCLEAN_COUNT_FILE || dataPath('.dictation_autoclean_count.json')
|
|
@@ -164,22 +173,59 @@ async function sendDraftError(res: Response, draftId: string, err: any): Promise
|
|
|
164
173
|
res.status(err.status ?? 500).json({ error: err.message })
|
|
165
174
|
}
|
|
166
175
|
|
|
176
|
+
function modeQualityKey(draftId: string, chunkIndex: number, mode: 'hq' | 'fast', hash: string): string {
|
|
177
|
+
return `${draftId}:${chunkIndex}:${mode}:${hash}`
|
|
178
|
+
}
|
|
179
|
+
|
|
167
180
|
async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffer, mode: 'hq' | 'fast', purpose: 'warm' | 'final'): Promise<string> {
|
|
168
181
|
const hash = audioHash(audio)
|
|
169
|
-
const
|
|
170
|
-
const
|
|
182
|
+
const purposeKey = `${draftId}:${chunkIndex}:${purpose}:${mode}:${hash}`
|
|
183
|
+
const sharedKey = modeQualityKey(draftId, chunkIndex, mode, hash)
|
|
184
|
+
|
|
185
|
+
// HQ warm and HQ finalize must share one decode (plan step 8a).
|
|
186
|
+
const existingShared = modeQualityJobs.get(sharedKey)
|
|
187
|
+
if (existingShared) {
|
|
188
|
+
try {
|
|
189
|
+
const text = await existingShared
|
|
190
|
+
if (purpose === 'final' && text) {
|
|
191
|
+
const current = loadPromptDraftMeta(draftId)
|
|
192
|
+
const warm = current?.warmTranscripts?.[String(chunkIndex)]
|
|
193
|
+
const cachedFinal = current?.finalTranscripts?.[String(chunkIndex)]
|
|
194
|
+
if (!cachedFinal || cachedFinal.hash !== hash) {
|
|
195
|
+
await markPromptDraftChunkTranscript(draftId, chunkIndex, {
|
|
196
|
+
text,
|
|
197
|
+
hash,
|
|
198
|
+
requestedMode: warm?.requestedMode ?? mode,
|
|
199
|
+
actualQuality: warm?.actualQuality ?? (mode === 'hq' ? 'hq' : 'fast'),
|
|
200
|
+
backend: warm?.backend ?? 'shared-inflight',
|
|
201
|
+
degraded: warm?.degraded ?? false,
|
|
202
|
+
...(warm?.degradationReason ? { degradationReason: warm.degradationReason } : {}),
|
|
203
|
+
}, 'final')
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return text
|
|
207
|
+
} catch {
|
|
208
|
+
// Shared warm failed under local-only; fall through so finalize can retry with automatic.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const existing = chunkTranscriptJobs.get(purposeKey)
|
|
171
213
|
if (existing) return existing
|
|
214
|
+
|
|
172
215
|
const job = (async () => {
|
|
173
216
|
try {
|
|
174
|
-
|
|
217
|
+
// Speculative warm is always local-only. Finalize may use automatic cloud fallback.
|
|
218
|
+
const policy = purpose === 'warm' ? 'local-only' as const : 'automatic' as const
|
|
219
|
+
const result = await transcribeAudioBuffer(audio, { mode, policy })
|
|
175
220
|
if (!isCurrentChunk(draftId, chunkIndex, audio)) return ''
|
|
176
221
|
const text = sanitizeTranscript(draftId, result.text)
|
|
177
222
|
const record: PromptDraftTranscriptRecord = {
|
|
178
223
|
text, hash, requestedMode: result.requestedMode, actualQuality: result.actualQuality,
|
|
179
224
|
backend: result.backend, degraded: result.degraded,
|
|
225
|
+
...(result.degradationReason ? { degradationReason: result.degradationReason } : {}),
|
|
180
226
|
}
|
|
181
227
|
await markPromptDraftChunkTranscript(draftId, chunkIndex, record, purpose)
|
|
182
|
-
console.log(`[prompt-draft] chunk ${draftId}/${chunkIndex}: ${result.elapsedMs.toFixed(1)}ms | ${result.backend} | ${text.length} chars`)
|
|
228
|
+
console.log(`[prompt-draft] chunk ${draftId}/${chunkIndex}: ${result.elapsedMs.toFixed(1)}ms | ${result.backend} | ${purpose}/${mode} | ${text.length} chars`)
|
|
183
229
|
return text
|
|
184
230
|
} catch (err) {
|
|
185
231
|
if (err instanceof NoSpeechDetectedError) {
|
|
@@ -190,10 +236,12 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
190
236
|
}
|
|
191
237
|
throw err
|
|
192
238
|
} finally {
|
|
193
|
-
chunkTranscriptJobs.delete(
|
|
239
|
+
chunkTranscriptJobs.delete(purposeKey)
|
|
240
|
+
modeQualityJobs.delete(sharedKey)
|
|
194
241
|
}
|
|
195
242
|
})()
|
|
196
|
-
chunkTranscriptJobs.set(
|
|
243
|
+
chunkTranscriptJobs.set(purposeKey, job)
|
|
244
|
+
modeQualityJobs.set(sharedKey, job)
|
|
197
245
|
return job
|
|
198
246
|
}
|
|
199
247
|
|
|
@@ -201,14 +249,40 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
201
249
|
const meta = loadPromptDraftMeta(draftId)
|
|
202
250
|
if (!meta) throw Object.assign(new Error('draft not found'), { status: 404 })
|
|
203
251
|
const texts: string[] = []
|
|
252
|
+
const transcriptRecords: PromptDraftTranscriptRecord[] = []
|
|
204
253
|
for (const chunk of readPromptDraftChunks(draftId)) {
|
|
205
254
|
try {
|
|
255
|
+
const hash = audioHash(chunk.audioBuffer)
|
|
256
|
+
// Await in-flight HQ warm before deciding cache miss (plan step 8b belt).
|
|
257
|
+
if (mode === 'hq') {
|
|
258
|
+
const inflight = modeQualityJobs.get(modeQualityKey(draftId, chunk.chunkIndex, 'hq', hash))
|
|
259
|
+
if (inflight) {
|
|
260
|
+
try { await inflight } catch { /* finalize may retry with automatic below */ }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
206
263
|
const current = loadPromptDraftMeta(draftId)
|
|
207
264
|
const cached = current?.finalTranscripts?.[String(chunk.chunkIndex)] ?? current?.warmTranscripts?.[String(chunk.chunkIndex)]
|
|
208
|
-
|
|
265
|
+
// Reuse the exact requested-mode decode even when HQ truthfully degraded
|
|
266
|
+
// to turbo. Finalize's automatic policy would make the same local choice
|
|
267
|
+
// again after a successful turbo result, so a second decode adds latency
|
|
268
|
+
// without improving quality. Legacy records stay excluded because their
|
|
269
|
+
// decoder provenance was reconstructed during migration.
|
|
270
|
+
const reusable = Boolean(
|
|
271
|
+
cached
|
|
272
|
+
&& cached.hash === hash
|
|
273
|
+
&& cached.requestedMode === mode
|
|
274
|
+
&& !cached.backend.startsWith('legacy'),
|
|
275
|
+
)
|
|
209
276
|
const raw = reusable ? cached!.text : await transcribeChunk(draftId, chunk.chunkIndex, chunk.audioBuffer, mode, 'final')
|
|
210
277
|
const text = sanitizeTranscript(draftId, raw, !reusable)
|
|
211
|
-
if (text.trim())
|
|
278
|
+
if (text.trim()) {
|
|
279
|
+
texts.push(text.trim())
|
|
280
|
+
const latest = loadPromptDraftMeta(draftId)
|
|
281
|
+
const used = latest?.finalTranscripts?.[String(chunk.chunkIndex)]
|
|
282
|
+
?? latest?.warmTranscripts?.[String(chunk.chunkIndex)]
|
|
283
|
+
?? cached
|
|
284
|
+
if (used && used.hash === hash) transcriptRecords.push(used)
|
|
285
|
+
}
|
|
212
286
|
} catch (err) {
|
|
213
287
|
if (err instanceof NoSpeechDetectedError) continue
|
|
214
288
|
throw err
|
|
@@ -221,7 +295,28 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
221
295
|
}
|
|
222
296
|
const finalText = await cleanOutboundDictation(text, { ...autoClean, signal })
|
|
223
297
|
const finalized = await markPromptDraftFinalized(draftId, finalText)
|
|
224
|
-
|
|
298
|
+
const qualities = transcriptRecords.map(record => record.actualQuality)
|
|
299
|
+
const actualQuality: 'hq' | 'fast' | 'cloud' = qualities.includes('cloud')
|
|
300
|
+
? 'cloud'
|
|
301
|
+
: qualities.length > 0 && qualities.every(quality => quality === 'hq')
|
|
302
|
+
? 'hq'
|
|
303
|
+
: 'fast'
|
|
304
|
+
const degraded = mode === 'hq' && (actualQuality !== 'hq' || transcriptRecords.some(record => record.degraded))
|
|
305
|
+
const backends = [...new Set(transcriptRecords.map(record => record.backend))]
|
|
306
|
+
const degradationReason = transcriptRecords.find(record => record.degradationReason)?.degradationReason
|
|
307
|
+
return {
|
|
308
|
+
draftId,
|
|
309
|
+
text: finalText,
|
|
310
|
+
recovered: true,
|
|
311
|
+
chunkCount: finalized.receivedChunkIndexes.length,
|
|
312
|
+
missingChunks: getMissingChunkIndexes(finalized),
|
|
313
|
+
expiresAt: finalized.expiresAt,
|
|
314
|
+
requestedMode: mode,
|
|
315
|
+
actualQuality,
|
|
316
|
+
degraded,
|
|
317
|
+
backend: backends.length === 1 ? backends[0] : 'mixed',
|
|
318
|
+
...(degradationReason ? { degradationReason } : {}),
|
|
319
|
+
}
|
|
225
320
|
}
|
|
226
321
|
|
|
227
322
|
const prunedAtBoot = maintenanceAdmissionsOpen() ? prunePromptDrafts() : 0
|
|
@@ -265,10 +360,13 @@ promptDraftsRouter.post('/prompt-drafts/:draftId/chunks', async (req, res) => {
|
|
|
265
360
|
const nextTotal = Object.values(before.chunkBytes).reduce((sum, bytes) => sum + bytes, 0) - existingBytes + audio.length
|
|
266
361
|
if (nextTotal > MAX_DRAFT_BYTES) return res.status(413).json({ error: 'prompt draft too large' })
|
|
267
362
|
const meta = await savePromptDraftChunk(req.params.draftId, chunkIndex, audio)
|
|
363
|
+
const requestedMode = routeMode(req)
|
|
268
364
|
const warmLease = acquireMaintenanceWork('prompt_draft_warm', {
|
|
269
365
|
allowDuringDrain: true,
|
|
270
366
|
phase: 'queued',
|
|
271
367
|
})
|
|
368
|
+
// Fast warm feeds the live HUD. Speculative HQ (when Settings HQ / default)
|
|
369
|
+
// overwrites warmTranscripts with actualQuality=hq for near-instant finalize.
|
|
272
370
|
warmTail = warmTail.then(async () => {
|
|
273
371
|
warmLease.setPhase('active')
|
|
274
372
|
const text = await transcribeChunk(req.params.draftId, chunkIndex, audio, 'fast', 'warm')
|
|
@@ -283,6 +381,21 @@ promptDraftsRouter.post('/prompt-drafts/:draftId/chunks', async (req, res) => {
|
|
|
283
381
|
}).catch(err => {
|
|
284
382
|
console.warn(`[prompt-draft] warm transcription failed ${req.params.draftId}/${chunkIndex}: ${err.message}`)
|
|
285
383
|
}).finally(() => warmLease.release())
|
|
384
|
+
|
|
385
|
+
if (requestedMode === 'hq' && speculativeHqWarmEnabled()) {
|
|
386
|
+
const hqLease = acquireMaintenanceWork('prompt_draft_warm', {
|
|
387
|
+
allowDuringDrain: true,
|
|
388
|
+
phase: 'queued',
|
|
389
|
+
})
|
|
390
|
+
hqWarmTail = hqWarmTail.then(async () => {
|
|
391
|
+
hqLease.setPhase('active')
|
|
392
|
+
// Cache only — never emitDisplay HQ (avoids HUD flicker). local-only via purpose=warm.
|
|
393
|
+
await transcribeChunk(req.params.draftId, chunkIndex, audio, 'hq', 'warm')
|
|
394
|
+
}).catch(err => {
|
|
395
|
+
console.warn(`[prompt-draft] speculative HQ warm failed ${req.params.draftId}/${chunkIndex}: ${err.message}`)
|
|
396
|
+
}).finally(() => hqLease.release())
|
|
397
|
+
}
|
|
398
|
+
|
|
286
399
|
res.json({ draftId: meta.draftId, chunkIndex, acked: true, receivedChunkIndexes: meta.receivedChunkIndexes, chunkBytes: meta.chunkBytes[String(chunkIndex)] ?? audio.length, transcriptPending: true, expiresAt: meta.expiresAt })
|
|
287
400
|
} catch (err: any) {
|
|
288
401
|
if (err instanceof MaintenanceLifecycleError) {
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { Router } from 'express'
|
|
2
2
|
import { runProviderProof, type ProofProvider } from '../lib/provider-proof.js'
|
|
3
|
+
import {
|
|
4
|
+
acquireMaintenanceWork,
|
|
5
|
+
maintenanceErrorPayload,
|
|
6
|
+
maintenanceOperationCredentialsValid,
|
|
7
|
+
MaintenanceLifecycleError,
|
|
8
|
+
} from '../lib/maintenance-lifecycle.js'
|
|
3
9
|
|
|
4
10
|
export const providerProofRouter = Router()
|
|
5
11
|
|
|
@@ -14,6 +20,38 @@ providerProofRouter.post('/diagnostics/provider-proof', async (req, res) => {
|
|
|
14
20
|
if (provider !== 'claude' && provider !== 'codex') {
|
|
15
21
|
return res.status(400).json({ error: 'provider must be claude or codex' })
|
|
16
22
|
}
|
|
17
|
-
const
|
|
18
|
-
|
|
23
|
+
const controllerProof = maintenanceOperationCredentialsValid({
|
|
24
|
+
leaseId: typeof req.headers['x-cos-maintenance-lease'] === 'string'
|
|
25
|
+
? req.headers['x-cos-maintenance-lease'] : undefined,
|
|
26
|
+
operationId: typeof req.headers['x-cos-maintenance-operation'] === 'string'
|
|
27
|
+
? req.headers['x-cos-maintenance-operation'] : undefined,
|
|
28
|
+
nonce: typeof req.headers['x-cos-maintenance-nonce'] === 'string'
|
|
29
|
+
? req.headers['x-cos-maintenance-nonce'] : undefined,
|
|
30
|
+
})
|
|
31
|
+
let lease
|
|
32
|
+
try {
|
|
33
|
+
lease = acquireMaintenanceWork('api_mutation', { allowDuringDrain: controllerProof })
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error instanceof MaintenanceLifecycleError) {
|
|
36
|
+
if (error.retryAfterSeconds != null) res.setHeader('Retry-After', String(error.retryAfterSeconds))
|
|
37
|
+
return res.status(error.status).json(maintenanceErrorPayload(error))
|
|
38
|
+
}
|
|
39
|
+
return res.status(500).json({ error: 'maintenance_internal_error', retryable: false })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const abort = new AbortController()
|
|
43
|
+
let responseFinished = false
|
|
44
|
+
const cancel = () => { if (!responseFinished) abort.abort(new Error('Control proof client disconnected')) }
|
|
45
|
+
req.once('aborted', cancel)
|
|
46
|
+
res.once('finish', () => { responseFinished = true })
|
|
47
|
+
res.once('close', cancel)
|
|
48
|
+
try {
|
|
49
|
+
const result = await runProviderProof(provider as ProofProvider, abort.signal)
|
|
50
|
+
if (abort.signal.aborted) return
|
|
51
|
+
return res.status(result.ok ? 200 : 503).json(result)
|
|
52
|
+
} finally {
|
|
53
|
+
req.removeListener('aborted', cancel)
|
|
54
|
+
res.removeListener('close', cancel)
|
|
55
|
+
lease.release()
|
|
56
|
+
}
|
|
19
57
|
})
|
|
@@ -44,7 +44,15 @@ transcribeRouter.post('/transcribe', async (req, res) => {
|
|
|
44
44
|
|
|
45
45
|
const result = await transcribeAudioBuffer(audioBuffer, { mode: resolveMode(req) })
|
|
46
46
|
console.log(`[perf] /transcribe: ${result.elapsedMs.toFixed(1)}ms | mode=${result.mode} | ${result.backend} | ${result.audioBytes}b | ${result.text.length} chars`)
|
|
47
|
-
res.json({
|
|
47
|
+
res.json({
|
|
48
|
+
text: result.text,
|
|
49
|
+
backend: result.backend,
|
|
50
|
+
mode: result.mode,
|
|
51
|
+
requestedMode: result.requestedMode,
|
|
52
|
+
actualQuality: result.actualQuality,
|
|
53
|
+
degraded: result.degraded,
|
|
54
|
+
...(result.degradationReason ? { degradationReason: result.degradationReason } : {}),
|
|
55
|
+
})
|
|
48
56
|
} catch (err: any) {
|
|
49
57
|
if (err instanceof MaintenanceLifecycleError) {
|
|
50
58
|
if (err.retryAfterSeconds != null) res.setHeader('Retry-After', String(err.retryAfterSeconds))
|