@gotcos/glasses-server 6.38.0 → 6.39.0

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.
@@ -32,6 +32,11 @@ import {
32
32
  getCursorModelCatalogSnapshot,
33
33
  isCursorProviderReady,
34
34
  } from '../lib/cursor-model-catalog.js'
35
+ import {
36
+ getOllamaCatalog,
37
+ getOllamaCatalogSnapshot,
38
+ isOllamaProviderReady,
39
+ } from '../lib/ollama-catalog.js'
35
40
  import { isMediaProcessingReady } from '../lib/image-safety.js'
36
41
  import {
37
42
  MAX_OTHER_MEDIA_BYTES,
@@ -117,6 +122,7 @@ healthRouter.get('/health', async (_req, res) => {
117
122
  claude: staticProbes.claude,
118
123
  codex: staticProbes.codex,
119
124
  cursor: staticProbes.cursor,
125
+ ollama: staticProbes.ollama,
120
126
  uptime_seconds: Math.floor((Date.now() - serverMetrics.startedAt) / 1000),
121
127
  request_count: serverMetrics.requestCount,
122
128
  }
@@ -125,6 +131,7 @@ healthRouter.get('/health', async (_req, res) => {
125
131
  const claudeAvailable = staticProbes.claudeAvailable
126
132
  const codexAvailable = staticProbes.codexAvailable
127
133
  const cursorAvailable = staticProbes.cursorAvailable
134
+ const ollamaAvailable = staticProbes.ollamaAvailable
128
135
 
129
136
  // Check session cache freshness (COS mode only)
130
137
  if (COS_SCRIPTS_DIR) {
@@ -195,6 +202,7 @@ healthRouter.get('/health', async (_req, res) => {
195
202
  claude: claudeAvailable,
196
203
  codex: codexAvailable,
197
204
  cursor: cursorAvailable,
205
+ ollama: ollamaAvailable,
198
206
  voice: keyStatus.hasKey || tts_local.ready,
199
207
  cos_pipeline: COS_MODE,
200
208
  whisper: isWhisperLocalAvailable(),
@@ -267,6 +275,7 @@ healthRouter.get('/health', async (_req, res) => {
267
275
  // agent binary paths stay on the authenticated /api/models surface.
268
276
  const cursorSnapshot = getCursorModelCatalogSnapshot()
269
277
  const { agentBinary: _cursorAgentBinary, ...cursor_models } = cursorSnapshot
278
+ const ollama_models = getOllamaCatalogSnapshot()
270
279
  const meeting_sync = getMeetingSyncSnapshot()
271
280
  const progressiveHq = getProgressiveHqSnapshot()
272
281
  // Quarantined unsaved captures (6.19.0). Compact on this unauthenticated
@@ -327,6 +336,10 @@ healthRouter.get('/health', async (_req, res) => {
327
336
  tts_local,
328
337
  codex_models,
329
338
  cursor_models,
339
+ ollama_models: {
340
+ ready: ollama_models.ready,
341
+ model: ollama_models.model,
342
+ },
330
343
  meeting_sync,
331
344
  meeting_library: {
332
345
  layout: meetingLibrary.layout,
@@ -397,6 +410,7 @@ healthRouter.get('/models', async (req, res) => {
397
410
  const forceRefresh = req.query.refresh === '1'
398
411
  const catalog = await getCodexModelCatalog(forceRefresh)
399
412
  const cursorCatalog = await getCursorModelCatalog(forceRefresh)
413
+ const ollamaCatalog = await getOllamaCatalog(forceRefresh)
400
414
  const durableJobs = durableQueryJobStatus()
401
415
  const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
402
416
  const transcription = getTranscriptionPolicySnapshot()
@@ -407,6 +421,9 @@ healthRouter.get('/models', async (req, res) => {
407
421
  const richMedia = await getRichMediaProcessingCapabilities()
408
422
  const videoUploadV2 = videoUploadV2Capability(richMedia.video)
409
423
  const cursorOptions = cursorCatalog.options.filter(option => !!option.id)
424
+ const ollamaOptions = isOllamaProviderReady() && ollamaCatalog.model
425
+ ? [{ preference: 'ollama' as const, id: ollamaCatalog.model, displayName: ollamaCatalog.model }]
426
+ : []
410
427
  // Same helper and the same three key names as /api/health, for the reason
411
428
  // liveCues carries three lines below: the companion's 15s liveness poll reads
412
429
  // THIS surface and Main.ts states outright that /api/health alone is not used,
@@ -418,9 +435,17 @@ healthRouter.get('/models', async (req, res) => {
418
435
  options: [
419
436
  ...(catalog.options ?? []),
420
437
  ...cursorOptions,
438
+ ...ollamaOptions,
421
439
  ],
422
440
  cursor: cursorCatalog,
423
441
  cursorReady: isCursorProviderReady(),
442
+ ollama: {
443
+ origin: ollamaCatalog.origin,
444
+ model: ollamaCatalog.model,
445
+ models: ollamaCatalog.models,
446
+ refreshedAt: ollamaCatalog.refreshedAt,
447
+ },
448
+ ollamaReady: isOllamaProviderReady(),
424
449
  serverInstanceId: getServerInstanceId(),
425
450
  capabilities: {
426
451
  durableQueryJobs: {
@@ -114,6 +114,7 @@ export function resolveModel(model?: string, _query?: string): ModelPreference {
114
114
  if (model === 'cos-haiku') return 'haiku'
115
115
  if (model === 'cos-gpt-frontier' || model === 'cos-codex-high' || model === 'cos-codex') return 'codex-frontier'
116
116
  if (model === 'cos-gpt-balanced') return 'codex-balanced'
117
+ if (model === 'cos-ollama' || model === 'ollama') return 'ollama'
117
118
  return normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL) ?? DEFAULT_MODEL
118
119
  }
119
120
 
@@ -126,6 +127,7 @@ const MODEL_NAMES: Record<ModelPreference, string> = {
126
127
  'codex-balanced': 'cos-gpt-balanced',
127
128
  'cursor-grok': 'cursor-grok',
128
129
  'cursor-composer': 'cursor-composer',
130
+ ollama: 'cos-ollama',
129
131
  }
130
132
  // Extract the user's latest message from the OpenAI messages array
131
133
  function extractUserQuery(messages: Array<{ role: string; content: string }>): string {
@@ -61,6 +61,14 @@ import {
61
61
  appendChunkEmbedding,
62
62
  sweepExpiredChunkEmbeddings,
63
63
  } from '../lib/chunk-embedding-store.js'
64
+ import {
65
+ evenSpeakerRoleMode,
66
+ formatEvenRoleAgreement,
67
+ parseEvenHubSpeakerRoleBody,
68
+ parseEvenHubSpeakerRoleQuery,
69
+ warnEvenSpeakerRoleApplyNotImplemented,
70
+ type EvenSpeakerRoleHistogram,
71
+ } from '../lib/even-hub-speaker-role.js'
64
72
  import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
65
73
  import {
66
74
  countChunkWavs,
@@ -276,6 +284,7 @@ export interface TranscriptChunk {
276
284
  latencyMs?: number
277
285
  audioSha256?: string
278
286
  canonical?: boolean
287
+ evenHubSpeakerRole?: EvenSpeakerRoleHistogram
279
288
  }
280
289
 
281
290
  export interface ProviderCandidateRecord {
@@ -1945,8 +1954,11 @@ async function processStreamChunk(opts: {
1945
1954
  clientElapsed?: number
1946
1955
  /** Original client recording start, applied only before canonical chunks. */
1947
1956
  startTimeOverride?: number
1957
+ evenHubSpeakerRole?: EvenSpeakerRoleHistogram
1948
1958
  }): Promise<StreamChunkCompletionResponse> {
1949
1959
  const { sessionId, chunkIndex, clientSpeaker, audioBuffer, candidate } = opts
1960
+ const evenHubSpeakerRole = evenSpeakerRoleMode() === 'off' ? undefined : opts.evenHubSpeakerRole
1961
+ if (evenHubSpeakerRole) warnEvenSpeakerRoleApplyNotImplemented()
1950
1962
  const tReq = performance.now()
1951
1963
  validateSessionId(sessionId)
1952
1964
  validateChunkIndex(chunkIndex)
@@ -2066,6 +2078,15 @@ async function processStreamChunk(opts: {
2066
2078
  }
2067
2079
 
2068
2080
  const { speaker, similarity } = await speakerPromise
2081
+ if (evenHubSpeakerRole) {
2082
+ console.log(formatEvenRoleAgreement({
2083
+ chunkIndex,
2084
+ even: evenHubSpeakerRole,
2085
+ amp: clientSpeaker,
2086
+ emb: speaker,
2087
+ similarity,
2088
+ }))
2089
+ }
2069
2090
  // Client time is authoritative for live network jitter and deferred replay.
2070
2091
  const elapsed = Number.isFinite(opts.clientElapsed) && (opts.clientElapsed as number) >= 0
2071
2092
  ? Math.round(opts.clientElapsed as number)
@@ -2110,6 +2131,7 @@ async function processStreamChunk(opts: {
2110
2131
  latencyMs,
2111
2132
  audioSha256,
2112
2133
  canonical: true,
2134
+ evenHubSpeakerRole,
2113
2135
  }
2114
2136
  const finalExisting = session.chunks[chunkIndex]
2115
2137
  if (finalExisting?.text) {
@@ -2263,6 +2285,7 @@ transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
2263
2285
  audioBuffer,
2264
2286
  clientElapsed,
2265
2287
  startTimeOverride,
2288
+ evenHubSpeakerRole: parseEvenHubSpeakerRoleQuery(req.query.eh),
2266
2289
  }))
2267
2290
  } catch (err: unknown) {
2268
2291
  sendStreamError(res, err)
@@ -2327,6 +2350,7 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/chun
2327
2350
  clientSpeaker: String(body.clientSpeaker ?? 'Unknown'),
2328
2351
  audioBuffer,
2329
2352
  clientElapsed: typeof body.elapsed === 'number' ? body.elapsed : Number(body.elapsed ?? 0),
2353
+ evenHubSpeakerRole: parseEvenHubSpeakerRoleBody(body.evenHubSpeakerRole),
2330
2354
  candidate: {
2331
2355
  provider: 'iphone-whisperkit-beta',
2332
2356
  text: normalizeCandidateText(candidate.text),
@@ -2405,6 +2429,7 @@ transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) =>
2405
2429
  clientSpeaker: String(body.clientSpeaker ?? 'Unknown'),
2406
2430
  audioBuffer,
2407
2431
  clientElapsed: typeof body.elapsed === 'number' ? body.elapsed : Number(body.elapsed ?? 0),
2432
+ evenHubSpeakerRole: parseEvenHubSpeakerRoleBody(body.evenHubSpeakerRole),
2408
2433
  candidate: {
2409
2434
  provider: 'iphone-whisperkit-beta',
2410
2435
  text: normalizeCandidateText(candidate.text),
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env tsx
2
+ // Repair day files written by the pre-upsert archive merge.
3
+ //
4
+ // npx tsx server/scripts/repair-archive-duplicates.ts # dry run, writes nothing
5
+ // npx tsx server/scripts/repair-archive-duplicates.ts --apply # rewrites, after backing up
6
+ //
7
+ // WHAT WENT WRONG. `runDailyArchiveMirror` re-archives every session still
8
+ // resident in memory, skipping only today's, at boot and every 24h -- without
9
+ // evicting it. `appendToArchive` merged with a blind `existing.chats.push(...)`.
10
+ // So a session that stayed resident gained one more copy of itself in its day
11
+ // file on every restart. Measured before the fix: 1.28 GB across 176 day files,
12
+ // ~1.26 GB of it duplicates. One 69 MB file held ONE conversation 2,388 times.
13
+ //
14
+ // The upsert in archive.ts fixes new writes AND self-heals a file the next time
15
+ // it is touched -- so most affected days repair themselves once the mirror
16
+ // revisits them. This script exists for the remainder: days whose sessions have
17
+ // since been evicted, which nothing will ever touch again.
18
+ //
19
+ // THIS SCRIPT IMPORTS NOTHING FROM THE SERVER. `archive.ts` runs
20
+ // checkYesterdayArchive() at module scope, so importing it would start archive
21
+ // work while we are rewriting the archive. The atomic write below is inlined for
22
+ // the same reason. Nothing here has an effect until --apply.
23
+
24
+ import { execFileSync } from 'node:child_process'
25
+ import { copyFileSync, existsSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs'
26
+ import { homedir } from 'node:os'
27
+ import { join, resolve } from 'node:path'
28
+ import { pathToFileURL } from 'node:url'
29
+
30
+ const APPLY = process.argv.includes('--apply')
31
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
32
+
33
+ interface Chat {
34
+ id: number
35
+ sessionId: string
36
+ startedAt: number
37
+ exchangeCount: number
38
+ [k: string]: unknown
39
+ }
40
+ interface Day { date: string; summary: string; chats: Chat[]; archivedAt: string; [k: string]: unknown }
41
+
42
+ function archiveDirPath(): string {
43
+ const base = process.env.COS_DATA_DIR ?? join(homedir(), '.cos-glasses', 'data')
44
+ return resolve(base, 'archive')
45
+ }
46
+
47
+ /** Identity of a chat. `startedAt` is its first exchange's timestamp, so it
48
+ * survives re-archiving. `id` does NOT -- it is renumbered on every merge,
49
+ * which is exactly why the old code could never see a duplicate. */
50
+ const keyOf = (c: Chat): string => `${c.sessionId}:${c.startedAt}`
51
+
52
+ /** Collapse duplicates, keeping the most complete copy of each chat. Pure. */
53
+ export function dedupeChats(chats: Chat[]): { kept: Chat[]; removed: number } {
54
+ const byKey = new Map<string, Chat>()
55
+ for (const chat of chats) {
56
+ const prior = byKey.get(keyOf(chat))
57
+ if (!prior || (chat.exchangeCount ?? 0) > (prior.exchangeCount ?? 0)) byKey.set(keyOf(chat), chat)
58
+ }
59
+ const kept = [...byKey.values()].sort((a, b) => a.startedAt - b.startedAt)
60
+ kept.forEach((c, i) => { c.id = i })
61
+ return { kept, removed: chats.length - kept.length }
62
+ }
63
+
64
+ function atomicWrite(path: string, data: string): void {
65
+ // Inlined rather than imported: see the header note about module-scope effects.
66
+ const tmp = `${path}.repair-tmp`
67
+ writeFileSync(tmp, data, { encoding: 'utf8', mode: 0o600 })
68
+ renameSync(tmp, path)
69
+ }
70
+
71
+ function main(): void {
72
+ const dir = archiveDirPath()
73
+ if (!existsSync(dir)) {
74
+ console.error(`No archive directory at ${dir}`)
75
+ process.exit(2)
76
+ }
77
+
78
+ // A concurrent appendToArchive would race this rewrite. The in-process archive
79
+ // lock cannot be taken from outside the server, so the only safe answer is to
80
+ // refuse while it is up rather than to hope the window is small.
81
+ // Only the DEFAULT data dir is at risk: that is the one the running server writes
82
+ // to. Pointed at a scratch copy, there is nothing to race, and refusing there
83
+ // would block the very rehearsal this script deserves before it touches real data.
84
+ const isLiveDataDir = process.env.COS_DATA_DIR === undefined
85
+ if (APPLY && isLiveDataDir && serverIsUp()) {
86
+ console.error('The COS server is listening on 127.0.0.1:3141.')
87
+ console.error('Stop it through COS Control before repairing, then re-run.')
88
+ console.error('Refusing to rewrite archive files while the server may write to them.')
89
+ process.exit(3)
90
+ }
91
+
92
+ const files = readdirSync(dir)
93
+ .filter(f => f.endsWith('.json') && DATE_RE.test(f.slice(0, -5)))
94
+ .sort()
95
+
96
+ let affected = 0
97
+ let chatsBefore = 0
98
+ let chatsAfter = 0
99
+ let bytesBefore = 0
100
+ let bytesAfter = 0
101
+
102
+ for (const file of files) {
103
+ const path = join(dir, file)
104
+ const size = statSync(path).size
105
+
106
+ let day: Day
107
+ try {
108
+ day = JSON.parse(readFileSync(path, 'utf8')) as Day
109
+ } catch (err) {
110
+ console.error(` SKIP ${file} — unreadable: ${(err as Error).message.slice(0, 80)}`)
111
+ continue
112
+ }
113
+ if (!Array.isArray(day.chats) || day.chats.length === 0) continue
114
+
115
+ const { kept, removed } = dedupeChats(day.chats)
116
+ if (removed === 0) continue
117
+
118
+ affected++
119
+ chatsBefore += day.chats.length
120
+ chatsAfter += kept.length
121
+ bytesBefore += size
122
+
123
+ const before = day.chats.length
124
+ day.chats = kept
125
+ const serialised = `${JSON.stringify(day, null, 2)}\n`
126
+ bytesAfter += Buffer.byteLength(serialised, 'utf8')
127
+
128
+ console.log(
129
+ ` ${APPLY ? 'REPAIR' : 'would repair'} ${file} ` +
130
+ `${(size / 1e6).toFixed(1)} MB → ${(Buffer.byteLength(serialised, 'utf8') / 1e6).toFixed(1)} MB ` +
131
+ `chats ${before} → ${kept.length} (-${removed})`,
132
+ )
133
+
134
+ if (APPLY) {
135
+ // Back up BEFORE writing. This is user conversation history; a bad rewrite
136
+ // with no copy is unrecoverable.
137
+ const backup = `${path}.bak-${Date.now()}`
138
+ copyFileSync(path, backup)
139
+ atomicWrite(path, serialised)
140
+
141
+ // Verify by UNIQUE CHAT COUNT, never by file size -- size is the metric the
142
+ // bug distorted, so shrinkage proves nothing about correctness.
143
+ const reread = JSON.parse(readFileSync(path, 'utf8')) as Day
144
+ const uniq = new Set(reread.chats.map(keyOf)).size
145
+ if (reread.chats.length !== kept.length || uniq !== kept.length) {
146
+ console.error(` FAILED verification on ${file}; original preserved at ${backup}`)
147
+ process.exit(4)
148
+ }
149
+ }
150
+ }
151
+
152
+ const summary = {
153
+ mode: APPLY ? 'applied' : 'dry-run',
154
+ filesScanned: files.length,
155
+ filesAffected: affected,
156
+ chats: { before: chatsBefore, after: chatsAfter, removed: chatsBefore - chatsAfter },
157
+ bytes: { before: bytesBefore, after: bytesAfter, reclaimed: bytesBefore - bytesAfter },
158
+ }
159
+ console.log('')
160
+ console.log(JSON.stringify(summary, null, 2))
161
+ if (!APPLY && affected > 0) {
162
+ console.log('')
163
+ console.log('Nothing was written. Re-run with --apply to repair (each file is backed up first).')
164
+ }
165
+ }
166
+
167
+ function serverIsUp(): boolean {
168
+ try {
169
+ const out = execFileSync('/usr/sbin/lsof', ['-ti', ':3141'], { encoding: 'utf8', timeout: 5000 })
170
+ return out.trim().length > 0
171
+ } catch {
172
+ return false // lsof missing or nothing listening — do not block on an inconclusive probe
173
+ }
174
+ }
175
+
176
+ // Run ONLY when invoked directly. Importing this file (a test, or any tooling)
177
+ // must not execute a repair or call process.exit -- the same module-scope hazard
178
+ // this script refuses to inherit from archive.ts.
179
+ const invokedDirectly = process.argv[1] !== undefined
180
+ && import.meta.url === pathToFileURL(process.argv[1]).href
181
+ if (invokedDirectly) main()
@@ -8,9 +8,11 @@ export type CodexModelPreference = 'codex-frontier' | 'codex-balanced'
8
8
  // even when features.cursor is false so version skew fail-closes instead of
9
9
  // silently remapping to Claude.
10
10
  export type CursorModelPreference = 'cursor-grok' | 'cursor-composer'
11
+ /** Local Ollama chat slot. Hidden unless the daemon answers on loopback. */
12
+ export type OllamaModelPreference = 'ollama'
11
13
  /** Cursor Agent CLI execution posture for glasses queries. */
12
14
  export type CursorExecutionMode = 'ask' | 'agent'
13
- export type ModelPreference = ClaudeModelPreference | CodexModelPreference | CursorModelPreference
15
+ export type ModelPreference = ClaudeModelPreference | CodexModelPreference | CursorModelPreference | OllamaModelPreference
14
16
 
15
17
  /** Invalid/omitted → ask (safe for old clients that don't send a mode). */
16
18
  export function normalizeCursorExecutionMode(value: unknown): CursorExecutionMode {
@@ -25,6 +27,7 @@ export const CODEX_BALANCED_MODEL: CodexModelPreference = 'codex-balanced'
25
27
  export const CODEX_HIGH_MODEL: CodexModelPreference = CODEX_FRONTIER_MODEL
26
28
  export const CURSOR_GROK_MODEL: CursorModelPreference = 'cursor-grok'
27
29
  export const CURSOR_COMPOSER_MODEL: CursorModelPreference = 'cursor-composer'
30
+ export const OLLAMA_MODEL: OllamaModelPreference = 'ollama'
28
31
  // Existing 6.1–6.3 installs may pin the legacy codex-high slot. Frontier is its
29
32
  // migration target; Balanced remains auto-catalog even when this override is set.
30
33
  export const CODEX_MODEL_ID = process.env.COS_CODEX_MODEL?.trim() ?? ''
@@ -49,6 +52,7 @@ export const MODEL_OPTIONS: ModelPreference[] = [
49
52
  CODEX_BALANCED_MODEL,
50
53
  CURSOR_GROK_MODEL,
51
54
  CURSOR_COMPOSER_MODEL,
55
+ OLLAMA_MODEL,
52
56
  ]
53
57
 
54
58
  const MODEL_SET = new Set<ModelPreference>([
@@ -60,6 +64,7 @@ const MODEL_SET = new Set<ModelPreference>([
60
64
  CODEX_BALANCED_MODEL,
61
65
  CURSOR_GROK_MODEL,
62
66
  CURSOR_COMPOSER_MODEL,
67
+ OLLAMA_MODEL,
63
68
  ])
64
69
 
65
70
  // Bare Claude tier aliases resolve to the newest model in that tier at spawn.
@@ -149,6 +154,21 @@ export function isCursorModel(model: ModelPreference): model is CursorModelPrefe
149
154
  return model === CURSOR_GROK_MODEL || model === CURSOR_COMPOSER_MODEL
150
155
  }
151
156
 
157
+ export function isOllamaModel(model: ModelPreference): model is OllamaModelPreference {
158
+ return model === OLLAMA_MODEL
159
+ }
160
+
161
+ /** Picker families. Cursor and Ollama stay hidden until their local probe is ready. */
162
+ export function visibleModelOptions(
163
+ cursorAvailable: boolean,
164
+ ollamaAvailable: boolean,
165
+ ): ModelPreference[] {
166
+ return MODEL_OPTIONS.filter(model =>
167
+ (!isCursorModel(model) || cursorAvailable) &&
168
+ (!isOllamaModel(model) || ollamaAvailable),
169
+ )
170
+ }
171
+
152
172
  export interface RuntimeCodexModelLabel {
153
173
  preference: CodexModelPreference
154
174
  displayName: string
@@ -197,6 +217,7 @@ export function modelLabel(model: ModelPreference): string {
197
217
  case 'codex-balanced': return runtimeCodexLabels[model] ?? 'GPT Balanced'
198
218
  case 'cursor-grok': return runtimeCursorLabels[model] ?? 'Grok Fast'
199
219
  case 'cursor-composer': return runtimeCursorLabels[model] ?? 'Composer 2.5 Fast'
220
+ case 'ollama': return 'Ollama'
200
221
  case 'opus':
201
222
  default:
202
223
  return 'Opus'
@@ -212,6 +233,7 @@ export function modelShortLabel(model: ModelPreference): string {
212
233
  case 'codex-balanced': return 'GPT Bal'
213
234
  case 'cursor-grok': return 'Grok'
214
235
  case 'cursor-composer': return 'Composer'
236
+ case 'ollama': return 'Ollama'
215
237
  case 'opus':
216
238
  default:
217
239
  return 'Opus'
@@ -227,6 +249,7 @@ export function modelButtonLabel(model: ModelPreference): string {
227
249
  case 'codex-balanced': return 'GPT BAL'
228
250
  case 'cursor-grok': return 'GROK'
229
251
  case 'cursor-composer': return 'CMP'
252
+ case 'ollama': return 'OLLAMA'
230
253
  case 'opus':
231
254
  default:
232
255
  return 'OPUS'
@@ -242,6 +265,7 @@ export function modelTag(model: ModelPreference): string {
242
265
  case 'codex-balanced': return 'GB'
243
266
  case 'cursor-grok': return 'GK'
244
267
  case 'cursor-composer': return 'C2'
268
+ case 'ollama': return 'OL'
245
269
  case 'opus':
246
270
  default:
247
271
  return 'O'