@gotcos/glasses-server 6.38.1 → 6.39.1

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,178 @@
1
+ import crypto from 'node:crypto'
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
3
+ import { dirname, resolve } from 'node:path'
4
+ import { OLLAMA_MODEL, type OllamaModelPreference } from '../../shared/model-preference.js'
5
+
6
+ const DEFAULT_MAX_RUNS = 100
7
+ const DEFAULT_TTL_MS = 7 * 24 * 60 * 60_000
8
+ const ERROR_PREVIEW_CHARS = 160
9
+ const RUNNING_STALE_MS = 30 * 60_000
10
+
11
+ export type OllamaRunStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'client_disconnected'
12
+
13
+ export interface OllamaRunRecord {
14
+ runId: string
15
+ turnId?: string
16
+ clientJobId?: string
17
+ cosSessionId: string
18
+ status: OllamaRunStatus
19
+ createdAt: string
20
+ updatedAt: string
21
+ model: OllamaModelPreference
22
+ ollamaModel: string
23
+ origin: string
24
+ queryPreview?: string
25
+ outputPreview?: string
26
+ errorCode?: string
27
+ errorPreview?: string
28
+ durationMs?: number
29
+ }
30
+
31
+ interface OllamaRunEvent {
32
+ runId: string
33
+ ts: string
34
+ patch: Partial<OllamaRunRecord>
35
+ }
36
+
37
+ function getProcessStartedAtMs(): number {
38
+ return Date.now() - Math.floor(process.uptime() * 1000)
39
+ }
40
+
41
+ export function getOllamaLedgerPath(): string {
42
+ return resolve(process.env.COS_OLLAMA_RUN_LEDGER_FILE || resolve(import.meta.dirname, '..', 'data', 'ollama-runs.jsonl'))
43
+ }
44
+
45
+ function getMaxRuns(): number {
46
+ const raw = Number(process.env.COS_OLLAMA_RUN_LEDGER_MAX ?? DEFAULT_MAX_RUNS)
47
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_RUNS
48
+ }
49
+
50
+ function getTtlMs(): number {
51
+ const rawDays = Number(process.env.COS_OLLAMA_RUN_LEDGER_TTL_DAYS ?? 7)
52
+ return Number.isFinite(rawDays) && rawDays > 0 ? rawDays * 24 * 60 * 60_000 : DEFAULT_TTL_MS
53
+ }
54
+
55
+ function appendEvent(event: OllamaRunEvent): void {
56
+ try {
57
+ const path = getOllamaLedgerPath()
58
+ mkdirSync(dirname(path), { recursive: true })
59
+ appendFileSync(path, JSON.stringify(event) + '\n')
60
+ } catch (err) {
61
+ console.warn('[ollama-run-ledger] write skipped:', err)
62
+ }
63
+ }
64
+
65
+ function readEvents(): OllamaRunEvent[] {
66
+ const path = getOllamaLedgerPath()
67
+ if (!existsSync(path)) return []
68
+ try {
69
+ const events: OllamaRunEvent[] = []
70
+ for (const line of readFileSync(path, 'utf-8').split('\n').map(row => row.trim()).filter(Boolean)) {
71
+ try {
72
+ const event = JSON.parse(line) as OllamaRunEvent
73
+ if (typeof event.runId === 'string' && typeof event.ts === 'string' && typeof event.patch === 'object') {
74
+ events.push(event)
75
+ }
76
+ } catch {
77
+ // Skip torn JSONL rows.
78
+ }
79
+ }
80
+ return events
81
+ } catch {
82
+ return []
83
+ }
84
+ }
85
+
86
+ export function redactForOllamaLedger(value: string, maxChars = ERROR_PREVIEW_CHARS): string {
87
+ return value.replace(/\s+/g, ' ').trim().slice(0, maxChars)
88
+ }
89
+
90
+ export function classifyOllamaError(message: string): string {
91
+ const text = message.toLowerCase()
92
+ if (/unreachable|econnrefused|fetch failed|enotfound/.test(text)) return 'ollama.unavailable'
93
+ if (/no models|not ready/.test(text)) return 'ollama.no_model'
94
+ if (/text-only|photo|image/.test(text)) return 'ollama.text_only'
95
+ if (/timeout|timed out|aborted/.test(text)) return 'ollama.timeout'
96
+ return 'ollama.error'
97
+ }
98
+
99
+ export function startOllamaRun(input: {
100
+ turnId?: string
101
+ clientJobId?: string
102
+ cosSessionId: string
103
+ ollamaModel: string
104
+ origin: string
105
+ query: string
106
+ }): OllamaRunRecord {
107
+ const now = new Date().toISOString()
108
+ const run: OllamaRunRecord = {
109
+ runId: `ollama-${crypto.randomUUID().slice(0, 8)}`,
110
+ turnId: input.turnId,
111
+ clientJobId: input.clientJobId,
112
+ cosSessionId: input.cosSessionId,
113
+ status: 'running',
114
+ createdAt: now,
115
+ updatedAt: now,
116
+ model: OLLAMA_MODEL,
117
+ ollamaModel: input.ollamaModel,
118
+ origin: input.origin,
119
+ queryPreview: redactForOllamaLedger(input.query),
120
+ }
121
+ appendEvent({ runId: run.runId, ts: now, patch: run })
122
+ return run
123
+ }
124
+
125
+ export function finishOllamaRun(runId: string, input: {
126
+ status: Exclude<OllamaRunStatus, 'running'>
127
+ startedAtMs: number
128
+ output?: string
129
+ error?: string
130
+ }): OllamaRunRecord | null {
131
+ const patch: Partial<OllamaRunRecord> = {
132
+ status: input.status,
133
+ durationMs: Math.max(0, Date.now() - input.startedAtMs),
134
+ }
135
+ if (input.output) patch.outputPreview = redactForOllamaLedger(input.output)
136
+ if (input.error) {
137
+ patch.errorCode = classifyOllamaError(input.error)
138
+ patch.errorPreview = redactForOllamaLedger(input.error)
139
+ }
140
+ const ts = new Date().toISOString()
141
+ appendEvent({ runId, ts, patch: { ...patch, updatedAt: ts } })
142
+ return getOllamaRun(runId)
143
+ }
144
+
145
+ export function getOllamaRun(runId: string): OllamaRunRecord | null {
146
+ return listOllamaRuns(getMaxRuns()).find(run => run.runId === runId) ?? null
147
+ }
148
+
149
+ export function listOllamaRuns(limit = 20, sessionId?: string): OllamaRunRecord[] {
150
+ const runs = new Map<string, OllamaRunRecord>()
151
+ const order = new Map<string, number>()
152
+ let eventIndex = 0
153
+ for (const event of readEvents()) {
154
+ eventIndex += 1
155
+ const existing = runs.get(event.runId)
156
+ const next = { ...(existing ?? {}), ...event.patch, runId: event.runId } as OllamaRunRecord
157
+ runs.set(event.runId, next)
158
+ order.set(event.runId, eventIndex)
159
+ }
160
+ const cutoff = Date.now() - getTtlMs()
161
+ return Array.from(runs.values())
162
+ .filter(run => run.createdAt && Date.parse(run.updatedAt || run.createdAt) >= cutoff)
163
+ .filter(run => !sessionId || run.cosSessionId === sessionId)
164
+ .map(run => {
165
+ const updatedMs = Date.parse(run.updatedAt || run.createdAt)
166
+ const predatesCurrentProcess = updatedMs < getProcessStartedAtMs() - 1000
167
+ if (run.status === 'running' && (predatesCurrentProcess || Date.now() - updatedMs > RUNNING_STALE_MS)) {
168
+ return {
169
+ ...run,
170
+ status: 'client_disconnected' as const,
171
+ errorCode: run.errorCode ?? 'ollama.timeout',
172
+ }
173
+ }
174
+ return run
175
+ })
176
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
177
+ .slice(0, limit)
178
+ }
@@ -13,6 +13,7 @@ import { QueryJobStore } from './query-job-store.js'
13
13
  import {
14
14
  isCodexModel,
15
15
  isCursorModel,
16
+ isOllamaModel,
16
17
  normalizeEffortPreference,
17
18
  normalizeModelPreference,
18
19
  type CursorExecutionMode,
@@ -101,7 +102,8 @@ export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<
101
102
  }
