@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.
- package/.env.example +23 -7
- package/CHANGELOG.md +108 -0
- package/README.md +28 -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 +61 -21
- package/server/lib/activity-preview.ts +168 -0
- package/server/lib/archive.ts +20 -6
- 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/display-bus.ts +61 -3
- 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-id.ts +55 -0
- package/server/lib/server-instance-lock.ts +122 -0
- package/server/lib/server-metrics.ts +7 -0
- package/server/routes/display.ts +43 -22
- package/server/routes/health.ts +19 -2
- package/server/routes/media.ts +285 -0
- package/server/routes/message-ref.ts +18 -6
- package/server/routes/openai-compat.ts +44 -11
- package/server/routes/query.ts +51 -16
- package/server/routes/sessions.ts +33 -4
- package/shared/media-attachment.ts +126 -0
- package/shared/model-preference.ts +140 -17
package/server/routes/display.ts
CHANGED
|
@@ -2,15 +2,28 @@
|
|
|
2
2
|
// Any connected glasses client receives real-time query responses
|
|
3
3
|
// regardless of which interface submitted the query
|
|
4
4
|
|
|
5
|
-
import { Router } from 'express'
|
|
6
|
-
import {
|
|
5
|
+
import { Router, type Response } from 'express'
|
|
6
|
+
import {
|
|
7
|
+
emitDisplay,
|
|
8
|
+
getDisplayWatermark,
|
|
9
|
+
onDisplay,
|
|
10
|
+
replayDisplayEvents,
|
|
11
|
+
type PublishedDisplayEvent,
|
|
12
|
+
} from '../lib/display-bus.js'
|
|
7
13
|
|
|
8
14
|
export const displayRouter = Router()
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
function writeEvent(res: Response, event: PublishedDisplayEvent): void {
|
|
17
|
+
const data = JSON.stringify({
|
|
18
|
+
...event.data,
|
|
19
|
+
_cosDisplayCursor: {
|
|
20
|
+
bootId: event.bootId,
|
|
21
|
+
eventId: event.eventId,
|
|
22
|
+
publishedAt: event.publishedAt,
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
res.write(`id: ${event.bootId}:${event.eventId}\nevent: ${event.type}\ndata: ${data}\n\n`)
|
|
26
|
+
}
|
|
14
27
|
|
|
15
28
|
displayRouter.get('/display-stream', (req, res) => {
|
|
16
29
|
res.writeHead(200, {
|
|
@@ -25,15 +38,29 @@ displayRouter.get('/display-stream', (req, res) => {
|
|
|
25
38
|
// Tell EventSource to retry quickly on disconnect (3s instead of browser default ~5-10s)
|
|
26
39
|
res.write('retry: 3000\n\n')
|
|
27
40
|
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
const headerCursor = String(req.headers['last-event-id'] ?? '')
|
|
42
|
+
const [headerBootId, headerEventId] = headerCursor.includes(':')
|
|
43
|
+
? headerCursor.split(':', 2)
|
|
44
|
+
: ['', headerCursor]
|
|
45
|
+
const cursorBootId = String(req.query.bootId ?? headerBootId ?? '') || null
|
|
46
|
+
const cursorEventId = Number(req.query.eventId ?? headerEventId ?? 0)
|
|
47
|
+
const replay = replayDisplayEvents(cursorBootId, Number.isFinite(cursorEventId) ? cursorEventId : 0)
|
|
48
|
+
|
|
49
|
+
// Ready is a transport handshake, not proof that replay was consumed. It
|
|
50
|
+
// must precede application events so build 188 can finish admission first.
|
|
51
|
+
const watermark = getDisplayWatermark()
|
|
52
|
+
res.write(`event: ready\ndata: ${JSON.stringify(watermark)}\n\n`)
|
|
53
|
+
if (replay.gap) {
|
|
54
|
+
res.write(`event: replay_gap\ndata: ${JSON.stringify({
|
|
55
|
+
reason: replay.reason,
|
|
56
|
+
requested: { bootId: cursorBootId, eventId: cursorEventId },
|
|
57
|
+
watermark,
|
|
58
|
+
oldestEventId: replay.oldestEventId,
|
|
59
|
+
})}\n\n`)
|
|
60
|
+
} else {
|
|
61
|
+
for (const event of replay.events) writeEvent(res, event)
|
|
62
|
+
if (replay.events.length > 0) {
|
|
63
|
+
console.log(`[display-bus] Replayed ${replay.events.length} publish-owned events after ${cursorEventId}`)
|
|
37
64
|
}
|
|
38
65
|
}
|
|
39
66
|
|
|
@@ -43,13 +70,7 @@ displayRouter.get('/display-stream', (req, res) => {
|
|
|
43
70
|
}, 15_000)
|
|
44
71
|
|
|
45
72
|
const unsub = onDisplay((event) => {
|
|
46
|
-
|
|
47
|
-
const data = JSON.stringify(event.data)
|
|
48
|
-
// Buffer for replay
|
|
49
|
-
replayBuffer.push({ id: eventId, type: event.type, data })
|
|
50
|
-
if (replayBuffer.length > REPLAY_BUFFER_SIZE) replayBuffer.shift()
|
|
51
|
-
// Send with id for Last-Event-ID tracking
|
|
52
|
-
try { res.write(`id: ${eventId}\nevent: ${event.type}\ndata: ${data}\n\n`) } catch { /* client gone */ }
|
|
73
|
+
try { writeEvent(res, event) } catch { /* client gone */ }
|
|
53
74
|
})
|
|
54
75
|
|
|
55
76
|
req.on('close', () => {
|
package/server/routes/health.ts
CHANGED
|
@@ -3,12 +3,19 @@ import { execFile } from 'node:child_process'
|
|
|
3
3
|
import { statSync } from 'node:fs'
|
|
4
4
|
import { resolve } from 'node:path'
|
|
5
5
|
import { COS_SCRIPTS_DIR, COS_MODE, PYTHON_BIN } from '../lib/python-bridge.js'
|
|
6
|
-
import { serverMetrics } from '../
|
|
6
|
+
import { serverMetrics } from '../lib/server-metrics.js'
|
|
7
|
+
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
7
8
|
import { isSileroAvailable } from '../lib/vad-silero.js'
|
|
8
9
|
import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
|
|
9
10
|
import { isWhisperLocalAvailable, getWhisperHealth } from '../lib/whisper-local.js'
|
|
10
11
|
import { getOpenAIWhisperBudgetState } from '../lib/openai-whisper-budget.js'
|
|
11
12
|
import { getKeyStatus } from '../lib/openai-key.js'
|
|
13
|
+
import {
|
|
14
|
+
getCodexModelCatalog,
|
|
15
|
+
getCodexModelCatalogSnapshot,
|
|
16
|
+
} from '../lib/codex-model-catalog.js'
|
|
17
|
+
import { isMediaProcessingReady } from '../lib/image-safety.js'
|
|
18
|
+
import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
|
|
12
19
|
|
|
13
20
|
export const healthRouter = Router()
|
|
14
21
|
|
|
@@ -106,6 +113,8 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
106
113
|
cos_pipeline: COS_MODE,
|
|
107
114
|
whisper: isWhisperLocalAvailable(),
|
|
108
115
|
iphoneAsrCandidates: process.env.COS_IOS_ASR_CANDIDATES === '1',
|
|
116
|
+
mediaProcessingReady: await isMediaProcessingReady(),
|
|
117
|
+
g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
|
|
109
118
|
}
|
|
110
119
|
const voice = {
|
|
111
120
|
hasKey: keyStatus.hasKey,
|
|
@@ -117,7 +126,15 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
117
126
|
const whisper_health = getWhisperHealth()
|
|
118
127
|
const openai_whisper_budget = getOpenAIWhisperBudgetState()
|
|
119
128
|
|
|
120
|
-
|
|
129
|
+
const codex_models = getCodexModelCatalogSnapshot()
|
|
130
|
+
res.json({ ...checks, features, voice, whisper_health, openai_whisper_budget, codex_models })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// Stable app slots backed by Codex's live model/list catalog. This route is
|
|
134
|
+
// authenticated by the global /api middleware; ?refresh=1 forces discovery.
|
|
135
|
+
healthRouter.get('/models', async (req, res) => {
|
|
136
|
+
const catalog = await getCodexModelCatalog(req.query.refresh === '1')
|
|
137
|
+
res.json({ ...catalog, serverInstanceId: getServerInstanceId() })
|
|
121
138
|
})
|
|
122
139
|
|
|
123
140
|
// GET /api/cli-session — returns current CLI session ID for cross-device resume
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Media attachment API (Release A) — authenticated upload, lifecycle, and
|
|
2
|
+
// content endpoints backed by server/lib/media-store.ts.
|
|
3
|
+
//
|
|
4
|
+
// POST /api/media — upload images (base64 JSON batch)
|
|
5
|
+
// POST /api/media/reserve — bind staged media to a queue item
|
|
6
|
+
// POST /api/media/associate — bind media to a run/message (replay-safe)
|
|
7
|
+
// POST /api/media/release — drop staged/reserved media (cancel path)
|
|
8
|
+
// GET /api/media/:id — metadata (public ref + availability)
|
|
9
|
+
// GET /api/media/:id/content — bytes (?variant=phone|thumb)
|
|
10
|
+
// DELETE /api/media/:id — delete UNASSOCIATED media only
|
|
11
|
+
//
|
|
12
|
+
// Uploads accept uploaded bytes only — no remote URLs, no filesystem paths,
|
|
13
|
+
// no data-URI passthrough. The dedicated body parser (mediaBodyParser) is
|
|
14
|
+
// mounted BEFORE the global express.json() so a maximum valid batch
|
|
15
|
+
// (8 MiB decoded ≈ 10.7 MiB base64) doesn't trip the global 10 MB limit;
|
|
16
|
+
// the allowance stays scoped to /api/media.
|
|
17
|
+
|
|
18
|
+
import { Router, json, type Request, type Response } from 'express'
|
|
19
|
+
import { readFileSync } from 'node:fs'
|
|
20
|
+
import {
|
|
21
|
+
MAX_ATTACHMENTS_PER_PROMPT,
|
|
22
|
+
isValidMediaId,
|
|
23
|
+
parseMediaIdList,
|
|
24
|
+
type MediaAttachmentRef,
|
|
25
|
+
} from '../../shared/media-attachment.js'
|
|
26
|
+
import {
|
|
27
|
+
G2_LENS_VARIANT_CAPABILITY,
|
|
28
|
+
getMediaStore,
|
|
29
|
+
MediaStoreError,
|
|
30
|
+
} from '../lib/media-store.js'
|
|
31
|
+
import {
|
|
32
|
+
ImageSafetyError,
|
|
33
|
+
MAX_BATCH_BYTES,
|
|
34
|
+
isMediaProcessingReady,
|
|
35
|
+
strictBase64Decode,
|
|
36
|
+
} from '../lib/image-safety.js'
|
|
37
|
+
|
|
38
|
+
// Route-scoped parser: 16 MB covers the max valid batch after base64 + JSON
|
|
39
|
+
// overhead. Mounted only for /api/media in server/index.ts — the global
|
|
40
|
+
// server limit is unchanged.
|
|
41
|
+
export const mediaBodyParser = json({ limit: '16mb' })
|
|
42
|
+
|
|
43
|
+
export const mediaRouter = Router()
|
|
44
|
+
|
|
45
|
+
const MEDIA_ERROR_STATUS: Record<string, number> = {
|
|
46
|
+
media_not_found: 404,
|
|
47
|
+
media_expired: 410,
|
|
48
|
+
media_unavailable: 503,
|
|
49
|
+
media_conflict: 409,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SAFETY_ERROR_STATUS: Record<string, number> = {
|
|
53
|
+
invalid_base64: 400,
|
|
54
|
+
unsupported_format: 400,
|
|
55
|
+
image_too_large: 400,
|
|
56
|
+
dimensions_too_large: 400,
|
|
57
|
+
corrupt_image: 400,
|
|
58
|
+
media_processing_unavailable: 503,
|
|
59
|
+
normalization_failed: 500,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sendMediaError(res: Response, err: unknown): void {
|
|
63
|
+
if (err instanceof MediaStoreError) {
|
|
64
|
+
res.status(MEDIA_ERROR_STATUS[err.code] ?? 500).json({ error: err.code })
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (err instanceof ImageSafetyError) {
|
|
68
|
+
const status = SAFETY_ERROR_STATUS[err.code] ?? 500
|
|
69
|
+
res.status(status).json({
|
|
70
|
+
error: err.code,
|
|
71
|
+
...(err.code === 'media_processing_unavailable' ? { mediaProcessingReady: false } : {}),
|
|
72
|
+
})
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
console.error('[media] unexpected error:', err)
|
|
76
|
+
res.status(500).json({ error: 'media_internal_error' })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function safeString(v: unknown, max: number): string | undefined {
|
|
80
|
+
return typeof v === 'string' && v.trim().length > 0 ? v.trim().slice(0, max) : undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Upload ───────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
mediaRouter.post('/media', async (req: Request, res: Response) => {
|
|
86
|
+
try {
|
|
87
|
+
const body = req.body ?? {}
|
|
88
|
+
const rawImages = Array.isArray(body.images) ? body.images : []
|
|
89
|
+
if (rawImages.length === 0) {
|
|
90
|
+
res.status(400).json({ error: 'no_images' })
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
if (rawImages.length > MAX_ATTACHMENTS_PER_PROMPT) {
|
|
94
|
+
res.status(400).json({ error: 'too_many_images', max: MAX_ATTACHMENTS_PER_PROMPT })
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
// Release A: only phone photos enter through this route. Traffic frames
|
|
98
|
+
// and generated visuals arrive via the Release C run-scoped publisher.
|
|
99
|
+
const kind = body.kind === undefined || body.kind === 'user_photo' ? 'user_photo' : null
|
|
100
|
+
if (!kind) {
|
|
101
|
+
res.status(400).json({ error: 'unsupported_kind' })
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (!(await isMediaProcessingReady())) {
|
|
105
|
+
res.status(503).json({ error: 'media_processing_unavailable', mediaProcessingReady: false })
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Decode + total-batch gate before any normalization work.
|
|
110
|
+
const decoded: Array<{ bytes: Buffer; label?: string; capturedAt?: string }> = []
|
|
111
|
+
let totalBytes = 0
|
|
112
|
+
for (const raw of rawImages) {
|
|
113
|
+
const item = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
|
|
114
|
+
const bytes = strictBase64Decode(item.data)
|
|
115
|
+
totalBytes += bytes.length
|
|
116
|
+
if (totalBytes > MAX_BATCH_BYTES) {
|
|
117
|
+
res.status(400).json({ error: 'batch_too_large', maxBytes: MAX_BATCH_BYTES })
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
decoded.push({
|
|
121
|
+
bytes,
|
|
122
|
+
label: safeString(item.label, 120),
|
|
123
|
+
capturedAt: safeString(item.capturedAt, 40),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const sessionId = safeString(body.sessionId, 64)
|
|
128
|
+
const store = getMediaStore()
|
|
129
|
+
const attachments: MediaAttachmentRef[] = []
|
|
130
|
+
for (const img of decoded) {
|
|
131
|
+
attachments.push(await store.ingestImage({
|
|
132
|
+
bytes: img.bytes,
|
|
133
|
+
kind,
|
|
134
|
+
label: img.label,
|
|
135
|
+
capturedAt: img.capturedAt,
|
|
136
|
+
sessionId,
|
|
137
|
+
}))
|
|
138
|
+
}
|
|
139
|
+
res.json({ attachments })
|
|
140
|
+
} catch (err) {
|
|
141
|
+
sendMediaError(res, err)
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
mediaRouter.post('/media/reserve', async (req: Request, res: Response) => {
|
|
148
|
+
try {
|
|
149
|
+
const ids = parseMediaIdList(req.body?.ids)
|
|
150
|
+
const clientQueueItemId = safeString(req.body?.clientQueueItemId, 120)
|
|
151
|
+
if (ids.length === 0 || !clientQueueItemId) {
|
|
152
|
+
res.status(400).json({ error: 'ids_and_client_queue_item_id_required' })
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
const attachments = await getMediaStore().reserve(ids, {
|
|
156
|
+
clientQueueItemId,
|
|
157
|
+
sessionId: safeString(req.body?.sessionId, 64),
|
|
158
|
+
})
|
|
159
|
+
res.json({ ok: true, attachments })
|
|
160
|
+
} catch (err) {
|
|
161
|
+
sendMediaError(res, err)
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
mediaRouter.post('/media/associate', async (req: Request, res: Response) => {
|
|
166
|
+
try {
|
|
167
|
+
const ids = parseMediaIdList(req.body?.ids)
|
|
168
|
+
if (ids.length === 0) {
|
|
169
|
+
res.status(400).json({ error: 'ids_required' })
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
const globalMsgNum = typeof req.body?.globalMsgNum === 'number' && req.body.globalMsgNum > 0
|
|
173
|
+
? Math.floor(req.body.globalMsgNum) : undefined
|
|
174
|
+
await getMediaStore().associate(ids, {
|
|
175
|
+
sessionId: safeString(req.body?.sessionId, 64),
|
|
176
|
+
runId: safeString(req.body?.runId, 120),
|
|
177
|
+
globalMsgNum,
|
|
178
|
+
})
|
|
179
|
+
res.json({ ok: true })
|
|
180
|
+
} catch (err) {
|
|
181
|
+
sendMediaError(res, err)
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
mediaRouter.post('/media/release', async (req: Request, res: Response) => {
|
|
186
|
+
try {
|
|
187
|
+
const ids = parseMediaIdList(req.body?.ids)
|
|
188
|
+
if (ids.length === 0) {
|
|
189
|
+
res.status(400).json({ error: 'ids_required' })
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
await getMediaStore().release(ids, {
|
|
193
|
+
sessionId: safeString(req.body?.sessionId, 64),
|
|
194
|
+
clientQueueItemId: safeString(req.body?.clientQueueItemId, 120),
|
|
195
|
+
})
|
|
196
|
+
res.json({ ok: true })
|
|
197
|
+
} catch (err) {
|
|
198
|
+
sendMediaError(res, err)
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// ── Reads ────────────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
mediaRouter.get('/media/:id', (req: Request, res: Response) => {
|
|
205
|
+
const id = req.params.id
|
|
206
|
+
if (!isValidMediaId(id)) {
|
|
207
|
+
res.status(400).json({ error: 'invalid_media_id' })
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
const store = getMediaStore()
|
|
211
|
+
const rec = store.getRecord(id)
|
|
212
|
+
if (!rec || rec.lifecycle === 'deleted') {
|
|
213
|
+
res.status(404).json({ error: 'media_not_found' })
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
const content = store.getContent(id, 'phone')
|
|
217
|
+
res.json({
|
|
218
|
+
attachment: rec.ref,
|
|
219
|
+
contentAvailable: content.status === 'ok',
|
|
220
|
+
...(content.status === 'expired' ? { expired: true } : {}),
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
mediaRouter.get('/media/:id/content', async (req: Request, res: Response) => {
|
|
225
|
+
const id = req.params.id
|
|
226
|
+
if (!isValidMediaId(id)) {
|
|
227
|
+
res.status(400).json({ error: 'invalid_media_id' })
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
// 'g2' (Release B) — exact 288x144 grayscale PNG for the lens, generated
|
|
231
|
+
// lazily and cached beside the asset.
|
|
232
|
+
const variant = req.query.variant === 'thumb' ? 'thumb' : req.query.variant === 'g2' ? 'g2' : 'phone'
|
|
233
|
+
const content = variant === 'g2'
|
|
234
|
+
? await getMediaStore().getG2Content(id)
|
|
235
|
+
: getMediaStore().getContent(id, variant)
|
|
236
|
+
if (content.status === 'not_found') {
|
|
237
|
+
res.status(404).json({ error: 'media_not_found' })
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
if (content.status === 'expired') {
|
|
241
|
+
res.status(410).json({ error: 'media_expired' })
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
if (content.status === 'unavailable') {
|
|
245
|
+
res.status(503).json({ error: 'media_unavailable' })
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
// Read + send the buffer directly: content-length is exact and no
|
|
250
|
+
// filesystem path semantics leak into the response.
|
|
251
|
+
const bytes = readFileSync(content.path)
|
|
252
|
+
res.status(200)
|
|
253
|
+
res.setHeader('Content-Type', content.mime)
|
|
254
|
+
res.setHeader('Cache-Control', 'private, no-store')
|
|
255
|
+
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
256
|
+
if (variant === 'g2') {
|
|
257
|
+
res.setHeader('X-COS-G2-Variant', G2_LENS_VARIANT_CAPABILITY)
|
|
258
|
+
// Even Hub loads the app from a different origin. Without an expose
|
|
259
|
+
// header, browser fetch can receive this value but cannot read it.
|
|
260
|
+
res.setHeader('Access-Control-Expose-Headers', 'X-COS-G2-Variant')
|
|
261
|
+
}
|
|
262
|
+
res.setHeader('Content-Length', String(bytes.length))
|
|
263
|
+
res.end(bytes)
|
|
264
|
+
} catch (err) {
|
|
265
|
+
sendMediaError(res, err)
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
mediaRouter.delete('/media/:id', async (req: Request, res: Response) => {
|
|
270
|
+
const id = req.params.id
|
|
271
|
+
if (!isValidMediaId(id)) {
|
|
272
|
+
res.status(400).json({ error: 'invalid_media_id' })
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const deleted = await getMediaStore().deleteUnassociated(id)
|
|
277
|
+
if (!deleted) {
|
|
278
|
+
res.status(404).json({ error: 'media_not_found' })
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
res.json({ deleted: true })
|
|
282
|
+
} catch (err) {
|
|
283
|
+
sendMediaError(res, err)
|
|
284
|
+
}
|
|
285
|
+
})
|
|
@@ -18,6 +18,7 @@ import { resolve } from 'path'
|
|
|
18
18
|
import { getActiveSessions } from '../lib/conversation.js'
|
|
19
19
|
import { dataPath } from '../lib/data-dir.js'
|
|
20
20
|
import { localDay } from '../lib/local-day.js'
|
|
21
|
+
import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
|
|
21
22
|
|
|
22
23
|
// v6.3.0 — read archives from the SAME persistent location the archive-mirror
|
|
23
24
|
// writes to (~/.cos-glasses/data/archive via dataPath), not a package-relative
|
|
@@ -31,6 +32,7 @@ export interface ResolvedGlobalMessage {
|
|
|
31
32
|
date: string
|
|
32
33
|
query: string
|
|
33
34
|
response: string
|
|
35
|
+
attachments?: MediaAttachmentRef[]
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
interface ExchangeLike {
|
|
@@ -38,11 +40,12 @@ interface ExchangeLike {
|
|
|
38
40
|
content?: string
|
|
39
41
|
timestamp?: number
|
|
40
42
|
globalMsgNum?: number
|
|
43
|
+
attachments?: unknown
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
/** Pair the stamped exchange with its other half: a user turn pairs forward
|
|
44
47
|
* to the next assistant turn; an assistant turn pairs backward. */
|
|
45
|
-
function pairExchange(exchanges: ExchangeLike[], i: number): { query: string; response: string } {
|
|
48
|
+
function pairExchange(exchanges: ExchangeLike[], i: number): { query: string; response: string; attachments: MediaAttachmentRef[] } {
|
|
46
49
|
const hit = exchanges[i]
|
|
47
50
|
const user = hit.role === 'user'
|
|
48
51
|
? hit
|
|
@@ -50,14 +53,21 @@ function pairExchange(exchanges: ExchangeLike[], i: number): { query: string; re
|
|
|
50
53
|
const assistant = hit.role === 'assistant'
|
|
51
54
|
? hit
|
|
52
55
|
: exchanges.slice(i + 1).find((e) => e?.role === 'assistant')
|
|
53
|
-
return {
|
|
56
|
+
return {
|
|
57
|
+
query: user?.content ?? '',
|
|
58
|
+
response: assistant?.content ?? '',
|
|
59
|
+
attachments: mergeMediaAttachmentRefs(user?.attachments, assistant?.attachments),
|
|
60
|
+
}
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
function scanExchanges(exchanges: ExchangeLike[], num: number, date: string): ResolvedGlobalMessage | null {
|
|
57
64
|
for (let i = 0; i < exchanges.length; i++) {
|
|
58
65
|
if (exchanges[i]?.globalMsgNum !== num) continue
|
|
59
|
-
const { query, response } = pairExchange(exchanges, i)
|
|
60
|
-
return {
|
|
66
|
+
const { query, response, attachments } = pairExchange(exchanges, i)
|
|
67
|
+
return {
|
|
68
|
+
globalMsgNum: num, date, query, response,
|
|
69
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
70
|
+
}
|
|
61
71
|
}
|
|
62
72
|
return null
|
|
63
73
|
}
|
|
@@ -96,7 +106,7 @@ export function readArchiveChatNumbered(
|
|
|
96
106
|
dir: string,
|
|
97
107
|
date: string,
|
|
98
108
|
chatIndex: number,
|
|
99
|
-
): Array<{ query: string; text: string; timestamp: number; no?: number }> {
|
|
109
|
+
): Array<{ query: string; text: string; timestamp: number; no?: number; attachments?: MediaAttachmentRef[] }> {
|
|
100
110
|
// Defense-in-depth against path traversal — `date` builds a `${date}.json`
|
|
101
111
|
// path. The archive route also validates, but this is exported/reused.
|
|
102
112
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return []
|
|
@@ -109,17 +119,19 @@ export function readArchiveChatNumbered(
|
|
|
109
119
|
const chat = (Array.isArray(day?.chats) ? day.chats : []).find((c) => c?.id === chatIndex)
|
|
110
120
|
if (!chat) return []
|
|
111
121
|
const exchanges: ExchangeLike[] = Array.isArray(chat.exchanges) ? chat.exchanges : []
|
|
112
|
-
const out: Array<{ query: string; text: string; timestamp: number; no?: number }> = []
|
|
122
|
+
const out: Array<{ query: string; text: string; timestamp: number; no?: number; attachments?: MediaAttachmentRef[] }> = []
|
|
113
123
|
for (let i = 0; i < exchanges.length; i++) {
|
|
114
124
|
const ex = exchanges[i]
|
|
115
125
|
if (ex?.role !== 'user') continue
|
|
116
126
|
const next = exchanges[i + 1]
|
|
117
127
|
if (next?.role !== 'assistant') continue
|
|
128
|
+
const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
|
|
118
129
|
out.push({
|
|
119
130
|
query: ex.content ?? '',
|
|
120
131
|
text: next.content ?? '',
|
|
121
132
|
timestamp: next.timestamp ?? ex.timestamp ?? 0,
|
|
122
133
|
no: ex.globalMsgNum ?? next.globalMsgNum,
|
|
134
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
123
135
|
})
|
|
124
136
|
i++
|
|
125
137
|
}
|
|
@@ -7,6 +7,10 @@ import { Router } from 'express'
|
|
|
7
7
|
import { preWarmCLI, logLatency } from '../lib/claude-bridge.js'
|
|
8
8
|
import { callModelStreaming } from '../lib/model-router.js'
|
|
9
9
|
import { normalizeModelPreference, DEFAULT_MODEL, type ModelPreference } from '../../shared/model-preference.js'
|
|
10
|
+
import {
|
|
11
|
+
getCodexModelCatalog,
|
|
12
|
+
resolveCodexPreferenceForModelId,
|
|
13
|
+
} from '../lib/codex-model-catalog.js'
|
|
10
14
|
import { tryInstantResponse } from '../lib/response-cache.js'
|
|
11
15
|
import crypto from 'node:crypto'
|
|
12
16
|
|
|
@@ -70,23 +74,31 @@ function validateAuth(req: any, res: any): boolean {
|
|
|
70
74
|
return true
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
// Resolve model from OpenAI-compatible
|
|
74
|
-
//
|
|
77
|
+
// Resolve model from OpenAI-compatible ids, stable app slots, or concrete ids
|
|
78
|
+
// currently advertised by the live Codex catalog.
|
|
75
79
|
function resolveModel(model?: string, _query?: string): ModelPreference {
|
|
76
80
|
const normalized = normalizeModelPreference(model)
|
|
77
81
|
if (normalized) return normalized
|
|
82
|
+
if (model) {
|
|
83
|
+
const catalogPreference = resolveCodexPreferenceForModelId(model)
|
|
84
|
+
if (catalogPreference) return catalogPreference
|
|
85
|
+
}
|
|
78
86
|
if (model === 'cos-opus') return 'opus'
|
|
87
|
+
if (model === 'cos-fable') return 'fable'
|
|
79
88
|
if (model === 'cos-sonnet') return 'sonnet'
|
|
80
89
|
if (model === 'cos-haiku') return 'haiku'
|
|
81
|
-
if (model === 'cos-codex-high' || model === 'cos-codex') return 'codex-
|
|
90
|
+
if (model === 'cos-gpt-frontier' || model === 'cos-codex-high' || model === 'cos-codex') return 'codex-frontier'
|
|
91
|
+
if (model === 'cos-gpt-balanced') return 'codex-balanced'
|
|
82
92
|
return normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL) ?? DEFAULT_MODEL
|
|
83
93
|
}
|
|
84
94
|
|
|
85
95
|
const MODEL_NAMES: Record<ModelPreference, string> = {
|
|
86
96
|
opus: 'cos-opus',
|
|
97
|
+
fable: 'cos-fable',
|
|
87
98
|
sonnet: 'cos-sonnet',
|
|
88
99
|
haiku: 'cos-haiku',
|
|
89
|
-
'codex-
|
|
100
|
+
'codex-frontier': 'cos-gpt-frontier',
|
|
101
|
+
'codex-balanced': 'cos-gpt-balanced',
|
|
90
102
|
}
|
|
91
103
|
|
|
92
104
|
// Extract the user's latest message from the OpenAI messages array
|
|
@@ -247,6 +259,7 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
247
259
|
let resolveInflight: (text: string) => void
|
|
248
260
|
let rejectInflight: (err: any) => void
|
|
249
261
|
const inflightPromise = new Promise<string>((res, rej) => { resolveInflight = res; rejectInflight = rej })
|
|
262
|
+
void inflightPromise.catch(() => { /* duplicate waiters observe the original rejection */ })
|
|
250
263
|
inflightQueries.set(dedupKey, { promise: inflightPromise, timestamp: Date.now() })
|
|
251
264
|
|
|
252
265
|
// ── Streaming response (SSE) ──
|
|
@@ -381,12 +394,23 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
381
394
|
const fullText = await new Promise<string>((resolve, reject) => {
|
|
382
395
|
let result = ''
|
|
383
396
|
let nsFirstChunkMs = -1
|
|
397
|
+
let settled = false
|
|
398
|
+
const fail = (error: unknown) => {
|
|
399
|
+
if (settled) return
|
|
400
|
+
settled = true
|
|
401
|
+
const err = error instanceof Error ? error : new Error(String(error))
|
|
402
|
+
rejectInflight!(err)
|
|
403
|
+
inflightQueries.delete(dedupKey)
|
|
404
|
+
reject(err)
|
|
405
|
+
}
|
|
384
406
|
callModelStreaming(query, currentSessionIdNS, {
|
|
385
407
|
onChunk: (text) => {
|
|
386
408
|
if (nsFirstChunkMs < 0) nsFirstChunkMs = Date.now() - requestReceivedAt
|
|
387
409
|
result += text
|
|
388
410
|
},
|
|
389
411
|
onDone: (fullText) => {
|
|
412
|
+
if (settled) return
|
|
413
|
+
settled = true
|
|
390
414
|
const text = fullText || result
|
|
391
415
|
resolveInflight!(text)
|
|
392
416
|
inflightQueries.delete(dedupKey)
|
|
@@ -404,13 +428,13 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
404
428
|
resolve(text)
|
|
405
429
|
},
|
|
406
430
|
onError: (error) => {
|
|
407
|
-
|
|
408
|
-
inflightQueries.delete(dedupKey)
|
|
409
|
-
reject(new Error(error))
|
|
431
|
+
fail(new Error(error))
|
|
410
432
|
},
|
|
411
433
|
onToolStatus: () => {},
|
|
412
434
|
onStart: () => {},
|
|
413
|
-
}, resolvedModel, undefined, undefined, undefined, { lightweight: true })
|
|
435
|
+
}, resolvedModel, undefined, undefined, undefined, { lightweight: true })
|
|
436
|
+
.then(sid => { g2SessionId = sid })
|
|
437
|
+
.catch(fail)
|
|
414
438
|
})
|
|
415
439
|
|
|
416
440
|
res.json({
|
|
@@ -433,14 +457,23 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
433
457
|
})
|
|
434
458
|
|
|
435
459
|
// GET /v1/models — required by some clients for model discovery
|
|
436
|
-
openaiCompatRouter.get('/v1/models', (_req, res) => {
|
|
460
|
+
openaiCompatRouter.get('/v1/models', async (_req, res) => {
|
|
461
|
+
const catalog = await getCodexModelCatalog()
|
|
437
462
|
res.json({
|
|
438
463
|
object: 'list',
|
|
439
|
-
|
|
464
|
+
data: [
|
|
440
465
|
{ id: 'cos-opus', object: 'model', created: 1709251200, owned_by: 'cos' },
|
|
466
|
+
{ id: 'cos-fable', object: 'model', created: 1709251200, owned_by: 'cos' },
|
|
441
467
|
{ id: 'cos-sonnet', object: 'model', created: 1709251200, owned_by: 'cos' },
|
|
442
468
|
{ id: 'cos-haiku', object: 'model', created: 1709251200, owned_by: 'cos' },
|
|
443
|
-
{ id: 'cos-
|
|
469
|
+
{ id: 'cos-gpt-frontier', object: 'model', created: 1709251200, owned_by: 'cos' },
|
|
470
|
+
{ id: 'cos-gpt-balanced', object: 'model', created: 1709251200, owned_by: 'cos' },
|
|
471
|
+
...catalog.options.filter(option => option.id).map(option => ({
|
|
472
|
+
id: option.id,
|
|
473
|
+
object: 'model',
|
|
474
|
+
created: 1709251200,
|
|
475
|
+
owned_by: 'openai',
|
|
476
|
+
})),
|
|
444
477
|
],
|
|
445
478
|
})
|
|
446
479
|
})
|