@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.
- package/.env.example +7 -0
- package/CHANGELOG.md +19 -0
- package/README.md +29 -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/cos-operations-meetings.ts +365 -0
- 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/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/models/silero_vad.onnx +0 -0
- package/server/routes/cli-debug.ts +8 -0
- package/server/routes/health.ts +56 -4
- package/server/routes/meetings.ts +43 -3
- package/server/routes/openai-compat.ts +28 -4
- package/shared/model-preference.ts +50 -2
|
@@ -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
|
+
}
|