@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.
@@ -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<{ text: string; words?: WhisperWord[] }> {
701
+ ): Promise<HighQualityTranscriptionResult> {
645
702
  if (!cliAvailable) {
646
703
  // Fall back to server (no beam search available via HTTP API)
647
- return transcribeLocal(audioBuffer, context)
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
- return words ? { text: corrected, words } : { text: corrected }
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
 
@@ -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 { isWhisperLocalAvailable, getWhisperHealth } from '../lib/whisper-local.js'
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's live model/list catalog. This route is
239
- // authenticated by the global /api middleware; ?refresh=1 forces discovery.
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 catalog = await getCodexModelCatalog(req.query.refresh === '1')
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 catalog.
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 resolvedModel = resolveModel(model, query)
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
- const reusable = Boolean(cached && cached.hash === hash && (mode === 'fast' ? cached.actualQuality === 'fast' : cached.actualQuality === 'hq'))
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()) texts.push(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
- return { draftId, text: finalText, recovered: true, chunkCount: finalized.receivedChunkIndexes.length, missingChunks: getMissingChunkIndexes(finalized), expiresAt: finalized.expiresAt }
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({ text: result.text, backend: result.backend, mode: result.mode })
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
- export type ModelPreference = ClaudeModelPreference | CodexModelPreference
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
  }