@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.
package/.env.example CHANGED
@@ -23,6 +23,13 @@ BIND_HOST=0.0.0.0
23
23
  # ~/.cos-glasses/data/media alongside the standalone conversation/archive data.
24
24
  # COS_MEDIA_ROOT=/path/on-a-local-volume/media
25
25
 
26
+ # Private canary for server 6.27.3 + companion 6.8.343. When enabled, every
27
+ # MP4/MOV uses a durable, resumable 256 KiB upload draft instead of one long
28
+ # WebView request. Accepted chunks and finalize receipts survive server
29
+ # restarts. Leave off for the released transport; COS Control 0.5.20 manages
30
+ # this setting and prevents unsafe binary rollback while V2 state exists.
31
+ # COS_VIDEO_UPLOAD_V2=0
32
+
26
33
  # Optional logical server identity location. Most installs should keep the
27
34
  # default (~/.cos-glasses/server-instance-id) so reconnects can verify the same
28
35
  # server after Wi-Fi/Tailscale changes and process restarts.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
1
+ ## 6.27.3
2
+
3
+ ### Durable, resumable video transport (private canary)
4
+
5
+ - Replaces fragile single-request phone video uploads with a generation-pinned,
6
+ resumable 256 KiB protocol whose accepted chunks survive server restarts.
7
+ - Adds idempotent init/finalize receipts, explicit acknowledgement and cancel,
8
+ bounded draft retention, and maintenance status so updates cannot erase a
9
+ video draft that is still being transferred or whose receipt is not stored.
10
+ - Keeps the published media record byte-compatible with 6.27.2: the original
11
+ MP4/MOV remains required and the existing validated ffprobe/ffmpeg path stays
12
+ the fallback. Phone frame extraction is separately gated until its physical
13
+ iPhone decoder/canvas acceptance test executes.
14
+ - New V2 admission is disabled by default (`COS_VIDEO_UPLOAD_V2=0`). Existing
15
+ V2 drafts remain observable, cancellable, and finalizable after the flag is
16
+ turned off so rollback never strands accepted bytes.
17
+
18
+ ## 6.27.2
19
+
20
+ ### One finalizer for phone and Mac dictation
21
+
22
+ - Added authenticated `POST /api/dictation/finalize` for already-transcribed
23
+ Moonshine text. It reuses the exact glossary, negative rules, Haiku/Sonnet
24
+ polish, circuit breaker, token audit, and daily cap already used by recovered
25
+ server prompt drafts.
26
+ - The route accepts bounded text only. Phone-local audio and rolling preview
27
+ audio do not leave the iPhone, and a finalizer failure falls back to the
28
+ deterministic glossary result instead of losing the transcription.
29
+ - Both Message commits and sealed Meeting preview phrases can use the same
30
+ final quality pass while canonical Large-v3 meeting transcription remains
31
+ unchanged.
32
+
1
33
  ## 6.27.1
2
34
 
3
35
  ### The 16-frame video from 6.27.0 now actually works end to end
package/README.md CHANGED
@@ -176,8 +176,12 @@ in the managed CLI working directory),
176
176
  `COS_WEATHER_DEFAULT_LAT` / `COS_WEATHER_DEFAULT_LON` /
177
177
  `COS_WEATHER_DEFAULT_CITY` (optional home fallback when phone GPS is denied),
178
178
  `COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=0` (optional
179
- machine-wide rollback for build 204+ server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
180
- location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
179
+ machine-wide rollback for build 204+ server-owned query recovery),
180
+ `COS_MEDIA_ROOT` (optional image/video store location; default
181
+ `~/.cos-glasses/data/media`), and `COS_VIDEO_UPLOAD_V2=1` (private 6.27.3+
182
+ resumable-video canary, managed by COS Control 0.5.20). The V2 canary retains
183
+ accepted 256 KiB chunks and finalize receipts across restarts; keep it off when
184
+ using an older companion. Your name + transcription vocabulary live in
181
185
  `~/.cos-glasses/.cos-profile.json` (see `.cos-profile.example.json`).
182
186
  Factory example values are ignored; add the real names, companies, acronyms,
183
187
  and specialist terms you say often. Guided Setup writes a safe empty profile
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.27.1",
3
+ "version": "6.27.3",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -162,6 +162,20 @@ app.use('/api', requireApiToken(API_TOKEN))
162
162
  // through their true terminal boundary.
