@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.
- package/.env.example +21 -1
- package/CHANGELOG.md +48 -0
- package/README.md +14 -1
- package/package.json +2 -2
- package/server/lib/attached-provider-adapter.ts +2 -48
- package/server/lib/banned-permission-args.ts +45 -0
- package/server/lib/claude-bridge.ts +2 -1
- package/server/lib/codex-bridge.ts +49 -4
- package/server/lib/codex-engine-sessions.ts +8 -2
- package/server/lib/codex-extra-args.ts +222 -0
- package/server/lib/even-hub-speaker-role.ts +116 -0
- package/server/lib/fork-thread.ts +2 -2
- package/server/lib/health-static-probes.ts +23 -1
- package/server/lib/model-router.ts +14 -0
- package/server/lib/ollama-bridge.ts +274 -0
- package/server/lib/ollama-catalog.ts +161 -0
- package/server/lib/ollama-run-ledger.ts +178 -0
- package/server/lib/query-job-runtime.ts +8 -2
- package/server/lib/query-job-store.ts +8 -3
- package/server/lib/query-job-types.ts +2 -1
- package/server/routes/health.ts +25 -0
- package/server/routes/openai-compat.ts +2 -0
- package/server/routes/transcribe-stream.ts +25 -0
- package/shared/model-preference.ts +25 -1
|
@@ -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
|
-
:
|
|
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
|
-
|
|
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
|
package/server/routes/health.ts
CHANGED
|
@@ -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'
|