@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.
@@ -0,0 +1,162 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import {
4
+ isCursorModel,
5
+ normalizeCursorExecutionMode,
6
+ normalizeModelPreference,
7
+ type CursorExecutionMode,
8
+ type CursorModelPreference,
9
+ } from '../../shared/model-preference.js'
10
+
11
+ export const CURSOR_ENGINE_SESSION_TTL_MS = 2 * 60 * 60_000
12
+
13
+ export interface CursorEngineSession {
14
+ key: string
15
+ cosSessionId: string
16
+ model: CursorModelPreference
17
+ executionMode: CursorExecutionMode
18
+ /** Cursor Agent CLI session_id from stream system/init — used with --resume. */
19
+ cursorSessionId: string
20
+ cwd: string
21
+ savedAt: number
22
+ lastUsedAt: number
23
+ expiresAt: string
24
+ }
25
+
26
+ interface CursorEngineSessionFile {
27
+ sessions: Record<string, CursorEngineSession>
28
+ savedAt: string
29
+ }
30
+
31
+ function sessionKey(
32
+ cosSessionId: string,
33
+ model: CursorModelPreference,
34
+ executionMode: CursorExecutionMode,
35
+ ): string {
36
+ return `${cosSessionId}:${model}:${executionMode}`
37
+ }
38
+
39
+ export function getCursorEngineSessionPath(): string {
40
+ return resolve(process.env.COS_CURSOR_ENGINE_SESSIONS_FILE || '/tmp/cos-cursor-engine-sessions.json')
41
+ }
42
+
43
+ function expiresAtFrom(now: number): string {
44
+ return new Date(now + CURSOR_ENGINE_SESSION_TTL_MS).toISOString()
45
+ }
46
+
47
+ function readStore(): CursorEngineSessionFile {
48
+ const path = getCursorEngineSessionPath()
49
+ if (!existsSync(path)) return { sessions: {}, savedAt: new Date().toISOString() }
50
+ try {
51
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<CursorEngineSessionFile>
52
+ const sessions: Record<string, CursorEngineSession> = {}
53
+ for (const raw of Object.values(parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {})) {
54
+ const model = normalizeModelPreference(raw?.model)
55
+ if (!model || !isCursorModel(model) || typeof raw?.cosSessionId !== 'string') continue
56
+ if (typeof raw?.cursorSessionId !== 'string' || !raw.cursorSessionId) continue
57
+ const executionMode = normalizeCursorExecutionMode(raw?.executionMode)
58
+ const key = sessionKey(raw.cosSessionId, model, executionMode)
59
+ const normalized = { ...raw, key, model, executionMode } as CursorEngineSession
60
+ const prior = sessions[key]
61
+ if (!prior || normalized.lastUsedAt > prior.lastUsedAt) sessions[key] = normalized
62
+ }
63
+ return {
64
+ sessions,
65
+ savedAt: typeof parsed.savedAt === 'string' ? parsed.savedAt : new Date().toISOString(),
66
+ }
67
+ } catch {
68
+ return { sessions: {}, savedAt: new Date().toISOString() }
69
+ }
70
+ }
71
+
72
+ function writeStore(store: CursorEngineSessionFile): void {
73
+ const path = getCursorEngineSessionPath()
74
+ mkdirSync(dirname(path), { recursive: true })
75
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
76
+ writeFileSync(tmp, JSON.stringify(store, null, 2))
77
+ renameSync(tmp, path)
78
+ }
79
+
80
+ function pruneExpired(sessions: Record<string, CursorEngineSession>, now = Date.now()): Record<string, CursorEngineSession> {
81
+ const next: Record<string, CursorEngineSession> = {}
82
+ for (const [key, session] of Object.entries(sessions)) {
83
+ const expiresMs = Date.parse(session.expiresAt)
84
+ if (Number.isFinite(expiresMs) && expiresMs > now) next[key] = session
85
+ }
86
+ return next
87
+ }
88
+
89
+ export function getCursorEngineSession(input: {
90
+ cosSessionId: string
91
+ model: CursorModelPreference
92
+ cwd: string
93
+ executionMode?: CursorExecutionMode
94
+ }): CursorEngineSession | null {
95
+ const store = readStore()
96
+ const now = Date.now()
97
+ const sessions = pruneExpired(store.sessions, now)
98
+ const executionMode = normalizeCursorExecutionMode(input.executionMode)
99
+ const existing = sessions[sessionKey(input.cosSessionId, input.model, executionMode)]
100
+ if (!existing) {
101
+ if (Object.keys(sessions).length !== Object.keys(store.sessions).length) {
102
+ writeStore({ sessions, savedAt: new Date().toISOString() })
103
+ }
104
+ return null
105
+ }
106
+ if (existing.cwd !== input.cwd) return null
107
+ return existing
108
+ }
109
+
110
+ export function saveCursorEngineSession(input: {
111
+ cosSessionId: string
112
+ model: CursorModelPreference
113
+ cursorSessionId: string
114
+ cwd: string
115
+ executionMode?: CursorExecutionMode
116
+ now?: number
117
+ }): CursorEngineSession {
118
+ const now = input.now ?? Date.now()
119
+ const store = readStore()
120
+ const sessions = pruneExpired(store.sessions, now)
121
+ const executionMode = normalizeCursorExecutionMode(input.executionMode)
122
+ const key = sessionKey(input.cosSessionId, input.model, executionMode)
123
+ const previous = sessions[key]
124
+ const session: CursorEngineSession = {
125
+ key,
126
+ cosSessionId: input.cosSessionId,
127
+ model: input.model,
128
+ executionMode,
129
+ cursorSessionId: input.cursorSessionId,
130
+ cwd: input.cwd,
131
+ savedAt: previous?.savedAt ?? now,
132
+ lastUsedAt: now,
133
+ expiresAt: expiresAtFrom(now),
134
+ }
135
+ sessions[key] = session
136
+ writeStore({ sessions, savedAt: new Date().toISOString() })
137
+ return session
138
+ }
139
+
140
+ export function clearCursorEngineSession(cosSessionId: string, model?: CursorModelPreference): number {
141
+ const store = readStore()
142
+ const sessions = pruneExpired(store.sessions)
143
+ let removed = 0
144
+ for (const key of Object.keys(sessions)) {
145
+ const session = sessions[key]
146
+ if (session.cosSessionId !== cosSessionId) continue
147
+ if (model && session.model !== model) continue
148
+ delete sessions[key]
149
+ removed += 1
150
+ }
151
+ if (removed > 0) writeStore({ sessions, savedAt: new Date().toISOString() })
152
+ return removed
153
+ }
154
+
155
+ export function listCursorEngineSessions(): CursorEngineSession[] {
156
+ const store = readStore()
157
+ const sessions = pruneExpired(store.sessions)
158
+ if (Object.keys(sessions).length !== Object.keys(store.sessions).length) {
159
+ writeStore({ sessions, savedAt: new Date().toISOString() })
160
+ }
161
+ return Object.values(sessions).sort((a, b) => b.lastUsedAt - a.lastUsedAt)
162
+ }
@@ -0,0 +1,288 @@
1
+ // Runtime Cursor Agent CLI model discovery.
2
+ //
3
+ // Stable app slots (cursor-grok / cursor-composer) map to concrete CLI model
4
+ // ids proven in Phase 0. `agent models` is text-only — parse + cache it.
5
+
6
+ import { spawn } from 'node:child_process'
7
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { dirname, resolve } from 'node:path'
10
+ import {
11
+ CURSOR_COMPOSER_MODEL,
12
+ CURSOR_GROK_MODEL,
13
+ setRuntimeCursorModelLabels,
14
+ type CursorModelPreference,
15
+ } from '../../shared/model-preference.js'
16
+
17
+ const DEFAULT_REFRESH_TTL_MS = 15 * 60_000
18
+ const DEFAULT_REFRESH_TIMEOUT_MS = 7_000
19
+
20
+ /**
21
+ * Stable slot → CLI model id mapping.
22
+ * Prefer high reasoning + fast variants so Grok/Composer are latency-comparable.
23
+ * Composer has no separate "high" id — only base vs `-fast`.
24
+ */
25
+ export const CURSOR_SLOT_MODEL_IDS = {
26
+ 'cursor-grok': 'cursor-grok-4.5-high-fast',
27
+ 'cursor-composer': 'composer-2.5-fast',
28
+ } as const satisfies Record<CursorModelPreference, string>
29
+
30
+ const SLOT_DISPLAY_FALLBACK = {
31
+ 'cursor-grok': 'Grok 4.5 Fast',
32
+ 'cursor-composer': 'Composer 2.5 Fast',
33
+ } as const satisfies Record<CursorModelPreference, string>
34
+
35
+ export type CursorCatalogSource = 'cli' | 'disk-cache' | 'unavailable'
36
+
37
+ export interface CursorCatalogModel {
38
+ id: string
39
+ displayName: string
40
+ }
41
+
42
+ export interface CursorModelOption extends CursorCatalogModel {
43
+ preference: CursorModelPreference
44
+ }
45
+
46
+ export interface CursorModelCatalog {
47
+ source: CursorCatalogSource
48
+ refreshedAt: string
49
+ options: CursorModelOption[]
50
+ agentBinary?: string
51
+ refreshError?: string
52
+ }
53
+
54
+ function catalogCachePath(): string {
55
+ return resolve(
56
+ process.env.COS_CURSOR_MODEL_CACHE_FILE?.trim()
57
+ || resolve(import.meta.dirname, '..', 'data', 'cursor-models-cache.json'),
58
+ )
59
+ }
60
+
61
+ /** Resolve the Cursor `agent` binary: PATH first, then ~/.local/bin/agent. */
62
+ export function resolveAgentBinary(): string | undefined {
63
+ const configured = process.env.COS_CURSOR_AGENT_BIN?.trim()
64
+ if (configured && existsSync(configured)) return configured
65
+
66
+ const pathEntries = (process.env.PATH ?? '').split(':').filter(Boolean)
67
+ for (const entry of pathEntries) {
68
+ const candidate = resolve(entry, 'agent')
69
+ if (existsSync(candidate)) return candidate
70
+ }
71
+
72
+ const homeLocal = resolve(homedir(), '.local', 'bin', 'agent')
73
+ if (existsSync(homeLocal)) return homeLocal
74
+ return undefined
75
+ }
76
+
77
+ /** Parse `agent models` text lines shaped like `id - Display Name`. */
78
+ export function parseAgentModelsText(text: string): CursorCatalogModel[] {
79
+ const models: CursorCatalogModel[] = []
80
+ const seen = new Set<string>()
81
+ for (const rawLine of text.split(/\r?\n/)) {
82
+ const line = rawLine.trim()
83
+ if (!line || line.startsWith('Available models') || line.startsWith('Tip:')) continue
84
+ const match = /^([a-z0-9][a-z0-9._\[\]-]*)\s+-\s+(.+)$/i.exec(line)
85
+ if (!match) continue
86
+ const id = match[1]
87
+ const displayName = match[2].trim()
88
+ if (!id || !displayName || seen.has(id)) continue
89
+ seen.add(id)
90
+ models.push({ id, displayName })
91
+ }
92
+ return models
93
+ }
94
+
95
+ export function buildCursorModelCatalog(
96
+ models: CursorCatalogModel[],
97
+ source: CursorCatalogSource,
98
+ refreshedAt = new Date().toISOString(),
99
+ agentBinary?: string,
100
+ refreshError?: string,
101
+ ): CursorModelCatalog {
102
+ const byId = new Map(models.map(model => [model.id, model]))
103
+ const slots: CursorModelPreference[] = [CURSOR_GROK_MODEL, CURSOR_COMPOSER_MODEL]
104
+ const options = slots.map((preference): CursorModelOption => {
105
+ const expectedId = CURSOR_SLOT_MODEL_IDS[preference]
106
+ const found = byId.get(expectedId)
107
+ return {
108
+ preference,
109
+ id: found?.id ?? '',
110
+ displayName: found?.displayName ?? SLOT_DISPLAY_FALLBACK[preference],
111
+ }
112
+ })
113
+
114
+ setRuntimeCursorModelLabels(options.filter(option => option.id).map(option => ({
115
+ preference: option.preference,
116
+ displayName: option.preference === CURSOR_GROK_MODEL ? 'Grok 4.5 Fast' : 'Composer 2.5 Fast',
117
+ })))
118
+
119
+ return {
120
+ source,
121
+ refreshedAt,
122
+ options,
123
+ ...(agentBinary ? { agentBinary } : {}),
124
+ ...(refreshError ? { refreshError } : {}),
125
+ }
126
+ }
127
+
128
+ function unavailableCatalog(refreshError?: string, agentBinary?: string): CursorModelCatalog {
129
+ return buildCursorModelCatalog([], 'unavailable', new Date().toISOString(), agentBinary, refreshError)
130
+ }
131
+
132
+ function readDiskCatalog(): CursorModelCatalog | null {
133
+ const path = catalogCachePath()
134
+ if (!existsSync(path)) return null
135
+ try {
136
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
137
+ models?: CursorCatalogModel[]
138
+ refreshedAt?: string
139
+ agentBinary?: string
140
+ }
141
+ const models = Array.isArray(parsed.models) ? parsed.models.filter(m => typeof m?.id === 'string' && m.id) : []
142
+ if (models.length === 0) return null
143
+ return buildCursorModelCatalog(
144
+ models,
145
+ 'disk-cache',
146
+ typeof parsed.refreshedAt === 'string' ? parsed.refreshedAt : new Date().toISOString(),
147
+ typeof parsed.agentBinary === 'string' ? parsed.agentBinary : undefined,
148
+ )
149
+ } catch {
150
+ return null
151
+ }
152
+ }
153
+
154
+ function writeDiskCatalog(models: CursorCatalogModel[], agentBinary?: string): void {
155
+ try {
156
+ const path = catalogCachePath()
157
+ mkdirSync(dirname(path), { recursive: true })
158
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
159
+ writeFileSync(tmp, JSON.stringify({
160
+ refreshedAt: new Date().toISOString(),
161
+ agentBinary,
162
+ models,
163
+ }, null, 2))
164
+ renameSync(tmp, path)
165
+ } catch (err) {
166
+ console.warn('[cursor-model-catalog] cache write skipped:', err)
167
+ }
168
+ }
169
+
170
+ function refreshTimeoutMs(): number {
171
+ const raw = Number(process.env.COS_CURSOR_MODEL_REFRESH_TIMEOUT_MS ?? DEFAULT_REFRESH_TIMEOUT_MS)
172
+ return Number.isFinite(raw) && raw >= 1_000 ? Math.floor(raw) : DEFAULT_REFRESH_TIMEOUT_MS
173
+ }
174
+
175
+ function refreshTtlMs(): number {
176
+ const raw = Number(process.env.COS_CURSOR_MODEL_REFRESH_TTL_MS ?? DEFAULT_REFRESH_TTL_MS)
177
+ return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : DEFAULT_REFRESH_TTL_MS
178
+ }
179
+
180
+ async function fetchCliModels(agentBinary: string): Promise<CursorCatalogModel[]> {
181
+ return new Promise((resolveModels, reject) => {
182
+ const env = { ...process.env }
183
+ delete env.CLAUDECODE
184
+ const child = spawn(agentBinary, ['models'], {
185
+ stdio: ['ignore', 'pipe', 'pipe'],
186
+ env,
187
+ })
188
+ let settled = false
189
+ let stdout = ''
190
+ let stderr = ''
191
+
192
+ const finish = (err?: Error, models?: CursorCatalogModel[]) => {
193
+ if (settled) return
194
+ settled = true
195
+ clearTimeout(timer)
196
+ try { child.kill() } catch { /* ignore */ }
197
+ if (err) reject(err)
198
+ else resolveModels(models ?? [])
199
+ }
200
+
201
+ const timer = setTimeout(() => {
202
+ finish(new Error('Cursor model discovery timed out'))
203
+ }, refreshTimeoutMs())
204
+
205
+ child.on('error', err => finish(err))
206
+ child.stdout.on('data', chunk => { stdout += String(chunk) })
207
+ child.stderr.on('data', chunk => { stderr = (stderr + String(chunk)).slice(-1_000) })
208
+ child.on('close', code => {
209
+ if (settled) return
210
+ const models = parseAgentModelsText(stdout)
211
+ if (models.length === 0) {
212
+ finish(new Error(`Cursor model discovery exited ${code}: ${stderr.slice(0, 160) || 'no models'}`))
213
+ return
214
+ }
215
+ finish(undefined, models)
216
+ })
217
+ })
218
+ }
219
+
220
+ let catalogSnapshot = readDiskCatalog() ?? unavailableCatalog()
221
+ let refreshPromise: Promise<CursorModelCatalog> | null = null
222
+
223
+ export function getCursorModelCatalogSnapshot(): CursorModelCatalog {
224
+ return catalogSnapshot
225
+ }
226
+
227
+ export function isCursorProviderReady(): boolean {
228
+ const binary = catalogSnapshot.agentBinary || resolveAgentBinary()
229
+ if (!binary) return false
230
+ return catalogSnapshot.options.every(option => !!option.id)
231
+ }
232
+
233
+ export function resolveCursorModelOption(preference: CursorModelPreference): CursorModelOption | undefined {
234
+ return catalogSnapshot.options.find(option => option.preference === preference)
235
+ }
236
+
237
+ /**
238
+ * Reverse of CURSOR_SLOT_MODEL_IDS: a concrete CLI model id → its stable app
239
+ * slot. Checks the live catalog too, so a slot whose concrete id moved (e.g.
240
+ * a new Composer build) still resolves without a code change.
241
+ */
242
+ export function resolveCursorPreferenceForModelId(modelId: string): CursorModelPreference | undefined {
243
+ const normalized = modelId.trim().toLowerCase()
244
+ if (!normalized) return undefined
245
+ for (const [preference, id] of Object.entries(CURSOR_SLOT_MODEL_IDS) as [CursorModelPreference, string][]) {
246
+ if (id.toLowerCase() === normalized) return preference
247
+ }
248
+ return catalogSnapshot.options.find(option => option.id && option.id.toLowerCase() === normalized)?.preference
249
+ }
250
+
251
+ export async function getCursorModelCatalog(forceRefresh = false): Promise<CursorModelCatalog> {
252
+ const ageMs = Date.now() - Date.parse(catalogSnapshot.refreshedAt)
253
+ if (!forceRefresh && catalogSnapshot.source === 'cli' && ageMs < refreshTtlMs() && isCursorProviderReady()) {
254
+ return catalogSnapshot
255
+ }
256
+ if (refreshPromise) return refreshPromise
257
+
258
+ refreshPromise = (async () => {
259
+ const agentBinary = resolveAgentBinary()
260
+ if (!agentBinary) {
261
+ const disk = readDiskCatalog()
262
+ catalogSnapshot = disk
263
+ ? { ...disk, refreshError: 'Cursor agent binary not found on PATH or ~/.local/bin/agent' }
264
+ : unavailableCatalog('Cursor agent binary not found on PATH or ~/.local/bin/agent')
265
+ return catalogSnapshot
266
+ }
267
+
268
+ try {
269
+ const models = await fetchCliModels(agentBinary)
270
+ catalogSnapshot = buildCursorModelCatalog(models, 'cli', new Date().toISOString(), agentBinary)
271
+ writeDiskCatalog(models, agentBinary)
272
+ } catch (err) {
273
+ const refreshError = err instanceof Error ? err.message : 'Live Cursor model discovery unavailable.'
274
+ if (catalogSnapshot.source === 'cli' && catalogSnapshot.options.some(option => option.id)) {
275
+ catalogSnapshot = { ...catalogSnapshot, agentBinary, refreshError }
276
+ return catalogSnapshot
277
+ }
278
+ const disk = readDiskCatalog()
279
+ catalogSnapshot = disk
280
+ ? { ...disk, agentBinary, refreshError }
281
+ : unavailableCatalog(refreshError, agentBinary)
282
+ }
283
+ return catalogSnapshot
284
+ })().finally(() => {
285
+ refreshPromise = null
286
+ })
287
+ return refreshPromise
288
+ }