@gotcos/glasses-server 6.24.2 → 6.24.4

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.
@@ -15,20 +15,28 @@
15
15
  import {
16
16
  MAX_ATTACHMENTS_PER_PROMPT,
17
17
  isValidMediaId,
18
+ mediaCategoryOf,
18
19
  parseMediaIdList,
19
20
  type MediaAttachmentRef,
20
21
  } from '../../shared/media-attachment.js'
21
22
  import { getMediaStore, MediaStoreError } from './media-store.js'
22
23
  import { strictBase64Decode, ImageSafetyError } from './image-safety.js'
23
24
  import type { ModelImageInput } from './model-image-input.js'
25
+ import { formatAttachmentSourceData, type AttachmentSourceData } from './prompt-reference-boundary.js'
24
26
 
25
27
  export interface ResolvedQueryAttachments {
26
28
  inputs: ModelImageInput[]
27
29
  refs: MediaAttachmentRef[]
28
30
  /** Ids to associate with the final message when the run completes. */
29
31
  ids: string[]
32
+ /** Provider-neutral quoted document data. Rebuilt at execution time; never
33
+ * persisted in a durable job or public attachment ref. */
34
+ promptBlock?: string
30
35
  }
31
36
 
37
+ const MAX_MODEL_IMAGE_INPUTS = 12
38
+ const MAX_ATTACHMENT_PROMPT_CHARS = 60_000
39
+
32
40
  export class QueryAttachmentError extends Error {
33
41
  readonly status: number
34
42
  readonly code: string
@@ -89,6 +97,11 @@ export async function resolveQueryAttachments(body: QueryImageBody): Promise<Res
89
97
  : undefined
90
98
 
91
99
  const inputs: ModelImageInput[] = []
100
+ const refs: MediaAttachmentRef[] = []
101
+ const resolvedIds: string[] = []
102
+ const sourceData: AttachmentSourceData[] = []
103
+ const optionalDocumentImages: Array<{ path: string; attachment: MediaAttachmentRef }> = []
104
+ let promptChars = 0
92
105
 
93
106
  // Reject over-limit requests BEFORE the shared parser caps them. Silent
94
107
  // truncation would run a successful vision query while dropping a user's
@@ -107,8 +120,39 @@ export async function resolveQueryAttachments(body: QueryImageBody): Promise<Res
107
120
  // 1. Durable attachment ids.
108
121
  const ids = parseMediaIdList(body.attachmentIds)
109
122
  for (const id of ids) {
110
- const { record, path } = store.resolveUsable(id, clientQueueItemId)
111
- inputs.push({ path, attachment: record.ref, deleteAfterRun: false })
123
+ const resolved = store.resolveModelDerivatives(id, clientQueueItemId)
124
+ const category = mediaCategoryOf(resolved.record.ref)
125
+ refs.push(resolved.record.ref)
126
+ resolvedIds.push(id)
127
+ if (category === 'image') {
128
+ inputs.push({ path: resolved.originalPath, attachment: resolved.record.ref, deleteAfterRun: false })
129
+ } else if (category === 'video') {
130
+ for (const path of resolved.imagePaths) {
131
+ inputs.push({ path, attachment: resolved.record.ref, deleteAfterRun: false })
132
+ }
133
+ } else {
134
+ // PDF page images are an optional layout aid. Text remains canonical;
135
+ // use only the remaining image budget instead of dropping user photos
136
+ // or making a text-readable PDF fail because it has many pages.
137
+ for (const path of resolved.imagePaths) optionalDocumentImages.push({ path, attachment: resolved.record.ref })
138
+ if (resolved.text !== undefined) {
139
+ const room = Math.max(0, MAX_ATTACHMENT_PROMPT_CHARS - promptChars)
140
+ const content = resolved.text.slice(0, room)
141
+ const truncated = content.length < resolved.text.length || resolved.record.ref.truncated === true
142
+ sourceData.push({
143
+ id,
144
+ label: resolved.record.ref.label ?? 'Attached document',
145
+ mime: resolved.record.ref.mime,
146
+ content,
147
+ ...(truncated ? { truncated: true } : {}),
148
+ })
149
+ promptChars += content.length
150
+ }
151
+ }
152
+ }
153
+ const requiredVisualCount = inputs.filter(input => mediaCategoryOf(input.attachment) !== 'document').length
154
+ if (requiredVisualCount > MAX_MODEL_IMAGE_INPUTS) {
155
+ throw new QueryAttachmentError(400, 'too_many_attachment_frames', `max ${MAX_MODEL_IMAGE_INPUTS} image/video frames per prompt`)
112
156
  }
113
157
 
114
158
  // 2. Legacy base64 — ingested through the SAME store (no bypass).
@@ -118,12 +162,26 @@ export async function resolveQueryAttachments(body: QueryImageBody): Promise<Res
118
162
  const ref = await store.ingestImage({ bytes, kind: 'user_photo', sessionId })
119
163
  const { path } = store.resolveUsable(ref.id)
120
164
  inputs.push({ path, attachment: ref, deleteAfterRun: false })
165
+ refs.push(ref)
166
+ resolvedIds.push(ref.id)
167
+ }
168
+
169
+ // PDF page renders are optional aids. Add them only after all required
170
+ // photo and video inputs so a PDF can never evict a user's visual source.
171
+ const remaining = Math.max(0, MAX_MODEL_IMAGE_INPUTS - inputs.length)
172
+ for (const item of optionalDocumentImages.slice(0, remaining)) {
173
+ inputs.push({ path: item.path, attachment: item.attachment, deleteAfterRun: false })
174
+ }
175
+
176
+ if (inputs.length > MAX_MODEL_IMAGE_INPUTS) {
177
+ throw new QueryAttachmentError(400, 'too_many_attachment_frames', `max ${MAX_MODEL_IMAGE_INPUTS} image/video frames per prompt`)
121
178
  }
