@gotcos/glasses-server 6.21.3 → 6.21.5

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/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ ## 6.21.5
2
+
3
+ - Drop the narrow Large-v3 stock-caption artifact family beginning with
4
+ “closed captioning provided by” from prompt dictation.
5
+ - When that exact HQ artifact occurs, retain the already-validated Fast
6
+ transcript for the same audio and report the result as degraded instead of
7
+ saving either the caption credit or an empty gap. All other HQ and no-speech
8
+ paths are unchanged.
9
+
10
+ ## 6.21.4
11
+
12
+ - Preserve validated phone-photo references on Claude and Codex conversation
13
+ exchanges so Recent Glasses and message-history clients can recover the
14
+ original visual context instead of receiving a text-only marker.
15
+ - Recover validated refs at read time from the durable media association index,
16
+ keyed by exact session ID, global message number, and message era. Pre-6.21.4
17
+ unversioned refs are recovered only for the active era when both creation and
18
+ association occurred after its boundary; ambiguous historical refs fail
19
+ closed rather than risking the wrong photo.
20
+
1
21
  ## 6.21.3
2
22
 
3
23
  - Route Max-tier provisional dictation preview through the resident Turbo
package/README.md CHANGED
@@ -112,6 +112,10 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
112
112
  authenticated query that requested it
113
113
  - Message History + cross-day "reference message N" — your chats are archived by day
114
114
  and every message keeps a permanent number you can recall (`/api/archive`, `/api/message/:num`)
115
+ - Recent/history responses preserve validated photo references. Recovery uses
116
+ exact session + global-message + message-era identity without exposing storage
117
+ paths. Ambiguous pre-version historical refs fail closed; unversioned refs are
118
+ recovered only inside the active era when created and associated after its boundary.
115
119
  - Send phone photos with queued prompts, and review assistant-selected generated,
116
120
  research, or explicitly used email images in Messages and on the G2 lens
