@gotcos/glasses-server 6.3.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/server/index.ts CHANGED
@@ -1,12 +1,12 @@
1
- // Load .env FIRST ESM evaluates this module before subsequent imports,
2
- // ensuring process.env is populated before python-bridge.ts reads COS_SCRIPTS_DIR.
3
- import './env.js'
1
+ // Load env and claim the one server slot before any mutable module initializes.
2
+ import './bootstrap.js'
4
3
 
5
4
  import express from 'express'
6
5
  import cors from 'cors'
7
6
  import path from 'node:path'
8
7
  import { fileURLToPath } from 'node:url'
9
8
  import { createServer as createHttpsServer } from 'node:https'
9
+ import { createServer as createHttpServer } from 'node:http'
10
10
  import { readFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
11
11
  import { networkInterfaces, homedir } from 'node:os'
12
12
  import { join } from 'node:path'
@@ -23,13 +23,21 @@ import { openaiKeyRouter } from './routes/openai-key.js'
23
23
  import { messageRefRouter } from './routes/message-ref.js'
24
24
  import { archiveRouter } from './routes/archive.js'
25
25
  import { sessionsRouter } from './routes/sessions.js'
26
+ import { mediaRouter, mediaBodyParser } from './routes/media.js'
26
27
  import { prewarmContext } from './lib/context-builder.js'
27
28
  import { preWarmCLI } from './lib/claude-bridge.js'
29
+ import { getCodexRunConfig } from './lib/codex-run-ledger.js'
30
+ import {
31
+ startCodexModelCatalogRefresh,
32
+ stopCodexModelCatalogRefresh,
33
+ } from './lib/codex-model-catalog.js'
28
34
  import { startWhisperServer, stopWhisperServer } from './lib/whisper-local.js'
29
35
  import { initSileroVAD } from './lib/vad-silero.js'
30
36
  import { initSessionCache } from './lib/session-cache-writer.js'
31
37
  import { initSpeakerEmbeddings } from './lib/speaker-embeddings.js'
32
38
  import { logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversation.js'
39
+ import { getMediaStore } from './lib/media-store.js'
40
+ import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
33
41
 
34
42
  const app = express()
35
43
  const PORT = parseInt(process.env.PORT ?? '3141', 10)
@@ -99,8 +107,6 @@ app.use(cors({
99
107
  cb(new Error('CORS blocked'))
100
108
  },
101
109
  }))
102
- app.use(express.json({ limit: '10mb' }))
103
-
104
110
  // Auth middleware — always active (token is auto-generated if not set)
105
111
  app.use('/api', (req, res, next) => {
106
112
  // Allow health checks, display stream, and client diagnostics without auth.
@@ -120,6 +126,11 @@ app.use('/api', (req, res, next) => {
120
126
  next()
121
127
  })
122
128
 
129
+ // Authenticate before parsing large upload bodies. The 16 MB allowance stays
130
+ // scoped to /api/media; every other route retains the 10 MB ceiling.
131
+ app.use('/api/media', mediaBodyParser)
132
+ app.use(express.json({ limit: '10mb' }))
133
+
123
134
  // Request counter — must be before route registrations
124
135
  app.use((_req, _res, next) => {
125
136
  serverMetrics.requestCount++
@@ -139,6 +150,7 @@ app.use('/api', openaiKeyRouter)
139
150
  app.use('/api', messageRefRouter)
140
151
  app.use('/api', archiveRouter)
141
152
  app.use('/api', sessionsRouter)
153
+ app.use('/api', mediaRouter)
142
154
 
143
155
  // OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
144
156
  // Mounted at root — routes are /v1/chat/completions and /v1/models
@@ -164,12 +176,19 @@ process.on('SIGTERM', () => {
164
176
  // Production stops (kill, service managers) send SIGTERM — flush session logs
165
177
  // exactly like SIGINT so active conversations aren't lost on shutdown.
166
178
  try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
179
+ stopCodexModelCatalogRefresh()
180
+ stopWhisperServer()
181
+ process.exit(0)
182
+ })
183
+ process.on('SIGINT', () => {
184
+ logActiveSessionsOnShutdown()
185
+ stopCodexModelCatalogRefresh()
167
186
  stopWhisperServer()
168
187
  process.exit(0)
169
188
  })
170
- process.on('SIGINT', () => { logActiveSessionsOnShutdown(); stopWhisperServer(); process.exit(0) })
171
189
 
172
- // Crash protection log and survive instead of dying mid-meeting
190
+ // Crash protection for runtime work. Listener failures are handled separately
191
+ // and exit immediately so a supervisor can restart a clean, unified process.
173
192
  process.on('uncaughtException', (err) => {
174
193
  console.error('[CRASH GUARD] Uncaught exception (server stays alive):', err.message)
175
194
  console.error(err.stack)
@@ -184,19 +203,24 @@ process.on('unhandledRejection', (reason: any) => {
184
203
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
185
204
  const HTTPS_PORT = parseInt(process.env.HTTPS_PORT ?? '3143', 10)
186
205
  const certDir = path.join(__dirname, 'certs')
206
+ const listeners: RequiredListener[] = []
187
207
  if (existsSync(path.join(certDir, 'cert.pem'))) {
188
208
  const httpsServer = createHttpsServer({
189
209
  cert: readFileSync(path.join(certDir, 'cert.pem')),
190
210
  key: readFileSync(path.join(certDir, 'key.pem')),
191
211
  }, app)
192
- httpsServer.listen(HTTPS_PORT, BIND_HOST, () => {
193
- console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
194
- })
212
+ listeners.push({ server: httpsServer, port: HTTPS_PORT, host: BIND_HOST, label: 'HTTPS' })
195
213
  } else {
196
214
  console.log('[COS API] No certs found — HTTPS disabled (drop cert.pem/key.pem in server/certs to enable)')
197
215
  }
198
216
 
199
- app.listen(PORT, BIND_HOST, () => {
217
+ const httpServer = createHttpServer(app)
218
+ listeners.push({ server: httpServer, port: PORT, host: BIND_HOST, label: 'HTTP' })
219
+
220
+ listenRequiredServers(listeners).then(() => {
221
+ if (listeners.some(listener => listener.label === 'HTTPS')) {
222
+ console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
223
+ }
200
224
  console.log(`[COS API] HTTP server running on http://${BIND_HOST}:${PORT}`)
201
225
  console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
202
226
 
@@ -228,15 +252,25 @@ app.listen(PORT, BIND_HOST, () => {
228
252
  console.log('')
229
253
  }
230
254
 
231
- // Check Claude CLI availability the chat backend
255
+ // Check Claude CLI availability. Codex-only installs remain fully valid.
256
+ let claudeAvailable = false
232
257
  try {
233
258
  execSync('claude --version', { timeout: 5000, stdio: 'pipe' })
259
+ claudeAvailable = true
234
260
  console.log('[COS API] Claude Code CLI detected')
235
261
  } catch {
236
262
  console.warn('[COS API] Claude Code CLI not found — install from https://claude.ai/download')
237
- console.warn('[COS API] AI queries will not work without the Claude Code CLI')
263
+ console.warn('[COS API] Claude models unavailable; Codex models still work when Codex CLI is installed')
238
264
  }
239
265
 
266
+ const codexConfig = getCodexRunConfig()
267
+ console.log(`[COS API] Codex mode: ${codexConfig.persistenceEnabled ? 'persistent' : 'ephemeral'} · ${codexConfig.reasoningEffort} · ${codexConfig.trustMode}`)
268
+ console.log(`[COS API] Codex models (${codexConfig.catalogSource}): ${codexConfig.availableModels.map(item => `${item.displayName}=${item.model}`).join(' · ')}`)
269
+ console.log(`[COS API] Codex workdir: ${codexConfig.cwd}`)
270
+ // Refresh immediately and then periodically. The catalog module retains the
271
+ // last-known-good snapshot if Codex is temporarily unavailable.
272
+ startCodexModelCatalogRefresh()
273
+
240
274
  if (COS_MODE) {
241
275
  initSessionCache()
242
276
  // Pre-warm context cache so first query doesn't wait for the pipeline
@@ -251,9 +285,17 @@ app.listen(PORT, BIND_HOST, () => {
251
285
  const vadOk = initSileroVAD()
252
286
  console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
253
287
 
254
- // Pre-warm the Claude CLI so the first query doesn't eat a 2-15s cold start
255
- preWarmCLI().catch(err => console.error('[startup] CLI pre-warm error:', err))
288
+ // Pre-warm Claude only when installed so Codex-only startup stays quiet.
289
+ if (claudeAvailable) {
290
+ preWarmCLI().catch(err => console.error('[startup] CLI pre-warm error:', err))
291
+ }
256
292
 
257
293
  // Auto-snapshot active sessions every 5 min (survives restarts)
258
294
  startAutoSnapshot(5 * 60_000)
295
+
296
+ // Durable media GC (staged/reserved expiry + generated-image content TTL).
297
+ getMediaStore().startGC()
298
+ }).catch((error: NodeJS.ErrnoException) => {
299
+ console.error(`[COS API] Fatal listener startup: ${error.message}`)
300
+ process.exit(error.code === 'EADDRINUSE' ? 75 : 74)
259
301
  })
@@ -0,0 +1,168 @@
1
+ // Safe, bounded activity previews for the live job monitor. This module only
2
+ // surfaces observable tool actions/results; it never exposes model reasoning.
3
+
4
+ export interface ActivityPreviewLine {
5
+ kind: 'input' | 'output'
6
+ text: string
7
+ }
8
+
9
+ const ANSI_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g
10
+ const PRIVATE_MATERIAL_BLOCK_RE = /-----BEGIN [A-Z0-9 ]*(?:PRIVATE KEY|CREDENTIALS?)-----[\s\S]*?(?:-----END [A-Z0-9 ]*(?:PRIVATE KEY|CREDENTIALS?)-----|$)/gi
11
+
12
+ const SECRET_PATTERNS: Array<[RegExp, string]> = [
13
+ // Cookie headers are credential containers; partial parsing is unsafe.
14
+ [/\b((?:set-)?cookie\s*:\s*)[^\r\n]+/gi, '$1[redacted]'],
15
+ // Authorization and proxy-authorization headers, including Basic credentials.
16
+ [/\b((?:proxy-)?authorization\s*[:=]\s*)(?:bearer|basic|digest)\s+[^\s,;"']+/gi, '$1[redacted]'],
17
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer [redacted]'],
18
+ // Structured assignments and JSON/env values. Key names are intentionally
19
+ // broad; a false-positive here is safer than putting a credential on a lens.
20
+ [/\b((?:[a-z0-9]+[_-])*(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|access[_-]?token|refresh[_-]?token|session[_-]?token|token|credential|auth(?:orization)?|password|passwd|pwd|secret|client[_-]?secret|private[_-]?key|database[_-]?url|connection[_-]?string|dsn|session(?:[_-]?id)?|cookie|phpsessid|jsessionid|sid)(?:[_-][a-z0-9]+)*["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}&]+)/gi, '$1[redacted]'],
21
+ // Shell/env whitespace assignments such as `export TOKEN value`.
22
+ [/\b((?:export|set|env)\s+(?:[a-z0-9]+[_-])*(?:api[_-]?key|access[_-]?token|refresh[_-]?token|session[_-]?token|token|credential|auth|password|passwd|pwd|secret|client[_-]?secret|private[_-]?key|database[_-]?url|connection[_-]?string|dsn|session(?:[_-]?id)?|cookie)\s+)(?:"[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
23
+ // Command-line flags commonly used for credentials.
24
+ [/(\s--?(?:api-key|token|access-token|refresh-token|password|passwd|secret|client-secret)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
25
+ [/(\s(?:-u|--user)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
26
+ // URL userinfo and secret-bearing query parameters.
27
+ [/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]*:[^\s/@]+@/gi, '$1[redacted]@'],
28
+ [/([?&](?:api[_-]?key|access[_-]?token|token|auth|password|secret)=)[^&#\s]+/gi, '$1[redacted]'],
29
+ // Common provider token formats.
30
+ [/\b(?:sk-(?:proj-|live-|test-)?|sk_(?:live|test)_|sess-|pat-|gh[pousr]_|github_pat_|glpat-|npm_|pypi-|shpat_|xox[baprs]-)[A-Za-z0-9._-]{8,}\b/gi, '[redacted-token]'],
31
+ [/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, '[redacted-aws-key]'],
32
+ [/\bAIza[A-Za-z0-9_-]{30,}\b/g, '[redacted-google-key]'],
33
+ // JWTs and PEM material.
34
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[redacted-jwt]'],
35
+ [/-----BEGIN [A-Z0-9 ]*(?:PRIVATE KEY|CREDENTIALS?)-----/gi, '[redacted-private-material]'],
36
+ [/-----END [A-Z0-9 ]*(?:PRIVATE KEY|CREDENTIALS?)-----/gi, '[redacted-private-material]'],
37
+ ]
38
+
39
+ function looksOpaqueSecret(text: string): boolean {
40
+ const compact = text.replace(/\s/g, '')
41
+ if (compact.length < 40) return false
42
+ if (!/^[A-Za-z0-9+/=_:.-]+$/.test(compact)) return false
43
+
44
+ // PEM bodies are conventionally wrapped into 64-character base64 lines,
45
+ // but exporters commonly use widths from 40–72. Suppress every standalone
46
+ // base64/hex chunk in that range, including low-variety padding lines.
47
+ if (!/\s/.test(text) && /^(?:[A-Za-z0-9+/]{40,}={0,2}|[A-Fa-f0-9]{40,})$/.test(compact)) return true
48
+
49
+ // Ordinary prose rarely has this mix without spaces. Requiring several
50
+ // character classes avoids hiding long paths or repeated divider lines.
51
+ let classes = 0
52
+ if (/[a-z]/.test(compact)) classes++
53
+ if (/[A-Z]/.test(compact)) classes++
54
+ if (/\d/.test(compact)) classes++
55
+ if (/[+/=_:.-]/.test(compact)) classes++
56
+ return classes >= 3
57
+ }
58
+
59
+ export function sanitizeActivityPreview(raw: unknown, max = 180): string | null {
60
+ if (typeof raw !== 'string') return null
61
+ let text = raw.replace(ANSI_RE, '').replace(PRIVATE_MATERIAL_BLOCK_RE, '[private material hidden]')
62
+ for (const [pattern, replacement] of SECRET_PATTERNS) text = text.replace(pattern, replacement)
63
+ text = text.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim()
64
+ if (!text) return null
65
+ if (looksOpaqueSecret(text)) return '[opaque output hidden]'
66
+ const safeMax = Number.isFinite(max) && max >= 8 ? Math.floor(max) : 180
67
+ return text.length > safeMax ? `${text.slice(0, safeMax - 1)}…` : text
68
+ }
69
+
70
+ export function textPreviewLines(raw: unknown, maxLines = 3): string[] {
71
+ if (typeof raw !== 'string') return []
72
+ const safeMaxLines = Number.isFinite(maxLines) && maxLines > 0 ? Math.min(Math.floor(maxLines), 5) : 3
73
+ const lines = raw.replace(PRIVATE_MATERIAL_BLOCK_RE, '[private material hidden]').replace(/\r\n?/g, '\n').split('\n')
74
+ .map(line => sanitizeActivityPreview(line))
75
+ .filter((line): line is string => !!line)
76
+ return lines.slice(-safeMaxLines)
77
+ }
78
+
79
+ function resultText(value: unknown): string | null {
80
+ if (typeof value === 'string') return value
81
+ if (Array.isArray(value)) {
82
+ const parts = value.flatMap((part) => {
83
+ if (typeof part === 'string') return [part]
84
+ if (typeof part?.text === 'string') return [part.text]
85
+ if (typeof part?.content === 'string') return [part.content]
86
+ return []
87
+ })
88
+ return parts.length ? parts.join('\n') : null
89
+ }
90
+ if (value && typeof value === 'object') {
91
+ const record = value as Record<string, unknown>
92
+ return resultText(record.content) ?? resultText(record.text) ?? resultText(record.output)
93
+ }
94
+ return null
95
+ }
96
+
97
+ /** Extract observable Codex tool activity from `codex exec --json` events. */
98
+ export function codexActivityPreviewLines(event: any): ActivityPreviewLine[] {
99
+ const eventType = String(event?.type ?? '')
100
+ const item = event?.item ?? event?.payload ?? {}
101
+ const itemType = String(item?.type ?? '')
102
+ const lines: ActivityPreviewLine[] = []
103
+
104
+ if (/command_execution|command|shell|exec/i.test(itemType)) {
105
+ if (/started/i.test(eventType)) {
106
+ const command = sanitizeActivityPreview(item.command ?? item.input)
107
+ if (command) lines.push({ kind: 'input', text: `$ ${command}` })
108
+ }
109
+ const output = item.aggregated_output ?? item.output ?? item.stdout ?? item.stderr
110
+ for (const text of textPreviewLines(output)) lines.push({ kind: 'output', text })
111
+ if (/completed/i.test(eventType) && Number.isInteger(item.exit_code)) {
112
+ lines.push({ kind: 'output', text: `exit ${item.exit_code}` })
113
+ }
114
+ return lines
115
+ }
116
+
117
+ if (/file_change|patch/i.test(itemType)) {
118
+ const changes = Array.isArray(item.changes) ? item.changes : []
119
+ for (const change of changes.slice(-3)) {
120
+ const path = sanitizeActivityPreview(change?.path ?? change?.file)
121
+ if (path) lines.push({ kind: 'output', text: `${change?.kind ?? 'updated'} ${path}` })
122
+ }
123
+ return lines
124
+ }
125
+
126
+ if (/web_search/i.test(itemType)) {
127
+ const query = sanitizeActivityPreview(item.query)
128
+ if (query) lines.push({ kind: 'input', text: `Search: ${query}` })
129
+ return lines
130
+ }
131
+
132
+ if (/mcp_tool_call|tool_call|tool/i.test(itemType)) {
133
+ const name = sanitizeActivityPreview([item.server, item.tool ?? item.name].filter(Boolean).join('.'))
134
+ if (name && /started/i.test(eventType)) lines.push({ kind: 'input', text: name })
135
+ const text = resultText(item.result ?? item.output)
136
+ for (const preview of textPreviewLines(text)) lines.push({ kind: 'output', text: preview })
137
+ }
138
+
139
+ return lines
140
+ }
141
+
142
+ /** Extract text from Claude tool_result events, never assistant reasoning. */
143
+ export function claudeToolResultPreviewLines(event: any): ActivityPreviewLine[] {
144
+ if (event?.type !== 'user') return []
145
+ const content = event?.message?.content ?? event?.content
146
+ if (!Array.isArray(content)) return []
147
+ const lines: ActivityPreviewLine[] = []
148
+ for (const block of content) {
149
+ if (block?.type !== 'tool_result') continue
150
+ const text = resultText(block.content)
151
+ for (const preview of textPreviewLines(text)) lines.push({ kind: 'output', text: preview })
152
+ }
153
+ return lines.slice(-3)
154
+ }
155
+
156
+ /** Turn completed Claude tool input JSON into one allowlisted useful line. */
157
+ export function claudeToolInputPreview(name: string, rawJson: string): ActivityPreviewLine | null {
158
+ let input: Record<string, unknown> = {}
159
+ try { input = JSON.parse(rawJson || '{}') } catch { return null }
160
+ const candidate = input.query ?? input.url ?? input.file_path ?? input.path ?? input.command
161
+ const preview = sanitizeActivityPreview(candidate)
162
+ if (!preview) return null
163
+ const label = name === 'WebSearch' ? 'Search'
164
+ : name === 'WebFetch' ? 'Read'
165
+ : name === 'Read' ? 'File'
166
+ : name
167
+ return { kind: 'input', text: `${label}: ${preview}` }
168
+ }
@@ -11,6 +11,7 @@ import { promisify } from 'node:util'
11
11
  import { logTokenAudit } from './token-audit.js'
12
12
  import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
13
13
  import { consumeArchiveLLMBudget } from './archive-budget.js'
14
+ import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
14
15
 
15
16
  const execAsync = promisify(exec)
16
17
  import type { Exchange } from './conversation.js'
@@ -348,20 +349,30 @@ export function getArchiveChats(date: string): ArchiveChatSummary[] {
348
349
  }))
349
350
  }
350
351
 
351
- /** Get paired Q&A messages for a specific chat within a day */
352
- export function getArchiveChatMessages(date: string, chatIndex: number): Array<{ query: string; text: string; timestamp: number }> {
352
+ /** Get paired Q&A messages for a specific chat within a day. Request refs on
353
+ * the user turn and model-output refs on the assistant turn surface together. */
354
+ export function getArchiveChatMessages(
355
+ date: string,
356
+ chatIndex: number,
357
+ ): Array<{ query: string; text: string; timestamp: number; attachments?: MediaAttachmentRef[] }> {
353
358
  const archive = loadArchive(date)
354
359
  if (!archive) return []
355
360
  const chat = archive.chats.find(c => c.id === chatIndex)
356
361
  if (!chat) return []
357
362
 
358
- const messages: Array<{ query: string; text: string; timestamp: number }> = []
363
+ const messages: Array<{ query: string; text: string; timestamp: number; attachments?: MediaAttachmentRef[] }> = []
359
364
  for (let i = 0; i < chat.exchanges.length; i++) {
360
365
  const ex = chat.exchanges[i]
361
366
  if (ex.role === 'user') {
362
367
  const next = chat.exchanges[i + 1]
363
368
  if (next && next.role === 'assistant') {
364
- messages.push({ query: ex.content, text: next.content, timestamp: next.timestamp })
369
+ const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
370
+ messages.push({
371
+ query: ex.content,
372
+ text: next.content,
373
+ timestamp: next.timestamp,
374
+ ...(attachments.length > 0 ? { attachments } : {}),
375
+ })
365
376
  i++ // skip assistant
366
377
  }
367
378
  }
@@ -374,23 +385,26 @@ export function getArchiveChatMessages(date: string, chatIndex: number): Array<{
374
385
  * (sessionId, timestamp) instead of bare timestamp (collision-prone). */
375
386
  export function getArchiveDayMessages(
376
387
  date: string,
377
- ): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string }> {
388
+ ): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; attachments?: MediaAttachmentRef[] }> {
378
389
  const archive = loadArchive(date)
379
390
  if (!archive) return []
380
391
 
381
- const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string }> = []
392
+ const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; attachments?: MediaAttachmentRef[] }> = []
382
393
  for (const chat of archive.chats) {
383
394
  for (let i = 0; i < chat.exchanges.length; i++) {
384
395
  const ex = chat.exchanges[i]
385
396
  if (ex.role === 'user') {
386
397
  const next = chat.exchanges[i + 1]
387
398
  if (next && next.role === 'assistant') {
399
+ const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
388
400
  messages.push({
389
401
  query: ex.content,
390
402
  text: next.content,
391
403
  timestamp: next.timestamp,
392
404
  chatIndex: chat.id,
393
405
  sessionId: chat.sessionId,
406
+ ...((ex.globalMsgNum ?? next.globalMsgNum) != null ? { no: ex.globalMsgNum ?? next.globalMsgNum } : {}),
407
+ ...(attachments.length > 0 ? { attachments } : {}),
394
408
  })
395
409
  i++
396
410
  }