@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.
@@ -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) {
@@ -0,0 +1,299 @@
1
+ // Session endpoints — recent list, full history, existence check, client-format messages, context breaks, end
2
+ import { Router } from 'express'
3
+ import { readFileSync } from 'fs'
4
+ import { join } from 'path'
5
+ import { getRecentSessions, getHistory, sessionExists, addContextBreak, endSession, getSessionRaw, getActiveSessions } from '../lib/conversation.js'
6
+ import { buildSessionLogEntry, writeSessionLog } from '../lib/session-log.js'
7
+ import { getArchiveDayMessages } from '../lib/archive.js'
8
+ import { localDay } from '../lib/local-day.js'
9
+ import { clearCodexEngineSession } from '../lib/codex-engine-sessions.js'
10
+ import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
11
+
12
+ export const sessionsRouter = Router()
13
+
14
+ sessionsRouter.get('/sessions/recent', (_req, res) => {
15
+ const sessions = getRecentSessions(24 * 60 * 60_000)
16
+ res.json({ sessions })
17
+ })
18
+
19
+ // HEAD /api/sessions/:id — lightweight existence check for restore validation
20
+ sessionsRouter.head('/sessions/:id', (req, res) => {
21
+ res.status(sessionExists(req.params.id) ? 200 : 404).end()
22
+ })
23
+
24
+ // GET /api/sessions/:id/history — full exchange list for session resume
25
+ sessionsRouter.get('/sessions/:id/history', (req, res) => {
26
+ const exchanges = getHistory(req.params.id)
27
+ if (exchanges.length === 0) {
28
+ res.status(404).json({ error: 'Session not found or empty' })
29
+ return
30
+ }
31
+ res.json({ exchanges })
32
+ })
33
+
34
+ // POST /api/sessions/:id/context-break — insert a context break (prompt history gate)
35
+ sessionsRouter.post('/sessions/:id/context-break', (req, res) => {
36
+ const ok = addContextBreak(req.params.id)
37
+ if (!ok) {
38
+ res.status(404).json({ error: 'Session not found' })
39
+ return
40
+ }
41
+ res.json({ ok: true })
42
+ })
43
+
44
+ // GET /api/sessions/:id/messages — client-compatible format (paired Q&A)
45
+ sessionsRouter.get('/sessions/:id/messages', (req, res) => {
46
+ const exchanges = getHistory(req.params.id)
47
+ if (exchanges.length === 0) {
48
+ res.status(404).json({ error: 'Session not found or empty' })
49
+ return
50
+ }
51
+
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
+ }> = []
62
+ for (let i = 0; i < exchanges.length; i++) {
63
+ const ex = exchanges[i]
64
+ if (ex.role === 'user') {
65
+ const next = exchanges[i + 1]
66
+ if (next && next.role === 'assistant') {
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
+ })
76
+ i++ // skip the assistant exchange
77
+ }
78
+ }
79
+ }
80
+
81
+ res.json({ messages })
82
+ })
83
+
84
+ // POST /api/sessions/:id/end — Explicitly end a session (archive + log + notify)
85
+ // Called by client on "new session", "clear session", or app backgrounding.
86
+ // Prevents data loss — session gets logged to .glasses_sessions.jsonl immediately
87
+ // instead of waiting for the 2hr TTL expiry.
88
+ // POST /api/sessions/lookup — batch resolve timestamps to session IDs
89
+ // Used to retroactively stamp messages that predate the sessionId feature
90
+ sessionsRouter.post('/sessions/lookup', (req, res) => {
91
+ const { timestamps } = req.body as { timestamps: number[] }
92
+ if (!timestamps || !Array.isArray(timestamps)) {
93
+ res.status(400).json({ error: 'timestamps[] required' })
94
+ return
95
+ }
96
+
97
+ // Build time ranges from BOTH JSONL history AND live server sessions
98
+ const logPath = join(process.env.COS_SCRIPTS_DIR || '', '.glasses_sessions.jsonl')
99
+ const sessionRanges: Array<{ sid: string; start: number; end: number }> = []
100
+
101
+ // 1. Live server sessions (always current — no snapshot lag)
102
+ const recentSessions = getRecentSessions(24 * 60 * 60_000)
103
+ for (const rs of recentSessions) {
104
+ const raw = getSessionRaw(rs.id)
105
+ if (raw) {
106
+ sessionRanges.push({ sid: raw.id, start: raw.createdAt, end: Date.now() }) // extends to NOW
107
+ }
108
+ }
109
+
110
+ // 2. JSONL history (ended sessions + snapshots)
111
+ try {
112
+ const lines = readFileSync(logPath, 'utf-8').trim().split('\n')
113
+ for (const line of lines) {
114
+ const d = JSON.parse(line)
115
+ if (d.session_id && d.created_at) {
116
+ const start = new Date(d.created_at).getTime()
117
+ const end = d.ended_at ? new Date(d.ended_at).getTime() : start + 7200_000
118
+ // Live sessions take priority. For JSONL, keep the widest (latest) time range per session.
119
+ const existing = sessionRanges.find(s => s.sid === d.session_id)
120
+ if (!existing) {
121
+ sessionRanges.push({ sid: d.session_id, start, end })
122
+ } else if (end > existing.end && !getSessionRaw(d.session_id)) {
123
+ // Widen the JSONL range (but don't overwrite live sessions which extend to NOW)
124
+ existing.end = end
125
+ }
126
+ }
127
+ }
128
+ } catch { /* no log file */ }
129
+
130
+ // Match each timestamp to a session (live sessions checked first, then JSONL)
131
+ const results: Record<number, string | null> = {}
132
+ for (const ts of timestamps) {
133
+ let match: string | null = null
134
+ for (const s of sessionRanges) {
135
+ if (ts >= s.start && ts <= s.end) {
136
+ match = s.sid
137
+ break
138
+ }
139
+ }
140
+ results[ts] = match
141
+ }
142
+
143
+ res.json({ results, sessionsScanned: sessionRanges.length })
144
+ })
145
+
146
+ sessionsRouter.post('/sessions/:id/end', async (req, res) => {
147
+ try {
148
+ const result = await endSession(req.params.id)
149
+ if (!result) {
150
+ res.status(404).json({ error: 'Session not found' })
151
+ return
152
+ }
153
+ // When logged === false the archive write failed — we keep the session
154
+ // in the Map for the next mirror to retry, but signal 503 so the client
155
+ // knows NOT to wipe local messages (they're still the user's only copy).
156
+ if (!result.logged && result.exchangeCount > 0) {
157
+ res.status(503).json({
158
+ ok: false,
159
+ error: 'Archive write failed — session retained for retry',
160
+ exchange_count: result.exchangeCount,
161
+ duration_minutes: result.durationMin,
162
+ })
163
+ return
164
+ }
165
+ clearCodexEngineSession(req.params.id)
166
+ res.json({
167
+ ok: true,
168
+ logged: result.logged,
169
+ exchange_count: result.exchangeCount,
170
+ duration_minutes: result.durationMin,
171
+ })
172
+ } catch (err) {
173
+ console.error('[sessions] /end unexpected error:', err)
174
+ res.status(500).json({ error: String(err) })
175
+ }
176
+ })
177
+
178
+ // POST /api/sessions/:id/snapshot — write live session to .glasses_sessions.jsonl WITHOUT ending it
179
+ // Enables M3 Ultra TUI to read current glasses conversation while session is still active
180
+ sessionsRouter.post('/sessions/:id/snapshot', (req, res) => {
181
+ const session = getSessionRaw(req.params.id)
182
+ if (!session) {
183
+ res.status(404).json({ error: 'Session not found' })
184
+ return
185
+ }
186
+
187
+ const entry = buildSessionLogEntry({
188
+ id: session.id,
189
+ exchanges: session.exchanges,
190
+ createdAt: session.createdAt,
191
+ lastActivity: session.lastActivity,
192
+ modelPreference: session.modelPreference,
193
+ endReason: 'explicit_end', // marker — will be overwritten when session actually ends
194
+ slug: `[LIVE] ${(session.exchanges.find(e => e.role === 'user')?.content ?? '').slice(0, 50)}`,
195
+ })
196
+
197
+ const logged = writeSessionLog(entry)
198
+ res.json({
199
+ ok: true,
200
+ logged,
201
+ session_id: session.id,
202
+ message_count: entry.total_message_count,
203
+ messages_logged: entry.messages.length,
204
+ })
205
+ })
206
+
207
+ // GET /api/sessions/today/live-chats — live session chat summaries for today (not yet archived).
208
+ // Date compare is LOCAL time so users chatting in CDT/PST late evening still see their
209
+ // session under "today" instead of "tomorrow UTC".
210
+ // Each summary includes `sessionId` so the client can drill down via
211
+ // `/api/sessions/:id/messages` — index=-1 is a sentinel and is NOT a valid archive chat index.
212
+ sessionsRouter.get('/sessions/today/live-chats', (_req, res) => {
213
+ const todayDate = localDay()
214
+ const liveSessions = getActiveSessions()
215
+ const chats: Array<{ index: number; summary: string; exchangeCount: number; startedAt: number; isLive: boolean; sessionId: string }> = []
216
+
217
+ for (const session of liveSessions) {
218
+ const sessionDay = localDay(session.lastActivity)
219
+ if (sessionDay !== todayDate) continue
220
+ if (session.exchanges.length === 0) continue
221
+
222
+ const firstQuery = session.exchanges.find(e => e.role === 'user')?.content ?? ''
223
+ const summary = firstQuery.length > 57 ? firstQuery.slice(0, 54) + '...' : firstQuery || 'Live session'
224
+
225
+ chats.push({
226
+ index: -1,
227
+ summary: `[LIVE] ${summary}`,
228
+ exchangeCount: session.exchanges.length,
229
+ startedAt: session.createdAt,
230
+ isLive: true,
231
+ sessionId: session.id,
232
+ })
233
+ }
234
+
235
+ res.json({ chats })
236
+ })
237
+
238
+ // GET /api/sessions/today/all-messages — merged view of today's archived + live session messages.
239
+ // Dedup key is `sessionId|timestamp` (was bare timestamp, which collided on NTP skew or
240
+ // same-ms adds). `sessionId` is always known for live exchanges; archive messages fall
241
+ // back to the archived chat's sessionId via getArchiveDayMessages.
242
+ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
243
+ const todayDate = localDay()
244
+
245
+ const archivedMessages = getArchiveDayMessages(todayDate).map(m => ({
246
+ ...m,
247
+ source: 'archive' as const,
248
+ }))
249
+
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
+ }> = []
260
+ const liveSessions = getActiveSessions()
261
+ for (const session of liveSessions) {
262
+ const sessionDay = localDay(session.lastActivity)
263
+ if (sessionDay !== todayDate) continue
264
+ for (let i = 0; i < session.exchanges.length; i++) {
265
+ const ex = session.exchanges[i]
266
+ if (ex.role === 'user') {
267
+ const next = session.exchanges[i + 1]
268
+ if (next && next.role === 'assistant') {
269
+ const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
270
+ liveMessages.push({
271
+ query: ex.content,
272
+ text: next.content,
273
+ timestamp: next.timestamp,
274
+ chatIndex: -1,
275
+ sessionId: session.id,
276
+ source: 'live',
277
+ ...((ex.globalMsgNum ?? next.globalMsgNum) != null ? { no: ex.globalMsgNum ?? next.globalMsgNum } : {}),
278
+ ...(attachments.length > 0 ? { attachments } : {}),
279
+ })
280
+ i++
281
+ }
282
+ }
283
+ }
284
+ }
285
+
286
+ // Merge, dedup by (sessionId, timestamp), sort chronologically.
287
+ const seen = new Set<string>()
288
+ const keyOf = (m: { sessionId?: string; timestamp: number }) => `${m.sessionId ?? ''}|${m.timestamp}`
289
+ const merged = [...archivedMessages, ...liveMessages]
290
+ .filter(m => {
291
+ const k = keyOf(m as any)
292
+ if (seen.has(k)) return false
293
+ seen.add(k)
294
+ return true
295
+ })
296
+ .sort((a, b) => a.timestamp - b.timestamp)
297
+
298
+ res.json({ messages: merged, date: todayDate })
299
+ })
@@ -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
+ }