@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.
- package/.cos-profile.example.json +3 -3
- package/.env.example +18 -0
- package/CHANGELOG.md +78 -0
- package/README.md +23 -0
- package/bin/cli.cjs +156 -4
- package/managed-runtime-contract.json +3 -0
- package/package.json +1 -1
- package/server/index.ts +16 -1
- package/server/lib/hallucination-filter.ts +5 -8
- package/server/lib/maintenance-lifecycle.ts +8 -0
- package/server/lib/meeting-batch-progress.ts +150 -3
- package/server/lib/meeting-batch-transcribe.ts +29 -2
- package/server/lib/profile.ts +81 -2
- package/server/lib/unsaved-audio-quarantine.ts +314 -0
- package/server/lib/whisper-local.ts +7 -14
- package/server/lib/whisper-preview.ts +243 -0
- package/server/routes/glossary.ts +2 -11
- package/server/routes/health.ts +34 -2
- package/server/routes/meeting.ts +281 -2
- package/server/routes/prompt-drafts.ts +2 -5
- package/server/routes/transcribe-stream.ts +65 -13
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"owner_name": "
|
|
2
|
+
"owner_name": "User",
|
|
3
3
|
"owner_speaker_label": "Me",
|
|
4
|
-
"vocabulary": [
|
|
5
|
-
"whisper_corrections": "{
|
|
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 (
|
|
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
|
|
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 >=
|
|
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
|
-
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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'
|