@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,116 @@
1
+ // Even Hub 0.0.14 wearer-vs-other histogram, carried on a meeting chunk.
2
+ // Identity is a suggestion. This module parses and logs. It does not name
3
+ // people and does not change identifyChunkSpeaker.
4
+
5
+ export type EvenSpeakerRole = 'self' | 'other' | 'unknown'
6
+ export type EvenSpeakerRoleMajority = EvenSpeakerRole | 'tie'
7
+
8
+ export interface EvenSpeakerRoleHistogram {
9
+ schema: 1
10
+ frames: number
11
+ self: number
12
+ other: number
13
+ unknown: number
14
+ majority: EvenSpeakerRoleMajority
15
+ directionPresent: number
16
+ directionLast: number | null
17
+ }
18
+
19
+ export type EvenSpeakerRoleMode = 'off' | 'log' | 'apply'
20
+
21
+ export function evenSpeakerRoleMode(): EvenSpeakerRoleMode {
22
+ const raw = (process.env.COS_EVEN_SPEAKER_ROLE ?? 'log').trim().toLowerCase()
23
+ if (raw === 'off' || raw === '0' || raw === 'false') return 'off'
24
+ if (raw === 'apply') return 'apply'
25
+ return 'log'
26
+ }
27
+
28
+ let applyNotImplementedWarned = false
29
+
30
+ /** Gate A is not in this slice. apply must not silently change labels. */
31
+ export function warnEvenSpeakerRoleApplyNotImplemented(): void {
32
+ if (evenSpeakerRoleMode() !== 'apply' || applyNotImplementedWarned) return
33
+ applyNotImplementedWarned = true
34
+ console.warn('[even-role] COS_EVEN_SPEAKER_ROLE=apply is not implemented; logging only')
35
+ }
36
+
37
+ function majorityOf(self: number, other: number, unknown: number, frames: number): EvenSpeakerRoleMajority {
38
+ if (frames <= 0) return 'unknown'
39
+ if (self > other && self > unknown) return 'self'
40
+ if (other > self && other > unknown) return 'other'
41
+ if (unknown > self && unknown > other) return 'unknown'
42
+ return 'tie'
43
+ }
44
+
45
+ function asNonNegInt(raw: unknown): number | null {
46
+ const n = typeof raw === 'number' ? raw : typeof raw === 'string' && raw !== '' ? Number(raw) : NaN
47
+ if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return null
48
+ return n
49
+ }
50
+
51
+ /** Compact query `eh=self,other,unknown,frames,directionPresent,directionLast`. */
52
+ export function parseEvenHubSpeakerRoleQuery(raw: unknown): EvenSpeakerRoleHistogram | undefined {
53
+ if (typeof raw !== 'string' || raw.length === 0) return undefined
54
+ const parts = raw.split(',')
55
+ if (parts.length < 4 || parts.length > 6) return undefined
56
+ const self = asNonNegInt(parts[0])
57
+ const other = asNonNegInt(parts[1])
58
+ const unknown = asNonNegInt(parts[2])
59
+ const frames = asNonNegInt(parts[3])
60
+ if (self == null || other == null || unknown == null || frames == null) return undefined
61
+ if (self + other + unknown !== frames) return undefined
62
+ const directionPresent = parts.length >= 5 ? asNonNegInt(parts[4]) : 0
63
+ if (directionPresent == null) return undefined
64
+ let directionLast: number | null = null
65
+ if (parts.length === 6 && parts[5] !== '') {
66
+ const last = Number(parts[5])
67
+ if (!Number.isFinite(last)) return undefined
68
+ directionLast = last
69
+ }
70
+ return {
71
+ schema: 1,
72
+ frames,
73
+ self,
74
+ other,
75
+ unknown,
76
+ majority: majorityOf(self, other, unknown, frames),
77
+ directionPresent,
78
+ directionLast,
79
+ }
80
+ }
81
+
82
+ export function parseEvenHubSpeakerRoleBody(raw: unknown): EvenSpeakerRoleHistogram | undefined {
83
+ if (!raw || typeof raw !== 'object') return undefined
84
+ const o = raw as Record<string, unknown>
85
+ const self = asNonNegInt(o.self)
86
+ const other = asNonNegInt(o.other)
87
+ const unknown = asNonNegInt(o.unknown)
88
+ const frames = asNonNegInt(o.frames)
89
+ if (self == null || other == null || unknown == null || frames == null) return undefined
90
+ if (self + other + unknown !== frames) return undefined
91
+ const directionPresent = o.directionPresent == null ? 0 : asNonNegInt(o.directionPresent)
92
+ if (directionPresent == null) return undefined
93
+ const directionLast = o.directionLast == null || o.directionLast === ''
94
+ ? null
95
+ : (typeof o.directionLast === 'number' && Number.isFinite(o.directionLast) ? o.directionLast : null)
96
+ return {
97
+ schema: 1,
98
+ frames,
99
+ self,
100
+ other,
101
+ unknown,
102
+ majority: majorityOf(self, other, unknown, frames),
103
+ directionPresent,
104
+ directionLast,
105
+ }
106
+ }
107
+
108
+ export function formatEvenRoleAgreement(opts: {
109
+ chunkIndex: number
110
+ even: EvenSpeakerRoleHistogram
111
+ amp: string
112
+ emb: string
113
+ similarity: number
114
+ }): string {
115
+ return `[even-role] chunk=${opts.chunkIndex} even=${opts.even.majority} amp=${opts.amp} emb=${opts.emb} sim=${opts.similarity.toFixed(2)} frames=${opts.even.frames}`
116
+ }
@@ -78,7 +78,6 @@ import {
78
78
  buildAttachedEnv,
79
79
  classifyStderr,
80
80
  extractNativeIdsFromLine,
81
- findBannedPermissionArg,
82
81
  isAttachedPermissionPolicy,
83
82
  resolveProviderBinary,
84
83
  type AttachedChildProcess,
@@ -86,6 +85,7 @@ import {
86
85
  type AttachedStderrClass,
87
86
  type BinaryResolution,
88
87
  } from './attached-provider-adapter.js'
88
+ import { findBannedPermissionArg } from './banned-permission-args.js'
89
89
 
90
90
  /**
91
91
  * Providers that can be forked. A strict subset of the attached set.
@@ -261,7 +261,7 @@ export function buildCodexForkArgs(nativeThreadId: string, cwd: string): string[
261
261
  /**
262
262
  * Build the argv, and REFUSE to hand back one carrying a banned permission flag.
263
263
  *
264
- * The predicate is IMPORTED from the adapter, not re-listed here. Two copies of a
264
+ * The predicate is IMPORTED from banned-permission-args, not re-listed here. Two copies of a
265
265
  * ban list in two modules is precisely the drift `native-thread-id.ts` was created
266
266
  * to end: the occupancy detector and the binding store each had their own idea of
267
267
  * what a thread id was, and a truncated id walked through the gap. A fork spawns a
@@ -6,6 +6,11 @@ import {
6
6
  isCursorProviderReady,
7
7
  resolveAgentBinary,
8
8
  } from './cursor-model-catalog.js'
9
+ import {
10
+ getOllamaCatalog,
11
+ getOllamaCatalogSnapshot,
12
+ isOllamaProviderReady,
13
+ } from './ollama-catalog.js'
9
14
 
10
15
  const DEFAULT_CACHE_TTL_MS = 30_000
11
16
  const PROBE_TIMEOUT_MS = 5_000
@@ -15,9 +20,11 @@ export interface HealthStaticProbeSnapshot {
15
20
  claude: string
16
21
  codex: string
17
22
  cursor: string
23
+ ollama: string
18
24
  claudeAvailable: boolean
19
25
  codexAvailable: boolean
20
26
  cursorAvailable: boolean
27
+ ollamaAvailable: boolean
21
28
  }
22
29
 
23
30
  interface CachedProbe<T> {
@@ -153,21 +160,36 @@ async function probeCursor(): Promise<{ value: string; available: boolean }> {
153
160
  }
154
161
  }
155
162
 
163
+ async function probeOllama(): Promise<{ value: string; available: boolean }> {
164
+ try {
165
+ const catalog = await getOllamaCatalog()
166
+ const available = isOllamaProviderReady()
167
+ if (available) return { value: catalog.model || 'available', available: true }
168
+ return { value: catalog.error || 'unavailable', available: false }
169
+ } catch {
170
+ const snapshot = getOllamaCatalogSnapshot()
171
+ return { value: snapshot.error || 'error', available: false }
172
+ }
173
+ }
174
+
156
175
  async function loadStaticHealthProbes(): Promise<HealthStaticProbeSnapshot> {
157
- const [python, claude, codex, cursor] = await Promise.all([
176
+ const [python, claude, codex, cursor, ollama] = await Promise.all([
158
177
  probePython(),
159
178
  probeClaude(),
160
179
  probeCodex(),
161
180
  probeCursor(),
181
+ probeOllama(),
162
182
  ])
163
183
  return {
164
184
  python,
165
185
  claude: claude.value,
166
186
  codex: codex.value,
167
187
  cursor: cursor.value,
188
+ ollama: ollama.value,
168
189
  claudeAvailable: claude.available,
169
190
  codexAvailable: codex.available,
170
191
  cursorAvailable: cursor.available,
192
+ ollamaAvailable: ollama.available,
171
193
  }
172
194
  }
173
195
 
@@ -1,6 +1,7 @@
1
1
  import { callClaudeStreaming, type CallOptions, type StreamCallbacks } from './claude-bridge.js'
2
2
  import { callCodexStreaming } from './codex-bridge.js'
3
3
  import { callCursorStreaming } from './cursor-bridge.js'
4
+ import { callOllamaStreaming } from './ollama-bridge.js'
4
5
  import {
5
6
  getOrCreateSession,
6
7
  getSessionModel,
@@ -13,6 +14,7 @@ import {
13
14
  isCodexModel,
14
15
  isClaudeModel,
15
16
  isCursorModel,
17
+ isOllamaModel,
16
18
  normalizeModelPreference,
17
19
  } from '../../shared/model-preference.js'
18
20
  import {
@@ -20,6 +22,10 @@ import {
20
22
  isCursorProviderReady,
21
23
  resolveCursorModelOption,
22
24
  } from './cursor-model-catalog.js'
25
+ import {
26
+ getOllamaCatalog,
27
+ isOllamaProviderReady,
28
+ } from './ollama-catalog.js'
23
29
  import type { ModelImageInput } from './model-image-input.js'
24
30
 
25
31
  // Bridges return as soon as their subprocess is spawned, while completion is
@@ -112,6 +118,14 @@ export async function callModelStreaming(
112
118
  }
113
119
  return await callCursorStreaming(query, sid, lockedCallbacks, resolvedModel, images, reference, globalMsgNum, options)
114
120
  }
121
+ if (isOllamaModel(resolvedModel)) {
122
+ await getOllamaCatalog()
123
+ if (!isOllamaProviderReady()) {
124
+ await lockedCallbacks.onError('ollama-bridge: Ollama is not running. Start ollama serve on this Mac.')
125
+ return sid
126
+ }
127
+ return await callOllamaStreaming(query, sid, lockedCallbacks, images, reference, globalMsgNum, options)
128
+ }
115
129
  if (isCodexModel(resolvedModel)) {
116
130
  return await callCodexStreaming(query, sid, lockedCallbacks, resolvedModel, images, reference, globalMsgNum, options)
117
131
  }
@@ -0,0 +1,274 @@
1
+ // Direct Ollama chat — POST /api/chat. No Codex --oss, no tools, text only.
2
+
3
+ import type { CallOptions, StreamCallbacks } from './claude-bridge.js'
4
+ import { buildLightweightSystemPrompt } from './context-builder.js'
5
+ import {
6
+ addExchange,
7
+ formatHistoryForPrompt,
8
+ getHistory,
9
+ getOrCreateSession,
10
+ getSessionRaw,
11
+ isNewSession,
12
+ markSessionNotified,
13
+ reconcileExchangeByJobIdentity,
14
+ type Exchange,
15
+ type PromptReference,
16
+ } from './conversation.js'
17
+ import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
18
+ import {
19
+ getOllamaCatalog,
20
+ isOllamaProviderReady,
21
+ ollamaFetch,
22
+ } from './ollama-catalog.js'
23
+ import {
24
+ classifyOllamaError,
25
+ finishOllamaRun,
26
+ startOllamaRun,
27
+ } from './ollama-run-ledger.js'
28
+ import { notifyExchange, notifySessionStart } from './telegram-notify.js'
29
+ import { OLLAMA_MODEL } from '../../shared/model-preference.js'
30
+
31
+ const INACTIVITY_MS = 60_000
32
+ const WALL_MAX_MS = 180_000
33
+ const HISTORY_LIMIT = 20
34
+
35
+ type OllamaChatMessage = { role: 'system' | 'user' | 'assistant'; content: string }
36
+
37
+ export function parseOllamaChatDelta(line: string): { content: string; done: boolean; error?: string } {
38
+ const trimmed = line.trim()
39
+ if (!trimmed) return { content: '', done: false }
40
+ try {
41
+ const event = JSON.parse(trimmed) as {
42
+ error?: unknown
43
+ done?: unknown
44
+ message?: { content?: unknown }
45
+ }
46
+ if (typeof event.error === 'string' && event.error.trim()) {
47
+ return { content: '', done: true, error: event.error.trim() }
48
+ }
49
+ const content = typeof event.message?.content === 'string' ? event.message.content : ''
50
+ return { content, done: event.done === true }
51
+ } catch {
52
+ return { content: '', done: false }
53
+ }
54
+ }
55
+
56
+ export function historyToOllamaMessages(
57
+ exchanges: Exchange[],
58
+ contextBreaks: number[],
59
+ limit = HISTORY_LIMIT,
60
+ ): OllamaChatMessage[] {
61
+ const lastBreak = contextBreaks.length > 0 ? contextBreaks[contextBreaks.length - 1]! : 0
62
+ const recent = exchanges.filter(ex => ex.timestamp >= lastBreak).slice(-limit)
63
+ const messages: OllamaChatMessage[] = []
64
+ for (const ex of recent) {
65
+ const content = ex.content.trim()
66
+ if (!content) continue
67
+ if (ex.role === 'user') messages.push({ role: 'user', content })
68
+ else messages.push({ role: 'assistant', content })
69
+ }
70
+ return messages
71
+ }
72
+
73
+ function safeOllamaUserError(message: string): string {
74
+ const code = classifyOllamaError(message)
75
+ if (code === 'ollama.unavailable') return 'Ollama is not running. Start ollama serve on this Mac.'
76
+ if (code === 'ollama.no_model') return 'Ollama has no pulled models. Run ollama pull, then retry.'
77
+ if (code === 'ollama.text_only') return 'Ollama is text-only here. Remove the photo and retry.'
78
+ if (code === 'ollama.timeout') return 'Ollama timed out. Retry or pick another model.'
79
+ return `Ollama failed (${code}). Retry or check that ollama serve is running.`
80
+ }
81
+
82
+ export async function callOllamaStreaming(
83
+ query: string,
84
+ sessionId: string | undefined,
85
+ callbacks: StreamCallbacks,
86
+ images?: ModelImageInput[],
87
+ reference?: PromptReference,
88
+ globalMsgNum?: number,
89
+ options?: CallOptions,
90
+ ): Promise<string> {
91
+ const sid = getOrCreateSession(sessionId)
92
+ const imageInputs = images ?? []
93
+ const inboundAttachments = options?.requestAttachments ?? []
94
+
95
+ if (imageInputs.length > 0 || inboundAttachments.length > 0) {
96
+ cleanupModelImageInputs(imageInputs)
97
+ await callbacks.onError(safeOllamaUserError('ollama-bridge: Ollama is text-only in this version.'))
98
+ return sid
99
+ }
100
+
101
+ await getOllamaCatalog()
102
+ const catalog = await getOllamaCatalog()
103
+ if (!isOllamaProviderReady() || !catalog.model) {
104
+ await callbacks.onError(safeOllamaUserError(
105
+ catalog.error ? `ollama-bridge: ${catalog.error}` : 'ollama-bridge: Ollama is not ready.',
106
+ ))
107
+ return sid
108
+ }
109
+
110
+ const history = getHistory(sid)
111
+ const session = getSessionRaw(sid)
112
+ const contextBreaks = session?.contextBreaks ?? []
113
+ const historyPrompt = formatHistoryForPrompt(history, contextBreaks, reference)
114
+ const handoffPrompt = options?.handoffContext?.promptBlock ? `\n\n${options.handoffContext.promptBlock}` : ''
115
+ const systemPrompt = `${buildLightweightSystemPrompt(query, `${historyPrompt}${handoffPrompt}`)}\n\nYou have no tools. Answer from the prompt and conversation only. Plain text.`
116
+
117
+ const startTime = Date.now()
118
+ const run = startOllamaRun({
119
+ turnId: options?.turnId,
120
+ clientJobId: options?.clientJobId,
121
+ cosSessionId: sid,
122
+ ollamaModel: catalog.model,
123
+ origin: catalog.origin,
124
+ query,
125
+ })
126
+
127
+ callbacks.onStart?.(OLLAMA_MODEL, sid, undefined, { ollamaRunId: run.runId })
128
+ await callbacks.onProviderProcess?.({
129
+ provider: 'ollama',
130
+ runId: run.runId,
131
+ clientJobId: options?.clientJobId,
132
+ generation: options?.jobGeneration ?? options?.generation,
133
+ })
134
+
135
+ const jobGeneration = options?.jobGeneration ?? options?.generation
136
+ const durableIdentity = options?.clientJobId && Number.isSafeInteger(jobGeneration) && jobGeneration! > 0
137
+ ? { clientJobId: options.clientJobId, generation: jobGeneration! } : undefined
138
+ if (durableIdentity) {
139
+ reconcileExchangeByJobIdentity(sid, durableIdentity, 'user', query, globalMsgNum, undefined, undefined, OLLAMA_MODEL)
140
+ } else {
141
+ addExchange(sid, 'user', query, globalMsgNum, undefined, durableIdentity, OLLAMA_MODEL)
142
+ }
143
+
144
+ if (isNewSession(sid)) {
145
+ notifySessionStart(sid, query)
146
+ markSessionNotified(sid)
147
+ }
148
+
149
+ const messages: OllamaChatMessage[] = [
150
+ { role: 'system', content: systemPrompt },
151
+ ...historyToOllamaMessages(history, contextBreaks),
152
+ { role: 'user', content: query },
153
+ ]
154
+
155
+ const abort = new AbortController()
156
+ const onExternalAbort = () => abort.abort()
157
+ options?.abortSignal?.addEventListener('abort', onExternalAbort, { once: true })
158
+
159
+ let inactivityTimer: ReturnType<typeof setTimeout> | undefined
160
+ let wallTimer: ReturnType<typeof setTimeout> | undefined
161
+ const clearTimers = () => {
162
+ if (inactivityTimer) clearTimeout(inactivityTimer)
163
+ if (wallTimer) clearTimeout(wallTimer)
164
+ }
165
+ const bumpInactivity = () => {
166
+ if (inactivityTimer) clearTimeout(inactivityTimer)
167
+ inactivityTimer = setTimeout(() => abort.abort(), INACTIVITY_MS)
168
+ }
169
+
170
+ let fullText = ''
171
+ let finalized = false
172
+ const finalizeError = async (raw: string) => {
173
+ if (finalized) return
174
+ finalized = true
175
+ clearTimers()
176
+ finishOllamaRun(run.runId, { status: 'failed', startedAtMs: startTime, error: raw })
177
+ await callbacks.onError(safeOllamaUserError(raw))
178
+ }
179
+ const finalizeDone = async () => {
180
+ if (finalized) return
181
+ finalized = true
182
+ clearTimers()
183
+ const text = fullText.trim()
184
+ if (!text) {
185
+ finishOllamaRun(run.runId, { status: 'failed', startedAtMs: startTime, error: 'ollama-bridge: empty response' })
186
+ await callbacks.onError(safeOllamaUserError('ollama-bridge: Ollama completed without a response.'))
187
+ return
188
+ }
189
+ if (durableIdentity) {
190
+ reconcileExchangeByJobIdentity(sid, durableIdentity, 'assistant', text, globalMsgNum, undefined, undefined, OLLAMA_MODEL)
191
+ } else {
192
+ addExchange(sid, 'assistant', text, globalMsgNum, undefined, durableIdentity, OLLAMA_MODEL)
193
+ }
194
+ finishOllamaRun(run.runId, { status: 'completed', startedAtMs: startTime, output: text })
195
+ notifyExchange(sid, query, text)
196
+ await callbacks.onDone(text, OLLAMA_MODEL, undefined, { ollamaRunId: run.runId })
197
+ }
198
+
199
+ bumpInactivity()
200
+ wallTimer = setTimeout(() => abort.abort(), WALL_MAX_MS)
201
+
202
+ try {
203
+ const response = await ollamaFetch(`${catalog.origin}/api/chat`, {
204
+ method: 'POST',
205
+ headers: { 'Content-Type': 'application/json' },
206
+ body: JSON.stringify({
207
+ model: catalog.model,
208
+ messages,
209
+ stream: true,
210
+ }),
211
+ signal: abort.signal,
212
+ })
213
+ if (!response.ok) {
214
+ const detail = (await response.text().catch(() => '')).trim().slice(0, 240)
215
+ await finalizeError(`ollama-bridge: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`)
216
+ return sid
217
+ }
218
+ if (!response.body) {
219
+ await finalizeError('ollama-bridge: empty stream')
220
+ return sid
221
+ }
222
+
223
+ const reader = response.body.getReader()
224
+ const decoder = new TextDecoder()
225
+ let buffer = ''
226
+ while (true) {
227
+ const { done, value } = await reader.read()
228
+ if (done) break
229
+ bumpInactivity()
230
+ buffer += decoder.decode(value, { stream: true })
231
+ const lines = buffer.split('\n')
232
+ buffer = lines.pop() ?? ''
233
+ for (const line of lines) {
234
+ const delta = parseOllamaChatDelta(line)
235
+ if (delta.error) {
236
+ await finalizeError(`ollama-bridge: ${delta.error}`)
237
+ return sid
238
+ }
239
+ if (delta.content) {
240
+ fullText += delta.content
241
+ callbacks.onChunk(delta.content)
242
+ }
243
+ if (delta.done) {
244
+ await finalizeDone()
245
+ return sid
246
+ }
247
+ }
248
+ }
249
+ if (buffer.trim()) {
250
+ const delta = parseOllamaChatDelta(buffer)
251
+ if (delta.error) {
252
+ await finalizeError(`ollama-bridge: ${delta.error}`)
253
+ return sid
254
+ }
255
+ if (delta.content) {
256
+ fullText += delta.content
257
+ callbacks.onChunk(delta.content)
258
+ }
259
+ }
260
+ await finalizeDone()
261
+ return sid
262
+ } catch (error: any) {
263
+ const aborted = abort.signal.aborted || options?.abortSignal?.aborted
264
+ await finalizeError(
265
+ aborted
266
+ ? 'ollama-bridge: request aborted'
267
+ : `ollama-bridge: ${error?.message ?? 'fetch failed'}`,
268
+ )
269
+ return sid
270
+ } finally {
271
+ clearTimers()
272
+ options?.abortSignal?.removeEventListener('abort', onExternalAbort)
273
+ }
274
+ }
@@ -0,0 +1,161 @@
1
+ // Local Ollama discovery. Hidden picker until GET /api/tags succeeds with a model.
2
+ // Host is loopback-only — COS_OLLAMA_HOST that is not 127.0.0.1 / localhost / ::1
3
+ // is refused (SSRF).
4
+
5
+ export const DEFAULT_OLLAMA_ORIGIN = 'http://127.0.0.1:11434'
6
+ const PROBE_TIMEOUT_MS = 2_000
7
+ const CACHE_TTL_MS = 30_000
8
+
9
+ export interface OllamaCatalog {
10
+ ready: boolean
11
+ origin: string
12
+ model: string
13
+ models: string[]
14
+ refreshedAt: string
15
+ error?: string
16
+ }
17
+
18
+ type FetchLike = typeof fetch
19
+ let catalogFetch: FetchLike = globalThis.fetch.bind(globalThis)
20
+
21
+ export function ollamaFetch(...args: Parameters<FetchLike>): ReturnType<FetchLike> {
22
+ return catalogFetch(...args)
23
+ }
24
+
25
+ export function _setOllamaCatalogFetchForTests(fn: FetchLike | null): void {
26
+ catalogFetch = fn ?? globalThis.fetch.bind(globalThis)
27
+ }
28
+
29
+ function unavailableCatalog(origin: string, error: string): OllamaCatalog {
30
+ return {
31
+ ready: false,
32
+ origin,
33
+ model: '',
34
+ models: [],
35
+ refreshedAt: new Date().toISOString(),
36
+ error,
37
+ }
38
+ }
39
+
40
+ export function isLoopbackHostname(hostname: string): boolean {
41
+ const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
42
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1'
43
+ }
44
+
45
+ export function resolveOllamaOrigin(
46
+ raw: string | undefined = process.env.COS_OLLAMA_HOST,
47
+ ): { ok: true; origin: string } | { ok: false; error: string } {
48
+ const trimmed = (raw ?? '').trim()
49
+ if (!trimmed) return { ok: true, origin: DEFAULT_OLLAMA_ORIGIN }
50
+ try {
51
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`
52
+ const url = new URL(withScheme)
53
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
54
+ return { ok: false, error: 'COS_OLLAMA_HOST must be http(s) on loopback' }
55
+ }
56
+ if (!isLoopbackHostname(url.hostname)) {
57
+ return { ok: false, error: 'COS_OLLAMA_HOST must be loopback (127.0.0.1, localhost, ::1)' }
58
+ }
59
+ if (url.username || url.password) {
60
+ return { ok: false, error: 'COS_OLLAMA_HOST must not include credentials' }
61
+ }
62
+ return { ok: true, origin: url.origin }
63
+ } catch {
64
+ return { ok: false, error: 'COS_OLLAMA_HOST is not a valid URL' }
65
+ }
66
+ }
67
+
68
+ export function selectOllamaModel(names: string[], preferred?: string): string {
69
+ const cleaned = names.map(name => name.trim()).filter(Boolean)
70
+ if (cleaned.length === 0) return ''
71
+ const pin = (preferred ?? process.env.COS_OLLAMA_MODEL ?? '').trim()
72
+ if (!pin) return cleaned[0] ?? ''
73
+ if (cleaned.includes(pin)) return pin
74
+ const tagged = cleaned.find(name => name.startsWith(`${pin}:`))
75
+ return tagged ?? ''
76
+ }
77
+
78
+ export function parseOllamaTagNames(body: unknown): string[] {
79
+ if (!body || typeof body !== 'object') return []
80
+ const models = (body as { models?: unknown }).models
81
+ if (!Array.isArray(models)) return []
82
+ const names: string[] = []
83
+ for (const row of models) {
84
+ if (!row || typeof row !== 'object') continue
85
+ const record = row as { name?: unknown; model?: unknown }
86
+ const name = typeof record.name === 'string' ? record.name
87
+ : typeof record.model === 'string' ? record.model
88
+ : ''
89
+ if (name.trim()) names.push(name.trim())
90
+ }
91
+ return names
92
+ }
93
+
94
+ let catalogSnapshot: OllamaCatalog = unavailableCatalog(DEFAULT_OLLAMA_ORIGIN, 'unprobed')
95
+ let refreshPromise: Promise<OllamaCatalog> | null = null
96
+
97
+ export function getOllamaCatalogSnapshot(): OllamaCatalog {
98
+ return catalogSnapshot
99
+ }
100
+
101
+ export function isOllamaProviderReady(): boolean {
102
+ return catalogSnapshot.ready && catalogSnapshot.model.length > 0
103
+ }
104
+
105
+ export function _resetOllamaCatalogCache(): void {
106
+ catalogSnapshot = unavailableCatalog(DEFAULT_OLLAMA_ORIGIN, 'unprobed')
107
+ refreshPromise = null
108
+ }
109
+
110
+ async function probeOllamaCatalog(): Promise<OllamaCatalog> {
111
+ const originResult = resolveOllamaOrigin()
112
+ if (!originResult.ok) return unavailableCatalog(DEFAULT_OLLAMA_ORIGIN, originResult.error)
113
+ const origin = originResult.origin
114
+ const controller = new AbortController()
115
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
116
+ try {
117
+ const response = await catalogFetch(`${origin}/api/tags`, { signal: controller.signal })
118
+ if (!response.ok) {
119
+ return unavailableCatalog(origin, `ollama /api/tags HTTP ${response.status}`)
120
+ }
121
+ const body = await response.json() as unknown
122
+ const models = parseOllamaTagNames(body)
123
+ const model = selectOllamaModel(models)
124
+ if (!model) return unavailableCatalog(origin, 'no models pulled')
125
+ return {
126
+ ready: true,
127
+ origin,
128
+ model,
129
+ models,
130
+ refreshedAt: new Date().toISOString(),
131
+ }
132
+ } catch (error: any) {
133
+ const message = error?.name === 'AbortError'
134
+ ? 'ollama probe timed out'
135
+ : (error?.message ? String(error.message).slice(0, 160) : 'ollama unreachable')
136
+ return unavailableCatalog(origin, message)
137
+ } finally {
138
+ clearTimeout(timer)
139
+ }
140
+ }
141
+
142
+ export async function getOllamaCatalog(forceRefresh = false): Promise<OllamaCatalog> {
143
+ const ageMs = Date.now() - Date.parse(catalogSnapshot.refreshedAt)
144
+ if (
145
+ !forceRefresh
146
+ && catalogSnapshot.ready
147
+ && Number.isFinite(ageMs)
148
+ && ageMs >= 0
149
+ && ageMs < CACHE_TTL_MS
150
+ ) {
151
+ return catalogSnapshot
152
+ }
153
+ if (refreshPromise) return refreshPromise
154
+ refreshPromise = probeOllamaCatalog().then(snapshot => {
155
+ catalogSnapshot = snapshot
156
+ return snapshot
157
+ }).finally(() => {
158
+ refreshPromise = null
159
+ })
160
+ return refreshPromise
161
+ }