@gotcos/glasses-server 6.27.5 → 6.27.7

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,346 @@
1
+ /**
2
+ * Memory and Threads lookup for COS Control.
3
+ *
4
+ * Keyword: local title/body scan. No model.
5
+ * Memories meaning: existing `cos_memory` index via bot_memory.py — one query
6
+ * embedding, no LLM, never the meeting Qdrant collection.
7
+ * Threads have no embedding index — keyword still works, semanticAvailable is false.
8
+ */
9
+
10
+ import { execFile } from 'node:child_process'
11
+ import { existsSync, readFileSync } from 'node:fs'
12
+ import { resolve } from 'node:path'
13
+ import { COS_SCRIPTS_DIR, PYTHON_BIN } from './python-bridge.js'
14
+ import {
15
+ hasFileMemory,
16
+ hasFileThreads,
17
+ readFileMemories,
18
+ readFileThreads,
19
+ resolveContextFilesRoot,
20
+ } from './context-files.js'
21
+ import { scoreKeywordMatch, tokenizeMeetingQuery } from './meeting-library-search.js'
22
+
23
+ const HEAD_BYTES = 8_000
24
+ const MAX_SCAN_FILES = 2_000
25
+ const SEMANTIC_TIMEOUT_MS = 15_000
26
+ export const MEMORY_SEMANTIC_SCRIPT = 'bot_memory.py'
27
+
28
+ export interface ContextSearchHit {
29
+ id: string
30
+ title: string
31
+ snippet: string
32
+ kind: 'memory' | 'thread'
33
+ type?: string
34
+ created_at?: string
35
+ name?: string
36
+ domain?: string
37
+ meeting_count?: number
38
+ is_resolved?: boolean
39
+ topics?: string[]
40
+ keywordScore: number
41
+ semanticScore: number
42
+ match: 'keyword' | 'semantic' | 'both'
43
+ }
44
+
45
+ export interface ContextSearchResult {
46
+ hits: ContextSearchHit[]
47
+ keywordCount: number
48
+ semanticCount: number
49
+ semanticAvailable: boolean
50
+ semanticReason?: string
51
+ }
52
+
53
+ export function memorySemanticArgs(query: string, limit: number, script = MEMORY_SEMANTIC_SCRIPT): string[] {
54
+ return [script, 'search', '--query', query, '--limit', String(limit)]
55
+ }
56
+
57
+ export function memorySemanticAvailable(
58
+ scriptsDir: string | null = COS_SCRIPTS_DIR,
59
+ pythonBin: string | null = PYTHON_BIN,
60
+ ): { ok: boolean; reason?: string } {
61
+ if (!scriptsDir || !pythonBin) return { ok: false, reason: 'no_memory_embeddings' }
62
+ if (!existsSync(pythonBin)) return { ok: false, reason: 'no_memory_embeddings' }
63
+ const script = resolve(scriptsDir, MEMORY_SEMANTIC_SCRIPT)
64
+ if (!existsSync(script)) return { ok: false, reason: 'no_memory_embeddings' }
65
+ return { ok: true }
66
+ }
67
+
68
+ export function threadSemanticAvailable(): { ok: false; reason: 'no_thread_embeddings' } {
69
+ return { ok: false, reason: 'no_thread_embeddings' }
70
+ }
71
+
72
+ function head(text: string): string {
73
+ return text.slice(0, HEAD_BYTES)
74
+ }
75
+
76
+ export function keywordHitsFromRecords(
77
+ tokens: string[],
78
+ records: Array<{ id: string; title: string; haystack: string }>,
79
+ kind: 'memory' | 'thread',
80
+ ): ContextSearchHit[] {
81
+ const hits: ContextSearchHit[] = []
82
+ for (const record of records) {
83
+ if (!record.id) continue
84
+ const scored = scoreKeywordMatch(tokens, record.title, record.haystack)
85
+ if (scored.score <= 0) continue
86
+ hits.push({
87
+ id: record.id,
88
+ title: record.title,
89
+ snippet: scored.snippet,
90
+ kind,
91
+ keywordScore: scored.score,
92
+ semanticScore: 0,
93
+ match: 'keyword',
94
+ })
95
+ }
96
+ return hits.sort((a, b) => b.keywordScore - a.keywordScore)
97
+ }
98
+
99
+ function memoryRecordsForKeyword(): Array<{ id: string; title: string; haystack: string; extra: Partial<ContextSearchHit> }> {
100
+ const root = resolveContextFilesRoot()
101
+ if (!root || !hasFileMemory(root)) return []
102
+ return readFileMemories(root, MAX_SCAN_FILES).map(row => ({
103
+ id: row.id,
104
+ title: row.summary || row.id,
105
+ haystack: head(`${row.id}\n${row.summary}\n${row.type}\n${row.content}`),
106
+ extra: { type: row.type, created_at: row.created_at, domain: row.domain },
107
+ }))
108
+ }
109
+
110
+ function parseThreadBag(raw: unknown): Array<Record<string, unknown>> {
111
+ if (Array.isArray(raw)) return raw.filter(row => row && typeof row === 'object' && !Array.isArray(row)) as Array<Record<string, unknown>>
112
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return []
113
+ const threads = (raw as { threads?: unknown }).threads
114
+ return Array.isArray(threads)
115
+ ? threads.filter(row => row && typeof row === 'object' && !Array.isArray(row)) as Array<Record<string, unknown>>
116
+ : []
117
+ }
118
+
119
+ function threadHaystack(row: Record<string, unknown>): string {
120
+ const topics = Array.isArray(row.topics) ? row.topics.join(' ') : String(row.topics || '')
121
+ const meetings = Array.isArray(row.meetings)
122
+ ? row.meetings.map(item => {
123
+ if (typeof item === 'string') return item
124
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
125
+ const meeting = item as Record<string, unknown>
126
+ return String(meeting.name || meeting.title || '')
127
+ }
128
+ return ''
129
+ }).join(' ')
130
+ : ''
131
+ const updates = Array.isArray(row.manual_updates)
132
+ ? row.manual_updates.map(item => {
133
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return ''
134
+ return String((item as Record<string, unknown>).content || '')
135
+ }).join('\n')
136
+ : String(row.initial_note || '')
137
+ return head(`${row.id}\n${row.name}\n${topics}\n${meetings}\n${updates}`)
138
+ }
139
+
140
+ export function keywordHitsFromThreadCache(tokens: string[], scriptsDir: string | null = COS_SCRIPTS_DIR): ContextSearchHit[] {
141
+ if (!scriptsDir) return []
142
+ const files = ['.threads_cache.json', '.manual_threads.json']
143
+ const grouped = new Map<string, ContextSearchHit>()
144
+ for (const name of files) {
145
+ const path = resolve(scriptsDir, name)
146
+ if (!existsSync(path)) continue
147
+ let parsed: unknown
148
+ try {
149
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
150
+ } catch {
151
+ continue
152
+ }
153
+ for (const row of parseThreadBag(parsed)) {
154
+ const id = String(row.id || '').trim()
155
+ const title = String(row.name || id).trim()
156
+ if (!id) continue
157
+ const scored = scoreKeywordMatch(tokens, title, threadHaystack(row))
158
+ if (scored.score <= 0) continue
159
+ const hit: ContextSearchHit = {
160
+ id,
161
+ title,
162
+ snippet: scored.snippet,
163
+ kind: 'thread',
164
+ domain: typeof row.domain === 'string' ? row.domain : undefined,
165
+ meeting_count: typeof row.meeting_count === 'number' ? row.meeting_count : undefined,
166
+ is_resolved: row.is_resolved === true || row.status === 'resolved',
167
+ topics: Array.isArray(row.topics) ? row.topics.map(String) : undefined,
168
+ keywordScore: scored.score,
169
+ semanticScore: 0,
170
+ match: 'keyword',
171
+ }
172
+ const existing = grouped.get(id)
173
+ if (!existing || hit.keywordScore > existing.keywordScore) grouped.set(id, hit)
174
+ }
175
+ }
176
+ return [...grouped.values()]
177
+ }
178
+
179
+ export function keywordSearchMemories(query: string): ContextSearchHit[] {
180
+ const tokens = tokenizeMeetingQuery(query)
181
+ if (tokens.length === 0) return []
182
+ const records = memoryRecordsForKeyword()
183
+ return keywordHitsFromRecords(tokens, records, 'memory').map(hit => {
184
+ const extra = records.find(row => row.id === hit.id)?.extra ?? {}
185
+ return { ...hit, ...extra, kind: 'memory' as const }
186
+ })
187
+ }
188
+
189
+ export function keywordSearchThreads(query: string): ContextSearchHit[] {
190
+ const tokens = tokenizeMeetingQuery(query)
191
+ if (tokens.length === 0) return []
192
+ const grouped = new Map<string, ContextSearchHit>()
193
+ const push = (hit: ContextSearchHit) => {
194
+ const existing = grouped.get(hit.id)
195
+ if (!existing || hit.keywordScore > existing.keywordScore) grouped.set(hit.id, hit)
196
+ }
197
+ const root = resolveContextFilesRoot()
198
+ if (root && hasFileThreads(root)) {
199
+ for (const row of readFileThreads(root, MAX_SCAN_FILES)) {
200
+ const title = row.name || row.id
201
+ const haystack = head(`${row.id}\n${title}\n${row.topics.join(' ')}\n${row.manual_updates.map(item => item.content).join('\n')}`)
202
+ const scored = scoreKeywordMatch(tokens, title, haystack)
203
+ if (scored.score <= 0) continue
204
+ push({
205
+ id: row.id,
206
+ title,
207
+ snippet: scored.snippet,
208
+ kind: 'thread',
209
+ domain: row.domain,
210
+ meeting_count: row.meeting_count,
211
+ is_resolved: row.is_resolved,
212
+ topics: row.topics,
213
+ keywordScore: scored.score,
214
+ semanticScore: 0,
215
+ match: 'keyword',
216
+ })
217
+ }
218
+ }
219
+ for (const hit of keywordHitsFromThreadCache(tokens)) push(hit)
220
+ return [...grouped.values()].sort((a, b) => b.keywordScore - a.keywordScore)
221
+ }
222
+
223
+ interface MemorySemanticRaw {
224
+ id?: string
225
+ type?: string
226
+ content?: string
227
+ summary?: string
228
+ created_at?: string
229
+ domain?: string
230
+ score?: number
231
+ }
232
+
233
+ function semanticHitFromMemory(raw: MemorySemanticRaw): ContextSearchHit | null {
234
+ const id = String(raw.id || '').trim()
235
+ if (!id) return null
236
+ const title = String(raw.summary || raw.content || id).trim()
237
+ const semanticScore = typeof raw.score === 'number' && Number.isFinite(raw.score)
238
+ ? Math.max(0, Math.min(1, raw.score))
239
+ : 0
240
+ return {
241
+ id,
242
+ title,
243
+ snippet: String(raw.content || raw.summary || '').replace(/\s+/g, ' ').trim().slice(0, 180),
244
+ kind: 'memory',
245
+ type: raw.type,
246
+ created_at: raw.created_at,
247
+ domain: raw.domain,
248
+ keywordScore: 0,
249
+ semanticScore,
250
+ match: 'semantic',
251
+ }
252
+ }
253
+
254
+ export function semanticSearchMemories(query: string, limit = 20): Promise<{
255
+ hits: ContextSearchHit[]
256
+ reason?: string
257
+ }> {
258
+ const available = memorySemanticAvailable()
259
+ if (!available.ok) return Promise.resolve({ hits: [], reason: available.reason })
260
+ const script = resolve(COS_SCRIPTS_DIR!, MEMORY_SEMANTIC_SCRIPT)
261
+ return new Promise(resolvePromise => {
262
+ execFile(
263
+ PYTHON_BIN!,
264
+ memorySemanticArgs(query, limit, script),
265
+ { cwd: COS_SCRIPTS_DIR!, timeout: SEMANTIC_TIMEOUT_MS, maxBuffer: 2 * 1024 * 1024 },
266
+ (error, stdout) => {
267
+ if (error) {
268
+ resolvePromise({ hits: [], reason: 'no_memory_embeddings' })
269
+ return
270
+ }
271
+ try {
272
+ const parsed = JSON.parse(String(stdout)) as { results?: MemorySemanticRaw[] } | MemorySemanticRaw[]
273
+ const rows = Array.isArray(parsed) ? parsed : (Array.isArray(parsed.results) ? parsed.results : [])
274
+ resolvePromise({
275
+ hits: rows.flatMap(row => {
276
+ const hit = semanticHitFromMemory(row)
277
+ return hit ? [hit] : []
278
+ }),
279
+ })
280
+ } catch {
281
+ resolvePromise({ hits: [], reason: 'memory_parse_error' })
282
+ }
283
+ },
284
+ )
285
+ })
286
+ }
287
+
288
+ export function mergeContextSearchHits(
289
+ keywordHits: ContextSearchHit[],
290
+ semanticHits: ContextSearchHit[],
291
+ limit: number,
292
+ ): ContextSearchHit[] {
293
+ const merged = new Map<string, ContextSearchHit>()
294
+ for (const hit of keywordHits) merged.set(hit.id, { ...hit })
295
+ for (const hit of semanticHits) {
296
+ const existing = merged.get(hit.id)
297
+ if (!existing) {
298
+ merged.set(hit.id, hit)
299
+ continue
300
+ }
301
+ merged.set(hit.id, {
302
+ ...existing,
303
+ snippet: existing.snippet || hit.snippet,
304
+ semanticScore: Math.max(existing.semanticScore, hit.semanticScore),
305
+ match: existing.keywordScore > 0 && hit.semanticScore > 0 ? 'both' : existing.match,
306
+ type: existing.type || hit.type,
307
+ created_at: existing.created_at || hit.created_at,
308
+ domain: existing.domain || hit.domain,
309
+ })
310
+ }
311
+ return [...merged.values()]
312
+ .sort((a, b) => {
313
+ const bothDelta = Number(b.match === 'both') - Number(a.match === 'both')
314
+ if (bothDelta) return bothDelta
315
+ return Math.max(b.keywordScore, b.semanticScore) - Math.max(a.keywordScore, a.semanticScore)
316
+ })
317
+ .slice(0, Math.max(1, Math.min(limit, 50)))
318
+ }
319
+
320
+ export async function searchMemories(options: { query: string; limit?: number }): Promise<ContextSearchResult> {
321
+ const query = options.query.trim()
322
+ const limit = Math.max(1, Math.min(options.limit ?? 20, 50))
323
+ const keywordHits = keywordSearchMemories(query)
324
+ const semantic = await semanticSearchMemories(query, limit)
325
+ return {
326
+ hits: mergeContextSearchHits(keywordHits, semantic.hits, limit),
327
+ keywordCount: keywordHits.length,
328
+ semanticCount: semantic.hits.length,
329
+ semanticAvailable: !semantic.reason,
330
+ ...(semantic.reason ? { semanticReason: semantic.reason } : {}),
331
+ }
332
+ }
333
+
334
+ export async function searchThreads(options: { query: string; limit?: number }): Promise<ContextSearchResult> {
335
+ const query = options.query.trim()
336
+ const limit = Math.max(1, Math.min(options.limit ?? 20, 50))
337
+ const keywordHits = keywordSearchThreads(query)
338
+ const unavailable = threadSemanticAvailable()
339
+ return {
340
+ hits: mergeContextSearchHits(keywordHits, [], limit),
341
+ keywordCount: keywordHits.length,
342
+ semanticCount: 0,
343
+ semanticAvailable: false,
344
+ semanticReason: unavailable.reason,
345
+ }
346
+ }
@@ -17,7 +17,7 @@ import { createHash } from 'node:crypto'
17
17
  import { basename, dirname, join, resolve } from 'node:path'