102
103
  }
103
104
 
104
- function providerFor(model: ModelPreference): 'claude' | 'codex' | 'cursor' {
105
+ function providerFor(model: ModelPreference): 'claude' | 'codex' | 'cursor' | 'ollama' {
106
+ if (isOllamaModel(model)) return 'ollama'
105
107
  if (isCursorModel(model)) return 'cursor'
106
108
  return isCodexModel(model) ? 'codex' : 'claude'
107
109
  }
@@ -183,6 +185,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
183
185
  codexRunId: metadata?.codexRunId,
184
186
  codexThreadId: metadata?.codexThreadId,
185
187
  cursorRunId: metadata?.cursorRunId,
188
+ ollamaRunId: metadata?.ollamaRunId,
186
189
  } as const
187
190
  await callbacks.onStart({ sessionId, ...linkage })
188
191
  emitDisplay({ type: 'start', data: {
@@ -205,7 +208,9 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
205
208
  ? { claudeRunId: metadata.runId }
206
209
  : metadata.provider === 'cursor'
207
210
  ? { cursorRunId: metadata.runId }
208
- : { codexRunId: metadata.runId }),
211
+ : metadata.provider === 'ollama'
212
+ ? { ollamaRunId: metadata.runId }
213
+ : { codexRunId: metadata.runId }),
209
214
  }),
