@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 +7 -0
- package/CHANGELOG.md +32 -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 +28 -0
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),
|
|
180
|
-
|
|
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.
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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 {
|
|
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: {
|
|
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 =
|
|
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 {
|