@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.
@@ -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
+ }
@@ -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.
@@ -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