210
215
  onChunk: text => { callbacks.onChunk(text) },
211
216
  onToolStatus: toolName => {
@@ -239,6 +244,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
239
244
  codexRunId: metadata?.codexRunId,
240
245
  codexThreadId: metadata?.codexThreadId,
241
246
  cursorRunId: metadata?.cursorRunId,
247
+ ollamaRunId: metadata?.ollamaRunId,
242
248
  } as const
243
249
  // Publish compatibility completion only after the durable terminal is
244
250
  // fsynced. Display subscribers can disappear without owning this job.
@@ -550,11 +550,11 @@ export class QueryJobStore {
550
550
  }
551
551
 
552
552
  private applyLinkage(snapshot: QueryJobSnapshot, raw: Record<string, unknown>): void {
553
- const provider = raw.provider === 'claude' || raw.provider === 'codex' || raw.provider === 'cursor'
553
+ const provider = raw.provider === 'claude' || raw.provider === 'codex' || raw.provider === 'cursor' || raw.provider === 'ollama'
554
554
  ? raw.provider
555
555
  : undefined
556
556
  if (provider) snapshot.provider = provider
557
- const fields = ['resolvedModel', 'cliSessionId', 'claudeRunId', 'codexRunId', 'codexThreadId', 'cursorRunId'] as const
557
+ const fields = ['resolvedModel', 'cliSessionId', 'claudeRunId', 'codexRunId', 'codexThreadId', 'cursorRunId', 'ollamaRunId'] as const
558
558
  for (const field of fields) {
559
559
  const value = safeOptional(raw[field])
560
560
  if (value) snapshot[field] = value
@@ -886,7 +886,11 @@ export class QueryJobStore {
886
886
 
887
887
  private safeLinkage(linkage: QueryJobProviderLinkage): QueryJobProviderLinkage {
888
888
  return {
889
- ...(linkage.provider === 'claude' || linkage.provider === 'codex' || linkage.provider === 'cursor'
889
+ // 'ollama' joined this allowlist in 6.39.1. The 6.39.0 types allowed it and
890
+ // query-job-runtime stamped it, but this sanitizer silently dropped it on
891
+ // persist -- so phone acknowledgement of a live or replayed Ollama job could
892
+ // never see its provider. Unknown providers still strip.
893
+ ...(linkage.provider === 'claude' || linkage.provider === 'codex' || linkage.provider === 'cursor' || linkage.provider === 'ollama'
890
894
  ? { provider: linkage.provider }
891
895
  : {}),
892
896
  ...(safeOptional(linkage.resolvedModel, 64) ? { resolvedModel: safeOptional(linkage.resolvedModel, 64) } : {}),
@@ -895,6 +899,7 @@ export class QueryJobStore {
895
899
  ...(safeOptional(linkage.codexRunId) ? { codexRunId: safeOptional(linkage.codexRunId) } : {}),
896
900
  ...(safeOptional(linkage.codexThreadId) ? { codexThreadId: safeOptional(linkage.codexThreadId) } : {}),
897
901
  ...(safeOptional(linkage.cursorRunId) ? { cursorRunId: safeOptional(linkage.cursorRunId) } : {}),
902
+ ...(safeOptional(linkage.ollamaRunId) ? { ollamaRunId: safeOptional(linkage.ollamaRunId) } : {}),
898
903
  }
899
904
  }
900
905
 
@@ -75,13 +75,14 @@ export interface QueryJobRequest {
75
75
  }
76
76
 
77
77
  export interface QueryJobProviderLinkage {
78
- provider?: 'claude' | 'codex' | 'cursor'
78
+ provider?: 'claude' | 'codex' | 'cursor' | 'ollama'
79
79
  resolvedModel?: string
80
80
  cliSessionId?: string
81
81
  claudeRunId?: string
82
82
  codexRunId?: string
83
83
  codexThreadId?: string
84
84
  cursorRunId?: string
85
+ ollamaRunId?: string
85
86
  }
86
87
 
87
88
  /** Path/id-free aggregate from output-image finalization. Values are bounded
@@ -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),
@@ -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'