@gotcos/glasses-server 6.16.0 → 6.16.2

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.
@@ -19,6 +19,12 @@ import {
19
19
  getCodexModelCatalog,
20
20
  getCodexModelCatalogSnapshot,
21
21
  } from '../lib/codex-model-catalog.js'
22
+ import {
23
+ getCursorModelCatalog,
24
+ getCursorModelCatalogSnapshot,
25
+ isCursorProviderReady,
26
+ resolveAgentBinary,
27
+ } from '../lib/cursor-model-catalog.js'
22
28
  import { isMediaProcessingReady } from '../lib/image-safety.js'
23
29
  import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
24
30
  import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
@@ -70,6 +76,7 @@ healthRouter.get('/health', async (_req, res) => {
70
76
  python: 'unknown',
71
77
  claude: 'unknown',
72
78
  codex: 'unknown',
79
+ cursor: 'unknown',
73
80
  uptime_seconds: Math.floor((Date.now() - serverMetrics.startedAt) / 1000),
74
81
  request_count: serverMetrics.requestCount,
75
82
  }
@@ -77,6 +84,7 @@ healthRouter.get('/health', async (_req, res) => {
77
84
  // Feature detection flags
78
85
  let claudeAvailable = false
79
86
  let codexAvailable = false
87
+ let cursorAvailable = false
80
88
 
81
89
  // Check Python venv (COS mode only)
82
90
  if (PYTHON_BIN) {
@@ -126,6 +134,36 @@ healthRouter.get('/health', async (_req, res) => {
126
134
  checks.codex = 'error'
127
135
  }
128
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
+
129
167
  // Check session cache freshness (COS mode only)
130
168
  if (COS_SCRIPTS_DIR) {
131
169
  try {
@@ -161,6 +199,7 @@ healthRouter.get('/health', async (_req, res) => {
161
199
  const features = {
162
200
  claude: claudeAvailable,
163
201
  codex: codexAvailable,
202
+ cursor: cursorAvailable,
164
203
  voice: keyStatus.hasKey || tts_local.ready,
165
204
  cos_pipeline: COS_MODE,
166
205
  whisper: isWhisperLocalAvailable(),
@@ -204,6 +243,10 @@ healthRouter.get('/health', async (_req, res) => {
204
243
  }
205
244
  const openai_whisper_budget = getOpenAIWhisperBudgetState()
206
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
207
250
  res.json({
208
251
  ...checks,
209
252
  server_version: managedServerVersion(),
@@ -217,6 +260,7 @@ healthRouter.get('/health', async (_req, res) => {
217
260
  openai_whisper_budget,
218
261
  tts_local,
219
262
  codex_models,
263
+ cursor_models,
220
264
  capabilities: {
221
265
  transcription: { ...transcription, hq: transcriptionHq },
222
266
  recovery,
@@ -240,16 +284,25 @@ healthRouter.get('/health', async (_req, res) => {
240
284
  })
241
285
  })
242
286
 
243
- // Stable app slots backed by Codex's live model/list catalog. This route is
244
- // 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.
245
289
  healthRouter.get('/models', async (req, res) => {
246
- 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)
247
293
  const durableJobs = durableQueryJobStatus()
248
294
  const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
249
295
  const transcription = getTranscriptionPolicySnapshot()
250
296
  const transcriptionHq = getHighQualityTranscriptionCapability()
297
+ const cursorOptions = cursorCatalog.options.filter(option => !!option.id)
251
298
  res.json({
252
299
  ...catalog,
300
+ options: [
301
+ ...(catalog.options ?? []),
302
+ ...cursorOptions,
303
+ ],
304
+ cursor: cursorCatalog,
305
+ cursorReady: isCursorProviderReady(),
253
306
  serverInstanceId: getServerInstanceId(),
254
307
  capabilities: {
255
308
  durableQueryJobs: {
@@ -263,7 +316,6 @@ healthRouter.get('/models', async (req, res) => {
263
316
  },
264
317
  })
265
318
  })
266
-
267
319
  // GET /api/cli-session — returns current CLI session ID for cross-device resume
268
320
  healthRouter.get('/cli-session', (req, res) => {
269
321
  const cosSessionId = req.query.sid as string | undefined
@@ -1,8 +1,13 @@
1
- // Standalone meeting archive backed only by the public server's private data
2
- // directory. No COS operations paths, classifiers, or user-specific stores.
1
+ // Meeting archive: COS operations tree when configured, else standalone recordings.
3
2
 
4
3
  import { Router } from 'express'
5
4
  import { getMeetingStore, MeetingStore, MeetingStoreError } from '../lib/meeting-store.js'
5
+ import {
6
+ cosOperationsMeetingsConfigured,
7
+ getCosOperationsMeetingDetail,
8
+ listCosOperationsMeetings,
9
+ resolveCosOperationsDir,
10
+ } from '../lib/cos-operations-meetings.js'
6
11
 
7
12
  export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): Router {
8
13
  const router = Router()
@@ -14,7 +19,18 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
14
19
  const limit = Number.isFinite(rawLimit) ? rawLimit : 20
15
20
  const domain = typeof req.query.domain === 'string' ? req.query.domain : 'all'
16
21
  res.set('Cache-Control', 'private, no-store')
17
- res.json({ meetings: store.list({ limit, domain }) })
22
+
23
+ if (cosOperationsMeetingsConfigured()) {
24
+ const meetings = listCosOperationsMeetings({ limit, domain })
25
+ res.json({
26
+ meetings,
27
+ source: 'cos_operations',
28
+ operationsDir: resolveCosOperationsDir(),
29
+ })
30
+ return
31
+ }
32
+
33
+ res.json({ meetings: store.list({ limit, domain }), source: 'standalone_recordings' })
18
34
  } catch (error) {
19
35
  sendMeetingStoreError(res, error)
20
36
  }
@@ -32,6 +48,17 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
32
48
  return
33
49
  }
34
50
  res.set('Cache-Control', 'private, no-store')
51
+
52
+ if (cosOperationsMeetingsConfigured()) {
53
+ const detail = getCosOperationsMeetingDetail(domain, month, filename)
54
+ if (detail) {
55
+ res.json(detail)
56
+ return
57
+ }
58
+ // Fall through to standalone store for G2-local recordings that share
59
+ // the same API shape when ops lookup misses.
60
+ }
61
+
35
62
  res.json(store.detail(domain, month, filename))
36
63
  } catch (error) {
37
64
  sendMeetingStoreError(res, error)
@@ -43,6 +70,19 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
43
70
  router.get('/meetings/:domain/:month/:filename', (req, res) => {
44
71
  try {
45
72
  res.set('Cache-Control', 'private, no-store')
73
+
74
+ if (cosOperationsMeetingsConfigured()) {
75
+ const detail = getCosOperationsMeetingDetail(
76
+ req.params.domain,
77
+ req.params.month,
78
+ req.params.filename,
79
+ )
80
+ if (detail) {
81
+ res.json(detail)
82
+ return
83
+ }
84
+ }
85
+
46
86
  res.json(store.detail(req.params.domain, req.params.month, req.params.filename))
47
87
  } catch (error) {
48
88
  sendMeetingStoreError(res, error)
@@ -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]
@@ -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
  }