@gotcos/glasses-server 6.18.8 → 6.20.0

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.
@@ -1,7 +1,7 @@
1
1
  {
2
- "owner_name": "Your Name",
2
+ "owner_name": "User",
3
3
  "owner_speaker_label": "Me",
4
- "vocabulary": ["NameOne", "NameTwo", "YourCompany", "ProductName"],
5
- "whisper_corrections": "{\"Soundalike\": \"YourName\"}",
4
+ "vocabulary": [],
5
+ "whisper_corrections": "{}",
6
6
  "negative_rules": []
7
7
  }
package/.env.example CHANGED
@@ -83,6 +83,13 @@ BIND_HOST=0.0.0.0
83
83
  # COS_HQ_SPECULATIVE_WARM=0 # disable background HQ warm
84
84
  # COS_BATCH_LARGE_V3=0 # explicitly use turbo instead of full HQ
85
85
  # COS_HQ_BEAM_INTERACTIVE=2 # interactive only; meetings stay at beam 5
86
+ # Adaptive prompt transcription keeps three quality lanes separate:
87
+ # Small.en provisional preview -> Large-v3-Turbo committed live text -> Large-v3 HQ polish.
88
+ # Unset preserves the pre-6.20 Turbo behavior. `auto` uses Small.en when it has
89
+ # been provisioned, otherwise Turbo. Guided Setup writes small.en explicitly
90
+ # and downloads its ~466 MB model. `off` hides provisional peeks without
91
+ # changing committed transcription or meeting audio.
92
+ # COS_WHISPER_PREVIEW_MODEL=auto # auto | small.en | turbo | off
86
93
 
87
94
  # Spoken reply playback defaults to local Kokoro on Apple silicon Macs. The
88
95
  # first run creates a private venv and downloads the model. Local mode fails
@@ -139,6 +146,17 @@ BIND_HOST=0.0.0.0
139
146
  # keeps working if the default ever flips to Metal-on.
140
147
  # COS_BATCH_HQ_FORCE_CPU=1
141
148
 
149
+ # ── UNSAVED-CAPTURE QUARANTINE (6.19.0) ─────────────────────────────────
150
+ # Meeting audio whose save never landed is QUARANTINED, never deleted. It
151
+ # surfaces on /api/health (unsaved_captures) and, with COS Control 0.3.1+,
152
+ # as an "Unsaved captures" row in the status card. One authenticated call
153
+ # recovers a capture into a durable meeting scribe:
154
+ # curl -X POST -H "x-cos-token: $COS_API_TOKEN" \
155
+ # http://127.0.0.1:3141/api/meeting/orphans/<sessionId>/recover
156
+ # Quarantined audio expires on this retention clock (hours, clamped 1-720).
157
+ # 72 covers a long weekend away from the Mac.
158
+ # COS_UNSAVED_AUDIO_RETENTION_HOURS=72
159
+
142
160
  # ── LIVE CUES (optional — requires the FULL COS PIPELINE above) ──────────
143
161
  # Live meeting coaching cues on the lens: transcript window -> Composer
144
162
  # planner -> Qdrant -> LightRAG -> Composer insight -> coaching_nudge.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,81 @@
