@gotcos/glasses-server 6.15.5 → 6.16.1
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 +37 -0
- package/README.md +59 -9
- package/bin/cli.cjs +71 -5
- package/package.json +8 -3
- package/server/lib/claude-bridge.ts +14 -1
- package/server/lib/cli-debug-view.ts +31 -4
- package/server/lib/cursor-bridge.ts +726 -0
- package/server/lib/cursor-engine-sessions.ts +162 -0
- package/server/lib/cursor-model-catalog.ts +288 -0
- package/server/lib/cursor-run-ledger.ts +300 -0
- package/server/lib/hallucination-filter.ts +1 -1
- package/server/lib/model-router.ts +26 -1
- package/server/lib/prompt-draft-store.ts +1 -0
- package/server/lib/provider-terminal-error.ts +1 -1
- package/server/lib/query-job-coordinator.ts +1 -0
- package/server/lib/query-job-runtime.ts +8 -2
- package/server/lib/query-job-store.ts +11 -3
- package/server/lib/query-job-types.ts +2 -1
- package/server/lib/speaker-trainer.ts +1 -1
- package/server/lib/token-audit.ts +11 -1
- package/server/lib/transcribe-audio.ts +11 -2
- package/server/lib/whisper-local.ts +78 -3
- package/server/models/silero_vad.onnx +0 -0
- package/server/routes/cli-debug.ts +8 -0
- package/server/routes/health.ts +65 -7
- package/server/routes/openai-compat.ts +28 -4
- package/server/routes/prompt-drafts.ts +44 -3
- package/server/routes/transcribe.ts +9 -1
- package/shared/model-preference.ts +50 -2
|
@@ -42,6 +42,49 @@ export interface WhisperSegment {
|
|
|
42
42
|
words?: WhisperWord[]
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export type HighQualityUnavailableReason =
|
|
46
|
+
| 'hq_disabled'
|
|
47
|
+
| 'whisper_cli_missing'
|
|
48
|
+
| 'turbo_model_missing'
|
|
49
|
+
| 'large_v3_model_missing'
|
|
50
|
+
|
|
51
|
+
export interface HighQualityTranscriptionCapability {
|
|
52
|
+
hqAvailable: boolean
|
|
53
|
+
model: 'large-v3' | null
|
|
54
|
+
backend: 'whisper-cli' | null
|
|
55
|
+
reason: HighQualityUnavailableReason | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface HighQualityTranscriptionResult {
|
|
59
|
+
text: string
|
|
60
|
+
words?: WhisperWord[]
|
|
61
|
+
model: 'large-v3' | 'turbo'
|
|
62
|
+
backend: 'whisper-cli' | 'whisper-server'
|
|
63
|
+
actualQuality: 'hq' | 'fast'
|
|
64
|
+
degradationReason?: HighQualityUnavailableReason
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function classifyHighQualityTranscriptionCapability(input: {
|
|
68
|
+
enabled: boolean
|
|
69
|
+
cliPresent: boolean
|
|
70
|
+
turboReady: boolean
|
|
71
|
+
largeV3Present: boolean
|
|
72
|
+
}): HighQualityTranscriptionCapability {
|
|
73
|
+
if (!input.enabled) {
|
|
74
|
+
return { hqAvailable: false, model: null, backend: null, reason: 'hq_disabled' }
|
|
75
|
+
}
|
|
76
|
+
if (!input.cliPresent) {
|
|
77
|
+
return { hqAvailable: false, model: null, backend: null, reason: 'whisper_cli_missing' }
|
|
78
|
+
}
|
|
79
|
+
if (!input.turboReady) {
|
|
80
|
+
return { hqAvailable: false, model: null, backend: null, reason: 'turbo_model_missing' }
|
|
81
|
+
}
|
|
82
|
+
if (!input.largeV3Present) {
|
|
83
|
+
return { hqAvailable: false, model: null, backend: null, reason: 'large_v3_model_missing' }
|
|
84
|
+
}
|
|
85
|
+
return { hqAvailable: true, model: 'large-v3', backend: 'whisper-cli', reason: null }
|
|
86
|
+
}
|
|
87
|
+
|
|
45
88
|
interface WhisperJsonResponse {
|
|
46
89
|
text?: unknown
|
|
47
90
|
}
|
|
@@ -554,6 +597,20 @@ export function getWhisperHealth(): {
|
|
|
554
597
|
}
|
|
555
598
|
}
|
|
556
599
|
|
|
600
|
+
/** Public, path-free truth about whether an HQ request can actually run the
|
|
601
|
+
* full large-v3 decoder. Keep this separate from generic Whisper liveness: the
|
|
602
|
+
* persistent turbo server may be healthy while HQ weights or the CLI are not. */
|
|
603
|
+
export function getHighQualityTranscriptionCapability(): HighQualityTranscriptionCapability {
|
|
604
|
+
// cliAvailable also proves the turbo model exists. That fallback is part of
|
|
605
|
+
// the current decoder contract and is initialized once at process startup.
|
|
606
|
+
return classifyHighQualityTranscriptionCapability({
|
|
607
|
+
enabled: BATCH_LARGE_V3_ENABLED,
|
|
608
|
+
cliPresent: existsSync(WHISPER_CLI),
|
|
609
|
+
turboReady: cliAvailable,
|
|
610
|
+
largeV3Present: existsSync(BATCH_MODEL_LARGE_V3),
|
|
611
|
+
})
|
|
612
|
+
}
|
|
613
|
+
|
|
557
614
|
/**
|
|
558
615
|
* Reconcile a cached unavailable flag with the daemon's live health endpoint.
|
|
559
616
|
* Only successful inference resets the failure count: /health can be responsive
|
|
@@ -641,10 +698,18 @@ export async function transcribeHighQuality(
|
|
|
641
698
|
audioBuffer: Buffer,
|
|
642
699
|
context?: string,
|
|
643
700
|
opts: { priority?: 'interactive' | 'batch' } = {},
|
|
644
|
-
): Promise<
|
|
701
|
+
): Promise<HighQualityTranscriptionResult> {
|
|
645
702
|
if (!cliAvailable) {
|
|
646
703
|
// Fall back to server (no beam search available via HTTP API)
|
|
647
|
-
|
|
704
|
+
const fallback = await transcribeLocal(audioBuffer, context)
|
|
705
|
+
return {
|
|
706
|
+
text: fallback.text,
|
|
707
|
+
words: fallback.words,
|
|
708
|
+
model: 'turbo',
|
|
709
|
+
backend: fallback.backend === 'server' ? 'whisper-server' : 'whisper-cli',
|
|
710
|
+
actualQuality: 'fast',
|
|
711
|
+
degradationReason: existsSync(WHISPER_CLI) ? 'turbo_model_missing' : 'whisper_cli_missing',
|
|
712
|
+
}
|
|
648
713
|
}
|
|
649
714
|
|
|
650
715
|
const start = Date.now()
|
|
@@ -769,7 +834,17 @@ export async function transcribeHighQuality(
|
|
|
769
834
|
`${words ? `, ${words.length} words` : ''}): ` +
|
|
770
835
|
`"${corrected.slice(0, 80)}${corrected.length > 80 ? '...' : ''}"`,
|
|
771
836
|
)
|
|
772
|
-
|
|
837
|
+
const metadata = useLargeV3
|
|
838
|
+
? { model: 'large-v3' as const, backend: 'whisper-cli' as const, actualQuality: 'hq' as const }
|
|
839
|
+
: {
|
|
840
|
+
model: 'turbo' as const,
|
|
841
|
+
backend: 'whisper-cli' as const,
|
|
842
|
+
actualQuality: 'fast' as const,
|
|
843
|
+
degradationReason: BATCH_LARGE_V3_ENABLED
|
|
844
|
+
? 'large_v3_model_missing' as const
|
|
845
|
+
: 'hq_disabled' as const,
|
|
846
|
+
}
|
|
847
|
+
return words ? { text: corrected, words, ...metadata } : { text: corrected, ...metadata }
|
|
773
848
|
} finally {
|
|
774
849
|
try { unlinkSync(tmpWav) } catch { /* cleanup */ }
|
|
775
850
|
if (captureBatchWords) {
|
|
Binary file
|
|
@@ -8,6 +8,10 @@ import {
|
|
|
8
8
|
getCodexRunConfig,
|
|
9
9
|
listCodexRuns,
|
|
10
10
|
} from '../lib/codex-run-ledger.js'
|
|
11
|
+
import {
|
|
12
|
+
getCursorRunConfig,
|
|
13
|
+
listCursorRuns,
|
|
14
|
+
} from '../lib/cursor-run-ledger.js'
|
|
11
15
|
import {
|
|
12
16
|
safeCliDebugResponse,
|
|
13
17
|
safeLegacyClaudeResponse,
|
|
@@ -38,13 +42,17 @@ cliDebugRouter.get('/cli/debug', (req, res) => {
|
|
|
38
42
|
const model = optionalClaudeModel(req.query.model)
|
|
39
43
|
const claudeConfig = getClaudeRunConfig()
|
|
40
44
|
const codexConfig = getCodexRunConfig()
|
|
45
|
+
const cursorConfig = getCursorRunConfig()
|
|
41
46
|
const claudeRuns = listClaudeRuns(limit, sessionId, model)
|
|
42
47
|
const codexRuns = listCodexRuns(limit, sessionId)
|
|
48
|
+
const cursorRuns = listCursorRuns(limit, sessionId)
|
|
43
49
|
res.json(safeCliDebugResponse(
|
|
44
50
|
claudeConfig,
|
|
45
51
|
claudeRuns[0],
|
|
46
52
|
codexConfig,
|
|
47
53
|
codexRuns[0],
|
|
54
|
+
cursorConfig,
|
|
55
|
+
cursorRuns[0],
|
|
48
56
|
))
|
|
49
57
|
})
|
|
50
58
|
|
package/server/routes/health.ts
CHANGED
|
@@ -8,13 +8,23 @@ import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
|
8
8
|
import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
|
|
9
9
|
import { isSileroAvailable } from '../lib/vad-silero.js'
|
|
10
10
|
import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
isWhisperLocalAvailable,
|
|
13
|
+
getWhisperHealth,
|
|
14
|
+
getHighQualityTranscriptionCapability,
|
|
15
|
+
} from '../lib/whisper-local.js'
|
|
12
16
|
import { getOpenAIWhisperBudgetState } from '../lib/openai-whisper-budget.js'
|
|
13
17
|
import { getKeyStatus } from '../lib/openai-key.js'
|
|
14
18
|
import {
|
|
15
19
|
getCodexModelCatalog,
|
|
16
20
|
getCodexModelCatalogSnapshot,
|
|
17
21
|
} from '../lib/codex-model-catalog.js'
|
|
22
|
+
import {
|
|
23
|
+
getCursorModelCatalog,
|
|
24
|
+
getCursorModelCatalogSnapshot,
|
|
25
|
+
isCursorProviderReady,
|
|
26
|
+
resolveAgentBinary,
|
|
27
|
+
} from '../lib/cursor-model-catalog.js'
|
|
18
28
|
import { isMediaProcessingReady } from '../lib/image-safety.js'
|
|
19
29
|
import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
|
|
20
30
|
import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
|
|
@@ -66,6 +76,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
66
76
|
python: 'unknown',
|
|
67
77
|
claude: 'unknown',
|
|
68
78
|
codex: 'unknown',
|
|
79
|
+
cursor: 'unknown',
|
|
69
80
|
uptime_seconds: Math.floor((Date.now() - serverMetrics.startedAt) / 1000),
|
|
70
81
|
request_count: serverMetrics.requestCount,
|
|
71
82
|
}
|
|
@@ -73,6 +84,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
73
84
|
// Feature detection flags
|
|
74
85
|
let claudeAvailable = false
|
|
75
86
|
let codexAvailable = false
|
|
87
|
+
let cursorAvailable = false
|
|
76
88
|
|
|
77
89
|
// Check Python venv (COS mode only)
|
|
78
90
|
if (PYTHON_BIN) {
|
|
@@ -122,6 +134,36 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
122
134
|
checks.codex = 'error'
|
|
123
135
|
}
|
|
124
136
|
|
|
137
|
+
// Cursor Agent CLI — probe via `agent about` only (≤5s). Never use
|
|
138
|
+
// `agent status` (can hang on login UX while logged out).
|
|
139
|
+
try {
|
|
140
|
+
const agentBinary = resolveAgentBinary()
|
|
141
|
+
if (!agentBinary) {
|
|
142
|
+
checks.cursor = 'error'
|
|
143
|
+
} else {
|
|
144
|
+
await new Promise<void>((resolveCheck, reject) => {
|
|
145
|
+
execFile(agentBinary, ['about'], { timeout: 5000 }, (err, stdout, stderr) => {
|
|
146
|
+
if (err) return reject(err)
|
|
147
|
+
const combined = `${stdout}\n${stderr}`.trim()
|
|
148
|
+
const versionLine = combined.split('\n').map(line => line.trim()).find(line =>
|
|
149
|
+
/CLI Version|cursor|agent/i.test(line),
|
|
150
|
+
)
|
|
151
|
+
checks.cursor = versionLine ?? combined.split('\n')[0] ?? 'available'
|
|
152
|
+
resolveCheck()
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
await getCursorModelCatalog()
|
|
156
|
+
cursorAvailable = isCursorProviderReady()
|
|
157
|
+
if (!cursorAvailable) {
|
|
158
|
+
checks.cursor = typeof checks.cursor === 'string' && checks.cursor !== 'error'
|
|
159
|
+
? `${checks.cursor} (models unresolved)`
|
|
160
|
+
: 'error'
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
checks.cursor = 'error'
|
|
165
|
+
}
|
|
166
|
+
|
|
125
167
|
// Check session cache freshness (COS mode only)
|
|
126
168
|
if (COS_SCRIPTS_DIR) {
|
|
127
169
|
try {
|
|
@@ -150,12 +192,14 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
150
192
|
const durableJobs = durableQueryJobStatus()
|
|
151
193
|
const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
|
|
152
194
|
const transcription = getTranscriptionPolicySnapshot()
|
|
195
|
+
const transcriptionHq = getHighQualityTranscriptionCapability()
|
|
153
196
|
const recovery = managedRuntimeCapability()
|
|
154
197
|
const maintenance = maintenanceLifecycle.snapshot()
|
|
155
198
|
const tts_local = getLocalTtsHealth()
|
|
156
199
|
const features = {
|
|
157
200
|
claude: claudeAvailable,
|
|
158
201
|
codex: codexAvailable,
|
|
202
|
+
cursor: cursorAvailable,
|
|
159
203
|
voice: keyStatus.hasKey || tts_local.ready,
|
|
160
204
|
cos_pipeline: COS_MODE,
|
|
161
205
|
whisper: isWhisperLocalAvailable(),
|
|
@@ -199,6 +243,10 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
199
243
|
}
|
|
200
244
|
const openai_whisper_budget = getOpenAIWhisperBudgetState()
|
|
201
245
|
const codex_models = getCodexModelCatalogSnapshot()
|
|
246
|
+
// Unauthenticated /api/health publishes Cursor slot capability only; concrete
|
|
247
|
+
// agent binary paths stay on the authenticated /api/models surface.
|
|
248
|
+
const cursorSnapshot = getCursorModelCatalogSnapshot()
|
|
249
|
+
const { agentBinary: _cursorAgentBinary, ...cursor_models } = cursorSnapshot
|
|
202
250
|
res.json({
|
|
203
251
|
...checks,
|
|
204
252
|
server_version: managedServerVersion(),
|
|
@@ -212,8 +260,9 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
212
260
|
openai_whisper_budget,
|
|
213
261
|
tts_local,
|
|
214
262
|
codex_models,
|
|
263
|
+
cursor_models,
|
|
215
264
|
capabilities: {
|
|
216
|
-
transcription,
|
|
265
|
+
transcription: { ...transcription, hq: transcriptionHq },
|
|
217
266
|
recovery,
|
|
218
267
|
maintenance: {
|
|
219
268
|
state: maintenance.state,
|
|
@@ -235,29 +284,38 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
235
284
|
})
|
|
236
285
|
})
|
|
237
286
|
|
|
238
|
-
// Stable app slots backed by Codex
|
|
239
|
-
//
|
|
287
|
+
// Stable app slots backed by Codex + Cursor live catalogs. Authenticated by
|
|
288
|
+
// global /api middleware; ?refresh=1 forces discovery.
|
|
240
289
|
healthRouter.get('/models', async (req, res) => {
|
|
241
|
-
const
|
|
290
|
+
const forceRefresh = req.query.refresh === '1'
|
|
291
|
+
const catalog = await getCodexModelCatalog(forceRefresh)
|
|
292
|
+
const cursorCatalog = await getCursorModelCatalog(forceRefresh)
|
|
242
293
|
const durableJobs = durableQueryJobStatus()
|
|
243
294
|
const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
|
|
244
295
|
const transcription = getTranscriptionPolicySnapshot()
|
|
296
|
+
const transcriptionHq = getHighQualityTranscriptionCapability()
|
|
297
|
+
const cursorOptions = cursorCatalog.options.filter(option => !!option.id)
|
|
245
298
|
res.json({
|
|
246
299
|
...catalog,
|
|
300
|
+
options: [
|
|
301
|
+
...(catalog.options ?? []),
|
|
302
|
+
...cursorOptions,
|
|
303
|
+
],
|
|
304
|
+
cursor: cursorCatalog,
|
|
305
|
+
cursorReady: isCursorProviderReady(),
|
|
247
306
|
serverInstanceId: getServerInstanceId(),
|
|
248
307
|
capabilities: {
|
|
249
308
|
durableQueryJobs: {
|
|
250
309
|
enabled: durableJobs.enabled,
|
|
251
310
|
protocolVersion: durableJobs.protocolVersion,
|
|
252
311
|
},
|
|
253
|
-
transcription,
|
|
312
|
+
transcription: { ...transcription, hq: transcriptionHq },
|
|
254
313
|
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
255
314
|
recovery: managedRuntimeCapability(),
|
|
256
315
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
257
316
|
},
|
|
258
317
|
})
|
|
259
318
|
})
|
|
260
|
-
|
|
261
319
|
// GET /api/cli-session — returns current CLI session ID for cross-device resume
|
|
262
320
|
healthRouter.get('/cli-session', (req, res) => {
|
|
263
321
|
const cosSessionId = req.query.sid as string | undefined
|
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
getCodexModelCatalog,
|
|
12
12
|
resolveCodexPreferenceForModelId,
|
|
13
13
|
} from '../lib/codex-model-catalog.js'
|
|
14
|
+
import { resolveCursorPreferenceForModelId } from '../lib/cursor-model-catalog.js'
|
|
14
15
|
import { tryInstantResponse } from '../lib/response-cache.js'
|
|
16
|
+
|
|
15
17
|
import crypto from 'node:crypto'
|
|
16
18
|
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
17
19
|
import {
|
|
@@ -80,14 +82,31 @@ function validateAuth(req: any, res: any): boolean {
|
|
|
80
82
|
return true
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Pick the model id an OpenAI-compatible request asked for. A `?model=` on the
|
|
87
|
+
* completion URL wins over the request body, so an Even "Add Agent" endpoint
|
|
88
|
+
* can pin a slot even though the client hardcodes a generic body model.
|
|
89
|
+
*/
|
|
90
|
+
export function selectOpenAICompatibleModel(
|
|
91
|
+
bodyModel?: string,
|
|
92
|
+
urlModel?: string,
|
|
93
|
+
): string | undefined {
|
|
94
|
+
const fromUrl = urlModel?.trim()
|
|
95
|
+
if (fromUrl) return fromUrl
|
|
96
|
+
const fromBody = bodyModel?.trim()
|
|
97
|
+
return fromBody || undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
83
100
|
// Resolve model from OpenAI-compatible ids, stable app slots, or concrete ids
|
|
84
|
-
// currently advertised by the live Codex
|
|
85
|
-
function resolveModel(model?: string, _query?: string): ModelPreference {
|
|
101
|
+
// currently advertised by the live Codex / Cursor catalogs.
|
|
102
|
+
export function resolveModel(model?: string, _query?: string): ModelPreference {
|
|
86
103
|
const normalized = normalizeModelPreference(model)
|
|
87
104
|
if (normalized) return normalized
|
|
88
105
|
if (model) {
|
|
89
106
|
const catalogPreference = resolveCodexPreferenceForModelId(model)
|
|
90
107
|
if (catalogPreference) return catalogPreference
|
|
108
|
+
const cursorPreference = resolveCursorPreferenceForModelId(model)
|
|
109
|
+
if (cursorPreference) return cursorPreference
|
|
91
110
|
}
|
|
92
111
|
if (model === 'cos-opus') return 'opus'
|
|
93
112
|
if (model === 'cos-fable') return 'fable'
|
|
@@ -105,8 +124,9 @@ const MODEL_NAMES: Record<ModelPreference, string> = {
|
|
|
105
124
|
haiku: 'cos-haiku',
|
|
106
125
|
'codex-frontier': 'cos-gpt-frontier',
|
|
107
126
|
'codex-balanced': 'cos-gpt-balanced',
|
|
127
|
+
'cursor-grok': 'cursor-grok',
|
|
128
|
+
'cursor-composer': 'cursor-composer',
|
|
108
129
|
}
|
|
109
|
-
|
|
110
130
|
// Extract the user's latest message from the OpenAI messages array
|
|
111
131
|
function extractUserQuery(messages: Array<{ role: string; content: string }>): string {
|
|
112
132
|
// Find the last user message
|
|
@@ -162,7 +182,11 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
162
182
|
console.log(`[g2] Request: stream=${!!stream}, query="${query.slice(0, 50)}"`)
|
|
163
183
|
|
|
164
184
|
const requestReceivedAt = Date.now()
|
|
165
|
-
const
|
|
185
|
+
const selectedModel = selectOpenAICompatibleModel(
|
|
186
|
+
model,
|
|
187
|
+
typeof req.query.model === 'string' ? req.query.model : undefined,
|
|
188
|
+
)
|
|
189
|
+
const resolvedModel = resolveModel(selectedModel, query)
|
|
166
190
|
const completionId = `chatcmpl-${crypto.randomUUID().slice(0, 12)}`
|
|
167
191
|
const timestamp = Math.floor(Date.now() / 1000)
|
|
168
192
|
const responseModel = MODEL_NAMES[resolvedModel]
|
|
@@ -199,6 +199,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
199
199
|
actualQuality: warm?.actualQuality ?? (mode === 'hq' ? 'hq' : 'fast'),
|
|
200
200
|
backend: warm?.backend ?? 'shared-inflight',
|
|
201
201
|
degraded: warm?.degraded ?? false,
|
|
202
|
+
...(warm?.degradationReason ? { degradationReason: warm.degradationReason } : {}),
|
|
202
203
|
}, 'final')
|
|
203
204
|
}
|
|
204
205
|
}
|
|
@@ -221,6 +222,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
221
222
|
const record: PromptDraftTranscriptRecord = {
|
|
222
223
|
text, hash, requestedMode: result.requestedMode, actualQuality: result.actualQuality,
|
|
223
224
|
backend: result.backend, degraded: result.degraded,
|
|
225
|
+
...(result.degradationReason ? { degradationReason: result.degradationReason } : {}),
|
|
224
226
|
}
|
|
225
227
|
await markPromptDraftChunkTranscript(draftId, chunkIndex, record, purpose)
|
|
226
228
|
console.log(`[prompt-draft] chunk ${draftId}/${chunkIndex}: ${result.elapsedMs.toFixed(1)}ms | ${result.backend} | ${purpose}/${mode} | ${text.length} chars`)
|
|
@@ -247,6 +249,7 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
247
249
|
const meta = loadPromptDraftMeta(draftId)
|
|
248
250
|
if (!meta) throw Object.assign(new Error('draft not found'), { status: 404 })
|
|
249
251
|
const texts: string[] = []
|
|
252
|
+
const transcriptRecords: PromptDraftTranscriptRecord[] = []
|
|
250
253
|
for (const chunk of readPromptDraftChunks(draftId)) {
|
|
251
254
|
try {
|
|
252
255
|
const hash = audioHash(chunk.audioBuffer)
|
|
@@ -259,10 +262,27 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
259
262
|
}
|
|
260
263
|
const current = loadPromptDraftMeta(draftId)
|
|
261
264
|
const cached = current?.finalTranscripts?.[String(chunk.chunkIndex)] ?? current?.warmTranscripts?.[String(chunk.chunkIndex)]
|
|
262
|
-
|
|
265
|
+
// Reuse the exact requested-mode decode even when HQ truthfully degraded
|
|
266
|
+
// to turbo. Finalize's automatic policy would make the same local choice
|
|
267
|
+
// again after a successful turbo result, so a second decode adds latency
|
|
268
|
+
// without improving quality. Legacy records stay excluded because their
|
|
269
|
+
// decoder provenance was reconstructed during migration.
|
|
270
|
+
const reusable = Boolean(
|
|
271
|
+
cached
|
|
272
|
+
&& cached.hash === hash
|
|
273
|
+
&& cached.requestedMode === mode
|
|
274
|
+
&& !cached.backend.startsWith('legacy'),
|
|
275
|
+
)
|
|
263
276
|
const raw = reusable ? cached!.text : await transcribeChunk(draftId, chunk.chunkIndex, chunk.audioBuffer, mode, 'final')
|
|
264
277
|
const text = sanitizeTranscript(draftId, raw, !reusable)
|
|
265
|
-
if (text.trim())
|
|
278
|
+
if (text.trim()) {
|
|
279
|
+
texts.push(text.trim())
|
|
280
|
+
const latest = loadPromptDraftMeta(draftId)
|
|
281
|
+
const used = latest?.finalTranscripts?.[String(chunk.chunkIndex)]
|
|
282
|
+
?? latest?.warmTranscripts?.[String(chunk.chunkIndex)]
|
|
283
|
+
?? cached
|
|
284
|
+
if (used && used.hash === hash) transcriptRecords.push(used)
|
|
285
|
+
}
|
|
266
286
|
} catch (err) {
|
|
267
287
|
if (err instanceof NoSpeechDetectedError) continue
|
|
268
288
|
throw err
|
|
@@ -275,7 +295,28 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
275
295
|
}
|
|
276
296
|
const finalText = await cleanOutboundDictation(text, { ...autoClean, signal })
|
|
277
297
|
const finalized = await markPromptDraftFinalized(draftId, finalText)
|
|
278
|
-
|
|
298
|
+
const qualities = transcriptRecords.map(record => record.actualQuality)
|
|
299
|
+
const actualQuality: 'hq' | 'fast' | 'cloud' = qualities.includes('cloud')
|
|
300
|
+
? 'cloud'
|
|
301
|
+
: qualities.length > 0 && qualities.every(quality => quality === 'hq')
|
|
302
|
+
? 'hq'
|
|
303
|
+
: 'fast'
|
|
304
|
+
const degraded = mode === 'hq' && (actualQuality !== 'hq' || transcriptRecords.some(record => record.degraded))
|
|
305
|
+
const backends = [...new Set(transcriptRecords.map(record => record.backend))]
|
|
306
|
+
const degradationReason = transcriptRecords.find(record => record.degradationReason)?.degradationReason
|
|
307
|
+
return {
|
|
308
|
+
draftId,
|
|
309
|
+
text: finalText,
|
|
310
|
+
recovered: true,
|
|
311
|
+
chunkCount: finalized.receivedChunkIndexes.length,
|
|
312
|
+
missingChunks: getMissingChunkIndexes(finalized),
|
|
313
|
+
expiresAt: finalized.expiresAt,
|
|
314
|
+
requestedMode: mode,
|
|
315
|
+
actualQuality,
|
|
316
|
+
degraded,
|
|
317
|
+
backend: backends.length === 1 ? backends[0] : 'mixed',
|
|
318
|
+
...(degradationReason ? { degradationReason } : {}),
|
|
319
|
+
}
|
|
279
320
|
}
|
|
280
321
|
|
|
281
322
|
const prunedAtBoot = maintenanceAdmissionsOpen() ? prunePromptDrafts() : 0
|
|
@@ -44,7 +44,15 @@ transcribeRouter.post('/transcribe', async (req, res) => {
|
|
|
44
44
|
|
|
45
45
|
const result = await transcribeAudioBuffer(audioBuffer, { mode: resolveMode(req) })
|
|
46
46
|
console.log(`[perf] /transcribe: ${result.elapsedMs.toFixed(1)}ms | mode=${result.mode} | ${result.backend} | ${result.audioBytes}b | ${result.text.length} chars`)
|
|
47
|
-
res.json({
|
|
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))
|
|
@@ -3,7 +3,19 @@ export type ClaudeModelPreference = 'opus' | 'fable' | 'sonnet' | 'haiku'
|
|
|
3
3
|
// Stable app-level slots. Concrete GPT model ids resolve at runtime from the
|
|
4
4
|
// local Codex CLI catalog, so a shipped glasses app keeps tracking new releases.
|
|
5
5
|
export type CodexModelPreference = 'codex-frontier' | 'codex-balanced'
|
|
6
|
-
|
|
6
|
+
// Cursor Agent CLI slots. Concrete ids (composer-2.5 / cursor-grok-4.5-high)
|
|
7
|
+
// resolve from `agent models` at runtime. Slots stay recognized in normalize
|
|
8
|
+
// even when features.cursor is false so version skew fail-closes instead of
|
|
9
|
+
// silently remapping to Claude.
|
|
10
|
+
export type CursorModelPreference = 'cursor-grok' | 'cursor-composer'
|
|
11
|
+
/** Cursor Agent CLI execution posture for glasses queries. */
|
|
12
|
+
export type CursorExecutionMode = 'ask' | 'agent'
|
|
13
|
+
export type ModelPreference = ClaudeModelPreference | CodexModelPreference | CursorModelPreference
|
|
14
|
+
|
|
15
|
+
/** Invalid/omitted → ask (safe for old clients that don't send a mode). */
|
|
16
|
+
export function normalizeCursorExecutionMode(value: unknown): CursorExecutionMode {
|
|
17
|
+
return value === 'agent' ? 'agent' : 'ask'
|
|
18
|
+
}
|
|
7
19
|
|
|
8
20
|
// Preserve the public server's established fast, broadly available default.
|
|
9
21
|
export const DEFAULT_MODEL = 'sonnet' as const
|
|
@@ -11,6 +23,8 @@ export const CODEX_FRONTIER_MODEL: CodexModelPreference = 'codex-frontier'
|
|
|
11
23
|
export const CODEX_BALANCED_MODEL: CodexModelPreference = 'codex-balanced'
|
|
12
24
|
// Backward-compatible export for callers that predate the two-slot catalog.
|
|
13
25
|
export const CODEX_HIGH_MODEL: CodexModelPreference = CODEX_FRONTIER_MODEL
|
|
26
|
+
export const CURSOR_GROK_MODEL: CursorModelPreference = 'cursor-grok'
|
|
27
|
+
export const CURSOR_COMPOSER_MODEL: CursorModelPreference = 'cursor-composer'
|
|
14
28
|
// Existing 6.1–6.3 installs may pin the legacy codex-high slot. Frontier is its
|
|
15
29
|
// migration target; Balanced remains auto-catalog even when this override is set.
|
|
16
30
|
export const CODEX_MODEL_ID = process.env.COS_CODEX_MODEL?.trim() ?? ''
|
|
@@ -33,6 +47,8 @@ export const MODEL_OPTIONS: ModelPreference[] = [
|
|
|
33
47
|
'sonnet',
|
|
34
48
|
CODEX_FRONTIER_MODEL,
|
|
35
49
|
CODEX_BALANCED_MODEL,
|
|
50
|
+
CURSOR_GROK_MODEL,
|
|
51
|
+
CURSOR_COMPOSER_MODEL,
|
|
36
52
|
]
|
|
37
53
|
|
|
38
54
|
const MODEL_SET = new Set<ModelPreference>([
|
|
@@ -42,6 +58,8 @@ const MODEL_SET = new Set<ModelPreference>([
|
|
|
42
58
|
'haiku',
|
|
43
59
|
CODEX_FRONTIER_MODEL,
|
|
44
60
|
CODEX_BALANCED_MODEL,
|
|
61
|
+
CURSOR_GROK_MODEL,
|
|
62
|
+
CURSOR_COMPOSER_MODEL,
|
|
45
63
|
])
|
|
46
64
|
|
|
47
65
|
// Bare Claude tier aliases resolve to the newest model in that tier at spawn.
|
|
@@ -127,12 +145,22 @@ export function isCodexModel(model: ModelPreference): model is CodexModelPrefere
|
|
|
127
145
|
return model === CODEX_FRONTIER_MODEL || model === CODEX_BALANCED_MODEL
|
|
128
146
|
}
|
|
129
147
|
|
|
148
|
+
export function isCursorModel(model: ModelPreference): model is CursorModelPreference {
|
|
149
|
+
return model === CURSOR_GROK_MODEL || model === CURSOR_COMPOSER_MODEL
|
|
150
|
+
}
|
|
151
|
+
|
|
130
152
|
export interface RuntimeCodexModelLabel {
|
|
131
153
|
preference: CodexModelPreference
|
|
132
154
|
displayName: string
|
|
133
155
|
}
|
|
134
156
|
|
|
157
|
+
export interface RuntimeCursorModelLabel {
|
|
158
|
+
preference: CursorModelPreference
|
|
159
|
+
displayName: string
|
|
160
|
+
}
|
|
161
|
+
|
|
135
162
|
const runtimeCodexLabels: Partial<Record<CodexModelPreference, string>> = {}
|
|
163
|
+
const runtimeCursorLabels: Partial<Record<CursorModelPreference, string>> = {}
|
|
136
164
|
|
|
137
165
|
export function setRuntimeCodexModelLabels(options: RuntimeCodexModelLabel[]): void {
|
|
138
166
|
for (const option of options) {
|
|
@@ -147,6 +175,19 @@ export function resetRuntimeCodexModelLabels(): void {
|
|
|
147
175
|
delete runtimeCodexLabels[CODEX_BALANCED_MODEL]
|
|
148
176
|
}
|
|
149
177
|
|
|
178
|
+
export function setRuntimeCursorModelLabels(options: RuntimeCursorModelLabel[]): void {
|
|
179
|
+
for (const option of options) {
|
|
180
|
+
if (!isCursorModel(option.preference)) continue
|
|
181
|
+
const label = typeof option.displayName === 'string' ? option.displayName.trim() : ''
|
|
182
|
+
if (label) runtimeCursorLabels[option.preference] = label
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function resetRuntimeCursorModelLabels(): void {
|
|
187
|
+
delete runtimeCursorLabels[CURSOR_GROK_MODEL]
|
|
188
|
+
delete runtimeCursorLabels[CURSOR_COMPOSER_MODEL]
|
|
189
|
+
}
|
|
190
|
+
|
|
150
191
|
export function modelLabel(model: ModelPreference): string {
|
|
151
192
|
switch (model) {
|
|
152
193
|
case 'fable': return 'Fable'
|
|
@@ -154,6 +195,8 @@ export function modelLabel(model: ModelPreference): string {
|
|
|
154
195
|
case 'haiku': return 'Haiku'
|
|
155
196
|
case 'codex-frontier': return runtimeCodexLabels[model] ?? 'GPT Frontier'
|
|
156
197
|
case 'codex-balanced': return runtimeCodexLabels[model] ?? 'GPT Balanced'
|
|
198
|
+
case 'cursor-grok': return runtimeCursorLabels[model] ?? 'Grok 4.5 Fast'
|
|
199
|
+
case 'cursor-composer': return runtimeCursorLabels[model] ?? 'Composer 2.5 Fast'
|
|
157
200
|
case 'opus':
|
|
158
201
|
default:
|
|
159
202
|
return 'Opus'
|
|
@@ -167,6 +210,8 @@ export function modelShortLabel(model: ModelPreference): string {
|
|
|
167
210
|
case 'haiku': return 'Haiku'
|
|
168
211
|
case 'codex-frontier': return 'GPT Max'
|
|
169
212
|
case 'codex-balanced': return 'GPT Bal'
|
|
213
|
+
case 'cursor-grok': return 'Grok'
|
|
214
|
+
case 'cursor-composer': return 'Composer'
|
|
170
215
|
case 'opus':
|
|
171
216
|
default:
|
|
172
217
|
return 'Opus'
|
|
@@ -180,6 +225,8 @@ export function modelButtonLabel(model: ModelPreference): string {
|
|
|
180
225
|
case 'haiku': return 'HAIKU'
|
|
181
226
|
case 'codex-frontier': return 'GPT MAX'
|
|
182
227
|
case 'codex-balanced': return 'GPT BAL'
|
|
228
|
+
case 'cursor-grok': return 'GROK'
|
|
229
|
+
case 'cursor-composer': return 'CMP'
|
|
183
230
|
case 'opus':
|
|
184
231
|
default:
|
|
185
232
|
return 'OPUS'
|
|
@@ -193,12 +240,13 @@ export function modelTag(model: ModelPreference): string {
|
|
|
193
240
|
case 'haiku': return 'H'
|
|
194
241
|
case 'codex-frontier': return 'GF'
|
|
195
242
|
case 'codex-balanced': return 'GB'
|
|
243
|
+
case 'cursor-grok': return 'GK'
|
|
244
|
+
case 'cursor-composer': return 'C2'
|
|
196
245
|
case 'opus':
|
|
197
246
|
default:
|
|
198
247
|
return 'O'
|
|
199
248
|
}
|
|
200
249
|
}
|
|
201
|
-
|
|
202
250
|
export function modelBracketTag(model: ModelPreference): string {
|
|
203
251
|
return ` [${modelTag(model)}]`
|
|
204
252
|
}
|