@gotcos/glasses-server 6.3.1 → 6.6.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.
Files changed (37) hide show
  1. package/.env.example +23 -7
  2. package/CHANGELOG.md +108 -0
  3. package/README.md +28 -8
  4. package/bin/cli.cjs +22 -10
  5. package/package.json +18 -6
  6. package/server/bin/cos-output-image-publisher.mjs +324 -0
  7. package/server/bootstrap.ts +16 -0
  8. package/server/index.ts +61 -21
  9. package/server/lib/activity-preview.ts +168 -0
  10. package/server/lib/archive.ts +20 -6
  11. package/server/lib/claude-bridge.ts +215 -60
  12. package/server/lib/claude-run-ledger.ts +7 -2
  13. package/server/lib/codex-bridge.ts +186 -71
  14. package/server/lib/codex-engine-sessions.ts +24 -2
  15. package/server/lib/codex-model-catalog.ts +450 -0
  16. package/server/lib/codex-run-ledger.ts +20 -4
  17. package/server/lib/conversation.ts +64 -2
  18. package/server/lib/display-bus.ts +61 -3
  19. package/server/lib/image-safety.ts +458 -0
  20. package/server/lib/listener-startup.ts +29 -0
  21. package/server/lib/media-store.ts +833 -0
  22. package/server/lib/model-image-input.ts +27 -0
  23. package/server/lib/model-router.ts +67 -8
  24. package/server/lib/query-attachments.ts +132 -0
  25. package/server/lib/run-output-images.ts +442 -0
  26. package/server/lib/server-instance-id.ts +55 -0
  27. package/server/lib/server-instance-lock.ts +122 -0
  28. package/server/lib/server-metrics.ts +7 -0
  29. package/server/routes/display.ts +43 -22
  30. package/server/routes/health.ts +19 -2
  31. package/server/routes/media.ts +285 -0
  32. package/server/routes/message-ref.ts +18 -6
  33. package/server/routes/openai-compat.ts +44 -11
  34. package/server/routes/query.ts +51 -16
  35. package/server/routes/sessions.ts +33 -4
  36. package/shared/media-attachment.ts +126 -0
  37. package/shared/model-preference.ts +140 -17
@@ -5,7 +5,10 @@ import { Router } from 'express'
5
5
  import { callModelStreaming } from '../lib/model-router.js'
6
6
  import { emitDisplay } from '../lib/display-bus.js'
7
7
  import { errMsg } from '../lib/utils.js'
8
- import { normalizeModelPreference } from '../../shared/model-preference.js'
8
+ import { normalizeEffortPreference, normalizeModelPreference } from '../../shared/model-preference.js'
9
+ import { QueryAttachmentError, resolveQueryAttachments } from '../lib/query-attachments.js'
10
+ import { getMediaStore } from '../lib/media-store.js'
11
+ import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
9
12
 
