@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.
- package/.env.example +7 -0
- package/CHANGELOG.md +46 -0
- package/README.md +6 -2
- package/package.json +1 -1
- package/server/index.ts +23 -1
- package/server/lib/media-store.ts +100 -9
- package/server/lib/query-attachments.ts +1 -1
- package/server/lib/video-upload-v2.ts +726 -0
- package/server/routes/health.ts +24 -0
- package/server/routes/maintenance.ts +20 -1
- package/server/routes/media.ts +172 -0
- package/server/routes/prompt-drafts.ts +23 -0
package/server/routes/health.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/server/routes/media.ts
CHANGED
|
@@ -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()
|