@gotcos/glasses-server 6.27.6 → 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.
- package/CHANGELOG.md +62 -0
- package/README.md +4 -4
- package/bin/cli.cjs +4 -4
- package/package.json +1 -1
- package/server/index.ts +3 -0
- package/server/lib/agent-session-search.ts +447 -0
- package/server/lib/agent-session-store.ts +1065 -0
- package/server/lib/context-library-search.ts +346 -0
- package/server/lib/cos-operations-meetings.ts +75 -6
- package/server/lib/cursor-model-catalog.ts +55 -14
- package/server/lib/meeting-library-search.ts +439 -0
- package/server/lib/meeting-store.ts +56 -2
- package/server/lib/message-era-reset.ts +112 -0
- package/server/lib/video-upload-v2.ts +59 -0
- package/server/routes/agent-sessions.ts +201 -0
- package/server/routes/media.ts +10 -0
- package/server/routes/meetings.ts +125 -10
- package/server/routes/memory.ts +20 -0
- package/server/routes/message-ref.ts +16 -0
- package/server/routes/threads.ts +20 -0
- package/server/scripts/reset-message-era.ts +9 -9
- package/shared/model-preference.ts +1 -1
|
@@ -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
|
|
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 =>
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
preference,
|
|
109
|
-
id:
|
|
110
|
-
displayName:
|
|
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.
|
|
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
|
}
|