@gotcos/glasses-server 6.27.1 → 6.27.3

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.
@@ -59,6 +59,8 @@ import { getEarlyMeetingSyncSnapshot } from '../lib/g2-ops-handoff.js'
59
59
  import { getProgressiveHqSnapshot } from '../lib/meeting-batch-transcribe.js'
60
60
  import { getMeetingFinalizationSnapshot } from '../lib/meeting-finalization-jobs.js'
61
61
  import { resolveMeetingLibrary } from '../lib/cos-operations-meetings.js'
62
+ import { videoUploadV2Capability } from '../lib/video-upload-v2.js'
63
+ import { MAX_MODEL_IMAGE_INPUTS } from '../lib/query-attachments.js'
62
64
 
63
65
  export const healthRouter = Router()
64
66
 
@@ -172,6 +174,7 @@ healthRouter.get('/health', async (_req, res) => {
172
174
  // capabilities.liveCues so the two surfaces can never disagree.
173
175
  const liveCues = liveCuesCapability()
174
176
  const richMedia = await getRichMediaProcessingCapabilities()
177
+ const videoUploadV2 = videoUploadV2Capability(richMedia.video)
175
178
  // Upload limits are published so the client never carries its own byte caps:
176
179
  // cos-glasses-app and cos-glasses-server are separate repos that have already
177
180
  // diverged, so a constant in both is guaranteed to drift. Absent means an
@@ -326,6 +329,11 @@ healthRouter.get('/health', async (_req, res) => {
326
329
  maxVideoBytesPerAttachment: MAX_VIDEO_MEDIA_BYTES,
327
330
  maxVideoMinutes: 20,
328
331
  maxStillFrames: 8,
332
+ videoUploadV2: {
333
+ available: videoUploadV2.available,
334
+ protocol: videoUploadV2.protocol,
335
+ reason: videoUploadV2.reason,
336
+ },
329
337
  },
330
338
  meetingLifecycle: {
331
339
  earlySyncClaim: getEarlyMeetingSyncSnapshot(),
@@ -359,6 +367,8 @@ healthRouter.get('/models', async (req, res) => {
359
367
  const transcriptionLive = getWhisperPreviewCapability()
360
368
  const transcriptionProfile = getTranscriptionProfileStatus()
361
369
  const progressiveHq = getProgressiveHqSnapshot()
370
+ const richMedia = await getRichMediaProcessingCapabilities()
371
+ const videoUploadV2 = videoUploadV2Capability(richMedia.video)
362
372
  const cursorOptions = cursorCatalog.options.filter(option => !!option.id)
363
373
  res.json({
364
374
  ...catalog,
@@ -391,6 +401,20 @@ healthRouter.get('/models', async (req, res) => {
391
401
  // THIS surface, so a value present only on /api/health leaves the
392
402
  // live-cues indicator blind.
393
403
  liveCues: liveCuesCapability(),
404
+ richMedia: {
405
+ video: richMedia.video,
406
+ maxPromptVisuals: MAX_MODEL_IMAGE_INPUTS,
407
+ videoUploadV2: {
408
+ ...videoUploadV2,
409
+ // The first private train deliberately admits only one video and no
410
+ // other visual attachment. This keeps a server fallback at its
411
+ // proven 16-frame policy inside the provider-wide 16-image ceiling.
412
+ promptAdmission: {
413
+ maxVideos: 1,
414
+ maxOtherVisualsWithVideo: 0,
415
+ },
416
+ },
417
+ },
394
418
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
395
419
  },
396
420
  })
@@ -16,6 +16,7 @@ import { serverMetrics } from '../lib/server-metrics.js'
16
16
  import { getServerInstanceId } from '../lib/server-instance-id.js'
17
17
  import { getWhisperHealth } from '../lib/whisper-local.js'
18
18
  import { getActiveTranscriptionSessionCount, getTranscriptionSessionLiveness } from './transcribe-stream.js'
19
+ import { getVideoUploadRegistry } from '../lib/video-upload-v2.js'
19
20
 
20
21
  export const maintenanceRouter = Router()
21
22
 
@@ -78,6 +79,21 @@ function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
78
79
  // rather than counted, and are never deleted here.
79
80
  const sessionLiveness = getTranscriptionSessionLiveness()
80
81
  const managed = managedRuntimeCapability()
82
+ let videoUploads
83
+ try {
84
+ videoUploads = getVideoUploadRegistry().status()
85
+ } catch {
86
+ videoUploads = {
87
+ protocol: 1 as const,
88
+ enabled: false,
89
+ receiving: 0,
90
+ finalizing: 0,
91
+ unacknowledgedPublished: 0,
92
+ failed: 0,
93
+ blocksRestart: false,
94
+ blocksRollback: false,
95
+ }
96
+ }
81
97
  const tracked = maintenanceLifecycle.snapshot(credentials, {
82
98
  recording_session: sessionLiveness.live,
83
99
  })
@@ -107,7 +123,10 @@ function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
107
123
  shuttingDown: jobs.shuttingDown,
108
124
  durableStoreState: jobs.store.state,
109
125
  lifecycle,
110
- safeToRestart: lifecycle.safeToRestart && !jobs.shuttingDown,
126
+ videoUploads,
127
+ // Published receipts intentionally survive an ordinary restart. They only
128
+ // block a binary downgrade, where an old server could erase V2 evidence.
129
+ safeToRestart: lifecycle.safeToRestart && !jobs.shuttingDown && !videoUploads.blocksRestart,
111
130
  whisper: getWhisperHealth(),
112
131
  }
113
132
  }
@@ -57,6 +57,16 @@ import {
57
57
  isValidUploadId,
58
58
  type UploadSessionErrorCode,
59
59
  } from '../lib/upload-session.js'
60
+ import {
61
+ VIDEO_UPLOAD_V2_CHUNK_BYTES,
62
+ VIDEO_UPLOAD_V2_MAX_FRAME_BYTES,
63
+ VideoUploadError,
64
+ getVideoUploadRegistry,
65
+ isValidVideoUploadId,
66
+ type VideoUploadErrorCode,
67
+ } from '../lib/video-upload-v2.js'
68
+ import { getServerInstanceId } from '../lib/server-instance-id.js'
69
+ import { maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
60
70
 
61
71
  // Route-scoped parser: 16 MB covers the max valid batch after base64 + JSON
62
72
  // overhead. Mounted only for /api/media in server/index.ts — the global
@@ -301,6 +311,9 @@ export function createMediaChunkBodyParser(options: MediaChunkParserOptions = {}
301
311
 
302
312
  /** Chunk parser at the advertised chunk size. */
303
313
  export const mediaChunkBodyParser = createMediaChunkBodyParser()
314
+ export const videoUploadChunkBodyParser = createMediaChunkBodyParser({
315
+ maxChunkBytes: Math.max(VIDEO_UPLOAD_V2_CHUNK_BYTES, VIDEO_UPLOAD_V2_MAX_FRAME_BYTES),
316
+ })
304
317
 
305
318
  function takeChunkBody(req: Request): Buffer | undefined {
306
319
  const bytes = chunkBodies.get(req)
@@ -347,6 +360,19 @@ const UPLOAD_ERROR_STATUS: Record<UploadSessionErrorCode, number> = {
347
360
  upload_staging_failed: 500,
348
361
  }
349
362
 
363
+ const VIDEO_UPLOAD_ERROR_STATUS: Record<VideoUploadErrorCode, number> = {
364
+ video_upload_disabled: 503,
365
+ video_upload_not_found: 404,
366
+ video_upload_conflict: 409,
367
+ video_upload_busy: 409,
368
+ video_upload_incomplete: 409,
369
+ video_upload_invalid: 400,
370
+ video_upload_quota: 507,
371
+ video_upload_cancelled: 410,
372
+ video_upload_failed: 500,
373
+ server_identity_mismatch: 409,
374
+ }
375
+
350
376
  function sendMediaError(res: Response, err: unknown): void {
351
377
  // Chunked-upload failures go through the SAME funnel as every other media
352
378
  // error, so there is one place that decides the error body's shape.
@@ -354,6 +380,14 @@ function sendMediaError(res: Response, err: unknown): void {
354
380
  res.status(UPLOAD_ERROR_STATUS[err.code] ?? 500).json({ error: err.code, ...err.detail })
355
381
  return
356
382
  }
383
+ if (err instanceof VideoUploadError) {
384
+ res.status(VIDEO_UPLOAD_ERROR_STATUS[err.code] ?? 500).json({
385
+ error: err.code,
386
+ detail: err.message,
387
+ ...err.detail,
388
+ })
389
+ return
390
+ }
357
391
  if (err instanceof MediaStoreError) {
358
392
  res.status(MEDIA_ERROR_STATUS[err.code] ?? 500).json({ error: err.code })
359
393
  return
@@ -374,6 +408,26 @@ function sendMediaError(res: Response, err: unknown): void {
374
408
  res.status(500).json({ error: 'media_internal_error' })
375
409
  }
376
410
 
411
+ function videoUploadServerIdentity(req: Request): string | undefined {
412
+ return safeString(req.header('x-cos-server-instance'), 160)
413
+ ?? safeString(req.header('x-cos-server-instance-id'), 160)
414
+ }
415
+
416
+ function requireVideoUploadAdmission(res: Response): boolean {
417
+ if (maintenanceAdmissionsOpen()) return true
418
+ res.status(503).json({ error: 'server_maintenance', retryable: true })
419
+ return false
420
+ }
421
+
422
+ function requireCurrentServerIdentity(req: Request): string {
423
+ const presented = videoUploadServerIdentity(req)
424
+ const current = getServerInstanceId()
425
+ if (!presented || presented !== current) {
426
+ throw new VideoUploadError('server_identity_mismatch', 'request targets a different COS server')
427
+ }
428
+ return presented
429
+ }
430
+
377
431
  function safeString(v: unknown, max: number): string | undefined {
378
432
  return typeof v === 'string' && v.trim().length > 0 ? v.trim().slice(0, max) : undefined
379
433
  }
@@ -489,6 +543,124 @@ function uploadLabelFrom(req: Request): string | undefined {
489
543
  try { return decodeURIComponent(rawLabel).slice(0, 120) } catch { return rawLabel.slice(0, 120) }
490
544
  }
491
545
 
546
+ // ── Resumable video upload V2 (private canary) ───────────────────────────────
547
+
548
+ mediaRouter.post('/media/video-upload/init', (req: Request, res: Response) => {
549
+ try {
550
+ const serverInstanceId = requireCurrentServerIdentity(req)
551
+ const body = req.body ?? {}
552
+ const progress = getVideoUploadRegistry().init({
553
+ clientRequestId: body.clientRequestId,
554
+ serverInstanceId,
555
+ totalBytes: body.totalBytes,
556
+ mime: body.mime,
557
+ label: uploadLabelFrom(req),
558
+ capturedAt: safeString(req.header('x-cos-captured-at'), 40),
559
+ sessionId: safeString(req.header('x-cos-session-id'), 64),
560
+ })
561
+ res.json(progress)
562
+ } catch (err) {
563
+ sendMediaError(res, err)
564
+ }
565
+ })
566
+
567
+ mediaRouter.get('/media/video-upload/:uploadId', (req: Request, res: Response) => {
568
+ try {
569
+ const serverInstanceId = requireCurrentServerIdentity(req)
570
+ if (!isValidVideoUploadId(req.params.uploadId)) {
571
+ throw new VideoUploadError('video_upload_not_found', 'unknown or expired video upload')
572
+ }
573
+ res.json(getVideoUploadRegistry().get(req.params.uploadId, serverInstanceId))
574
+ } catch (err) {
575
+ sendMediaError(res, err)
576
+ }
577
+ })
578
+
579
+ mediaRouter.put(
580
+ '/media/video-upload/:uploadId/original/:index',
581
+ videoUploadChunkBodyParser,
582
+ async (req: Request, res: Response) => {
583
+ const bytes = takeChunkBody(req)
584
+ try {
585
+ if (!requireVideoUploadAdmission(res)) return
586
+ const serverInstanceId = requireCurrentServerIdentity(req)
587
+ if (!isValidVideoUploadId(req.params.uploadId) || bytes === undefined) {
588
+ throw new VideoUploadError('video_upload_invalid', 'valid upload id and raw chunk bytes are required')
589
+ }
590
+ res.json(await getVideoUploadRegistry().putOriginal(
591
+ req.params.uploadId,
592
+ req.params.index,
593
+ bytes,
594
+ serverInstanceId,
595
+ ))
596
+ } catch (err) {
597
+ sendMediaError(res, err)
598
+ }
599
+ },
600
+ )
601
+
602
+ mediaRouter.put(
603
+ '/media/video-upload/:uploadId/frame/:index',
604
+ videoUploadChunkBodyParser,
605
+ async (req: Request, res: Response) => {
606
+ const bytes = takeChunkBody(req)
607
+ try {
608
+ if (!requireVideoUploadAdmission(res)) return
609
+ const serverInstanceId = requireCurrentServerIdentity(req)
610
+ if (!isValidVideoUploadId(req.params.uploadId) || bytes === undefined) {
611
+ throw new VideoUploadError('video_upload_invalid', 'valid upload id and raw frame bytes are required')
612
+ }
613
+ res.json(await getVideoUploadRegistry().putFrame(
614
+ req.params.uploadId,
615
+ req.params.index,
616
+ bytes,
617
+ serverInstanceId,
618
+ ))
619
+ } catch (err) {
620
+ sendMediaError(res, err)
621
+ }
622
+ },
623
+ )
624
+
625
+ mediaRouter.post('/media/video-upload/:uploadId/finalize', async (req: Request, res: Response) => {
626
+ try {
627
+ if (!requireVideoUploadAdmission(res)) return
628
+ const serverInstanceId = requireCurrentServerIdentity(req)
629
+ if (!isValidVideoUploadId(req.params.uploadId)) {
630
+ throw new VideoUploadError('video_upload_not_found', 'unknown or expired video upload')
631
+ }
632
+ res.json(await getVideoUploadRegistry().finalize(req.params.uploadId, serverInstanceId))
633
+ } catch (err) {
634
+ sendMediaError(res, err)
635
+ }
636
+ })
637
+
638
+ mediaRouter.post('/media/video-upload/:uploadId/ack', async (req: Request, res: Response) => {
639
+ try {
640
+ const serverInstanceId = requireCurrentServerIdentity(req)
641
+ if (!isValidVideoUploadId(req.params.uploadId)) {
642
+ throw new VideoUploadError('video_upload_not_found', 'unknown or expired video upload')
643
+ }
644
+ res.json(await getVideoUploadRegistry().acknowledge(req.params.uploadId, serverInstanceId))
645
+ } catch (err) {
646
+ sendMediaError(res, err)
647
+ }
648
+ })
649
+
650
+ mediaRouter.delete('/media/video-upload/:uploadId', async (req: Request, res: Response) => {
651
+ try {
652
+ const serverInstanceId = requireCurrentServerIdentity(req)
653
+ if (!isValidVideoUploadId(req.params.uploadId)) {
654
+ res.json({ ok: true, dropped: false })
655
+ return
656
+ }
657
+ const progress = await getVideoUploadRegistry().cancel(req.params.uploadId, serverInstanceId)
658
+ res.json({ ok: true, dropped: progress !== null, ...(progress ? { upload: progress } : {}) })
659
+ } catch (err) {
660
+ sendMediaError(res, err)
661
+ }
662
+ })
663
+
492
664
  mediaRouter.post('/media/upload/init', (req: Request, res: Response) => {
493
665
  try {
494
666
  const body = req.body ?? {}
@@ -124,6 +124,34 @@ async function cleanOutboundDictation(text: string, opts: AutoCleanRequest & { s
124
124
  }
125
125
  }
126
126
 
127
+ /** Finalize already-transcribed phone text through the same glossary + optional
128
+ * AI cleanup used by server-owned prompt drafts. This route accepts text only:
129
+ * Moonshine audio and rolling preview audio never leave the phone. */
130
+ promptDraftsRouter.post('/dictation/finalize', async (req, res) => {
131
+ const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''
132
+ const surface = req.body?.surface
133
+ const model = req.body?.autocleanModel
134
+ if (!text) return res.status(400).json({ error: 'text_required' })
135
+ if (text.length > AUTOCLEAN_MAX_CHARS) {
136
+ return res.status(413).json({ error: 'text_too_large', maxChars: AUTOCLEAN_MAX_CHARS })
137
+ }
138
+ if (surface !== 'message' && surface !== 'meeting') {
139
+ return res.status(400).json({ error: 'invalid_surface' })
140
+ }
141
+ if (model !== undefined && model !== 'haiku' && model !== 'sonnet') {
142
+ return res.status(400).json({ error: 'invalid_autoclean_model' })
143
+ }
144
+
145
+ const abort = new AbortController()
146
+ res.on('close', () => { if (!res.writableEnded) abort.abort() })
147
+ const finalText = await cleanOutboundDictation(text, {
148
+ enabled: true,
149
+ model,
150
+ signal: abort.signal,
151
+ })
152
+ res.json({ text: finalText, surface, polished: finalText !== text })
153
+ })
154
+
127
155
  async function readRawBody(req: AsyncIterable<Buffer | Uint8Array | string>): Promise<Buffer> {
128
156
  const chunks: Buffer[] = []
129
157
  let total = 0