18
18
  import { discoveredDomains, domainAbbreviation as deriveAbbr, isSafeDomainName as safeName } from './domains.js'
19
19
  import type { MeetingDetail, MeetingMeta } from './meeting-store.js'
20
- import { MEETING_SOURCE_MAX_BYTES } from './meeting-store.js'
20
+ import { MEETING_SOURCE_MAX_BYTES, meetingDayCountsFromNames, meetingListLimit } from './meeting-store.js'
21
21
 
22
22
  /**
23
23
  * The four domains of ONE user's COS. Retained as the documented example layout
@@ -151,7 +151,7 @@ const SIDECAR_HEAD_BYTES = 4096
151
151
  * them whole would make listing cost scale with total transcript size — and this
152
152
  * lister already reads every markdown file it finds.
153
153
  */
154
- function sidecarSessionId(monthDir: string, meetingFilename: string): string | undefined {
154
+ export function sidecarSessionId(monthDir: string, meetingFilename: string): string | undefined {
155
155
  const sidecarName = meetingFilename.replace(/\.md$/, '.g2-chunks.json')
156
156
  if (sidecarName === meetingFilename) return undefined
157
157
  const path = join(monthDir, sidecarName)
@@ -520,11 +520,14 @@ export function findDirectLibraryMeetingBySessionId(sessionId: string): MeetingL
520
520
  export function listCosOperationsMeetings(options: {
521
521
  limit?: number
522
522
  domain?: string
523
+ month?: string
524
+ day?: string
523
525
  } = {}): CosOperationsMeetingMeta[] {
524
526
  const operationsDir = resolveCosOperationsDir()
525
527
  if (!operationsDir) return []
526
528
 
527
- const limit = Math.min(Math.max(options.limit ?? 20, 1), 50)
529
+ const scoped = Boolean(options.month || options.day)
530
+ const limit = meetingListLimit(options.limit, scoped)
528
531
  const domainFilter = options.domain || 'all'
529
532
  const discovered = discoverMeetingDomains(operationsDir)
530
533
  const domains = domainFilter === 'all'
@@ -537,11 +540,12 @@ export function listCosOperationsMeetings(options: {
537
540
  const meetingsBase = join(operationsDir, domain, 'meetings')
538
541
  try {
539
542
  const months = readdirSync(meetingsBase)
540
- .filter(d => /^\d{4}-\d{2}$/.test(d))
543
+ .filter(d => MONTH_PATTERN.test(d))
541
544
  .sort()
542
545
  .reverse()
543
546
 
544
547
  for (const month of months) {
548
+ if (options.month && month !== options.month) continue
545
549
  const monthDir = join(meetingsBase, month)
546
550
  try {
547
551
  const files = readdirSync(monthDir)
@@ -561,6 +565,7 @@ export function listCosOperationsMeetings(options: {
561
565
  meta.canonicalRecord = `operations/${domain}/meetings/${month}/${file}`
562
566
  const sessionId = sidecarSessionId(monthDir, file)
563
567
  if (sessionId) meta.sessionId = sessionId
568
+ if (options.day && meta.date !== options.day) continue
564
569
  allMeetings.push(meta)
565
570
  } catch { /* skip unreadable files */ }
566
571
  }
@@ -573,13 +578,57 @@ export function listCosOperationsMeetings(options: {
573
578
  return allMeetings.slice(0, limit)
574
579
  }
575
580
 
576
- export function listDirectLibraryMeetings(options: { limit?: number } = {}): CosOperationsMeetingMeta[] {
581
+ /** Folder names only. Used by the Control calendar pager. */
582
+ export function listCosOperationsMeetingMonths(domainFilter = 'all'): string[] {
583
+ const operationsDir = resolveCosOperationsDir()
584
+ if (!operationsDir) return []
585
+ const discovered = discoverMeetingDomains(operationsDir)
586
+ const domains = domainFilter === 'all'
587
+ ? discovered
588
+ : discovered.includes(domainFilter) ? [domainFilter] : []
589
+ const months = new Set<string>()
590
+ for (const domain of domains) {
591
+ const meetingsBase = join(operationsDir, domain, 'meetings')
592
+ try {
593
+ for (const month of readdirSync(meetingsBase).filter(name => MONTH_PATTERN.test(name))) {
594
+ months.add(month)
595
+ }
596
+ } catch { /* domain has no meetings dir */ }
597
+ }
598
+ return [...months].sort().reverse()
599
+ }
600
+
601
+ export function listCosOperationsMeetingDays(month: string, domainFilter = 'all'): Array<{ date: string; count: number }> {
602
+ if (!MONTH_PATTERN.test(month)) return []
603
+ const operationsDir = resolveCosOperationsDir()
604
+ if (!operationsDir) return []
605
+ const discovered = discoverMeetingDomains(operationsDir)
606
+ const domains = domainFilter === 'all'
607
+ ? discovered
608
+ : discovered.includes(domainFilter) ? [domainFilter] : []
609
+ const names: string[] = []
610
+ for (const domain of domains) {
611
+ const monthDir = join(operationsDir, domain, 'meetings', month)
612
+ try {
613
+ names.push(...readdirSync(monthDir).filter(name => name.endsWith('.md')))
614
+ } catch { /* missing month */ }
615
+ }
616
+ return meetingDayCountsFromNames(names)
617
+ }
618
+
619
+ export function listDirectLibraryMeetings(options: {
620
+ limit?: number
621
+ month?: string
622
+ day?: string
623
+ } = {}): CosOperationsMeetingMeta[] {
577
624
  const inspection = resolveMeetingLibrary()
578
625
  if (inspection.layout !== 'direct' || !inspection.root) return []
579
- const limit = Math.min(Math.max(options.limit ?? 20, 1), 50)
626
+ const scoped = Boolean(options.month || options.day)
627
+ const limit = meetingListLimit(options.limit, scoped)
580
628
  const all: CosOperationsMeetingMeta[] = []
581
629
  let candidates = 0
582
630
  for (const month of readdirSync(inspection.root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()) {
631
+ if (options.month && month !== options.month) continue
583
632
  const monthDir = safeChildDirectory(inspection.root, join(inspection.root, month))
584
633
  if (!monthDir) continue
585
634
  for (const file of readdirSync(monthDir).filter(name => name.endsWith('.md')).sort().reverse()) {
@@ -594,6 +643,7 @@ export function listDirectLibraryMeetings(options: { limit?: number } = {}): Cos
594
643
  meta.mutable = false
595
644
  const sessionId = sidecarSessionId(monthDir, file)
596
645
  if (sessionId) meta.sessionId = sessionId
646
+ if (options.day && meta.date !== options.day) continue
597
647
  all.push(meta)
598
648
  }
599
649
  if (candidates > MAX_LIST_CANDIDATES) break
@@ -602,6 +652,25 @@ export function listDirectLibraryMeetings(options: { limit?: number } = {}): Cos
602
652
  return all.slice(0, limit)
603
653
  }
604
654
 
655
+ export function listDirectLibraryMeetingMonths(): string[] {
656
+ const inspection = resolveMeetingLibrary()
657
+ const root = inspection.root
658
+ if (inspection.layout !== 'direct' || !root) return []
659
+ return readdirSync(root)
660
+ .filter(name => MONTH_PATTERN.test(name) && safeChildDirectory(root, join(root, name)))
661
+ .sort()
662
+ .reverse()
663
+ }
664
+
665
+ export function listDirectLibraryMeetingDays(month: string): Array<{ date: string; count: number }> {
666
+ if (!MONTH_PATTERN.test(month)) return []
667
+ const inspection = resolveMeetingLibrary()
668
+ if (inspection.layout !== 'direct' || !inspection.root) return []
669
+ const monthDir = safeChildDirectory(inspection.root, join(inspection.root, month))
670
+ if (!monthDir) return []
671
+ return meetingDayCountsFromNames(readdirSync(monthDir).filter(name => name.endsWith('.md')))
672
+ }
673
+
605
674
  export function getCosOperationsMeetingDetail(
606
675
  domain: string,
607
676
  month: string,
@@ -19,8 +19,10 @@ const DEFAULT_REFRESH_TIMEOUT_MS = 7_000
19
19
 
20
20
  /**
21
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`.
22
+ * Composer stays pinned (no versioned high-fast family). Grok high-fast is
23
+ * chosen at catalog build: newest `cursor-grok-<ver>-high-fast` in `agent
24
+ * models`. This grok id is only the fallback when the live list has none.
25
+ * `xhigh-fast` is a different SKU and is never selected here.
24
26
  */
25
27
  export const CURSOR_SLOT_MODEL_IDS = {
26
28
  'cursor-grok': 'cursor-grok-4.5-high-fast',
@@ -28,10 +30,43 @@ export const CURSOR_SLOT_MODEL_IDS = {
28
30
  } as const satisfies Record<CursorModelPreference, string>
29
31
 
30
32
  const SLOT_DISPLAY_FALLBACK = {
31
- 'cursor-grok': 'Grok 4.5 Fast',
33
+ 'cursor-grok': 'Grok Fast',
32
34
  'cursor-composer': 'Composer 2.5 Fast',
33
35
  } as const satisfies Record<CursorModelPreference, string>
34
36
 
37
+ const GROK_HIGH_FAST_RE = /^cursor-grok-(\d+(?:\.\d+)*)-high-fast$/i
38
+
39
+ export function parseCursorGrokHighFastVersion(id: string): number[] | null {
40
+ const match = GROK_HIGH_FAST_RE.exec(id.trim())
41
+ if (!match) return null
42
+ return match[1].split('.').map(part => Number(part))
43
+ }
44
+
45
+ export function compareVersionTuples(a: number[], b: number[]): number {
46
+ const length = Math.max(a.length, b.length)
47
+ for (let index = 0; index < length; index++) {
48
+ const left = a[index] ?? 0
49
+ const right = b[index] ?? 0
50
+ if (left !== right) return left - right
51
+ }
52
+ return 0
53
+ }
54
+
55
+ /** Newest `cursor-grok-*-high-fast`. Ignores low/medium/xhigh and non-fast. */
56
+ export function selectNewestCursorGrokHighFast(
57
+ models: CursorCatalogModel[],
58
+ ): CursorCatalogModel | undefined {
59
+ let best: { model: CursorCatalogModel; version: number[] } | undefined
60
+ for (const model of models) {
61
+ const version = parseCursorGrokHighFastVersion(model.id)
62
+ if (!version) continue
63
+ if (!best || compareVersionTuples(version, best.version) > 0) {
64
+ best = { model, version }
65
+ }
66
+ }
67
+ return best?.model
68
+ }
69
+
35
70
  export type CursorCatalogSource = 'cli' | 'disk-cache' | 'unavailable'
36
71
 
37
72
  export interface CursorCatalogModel {
@@ -100,20 +135,25 @@ export function buildCursorModelCatalog(
100
135
  refreshError?: string,
101
136
  ): CursorModelCatalog {
102
137
  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
- })
138
+ const grok = selectNewestCursorGrokHighFast(models)
139
+ ?? byId.get(CURSOR_SLOT_MODEL_IDS[CURSOR_GROK_MODEL])
140
+ const composer = byId.get(CURSOR_SLOT_MODEL_IDS[CURSOR_COMPOSER_MODEL])
141
+ const options: CursorModelOption[] = [
142
+ {
143
+ preference: CURSOR_GROK_MODEL,
144
+ id: grok?.id ?? '',
145
+ displayName: grok?.displayName ?? SLOT_DISPLAY_FALLBACK[CURSOR_GROK_MODEL],
146
+ },
147
+ {
148
+ preference: CURSOR_COMPOSER_MODEL,
149
+ id: composer?.id ?? '',
150
+ displayName: composer?.displayName ?? SLOT_DISPLAY_FALLBACK[CURSOR_COMPOSER_MODEL],
151
+ },
152
+ ]
113
153
 
114
154
  setRuntimeCursorModelLabels(options.filter(option => option.id).map(option => ({
115
155
  preference: option.preference,
116
- displayName: option.preference === CURSOR_GROK_MODEL ? 'Grok 4.5 Fast' : 'Composer 2.5 Fast',
156
+ displayName: option.displayName,
117
157
  })))
118
158
 
119
159
  return {
@@ -242,6 +282,7 @@ export function resolveCursorModelOption(preference: CursorModelPreference): Cur
242
282
  export function resolveCursorPreferenceForModelId(modelId: string): CursorModelPreference | undefined {
243
283
  const normalized = modelId.trim().toLowerCase()
244
284
  if (!normalized) return undefined
285
+ if (parseCursorGrokHighFastVersion(normalized)) return CURSOR_GROK_MODEL
245
286
  for (const [preference, id] of Object.entries(CURSOR_SLOT_MODEL_IDS) as [CursorModelPreference, string][]) {
246
287
  if (id.toLowerCase() === normalized) return preference
247
288
  }