163
163
  app.use('/api', (req, res, next) => {
164
164
  if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next()
165
+ // V2 video chunks are bounded to 256 KiB and commit through the upload
166
+ // registry's own generation lock. Holding the global mutation lease while
167
+ // the phone transfers the body recreated the exact 90-second drain failure
168
+ // this protocol exists to remove. Admission is checked by the route before
169
+ // bytes are committed; in-flight bounded bodies finish or are discarded.
170
+ const videoUploadBody = req.method === 'PUT'
171
+ && /^\/media\/video-upload\/vu_[0-9a-f]{24}\/(?:original|frame)\/\d+$/.test(req.path)
172
+ // Finalize may run ffmpeg for a long clip. Its durable registry state is the
173
+ // restart/rollback gate, so holding the global request lease as well would
174
+ // recreate Control's 90-second drain timeout. The route performs its own
175
+ // fail-closed admission immediately before claiming `finalizing`.
176
+ const videoUploadFinalize = req.method === 'POST'
177
+ && /^\/media\/video-upload\/vu_[0-9a-f]{24}\/finalize$/.test(req.path)
178
+ if (videoUploadBody || videoUploadFinalize) return next()
165
179
  const lifecycleOwned = (req.path === '/query-jobs' && req.method === 'POST')
166
180
  || req.path === '/query'
167
181
  || req.path === '/diagnostics/provider-proof'
@@ -190,6 +204,14 @@ app.use('/api', (req, res, next) => {
190
204
  nonce: typeof req.headers['x-cos-maintenance-nonce'] === 'string'
191
205
  ? req.headers['x-cos-maintenance-nonce'] : undefined,
192
206
  })
207
+ // Cleanup and receipt acknowledgement only reduce rollback blockers. They
208
+ // remain available during a drain so Control is never forced to wait for a
209
+ // four-hour upload TTL after the phone already cancelled or persisted the
210
+ // terminal receipt.
211
+ const videoUploadCleanup = /^\/media\/video-upload\/vu_[0-9a-f]{24}$/.test(req.path)
212
+ && req.method === 'DELETE'
213
+ || /^\/media\/video-upload\/vu_[0-9a-f]{24}\/ack$/.test(req.path)
214
+ && req.method === 'POST'
193
215
 
194
216
  try {
195
217
  // KNOWN HAZARD, deliberately not fixed here (2026-08-11). This lease is held for
@@ -207,7 +229,7 @@ app.use('/api', (req, res, next) => {
207
229
  // of the network transfer, which means changing a fail-closed middleware that
208
230
  // guards every mutation. That needs its own pass and its own tests.
209
231
  const lease = acquireMaintenanceWork('api_mutation', {
210
- allowDuringDrain: controllerProof,
232
+ allowDuringDrain: controllerProof || videoUploadCleanup,
211
233
  })
212
234
  let released = false
213
235
  const release = () => {
@@ -31,7 +31,7 @@ import {
31
31
  writeFileSync,
32
32
  } from 'node:fs'
33
33
  import { join, resolve, sep } from 'node:path'
34
- import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
34
+ import { atomicWriteFileSync, durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
35
35
  import { dataPath } from './data-dir.js'
36
36
  import {
37
37
  isValidMediaId,
@@ -183,6 +183,10 @@ export interface MediaRecord {
183
183
  updatedAtMs: number
184
184
  reservedAtMs?: number
185
185
  associatedAtMs?: number
186
+ /** Durable provenance for resumable video uploads. It is never exposed in
187
+ * the public attachment ref; it lets a lost finalize response or server
188
+ * restart rediscover the one record that upload already published. */
189
+ videoUploadId?: string
186
190
  }
187
191
 
188
192
  interface MediaIndexFile {
@@ -236,6 +240,10 @@ export interface IngestRichMediaFileInput {
236
240
  * existing ceiling; chunked finalize passes 'chunked' so a multi-hundred-MB video
237
241
  * is judged against the chunked cap instead of being refused AFTER transfer. */
238
242
  transfer?: MediaTransferMode
243
+ /** Preallocated by the durable video-upload manifest before publication. */
244
+ mediaId?: string
245
+ /** Private idempotency link used only by video-upload receipt recovery. */
246
+ videoUploadId?: string
239
247
  }
240
248
 
241
249
  /** Handle for a streaming upload's staging file. `dispose()` is idempotent and
@@ -313,6 +321,9 @@ function sanitizeRecord(raw: unknown): MediaRecord | null {
313
321
  updatedAtMs: typeof r.updatedAtMs === 'number' ? r.updatedAtMs : Date.now(),
314
322
  ...(typeof r.reservedAtMs === 'number' ? { reservedAtMs: r.reservedAtMs } : {}),
315
323
  ...(typeof r.associatedAtMs === 'number' ? { associatedAtMs: r.associatedAtMs } : {}),
324
+ ...(typeof r.videoUploadId === 'string' && /^vu_[0-9a-f]{24}$/.test(r.videoUploadId)
325
+ ? { videoUploadId: r.videoUploadId }
326
+ : {}),
316
327
  }
317
328
  }
318
329
 
@@ -468,15 +479,35 @@ export class MediaStore {
468
479
  rmSync(join(this.root, 'tmp'), { recursive: true, force: true })
469
480
  mkdirSync(join(this.root, 'tmp'), { recursive: true, mode: 0o700 })
470
481
  } catch { /* best effort */ }
482
+ let dirty = false
471
483
  if (this.allowOrphanCleanup) {
472
484
  try {
473
485
  for (const entry of readdirSync(join(this.root, 'assets'))) {
474
486
  try {
475
487
  if (!this.records.has(entry)) {
476
- // Unpublished orphan the upload died between rename and index
477
- // publish. Remove; the client never received this id.
478
- rmSync(join(this.root, 'assets', entry), { recursive: true, force: true })
479
- console.warn(`[media-store] removed unpublished orphan asset ${entry}`)
488
+ const intentPath = join(this.root, 'assets', entry, 'publication-intent.json')
489
+ let recovered = false
490
+ if (existsSync(intentPath)) {
491
+ try {
492
+ const candidate = sanitizeRecord(JSON.parse(readFileSync(intentPath, 'utf8')))
493
+ if (candidate && candidate.ref.id === entry && candidate.videoUploadId
494
+ && existsSync(this.absPath(candidate.storagePath))) {
495
+ this.records.set(entry, candidate)
496
+ dirty = true
497
+ recovered = true
498
+ console.warn(`[media-store] recovered resumable video publication ${entry}`)
499
+ }
500
+ } catch {
501
+ // An unreadable/inconsistent intent grants no publication.
502
+ // The client can safely repeat finalize from its durable draft.
503
+ }
504
+ }
505
+ if (!recovered) {
506
+ // Unpublished orphan — the upload died between rename and index
507
+ // publish and supplied no valid durable publication intent.
508
+ rmSync(join(this.root, 'assets', entry), { recursive: true, force: true })
509
+ console.warn(`[media-store] removed unpublished orphan asset ${entry}`)
510
+ }
480
511
  }
481
512
  } catch { /* skip this asset */ }
482
513
  }
@@ -484,7 +515,6 @@ export class MediaStore {
484
515
  } else {
485
516
  console.warn('[media-store] preserving unindexed asset dirs for recovery this boot')
486
517
  }
487
- let dirty = false
488
518
  for (const rec of this.records.values()) {
489
519
  try {
490
520
  if (!rec.contentRemoved && rec.lifecycle !== 'deleted' && rec.lifecycle !== 'expired' &&
@@ -497,7 +527,13 @@ export class MediaStore {
497
527
  } catch { /* skip */ }
498
528
  }
499
529
  if (dirty) {
500
- try { this.saveIndex() } catch (err) { console.error('[media-store] reconcile save failed:', err) }
530
+ try {
531
+ this.saveIndex()
532
+ for (const rec of this.records.values()) {
533
+ if (!rec.videoUploadId) continue
534
+ try { rmSync(join(this.root, 'assets', rec.ref.id, 'publication-intent.json'), { force: true }) } catch { /* next boot */ }
535
+ }
536
+ } catch (err) { console.error('[media-store] reconcile save failed:', err) }
501
537
  }
502
538
  }
503
539
 
@@ -563,10 +599,23 @@ export class MediaStore {
563
599
  }
564
600
 
565
601
  private async publishPreparedRichMedia(
566
- input: { label?: string; capturedAt?: string; sessionId?: string },
602
+ input: {
603
+ label?: string
604
+ capturedAt?: string
605
+ sessionId?: string
606
+ mediaId?: string
607
+ videoUploadId?: string
608
+ },
567
609
  prepared: PreparedRichMediaFile,
568
610
  ): Promise<MediaAttachmentRef> {
569
- const id = `m_${randomBytes(12).toString('hex')}`
611
+ const id = input.mediaId && isValidMediaId(input.mediaId)
612
+ ? input.mediaId
613
+ : `m_${randomBytes(12).toString('hex')}`
614
+ const existing = this.getRecord(id)
615
+ if (existing) {
616
+ if (input.videoUploadId && existing.videoUploadId === input.videoUploadId) return existing.ref
617
+ throw new MediaStoreError('media_conflict', `attachment ${id} already exists`)
618
+ }
570
619
  const now = Date.now()
571
620
  const nowIso = new Date(now).toISOString()
572
621
  const extension = prepared.category === 'video'
@@ -631,13 +680,24 @@ export class MediaStore {
631
680
  sha256,
632
681
  lifecycle: 'staged',
633
682
  ...(input.sessionId ? { sessionId: input.sessionId } : {}),
683
+ ...(input.videoUploadId ? { videoUploadId: input.videoUploadId } : {}),
634
684
  createdAtMs: now,
635
685
  updatedAtMs: now,
636
686
  }
687
+ // A crash can land after assets/<id> is renamed but before index.json is
688
+ // committed. Keep a private complete record beside the asset so boot
689
+ // reconciliation can finish that exact publication rather than deleting a
690
+ // valid video as an orphan. The public ref never exposes this file.
691
+ durableAtomicWriteFileSync(
692
+ join(this.root, 'assets', id, 'publication-intent.json'),
693
+ JSON.stringify(record),
694
+ { mode: 0o600 },
695
+ )
637
696
  await this.withLock(() => {
638
697
  this.records.set(id, record)
639
698
  this.saveIndex()
640
699
  })
700
+ try { rmSync(join(this.root, 'assets', id, 'publication-intent.json'), { force: true }) } catch { /* boot can clean it */ }
641
701
  // AFTER publication and outside the lock: x265 encodes at roughly real
642
702
  // time, so awaiting it here would hold the upload response open for the
643
703
  // length of the video.
@@ -814,6 +874,16 @@ export class MediaStore {
814
874
  return this.getRecord(id)?.ref ?? null
815
875
  }
816
876
 
877
+ /** Private receipt-recovery lookup. Upload ids are not capabilities and are
878
+ * never returned by ordinary media metadata routes. */
879
+ findByVideoUploadId(uploadId: string): MediaRecord | null {
880
+ if (!/^vu_[0-9a-f]{24}$/.test(uploadId)) return null
881
+ for (const record of this.records.values()) {
882
+ if (record.videoUploadId === uploadId) return record
883
+ }
884
+ return null
885
+ }
886
+
817
887
  /** Recover the public refs associated with one exact conversation turn.
818
888
  *
819
889
  * The media index is already the durable lifecycle authority for uploaded
@@ -1159,6 +1229,27 @@ export class MediaStore {
1159
1229
  })
1160
1230
  }
1161
1231
 
1232
+ /** Resumable-upload cancel may release a just-published result, but it must
1233
+ * never delete a reservation that a queued Ask already owns. */
1234
+ deleteExactlyStaged(id: string): Promise<'deleted' | 'retained' | 'missing'> {
1235
+ return this.withLock(() => {
1236
+ const rec = this.records.get(id)
1237
+ if (!rec || rec.lifecycle === 'deleted') return 'missing'
1238
+ if (rec.lifecycle !== 'staged') return 'retained'
1239
+ this.removeAssetFiles(rec)
1240
+ rec.lifecycle = 'deleted'
1241
+ rec.contentRemoved = true
1242
+ rec.updatedAtMs = Date.now()
1243
+ this.saveIndex()
1244
+ return 'deleted'
1245
+ })
1246
+ }
1247
+
1248
+ /** Durable upload manifests live beside, not inside, the legacy tmp tree. */
1249
+ rootDirectory(): string {
1250
+ return this.root
1251
+ }
1252
+
1162
1253
  private removeAssetFiles(rec: MediaRecord): void {
1163
1254
  try {
1164
1255
  rmSync(join(this.root, 'assets', rec.ref.id), { recursive: true, force: true })
@@ -50,7 +50,7 @@ export interface ResolvedQueryAttachments {
50
50
  * an optional aid over canonical text — so this ceiling cannot be treated as a
51
51
  * budget to trim silently. It has to fit a whole video.
52
52
  */
53
- const MAX_MODEL_IMAGE_INPUTS = VIDEO_SUMMARY_FRAMES_MAX
53
+ export const MAX_MODEL_IMAGE_INPUTS = VIDEO_SUMMARY_FRAMES_MAX
54
54
  const MAX_ATTACHMENT_PROMPT_CHARS = 60_000
55
55
 
56
56
  export class QueryAttachmentError extends Error {