@gotcos/glasses-server 6.21.5 → 6.21.7

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
@@ -28,13 +28,13 @@ BIND_HOST=0.0.0.0
28
28
  # server after Wi-Fi/Tailscale changes and process restarts.
29
29
  # COS_SERVER_INSTANCE_ID_PATH=/path/to/server-instance-id
30
30
 
31
- # Build 204+ can opt into server-owned durable query jobs. The server fsyncs an
31
+ # Build 204+ uses server-owned durable query jobs by default. The server fsyncs an
32
32
  # accepted prompt before returning 202, then keeps provider work running if the
33
33
  # phone backgrounds, reloads, or changes networks. Reopening COS reattaches to
34
- # the same job. Set to 0 (or remove) to send NEW prompts through the legacy
34
+ # the same job. Set to 0 to send NEW prompts through the legacy
35
35
  # streaming route; already accepted durable jobs remain readable/cancellable
36
36
  # and drain safely.
37
- # COS_DURABLE_QUERY_JOBS=1
37
+ # COS_DURABLE_QUERY_JOBS=0
38
38
 
39
39
  # ── THE LLM (chat) ──────────────────────────────────────────────────────
40
40
  # Chat runs through your LOCAL agent CLI — NOT an API key:
package/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
1
+ ## 6.21.7
2
+
3
+ - Add a default-off, authenticated meeting-preview endpoint for private canaries.
4
+ It accepts bounded, server-pinned audio snapshots and returns disposable
5
+ Large-v3-Turbo text without creating or mutating meeting sessions.
6
+ - Keep canonical Large-v3 transcription, speaker attribution, recovery, save, HQ
7
+ polish, and indexing unchanged. Preview never falls back to the canonical worker
8
+ and drops under canonical Metal contention.
9
+ - Reject stale server pins and oversized bodies before inference, recheck
10
+ maintenance admission after slow uploads, and drop concurrent preview work rather
11
+ than building a latency queue. `COS_WHISPER_MEETING_PREVIEW=1` is required.
12
+
13
+ ## 6.21.6
14
+
15
+ - Make server-owned durable query jobs the default so accepted replies keep
16
+ running through phone backgrounding, WebView reloads, and network handoffs.
17
+ - Preserve `COS_DURABLE_QUERY_JOBS=0` as the machine-wide rollback. Existing
18
+ accepted jobs remain recoverable and cancellable while new prompts use the
19
+ legacy streaming path.
20
+ - Publish the capability truth through authenticated health/model surfaces so
21
+ COS Control and the companion can independently enforce machine and device
22
+ preferences without changing the proven two-step cancel gesture.
23
+
1
24
  ## 6.21.5
2
25
 
3
26
  - Drop the narrow Large-v3 stock-caption artifact family beginning with
package/README.md CHANGED
@@ -102,8 +102,8 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
102
102
  ## What it does
103
103
 
104
104
  - Ask anything, get a streamed answer on the lens (`/api/query`, `/v1/chat/completions`)
105
- - With COS Glasses build 204+, opt into server-owned durable queries with
106
- `COS_DURABLE_QUERY_JOBS=1`: accepted work survives phone backgrounding,
105
+ - With COS Glasses build 204+, server-owned durable queries are on by default:
106
+ accepted work survives phone backgrounding,
107
107
  WebView reloads, and network handoffs, then reattaches without duplicate work
108
108
  or duplicate replies
109
109
  - Choose Opus, Fable, Sonnet, GPT Frontier, GPT Balanced, Composer 2.5 Fast, or
@@ -174,8 +174,8 @@ in the managed CLI working directory),
174
174
  `COS_CURSOR_PERSIST_SESSIONS=0` (disable Cursor session resume),
175
175
  `COS_WEATHER_DEFAULT_LAT` / `COS_WEATHER_DEFAULT_LON` /
176
176
  `COS_WEATHER_DEFAULT_CITY` (optional home fallback when phone GPS is denied),