122
179
 
123
180
  return {
124
181
  inputs,
125
- refs: inputs.map((i) => i.attachment),
126
- ids: inputs.map((i) => i.attachment.id),
182
+ refs,
183
+ ids: resolvedIds,
184
+ ...(sourceData.length > 0 ? { promptBlock: formatAttachmentSourceData(sourceData) } : {}),
127
185
  }
128
186
  } catch (err) {
129
187
  if (err instanceof QueryAttachmentError) throw err
@@ -18,6 +18,7 @@ import {
18
18
  type ModelPreference,
19
19
  } from '../../shared/model-preference.js'
20
20
  import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
21
+ import { attachmentHistoryPrefix, defaultAttachmentRequest } from '../../shared/media-attachment.js'
21
22
  import {
22
23
  findExchangesByJobIdentity,
23
24
  flushConversationToDisk,
@@ -99,9 +100,11 @@ async function projectPublicConversationTerminal(
99
100
 
100
101
  const existing = findExchangesByJobIdentity(request.sessionId, identity)
101
102
  const existingAssistant = existing.find(exchange => exchange.role === 'assistant')
102
- const imageCount = request.attachmentRefs.length
103
- const photoPrefix = imageCount === 1 ? '[Photo]' : imageCount > 1 ? `[${imageCount} Photos]` : ''
104
- const userContent = photoPrefix ? `${photoPrefix} ${request.query || 'What do you see?'}` : request.query
103
+ const attachmentPrefix = attachmentHistoryPrefix(request.attachmentRefs)
104
+ const defaultRequest = defaultAttachmentRequest(request.attachmentRefs)
105
+ const userContent = attachmentPrefix
106
+ ? `${attachmentPrefix} ${request.query || defaultRequest}`
107
+ : request.query
105
108
  const requestIds = new Set(request.attachmentRefs.map(ref => ref.id))
106
109
  const outputAttachments = job.attachments.filter(ref => !requestIds.has(ref.id))
107
110
  const existingOutputAttachments = existingAssistant?.attachments?.filter(ref => !requestIds.has(ref.id))
@@ -281,6 +284,8 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
281
284
  : {}),
282
285
  clientJobId: request.clientJobId,
283
286
  generation: request.generation,
287
+ requestAttachments: resolvedAttachments.refs,
288
+ attachmentPromptBlock: resolvedAttachments.promptBlock,
284
289
  sessionLockHeld: true,
285
290
  },
286
291
  )