1
+ ## 6.20.0
2
+
3
+ Adaptive transcription makes fast feedback additive instead of a quality
4
+ trade-off. The preview lane can be fast without changing the transcript that is
5
+ saved, searched, or sent.
6
+
7
+ - **Three explicit local transcription lanes.** Prompt previews can run on a
8
+ dedicated Small.en sidecar, committed live text remains on
9
+ Large-v3-Turbo, and HQ polish remains on Large-v3. The existing phone
10
+ `provisional` contract is unchanged. A late or failed preview cannot replace
11
+ committed text or advance the recovery ledger.
12
+ - **Safe adaptive fallback.** `COS_WHISPER_PREVIEW_MODEL` accepts `auto`,
13
+ `small.en`, `turbo`, or `off`. `auto` uses Small.en only when provisioned;
14
+ otherwise it preserves the prior Turbo behavior. A Small.en failure falls
15
+ back to the non-circuit Turbo path and cannot trip or success-reset the
16
+ authoritative Whisper breaker. The early private
17
+ `COS_WHISPER_REALTIME_MODEL` name remains a migration alias.
18
+ - **No surprise on update.** An unset preview setting keeps the pre-6.20 Turbo
19
+ behavior. Guided Setup explicitly opts users into Small.en after provisioning
20
+ its weights.
21
+ - **One setup command.** `--setup-transcription` records the adaptive choice
22
+ and provisions Small.en preview, Turbo commit, and Large-v3 HQ weights.
23
+ Pair with `--prepare-only` for COS Control so provisioning exits before the
24
+ managed LaunchAgent takes ownership.
25
+ - **Truthful health.** `/api/health` and `/api/models` add path-free
26
+ `capabilities.transcription.live` and `.profile` blocks alongside the
27
+ existing `.hq` block. COS Control can report the effective preview, commit,
28
+ and HQ models independently.
29
+ - **Factory vocabulary can no longer reduce accuracy.** The shipped profile is
30
+ empty and safe. Existing `Your Name`, `NameOne`, `NameTwo`, `YourCompany`,
31
+ `ProductName`, and `Soundalike -> YourName` examples are ignored everywhere
32
+ decoder bias or correction data is consumed. Startup warns once, and health
33
+ reports the ignored count. Real terms are trimmed and deduplicated.
34
+ - **Correction keys are literal.** User correction keys are escaped before
35
+ regular-expression construction, so punctuation in names cannot alter the
36
+ matcher.
37
+
38
+ ## 6.19.0
39
+
40
+ Meeting audio is evidence. This release stops the server from ever deleting an
41
+ unsaved capture, and makes batch status stop lying about finished work.
42
+
43
+ - **Unsaved-capture quarantine (the 2026-08-01 data-loss fix).** The
44
+ session-audio purge (boot sweep + 60s interval + non-saved session close)
45
+ DELETED any directory not tracked in memory once the 4h idle retention
46
+ passed — an offline meeting whose deferred save never landed lost its
47
+ full-fidelity audio within a minute. Two real meetings were destroyed this
48
+ way on 2026-08-01; only speaker-enrollment fragments survived. Audio-bearing
49
+ directories are now MOVED to `data/unsaved-audio/` with a manifest, never
50
+ deleted in place. Empty directories are still cleaned. A failed quarantine
51
+ move leaves the source untouched. Quarantine expires on
52
+ `COS_UNSAVED_AUDIO_RETENTION_HOURS` (default 72, clamped 1–720) — the only
53
+ place quarantined audio is ever deleted.
54
+ - **Unsaved captures are visible.** `/api/health` gains `unsaved_captures`
55
+ (count + compact items, same exposure level as `meeting_sync`). The
56
+ authenticated `GET /api/meeting/orphans` returns full detail.
57
+ - **Miles-triggered recovery, surface-only by decision (2026-08-02).**
58
+ `POST /api/meeting/orphans/:sessionId/recover` batch-transcribes the
59
+ quarantined WAVs (same segment/enhance/Metal-preempt contract as HQ polish,
60
+ under a new `orphan_recovery` maintenance lease), writes a durable scribe,
61
+ and hands off to operations when the COS pipeline is configured. Idempotent
62
+ via the save-receipt short-circuit; the server never drives recovery on its
63
+ own, and audio stays in quarantine until the retention clock — a failed
64
+ recovery is retryable.
65
+ - **Rejected HQ batches release their status.** A terminal batch outcome
66
+ (rejected quality, pipeline failure, accepted-but-unpersisted) now writes
67
+ `_batch_terminal.json` next to the retained WAVs. `meeting_sync` reports
68
+ those as `retained` — never as active work — so a rejected batch no longer
69
+ shows "HQ polish · N chunks" with `blocksRestart: true` for the 12h WAV
70
+ retention after the work already finished (observed on
71
+ meeting_1785695339502_mvqm0p, reason `repetitive-output`). A retry clears
72
+ the terminal record; live progress always wins. `meeting_sync.retained` is
73
+ additive — older consumers ignore it.
74
+ - Deferred by design: the realtime-model fallback port (W3) ships in its own
75
+ release. The app-side module has diverged ~1,100 lines from this repo's
76
+ whisper path; transplanting it alongside the data-loss fix would couple the
77
+ release's safest change to its riskiest. No default flips either way.
78
+
1
79
  ## 6.18.8
2
80
 
3
81
  - **Prompt draft peeks for live ASR.** `POST /api/prompt-drafts/:draftId/peek`
package/README.md CHANGED
@@ -123,6 +123,12 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
123
123
  locally through a network interruption. Reconnecting reconciles the exact
124
124
  chunks already stored by the Mac, uploads only missing audio, and finalizes
