@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.
- package/.env.example +21 -1
- package/CHANGELOG.md +55 -0
- package/README.md +14 -1
- package/package.json +2 -2
- package/server/lib/archive.ts +52 -5
- 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-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/server/scripts/repair-archive-duplicates.ts +181 -0
- package/shared/model-preference.ts +25 -1
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),
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
// Repair day files written by the pre-upsert archive merge.
|
|
3
|
+
//
|
|
4
|
+
// npx tsx server/scripts/repair-archive-duplicates.ts # dry run, writes nothing
|
|
5
|
+
// npx tsx server/scripts/repair-archive-duplicates.ts --apply # rewrites, after backing up
|
|
6
|
+
//
|
|
7
|
+
// WHAT WENT WRONG. `runDailyArchiveMirror` re-archives every session still
|
|
8
|
+
// resident in memory, skipping only today's, at boot and every 24h -- without
|
|
9
|
+
// evicting it. `appendToArchive` merged with a blind `existing.chats.push(...)`.
|
|
10
|
+
// So a session that stayed resident gained one more copy of itself in its day
|
|
11
|
+
// file on every restart. Measured before the fix: 1.28 GB across 176 day files,
|
|
12
|
+
// ~1.26 GB of it duplicates. One 69 MB file held ONE conversation 2,388 times.
|
|
13
|
+
//
|
|
14
|
+
// The upsert in archive.ts fixes new writes AND self-heals a file the next time
|
|
15
|
+
// it is touched -- so most affected days repair themselves once the mirror
|
|
16
|
+
// revisits them. This script exists for the remainder: days whose sessions have
|
|
17
|
+
// since been evicted, which nothing will ever touch again.
|
|
18
|
+
//
|
|
19
|
+
// THIS SCRIPT IMPORTS NOTHING FROM THE SERVER. `archive.ts` runs
|
|
20
|
+
// checkYesterdayArchive() at module scope, so importing it would start archive
|
|
21
|
+
// work while we are rewriting the archive. The atomic write below is inlined for
|
|
22
|
+
// the same reason. Nothing here has an effect until --apply.
|
|
23
|
+
|
|
24
|
+
import { execFileSync } from 'node:child_process'
|
|
25
|
+
import { copyFileSync, existsSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
26
|
+
import { homedir } from 'node:os'
|
|
27
|
+
import { join, resolve } from 'node:path'
|
|
28
|
+
import { pathToFileURL } from 'node:url'
|
|
29
|
+
|
|
30
|
+
const APPLY = process.argv.includes('--apply')
|
|
31
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
32
|
+
|
|
33
|
+
interface Chat {
|
|
34
|
+
id: number
|
|
35
|
+
sessionId: string
|
|
36
|
+
startedAt: number
|
|
37
|
+
exchangeCount: number
|
|
38
|
+
[k: string]: unknown
|
|
39
|
+
}
|
|
40
|
+
interface Day { date: string; summary: string; chats: Chat[]; archivedAt: string; [k: string]: unknown }
|
|
41
|
+
|
|
42
|
+
function archiveDirPath(): string {
|
|
43
|
+
const base = process.env.COS_DATA_DIR ?? join(homedir(), '.cos-glasses', 'data')
|
|
44
|
+
return resolve(base, 'archive')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Identity of a chat. `startedAt` is its first exchange's timestamp, so it
|
|
48
|
+
* survives re-archiving. `id` does NOT -- it is renumbered on every merge,
|
|
49
|
+
* which is exactly why the old code could never see a duplicate. */
|
|
50
|
+
const keyOf = (c: Chat): string => `${c.sessionId}:${c.startedAt}`
|
|
51
|
+
|
|
52
|
+
/** Collapse duplicates, keeping the most complete copy of each chat. Pure. */
|
|
53
|
+
export function dedupeChats(chats: Chat[]): { kept: Chat[]; removed: number } {
|
|
54
|
+
const byKey = new Map<string, Chat>()
|
|
55
|
+
for (const chat of chats) {
|
|
56
|
+
const prior = byKey.get(keyOf(chat))
|
|
57
|
+
if (!prior || (chat.exchangeCount ?? 0) > (prior.exchangeCount ?? 0)) byKey.set(keyOf(chat), chat)
|
|
58
|
+
}
|
|
59
|
+
const kept = [...byKey.values()].sort((a, b) => a.startedAt - b.startedAt)
|
|
60
|
+
kept.forEach((c, i) => { c.id = i })
|
|
61
|
+
return { kept, removed: chats.length - kept.length }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function atomicWrite(path: string, data: string): void {
|
|
65
|
+
// Inlined rather than imported: see the header note about module-scope effects.
|
|
66
|
+
const tmp = `${path}.repair-tmp`
|
|
67
|
+
writeFileSync(tmp, data, { encoding: 'utf8', mode: 0o600 })
|
|
68
|
+
renameSync(tmp, path)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function main(): void {
|
|
72
|
+
const dir = archiveDirPath()
|
|
73
|
+
if (!existsSync(dir)) {
|
|
74
|
+
console.error(`No archive directory at ${dir}`)
|
|
75
|
+
process.exit(2)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A concurrent appendToArchive would race this rewrite. The in-process archive
|
|
79
|
+
// lock cannot be taken from outside the server, so the only safe answer is to
|
|
80
|
+
// refuse while it is up rather than to hope the window is small.
|
|
81
|
+
// Only the DEFAULT data dir is at risk: that is the one the running server writes
|
|
82
|
+
// to. Pointed at a scratch copy, there is nothing to race, and refusing there
|
|
83
|
+
// would block the very rehearsal this script deserves before it touches real data.
|
|
84
|
+
const isLiveDataDir = process.env.COS_DATA_DIR === undefined
|
|
85
|
+
if (APPLY && isLiveDataDir && serverIsUp()) {
|
|
86
|
+
console.error('The COS server is listening on 127.0.0.1:3141.')
|
|
87
|
+
console.error('Stop it through COS Control before repairing, then re-run.')
|
|
88
|
+
console.error('Refusing to rewrite archive files while the server may write to them.')
|
|
89
|
+
process.exit(3)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const files = readdirSync(dir)
|
|
93
|
+
.filter(f => f.endsWith('.json') && DATE_RE.test(f.slice(0, -5)))
|
|
94
|
+
.sort()
|
|
95
|
+
|
|
96
|
+
let affected = 0
|
|
97
|
+
let chatsBefore = 0
|
|
98
|
+
let chatsAfter = 0
|
|
99
|
+
let bytesBefore = 0
|
|
100
|
+
let bytesAfter = 0
|
|
101
|
+
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
const path = join(dir, file)
|
|
104
|
+
const size = statSync(path).size
|
|
105
|
+
|
|
106
|
+
let day: Day
|
|
107
|
+
try {
|
|
108
|
+
day = JSON.parse(readFileSync(path, 'utf8')) as Day
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.error(` SKIP ${file} — unreadable: ${(err as Error).message.slice(0, 80)}`)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if (!Array.isArray(day.chats) || day.chats.length === 0) continue
|
|
114
|
+
|
|
115
|
+
const { kept, removed } = dedupeChats(day.chats)
|
|
116
|
+
if (removed === 0) continue
|
|
117
|
+
|
|
118
|
+
affected++
|
|
119
|
+
chatsBefore += day.chats.length
|
|
120
|
+
chatsAfter += kept.length
|
|
121
|
+
bytesBefore += size
|
|
122
|
+
|
|
123
|
+
const before = day.chats.length
|
|
124
|
+
day.chats = kept
|
|
125
|
+
const serialised = `${JSON.stringify(day, null, 2)}\n`
|
|
126
|
+
bytesAfter += Buffer.byteLength(serialised, 'utf8')
|
|
127
|
+
|
|
128
|
+
console.log(
|
|
129
|
+
` ${APPLY ? 'REPAIR' : 'would repair'} ${file} ` +
|
|
130
|
+
`${(size / 1e6).toFixed(1)} MB → ${(Buffer.byteLength(serialised, 'utf8') / 1e6).toFixed(1)} MB ` +
|
|
131
|
+
`chats ${before} → ${kept.length} (-${removed})`,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if (APPLY) {
|
|
135
|
+
// Back up BEFORE writing. This is user conversation history; a bad rewrite
|
|
136
|
+
// with no copy is unrecoverable.
|
|
137
|
+
const backup = `${path}.bak-${Date.now()}`
|
|
138
|
+
copyFileSync(path, backup)
|
|
139
|
+
atomicWrite(path, serialised)
|
|
140
|
+
|
|
141
|
+
// Verify by UNIQUE CHAT COUNT, never by file size -- size is the metric the
|
|
142
|
+
// bug distorted, so shrinkage proves nothing about correctness.
|
|
143
|
+
const reread = JSON.parse(readFileSync(path, 'utf8')) as Day
|
|
144
|
+
const uniq = new Set(reread.chats.map(keyOf)).size
|
|
145
|
+
if (reread.chats.length !== kept.length || uniq !== kept.length) {
|
|
146
|
+
console.error(` FAILED verification on ${file}; original preserved at ${backup}`)
|
|
147
|
+
process.exit(4)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const summary = {
|
|
153
|
+
mode: APPLY ? 'applied' : 'dry-run',
|
|
154
|
+
filesScanned: files.length,
|
|
155
|
+
filesAffected: affected,
|
|
156
|
+
chats: { before: chatsBefore, after: chatsAfter, removed: chatsBefore - chatsAfter },
|
|
157
|
+
bytes: { before: bytesBefore, after: bytesAfter, reclaimed: bytesBefore - bytesAfter },
|
|
158
|
+
}
|
|
159
|
+
console.log('')
|
|
160
|
+
console.log(JSON.stringify(summary, null, 2))
|
|
161
|
+
if (!APPLY && affected > 0) {
|
|
162
|
+
console.log('')
|
|
163
|
+
console.log('Nothing was written. Re-run with --apply to repair (each file is backed up first).')
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function serverIsUp(): boolean {
|
|
168
|
+
try {
|
|
169
|
+
const out = execFileSync('/usr/sbin/lsof', ['-ti', ':3141'], { encoding: 'utf8', timeout: 5000 })
|
|
170
|
+
return out.trim().length > 0
|
|
171
|
+
} catch {
|
|
172
|
+
return false // lsof missing or nothing listening — do not block on an inconclusive probe
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Run ONLY when invoked directly. Importing this file (a test, or any tooling)
|
|
177
|
+
// must not execute a repair or call process.exit -- the same module-scope hazard
|
|
178
|
+
// this script refuses to inherit from archive.ts.
|
|
179
|
+
const invokedDirectly = process.argv[1] !== undefined
|
|
180
|
+
&& import.meta.url === pathToFileURL(process.argv[1]).href
|
|
181
|
+
if (invokedDirectly) main()
|
|
@@ -8,9 +8,11 @@ export type CodexModelPreference = 'codex-frontier' | 'codex-balanced'
|
|
|
8
8
|
// even when features.cursor is false so version skew fail-closes instead of
|
|
9
9
|
// silently remapping to Claude.
|
|
10
10
|
export type CursorModelPreference = 'cursor-grok' | 'cursor-composer'
|
|
11
|
+
/** Local Ollama chat slot. Hidden unless the daemon answers on loopback. */
|
|
12
|
+
export type OllamaModelPreference = 'ollama'
|
|
11
13
|
/** Cursor Agent CLI execution posture for glasses queries. */
|
|
12
14
|
export type CursorExecutionMode = 'ask' | 'agent'
|
|
13
|
-
export type ModelPreference = ClaudeModelPreference | CodexModelPreference | CursorModelPreference
|
|
15
|
+
export type ModelPreference = ClaudeModelPreference | CodexModelPreference | CursorModelPreference | OllamaModelPreference
|
|
14
16
|
|
|
15
17
|
/** Invalid/omitted → ask (safe for old clients that don't send a mode). */
|
|
16
18
|
export function normalizeCursorExecutionMode(value: unknown): CursorExecutionMode {
|
|
@@ -25,6 +27,7 @@ export const CODEX_BALANCED_MODEL: CodexModelPreference = 'codex-balanced'
|
|
|
25
27
|
export const CODEX_HIGH_MODEL: CodexModelPreference = CODEX_FRONTIER_MODEL
|
|
26
28
|
export const CURSOR_GROK_MODEL: CursorModelPreference = 'cursor-grok'
|
|
27
29
|
export const CURSOR_COMPOSER_MODEL: CursorModelPreference = 'cursor-composer'
|
|
30
|
+
export const OLLAMA_MODEL: OllamaModelPreference = 'ollama'
|
|
28
31
|
// Existing 6.1–6.3 installs may pin the legacy codex-high slot. Frontier is its
|
|
29
32
|
// migration target; Balanced remains auto-catalog even when this override is set.
|
|
30
33
|
export const CODEX_MODEL_ID = process.env.COS_CODEX_MODEL?.trim() ?? ''
|
|
@@ -49,6 +52,7 @@ export const MODEL_OPTIONS: ModelPreference[] = [
|
|
|
49
52
|
CODEX_BALANCED_MODEL,
|
|
50
53
|
CURSOR_GROK_MODEL,
|
|
51
54
|
CURSOR_COMPOSER_MODEL,
|
|
55
|
+
OLLAMA_MODEL,
|
|
52
56
|
]
|
|
53
57
|
|
|
54
58
|
const MODEL_SET = new Set<ModelPreference>([
|
|
@@ -60,6 +64,7 @@ const MODEL_SET = new Set<ModelPreference>([
|
|
|
60
64
|
CODEX_BALANCED_MODEL,
|
|
61
65
|
CURSOR_GROK_MODEL,
|
|
62
66
|
CURSOR_COMPOSER_MODEL,
|
|
67
|
+
OLLAMA_MODEL,
|
|
63
68
|
])
|
|
64
69
|
|
|
65
70
|
// Bare Claude tier aliases resolve to the newest model in that tier at spawn.
|
|
@@ -149,6 +154,21 @@ export function isCursorModel(model: ModelPreference): model is CursorModelPrefe
|
|
|
149
154
|
return model === CURSOR_GROK_MODEL || model === CURSOR_COMPOSER_MODEL
|
|
150
155
|
}
|
|
151
156
|
|
|
157
|
+
export function isOllamaModel(model: ModelPreference): model is OllamaModelPreference {
|
|
158
|
+
return model === OLLAMA_MODEL
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Picker families. Cursor and Ollama stay hidden until their local probe is ready. */
|
|
162
|
+
export function visibleModelOptions(
|
|
163
|
+
cursorAvailable: boolean,
|
|
164
|
+
ollamaAvailable: boolean,
|
|
165
|
+
): ModelPreference[] {
|
|
166
|
+
return MODEL_OPTIONS.filter(model =>
|
|
167
|
+
(!isCursorModel(model) || cursorAvailable) &&
|
|
168
|
+
(!isOllamaModel(model) || ollamaAvailable),
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
152
172
|
export interface RuntimeCodexModelLabel {
|
|
153
173
|
preference: CodexModelPreference
|
|
154
174
|
displayName: string
|
|
@@ -197,6 +217,7 @@ export function modelLabel(model: ModelPreference): string {
|
|
|
197
217
|
case 'codex-balanced': return runtimeCodexLabels[model] ?? 'GPT Balanced'
|
|
198
218
|
case 'cursor-grok': return runtimeCursorLabels[model] ?? 'Grok Fast'
|
|
199
219
|
case 'cursor-composer': return runtimeCursorLabels[model] ?? 'Composer 2.5 Fast'
|
|
220
|
+
case 'ollama': return 'Ollama'
|
|
200
221
|
case 'opus':
|
|
201
222
|
default:
|
|
202
223
|
return 'Opus'
|
|
@@ -212,6 +233,7 @@ export function modelShortLabel(model: ModelPreference): string {
|
|
|
212
233
|
case 'codex-balanced': return 'GPT Bal'
|
|
213
234
|
case 'cursor-grok': return 'Grok'
|
|
214
235
|
case 'cursor-composer': return 'Composer'
|
|
236
|
+
case 'ollama': return 'Ollama'
|
|
215
237
|
case 'opus':
|
|
216
238
|
default:
|
|
217
239
|
return 'Opus'
|
|
@@ -227,6 +249,7 @@ export function modelButtonLabel(model: ModelPreference): string {
|
|
|
227
249
|
case 'codex-balanced': return 'GPT BAL'
|
|
228
250
|
case 'cursor-grok': return 'GROK'
|
|
229
251
|
case 'cursor-composer': return 'CMP'
|
|
252
|
+
case 'ollama': return 'OLLAMA'
|
|
230
253
|
case 'opus':
|
|
231
254
|
default:
|
|
232
255
|
return 'OPUS'
|
|
@@ -242,6 +265,7 @@ export function modelTag(model: ModelPreference): string {
|
|
|
242
265
|
case 'codex-balanced': return 'GB'
|
|
243
266
|
case 'cursor-grok': return 'GK'
|
|
244
267
|
case 'cursor-composer': return 'C2'
|
|
268
|
+
case 'ollama': return 'OL'
|
|
245
269
|
case 'opus':
|
|
246
270
|
default:
|
|
247
271
|
return 'O'
|