177
- `COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=1` (build 204+
178
- server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
177
+ `COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=0` (optional
178
+ machine-wide rollback for build 204+ server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
179
179
  location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
180
180
  `~/.cos-glasses/.cos-profile.json` (see `.cos-profile.example.json`).
181
181
  Factory example values are ignored; add the real names, companies, acronyms,
@@ -338,10 +338,10 @@ BIND_HOST=0.0.0.0 npm run start:server
338
338
  only when OpenAI playback is intentionally configured.
339
339
  - *Photos unavailable?* — install `ffmpeg`, restart the server, and confirm `/api/health` reports `features.mediaProcessingReady: true`.
340
340
  - *Prompt recovery unavailable?* — update with `npx --yes @gotcos/glasses-server@latest`, then confirm `/api/health` reports `features.promptRecovery: true`.
341
- - *Durable query recovery unavailable?* — build 204+ requires server 6.10.0+ and
342
- `COS_DURABLE_QUERY_JOBS=1`. Restart once, then confirm `/api/health` reports
341
+ - *Durable query recovery unavailable?* — build 204+ requires server 6.10.0+.
342
+ Restart once, then confirm `/api/health` reports
343
343
  `features.durableQueryJobs: true`, protocol `1`, and state `ready`. To roll
344
- back, remove the flag; accepted jobs still drain while new prompts use legacy streaming.
344
+ back, set `COS_DURABLE_QUERY_JOBS=0`; accepted jobs still drain while new prompts use legacy streaming.
345
345
  - *Offline meeting recovery unavailable?* — build 209+ requires server 6.11.0+.
346
346
  Restart once, then confirm `/api/health` reports
347
347
  `features.localFirstMeetings: true` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.5",
3
+ "version": "6.21.7",
4
4
  "description": "COS Glasses — 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
@@ -432,23 +432,22 @@ listenRequiredServers(listeners).then(() => {
432
432
  console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
433
433
  }
434
434
 
435
- // Durable recovery and state-mutating background work remain closed until an
436
- // authenticated controller release. The lifecycle callback is delivered for
437
- // both an already-open fresh boot and a later cross-boot release.
435
+ // Durable recovery starts with the admitted runtime by default. A literal
436
+ // COS_DURABLE_QUERY_JOBS=0 is the machine-wide rollback.
438
437
  let admittedRuntimeStarted = false
439
438
  const startAdmittedRuntime = () => {
440
439
  if (admittedRuntimeStarted) return
441
440
  admittedRuntimeStarted = true
442
441
 
443
442
  void initQueryJobRuntime().then(health => {
444
- if (process.env.COS_DURABLE_QUERY_JOBS === '1') {
443
+ if (process.env.COS_DURABLE_QUERY_JOBS !== '0') {
445
444
  console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
446
445
  } else {
447
- console.log('[COS API] Durable query jobs: disabled (set COS_DURABLE_QUERY_JOBS=1 to enable)')
446
+ console.log('[COS API] Durable query jobs: disabled by COS_DURABLE_QUERY_JOBS=0')
448
447
  }
449
448
  }).catch(error => {
450
449
  // The store remains degraded and rejects admission. Legacy /api/query is
451
- // still mounted, so disabling the feature flag is an immediate rollback.
450
+ // still mounted, so the kill switch is an immediate rollback.
452
451
  console.error('[COS API] Durable query-job store unavailable:', error)
453
452
  })
454
453
 
@@ -1,12 +1,12 @@
1
1
  import { QUERY_JOB_PROTOCOL_VERSION } from './query-job-types.js'
2
2
 
3
3
  /**
4
- * Durable jobs ship dark. The private canary enables them explicitly with
5
- * COS_DURABLE_QUERY_JOBS=1; removing the flag is an immediate server-side
6
- * rollback that leaves the legacy /api/query path untouched.
4
+ * Durable jobs are the public default. COS_DURABLE_QUERY_JOBS=0 is the
5
+ * machine-wide rollback; every other value (including unset) keeps the
6
+ * capability available. COS Control owns the user-facing policy switch.
7
7
  */
8
8
  export function durableQueryJobsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
9
- return env.COS_DURABLE_QUERY_JOBS === '1'
9
+ return env.COS_DURABLE_QUERY_JOBS !== '0'
10
10
  }
11
11
 
12
12
  export function durableQueryJobsCapability(): {
@@ -434,3 +434,37 @@ export async function transcribeWhisperPreview(audioBuffer: Buffer): Promise<{
434
434
  throw error
435
435
  }
436
436
  }
437
+
438
+ /**
439
+ * Meeting-preview canary: use only the isolated Turbo sidecar.
440
+ *
441
+ * Unlike prompt preview, this path never falls through to the canonical
442
+ * Large-v3 worker. A missing, busy, preempted, or failed sidecar is a silent
443
+ * cosmetic miss; durable meeting transcription continues on its unchanged
444
+ * canonical path.
445
+ */
446
+ export async function transcribeWhisperMeetingPreview(audioBuffer: Buffer): Promise<{
447
+ text: string
448
+ model: 'large-v3-turbo'
449
+ backend: 'whisper-preview-server'
450
+ } | null> {
451
+ if (!previewAvailable || previewWorkerModel !== 'large-v3-turbo') return null
452
+ const previewLease = tryAcquireMetalPreview()
453
+ if (!previewLease) return null
454
+ try {
455
+ return {
456
+ text: await transcribeViaPreviewServer(audioBuffer, previewLease.signal),
457
+ model: 'large-v3-turbo',
458
+ backend: 'whisper-preview-server',
459
+ }
460
+ } catch (error) {
461
+ if (previewLease.signal.aborted) return null
462
+ previewAvailable = false
463
+ previewWorkerModel = null
464
+ previewFailure = 'preview_sidecar_unavailable'
465
+ console.warn(`[whisper-preview] meeting Turbo preview failed; canonical meeting ASR was not affected: ${error instanceof Error ? error.message : error}`)
466
+ return null
467
+ } finally {
468
+ previewLease.release()
469
+ }
470
+ }
@@ -29,11 +29,13 @@ import {
29
29
  } from '../lib/openai-whisper-budget.js'
30
30
  import {
31
31
  stripInlineHallucinations as sharedStripInlineHallucinations,
32
+ stripInlineHallucinationsOneShot,
32
33
  isFullHallucination as sharedIsFullHallucination,
33
34
  clearSessionHallucinationState,
34
35
  streamSilenceDropReason,
35
36
  isVocabEchoOnly,
36
37
  } from '../lib/hallucination-filter.js'
38
+ import { transcribeWhisperMeetingPreview } from '../lib/whisper-preview.js'
37
39
  import { dataPath } from '../lib/data-dir.js'
38
40
  import {
39
41
  countChunkWavs,
@@ -101,6 +103,12 @@ function ensurePrivateDirectory(path: string): void {
101
103
  // Rollback: COS_WHISPER_STRIP_BRAND_URLS=0 (URL drops), COS_WHISPER_THANKYOU_FILTER=0.
102
104
  const STRIP_BRAND_URLS = process.env.COS_WHISPER_STRIP_BRAND_URLS !== '0'
103
105
  const THANKYOU_FILTER = process.env.COS_WHISPER_THANKYOU_FILTER !== '0'
106
+ const MEETING_PREVIEW_MAX_BYTES = 512 * 1024
107
+ let meetingPreviewBusy = false
108
+
109
+ function meetingTurboPreviewEnabled(): boolean {
110
+ return process.env.COS_WHISPER_MEETING_PREVIEW === '1'
111
+ }
104
112
 
105
113
  // Audio persistence: save G2-mic chunks for speakers who need more training data
106
114
  const AUDIO_SAVE_DIR = dataPath('training-audio')
@@ -1180,6 +1188,21 @@ async function readRawBody(req: AsyncIterable<Buffer | Uint8Array | string>): Pr
1180
1188
  return Buffer.concat(buffers)
1181
1189
  }
1182
1190
 
1191
+ async function readBoundedRawBody(
1192
+ req: AsyncIterable<Buffer | Uint8Array | string>,
1193
+ maxBytes: number,
1194
+ ): Promise<Buffer> {
1195
+ const buffers: Buffer[] = []
1196
+ let total = 0
1197
+ for await (const chunk of req) {
1198
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
1199
+ total += buffer.length
1200
+ if (total > maxBytes) throw makeHttpError(413, 'audio chunk too large', 'chunk_too_large')
1201
+ buffers.push(buffer)
1202
+ }
1203
+ return Buffer.concat(buffers)
1204
+ }
1205
+
1183
1206
  async function persistRawSessionAudioChunk(sessionId: string, chunkIndex: number, audioBuffer: Buffer): Promise<void> {
1184
1207
  const sessionDir = resolve(SESSION_AUDIO_DIR, sessionId)
1185
1208
  ensurePrivateDirectory(sessionDir)
@@ -1664,6 +1687,70 @@ function sendStreamError(res: { status: (code: number) => { json: (body: unknown
1664
1687
  return res.status(status).json({ error: errMsg(err), reason: (err as any)?.reason })
1665
1688
  }
1666
1689
 
1690
+ /**
1691
+ * Default-off meeting preview canary.
1692
+ *
1693
+ * This route deliberately owns no meeting session, recovery ledger, speaker
1694
+ * attribution, or persistence. It accepts a pinned copy of the still-open
1695
+ * phrase and returns provisional Turbo text. Any contention or failure is a
1696
+ * 204 cosmetic miss; the canonical /transcribe-stream upload remains the only
1697
+ * durable and speaker-attributed result.
1698
+ */
1699
+ transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
1700
+ try {
1701
+ if (!meetingTurboPreviewEnabled() || !maintenanceAdmissionsOpen()) {
1702
+ return void res.status(204).send()
1703
+ }
1704
+
1705
+ const headerPin = req.get('X-COS-Server-Instance')?.trim() ?? ''
1706
+ const queryPin = typeof req.query.serverInstanceId === 'string'
1707
+ ? req.query.serverInstanceId.trim()
1708
+ : ''
1709
+ if (!headerPin && !queryPin) {
1710
+ throw makeHttpError(400, 'server identity pin required', 'server_identity_pin_required')
1711
+ }
1712
+ const serverInstanceId = assertPinnedServerIdentity(headerPin, queryPin)
1713
+ const sessionId = String(req.query.sessionId ?? '')
1714
+ validateSessionId(sessionId)
1715
+ const chunkIndex = Number(req.query.chunkIndex)
1716
+ validateChunkIndex(chunkIndex)
1717
+ const previewGen = Number(req.query.previewGen)
1718
+ if (!Number.isInteger(previewGen) || previewGen < 0 || previewGen > 1_000_000_000) {
1719
+ throw makeHttpError(400, 'invalid previewGen', 'invalid_preview_generation')
1720
+ }
1721
+
1722
+ const audio = await readBoundedRawBody(req, MEETING_PREVIEW_MAX_BYTES)
1723
+ if (audio.length < 100) throw makeHttpError(400, 'audio too short', 'audio_too_short')
1724
+ if (!maintenanceAdmissionsOpen() || meetingPreviewBusy) {
1725
+ return void res.status(204).send()
1726
+ }
1727
+
1728
+ meetingPreviewBusy = true
1729
+ try {
1730
+ const result = await transcribeWhisperMeetingPreview(audio)
1731
+ if (!result) return void res.status(204).send()
1732
+ const cleaned = stripInlineHallucinationsOneShot(result.text.trim()).trim()
1733
+ if (!cleaned || sharedIsFullHallucination(cleaned)) {
1734
+ return void res.status(204).send()
1735
+ }
1736
+ return void res.json({
1737
+ sessionId,
1738
+ chunkIndex,
1739
+ previewGen,
1740
+ text: cleaned,
1741
+ provisional: true,
1742
+ model: result.model,
1743
+ backend: result.backend,
1744
+ serverInstanceId,
1745
+ })
1746
+ } finally {
1747
+ meetingPreviewBusy = false
1748
+ }
1749
+ } catch (err: unknown) {
1750
+ sendStreamError(res, err)
1751
+ }
1752
+ })
1753
+
1667
1754
  transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
1668
1755
  let maintenanceLease: MaintenanceWorkLease | undefined
1669
1756
  try {