@gotcos/glasses-server 6.2.1 → 6.5.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 +18 -7
- package/CHANGELOG.md +116 -0
- package/README.md +26 -8
- package/bin/cli.cjs +22 -10
- package/package.json +18 -6
- package/server/bin/cos-output-image-publisher.mjs +324 -0
- package/server/bootstrap.ts +16 -0
- package/server/index.ts +65 -15
- package/server/lib/activity-preview.ts +168 -0
- package/server/lib/archive.ts +27 -9
- package/server/lib/claude-bridge.ts +215 -60
- package/server/lib/claude-run-ledger.ts +7 -2
- package/server/lib/codex-bridge.ts +186 -71
- package/server/lib/codex-engine-sessions.ts +24 -2
- package/server/lib/codex-model-catalog.ts +450 -0
- package/server/lib/codex-run-ledger.ts +20 -4
- package/server/lib/conversation.ts +64 -2
- package/server/lib/image-safety.ts +458 -0
- package/server/lib/listener-startup.ts +29 -0
- package/server/lib/media-store.ts +833 -0
- package/server/lib/model-image-input.ts +27 -0
- package/server/lib/model-router.ts +67 -8
- package/server/lib/query-attachments.ts +132 -0
- package/server/lib/run-output-images.ts +442 -0
- package/server/lib/server-instance-lock.ts +122 -0
- package/server/routes/archive.ts +79 -0
- package/server/routes/health.ts +17 -1
- package/server/routes/media.ts +285 -0
- package/server/routes/message-ref.ts +202 -0
- package/server/routes/openai-compat.ts +44 -11
- package/server/routes/query.ts +51 -16
- package/server/routes/sessions.ts +299 -0
- package/shared/media-attachment.ts +126 -0
- package/shared/model-preference.ts +140 -17
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// Runtime Codex model discovery.
|
|
2
|
+
//
|
|
3
|
+
// The client persists stable frontier/balanced slots. This module resolves
|
|
4
|
+
// those slots to concrete model ids through Codex's official `model/list`
|
|
5
|
+
// method. A last-known-good catalog is retained across transient failures;
|
|
6
|
+
// the CLI default is used only when no discovered catalog exists yet.
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'node:child_process'
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { resolve } from 'node:path'
|
|
12
|
+
import {
|
|
13
|
+
CODEX_BALANCED_MODEL,
|
|
14
|
+
CODEX_FRONTIER_MODEL,
|
|
15
|
+
CODEX_MODEL_ID,
|
|
16
|
+
CODEX_SERVICE_TIER,
|
|
17
|
+
resolveConfiguredCodexReasoningEffort,
|
|
18
|
+
resolveCodexReasoningEffort,
|
|
19
|
+
setRuntimeCodexModelLabels,
|
|
20
|
+
type CodexModelPreference,
|
|
21
|
+
type EffortPreference,
|
|
22
|
+
} from '../../shared/model-preference.js'
|
|
23
|
+
|
|
24
|
+
const DEFAULT_REFRESH_TTL_MS = 15 * 60_000
|
|
25
|
+
const DEFAULT_REFRESH_TIMEOUT_MS = 7_000
|
|
26
|
+
const MIN_PERIODIC_REFRESH_MS = 60_000
|
|
27
|
+
|
|
28
|
+
export type CodexCatalogSource = 'app-server' | 'disk-cache' | 'cli-default'
|
|
29
|
+
|
|
30
|
+
export interface CodexCatalogModel {
|
|
31
|
+
id: string
|
|
32
|
+
displayName: string
|
|
33
|
+
description: string
|
|
34
|
+
hidden: boolean
|
|
35
|
+
supportedReasoningEfforts: string[]
|
|
36
|
+
defaultReasoningEffort: string
|
|
37
|
+
serviceTiers: string[]
|
|
38
|
+
isDefault: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CodexModelOption extends CodexCatalogModel {
|
|
42
|
+
preference: CodexModelPreference
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CodexModelCatalog {
|
|
46
|
+
source: CodexCatalogSource
|
|
47
|
+
refreshedAt: string
|
|
48
|
+
autoUpdates: true
|
|
49
|
+
selectionPolicy: 'newest-generation-top-two'
|
|
50
|
+
options: CodexModelOption[]
|
|
51
|
+
refreshError?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type CodexCatalogFetcher = () => Promise<CodexCatalogModel[]>
|
|
55
|
+
|
|
56
|
+
function modelCachePath(): string {
|
|
57
|
+
return resolve(
|
|
58
|
+
process.env.CODEX_HOME?.trim() || resolve(homedir(), '.codex'),
|
|
59
|
+
'models_cache.json',
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatDisplayName(value: string): string {
|
|
64
|
+
return value.replace(/^(GPT-\d+(?:\.\d+)+)-/i, '$1 ').trim()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function versionParts(modelId: string): number[] | null {
|
|
68
|
+
const match = /^gpt-(\d+(?:\.\d+)*)/i.exec(modelId)
|
|
69
|
+
if (!match) return null
|
|
70
|
+
return match[1].split('.').map(Number)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function compareVersionsDesc(a: number[], b: number[]): number {
|
|
74
|
+
const width = Math.max(a.length, b.length)
|
|
75
|
+
for (let i = 0; i < width; i++) {
|
|
76
|
+
const diff = (b[i] ?? 0) - (a[i] ?? 0)
|
|
77
|
+
if (diff !== 0) return diff
|
|
78
|
+
}
|
|
79
|
+
return 0
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sameVersion(a: number[], b: number[]): boolean {
|
|
83
|
+
return compareVersionsDesc(a, b) === 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isCapabilityCandidate(model: CodexCatalogModel): boolean {
|
|
87
|
+
const identity = `${model.id} ${model.displayName}`
|
|
88
|
+
if (/(?:^|[-\s])(mini|nano|spark|review)(?:$|[-\s])/i.test(identity)) return false
|
|
89
|
+
return !/(?:fast and affordable|cost-efficient|small,? fast|ultra-fast)/i.test(model.description)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Select two full-size models from the newest visible GPT generation. */
|
|
93
|
+
export function selectTopCodexModels(models: CodexCatalogModel[]): CodexCatalogModel[] {
|
|
94
|
+
const visible = models.filter(model => !model.hidden && versionParts(model.id))
|
|
95
|
+
if (visible.length === 0) return []
|
|
96
|
+
|
|
97
|
+
const capable = visible.filter(isCapabilityCandidate)
|
|
98
|
+
const pool = capable.length > 0 ? capable : visible
|
|
99
|
+
const versions = pool
|
|
100
|
+
.map(model => versionParts(model.id))
|
|
101
|
+
.filter((parts): parts is number[] => !!parts)
|
|
102
|
+
.sort(compareVersionsDesc)
|
|
103
|
+
const newest = versions[0]
|
|
104
|
+
if (!newest) return pool.slice(0, 2)
|
|
105
|
+
|
|
106
|
+
const selected = pool.filter(model => {
|
|
107
|
+
const parts = versionParts(model.id)
|
|
108
|
+
return !!parts && sameVersion(parts, newest)
|
|
109
|
+
}).slice(0, 2)
|
|
110
|
+
|
|
111
|
+
if (selected.length < 2) {
|
|
112
|
+
const older = pool
|
|
113
|
+
.filter(model => !selected.includes(model))
|
|
114
|
+
.map((model, index) => ({ model, version: versionParts(model.id) ?? [], index }))
|
|
115
|
+
.sort((a, b) => compareVersionsDesc(a.version, b.version) || a.index - b.index)
|
|
116
|
+
for (const entry of older) {
|
|
117
|
+
selected.push(entry.model)
|
|
118
|
+
if (selected.length === 2) break
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return selected
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function buildCodexModelCatalog(
|
|
126
|
+
models: CodexCatalogModel[],
|
|
127
|
+
source: CodexCatalogSource,
|
|
128
|
+
refreshedAt = new Date().toISOString(),
|
|
129
|
+
): CodexModelCatalog {
|
|
130
|
+
const selected = selectTopCodexModels(models)
|
|
131
|
+
const slots: CodexModelPreference[] = [CODEX_FRONTIER_MODEL, CODEX_BALANCED_MODEL]
|
|
132
|
+
const options = selected.map((model, index): CodexModelOption => ({
|
|
133
|
+
...model,
|
|
134
|
+
displayName: formatDisplayName(model.displayName || model.id),
|
|
135
|
+
preference: slots[index],
|
|
136
|
+
}))
|
|
137
|
+
|
|
138
|
+
setRuntimeCodexModelLabels(options)
|
|
139
|
+
return {
|
|
140
|
+
source,
|
|
141
|
+
refreshedAt,
|
|
142
|
+
autoUpdates: true,
|
|
143
|
+
selectionPolicy: 'newest-generation-top-two',
|
|
144
|
+
options,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function cliDefaultCatalog(refreshError?: string): CodexModelCatalog {
|
|
149
|
+
const common = {
|
|
150
|
+
id: '',
|
|
151
|
+
description: 'Uses the current Codex CLI default until model discovery is available.',
|
|
152
|
+
hidden: false,
|
|
153
|
+
supportedReasoningEfforts: ['high', 'xhigh'],
|
|
154
|
+
defaultReasoningEffort: 'high',
|
|
155
|
+
serviceTiers: [] as string[],
|
|
156
|
+
isDefault: true,
|
|
157
|
+
}
|
|
158
|
+
const catalog: CodexModelCatalog = {
|
|
159
|
+
source: 'cli-default',
|
|
160
|
+
refreshedAt: new Date().toISOString(),
|
|
161
|
+
autoUpdates: true,
|
|
162
|
+
selectionPolicy: 'newest-generation-top-two',
|
|
163
|
+
options: [
|
|
164
|
+
{ ...common, preference: CODEX_FRONTIER_MODEL, displayName: 'GPT Frontier (auto)' },
|
|
165
|
+
{ ...common, preference: CODEX_BALANCED_MODEL, displayName: 'GPT Balanced (auto)', isDefault: false },
|
|
166
|
+
],
|
|
167
|
+
...(refreshError ? { refreshError } : {}),
|
|
168
|
+
}
|
|
169
|
+
setRuntimeCodexModelLabels(catalog.options)
|
|
170
|
+
return catalog
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizeAppServerModel(raw: any): CodexCatalogModel | null {
|
|
174
|
+
const id = typeof raw?.model === 'string' ? raw.model : typeof raw?.id === 'string' ? raw.id : ''
|
|
175
|
+
if (!id) return null
|
|
176
|
+
const efforts = Array.isArray(raw?.supportedReasoningEfforts)
|
|
177
|
+
? raw.supportedReasoningEfforts
|
|
178
|
+
.map((item: any) => typeof item === 'string' ? item : item?.reasoningEffort)
|
|
179
|
+
.filter((value: unknown): value is string => typeof value === 'string' && !!value)
|
|
180
|
+
: []
|
|
181
|
+
const serviceTiers = Array.isArray(raw?.serviceTiers)
|
|
182
|
+
? raw.serviceTiers
|
|
183
|
+
.map((item: any) => typeof item === 'string' ? item : item?.id)
|
|
184
|
+
.filter((value: unknown): value is string => typeof value === 'string' && !!value)
|
|
185
|
+
: []
|
|
186
|
+
return {
|
|
187
|
+
id,
|
|
188
|
+
displayName: typeof raw?.displayName === 'string' ? raw.displayName : id,
|
|
189
|
+
description: typeof raw?.description === 'string' ? raw.description : '',
|
|
190
|
+
hidden: raw?.hidden === true,
|
|
191
|
+
supportedReasoningEfforts: efforts,
|
|
192
|
+
defaultReasoningEffort: typeof raw?.defaultReasoningEffort === 'string' ? raw.defaultReasoningEffort : 'high',
|
|
193
|
+
serviceTiers,
|
|
194
|
+
isDefault: raw?.isDefault === true,
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function readDiskCatalog(): CodexModelCatalog | null {
|
|
199
|
+
const path = modelCachePath()
|
|
200
|
+
if (!existsSync(path)) return null
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
203
|
+
const models = Array.isArray(parsed?.models)
|
|
204
|
+
? parsed.models.map((raw: any): CodexCatalogModel | null => {
|
|
205
|
+
const id = typeof raw?.slug === 'string' ? raw.slug : ''
|
|
206
|
+
if (!id) return null
|
|
207
|
+
return {
|
|
208
|
+
id,
|
|
209
|
+
displayName: typeof raw?.display_name === 'string' ? raw.display_name : id,
|
|
210
|
+
description: typeof raw?.description === 'string' ? raw.description : '',
|
|
211
|
+
hidden: raw?.visibility === 'hide',
|
|
212
|
+
supportedReasoningEfforts: Array.isArray(raw?.supported_reasoning_levels)
|
|
213
|
+
? raw.supported_reasoning_levels
|
|
214
|
+
.map((item: any) => item?.effort)
|
|
215
|
+
.filter((value: unknown): value is string => typeof value === 'string' && !!value)
|
|
216
|
+
: [],
|
|
217
|
+
defaultReasoningEffort: typeof raw?.default_reasoning_level === 'string'
|
|
218
|
+
? raw.default_reasoning_level
|
|
219
|
+
: 'high',
|
|
220
|
+
serviceTiers: Array.isArray(raw?.service_tiers)
|
|
221
|
+
? raw.service_tiers
|
|
222
|
+
.map((item: any) => item?.id)
|
|
223
|
+
.filter((value: unknown): value is string => typeof value === 'string' && !!value)
|
|
224
|
+
: [],
|
|
225
|
+
isDefault: raw?.is_default === true,
|
|
226
|
+
}
|
|
227
|
+
}).filter((model: CodexCatalogModel | null): model is CodexCatalogModel => !!model)
|
|
228
|
+
: []
|
|
229
|
+
if (models.length === 0) return null
|
|
230
|
+
const refreshedAt = typeof parsed?.fetched_at === 'string' ? parsed.fetched_at : new Date().toISOString()
|
|
231
|
+
return buildCodexModelCatalog(models, 'disk-cache', refreshedAt)
|
|
232
|
+
} catch {
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function refreshTimeoutMs(): number {
|
|
238
|
+
const raw = Number(process.env.COS_CODEX_MODEL_REFRESH_TIMEOUT_MS ?? DEFAULT_REFRESH_TIMEOUT_MS)
|
|
239
|
+
return Number.isFinite(raw) && raw >= 1_000 ? Math.floor(raw) : DEFAULT_REFRESH_TIMEOUT_MS
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function refreshTtlMs(): number {
|
|
243
|
+
const raw = Number(process.env.COS_CODEX_MODEL_REFRESH_TTL_MS ?? DEFAULT_REFRESH_TTL_MS)
|
|
244
|
+
return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : DEFAULT_REFRESH_TTL_MS
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function fetchAppServerModels(): Promise<CodexCatalogModel[]> {
|
|
248
|
+
return new Promise((resolveModels, reject) => {
|
|
249
|
+
const env = { ...process.env }
|
|
250
|
+
delete env.CLAUDECODE
|
|
251
|
+
const child = spawn('codex', ['app-server', '--stdio'], {
|
|
252
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
253
|
+
env,
|
|
254
|
+
})
|
|
255
|
+
let settled = false
|
|
256
|
+
let stdoutBuffer = ''
|
|
257
|
+
let stderr = ''
|
|
258
|
+
|
|
259
|
+
const finish = (err?: Error, models?: CodexCatalogModel[]) => {
|
|
260
|
+
if (settled) return
|
|
261
|
+
settled = true
|
|
262
|
+
clearTimeout(timer)
|
|
263
|
+
try { child.stdin.end() } catch { /* ignore */ }
|
|
264
|
+
try { child.kill() } catch { /* ignore */ }
|
|
265
|
+
if (err) reject(err)
|
|
266
|
+
else resolveModels(models ?? [])
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const timer = setTimeout(() => finish(new Error('Codex model discovery timed out')), refreshTimeoutMs())
|
|
270
|
+
|
|
271
|
+
child.on('error', err => finish(err))
|
|
272
|
+
child.on('close', code => {
|
|
273
|
+
if (!settled) finish(new Error(`Codex model discovery exited ${code}: ${stderr.slice(0, 160)}`))
|
|
274
|
+
})
|
|
275
|
+
child.stderr.on('data', chunk => {
|
|
276
|
+
stderr = (stderr + String(chunk)).slice(-1_000)
|
|
277
|
+
})
|
|
278
|
+
child.stdout.on('data', chunk => {
|
|
279
|
+
stdoutBuffer += String(chunk)
|
|
280
|
+
for (;;) {
|
|
281
|
+
const newline = stdoutBuffer.indexOf('\n')
|
|
282
|
+
if (newline < 0) break
|
|
283
|
+
const line = stdoutBuffer.slice(0, newline).trim()
|
|
284
|
+
stdoutBuffer = stdoutBuffer.slice(newline + 1)
|
|
285
|
+
if (!line) continue
|
|
286
|
+
try {
|
|
287
|
+
const message = JSON.parse(line)
|
|
288
|
+
if (message?.id !== 2) continue
|
|
289
|
+
if (message?.error) {
|
|
290
|
+
finish(new Error('Codex model/list failed'))
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
const models = Array.isArray(message?.result?.data)
|
|
294
|
+
? message.result.data
|
|
295
|
+
.map(normalizeAppServerModel)
|
|
296
|
+
.filter((model: CodexCatalogModel | null): model is CodexCatalogModel => !!model)
|
|
297
|
+
: []
|
|
298
|
+
if (models.length === 0) {
|
|
299
|
+
finish(new Error('Codex model/list returned no models'))
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
finish(undefined, models)
|
|
303
|
+
return
|
|
304
|
+
} catch {
|
|
305
|
+
// Notifications and partial/non-JSON diagnostics are irrelevant.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
child.stdin.write(JSON.stringify({
|
|
311
|
+
id: 1,
|
|
312
|
+
method: 'initialize',
|
|
313
|
+
params: {
|
|
314
|
+
clientInfo: { name: 'cos-glasses-server', version: '6.4.0' },
|
|
315
|
+
capabilities: { experimentalApi: false },
|
|
316
|
+
},
|
|
317
|
+
}) + '\n')
|
|
318
|
+
child.stdin.write(JSON.stringify({
|
|
319
|
+
id: 2,
|
|
320
|
+
method: 'model/list',
|
|
321
|
+
params: { limit: 50, includeHidden: false },
|
|
322
|
+
}) + '\n')
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let catalogSnapshot = readDiskCatalog() ?? cliDefaultCatalog()
|
|
327
|
+
let refreshPromise: Promise<CodexModelCatalog> | null = null
|
|
328
|
+
let periodicRefreshTimer: ReturnType<typeof setInterval> | null = null
|
|
329
|
+
|
|
330
|
+
export function getCodexModelCatalogSnapshot(): CodexModelCatalog {
|
|
331
|
+
return catalogSnapshot
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Refresh with an injectable fetcher for alternate runtimes and tests. */
|
|
335
|
+
export async function refreshCodexModelCatalog(
|
|
336
|
+
fetcher: CodexCatalogFetcher = fetchAppServerModels,
|
|
337
|
+
): Promise<CodexModelCatalog> {
|
|
338
|
+
try {
|
|
339
|
+
const models = await fetcher()
|
|
340
|
+
const refreshed = buildCodexModelCatalog(models, 'app-server')
|
|
341
|
+
if (refreshed.options.length === 0) throw new Error('No eligible GPT models')
|
|
342
|
+
catalogSnapshot = refreshed
|
|
343
|
+
} catch {
|
|
344
|
+
// Never downgrade a working app-server/disk catalog because one refresh
|
|
345
|
+
// failed. A fresh disk read is useful only when there is no known model id.
|
|
346
|
+
const hasKnownModel = catalogSnapshot.options.some(option => !!option.id)
|
|
347
|
+
const fallback = hasKnownModel ? catalogSnapshot : readDiskCatalog()
|
|
348
|
+
if (fallback) {
|
|
349
|
+
catalogSnapshot = {
|
|
350
|
+
...fallback,
|
|
351
|
+
refreshError: 'Live Codex model discovery unavailable; retaining the last-known-good catalog.',
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
catalogSnapshot = cliDefaultCatalog('Live Codex model discovery unavailable; using the Codex CLI default.')
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return catalogSnapshot
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function getCodexModelCatalog(
|
|
361
|
+
forceRefresh = false,
|
|
362
|
+
fetcher?: CodexCatalogFetcher,
|
|
363
|
+
): Promise<CodexModelCatalog> {
|
|
364
|
+
const parsedRefreshedAt = Date.parse(catalogSnapshot.refreshedAt)
|
|
365
|
+
const ageMs = Number.isFinite(parsedRefreshedAt) ? Date.now() - parsedRefreshedAt : Number.POSITIVE_INFINITY
|
|
366
|
+
if (!forceRefresh && catalogSnapshot.source === 'app-server' && ageMs < refreshTtlMs()) {
|
|
367
|
+
return catalogSnapshot
|
|
368
|
+
}
|
|
369
|
+
if (refreshPromise) return refreshPromise
|
|
370
|
+
|
|
371
|
+
refreshPromise = refreshCodexModelCatalog(fetcher).finally(() => {
|
|
372
|
+
refreshPromise = null
|
|
373
|
+
})
|
|
374
|
+
return refreshPromise
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Start boot + periodic refresh. Idempotent; returned stop function aids tests. */
|
|
378
|
+
export function startCodexModelCatalogRefresh(options?: {
|
|
379
|
+
intervalMs?: number
|
|
380
|
+
refresh?: () => Promise<unknown>
|
|
381
|
+
}): () => void {
|
|
382
|
+
if (periodicRefreshTimer) return () => stopCodexModelCatalogRefresh()
|
|
383
|
+
const refresh = options?.refresh ?? (() => getCodexModelCatalog(true))
|
|
384
|
+
const configuredInterval = options?.intervalMs ?? refreshTtlMs()
|
|
385
|
+
const intervalMs = Math.max(MIN_PERIODIC_REFRESH_MS, configuredInterval)
|
|
386
|
+
|
|
387
|
+
void refresh().catch(() => { /* refresh function retains its own fallback */ })
|
|
388
|
+
periodicRefreshTimer = setInterval(() => {
|
|
389
|
+
void refresh().catch(() => { /* keep the prior catalog */ })
|
|
390
|
+
}, intervalMs)
|
|
391
|
+
periodicRefreshTimer.unref?.()
|
|
392
|
+
return () => stopCodexModelCatalogRefresh()
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function stopCodexModelCatalogRefresh(): void {
|
|
396
|
+
if (!periodicRefreshTimer) return
|
|
397
|
+
clearInterval(periodicRefreshTimer)
|
|
398
|
+
periodicRefreshTimer = null
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function resolveCodexModelOption(preference: CodexModelPreference): CodexModelOption {
|
|
402
|
+
const base = catalogSnapshot.options.find(option => option.preference === preference)
|
|
403
|
+
?? catalogSnapshot.options[0]
|
|
404
|
+
?? cliDefaultCatalog().options[0]
|
|
405
|
+
const configuredId = process.env.COS_CODEX_MODEL?.trim() || CODEX_MODEL_ID
|
|
406
|
+
if (preference === CODEX_FRONTIER_MODEL && configuredId) {
|
|
407
|
+
return {
|
|
408
|
+
...base,
|
|
409
|
+
preference,
|
|
410
|
+
id: configuredId,
|
|
411
|
+
displayName: configuredId,
|
|
412
|
+
description: 'Explicit COS_CODEX_MODEL compatibility override for the legacy/frontier slot.',
|
|
413
|
+
supportedReasoningEfforts: [],
|
|
414
|
+
defaultReasoningEffort: resolveConfiguredCodexReasoningEffort(),
|
|
415
|
+
serviceTiers: [],
|
|
416
|
+
isDefault: true,
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return base
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function resolveCodexPreferenceForModelId(modelId: string): CodexModelPreference | undefined {
|
|
423
|
+
const normalized = modelId.trim().toLowerCase()
|
|
424
|
+
return catalogSnapshot.options.find(option => option.id.toLowerCase() === normalized)?.preference
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function resolveCodexEffortForModel(
|
|
428
|
+
option: CodexModelOption,
|
|
429
|
+
effort: EffortPreference | undefined,
|
|
430
|
+
): string {
|
|
431
|
+
const requested = effort === undefined && option.preference === CODEX_FRONTIER_MODEL
|
|
432
|
+
? resolveConfiguredCodexReasoningEffort()
|
|
433
|
+
: resolveCodexReasoningEffort(effort)
|
|
434
|
+
const supported = new Set(option.supportedReasoningEfforts)
|
|
435
|
+
if (supported.size === 0 || supported.has(requested)) return requested
|
|
436
|
+
|
|
437
|
+
const fallbacks: Record<string, string[]> = {
|
|
438
|
+
ultra: ['max', 'xhigh', 'high', 'medium', 'low'],
|
|
439
|
+
max: ['xhigh', 'high', 'medium', 'low'],
|
|
440
|
+
xhigh: ['high', 'medium', 'low'],
|
|
441
|
+
high: ['medium', 'low'],
|
|
442
|
+
}
|
|
443
|
+
return (fallbacks[requested] ?? []).find(candidate => supported.has(candidate))
|
|
444
|
+
?? option.defaultReasoningEffort
|
|
445
|
+
?? 'high'
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function resolveCodexServiceTier(option: CodexModelOption): string | undefined {
|
|
449
|
+
return option.serviceTiers.includes(CODEX_SERVICE_TIER) ? CODEX_SERVICE_TIER : undefined
|
|
450
|
+
}
|
|
@@ -5,11 +5,15 @@ import { COS_SCRIPTS_DIR } from './python-bridge.js'
|
|
|
5
5
|
import { cosBrainDir } from './launch-dir.js'
|
|
6
6
|
import { CODEX_ENGINE_SESSION_TTL_MS, type CodexTrustMode } from './codex-engine-sessions.js'
|
|
7
7
|
import {
|
|
8
|
+
CODEX_FRONTIER_MODEL,
|
|
8
9
|
CODEX_HIGH_REASONING_EFFORT,
|
|
9
|
-
CODEX_MODEL_ID,
|
|
10
10
|
type CodexModelPreference,
|
|
11
11
|
} from '../../shared/model-preference.js'
|
|
12
12
|
import { dataPath } from './data-dir.js'
|
|
13
|
+
import {
|
|
14
|
+
getCodexModelCatalogSnapshot,
|
|
15
|
+
resolveCodexModelOption,
|
|
16
|
+
} from './codex-model-catalog.js'
|
|
13
17
|
|
|
14
18
|
const DEFAULT_MAX_RUNS = 100
|
|
15
19
|
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60_000
|
|
@@ -59,6 +63,8 @@ interface CodexRunEvent {
|
|
|
59
63
|
|
|
60
64
|
export interface CodexRunConfig {
|
|
61
65
|
cliModel: string
|
|
66
|
+
catalogSource: string
|
|
67
|
+
availableModels: Array<{ preference: CodexModelPreference; model: string; displayName: string }>
|
|
62
68
|
reasoningEffort: string
|
|
63
69
|
persistenceEnabled: boolean
|
|
64
70
|
cwd: string
|
|
@@ -95,8 +101,16 @@ export function getCodexTrustMode(): CodexTrustMode {
|
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
export function getCodexRunConfig(): CodexRunConfig {
|
|
104
|
+
const catalog = getCodexModelCatalogSnapshot()
|
|
105
|
+
const frontier = resolveCodexModelOption(CODEX_FRONTIER_MODEL)
|
|
98
106
|
return {
|
|
99
|
-
cliModel:
|
|
107
|
+
cliModel: frontier.id || 'codex-cli-default',
|
|
108
|
+
catalogSource: catalog.source,
|
|
109
|
+
availableModels: catalog.options.map(option => ({
|
|
110
|
+
preference: option.preference,
|
|
111
|
+
model: option.id || 'codex-cli-default',
|
|
112
|
+
displayName: option.displayName,
|
|
113
|
+
})),
|
|
100
114
|
reasoningEffort: CODEX_HIGH_REASONING_EFFORT,
|
|
101
115
|
persistenceEnabled: isCodexPersistenceEnabled(),
|
|
102
116
|
cwd: getCodexExecutionCwd(),
|
|
@@ -233,6 +247,8 @@ export function startCodexRun(input: {
|
|
|
233
247
|
codexThreadId?: string
|
|
234
248
|
expiresAt?: string
|
|
235
249
|
query: string
|
|
250
|
+
cliModel?: string
|
|
251
|
+
reasoningEffort?: string
|
|
236
252
|
}): CodexRunRecord {
|
|
237
253
|
const now = new Date().toISOString()
|
|
238
254
|
const run: CodexRunRecord = {
|
|
@@ -242,8 +258,8 @@ export function startCodexRun(input: {
|
|
|
242
258
|
createdAt: now,
|
|
243
259
|
updatedAt: now,
|
|
244
260
|
model: input.model,
|
|
245
|
-
cliModel:
|
|
246
|
-
reasoningEffort: CODEX_HIGH_REASONING_EFFORT,
|
|
261
|
+
cliModel: input.cliModel ?? (resolveCodexModelOption(input.model).id || 'codex-cli-default'),
|
|
262
|
+
reasoningEffort: input.reasoningEffort ?? CODEX_HIGH_REASONING_EFFORT,
|
|
247
263
|
cwd: input.cwd,
|
|
248
264
|
ephemeral: input.ephemeral,
|
|
249
265
|
resumed: input.resumed,
|
|
@@ -13,6 +13,7 @@ import { logSessionEnd, buildSessionLogEntry, writeSessionLog } from './session-
|
|
|
13
13
|
import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
|
|
14
14
|
import { localDay } from './local-day.js'
|
|
15
15
|
import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
|
|
16
|
+
import { parseMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
|
|
16
17
|
|
|
17
18
|
export type { ModelPreference }
|
|
18
19
|
|
|
@@ -21,6 +22,10 @@ export interface Exchange {
|
|
|
21
22
|
content: string
|
|
22
23
|
timestamp: number
|
|
23
24
|
globalMsgNum?: number // Client's global message number for this Q&A pair
|
|
25
|
+
/** Public attachment refs may live on either half of a Q&A pair: request
|
|
26
|
+
* photos on the user exchange, model-published images on the assistant
|
|
27
|
+
* exchange. Bytes, filesystem paths, and capabilities never persist here. */
|
|
28
|
+
attachments?: MediaAttachmentRef[]
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
interface Session {
|
|
@@ -73,6 +78,15 @@ function loadFromDisk(): void {
|
|
|
73
78
|
for (const [id, session] of Object.entries(result.data.sessions)) {
|
|
74
79
|
if (!session.contextBreaks) session.contextBreaks = []
|
|
75
80
|
session.modelPreference = normalizeModelPreference(session.modelPreference) ?? null
|
|
81
|
+
// Persistence boundary: malformed refs are dropped without sacrificing
|
|
82
|
+
// the surrounding exchange or the rest of the recovered session.
|
|
83
|
+
for (const exchange of session.exchanges) {
|
|
84
|
+
if ('attachments' in exchange && exchange.attachments !== undefined) {
|
|
85
|
+
const refs = parseMediaAttachmentRefs(exchange.attachments)
|
|
86
|
+
if (refs.length > 0) exchange.attachments = refs
|
|
87
|
+
else delete exchange.attachments
|
|
88
|
+
}
|
|
89
|
+
}
|
|
76
90
|
sessions.set(id, session)
|
|
77
91
|
loaded++
|
|
78
92
|
}
|
|
@@ -296,14 +310,27 @@ export function replaceLastExchangeWithSummary(
|
|
|
296
310
|
scheduleCacheUpdate()
|
|
297
311
|
}
|
|
298
312
|
|
|
299
|
-
export function addExchange(
|
|
313
|
+
export function addExchange(
|
|
314
|
+
sessionId: string,
|
|
315
|
+
role: 'user' | 'assistant',
|
|
316
|
+
content: string,
|
|
317
|
+
globalMsgNum?: number,
|
|
318
|
+
attachments?: MediaAttachmentRef[],
|
|
319
|
+
): Exchange {
|
|
300
320
|
let session = sessions.get(sessionId)
|
|
301
321
|
if (!session) {
|
|
302
322
|
session = { id: sessionId, exchanges: [], lastActivity: Date.now(), createdAt: Date.now(), modelPreference: null, contextBreaks: [] }
|
|
303
323
|
sessions.set(sessionId, session)
|
|
304
324
|
}
|
|
305
325
|
|
|
306
|
-
|
|
326
|
+
const exchange: Exchange = {
|
|
327
|
+
role,
|
|
328
|
+
content,
|
|
329
|
+
timestamp: Date.now(),
|
|
330
|
+
globalMsgNum,
|
|
331
|
+
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
|
332
|
+
}
|
|
333
|
+
session.exchanges.push(exchange)
|
|
307
334
|
session.lastActivity = Date.now()
|
|
308
335
|
|
|
309
336
|
// Trim to rolling buffer
|
|
@@ -313,6 +340,41 @@ export function addExchange(sessionId: string, role: 'user' | 'assistant', conte
|
|
|
313
340
|
|
|
314
341
|
scheduleSave()
|
|
315
342
|
scheduleCacheUpdate()
|
|
343
|
+
return exchange
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Attach validated refs to an already-persisted exchange. Bridges save the
|
|
347
|
+
* assistant text first, then use this after slower output-image preparation.
|
|
348
|
+
* Object identity prevents a concurrent turn from receiving the refs. */
|
|
349
|
+
export function setExchangeAttachments(
|
|
350
|
+
sessionId: string,
|
|
351
|
+
exchange: Exchange,
|
|
352
|
+
attachments: unknown,
|
|
353
|
+
): boolean {
|
|
354
|
+
const session = sessions.get(sessionId)
|
|
355
|
+
if (!session || !session.exchanges.includes(exchange)) return false
|
|
356
|
+
const refs = parseMediaAttachmentRefs(attachments)
|
|
357
|
+
if (refs.length > 0) exchange.attachments = refs
|
|
358
|
+
else delete exchange.attachments
|
|
359
|
+
session.lastActivity = Date.now()
|
|
360
|
+
scheduleSave()
|
|
361
|
+
scheduleCacheUpdate()
|
|
362
|
+
return true
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Remove exactly the exchange object returned by addExchange.
|
|
366
|
+
* Identity matching prevents a failed duplicate prompt from deleting an older,
|
|
367
|
+
* byte-identical turn in the same conversation. */
|
|
368
|
+
export function removeExchange(sessionId: string, exchange: Exchange): boolean {
|
|
369
|
+
const session = sessions.get(sessionId)
|
|
370
|
+
if (!session) return false
|
|
371
|
+
const index = session.exchanges.indexOf(exchange)
|
|
372
|
+
if (index < 0) return false
|
|
373
|
+
session.exchanges.splice(index, 1)
|
|
374
|
+
session.lastActivity = Date.now()
|
|
375
|
+
scheduleSave()
|
|
376
|
+
scheduleCacheUpdate()
|
|
377
|
+
return true
|
|
316
378
|
}
|
|
317
379
|
|
|
318
380
|
/** Clear a session — archive + log BEFORE deleting from the live Map.
|