@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 +3 -3
- package/CHANGELOG.md +23 -0
- package/README.md +7 -7
- package/package.json +1 -1
- package/server/index.ts +5 -6
- package/server/lib/query-job-feature.ts +4 -4
- package/server/lib/whisper-preview.ts +34 -0
- package/server/routes/transcribe-stream.ts +87 -0
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+
|
|
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
|
|
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=
|
|
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+,
|
|
106
|
-
|
|
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=
|
|
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
|
|
342
|
-
|
|
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,
|
|
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
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
|
|
436
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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 {
|