125
125
  through an idempotent save receipt without duplicating the meeting.
126
+ - Since 6.19.0, meeting audio whose save never lands is quarantined for 72 hours
127
+ (`COS_UNSAVED_AUDIO_RETENTION_HOURS`) instead of being cleaned up, surfaces on
128
+ `/api/health` as `unsaved_captures`, and can be recovered into a durable
129
+ meeting scribe with one authenticated call
130
+ (`POST /api/meeting/orphans/:sessionId/recover`; list via
131
+ `GET /api/meeting/orphans`).
126
132
  - Local whisper.cpp transcription (free and local-only by default). OpenAI
127
133
  Whisper fallback is optional and requires both the exact
128
134
  `COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a configured key; a key alone never
@@ -168,6 +174,9 @@ in the managed CLI working directory),
168
174
  server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
169
175
  location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
170
176
  `~/.cos-glasses/.cos-profile.json` (see `.cos-profile.example.json`).
177
+ Factory example values are ignored; add the real names, companies, acronyms,
178
+ and specialist terms you say often. Guided Setup writes a safe empty profile
179
+ instead of biasing Whisper toward placeholder text.
171
180
  Telegram activity export is disabled by default even when a private COS
172
181
  pipeline contains `.telegram_config.json`; enable it only with the explicit
173
182
  `COS_TELEGRAM_NOTIFICATIONS=1` opt-in.
@@ -227,6 +236,20 @@ Prompt dictation defaults to HQ. The phone owns the preference: **Fast mode
227
236
  OFF** requests HQ, and **Fast mode ON** requests turbo. The Mac performs all
228
237
  decoding; the phone does not run Whisper.
229
238
 
239
+ For the recommended adaptive setup, run:
240
+
241
+ ```bash
242
+ npx --yes @gotcos/glasses-server@latest --setup-transcription
243
+ ```
244
+
245
+ That keeps three jobs separate: Small.en supplies provisional words on the
246
+ lens, Large-v3-Turbo commits the authoritative live transcript, and Large-v3
247
+ polishes saved prompts and meetings. Small.en never writes the recovery ledger.
248
+ If its sidecar is missing or unhealthy, preview falls back to Turbo without
249
+ changing final quality. Set `COS_WHISPER_PREVIEW_MODEL=turbo` to keep one live
250
+ model, or `off` to disable provisional peeks. Existing installs that only
251
+ update the server remain on Turbo until Guided Setup opts them into Small.en.
252
+
230
253
  The first server start downloads the real-time turbo model. True HQ additionally
231
254
  requires the full `ggml-large-v3.bin` model (about 3.1 GB):
232
255
 
package/bin/cli.cjs CHANGED
@@ -15,6 +15,8 @@ const {
15
15
  unlinkSync,
16
16
  renameSync,
17
17
  chmodSync,
18
+ writeFileSync,
19
+ statfsSync,
18
20
  } = require('fs')
19
21
  const { delimiter, join, resolve } = require('path')
20
22
  const { homedir } = require('os')
@@ -22,6 +24,8 @@ const { homedir } = require('os')
22
24
  // bin/cli.cjs -> package root is one level up. The server lives at <root>/server.
23
25
  const PKG_ROOT = resolve(__dirname, '..')
24
26
  const CONFIG_DIR = join(homedir(), '.cos-glasses')
27
+ const PREPARE_ONLY = process.argv.includes('--prepare-only')
28
+ const SETUP_TRANSCRIPTION = process.argv.includes('--setup-transcription')
25
29
 
26
30
  // Record where the user ran `npx @gotcos/glasses-server` from. The server spawns
27
31
  // with cwd = PKG_ROOT (the npx cache), so without this the user's Starter-Kit COS
@@ -41,6 +45,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
41
45
  console.log('')
42
46
  console.log(' Usage:')
43
47
  console.log(' npx --yes @gotcos/glasses-server@latest')
48
+ console.log(' npx --yes @gotcos/glasses-server@latest --setup-transcription')
44
49
  console.log(' npx --yes @gotcos/glasses-server@latest --prepare-only')
45
50
  console.log('')
46
51
  console.log(' Requirements:')
@@ -261,7 +266,7 @@ try {
261
266
  // and the packaged runtime without creating COS config, downloading Kokoro
262
267
  // models, installing Kokoro packages, changing COS permissions, or starting a
263
268
  // listener. An invoked agent CLI may still maintain its own user cache.
264
- if (process.argv.includes('--prepare-only')) {
269
+ if (PREPARE_ONLY && !SETUP_TRANSCRIPTION) {
265
270
  if (process.platform === 'darwin' && process.arch === 'arm64') {
266
271
  const python = compatibleKokoroPython()
267
272
  if (python) {
@@ -329,6 +334,31 @@ if (existsSync(ENV_FILE)) {
329
334
  }
330
335
  } catch { /* config is optional */ }
331
336
  }
337
+
338
+ function upsertEnvValue(file, key, value) {
339
+ const current = existsSync(file) ? readFileSync(file, 'utf8') : ''
340
+ const lines = current.split(/\r?\n/)
341
+ const prefix = `${key}=`
342
+ let replaced = false
343
+ const next = lines.map((line) => {
344
+ if (line.trimStart().startsWith('#')) return line
345
+ if (!line.startsWith(prefix)) return line
346
+ replaced = true
347
+ return `${prefix}${value}`
348
+ })
349
+ if (!replaced) {
350
+ if (next.length && next.at(-1) !== '') next.push('')
351
+ next.push(`${prefix}${value}`)
352
+ }
353
+ writeFileSync(file, next.join('\n').replace(/\n*$/, '\n'), { mode: 0o600 })
354
+ chmodSync(file, 0o600)
355
+ }
356
+
357
+ if (SETUP_TRANSCRIPTION) {
358
+ upsertEnvValue(ENV_FILE, 'COS_WHISPER_PREVIEW_MODEL', 'small.en')
359
+ process.env.COS_WHISPER_PREVIEW_MODEL = 'small.en'
360
+ console.log(green(' ✓') + ' Adaptive transcription selected ' + dim('— Small.en preview · Turbo commit · Large-v3 HQ'))
361
+ }
332
362
  // Persistent profile (identity + transcription vocabulary)
333
363
  const PROFILE_FILE = join(CONFIG_DIR, '.cos-profile.json')
334
364
  const PROFILE_EXAMPLE = join(PKG_ROOT, '.cos-profile.example.json')
@@ -349,11 +379,23 @@ if (!process.env.COS_PROFILE_PATH) process.env.COS_PROFILE_PATH = PROFILE_FILE
349
379
  // Step 5: local Whisper detection + model download. Voice stays local-only by
350
380
  // default; cloud fallback requires an explicit flag plus a configured key.
351
381
  const WHISPER_KNOWN_PATHS = ['/opt/homebrew/bin/whisper-cli', '/usr/local/bin/whisper-cli']
382
+ const WHISPER_SERVER_KNOWN_PATHS = ['/opt/homebrew/bin/whisper-server', '/usr/local/bin/whisper-server']
352
383
  const WHISPER_MODEL_DIR = join(homedir(), '.local/share/whisper-models')
353
384
  const WHISPER_MODEL_PATH = join(WHISPER_MODEL_DIR, 'ggml-large-v3-turbo.bin')
354
385
  const WHISPER_MODEL_PARTIAL = WHISPER_MODEL_PATH + '.partial'
355
386
  const WHISPER_MODEL_URL = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin'
356
387
  const WHISPER_MODEL_MIN_BYTES = 800_000_000
388
+ const WHISPER_SMALL_MODEL_PATH = join(WHISPER_MODEL_DIR, 'ggml-small.en.bin')
389
+ const WHISPER_SMALL_MODEL_PARTIAL = WHISPER_SMALL_MODEL_PATH + '.partial'
390
+ const WHISPER_SMALL_MODEL_URL = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin'
391
+ const WHISPER_SMALL_MODEL_MIN_BYTES = 400_000_000
392
+ const WHISPER_LARGE_MODEL_PATH = join(WHISPER_MODEL_DIR, 'ggml-large-v3.bin')
393
+ const WHISPER_LARGE_MODEL_PARTIAL = WHISPER_LARGE_MODEL_PATH + '.partial'
394
+ const WHISPER_LARGE_MODEL_URL = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin'
395
+ const WHISPER_LARGE_MODEL_MIN_BYTES = 2_800_000_000
396
+ const WHISPER_MODEL_EXPECTED_BYTES = 1_620_000_000
397
+ const WHISPER_SMALL_MODEL_EXPECTED_BYTES = 466_000_000
398
+ const WHISPER_LARGE_MODEL_EXPECTED_BYTES = 3_100_000_000
357
399
  function findWhisperCli() {
358
400
  for (const p of WHISPER_KNOWN_PATHS) { if (existsSync(p)) return p }
359
401
  try {
@@ -363,12 +405,58 @@ function findWhisperCli() {
363
405
  return null
364
406
  }
365
407
  }
366
- function isValidWhisperModel(p) {
408
+ function findWhisperServer() {
409
+ for (const p of WHISPER_SERVER_KNOWN_PATHS) { if (existsSync(p)) return p }
410
+ try {
411
+ const found = execSync('command -v whisper-server 2>/dev/null', { shell: '/bin/sh', stdio: 'pipe', timeout: 2000 }).toString().trim()
412
+ return found || null
413
+ } catch {
414
+ return null
415
+ }
416
+ }
417
+ function isValidWhisperModel(p, minBytes = WHISPER_MODEL_MIN_BYTES) {
367
418
  if (!existsSync(p)) return false
368
- try { return statSync(p).size >= WHISPER_MODEL_MIN_BYTES } catch { return false }
419
+ try { return statSync(p).size >= minBytes } catch { return false }
369
420
  }
370
421
  const whisperCliPath = findWhisperCli()
422
+ const whisperServerPath = findWhisperServer()
371
423
  const hasValidModel = isValidWhisperModel(WHISPER_MODEL_PATH)
424
+
425
+ if (SETUP_TRANSCRIPTION && (!whisperCliPath || !whisperServerPath)) {
426
+ console.log('')
427
+ console.log(red(' ✗ Adaptive transcription needs whisper.cpp'))
428
+ console.log(' Install it first: ' + bold('brew install whisper-cpp'))
429
+ console.log(' Then rerun this setup command. No model download was started.')
430
+ console.log('')
431
+ process.exit(1)
432
+ }
433
+
434
+ if (SETUP_TRANSCRIPTION && process.env.SKIP_WHISPER_DOWNLOAD !== '1') {
435
+ mkdirSync(WHISPER_MODEL_DIR, { recursive: true })
436
+ const targets = [
437
+ [WHISPER_MODEL_PATH, WHISPER_MODEL_PARTIAL, WHISPER_MODEL_MIN_BYTES, WHISPER_MODEL_EXPECTED_BYTES],
438
+ [WHISPER_SMALL_MODEL_PATH, WHISPER_SMALL_MODEL_PARTIAL, WHISPER_SMALL_MODEL_MIN_BYTES, WHISPER_SMALL_MODEL_EXPECTED_BYTES],
439
+ [WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_PARTIAL, WHISPER_LARGE_MODEL_MIN_BYTES, WHISPER_LARGE_MODEL_EXPECTED_BYTES],
440
+ ]
441
+ const missingBytes = targets.reduce((sum, [path, , minimum, expected]) =>
442
+ sum + (isValidWhisperModel(path, minimum) ? 0 : expected), 0)
443
+ const reclaimableBytes = targets.reduce((sum, [, partial]) => {
444
+ try { return sum + statSync(partial).size } catch { return sum }
445
+ }, 0)
446
+ const fsStats = statfsSync(WHISPER_MODEL_DIR)
447
+ const availableBytes = (fsStats.bavail * fsStats.bsize) + reclaimableBytes
448
+ const safetyMarginBytes = 750_000_000
449
+ if (missingBytes > 0 && availableBytes < missingBytes + safetyMarginBytes) {
450
+ const requiredGB = ((missingBytes + safetyMarginBytes) / 1_000_000_000).toFixed(1)
451
+ const availableGB = (availableBytes / 1_000_000_000).toFixed(1)
452
+ console.log('')
453
+ console.log(red(' ✗ Not enough free disk space for adaptive transcription'))
454
+ console.log(` Need about ${requiredGB} GB; ${availableGB} GB is available after reusable partial downloads.`)
455
+ console.log(' Free space, then rerun this setup command. Existing models were not removed.')
456
+ console.log('')
457
+ process.exit(1)
458
+ }
459
+ }
372
460
  let localVoiceReady = Boolean(whisperCliPath && hasValidModel)
373
461
  if (whisperCliPath && hasValidModel) {
374
462
  console.log(green(' ✓') + ' whisper.cpp + model ready ' + dim('— voice = local (FREE)'))
@@ -383,7 +471,7 @@ if (whisperCliPath && hasValidModel) {
383
471
  } else {
384
472
  try {
385
473
  mkdirSync(WHISPER_MODEL_DIR, { recursive: true })
386
- execSync(`curl -fL --progress-bar "${WHISPER_MODEL_URL}" -o "${WHISPER_MODEL_PARTIAL}"`, { stdio: 'inherit', timeout: 900000 })
474
+ execFileSync('curl', ['-fL', '--progress-bar', WHISPER_MODEL_URL, '-o', WHISPER_MODEL_PARTIAL], { stdio: 'inherit', timeout: 900000 })
387
475
  const stats = statSync(WHISPER_MODEL_PARTIAL)
388
476
  if (stats.size < WHISPER_MODEL_MIN_BYTES) throw new Error(`Downloaded file too small: ${stats.size} bytes`)
389
477
  renameSync(WHISPER_MODEL_PARTIAL, WHISPER_MODEL_PATH)
@@ -405,6 +493,70 @@ if (process.env.COS_OPENAI_WHISPER_FALLBACK === '1') {
405
493
  console.log(green(' ✓') + ' Transcription policy: local-only ' + dim('— a key alone never uploads audio'))
406
494
  }
407
495
 
496
+ const previewChoice = (process.env.COS_WHISPER_PREVIEW_MODEL || process.env.COS_WHISPER_REALTIME_MODEL || 'auto').trim().toLowerCase()
497
+ const wantsSmallPreview = ['small', 'small.en', 'ggml-small.en.bin'].includes(previewChoice)
498
+ if (whisperCliPath && wantsSmallPreview) {
499
+ if (isValidWhisperModel(WHISPER_SMALL_MODEL_PATH, WHISPER_SMALL_MODEL_MIN_BYTES)) {
500
+ console.log(green(' ✓') + ' Small.en preview model ready ' + dim('— Turbo remains authoritative'))
501
+ } else {
502
+ if (existsSync(WHISPER_SMALL_MODEL_PATH)) { try { unlinkSync(WHISPER_SMALL_MODEL_PATH) } catch {} }
503
+ if (existsSync(WHISPER_SMALL_MODEL_PARTIAL)) { try { unlinkSync(WHISPER_SMALL_MODEL_PARTIAL) } catch {} }
504
+ console.log(yellow(' ⚠') + ' Adaptive preview model missing')
505
+ console.log(' ' + dim('Downloading ggml-small.en (~466 MB).'))
506
+ if (process.env.SKIP_WHISPER_DOWNLOAD === '1') {
507
+ console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — previews use Turbo until Small.en is provisioned')
508
+ } else {
509
+ try {
510
+ mkdirSync(WHISPER_MODEL_DIR, { recursive: true })
511
+ execFileSync('curl', ['-fL', '--progress-bar', WHISPER_SMALL_MODEL_URL, '-o', WHISPER_SMALL_MODEL_PARTIAL], { stdio: 'inherit', timeout: 900000 })
512
+ const stats = statSync(WHISPER_SMALL_MODEL_PARTIAL)
513
+ if (stats.size < WHISPER_SMALL_MODEL_MIN_BYTES) throw new Error(`Downloaded file too small: ${stats.size} bytes`)
514
+ renameSync(WHISPER_SMALL_MODEL_PARTIAL, WHISPER_SMALL_MODEL_PATH)
515
+ console.log(green(' ✓') + ' Small.en preview model downloaded ' + dim('— Turbo commit unchanged'))
516
+ } catch (err) {
517
+ try { unlinkSync(WHISPER_SMALL_MODEL_PARTIAL) } catch {}
518
+ console.log(red(' ✗') + ' Small.en download failed ' + dim('— previews safely fall back to Turbo'))
519
+ console.log(' ' + dim('Error: ' + (err.message || err).toString().slice(0, 120)))
520
+ }
521
+ }
522
+ }
523
+ }
524
+
525
+ if (whisperCliPath && SETUP_TRANSCRIPTION) {
526
+ if (isValidWhisperModel(WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_MIN_BYTES)) {
527
+ console.log(green(' ✓') + ' Large-v3 HQ model ready ' + dim('— saved meetings use full polish'))
528
+ } else {
529
+ if (existsSync(WHISPER_LARGE_MODEL_PATH)) { try { unlinkSync(WHISPER_LARGE_MODEL_PATH) } catch {} }
530
+ if (existsSync(WHISPER_LARGE_MODEL_PARTIAL)) { try { unlinkSync(WHISPER_LARGE_MODEL_PARTIAL) } catch {} }
531
+ console.log(yellow(' ⚠') + ' HQ polish model missing')
532
+ console.log(' ' + dim('Downloading ggml-large-v3 (~3.1 GB).'))
533
+ if (process.env.SKIP_WHISPER_DOWNLOAD === '1') {
534
+ console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — HQ safely reports unavailable and live Turbo continues')
535
+ } else {
536
+ try {
537
+ mkdirSync(WHISPER_MODEL_DIR, { recursive: true })
538
+ execFileSync('curl', ['-fL', '--progress-bar', WHISPER_LARGE_MODEL_URL, '-o', WHISPER_LARGE_MODEL_PARTIAL], { stdio: 'inherit', timeout: 1800000 })
539
+ const stats = statSync(WHISPER_LARGE_MODEL_PARTIAL)
540
+ if (stats.size < WHISPER_LARGE_MODEL_MIN_BYTES) throw new Error(`Downloaded file too small: ${stats.size} bytes`)
541
+ renameSync(WHISPER_LARGE_MODEL_PARTIAL, WHISPER_LARGE_MODEL_PATH)
542
+ console.log(green(' ✓') + ' Large-v3 HQ model downloaded')
543
+ } catch (err) {
544
+ try { unlinkSync(WHISPER_LARGE_MODEL_PARTIAL) } catch {}
545
+ console.log(red(' ✗') + ' Large-v3 download failed ' + dim('— live Turbo remains available'))
546
+ console.log(' ' + dim('Error: ' + (err.message || err).toString().slice(0, 120)))
547
+ }
548
+ }
549
+ }
550
+ }
551
+
552
+ if (PREPARE_ONLY && SETUP_TRANSCRIPTION) {
553
+ console.log('')
554
+ console.log(green(' ✓ Adaptive transcription setup complete'))
555
+ console.log(' Return to COS Control and Restart, install, or update the managed server.')
556
+ console.log('')
557
+ process.exit(0)
558
+ }
559
+
408
560
  // Step 6: image capability — ffmpeg validates, strips metadata, normalizes,
409
561
  // and builds the exact 288x144 G2 variant. It is optional so text/voice remain
410
562
  // useful on a minimal install, but the launcher should make the gap visible.
@@ -21,6 +21,9 @@
21
21
  "COS_LIVE_CUES_AUTO",
22
22
  "COS_BATCH_HQ_METAL",
23
23
  "COS_BATCH_HQ_FORCE_CPU",
24
+ "COS_WHISPER_PREVIEW_MODEL",
25
+ "COS_WHISPER_REALTIME_MODEL",
26
+ "COS_UNSAVED_AUDIO_RETENTION_HOURS",
24
27
  "COS_CLAUDE_TRUST_MODE",
25
28
  "COS_CODEX_SANDBOX",
26
29
  "COS_SCRIPTS_DIR"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.18.8",
3
+ "version": "6.20.0",
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
@@ -48,6 +48,7 @@ import {
48
48
  stopCodexModelCatalogRefresh,
49
49
  } from './lib/codex-model-catalog.js'
50
50
  import { startWhisperServer, stopWhisperServer } from './lib/whisper-local.js'
51
+ import { startWhisperPreviewServer, stopWhisperPreviewServer } from './lib/whisper-preview.js'
51
52
  import { startLocalTtsServer, stopLocalTtsServer } from './lib/tts-local.js'
52
53
  import { initSileroVAD } from './lib/vad-silero.js'
53
54
  import { initSessionCache } from './lib/session-cache-writer.js'
@@ -58,6 +59,7 @@ import { listenRequiredServers, type RequiredListener } from './lib/listener-sta
58
59
  import { serverMetrics } from './lib/server-metrics.js'
59
60
  import { initializeServerInstanceId } from './lib/server-instance-id.js'
60
61
  import { appendPrivateEnvBlock, UnsafeUserConfigPathError } from './lib/secure-user-config.js'
62
+ import { getTranscriptionProfileStatus } from './lib/profile.js'
61
63
  import { createQueryJobsRouter } from './routes/query-jobs.js'
62
64
  import {
63
65
  initQueryJobRuntime,
@@ -296,6 +298,7 @@ async function gracefulShutdown(): Promise<void> {
296
298
  try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
297
299
  stopCodexModelCatalogRefresh()
298
300
  stopWhisperServer()
301
+ stopWhisperPreviewServer()
299
302
  stopLocalTtsServer()
300
303
  clearTimeout(forceExit)
301
304
  process.exit(0)
@@ -407,7 +410,19 @@ listenRequiredServers(listeners).then(() => {
407
410
  // known-good snapshot if Codex is temporarily unavailable.
408
411
  startCodexModelCatalogRefresh()
409
412
  // Start local whisper-server (model stays in RAM for ~50ms transcription)
410
- startWhisperServer().catch(err => console.error('[startup] Whisper server error:', err))
413
+ const transcriptionProfile = getTranscriptionProfileStatus()
414
+ if (transcriptionProfile.ignoredPlaceholderTerms > 0 || transcriptionProfile.ignoredPlaceholderCorrection) {
415
+ console.warn(
416
+ '[whisper] .cos-profile.json still contains factory vocabulary. ' +
417
+ 'Placeholder terms are ignored; add real names and terminology in Guided Setup for better accuracy.',
418
+ )
419
+ }
420
+ // Load the authoritative Turbo worker first. Starting both model loads at
421
+ // once made first boot slower on smaller Macs and could cause Control's
422
+ // readiness proof to time out even though both models were healthy.
423
+ startWhisperServer()
424
+ .then(() => startWhisperPreviewServer())
425
+ .catch(err => console.error('[startup] Whisper server/preview error:', err))
411
426
  startLocalTtsServer().catch(err => console.error('[startup] Local TTS server error:', err))
412
427
  // Initialize speaker embeddings (voiceprint-based diarization) — fails soft if model absent
413
428
  const embeddingOk = initSpeakerEmbeddings()
@@ -11,7 +11,7 @@
11
11
  // isFullHallucination(text) — returns true if the text IS a hallucination in its
12
12
  // entirety (silence artifacts, caption training, foreign script, filler-only).
13
13
 
14
- import { getNegativeRules, getVocabulary, getOwnerName, loadProfileField } from './profile.js'
14
+ import { getNegativeRules, getVocabulary, getOwnerName, getWhisperCorrections } from './profile.js'
15
15
 
16
16
  // ── Whole-chunk silence hallucinations ─────────────────────────────────────
17
17
  const KNOWN_HALLUCINATIONS = [
@@ -333,13 +333,10 @@ function getVocabEchoMatcher(): RegExp {
333
333
  for (const v of getVocabulary()) if (v && v.trim()) raw.add(v.trim())
334
334
  // Include whisper_corrections key/value variants so the echo matches whatever
335
335
  // spelling whisper emits ("POS Nation" ↔ "POSNation", "Jewel 360" ↔ "Jewel360").
336
- try {
337
- const corrRaw = loadProfileField('whisper_corrections', '')
338
- if (corrRaw) {
339
- const map = JSON.parse(corrRaw) as Record<string, string>
340
- for (const [k, val] of Object.entries(map)) { if (k) raw.add(k); if (val) raw.add(val) }
341
- }
342
- } catch { /* malformed corrections — ignore */ }
336
+ for (const [key, value] of Object.entries(getWhisperCorrections())) {
337
+ raw.add(key)
338
+ raw.add(value)
339
+ }
343
340
  // Only UNAMBIGUOUS terms trigger an echo drop: multi-word phrases ("POS Nation",
344
341
  // "IT Retail", "Jeremy Sokolic") and brand-shaped single tokens with an internal
345
342
  // capital or digit ("POSNation", "CaratIQ", "Jewel360"). Plain single-word tokens
@@ -54,6 +54,14 @@ export type MaintenanceWorkKind =
54
54
  // budget must stay under COS Control's 90s drain timeout (main.swift:1853)
55
55
  // or every drain that catches a cue in flight hard-fails to Repair.
56
56
  | 'live_cue_pipeline'
57
+ // Miles-triggered recovery of a quarantined unsaved capture (6.19.0):
58
+ // batch-transcribes retained WAVs into a durable scribe. Held for the whole
59
+ // background run so an Update Server drain waits for it like any batch.
60
+ // Long runs are made visible: the active-recovery registry renders a
61
+ // meeting_sync row with blocksRestart so COS Control warns BEFORE
62
+ // committing a drain into a decode that outlives its 90s timeout
63
+ // (main.swift:1963 waitForRestartProof).
64
+ | 'orphan_recovery'
57
65
 
58
66
  export type MaintenanceWorkPhase = 'queued' | 'active'
59
67
  export type MaintenanceOperationScope = 'same_boot' | 'cross_boot'