@@ -0,0 +1,324 @@
1
+ // Strict document/video validation and derivative generation for user uploads.
2
+ // Paths and filenames never enter the public attachment contract. Inputs are
3
+ // bounded bytes from the authenticated binary route; no URL/path ingestion.
4
+
5
+ import { spawn } from 'node:child_process'
6
+ import {
7
+ mkdtempSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from 'node:fs'
13
+ import { tmpdir } from 'node:os'
14
+ import { extname, join } from 'node:path'
15
+ import type { MediaMime } from '../../shared/media-attachment.js'
16
+
17
+ export const MAX_RICH_MEDIA_BYTES = 64 * 1024 * 1024
18
+ export const MAX_DOCUMENT_TEXT_CHARS = 100_000
19
+ export const MAX_VIDEO_DURATION_MS = 20 * 60_000
20
+ export const MAX_DERIVATIVE_IMAGES = 8
21
+ const PROCESS_STDERR_MAX = 8_192
22
+ const PROCESS_TIMEOUT_MS = 30_000
23
+
24
+ let richMediaCapabilityCache: { pdf: boolean; video: boolean } | null = null
25
+
26
+ export type RichMediaErrorCode =
27
+ | 'unsupported_attachment_format'
28
+ | 'attachment_too_large'
29
+ | 'corrupt_attachment'
30
+ | 'attachment_processing_unavailable'
31
+ | 'attachment_processing_failed'
32
+ | 'video_too_long'
33
+
34
+ export class RichMediaSafetyError extends Error {
35
+ constructor(readonly code: RichMediaErrorCode, message: string) {
36
+ super(message)
37
+ this.name = 'RichMediaSafetyError'
38
+ }
39
+ }
40
+
41
+ export interface PreparedDocument {
42
+ category: 'document'
43
+ mime: Extract<MediaMime, 'text/plain' | 'text/markdown' | 'text/csv' | 'application/json' | 'application/pdf'>
44
+ original: Buffer
45
+ extractedText: string
46
+ textTruncated: boolean
47
+ pageImages: Buffer[]
48
+ }
49
+
50
+ export interface PreparedVideo {
51
+ category: 'video'
52
+ mime: Extract<MediaMime, 'video/mp4' | 'video/quicktime'>
53
+ original: Buffer
54
+ width: number
55
+ height: number
56
+ durationMs: number
57
+ frames: Buffer[]
58
+ }
59
+
60
+ export type PreparedRichMedia = PreparedDocument | PreparedVideo
61
+
62
+ async function executableReady(command: string, args: string[]): Promise<boolean> {
63
+ return new Promise(resolve => {
64
+ let settled = false
65
+ let timer: ReturnType<typeof setTimeout> | null = null
66
+ const finish = (ready: boolean) => {
67
+ if (settled) return
68
+ settled = true
69
+ if (timer) clearTimeout(timer)
70
+ resolve(ready)
71
+ }
72
+ let proc: ReturnType<typeof spawn>
73
+ try {
74
+ proc = spawn(command, args, { stdio: 'ignore' })
75
+ } catch {
76
+ finish(false)
77
+ return
78
+ }
79
+ timer = setTimeout(() => {
80
+ proc.kill('SIGKILL')
81
+ finish(false)
82
+ }, 2_000)
83
+ timer.unref?.()
84
+ proc.once('error', () => finish(false))
85
+ proc.once('close', code => finish(code === 0))
86
+ })
87
+ }
88
+
89
+ /** Public capability only—never paths. Cached because health is polled often. */
90
+ export async function getRichMediaProcessingCapabilities(): Promise<{ pdf: boolean; video: boolean }> {
91
+ if (richMediaCapabilityCache) return { ...richMediaCapabilityCache }
92
+ const [pdftotext, pdftoppm, ffmpeg, ffprobe] = await Promise.all([
93
+ executableReady('pdftotext', ['-v']),
94
+ executableReady('pdftoppm', ['-v']),
95
+ executableReady('ffmpeg', ['-version']),
96
+ executableReady('ffprobe', ['-version']),
97
+ ])
98
+ richMediaCapabilityCache = { pdf: pdftotext && pdftoppm, video: ffmpeg && ffprobe }
99
+ return { ...richMediaCapabilityCache }
100
+ }
101
+
102
+ export function _resetRichMediaCapabilityCacheForTests(): void {
103
+ richMediaCapabilityCache = null
104
+ }
105
+
106
+ function boundedLabel(label: string | undefined): string {
107
+ return (label ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 120)
108
+ }
109
+
110
+ function fileExtension(label: string | undefined): string {
111
+ return extname(boundedLabel(label)).toLowerCase()
112
+ }
113
+
114
+ function isPdf(bytes: Buffer): boolean {
115
+ return bytes.length >= 5 && bytes.subarray(0, 5).toString('ascii') === '%PDF-'
116
+ }
117
+
118
+ function isIsoBmff(bytes: Buffer): boolean {
119
+ return bytes.length >= 12 && bytes.subarray(4, 8).toString('ascii') === 'ftyp'
120
+ }
121
+
122
+ function decodeStrictUtf8(bytes: Buffer): string {
123
+ if (bytes.includes(0)) throw new RichMediaSafetyError('corrupt_attachment', 'text attachment contains NUL bytes')
124
+ let text: string
125
+ try {
126
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
127
+ } catch {
128
+ throw new RichMediaSafetyError('corrupt_attachment', 'text attachment is not valid UTF-8')
129
+ }
130
+ const sample = text.slice(0, 20_000)
131
+ const printable = [...sample].filter(ch => ch === '\n' || ch === '\r' || ch === '\t' || ch >= ' ').length
132
+ if (sample.length > 0 && printable / sample.length < 0.9) {
133
+ throw new RichMediaSafetyError('corrupt_attachment', 'text attachment contains too much control data')
134
+ }
135
+ return text.replace(/\r\n?/g, '\n')
136
+ }
137
+
138
+ function capText(text: string): { text: string; truncated: boolean } {
139
+ const normalized = text.replace(/\u0000/g, '').trim()
140
+ if (normalized.length <= MAX_DOCUMENT_TEXT_CHARS) return { text: normalized, truncated: false }
141
+ return {
142
+ text: `${normalized.slice(0, MAX_DOCUMENT_TEXT_CHARS)}\n\n[Document truncated by COS at ${MAX_DOCUMENT_TEXT_CHARS} characters.]`,
143
+ truncated: true,
144
+ }
145
+ }
146
+
147
+ async function runProcess(command: string, args: string[], timeoutMs = PROCESS_TIMEOUT_MS): Promise<void> {
148
+ await new Promise<void>((resolve, reject) => {
149
+ let settled = false
150
+ let stderr = ''
151
+ const proc = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'] })
152
+ const timer = setTimeout(() => {
153
+ if (settled) return
154
+ settled = true
155
+ proc.kill('SIGKILL')
156
+ reject(new RichMediaSafetyError('attachment_processing_failed', `${command} timed out`))
157
+ }, timeoutMs)
158
+ timer.unref?.()
159
+ proc.stderr.on('data', (chunk: Buffer) => {
160
+ if (stderr.length < PROCESS_STDERR_MAX) stderr += chunk.toString('utf8').slice(0, PROCESS_STDERR_MAX - stderr.length)
161
+ })
162
+ proc.once('error', error => {
163
+ if (settled) return
164
+ settled = true
165
+ clearTimeout(timer)
166
+ const code = (error as NodeJS.ErrnoException).code === 'ENOENT'
167
+ ? 'attachment_processing_unavailable'
168
+ : 'attachment_processing_failed'
169
+ reject(new RichMediaSafetyError(code, `${command} unavailable: ${error.message}`))
170
+ })
171
+ proc.once('close', code => {
172
+ if (settled) return
173
+ settled = true
174
+ clearTimeout(timer)
175
+ if (code === 0) resolve()
176
+ else reject(new RichMediaSafetyError('corrupt_attachment', `${command} rejected attachment`))
177
+ })
178
+ })
179
+ }
180
+
181
+ async function processPdf(bytes: Buffer): Promise<PreparedDocument> {
182
+ const root = mkdtempSync(join(tmpdir(), 'cos-pdf-'))
183
+ const input = join(root, 'input.pdf')
184
+ const output = join(root, 'content.txt')
185
+ const pagesPrefix = join(root, 'page')
186
+ try {
187
+ writeFileSync(input, bytes, { mode: 0o600 })
188
+ await runProcess('pdftotext', ['-layout', '-enc', 'UTF-8', input, output])
189
+ const rawText = readFileSync(output, 'utf8')
190
+ const capped = capText(rawText)
191
+ const pageImages: Buffer[] = []
192
+ try {
193
+ await runProcess('pdftoppm', ['-jpeg', '-r', '120', '-f', '1', '-l', String(MAX_DERIVATIVE_IMAGES), input, pagesPrefix])
194
+ for (const name of readdirSync(root).filter(name => /^page-\d+\.jpg$/i.test(name)).sort().slice(0, MAX_DERIVATIVE_IMAGES)) {
195
+ pageImages.push(readFileSync(join(root, name)))
196
+ }
197
+ } catch (error) {
198
+ if (capped.text.length === 0) throw error
199
+ }
200
+ if (capped.text.length === 0 && pageImages.length === 0) {
201
+ throw new RichMediaSafetyError('corrupt_attachment', 'PDF contains no extractable text or pages')
202
+ }
203
+ return {
204
+ category: 'document', mime: 'application/pdf', original: bytes,
205
+ extractedText: capped.text, textTruncated: capped.truncated, pageImages,
206
+ }
207
+ } finally {
208
+ try { rmSync(root, { recursive: true, force: true }) } catch { /* private tmp cleanup */ }
209
+ }
210
+ }
211
+
212
+ interface ProbePayload {
213
+ format?: { duration?: string; format_name?: string; tags?: Record<string, string> }
214
+ streams?: Array<{ codec_type?: string; width?: number; height?: number }>
215
+ }
216
+
217
+ async function processVideo(bytes: Buffer, label: string | undefined, declaredMime: string | undefined): Promise<PreparedVideo> {
218
+ const root = mkdtempSync(join(tmpdir(), 'cos-video-'))
219
+ const ext = fileExtension(label) === '.mov' ? '.mov' : '.mp4'
220
+ const input = join(root, `input${ext}`)
221
+ const probePath = join(root, 'probe.json')
222
+ try {
223
+ writeFileSync(input, bytes, { mode: 0o600 })
224
+ await new Promise<void>((resolve, reject) => {
225
+ let settled = false
226
+ let stdout = ''
227
+ let stderr = ''
228
+ const proc = spawn('ffprobe', [
229
+ '-v', 'error', '-show_entries', 'format=duration,format_name:stream=codec_type,width,height',
230
+ '-of', 'json', input,
231
+ ], { stdio: ['ignore', 'pipe', 'pipe'] })
232
+ const timer = setTimeout(() => {
233
+ if (settled) return
234
+ settled = true
235
+ proc.kill('SIGKILL')
236
+ reject(new RichMediaSafetyError('attachment_processing_failed', 'ffprobe timed out'))
237
+ }, 10_000)
238
+ proc.stdout.on('data', (chunk: Buffer) => { if (stdout.length < 64_000) stdout += chunk.toString('utf8') })
239
+ proc.stderr.on('data', (chunk: Buffer) => { if (stderr.length < PROCESS_STDERR_MAX) stderr += chunk.toString('utf8') })
240
+ proc.once('error', error => {
241
+ if (settled) return
242
+ settled = true
243
+ clearTimeout(timer)
244
+ reject(new RichMediaSafetyError(
245
+ (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'attachment_processing_unavailable' : 'attachment_processing_failed',
246
+ `ffprobe unavailable: ${error.message}`,
247
+ ))
248
+ })
249
+ proc.once('close', code => {
250
+ if (settled) return
251
+ settled = true
252
+ clearTimeout(timer)
253
+ if (code !== 0) {
254
+ reject(new RichMediaSafetyError('corrupt_attachment', 'ffprobe rejected video'))
255
+ return
256
+ }
257
+ writeFileSync(probePath, stdout, { mode: 0o600 })
258
+ resolve()
259
+ })
260
+ })
261
+ let probe: ProbePayload
262
+ try { probe = JSON.parse(readFileSync(probePath, 'utf8')) as ProbePayload } catch {
263
+ throw new RichMediaSafetyError('corrupt_attachment', 'ffprobe returned invalid metadata')
264
+ }
265
+ const stream = probe.streams?.find(item => item.codec_type === 'video')
266
+ const durationMs = Math.round(Number(probe.format?.duration) * 1000)
267
+ if (!stream || !Number.isFinite(stream.width) || !Number.isFinite(stream.height)
268
+ || !Number.isFinite(durationMs) || durationMs <= 0) {
269
+ throw new RichMediaSafetyError('corrupt_attachment', 'attachment has no valid video stream')
270
+ }
271
+ if (durationMs > MAX_VIDEO_DURATION_MS) {
272
+ throw new RichMediaSafetyError('video_too_long', `video exceeds ${MAX_VIDEO_DURATION_MS / 60_000} minute limit`)
273
+ }
274
+ const frameCount = Math.min(MAX_DERIVATIVE_IMAGES, Math.max(1, Math.ceil(durationMs / 15_000)))
275
+ const fps = Math.max(0.001, frameCount / (durationMs / 1000))
276
+ await runProcess('ffmpeg', [
277
+ '-nostdin', '-v', 'error', '-i', input,
278
+ '-vf', `fps=${fps.toFixed(6)},scale=1280:-2:force_original_aspect_ratio=decrease`,
279
+ '-frames:v', String(frameCount), '-q:v', '3', join(root, 'frame-%02d.jpg'),
280
+ ])
281
+ const frames = readdirSync(root)
282
+ .filter(name => /^frame-\d+\.jpg$/i.test(name)).sort().slice(0, MAX_DERIVATIVE_IMAGES)
283
+ .map(name => readFileSync(join(root, name)))
284
+ if (frames.length === 0) throw new RichMediaSafetyError('corrupt_attachment', 'video produced no review frames')
285
+ const mime: PreparedVideo['mime'] = declaredMime === 'video/quicktime' || ext === '.mov'
286
+ ? 'video/quicktime' : 'video/mp4'
287
+ return {
288
+ category: 'video', mime, original: bytes,
289
+ width: Math.floor(stream.width!), height: Math.floor(stream.height!), durationMs, frames,
290
+ }
291
+ } finally {
292
+ try { rmSync(root, { recursive: true, force: true }) } catch { /* private tmp cleanup */ }
293
+ }
294
+ }
295
+
296
+ export async function prepareRichMedia(
297
+ bytes: Buffer,
298
+ options: { label?: string; declaredMime?: string },
299
+ ): Promise<PreparedRichMedia> {
300
+ if (bytes.length === 0) throw new RichMediaSafetyError('corrupt_attachment', 'attachment is empty')
301
+ if (bytes.length > MAX_RICH_MEDIA_BYTES) {
302
+ throw new RichMediaSafetyError('attachment_too_large', `attachment exceeds ${MAX_RICH_MEDIA_BYTES} byte limit`)
303
+ }
304
+ if (isPdf(bytes)) return processPdf(bytes)
305
+ if (isIsoBmff(bytes)) return processVideo(bytes, options.label, options.declaredMime)
306
+
307
+ const ext = fileExtension(options.label)
308
+ const declared = (options.declaredMime ?? '').toLowerCase().split(';', 1)[0]
309
+ const textMime: PreparedDocument['mime'] | null = ext === '.md' || ext === '.markdown' || declared === 'text/markdown'
310
+ ? 'text/markdown'
311
+ : ext === '.csv' || declared === 'text/csv'
312
+ ? 'text/csv'
313
+ : ext === '.json' || declared === 'application/json'
314
+ ? 'application/json'
315
+ : ext === '.txt' || declared === 'text/plain'
316
+ ? 'text/plain'
317
+ : null
318
+ if (!textMime) throw new RichMediaSafetyError('unsupported_attachment_format', 'supported files: TXT, MD, CSV, JSON, PDF, MP4, MOV')
319
+ const capped = capText(decodeStrictUtf8(bytes))
320
+ return {
321
+ category: 'document', mime: textMime, original: bytes,
322
+ extractedText: capped.text, textTruncated: capped.truncated, pageImages: [],
323
+ }
324
+ }
@@ -1,4 +1,5 @@
1
1
  import { Router } from 'express'
2
+ import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
2
3
  import { statSync } from 'node:fs'
3
4
  import { resolve } from 'node:path'
4
5
  import { COS_SCRIPTS_DIR, COS_MODE } from '../lib/python-bridge.js'
@@ -29,6 +30,7 @@ import {
29
30
  isCursorProviderReady,
30
31
  } from '../lib/cursor-model-catalog.js'
31
32
  import { isMediaProcessingReady } from '../lib/image-safety.js'
33
+ import { getRichMediaProcessingCapabilities } from '../lib/rich-media-safety.js'
32
34
  import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
33
35
  import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
34
36
  import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
@@ -161,6 +163,7 @@ healthRouter.get('/health', async (_req, res) => {
161
163
  // Computed once per request; the same value feeds features.liveCues and
162
164
  // capabilities.liveCues so the two surfaces can never disagree.
163
165
  const liveCues = liveCuesCapability()
166
+ const richMedia = await getRichMediaProcessingCapabilities()
164
167
  const features = {
165
168
  claude: claudeAvailable,
166
169
  codex: codexAvailable,
@@ -172,6 +175,8 @@ healthRouter.get('/health', async (_req, res) => {
172
175
  meetingFinalization: true,
173
176
  iphoneAsrCandidates: process.env.COS_IOS_ASR_CANDIDATES === '1',
174
177
  mediaProcessingReady: await isMediaProcessingReady(),
178
+ pdfProcessingReady: richMedia.pdf,
179
+ videoProcessingReady: richMedia.video,
175
180
  g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
176
181
  durableQueryJobs: durableJobs.enabled,
177
182
  durableQueryJobsProtocol: durableJobs.protocolVersion,
@@ -227,7 +232,7 @@ healthRouter.get('/health', async (_req, res) => {
227
232
  // plus the recover action live on the authenticated /api/meeting/orphans.
228
233
  const unsavedList = listUnsavedCaptures()
229
234
  const unsaved_captures = {
230
- count: unsavedList.filter(item => !item.recovered).length,
235
+ count: unsavedList.filter(isWorthRecovering).length,
231
236
  items: unsavedList.slice(0, 10).map(item => ({
232
237
  sessionId: item.sessionId,
233
238
  ageHours: item.ageHours,
@@ -295,6 +300,15 @@ healthRouter.get('/health', async (_req, res) => {
295
300
  },
296
301
  cliDebug: CLI_DEBUG_CAPABILITY,
297
302
  liveCues,
303
+ richMedia: {
304
+ text: true,
305
+ pdf: richMedia.pdf,
306
+ video: richMedia.video,
307
+ maxAttachments: 5,
308
+ maxBytesPerAttachment: 64 * 1024 * 1024,
309
+ maxVideoMinutes: 20,
310
+ maxStillFrames: 8,
311
+ },
298
312
  meetingLifecycle: {
299
313
  earlySyncClaim: getEarlyMeetingSyncSnapshot(),
300
314
  progressiveHq,
@@ -15,7 +15,7 @@
15
15
  // (8 MiB decoded ≈ 10.7 MiB base64) doesn't trip the global 10 MB limit;
16
16
  // the allowance stays scoped to /api/media.
17
17
 
18
- import { Router, json, type Request, type Response } from 'express'
18
+ import { Router, json, raw, type Request, type Response } from 'express'
19
19
  import { readFileSync } from 'node:fs'
20
20
  import {
21
21
  MAX_ATTACHMENTS_PER_PROMPT,
@@ -35,11 +35,18 @@ import {
35
35
  isMediaProcessingReady,
36
36
  strictBase64Decode,
37
37
  } from '../lib/image-safety.js'
38
+ import {
39
+ MAX_RICH_MEDIA_BYTES,
40
+ RichMediaSafetyError,
41
+ } from '../lib/rich-media-safety.js'
38
42
 
39
43
  // Route-scoped parser: 16 MB covers the max valid batch after base64 + JSON
40
44
  // overhead. Mounted only for /api/media in server/index.ts — the global
41
45
  // server limit is unchanged.
42
46
  export const mediaBodyParser = json({ limit: '16mb' })
47
+ /** Binary parser is mounted only on /api/media/file before global JSON. It
48
+ * avoids base64 amplification while retaining a hard route-local cap. */
49
+ export const mediaBinaryBodyParser = raw({ type: () => true, limit: MAX_RICH_MEDIA_BYTES })
43
50
 
44
51
  export const mediaRouter = Router()
45
52
 
@@ -58,6 +65,12 @@ const SAFETY_ERROR_STATUS: Record<string, number> = {
58
65
  corrupt_image: 400,
59
66
  media_processing_unavailable: 503,
60
67
  normalization_failed: 500,
68
+ unsupported_attachment_format: 400,
69
+ attachment_too_large: 413,
70
+ corrupt_attachment: 400,
71
+ attachment_processing_unavailable: 503,
72
+ attachment_processing_failed: 500,
73
+ video_too_long: 400,
61
74
  }
62
75
 
63
76
  function sendMediaError(res: Response, err: unknown): void {
@@ -73,6 +86,10 @@ function sendMediaError(res: Response, err: unknown): void {
73
86
  })
74
87
  return
75
88
  }
89
+ if (err instanceof RichMediaSafetyError) {
90
+ res.status(SAFETY_ERROR_STATUS[err.code] ?? 500).json({ error: err.code, detail: err.message })
91
+ return
92
+ }
76
93
  console.error('[media] unexpected error:', err)
77
94
  res.status(500).json({ error: 'media_internal_error' })
78
95
  }
@@ -143,6 +160,34 @@ mediaRouter.post('/media', async (req: Request, res: Response) => {
143
160
  }
144
161
  })
145
162
 
163
+ /** One document or video per binary request. The client may issue several
164
+ * bounded requests, but composer admission still owns the shared five-item
165
+ * prompt cap. Filename and MIME are hints only; prepareRichMedia sniffs and
166
+ * validates the actual bytes. */
167
+ mediaRouter.post('/media/file', async (req: Request, res: Response) => {
168
+ try {
169
+ if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
170
+ res.status(400).json({ error: 'attachment_bytes_required' })
171
+ return
172
+ }
173
+ const rawLabel = safeString(req.header('x-cos-filename'), 360)
174
+ let label: string | undefined
175
+ if (rawLabel) {
176
+ try { label = decodeURIComponent(rawLabel).slice(0, 120) } catch { label = rawLabel.slice(0, 120) }
177
+ }
178
+ const attachment = await getMediaStore().ingestRichMedia({
179
+ bytes: req.body,
180
+ label,
181
+ declaredMime: safeString(req.header('content-type'), 120),
182
+ capturedAt: safeString(req.header('x-cos-captured-at'), 40),
183
+ sessionId: safeString(req.header('x-cos-session-id'), 64),
184
+ })
185
+ res.json({ attachment })
186
+ } catch (err) {
187
+ sendMediaError(res, err)
188
+ }
189
+ })
190
+
146
191
  // ── Lifecycle ────────────────────────────────────────────────────────────────
147
192
 
148
193
  mediaRouter.post('/media/reserve', async (req: Request, res: Response) => {
@@ -3,6 +3,7 @@
3
3
  // are durable before the session is closed; batch improvement runs afterward.
4
4
 
5
5
  import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'
6
+ import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
6
7
  import { resolve } from 'node:path'
7
8
  import { Router } from 'express'
8
9
  import { emitDisplay } from '../lib/display-bus.js'
@@ -1672,7 +1673,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1672
1673
  // route answered count: 0.
1673
1674
  const stranded = getStrandedCaptures()
1674
1675
  res.json({
1675
- count: items.filter(item => !item.recovered).length,
1676
+ count: items.filter(isWorthRecovering).length,
1676
1677
  strandedCount: stranded.length,
1677
1678
  stranded,
1678
1679
  recovering: [...recoveringOrphans],
@@ -83,10 +83,10 @@ queryRouter.post('/query', async (req, res) => {
83
83
 
84
84
  const resolvedQuery = typeof query === 'string' ? query : ''
85
85
 
86
- // Vision queries can have an empty query (default to "describe what you see")
87
- if ((!resolvedQuery || typeof resolvedQuery !== 'string') && !imageInputs) {
86
+ // Attachment-only queries use a category-aware provider default.
87
+ if ((!resolvedQuery || typeof resolvedQuery !== 'string') && attachmentRefs.length === 0) {
88
88
  maintenanceLease.release()
89
- return res.status(400).json({ error: 'query string or image required' })
89
+ return res.status(400).json({ error: 'query string or attachment required' })
90
90
  }
91
91
 
92
92
  // Validate model if provided
@@ -201,6 +201,8 @@ queryRouter.post('/query', async (req, res) => {
201
201
  abortSignal: abortController.signal,
202
202
  effort: validEffort,
203
203
  messageEra: activeMessageEra,
204
+ requestAttachments: attachmentRefs,
205
+ attachmentPromptBlock: resolvedAttachments.promptBlock,
204
206
  ...(validModel && isCursorModel(validModel) ? { cursorExecutionMode } : {}),
205
207
  },
206
208
  )
@@ -1015,8 +1015,22 @@ setInterval(() => {
1015
1015
  }
1016
1016
  }
1017
1017
  purgeExpiredQuarantine()
1018
+ } catch (error) {
1019
+ // Was a bare `catch {}`. That is what made the auto-recover failure below
1020
+ // undiagnosable: three minutes of watching a live server produced no recovery, no
1021
+ // log, and nothing to reason about, because any throw in this block vanished.
1022
+ console.error(`[cleanup] Orphan-audio sweep failed: ${error instanceof Error ? error.message : error}`)
1023
+ }
1024
+ // DELIBERATELY ITS OWN TRY. This used to sit inside the block above, one line after
1025
+ // purgeExpiredQuarantine, so a throw anywhere in that sweep meant auto-recovery
1026
+ // silently never ran — which is exactly what happened in production on 6.23.1
1027
+ // through 6.24.2. Recovering quarantined audio has nothing to do with sweeping
1028
+ // orphaned session-audio dirs and must not depend on it succeeding.
1029
+ try {
1018
1030
  autoRecoverOneQuarantinedCapture()
1019
- } catch {}
1031
+ } catch (error) {
1032
+ console.error(`[quarantine] Auto-recover pass failed: ${error instanceof Error ? error.message : error}`)
1033
+ }
1020
1034
  // Purge stale pending-batch dirs. HQ large-v3 on long meetings + Control
1021
1035
  // restart can exceed 2h (2026-07-27: two sessions purged before batch).
1022
1036
  // Use 12h, and never purge while a meeting_batch_finalization lease is held.