117
121
  - Recover long voice prompts after phone, network, or server interruptions. Audio
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.3",
3
+ "version": "6.21.5",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -428,6 +428,7 @@ export async function callClaudeStreaming(
428
428
  outputImagePublisher = createRunOutputImagePublisher({
429
429
  sessionId: sid,
430
430
  globalMsgNum,
431
+ messageEra: options?.messageEra,
431
432
  maxImages: outputImageBudget,
432
433
  })
433
434
  } catch (err) {
@@ -473,6 +474,7 @@ export async function callClaudeStreaming(
473
474
  // Record user message (with [Photo]/[N Photos] prefix for vision queries)
474
475
  const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
475
476
  const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
477
+ const inboundAttachments = imageInputs.length > 0 ? imageInputs.map(input => input.attachment) : undefined
476
478
  const exchangeProvenance = {
477
479
  clientJobId: options?.clientJobId,
478
480
  generation: options?.generation,
@@ -482,7 +484,7 @@ export async function callClaudeStreaming(
482
484
  'user',
483
485
  historyQuery,
484
486
  globalMsgNum,
485
- undefined,
487
+ inboundAttachments,
486
488
  exchangeProvenance,
487
489
  resolvedModel,
488
490
  )
@@ -321,6 +321,7 @@ export async function callCodexStreaming(
321
321
  sessionId: sid,
322
322
  globalMsgNum,
323
323
  runId: run.runId,
324
+ messageEra: options?.messageEra,
324
325
  maxImages: outputImageBudget,
325
326
  })
326
327
  } catch (err) {
@@ -363,6 +364,7 @@ export async function callCodexStreaming(
363
364
  const isFirstQuery = isNewSession(sid)
364
365
  const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
365
366
  const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
367
+ const inboundAttachments = imageInputs.length > 0 ? imageInputs.map(input => input.attachment) : undefined
366
368
  const exchangeProvenance = {
367
369
  clientJobId: options?.clientJobId,
368
370
  generation: options?.generation,
@@ -372,7 +374,7 @@ export async function callCodexStreaming(
372
374
  'user',
373
375
  historyQuery,
374
376
  globalMsgNum,
375
- undefined,
377
+ inboundAttachments,
376
378
  exchangeProvenance,
377
379
  model,
378
380
  )
@@ -262,6 +262,7 @@ export async function callCursorStreaming(
262
262
  sessionId: sid,
263
263
  globalMsgNum,
264
264
  runId: run.runId,
265
+ messageEra: options?.messageEra,
265
266
  maxImages: outputImageBudget,
266
267
  })
267
268
  } catch (err) {
@@ -14,8 +14,11 @@
14
14
  import { getNegativeRules, getVocabulary, getOwnerName, getWhisperCorrections } from './profile.js'
15
15
 
16
16
  // ── Whole-chunk silence hallucinations ─────────────────────────────────────
17
+ const CAPTION_CREDIT_HALLUCINATION = /^\s*(?:closed\s+)?caption(?:ing|s)?\s+(?:is\s+)?provided\s+by\b/i
18
+
17
19
  const KNOWN_HALLUCINATIONS = [
18
20
  /^subtitles?\s+by\b/i,
21
+ CAPTION_CREDIT_HALLUCINATION,
19
22
  /\blike\s+and\s+subscribe\b/i,
20
23
  /\bthanks?\s+for\s+watching\b/i,
21
24
  /\bplease\s+subscribe\b/i,
@@ -27,6 +30,13 @@ const KNOWN_HALLUCINATIONS = [
27
30
  /^\s*♪+\s*[^♪\n]{0,40}\s*♪+\s*$/,
28
31
  ]
29
32
 
33
+ /** Narrow stock-caption detector used to retain a valid Fast draft when an HQ
34
+ * decode leaks a caption credit. It intentionally requires the phrase to begin
35
+ * the chunk so normal questions about closed captioning remain untouched. */
36
+ export function isCaptionCreditHallucination(text: string): boolean {
37
+ return CAPTION_CREDIT_HALLUCINATION.test(text)
38
+ }
39
+
30
40
  // ── Inline "Name:" detector (session-aware in streaming, static in one-shot) ──
31
41
  const INLINE_HALLUCINATION_THRESHOLD = 3
32
42
  // Seed list of known Whisper "Name:" training artifacts. Empty by default — the
@@ -33,6 +33,7 @@ import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
33
33
  import { dataPath } from './data-dir.js'
34
34
  import {
35
35
  isValidMediaId,
36
+ mergeMediaAttachmentRefs,
36
37
  parseMediaAttachmentRef,
37
38
  type MediaAttachmentRef,
38
39
  type MediaKind,
@@ -131,6 +132,7 @@ export interface MediaRecord {
131
132
  clientQueueItemId?: string
132
133
  runId?: string
133
134
  globalMsgNum?: number
135
+ messageEra?: string
134
136
  createdAtMs: number
135
137
  updatedAtMs: number
136
138
  reservedAtMs?: number
@@ -197,6 +199,9 @@ function sanitizeRecord(raw: unknown): MediaRecord | null {
197
199
  ...(typeof r.clientQueueItemId === 'string' ? { clientQueueItemId: r.clientQueueItemId } : {}),
198
200
  ...(typeof r.runId === 'string' ? { runId: r.runId } : {}),
199
201
  ...(typeof r.globalMsgNum === 'number' ? { globalMsgNum: r.globalMsgNum } : {}),
202
+ ...(typeof r.messageEra === 'string' && /^[a-z0-9][a-z0-9._-]{0,79}$/i.test(r.messageEra)
203
+ ? { messageEra: r.messageEra }
204
+ : {}),
200
205
  createdAtMs: typeof r.createdAtMs === 'number' ? r.createdAtMs : Date.now(),
201
206
  updatedAtMs: typeof r.updatedAtMs === 'number' ? r.updatedAtMs : Date.now(),
202
207
  ...(typeof r.reservedAtMs === 'number' ? { reservedAtMs: r.reservedAtMs } : {}),
@@ -464,6 +469,51 @@ export class MediaStore {
464
469
  return this.getRecord(id)?.ref ?? null
465
470
  }
466
471
 
472
+ /** Recover the public refs associated with one exact conversation turn.
473
+ *
474
+ * The media index is already the durable lifecycle authority for uploaded
475
+ * and generated images. Older/legacy query paths associated the media here
476
+ * but failed to copy the refs onto the conversation exchange. Readers may
477
+ * use this exact session + message key to repair that projection without
478
+ * exposing storage paths or inventing a second attachment database.
479
+ */
480
+ getAssociatedRefs(target: {
481
+ sessionId: string
482
+ globalMsgNum: number
483
+ messageEra?: string
484
+ activeMessageEra: string
485
+ activeEraStartedAt: number
486
+ }): MediaAttachmentRef[] {
487
+ if (!target.sessionId || !Number.isSafeInteger(target.globalMsgNum) || target.globalMsgNum < 1) return []
488
+ const validEra = (value: unknown): value is string =>
489
+ typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{0,79}$/i.test(value)
490
+ if (!validEra(target.activeMessageEra) || !Number.isFinite(target.activeEraStartedAt)) return []
491
+ const targetEra = validEra(target.messageEra) ? target.messageEra : undefined
492
+ const eraMatches = (rec: MediaRecord): boolean => {
493
+ if (rec.messageEra) return targetEra != null && rec.messageEra === targetEra
494
+
495
+ // Pre-6.21.4 associations did not persist an era. Recover them only for
496
+ // the active era when both creation and association are on/after its
497
+ // boundary. Requiring both closes the race where a pre-reset upload was
498
+ // associated only after the reset. Anything older may belong to legacy
499
+ // or any prior named era and fails closed.
500
+ const associatedAt = rec.associatedAtMs ?? rec.createdAtMs
501
+ if (target.activeMessageEra === 'legacy') return targetEra == null || targetEra === 'legacy'
502
+ if (targetEra === target.activeMessageEra) {
503
+ return rec.createdAtMs >= target.activeEraStartedAt && associatedAt >= target.activeEraStartedAt
504
+ }
505
+ return false
506
+ }
507
+ const matching = [...this.records.values()]
508
+ .filter(rec => rec.lifecycle === 'associated'
509
+ && rec.sessionId === target.sessionId
510
+ && rec.globalMsgNum === target.globalMsgNum
511
+ && eraMatches(rec))
512
+ .sort((a, b) => a.createdAtMs - b.createdAtMs)
513
+ .map(rec => rec.ref)
514
+ return mergeMediaAttachmentRefs(matching)
515
+ }
516
+
467
517
  /** Resolve content for serving/model input, honoring lifecycle + TTLs. */
468
518
  getContent(id: string, variant: 'phone' | 'thumb' | 'g2' = 'phone'): MediaContentResult {
469
519
  const rec = this.getRecord(id)
@@ -667,7 +717,7 @@ export class MediaStore {
667
717
 
668
718
  /** Bind media to its final run/message. Safe to replay; wins over a
669
719
  * delayed release. */
670
- associate(ids: string[], target: { sessionId?: string; runId?: string; globalMsgNum?: number }): Promise<void> {
720
+ associate(ids: string[], target: { sessionId?: string; runId?: string; globalMsgNum?: number; messageEra?: string }): Promise<void> {
671
721
  return this.withLock(() => {
672
722
  const now = Date.now()
673
723
  let dirty = false
@@ -684,6 +734,8 @@ export class MediaStore {
684
734
  if (target.sessionId && rec.sessionId !== target.sessionId) { rec.sessionId = target.sessionId; recDirty = true }
685
735
  if (target.runId && rec.runId !== target.runId) { rec.runId = target.runId; recDirty = true }
686
736
  if (target.globalMsgNum != null && rec.globalMsgNum !== target.globalMsgNum) { rec.globalMsgNum = target.globalMsgNum; recDirty = true }
737
+ if (target.messageEra && /^[a-z0-9][a-z0-9._-]{0,79}$/i.test(target.messageEra)
738
+ && rec.messageEra !== target.messageEra) { rec.messageEra = target.messageEra; recDirty = true }
687
739
  if (recDirty) {
688
740
  rec.updatedAtMs = now
689
741
  dirty = true
@@ -227,6 +227,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
227
227
  await getMediaStore().associate(resolvedAttachments.ids, {
228
228
  sessionId: request.sessionId,
229
229
  ...(request.globalMsgNum ? { globalMsgNum: request.globalMsgNum } : {}),
230
+ messageEra: request.messageEra,
230
231
  }).catch(error => console.error('[query-jobs] attachment association failed:', error))
231
232
  }
232
233
  const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
@@ -41,6 +41,7 @@ export interface RunOutputImageTarget {
41
41
  sessionId: string
42
42
  globalMsgNum?: number
43
43
  runId?: string
44
+ messageEra?: string
44
45
  }
45
46
 
46
47
  export interface CreateRunOutputImagePublisherOptions extends RunOutputImageTarget {
@@ -223,7 +224,15 @@ function safeTarget(options: CreateRunOutputImagePublisherOptions): RunOutputIma
223
224
  const runId = typeof options.runId === 'string' && options.runId.trim()
224
225
  ? options.runId.trim().slice(0, 120)
225
226
  : undefined
226
- return { sessionId, ...(globalMsgNum ? { globalMsgNum } : {}), ...(runId ? { runId } : {}) }
227
+ const messageEra = typeof options.messageEra === 'string' && /^[a-z0-9][a-z0-9._-]{0,79}$/i.test(options.messageEra)
228
+ ? options.messageEra
229
+ : undefined
230
+ return {
231
+ sessionId,
232
+ ...(globalMsgNum ? { globalMsgNum } : {}),
233
+ ...(runId ? { runId } : {}),
234
+ ...(messageEra ? { messageEra } : {}),
235
+ }
227
236
  }
228
237
 
229
238
  function assertTempRoot(rawRoot: string): string {
@@ -28,6 +28,7 @@ import {
28
28
  getMediaStore,
29
29
  MediaStoreError,
30
30
  } from '../lib/media-store.js'
31
+ import { currentMessageEra } from '../lib/message-era.js'
31
32
  import {
32
33
  ImageSafetyError,
33
34
  MAX_BATCH_BYTES,
@@ -175,6 +176,9 @@ mediaRouter.post('/media/associate', async (req: Request, res: Response) => {
175
176
  sessionId: safeString(req.body?.sessionId, 64),
176
177
  runId: safeString(req.body?.runId, 120),
177
178
  globalMsgNum,
179
+ // Association always belongs to the server's active message era. Never
180
+ // trust a client-supplied era to make media visible in historical turns.
181
+ messageEra: currentMessageEra(),
178
182
  })
179
183
  res.json({ ok: true })
180
184
  } catch (err) {
@@ -29,6 +29,7 @@ import {
29
29
  stripInlineHallucinations,
30
30
  stripPromptDictationArtifacts,
31
31
  isFullHallucination,
32
+ isCaptionCreditHallucination,
32
33
  isBrandUrlOnly,
33
34
  clearSessionHallucinationState,
34
35
  applyNegativeRules,
@@ -250,6 +251,35 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
250
251
  return text
251
252
  } catch (err) {
252
253
  if (err instanceof NoSpeechDetectedError) {
254
+ // Large-v3 can rarely replace a clipped but valid phrase with a stock
255
+ // caption credit. Keep the already-decoded Fast text for this one
256
+ // recognized failure shape instead of persisting either the artifact
257
+ // or an empty gap. Other no-speech results retain existing behavior.
258
+ if (mode === 'hq' && isCaptionCreditHallucination(err.rawText)) {
259
+ const fastJob = modeQualityJobs.get(modeQualityKey(draftId, chunkIndex, 'fast', hash))
260
+ if (fastJob) {
261
+ try { await fastJob } catch { /* normal no-speech path below */ }
262
+ }
263
+ const fast = loadPromptDraftMeta(draftId)?.warmTranscripts?.[String(chunkIndex)]
264
+ if (
265
+ fast
266
+ && fast.hash === hash
267
+ && fast.requestedMode === 'fast'
268
+ && fast.actualQuality === 'fast'
269
+ && fast.text.trim()
270
+ ) {
271
+ if (purpose === 'final') {
272
+ await markPromptDraftChunkTranscript(draftId, chunkIndex, {
273
+ ...fast,
274
+ requestedMode: 'hq',
275
+ degraded: true,
276
+ degradationReason: 'hq_caption_credit_hallucination',
277
+ }, 'final')
278
+ }
279
+ console.warn(`[prompt-draft] dropped HQ caption-credit hallucination ${draftId}/${chunkIndex}; retained Fast text`)
280
+ return fast.text
281
+ }
282
+ }
253
283
  await markPromptDraftChunkTranscript(draftId, chunkIndex, {
254
284
  text: '', hash, requestedMode: mode, actualQuality: mode, backend: 'no-speech', degraded: false,
255
285
  }, purpose)
@@ -160,6 +160,7 @@ queryRouter.post('/query', async (req, res) => {
160
160
  await getMediaStore().associate(resolvedAttachments.ids, {
161
161
  sessionId: sid,
162
162
  ...(validGlobalMsgNum ? { globalMsgNum: validGlobalMsgNum } : {}),
163
+ messageEra: activeMessageEra,
163
164
  }).catch((err) => console.error('[query] attachment association failed:', err))
164
165
  }
165
166
  if (!done) {
@@ -199,6 +200,7 @@ queryRouter.post('/query', async (req, res) => {
199
200
  {
200
201
  abortSignal: abortController.signal,
201
202
  effort: validEffort,
203
+ messageEra: activeMessageEra,
202
204
  ...(validModel && isCursorModel(validModel) ? { cursorExecutionMode } : {}),
203
205
  },
204
206
  )
@@ -8,9 +8,46 @@ 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
10
  import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
11
- import { currentMessageEra, exchangeBelongsToEra } from '../lib/message-era.js'
11
+ import { currentMessageEraState, exchangeBelongsToEra, type MessageEraState } from '../lib/message-era.js'
12
+ import { getMediaStore } from '../lib/media-store.js'
12
13
 
13
14
  export const sessionsRouter = Router()
15
+ let mediaAssociationLookupWarned = false
16
+
17
+ /** Conversation exchanges are the normal attachment projection. The media
18
+ * store fallback repairs turns created by the legacy query path, which did
19
+ * durably associate media to session + message but did not stamp the refs on
20
+ * the exchange. Both sources contain public refs only and remain capped by the
21
+ * shared merge validator. */
22
+ function turnAttachments(
23
+ sessionId: string,
24
+ globalMsgNum: number | undefined,
25
+ messageEra: string | undefined,
26
+ activeEra: MessageEraState,
27
+ ...sources: unknown[]
28
+ ): MediaAttachmentRef[] {
29
+ let associated: MediaAttachmentRef[] = []
30
+ if (globalMsgNum != null) {
31
+ try {
32
+ associated = getMediaStore().getAssociatedRefs({
33
+ sessionId,
34
+ globalMsgNum,
35
+ messageEra,
36
+ activeMessageEra: activeEra.era,
37
+ activeEraStartedAt: activeEra.startedAt,
38
+ })
39
+ } catch (error) {
40
+ // Conversation history remains useful when optional media recovery is
41
+ // unavailable. Warn once per process instead of turning a text endpoint
42
+ // into a 500 or flooding logs once per turn.
43
+ if (!mediaAssociationLookupWarned) {
44
+ mediaAssociationLookupWarned = true
45
+ console.warn(`[sessions] media association lookup unavailable; serving text-only history: ${String(error)}`)
46
+ }
47
+ }
48
+ }
49
+ return mergeMediaAttachmentRefs(...sources, associated)
50
+ }
14
51
 
15
52
  sessionsRouter.get('/sessions/recent', (_req, res) => {
16
53
  const sessions = getRecentSessions(24 * 60 * 60_000)
@@ -62,19 +99,22 @@ sessionsRouter.get('/sessions/:id/messages', (req, res) => {
62
99
  attachments?: MediaAttachmentRef[]
63
100
  }> = []
64
101
  const session = getSessionRaw(req.params.id)
102
+ const activeEra = currentMessageEraState()
65
103
  for (let i = 0; i < exchanges.length; i++) {
66
104
  const ex = exchanges[i]
67
105
  if (ex.role === 'user') {
68
106
  const next = exchanges[i + 1]
69
107
  if (next && next.role === 'assistant') {
70
- const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
108
+ const globalMsgNum = ex.globalMsgNum ?? next.globalMsgNum
109
+ const messageEra = ex.messageEra ?? next.messageEra
110
+ const attachments = turnAttachments(req.params.id, globalMsgNum, messageEra, activeEra, ex.attachments, next.attachments)
71
111
  const modelPreference = resolveExchangePairModel(ex, next, session?.modelPreference)
72
112
  messages.push({
73
113
  query: ex.content,
74
114
  text: next.content,
75
115
  timestamp: next.timestamp,
76
116
  sessionId: req.params.id,
77
- ...((ex.globalMsgNum ?? next.globalMsgNum) != null ? { no: ex.globalMsgNum ?? next.globalMsgNum } : {}),
117
+ ...(globalMsgNum != null ? { no: globalMsgNum } : {}),
78
118
  ...(modelPreference ? { modelPreference } : {}),
79
119
  ...(attachments.length > 0 ? { attachments } : {}),
80
120
  })
@@ -246,14 +286,23 @@ sessionsRouter.get('/sessions/today/live-chats', (_req, res) => {
246
286
  // back to the archived chat's sessionId via getArchiveDayMessages.
247
287
  sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
248
288
  const todayDate = localDay()
249
- const era = currentMessageEra()
289
+ const activeEra = currentMessageEraState()
290
+ const era = activeEra.era
250
291
 
251
292
  const archivedMessages = getArchiveDayMessages(todayDate)
252
293
  .filter(m => exchangeBelongsToEra(m, era))
253
- .map(m => ({
254
- ...m,
255
- source: 'archive' as const,
256
- }))
294
+ .map(m => {
295
+ const globalMsgNum = m.globalMsgNum ?? m.no
296
+ const attachments = turnAttachments(m.sessionId, globalMsgNum, m.messageEra, activeEra, m.attachments)
297
+ // Never pass an unvalidated archive attachment array through the object
298
+ // spread when the merge rejects it.
299
+ const { attachments: _rawAttachments, ...message } = m
300
+ return {
301
+ ...message,
302
+ source: 'archive' as const,
303
+ ...(attachments.length > 0 ? { attachments } : {}),
304
+ }
305
+ })
257
306
 
258
307
  const liveMessages: Array<{
259
308
  query: string
@@ -278,9 +327,9 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
278
327
  if (!exchangeBelongsToEra(ex, era)) continue
279
328
  const next = session.exchanges[i + 1]
280
329
  if (next && next.role === 'assistant') {
281
- const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
282
330
  const globalMsgNum = ex.globalMsgNum ?? next.globalMsgNum
283
331
  const messageEra = ex.messageEra ?? next.messageEra
332
+ const attachments = turnAttachments(session.id, globalMsgNum, messageEra, activeEra, ex.attachments, next.attachments)
284
333
  const modelPreference = resolveExchangePairModel(ex, next, session.modelPreference)
285
334
  liveMessages.push({
286
335
  query: ex.content,