@gotcos/glasses-server 6.27.2 → 6.27.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.
@@ -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 ?? {}
@@ -35,6 +35,8 @@ import {
35
35
  applyNegativeRules,
36
36
  } from '../lib/hallucination-filter.js'
37
37
  import { applyCorrections } from '../lib/whisper-local.js'
38
+ import { applyFuzzyCorrections } from '../lib/fuzzy-correct.js'
39
+ import { getAllSpeakerNames } from '../lib/speaker-embeddings.js'
38
40
  import { transcribeWhisperPreview } from '../lib/whisper-preview.js'
39
41
  import { autoCleanDictation, AUTOCLEAN_MAX_CHARS } from '../lib/dictation-clean.js'
40
42
  import { getVocabulary } from '../lib/profile.js'
@@ -104,6 +106,27 @@ function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
104
106
 
105
107
  async function cleanOutboundDictation(text: string, opts: AutoCleanRequest & { signal?: AbortSignal }): Promise<string> {
106
108
  let cleaned = applyNegativeRules(applyCorrections(text)).replace(/\s+/g, ' ').trim() || text
109
+ // applyCorrections above is an EXACT string map, so it only fixes misspellings someone
110
+ // already hand-authored. A novel miss ("Miyala" for Niala, "Yukoma" for Ukaoma) sails
111
+ // through it. The Levenshtein pass is what catches those, and until now it had exactly
112
+ // ONE call site — transcribe-audio.ts:252, the server-transcription route — so phone
113
+ // Moonshine dictation, which arrives here as text, never got it. Same construction and
114
+ // same non-fatal posture as that site, deliberately: targets are speaker names plus
115
+ // vocabulary, and a throw must never cost the user their transcript.
116
+ //
117
+ // Runs BEFORE the autoclean LLM so the model sees corrected proper nouns rather than
118
+ // being asked to guess at them, and it still helps on every path where autoclean is
119
+ // off, over the char cap, or breaker-open.
120
+ try {
121
+ const fuzzyTargets = [...getAllSpeakerNames(), ...getVocabulary()]
122
+ const { text: corrected, replacements } = applyFuzzyCorrections(cleaned, fuzzyTargets)
123
+ if (replacements > 0) {
124
+ console.log(`[prompt-draft] Fuzzy corrected ${replacements} word(s)`)
125
+ cleaned = corrected
126
+ }
127
+ } catch (fuzzyErr: any) {
128
+ console.warn(`[prompt-draft] Fuzzy correction failed (non-fatal): ${fuzzyErr?.message ?? fuzzyErr}`)
129
+ }
107
130
  if (!(opts.enabled ?? autoCleanDefaultEnabled())) return cleaned
108
131
  if (cleaned.length > AUTOCLEAN_MAX_CHARS || autoCleanBreaker.isOpen() || autoCleanCountToday() >= autoCleanDailyCap()) return cleaned
109
132
  const startedAt = Date.now()