@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,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
|
+
})
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Global message reference resolution (v5.15.0) — the server half of
|
|
2
|
+
// "reference message N" across days. Numbers are stamped at exchange time
|
|
3
|
+
// (client-sent, stored via conversation.addExchange) and persist durably in
|
|
4
|
+
// the day archives; this router resolves a number the client no longer holds
|
|
5
|
+
// in its local list, and publishes the numbering ceiling so a cleared or
|
|
6
|
+
// fresh client continues the sequence instead of reusing numbers.
|
|
7
|
+
//
|
|
8
|
+
// GET /api/message/:num → { globalMsgNum, date, query, response } (404 when unknown)
|
|
9
|
+
// GET /api/message-counter → { max }
|
|
10
|
+
//
|
|
11
|
+
// Resolution order (per the prompt-queue/archive plan): live in-memory
|
|
12
|
+
// sessions first (covers the mirror's 15-minute lag), then day archives
|
|
13
|
+
// newest-first. Day files are read as plain data — their write path belongs
|
|
14
|
+
// to the archive workstream and is not touched here.
|
|
15
|
+
import { Router } from 'express'
|
|
16
|
+
import { readdirSync, readFileSync } from 'fs'
|
|
17
|
+
import { resolve } from 'path'
|
|
18
|
+
import { getActiveSessions } from '../lib/conversation.js'
|
|
19
|
+
import { dataPath } from '../lib/data-dir.js'
|
|
20
|
+
import { localDay } from '../lib/local-day.js'
|
|
21
|
+
import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
|
|
22
|
+
|
|
23
|
+
// v6.3.0 — read archives from the SAME persistent location the archive-mirror
|
|
24
|
+
// writes to (~/.cos-glasses/data/archive via dataPath), not a package-relative
|
|
25
|
+
// dir. The app repo uses server/data/archive; the public package uses dataPath,
|
|
26
|
+
// so this file must match the package's lib/archive.ts, or npx users' cross-day
|
|
27
|
+
// references + archive-chat detail read an empty/nonexistent directory.
|
|
28
|
+
const ARCHIVE_DIR = dataPath('archive')
|
|
29
|
+
|
|
30
|
+
export interface ResolvedGlobalMessage {
|
|
31
|
+
globalMsgNum: number
|
|
32
|
+
date: string
|
|
33
|
+
query: string
|
|
34
|
+
response: string
|
|
35
|
+
attachments?: MediaAttachmentRef[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ExchangeLike {
|
|
39
|
+
role?: string
|
|
40
|
+
content?: string
|
|
41
|
+
timestamp?: number
|
|
42
|
+
globalMsgNum?: number
|
|
43
|
+
attachments?: unknown
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Pair the stamped exchange with its other half: a user turn pairs forward
|
|
47
|
+
* to the next assistant turn; an assistant turn pairs backward. */
|
|
48
|
+
function pairExchange(exchanges: ExchangeLike[], i: number): { query: string; response: string; attachments: MediaAttachmentRef[] } {
|
|
49
|
+
const hit = exchanges[i]
|
|
50
|
+
const user = hit.role === 'user'
|
|
51
|
+
? hit
|
|
52
|
+
: [...exchanges.slice(0, i)].reverse().find((e) => e?.role === 'user')
|
|
53
|
+
const assistant = hit.role === 'assistant'
|
|
54
|
+
? hit
|
|
55
|
+
: exchanges.slice(i + 1).find((e) => e?.role === 'assistant')
|
|
56
|
+
return {
|
|
57
|
+
query: user?.content ?? '',
|
|
58
|
+
response: assistant?.content ?? '',
|
|
59
|
+
attachments: mergeMediaAttachmentRefs(user?.attachments, assistant?.attachments),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function scanExchanges(exchanges: ExchangeLike[], num: number, date: string): ResolvedGlobalMessage | null {
|
|
64
|
+
for (let i = 0; i < exchanges.length; i++) {
|
|
65
|
+
if (exchanges[i]?.globalMsgNum !== num) continue
|
|
66
|
+
const { query, response, attachments } = pairExchange(exchanges, i)
|
|
67
|
+
return {
|
|
68
|
+
globalMsgNum: num, date, query, response,
|
|
69
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Resolve a global message number from the day archives, newest-first.
|
|
76
|
+
* Exported with an explicit dir for tests. */
|
|
77
|
+
export function resolveFromArchiveDir(dir: string, num: number): ResolvedGlobalMessage | null {
|
|
78
|
+
let files: string[] = []
|
|
79
|
+
try {
|
|
80
|
+
files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort().reverse()
|
|
81
|
+
} catch {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
for (const f of files) {
|
|
85
|
+
try {
|
|
86
|
+
const day = JSON.parse(readFileSync(resolve(dir, f), 'utf8'))
|
|
87
|
+
const chats = Array.isArray(day?.chats) ? day.chats : []
|
|
88
|
+
for (const chat of chats) {
|
|
89
|
+
const exchanges = Array.isArray(chat?.exchanges) ? chat.exchanges : []
|
|
90
|
+
const hit = scanExchanges(exchanges, num, typeof day?.date === 'string' ? day.date : f.slice(0, 10))
|
|
91
|
+
if (hit) return hit
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// Unreadable/corrupt day file — skip; the archive workstream owns repair.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Read a specific archived chat's paired Q&A messages WITH their durable
|
|
101
|
+
* global numbers (the archive-lib read path strips globalMsgNum; the browser
|
|
102
|
+
* needs it so "reference message N" is self-evident from the screen). Same
|
|
103
|
+
* user->next-assistant pairing as the lib; the pair's number is the user
|
|
104
|
+
* turn's stamp (falling back to the assistant's). Dir-param form for tests. */
|
|
105
|
+
export function readArchiveChatNumbered(
|
|
106
|
+
dir: string,
|
|
107
|
+
date: string,
|
|
108
|
+
chatIndex: number,
|
|
109
|
+
): Array<{ query: string; text: string; timestamp: number; no?: number; attachments?: MediaAttachmentRef[] }> {
|
|
110
|
+
// Defense-in-depth against path traversal — `date` builds a `${date}.json`
|
|
111
|
+
// path. The archive route also validates, but this is exported/reused.
|
|
112
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return []
|
|
113
|
+
let day: { chats?: Array<{ id?: number; exchanges?: ExchangeLike[] }> }
|
|
114
|
+
try {
|
|
115
|
+
day = JSON.parse(readFileSync(resolve(dir, `${date}.json`), 'utf8'))
|
|
116
|
+
} catch {
|
|
117
|
+
return []
|
|
118
|
+
}
|
|
119
|
+
const chat = (Array.isArray(day?.chats) ? day.chats : []).find((c) => c?.id === chatIndex)
|
|
120
|
+
if (!chat) return []
|
|
121
|
+
const exchanges: ExchangeLike[] = Array.isArray(chat.exchanges) ? chat.exchanges : []
|
|
122
|
+
const out: Array<{ query: string; text: string; timestamp: number; no?: number; attachments?: MediaAttachmentRef[] }> = []
|
|
123
|
+
for (let i = 0; i < exchanges.length; i++) {
|
|
124
|
+
const ex = exchanges[i]
|
|
125
|
+
if (ex?.role !== 'user') continue
|
|
126
|
+
const next = exchanges[i + 1]
|
|
127
|
+
if (next?.role !== 'assistant') continue
|
|
128
|
+
const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
|
|
129
|
+
out.push({
|
|
130
|
+
query: ex.content ?? '',
|
|
131
|
+
text: next.content ?? '',
|
|
132
|
+
timestamp: next.timestamp ?? ex.timestamp ?? 0,
|
|
133
|
+
no: ex.globalMsgNum ?? next.globalMsgNum,
|
|
134
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
135
|
+
})
|
|
136
|
+
i++
|
|
137
|
+
}
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** ARCHIVE_DIR-bound form for the route. */
|
|
142
|
+
export function getArchiveChatMessagesNumbered(date: string, chatIndex: number) {
|
|
143
|
+
return readArchiveChatNumbered(ARCHIVE_DIR, date, chatIndex)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Highest stamped number across the day archives (0 when none). */
|
|
147
|
+
export function maxGlobalMsgNumInDir(dir: string): number {
|
|
148
|
+
let max = 0
|
|
149
|
+
let files: string[] = []
|
|
150
|
+
try {
|
|
151
|
+
files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f))
|
|
152
|
+
} catch {
|
|
153
|
+
return 0
|
|
154
|
+
}
|
|
155
|
+
for (const f of files) {
|
|
156
|
+
try {
|
|
157
|
+
const day = JSON.parse(readFileSync(resolve(dir, f), 'utf8'))
|
|
158
|
+
for (const chat of Array.isArray(day?.chats) ? day.chats : []) {
|
|
159
|
+
for (const ex of Array.isArray(chat?.exchanges) ? chat.exchanges : []) {
|
|
160
|
+
if (typeof ex?.globalMsgNum === 'number' && ex.globalMsgNum > max) max = ex.globalMsgNum
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch { /* skip */ }
|
|
164
|
+
}
|
|
165
|
+
return max
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function resolveFromLiveSessions(num: number): ResolvedGlobalMessage | null {
|
|
169
|
+
const today = localDay() // local calendar day, not UTC — a live ref in the user's evening must not label tomorrow
|
|
170
|
+
for (const session of getActiveSessions()) {
|
|
171
|
+
const exchanges = (session as { exchanges?: ExchangeLike[] }).exchanges ?? []
|
|
172
|
+
const hit = scanExchanges(exchanges, num, today)
|
|
173
|
+
if (hit) return hit
|
|
174
|
+
}
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const messageRefRouter = Router()
|
|
179
|
+
|
|
180
|
+
messageRefRouter.get('/message/:num', (req, res) => {
|
|
181
|
+
const num = Number.parseInt(req.params.num, 10)
|
|
182
|
+
if (!Number.isFinite(num) || num < 1) {
|
|
183
|
+
res.status(400).json({ error: 'invalid message number' })
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
const hit = resolveFromLiveSessions(num) ?? resolveFromArchiveDir(ARCHIVE_DIR, num)
|
|
187
|
+
if (!hit) {
|
|
188
|
+
res.status(404).json({ error: `message ${num} not found` })
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
res.json(hit)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
messageRefRouter.get('/message-counter', (_req, res) => {
|
|
195
|
+
let liveMax = 0
|
|
196
|
+
for (const session of getActiveSessions()) {
|
|
197
|
+
for (const ex of ((session as { exchanges?: ExchangeLike[] }).exchanges ?? [])) {
|
|
198
|
+
if (typeof ex?.globalMsgNum === 'number' && ex.globalMsgNum > liveMax) liveMax = ex.globalMsgNum
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
res.json({ max: Math.max(liveMax, maxGlobalMsgNumInDir(ARCHIVE_DIR)) })
|
|
202
|
+
})
|
|
@@ -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
|
})
|