10
13
  const TOOL_STATUS_MESSAGES: Record<string, string> = {
11
14
  WebSearch: 'Searching web...',
@@ -16,28 +19,35 @@ const TOOL_STATUS_MESSAGES: Record<string, string> = {
16
19
  export const queryRouter = Router()
17
20
 
18
21
  queryRouter.post('/query', async (req, res) => {
19
- const { query, sessionId, model, image, images, reference, globalMsgNum } = req.body
22
+ const { query, sessionId, model, effort, reference, globalMsgNum } = req.body
23
+ const activityToolMode = req.body.activityToolMode === 'off' || req.body.activityToolMode === 'preview'
24
+ ? req.body.activityToolMode
25
+ : 'status'
20
26
 
21
- // Normalize: accept `images` array or legacy `image` string
22
- let validImages: string[] | undefined
23
- if (Array.isArray(images) && images.length > 0) {
24
- // Filter to valid non-empty strings, cap at 5
25
- validImages = images.filter((img: unknown) => typeof img === 'string' && img.length > 0).slice(0, 5)
26
- if (validImages.length === 0) validImages = undefined
27
- } else if (typeof image === 'string' && image.length > 0) {
28
- // Backward compat: wrap single image as array
29
- validImages = [image]
27
+ // Resolve durable attachment ids and legacy base64 images through one
28
+ // validation/normalization path before opening SSE.
29
+ let resolvedAttachments
30
+ try {
31
+ resolvedAttachments = await resolveQueryAttachments(req.body)
32
+ } catch (err) {
33
+ if (err instanceof QueryAttachmentError) {
34
+ return res.status(err.status).json({ error: err.code, detail: err.message })
35
+ }
36
+ return res.status(500).json({ error: errMsg(err) })
30
37
  }
38
+ const imageInputs = resolvedAttachments.inputs.length > 0 ? resolvedAttachments.inputs : undefined
39
+ const attachmentRefs = resolvedAttachments.refs
31
40
 
32
41
  const resolvedQuery = typeof query === 'string' ? query : ''
33
42
 
34
43
  // Vision queries can have an empty query (default to "describe what you see")
35
- if ((!resolvedQuery || typeof resolvedQuery !== 'string') && !validImages) {
44
+ if ((!resolvedQuery || typeof resolvedQuery !== 'string') && !imageInputs) {
36
45
  return res.status(400).json({ error: 'query string or image required' })
37
46
  }
38
47
 
39
48
  // Validate model if provided
40
49
  const validModel = normalizeModelPreference(model)
50
+ const validEffort = normalizeEffortPreference(effort)
41
51
 
42
52
  // Validate globalMsgNum if provided
43
53
  const validGlobalMsgNum = typeof globalMsgNum === 'number' && globalMsgNum > 0
@@ -80,15 +90,40 @@ queryRouter.post('/query', async (req, res) => {
80
90
  },
81
91
  onToolStatus: (toolName) => {
82
92
  if (!done) {
83
- const message = TOOL_STATUS_MESSAGES[toolName] ?? (/\s|\.{3}$/.test(toolName) ? toolName : `Using ${toolName}...`)
93
+ const message = activityToolMode === 'off'
94
+ ? 'Processing...'
95
+ : TOOL_STATUS_MESSAGES[toolName] ?? (/\s|\.{3}$/.test(toolName) ? toolName : `Using ${toolName}...`)
84
96
  res.write(`event: tool_status\ndata: ${JSON.stringify({ message })}\n\n`)
85
97
  emitDisplay({ type: 'tool_status', data: { message } })
86
98
  }
87
99
  },
100
+ // Activity lines stay on this authenticated request stream. The global
101
+ // display stream is intentionally unauthenticated for Even Hub recovery,
102
+ // so observable command/output text must never be broadcast there.
103
+ ...(activityToolMode === 'preview' ? {
104
+ onActivityLine: (line: { kind: 'input' | 'output'; text: string }) => {
105
+ if (!done) {
106
+ res.write(`event: activity_line\ndata: ${JSON.stringify(line)}\n\n`)
107
+ }
108
+ },
109
+ } : {}),
88
110
  onDone: (fullText, model, cliSessionId, metadata) => {
111
+ // Durable association is independent of the SSE socket. Backgrounding
112
+ // the phone cannot leave request media reserved until expiry.
113
+ if (resolvedAttachments.ids.length > 0) {
114
+ getMediaStore().associate(resolvedAttachments.ids, {
115
+ sessionId: sid,
116
+ ...(validGlobalMsgNum ? { globalMsgNum: validGlobalMsgNum } : {}),
117
+ }).catch((err) => console.error('[query] attachment association failed:', err))
118
+ }
89
119
  if (!done) {
90
120
  done = true
91
- const payload = { text: fullText, sessionId: sid, model, cliSessionId, ...metadata }
121
+ const attachments = mergeMediaAttachmentRefs(attachmentRefs, metadata?.outputAttachments)
122
+ const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
123
+ const payload = {
124
+ text: fullText, sessionId: sid, model, cliSessionId, ...runMetadata,
125
+ ...(attachments.length > 0 ? { attachments } : {}),
126
+ }
92
127
  res.write(`event: done\ndata: ${JSON.stringify(payload)}\n\n`)
93
128
  emitDisplay({ type: 'done', data: payload })
94
129
  res.end()
@@ -102,13 +137,13 @@ queryRouter.post('/query', async (req, res) => {
102
137
  res.end()
103
138
  }
104
139
  },
105
- }, validModel, validImages,
140
+ }, validModel, imageInputs,
106
141
  // Pass reference if provided (for "recall message N" feature)
107
142
  reference && typeof reference === 'object' && reference.query && reference.response
108
143
  ? { query: String(reference.query), response: String(reference.response) }
109
144
  : undefined,
110
145
  validGlobalMsgNum,
111
- { abortSignal: abortController.signal },
146
+ { abortSignal: abortController.signal, effort: validEffort },
112
147
  )
113
148
  } catch (err: unknown) {
114
149
  if (!done) {
@@ -7,6 +7,7 @@ import { buildSessionLogEntry, writeSessionLog } from '../lib/session-log.js'
7
7
  import { getArchiveDayMessages } from '../lib/archive.js'
8
8
  import { localDay } from '../lib/local-day.js'
9
9
  import { clearCodexEngineSession } from '../lib/codex-engine-sessions.js'
10
+ import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
10
11
 
11
12
  export const sessionsRouter = Router()
12
13
 
@@ -48,14 +49,30 @@ sessionsRouter.get('/sessions/:id/messages', (req, res) => {
48
49
  return
49
50
  }
50
51
 
51
- // Pair user+assistant exchanges into client message format
52
- const messages: Array<{ query: string; text: string; timestamp: number }> = []
52
+ // Pair user+assistant exchanges into client message format. Request refs on
53
+ // the user turn and output refs on the assistant turn become one safe list.
54
+ const messages: Array<{
55
+ query: string
56
+ text: string
57
+ timestamp: number
58
+ no?: number
59
+ sessionId: string
60
+ attachments?: MediaAttachmentRef[]
61
+ }> = []
53
62
  for (let i = 0; i < exchanges.length; i++) {
54
63
  const ex = exchanges[i]
55
64
  if (ex.role === 'user') {
56
65
  const next = exchanges[i + 1]
57
66
  if (next && next.role === 'assistant') {
58
- messages.push({ query: ex.content, text: next.content, timestamp: next.timestamp })
67
+ const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
68
+ messages.push({
69
+ query: ex.content,
70
+ text: next.content,
71
+ timestamp: next.timestamp,
72
+ sessionId: req.params.id,
73
+ ...((ex.globalMsgNum ?? next.globalMsgNum) != null ? { no: ex.globalMsgNum ?? next.globalMsgNum } : {}),
74
+ ...(attachments.length > 0 ? { attachments } : {}),
75
+ })
59
76
  i++ // skip the assistant exchange
60
77
  }
61
78
  }
@@ -230,7 +247,16 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
230
247
  source: 'archive' as const,
231
248
  }))
232
249
 
233
- const liveMessages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; source: 'live' }> = []
250
+ const liveMessages: Array<{
251
+ query: string
252
+ text: string
253
+ timestamp: number
254
+ chatIndex: number
255
+ sessionId: string
256
+ source: 'live'
257
+ no?: number
258
+ attachments?: MediaAttachmentRef[]
259
+ }> = []
234
260
  const liveSessions = getActiveSessions()
235
261
  for (const session of liveSessions) {
236
262
  const sessionDay = localDay(session.lastActivity)
@@ -240,6 +266,7 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
240
266
  if (ex.role === 'user') {
241
267
  const next = session.exchanges[i + 1]
242
268
  if (next && next.role === 'assistant') {
269
+ const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
243
270
  liveMessages.push({
244
271
  query: ex.content,
245
272
  text: next.content,
@@ -247,6 +274,8 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
247
274
  chatIndex: -1,
248
275
  sessionId: session.id,
249
276
  source: 'live',
277
+ ...((ex.globalMsgNum ?? next.globalMsgNum) != null ? { no: ex.globalMsgNum ?? next.globalMsgNum } : {}),
278
+ ...(attachments.length > 0 ? { attachments } : {}),
250
279
  })
251
280
  i++
252
281
  }
@@ -0,0 +1,126 @@
1
+ // Media attachment contract — the ONE shape both the browser client and the
2
+ // server exchange for image attachments (Release A of the image-attachments
3
+ // plan). The public ref deliberately carries NO storage path, URL, token,
4
+ // base64, checksum, or internal lifecycle state — those live only in the
5
+ // server media index. Anything that persists or transmits an attachment
6
+ // persists THIS shape (or just the id) and nothing else.
7
+
8
+ export type MediaKind = 'user_photo' | 'traffic_frame' | 'generated_visual'
9
+
10
+ export type MediaMime = 'image/jpeg' | 'image/png'
11
+
12
+ export interface MediaAttachmentRef {
13
+ id: string
14
+ kind: MediaKind
15
+ mime: MediaMime
16
+ width: number
17
+ height: number
18
+ createdAt: string
19
+ label?: string
20
+ capturedAt?: string
21
+ expiresAt?: string
22
+ }
23
+
24
+ /** Hard cap on attachments per prompt — mirrored by upload validation,
25
+ * query resolution, and the phone composer. */
26
+ export const MAX_ATTACHMENTS_PER_PROMPT = 5
27
+
28
+ // ── Media IDs ────────────────────────────────────────────────────────────────
29
+ // One strict generated format, one strict validator. The id builds filesystem
30
+ // paths on the server, so the validator rejects anything that isn't exactly
31
+ // `m_` + 24 lowercase hex chars — no path characters can ever pass.
32
+
33
+ export const MEDIA_ID_RE = /^m_[a-f0-9]{24}$/
34
+
35
+ export function isValidMediaId(id: unknown): id is string {
36
+ return typeof id === 'string' && MEDIA_ID_RE.test(id)
37
+ }
38
+
39
+ const VALID_KINDS: ReadonlySet<string> = new Set(['user_photo', 'traffic_frame', 'generated_visual'])
40
+ const VALID_MIMES: ReadonlySet<string> = new Set(['image/jpeg', 'image/png'])
41
+ const MAX_LABEL_LEN = 120
42
+ // ISO-8601 subset — what `new Date().toISOString()` emits.
43
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/
44
+
45
+ function isIsoTimestamp(v: unknown): v is string {
46
+ return typeof v === 'string' && ISO_RE.test(v) && Number.isFinite(new Date(v).getTime())
47
+ }
48
+
49
+ function isDimension(v: unknown): v is number {
50
+ return typeof v === 'number' && Number.isInteger(v) && v > 0 && v <= 65_535
51
+ }
52
+
53
+ /** Validate an UNTRUSTED value into a MediaAttachmentRef, or null.
54
+ * TypeScript types alone are not validation — every persistence and API
55
+ * boundary that accepts a ref from outside must run it through here.
56
+ * Returns a fresh object containing only the known fields (drops extras). */
57
+ export function parseMediaAttachmentRef(raw: unknown): MediaAttachmentRef | null {
58
+ if (!raw || typeof raw !== 'object') return null
59
+ const r = raw as Record<string, unknown>
60
+ if (!isValidMediaId(r.id)) return null
61
+ if (typeof r.kind !== 'string' || !VALID_KINDS.has(r.kind)) return null
62
+ if (typeof r.mime !== 'string' || !VALID_MIMES.has(r.mime)) return null
63
+ if (!isDimension(r.width) || !isDimension(r.height)) return null
64
+ if (!isIsoTimestamp(r.createdAt)) return null
65
+ const ref: MediaAttachmentRef = {
66
+ id: r.id,
67
+ kind: r.kind as MediaKind,
68
+ mime: r.mime as MediaMime,
69
+ width: r.width,
70
+ height: r.height,
71
+ createdAt: r.createdAt,
72
+ }
73
+ if (typeof r.label === 'string' && r.label.length > 0) {
74
+ ref.label = r.label.slice(0, MAX_LABEL_LEN)
75
+ }
76
+ if (isIsoTimestamp(r.capturedAt)) ref.capturedAt = r.capturedAt
77
+ if (isIsoTimestamp(r.expiresAt)) ref.expiresAt = r.expiresAt
78
+ return ref
79
+ }
80
+
81
+ /** Validate an untrusted array of refs, dropping only the invalid entries
82
+ * (a bad ref must never take the whole conversation record with it). */
83
+ export function parseMediaAttachmentRefs(raw: unknown): MediaAttachmentRef[] {
84
+ if (!Array.isArray(raw)) return []
85
+ const out: MediaAttachmentRef[] = []
86
+ for (const item of raw) {
87
+ const ref = parseMediaAttachmentRef(item)
88
+ if (ref) out.push(ref)
89
+ if (out.length >= MAX_ATTACHMENTS_PER_PROMPT) break
90
+ }
91
+ return out
92
+ }
93
+
94
+ /** Validate, merge, and de-duplicate attachment refs from multiple untrusted
95
+ * exchange surfaces. A completed Q&A pair can carry request refs on the user
96
+ * turn and generated/research refs on the assistant turn; readers should see
97
+ * one bounded list without trusting either persisted shape. First occurrence
98
+ * wins so the request-side ref remains stable when the server echoes it back
99
+ * in completion metadata. */
100
+ export function mergeMediaAttachmentRefs(...sources: unknown[]): MediaAttachmentRef[] {
101
+ const out: MediaAttachmentRef[] = []
102
+ const seen = new Set<string>()
103
+ for (const source of sources) {
104
+ if (!Array.isArray(source)) continue
105
+ for (const item of source) {
106
+ const ref = parseMediaAttachmentRef(item)
107
+ if (!ref || seen.has(ref.id)) continue
108
+ seen.add(ref.id)
109
+ out.push(ref)
110
+ if (out.length >= MAX_ATTACHMENTS_PER_PROMPT) return out
111
+ }
112
+ }
113
+ return out
114
+ }
115
+
116
+ /** Validate an untrusted list of media IDs (dedup, cap, strict format). */
117
+ export function parseMediaIdList(raw: unknown): string[] {
118
+ if (!Array.isArray(raw)) return []
119
+ const out: string[] = []
120
+ for (const item of raw) {
121
+ if (!isValidMediaId(item) || out.includes(item)) continue
122
+ out.push(item)
123
+ if (out.length >= MAX_ATTACHMENTS_PER_PROMPT) break
124
+ }
125
+ return out
126
+ }
@@ -1,42 +1,159 @@
1
- export type ClaudeModelPreference = 'opus' | 'sonnet' | 'haiku'
2
- export type CodexModelPreference = 'codex-high'
1
+ export type ClaudeModelPreference = 'opus' | 'fable' | 'sonnet' | 'haiku'
2
+
3
+ // Stable app-level slots. Concrete GPT model ids resolve at runtime from the
4
+ // local Codex CLI catalog, so a shipped glasses app keeps tracking new releases.
5
+ export type CodexModelPreference = 'codex-frontier' | 'codex-balanced'
3
6
  export type ModelPreference = ClaudeModelPreference | CodexModelPreference
4
7
 
8
+ // Preserve the public server's established fast, broadly available default.
5
9
  export const DEFAULT_MODEL = 'sonnet' as const
6
- export const CODEX_HIGH_MODEL: CodexModelPreference = 'codex-high'
10
+ export const CODEX_FRONTIER_MODEL: CodexModelPreference = 'codex-frontier'
11
+ export const CODEX_BALANCED_MODEL: CodexModelPreference = 'codex-balanced'
12
+ // Backward-compatible export for callers that predate the two-slot catalog.
13
+ export const CODEX_HIGH_MODEL: CodexModelPreference = CODEX_FRONTIER_MODEL
14
+ // Existing 6.1–6.3 installs may pin the legacy codex-high slot. Frontier is its
15
+ // migration target; Balanced remains auto-catalog even when this override is set.
16
+ export const CODEX_MODEL_ID = process.env.COS_CODEX_MODEL?.trim() ?? ''
17
+
18
+ export type CodexReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'
19
+ const CODEX_REASONING_EFFORT_SET = new Set<CodexReasoningEffort>(['low', 'medium', 'high', 'xhigh', 'max', 'ultra'])
20
+
21
+ export function resolveConfiguredCodexReasoningEffort(): CodexReasoningEffort {
22
+ const raw = process.env.COS_CODEX_REASONING_EFFORT?.trim().toLowerCase()
23
+ return raw && CODEX_REASONING_EFFORT_SET.has(raw as CodexReasoningEffort)
24
+ ? raw as CodexReasoningEffort
25
+ : 'high'
26
+ }
27
+
28
+ export const CODEX_HIGH_REASONING_EFFORT: CodexReasoningEffort = resolveConfiguredCodexReasoningEffort()
29
+
30
+ export const MODEL_OPTIONS: ModelPreference[] = [
31
+ 'opus',
32
+ 'fable',
33
+ 'sonnet',
34
+ CODEX_FRONTIER_MODEL,
35
+ CODEX_BALANCED_MODEL,
36
+ ]
37
+
38
+ const MODEL_SET = new Set<ModelPreference>([
39
+ 'opus',
40
+ 'fable',
41
+ 'sonnet',
42
+ 'haiku',
43
+ CODEX_FRONTIER_MODEL,
44
+ CODEX_BALANCED_MODEL,
45
+ ])
46
+
47
+ // Bare Claude tier aliases resolve to the newest model in that tier at spawn.
48
+ // The [1m] suffix keeps the large-context contract without pinning a version.
49
+ export const CLAUDE_CLI_MODEL_ID: Record<ClaudeModelPreference, string> = {
50
+ opus: 'opus[1m]',
51
+ fable: 'fable[1m]',
52
+ sonnet: 'sonnet[1m]',
53
+ haiku: 'haiku',
54
+ }
55
+
56
+ export function resolveClaudeCliModelId(model: ClaudeModelPreference): string {
57
+ return CLAUDE_CLI_MODEL_ID[model] ?? CLAUDE_CLI_MODEL_ID[DEFAULT_MODEL]
58
+ }
59
+
60
+ /** Format a concrete Claude CLI model id for diagnostics. */
61
+ export function formatResolvedModelDisplay(cliModelId: string | undefined): string {
62
+ if (!cliModelId) return ''
63
+ const oneM = cliModelId.includes('[1m]')
64
+ const bare = cliModelId.replace('[1m]', '')
65
+ const match = /^claude-([a-z]+)-(\d+(?:-\d+)*)$/.exec(bare)
66
+ if (!match) return cliModelId
67
+ const family = match[1].charAt(0).toUpperCase() + match[1].slice(1)
68
+ const version = match[2].split('-').join('.')
69
+ return `${family} ${version}${oneM ? ' (1M)' : ''}`
70
+ }
71
+
72
+ export type EffortPreference = 'high' | 'xhigh' | 'max' | 'ultracode'
73
+ export const DEFAULT_EFFORT: EffortPreference = 'high'
74
+ export const EFFORT_OPTIONS: EffortPreference[] = ['high', 'xhigh', 'max', 'ultracode']
75
+ const EFFORT_SET = new Set<EffortPreference>(EFFORT_OPTIONS)
76
+ export const ULTRACODE_KEYWORD = 'ultracode'
77
+
78
+ export function isEffortPreference(value: unknown): value is EffortPreference {
79
+ return typeof value === 'string' && EFFORT_SET.has(value as EffortPreference)
80
+ }
7
81
 
8
- // Optional codex model passed to `codex exec --model`. Empty (the default) means
9
- // "use whatever model your codex CLI is configured for" — so the public server
10
- // never pins a specific (possibly unreleased) model id. Set COS_CODEX_MODEL to
11
- // pin one. COS_CODEX_REASONING_EFFORT tunes the reasoning level (default high).
12
- export const CODEX_MODEL_ID = process.env.COS_CODEX_MODEL ?? ''
13
- export const CODEX_HIGH_REASONING_EFFORT = process.env.COS_CODEX_REASONING_EFFORT ?? 'high'
82
+ export function normalizeEffortPreference(value: unknown): EffortPreference | undefined {
83
+ return isEffortPreference(value) ? value : undefined
84
+ }
85
+
86
+ /** Map the UI-only ultracode choice to a Claude CLI effort flag. */
87
+ export function resolveCliEffortFlag(effort: EffortPreference): 'high' | 'xhigh' | 'max' {
88
+ return effort === 'ultracode' ? 'xhigh' : effort
89
+ }
90
+
91
+ // Codex Fast mode is emitted only when the selected live model advertises it.
92
+ export const CODEX_SERVICE_TIER = 'priority'
14
93
 
15
- export const MODEL_OPTIONS: ModelPreference[] = ['opus', 'sonnet', 'haiku', 'codex-high']
94
+ export function resolveCodexReasoningEffort(effort: EffortPreference | undefined): CodexReasoningEffort {
95
+ if (effort === 'ultracode') return 'ultra'
96
+ if (effort === 'max') return 'max'
97
+ if (effort === 'xhigh') return 'xhigh'
98
+ return 'high'
99
+ }
16
100
 
17
- const MODEL_SET = new Set<ModelPreference>(['opus', 'sonnet', 'haiku', 'codex-high'])
101
+ export function effortLabel(effort: EffortPreference): string {
102
+ switch (effort) {
103
+ case 'xhigh': return 'Extra High'
104
+ case 'max': return 'Max'
105
+ case 'ultracode': return 'Ultracode'
106
+ case 'high':
107
+ default:
108
+ return 'High'
109
+ }
110
+ }
18
111
 
19
112
  export function isModelPreference(value: unknown): value is ModelPreference {
20
113
  return typeof value === 'string' && MODEL_SET.has(value as ModelPreference)
21
114
  }
22
115
 
23
116
  export function normalizeModelPreference(value: unknown): ModelPreference | undefined {
117
+ // Existing installs persist codex-high. Migrate it to the live frontier slot.
118
+ if (value === 'codex-high') return CODEX_FRONTIER_MODEL
24
119
  return isModelPreference(value) ? value : undefined
25
120
  }
26
121
 
27
122
  export function isClaudeModel(model: ModelPreference): model is ClaudeModelPreference {
28
- return model === 'opus' || model === 'sonnet' || model === 'haiku'
123
+ return model === 'opus' || model === 'fable' || model === 'sonnet' || model === 'haiku'
29
124
  }
30
125
 
31
126
  export function isCodexModel(model: ModelPreference): model is CodexModelPreference {
32
- return model === 'codex-high'
127
+ return model === CODEX_FRONTIER_MODEL || model === CODEX_BALANCED_MODEL
128
+ }
129
+
130
+ export interface RuntimeCodexModelLabel {
131
+ preference: CodexModelPreference
132
+ displayName: string
133
+ }
134
+
135
+ const runtimeCodexLabels: Partial<Record<CodexModelPreference, string>> = {}
136
+
137
+ export function setRuntimeCodexModelLabels(options: RuntimeCodexModelLabel[]): void {
138
+ for (const option of options) {
139
+ if (!isCodexModel(option.preference)) continue
140
+ const label = typeof option.displayName === 'string' ? option.displayName.trim() : ''
141
+ if (label) runtimeCodexLabels[option.preference] = label
142
+ }
143
+ }
144
+
145
+ export function resetRuntimeCodexModelLabels(): void {
146
+ delete runtimeCodexLabels[CODEX_FRONTIER_MODEL]
147
+ delete runtimeCodexLabels[CODEX_BALANCED_MODEL]
33
148
  }
34
149
 
35
150
  export function modelLabel(model: ModelPreference): string {
36
151
  switch (model) {
152
+ case 'fable': return 'Fable'
37
153
  case 'sonnet': return 'Sonnet'
38
154
  case 'haiku': return 'Haiku'
39
- case 'codex-high': return 'Codex High'
155
+ case 'codex-frontier': return runtimeCodexLabels[model] ?? 'GPT Frontier'
156
+ case 'codex-balanced': return runtimeCodexLabels[model] ?? 'GPT Balanced'
40
157
  case 'opus':
41
158
  default:
42
159
  return 'Opus'
@@ -45,9 +162,11 @@ export function modelLabel(model: ModelPreference): string {
45
162
 
46
163
  export function modelShortLabel(model: ModelPreference): string {
47
164
  switch (model) {
165
+ case 'fable': return 'Fable'
48
166
  case 'sonnet': return 'Sonnet'
49
167
  case 'haiku': return 'Haiku'
50
- case 'codex-high': return 'Codex H'
168
+ case 'codex-frontier': return 'GPT Max'
169
+ case 'codex-balanced': return 'GPT Bal'
51
170
  case 'opus':
52
171
  default:
53
172
  return 'Opus'
@@ -56,9 +175,11 @@ export function modelShortLabel(model: ModelPreference): string {
56
175
 
57
176
  export function modelButtonLabel(model: ModelPreference): string {
58
177
  switch (model) {
178
+ case 'fable': return 'FABLE'
59
179
  case 'sonnet': return 'SNNT'
60
180
  case 'haiku': return 'HAIKU'
61
- case 'codex-high': return 'CODEX H'
181
+ case 'codex-frontier': return 'GPT MAX'
182
+ case 'codex-balanced': return 'GPT BAL'
62
183
  case 'opus':
63
184
  default:
64
185
  return 'OPUS'
@@ -67,9 +188,11 @@ export function modelButtonLabel(model: ModelPreference): string {
67
188
 
68
189
  export function modelTag(model: ModelPreference): string {
69
190
  switch (model) {
191
+ case 'fable': return 'F'
70
192
  case 'sonnet': return 'S'
71
193
  case 'haiku': return 'H'
72
- case 'codex-high': return 'CH'
194
+ case 'codex-frontier': return 'GF'
195
+ case 'codex-balanced': return 'GB'
73
196
  case 'opus':
74
197
  default:
75
198
  return 'O'