@gotcos/glasses-server 6.73.14 → 6.74.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/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## 6.74.0 - Mac dictation works for every user, and Parakeet installs from COS Control (local candidate)
2
+
3
+ - Mac dictation has its own engine setting, `dictation`: `auto` (default), `parakeet-ultra` or `whisper`, saved with the other speech settings (`PUT /api/stt-settings {"dictation":"whisper"}`; GET adds `dictation.desktop` with `selected`, `effective`, `reason`, `reasonCode`, `options` and `note`). It no longer follows the Preview model. Every settings file written before 6.74.0 has no key and reads as `auto`, so a Mac that chose Parakeet keeps it.
4
+ - `POST /api/transcribe?requiredEngine=local` (COS Control 0.5.317): under `auto`, Parakeet Ultra when installed, warm and its breaker closed; otherwise the local Whisper live worker decodes that dictation, a Parakeet warm-up starts, and the answer says so (`engine`, `fallbackReason`, `dictationSetting`). Cold, warming, warm-up failed, breaker open, both interactive slots busy, and a failed Parakeet decode all fall back to Whisper. Never the cloud, even with the OpenAI fallback configured. When nothing local can run: 503 `no_local_engine` with `action` (`install_parakeet`, `retry_when_warm` or `open_transcription_settings`), `detail`, and `retryable` true only when a retry can succeed. The explicit `parakeet-ultra` setting stays strict: 503 `parakeet_unavailable` with the action, never Whisper.
5
+ - `requiredEngine=parakeet-ultra` (COS Control 0.5.316 and earlier) keeps the 6.73 contract: Parakeet only, the same 503 bodies and messages. It now follows the dictation setting rather than the Preview model, so it succeeds wherever Parakeet is installed and warm.
6
+ - `GET /api/transcribe/capabilities` keeps `desktopDictationVersion: 1` (COS Control 0.5.316 accepts exactly 1) and adds `desktopDictationVersions: [1, 2]`, `requiredEngines: ['parakeet-ultra', 'local']` and `benchmark: 1`. COS Control sends `local` only when the list holds 2.
7
+ - Parakeet installs from COS Control. FluidAudio publishes no 0.17.4 binary, so COS Control ships its own signed build in `Contents/Helpers`. The server trusts it when `codesign --verify --strict` passes against a requirement anchored at Apple for the COS team, its TeamIdentifier is the COS team, its codesign identifier and embedded CFBundleIdentifier are both `com.gotcos.control.fluidaudiocli`, and that signed, embedded Info.plist says version 0.17.4 (the upstream 0.17.4 CLI has no version command, and the check never runs the binary). The SHA-256 pinned hand copy still works and wins when present. The installed copy is checked again after it is copied; its hash and size go in the receipt and every run checks them.
8
+ - `POST /api/stt-settings/parakeet/cli {cliPath}` lets COS Control register its CLI and get the verdict (200 trusted, 422 with `reason`: unsigned, signature_invalid, wrong_team, wrong_identifier, wrong_version, missing). The server also looks in `/Applications` and `~/Applications`. `installable` and `note` now reflect it; the note never points at `~/.cos-glasses/bin`.
9
+ - Install: `{cliPath, allowDownload: true}`. A 1.5 GB free-space check runs before anything is written (507 `insufficient_disk` with `freeBytes` and `requiredBytes`). The progress block adds `errorCode` (cancelled, insufficient_disk, sha_mismatch, download_failed, stalled, cli_untrusted, cli_missing). `allowDownload: false` without a local source never downloads. A failed or cancelled install keeps the previous install and Whisper dictation.
10
+ - `POST /api/transcribe/benchmark?engines=whisper,parakeet-ultra` for onboarding: the same 16 kHz WAV through each installed local engine, with `elapsedMs`, `rtf`, `peakRssBytes` (Parakeet only; the Whisper worker is shared and always resident), `warmupMs` for a cold Parakeet, and the text. One run at a time, refused while a recording is live (409), 60 s per engine, 150 s in all.
11
+ - Not changed: phone and glasses dictation still follow the Preview and HQ models. Speaker separation still looks for the CLI only at `COS_NEMOTRON_CLI`, `~/.cos-glasses/bin` and `PATH`, so a GUI Parakeet install does not enable it.
12
+ - Pair with COS Control 0.5.317. Includes 6.73.15. Transcription-candidate and cue flags stay disabled. No public publication is implied.
13
+
14
+ ## 6.73.15 — Work chat knows which sessions still have earlier work open, and takes reference sessions (local candidate)
15
+
16
+ - Work chat input accepts an optional per-session `openWork` (boolean only; anything else is refused): true when that session still has an earlier handoff from this task open (running, queued, unconfirmed, or a delivered reply not yet checked). It reaches the planner as evidence, and the planner is told such an assignment waits until that work resolves and is never delivered mid-turn, to prefer a free automatic session for independent new work, and to say in the answer when an assignment will wait.
17
+ - GET `/api/work/chat` advertises `queueRounds: true`. Control 0.5.317 queues a held send or sends it in parallel only when it sees this; on an older server it keeps the check-first fence and says so.
18
+ - Reference sessions (Miles 2026-10-16 12:16): Work chat input accepts optional `references` (at most 6; `{id, title, summary, result}`, title 300, summary 2000, result 4000 characters, redacted with the rest). A reference is a session linked to the task as a read-only reference point, usually working on another task: evidence for the planner, cited by title, never an assignment target (an id there may not also be one of `sessions`, and an assignment to it is refused as before) and never counted as this task's progress. GET advertises `references: true` and `maxReferences: 6`. The links themselves are Control's (its session-references file beside the working links); the server stores no session-to-task links.
19
+ - The queue itself is Control's (the round's saved history beside the work journal), dispatched through Control's existing send path, which still parks a turn behind a busy native thread with `session-chat-queue`. Nothing on the server dispatches a queued round, so queued work waits while COS Control is closed.
20
+ - Pair with Control 0.5.317. Older Control sends no `openWork` and is unchanged. Transcription-candidate and cue flags stay disabled. No public publication is implied.
21
+
1
22
  ## 6.73.14 — Saved Mac recordings appear in Meetings; Work chat keeps a plan with a stray item (local candidate)
2
23
 
3
24
  - A saved Mac recording now lists in Meetings on a Mac whose COS pipeline files G2 recordings (Miles 2026-10-15: the 10:39 PM recording saved but Control said "Saved meeting not found yet" forever). Mac captures never reach the pipeline (routes/meeting.ts, MAC_CAPTURE_ID), and that layout listed only the operations tree. The store's Mac rows now join it (`withMacStoreRows`, standalone identity, an operations copy wins by session), are filtered before the store's 200-row cap, count in the calendar's day dots (`macStoreDayCounts`), and a month holding only Mac recordings reaches the pager. G2 store rows stay the pipeline's.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.73.14",
3
+ "version": "6.74.0",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,8 @@ import { execFileSync } from 'node:child_process'
18
18
  import { chmodSync, existsSync, lstatSync, mkdirSync, readlinkSync, statSync, symlinkSync, unlinkSync } from 'node:fs'
19
19
  import { homedir } from 'node:os'
20
20
  import { join } from 'node:path'
21
+ import { installedCliTrustedNow, parakeetCliPath, pinnedCliKnown } from './parakeet.js'
22
+ import { controlCliCandidates, knownCliTrust } from './parakeet-cli-trust.js'
21
23
 
22
24
  export type DiarizerMode = 'nemotron' | 'embedding'
23
25
 
@@ -57,7 +59,7 @@ export function nemotronCliPath(env: NodeJS.ProcessEnv = process.env, home: stri
57
59
  const key = env.PATH ?? ''
58
60
  const now = Date.now()
59
61
  if (whichCache && whichCache.key === key && now - whichCache.at < WHICH_CACHE_MS) {
60
- return whichCache.value && existsSync(whichCache.value) ? whichCache.value : null
62
+ return whichCache.value && existsSync(whichCache.value) ? whichCache.value : trustedParakeetCli(home)
61
63
  }
62
64
  let value: string | null = null
63
65
  try {
@@ -67,7 +69,27 @@ export function nemotronCliPath(env: NodeJS.ProcessEnv = process.env, home: stri
67
69
  value = null
68
70
  }
69
71
  whichCache = { key, at: now, value }
70
- return value
72
+ return value ?? trustedParakeetCli(home)
73
+ }
74
+
75
+ /**
76
+ * 6.74.0, after every earlier location: the Parakeet CLI a GUI install put under
77
+ * <data>/stt/parakeet/current/bin, then COS Control's bundled copy (Contents/Helpers).
78
+ * Each only when trusted (a verified install receipt, or a Developer ID build of the COS
79
+ * team, or the SHA-256 pinned bytes). Never runs anything; an unchecked file is skipped
80
+ * until its background check lands, so a health read never blocks.
81
+ */
82
+ export function trustedParakeetCli(home: string = homedir()): string | null {
83
+ try {
84
+ if (installedCliTrustedNow() === true) return parakeetCliPath()
85
+ } catch { /* not installed */ }
86
+ for (const path of controlCliCandidates(home)) {
87
+ try {
88
+ if (!existsSync(path)) continue
89
+ if (knownCliTrust(path)?.trusted || pinnedCliKnown([path]) === true) return path
90
+ } catch { /* next */ }
91
+ }
92
+ return null
71
93
  }
72
94
 
73
95
  const WHICH_CACHE_MS = 60_000
@@ -0,0 +1,197 @@
1
+ // 6.74.0: the on-device dictation check COS Control's onboarding runs on its pinned
2
+ // 30 s sample. Each installed local engine decodes the same WAV once; the result is
3
+ // speed (real-time factor), peak memory where it can be measured, and the text.
4
+ // Control scores accuracy against its own reference transcript.
5
+ //
6
+ // One clip is a sanity check on this Mac, not a benchmark of the engine. Bounded:
7
+ // one run at a time, never while a recording is live, a timeout per engine and in all.
8
+ // No cloud and no LLM: Whisper is the local live worker, Parakeet the sandboxed CLI.
9
+
10
+ import { execFile } from 'node:child_process'
11
+ import { applyCorrections, getWhisperCommitCapability, transcribeLocal } from './whisper-local.js'
12
+ import { parakeetInstalled, parakeetWarmState, transcribeParakeet, warmParakeet, FIRST_LOAD_TIMEOUT_MS } from './parakeet.js'
13
+ import { isRecordingLive, PREVIEW_PARAKEET_MODEL } from './stt-runtime.js'
14
+ import { parakeetDictationTimeoutMs, isPcm16kMonoWav } from './dictation-stt.js'
15
+
16
+ export const BENCHMARK_ENGINES = ['whisper', 'parakeet-ultra'] as const
17
+ export type BenchmarkEngine = typeof BENCHMARK_ENGINES[number]
18
+ /** One engine (including a Parakeet cold start) gets this long. */
19
+ export const BENCHMARK_ENGINE_TIMEOUT_MS = 60_000
20
+ /** The whole run gets this long. */
21
+ export const BENCHMARK_TOTAL_TIMEOUT_MS = 150_000
22
+ export const BENCHMARK_MAX_BYTES = 3_840_044
23
+
24
+ export interface BenchmarkEngineResult {
25
+ engine: BenchmarkEngine
26
+ /** whisper: the live worker model (large-v3-turbo or large-v3); parakeet-ultra: the pinned model. */
27
+ model: string
28
+ ran: boolean
29
+ skipped?: 'not_installed' | 'unavailable' | 'busy' | 'timeout' | 'total_timeout'
30
+ error?: string
31
+ /** Wall time of the decode (warm for Parakeet). */
32
+ elapsedMs?: number
33
+ /** elapsed seconds / audio seconds; below 1 is faster than real time. */
34
+ rtf?: number
35
+ /** Peak resident memory of the decoding process, when this server can sample it. */
36
+ peakRssBytes: number | null
37
+ /** Parakeet only: time to compile and warm the model first, when it was cold. */
38
+ warmupMs?: number
39
+ /** The engine's text after the COS dictionary corrections, before the hallucination filters. */
40
+ text?: string
41
+ }
42
+
43
+ export interface BenchmarkResult { audioSeconds: number; results: BenchmarkEngineResult[] }
44
+
45
+ export class BenchmarkRefusedError extends Error {
46
+ constructor(readonly status: number, readonly reason: 'benchmark_running' | 'recording_live' | 'audio_not_pcm_wav' | 'audio_too_large' | 'invalid_engine', message: string) {
47
+ super(message)
48
+ this.name = 'BenchmarkRefusedError'
49
+ }
50
+ }
51
+
52
+ export interface BenchmarkDeps {
53
+ recordingLive: () => boolean
54
+ whisperPresent: () => boolean
55
+ whisperModel: () => string
56
+ whisper: (audio: Buffer, signal: AbortSignal) => Promise<{ text: string }>
57
+ parakeetInstalled: () => boolean
58
+ parakeetWarm: () => boolean
59
+ /** Compile and warm Parakeet; resolves true when warm. */
60
+ warmParakeet: () => Promise<boolean>
61
+ parakeet: (audio: Buffer, timeoutMs: number, onSpawn: (pid: number) => void, signal: AbortSignal) => Promise<{ text: string }>
62
+ /** Resident set size of a pid in bytes, or null. */
63
+ rss: (pid: number) => Promise<number | null>
64
+ now: () => number
65
+ engineTimeoutMs: number
66
+ totalTimeoutMs: number
67
+ }
68
+
69
+ function psRss(pid: number): Promise<number | null> {
70
+ return new Promise(resolve => {
71
+ execFile('/bin/ps', ['-o', 'rss=', '-p', String(pid)], { timeout: 1_000 }, (error, stdout) => {
72
+ const kb = Number(String(stdout ?? '').trim())
73
+ resolve(error || !Number.isFinite(kb) || kb <= 0 ? null : kb * 1024)
74
+ })
75
+ })
76
+ }
77
+
78
+ export const defaultBenchmarkDeps: BenchmarkDeps = {
79
+ recordingLive: () => isRecordingLive(),
80
+ whisperPresent: () => getWhisperCommitCapability().configured,
81
+ whisperModel: () => getWhisperCommitCapability().effectiveModel,
82
+ // The live worker, as Mac dictation runs it: the vocabulary prompt on, the breaker untouched.
83
+ whisper: async audio => ({ text: (await transcribeLocal(audio, undefined, undefined, { affectsCircuit: false })).text }),
84
+ parakeetInstalled: () => parakeetInstalled().installed,
85
+ parakeetWarm: () => parakeetWarmState(PREVIEW_PARAKEET_MODEL).state === 'warm',
86
+ warmParakeet: () => warmParakeet(PREVIEW_PARAKEET_MODEL),
87
+ parakeet: async (audio, timeoutMs, onSpawn, signal) => {
88
+ const r = await transcribeParakeet(audio, { model: PREVIEW_PARAKEET_MODEL, lane: 'interactive', timeoutMs, onSpawn, signal })
89
+ return { text: applyCorrections(r.text) }
90
+ },
91
+ rss: psRss,
92
+ now: () => performance.now(),
93
+ engineTimeoutMs: BENCHMARK_ENGINE_TIMEOUT_MS,
94
+ totalTimeoutMs: BENCHMARK_TOTAL_TIMEOUT_MS,
95
+ }
96
+
97
+ let running = false
98
+ export function benchmarkRunning(): boolean { return running }
99
+ export function __resetBenchmarkForTests(): void { running = false }
100
+
101
+ class Timeout extends Error { constructor(readonly which: 'timeout' | 'total_timeout') { super(which) } }
102
+
103
+ /** Resolve with the promise, or reject with Timeout after ms. Aborts ctl on timeout. */
104
+ function within<T>(promise: Promise<T>, ms: number, which: 'timeout' | 'total_timeout', ctl: AbortController): Promise<T> {
105
+ return new Promise<T>((resolve, reject) => {
106
+ const timer = setTimeout(() => { ctl.abort(); reject(new Timeout(which)) }, Math.max(0, ms))
107
+ timer.unref?.()
108
+ promise.then(v => { clearTimeout(timer); resolve(v) }, e => { clearTimeout(timer); reject(e) })
109
+ })
110
+ }
111
+
112
+ export function parseBenchmarkEngines(raw: unknown): BenchmarkEngine[] | null {
113
+ if (raw === undefined || raw === '') return [...BENCHMARK_ENGINES]
114
+ if (typeof raw !== 'string') return null
115
+ const list = raw.split(',').map(s => s.trim()).filter(Boolean)
116
+ if (!list.length || list.some(e => !(BENCHMARK_ENGINES as readonly string[]).includes(e))) return null
117
+ return [...new Set(list)] as BenchmarkEngine[]
118
+ }
119
+
120
+ export async function runDictationBenchmark(audio: Buffer, engines: BenchmarkEngine[] = [...BENCHMARK_ENGINES], deps: BenchmarkDeps = defaultBenchmarkDeps): Promise<BenchmarkResult> {
121
+ if (audio.length > BENCHMARK_MAX_BYTES) throw new BenchmarkRefusedError(413, 'audio_too_large', 'The sample is longer than two minutes.')
122
+ if (!isPcm16kMonoWav(audio)) throw new BenchmarkRefusedError(400, 'audio_not_pcm_wav', 'The sample must be 16 kHz mono 16-bit WAV.')
123
+ if (running) throw new BenchmarkRefusedError(409, 'benchmark_running', 'A dictation check is already running.')
124
+ if (deps.recordingLive()) throw new BenchmarkRefusedError(409, 'recording_live', 'A recording is live. Run the check after it is saved.')
125
+ running = true
126
+ try {
127
+ const audioSeconds = Math.max(0, (audio.length - 44) / 32_000)
128
+ const started = deps.now()
129
+ const results: BenchmarkEngineResult[] = []
130
+ for (const engine of engines) {
131
+ const left = deps.totalTimeoutMs - (deps.now() - started)
132
+ const model = engine === 'whisper' ? deps.whisperModel() : 'parakeet-ultra'
133
+ if (left <= 0) { results.push({ engine, model, ran: false, skipped: 'total_timeout', peakRssBytes: null }); continue }
134
+ // A recording that started during the run stops it: the live decoders come first.
135
+ if (deps.recordingLive()) throw new BenchmarkRefusedError(409, 'recording_live', 'A recording started. Run the check after it is saved.')
136
+ const budget = Math.min(deps.engineTimeoutMs, left)
137
+ const which = budget < deps.engineTimeoutMs ? 'total_timeout' as const : 'timeout' as const
138
+ results.push(engine === 'whisper' ? await runWhisper(audio, audioSeconds, model, budget, which, deps) : await runParakeet(audio, audioSeconds, budget, which, deps))
139
+ }
140
+ return { audioSeconds, results }
141
+ } finally {
142
+ running = false
143
+ }
144
+ }
145
+
146
+ /** One line, single spaces: Control tokenizes for its word error rate. */
147
+ const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
148
+ const rtfOf = (ms: number, seconds: number) => seconds > 0 ? Math.round((ms / 1000 / seconds) * 1000) / 1000 : 0
149
+
150
+ async function runWhisper(audio: Buffer, seconds: number, model: string, budget: number, which: 'timeout' | 'total_timeout', deps: BenchmarkDeps): Promise<BenchmarkEngineResult> {
151
+ // The live worker is shared and always resident, so its peak is not this decode's.
152
+ const base = { engine: 'whisper' as const, model, peakRssBytes: null }
153
+ if (!deps.whisperPresent()) return { ...base, ran: false, skipped: 'not_installed' }
154
+ const ctl = new AbortController()
155
+ const t0 = deps.now()
156
+ try {
157
+ const r = await within(deps.whisper(audio, ctl.signal), budget, which, ctl)
158
+ const elapsedMs = Math.round(deps.now() - t0)
159
+ return { ...base, ran: true, elapsedMs, rtf: rtfOf(elapsedMs, seconds), text: oneLine(r.text) }
160
+ } catch (error) {
161
+ if (error instanceof Timeout) return { ...base, ran: false, skipped: error.which }
162
+ return { ...base, ran: false, skipped: 'unavailable', error: error instanceof Error ? error.message.slice(0, 200) : String(error) }
163
+ }
164
+ }
165
+
166
+ async function runParakeet(audio: Buffer, seconds: number, budget: number, which: 'timeout' | 'total_timeout', deps: BenchmarkDeps): Promise<BenchmarkEngineResult> {
167
+ const base = { engine: 'parakeet-ultra' as const, model: 'parakeet-ultra', peakRssBytes: null as number | null }
168
+ if (!deps.parakeetInstalled()) return { ...base, ran: false, skipped: 'not_installed' }
169
+ const ctl = new AbortController()
170
+ const t0 = deps.now()
171
+ let warmupMs: number | undefined
172
+ try {
173
+ if (!deps.parakeetWarm()) {
174
+ const ok = await within(deps.warmParakeet(), Math.min(budget, FIRST_LOAD_TIMEOUT_MS), which, ctl)
175
+ warmupMs = Math.round(deps.now() - t0)
176
+ if (!ok) return { ...base, ran: false, skipped: 'unavailable', warmupMs, error: 'Parakeet did not warm up on this Mac.' }
177
+ }
178
+ const left = budget - (deps.now() - t0)
179
+ if (left <= 0) return { ...base, ran: false, skipped: which, ...(warmupMs !== undefined ? { warmupMs } : {}) }
180
+ let peak: number | null = null
181
+ let sampler: ReturnType<typeof setInterval> | null = null
182
+ const sample = (pid: number) => { void deps.rss(pid).then(v => { if (v !== null && (peak === null || v > peak)) peak = v }) }
183
+ const onSpawn = (pid: number) => { sample(pid); sampler = setInterval(() => sample(pid), 50); sampler.unref?.() }
184
+ const t1 = deps.now()
185
+ try {
186
+ const r = await within(deps.parakeet(audio, Math.min(left, parakeetDictationTimeoutMs(audio) * 4), onSpawn, ctl.signal), left, which, ctl)
187
+ const elapsedMs = Math.round(deps.now() - t1)
188
+ return { ...base, ran: true, elapsedMs, rtf: rtfOf(elapsedMs, seconds), peakRssBytes: peak, ...(warmupMs !== undefined ? { warmupMs } : {}), text: oneLine(r.text) }
189
+ } finally {
190
+ if (sampler) clearInterval(sampler)
191
+ }
192
+ } catch (error) {
193
+ if (error instanceof Timeout) return { ...base, ran: false, skipped: error.which, ...(warmupMs !== undefined ? { warmupMs } : {}) }
194
+ const busy = (error as { reason?: string })?.reason === 'busy'
195
+ return { ...base, ran: false, skipped: busy ? 'busy' : 'unavailable', ...(warmupMs !== undefined ? { warmupMs } : {}), error: error instanceof Error ? error.message.slice(0, 200) : String(error) }
196
+ }
197
+ }
@@ -15,11 +15,13 @@
15
15
  import { transcribeParakeet, ParakeetError } from './parakeet.js'
16
16
  import { asrFrontEnd } from './stt-live.js'
17
17
  import {
18
- currentSttCapabilities, ensurePreviewWarm, noteParakeetDictationHit, noteParakeetDictationMiss,
18
+ currentSttCapabilities, ensurePreviewWarm, installCliKnown, noteParakeetDictationHit, noteParakeetDictationMiss,
19
19
  PARAKEET_PREVIEW_TIMEOUT_MS, PREVIEW_PARAKEET_MODEL,
20
20
  } from './stt-runtime.js'
21
+ import { getWhisperHealth } from './whisper-local.js'
22
+ import { parakeetInstallRunning } from './parakeet.js'
21
23
  import {
22
- dictationPlan, readSttSettings, type DictationPlan, type DictationSlot, type FrontEndId,
24
+ desktopDictationPlan, dictationPlan, readSttSettings, type DesktopDictationPlan, type DictationPlan, type DictationSlot, type FrontEndId,
23
25
  } from './stt-settings.js'
24
26
 
25
27
  /** The plan for a dictation decode starting now. A Parakeet selection that is
@@ -37,6 +39,63 @@ export function currentDictationPlan(slot: DictationSlot): DictationPlan {
37
39
  return plan
38
40
  }
39
41
 
42
+ /** 6.74.0: the plan for a Mac dictation decode starting now (its own setting, not the
43
+ * Preview model). A Parakeet that is installed but cold starts warming here. */
44
+ export function currentDesktopDictationPlan(slot: DictationSlot): DesktopDictationPlan {
45
+ try {
46
+ const plan = desktopDictationPlan(slot, readSttSettings(), currentSttCapabilities())
47
+ if (plan.reason === 'parakeet_warming' || plan.reason === 'parakeet_warm_failed') ensurePreviewWarm()
48
+ return plan
49
+ } catch (error) {
50
+ console.warn(`[stt] Mac dictation settings unreadable, using Whisper: ${error instanceof Error ? error.message : error}`)
51
+ return { slot, engine: 'whisper', reason: null, frontEnd: 'highpass', key: slot === 'final' ? 'final:large-v3' : `${slot}:turbo`, setting: 'auto', whisperAllowed: true }
52
+ }
53
+ }
54
+
55
+ /** The one action a Mac dictation failure offers (see the contract). */
56
+ export type DesktopDictationAction = 'install_parakeet' | 'use_whisper' | 'retry_when_warm' | 'open_transcription_settings'
57
+ export interface DesktopFailureHint { action: DesktopDictationAction; retryable: boolean; detail: string }
58
+ export interface DesktopFailureFacts {
59
+ /** no_local_engine: nothing local could run. parakeet_unavailable: the setting is parakeet-ultra. */
60
+ kind: 'no_local_engine' | 'parakeet_unavailable'
61
+ /** The Parakeet reason code (plan or decode), when there is one. */
62
+ parakeetReason: string | null
63
+ parakeetInstallable: boolean
64
+ whisperStarting: boolean
65
+ whisperPresent: boolean
66
+ }
67
+
68
+ /** Pure. Retry is offered only when waiting can make it succeed. */
69
+ export function desktopFailureHint(f: DesktopFailureFacts): DesktopFailureHint {
70
+ const waiting = f.parakeetReason === 'parakeet_warming' || f.parakeetReason === 'parakeet_busy'
71
+ if (f.kind === 'parakeet_unavailable') {
72
+ const detail = f.parakeetReason ?? 'parakeet_failed'
73
+ if (detail === 'parakeet_not_installed') return { action: f.parakeetInstallable ? 'install_parakeet' : 'use_whisper', retryable: false, detail }
74
+ if (waiting) return { action: 'retry_when_warm', retryable: true, detail }
75
+ return { action: 'use_whisper', retryable: false, detail }
76
+ }
77
+ if (f.whisperStarting) return { action: 'retry_when_warm', retryable: true, detail: 'whisper_starting' }
78
+ const detail = f.whisperPresent ? 'whisper_unavailable' : 'whisper_missing'
79
+ if (waiting) return { action: 'retry_when_warm', retryable: true, detail }
80
+ if (f.parakeetReason === 'parakeet_not_installed' && f.parakeetInstallable) return { action: 'install_parakeet', retryable: false, detail }
81
+ return { action: 'open_transcription_settings', retryable: false, detail }
82
+ }
83
+
84
+ /** Production facts for desktopFailureHint. Never throws. */
85
+ export function currentDesktopFailureHint(kind: DesktopFailureFacts['kind'], parakeetReason: string | null): DesktopFailureHint {
86
+ let whisperStarting = false, whisperPresent = true, parakeetInstallable = false
87
+ try {
88
+ const h = getWhisperHealth()
89
+ whisperStarting = h.restarting || h.startupState === 'preflight' || h.startupState === 'loading'
90
+ whisperPresent = h.serverConfigured
91
+ } catch { /* unknown: treat as present and not starting */ }
92
+ try {
93
+ const installing = parakeetInstallRunning()
94
+ parakeetInstallable = installing || (process.platform === 'darwin' && process.arch === 'arm64' && installCliKnown().known === true)
95
+ } catch { /* not installable */ }
96
+ return desktopFailureHint({ kind, parakeetReason, parakeetInstallable, whisperStarting, whisperPresent })
97
+ }
98
+
40
99
  /** Parakeet reads 16 kHz mono PCM16 WAV. The browser recorder posts WebM, which stays on Whisper. */
41
100
  export function isPcm16kMonoWav(audio: Buffer): boolean {
42
101
  if (audio.length < 44 || audio.toString('ascii', 0, 4) !== 'RIFF' || audio.toString('ascii', 8, 12) !== 'WAVE') return false
@@ -0,0 +1,262 @@
1
+ // 6.74.0: trust for a fluidaudiocli that COS Control ships signed (its Contents/Helpers).
2
+ //
3
+ // FluidAudio publishes no binary for v0.17.4, so COS Control builds it from the pinned
4
+ // source tag and signs it with the COS Developer ID. That build cannot match the SHA-256
5
+ // pin (parakeet-manifest.ts) because the signature is part of the bytes, so it is trusted
6
+ // on three proofs instead, all required:
7
+ //
8
+ // 1. `codesign --verify --strict` passes, and passes again against the requirement
9
+ // that the leaf certificate chains to Apple and names the COS team (a self-signed
10
+ // certificate can claim any team name; `anchor apple generic` rules that out).
11
+ // 2. The TeamIdentifier codesign reports is COS_TEAM_ID.
12
+ // 3. The codesign Identifier, and the CFBundleIdentifier of the Info.plist embedded in
13
+ // the binary, are both CLI_IDENTIFIER.
14
+ // 4. That embedded Info.plist is bound by the signature (`Info.plist entries=`) and its
15
+ // CFBundleShortVersionString is 0.17.4. The upstream 0.17.4 CLI has no version
16
+ // command, so the binary is never run to ask.
17
+ //
18
+ // The hand-placed SHA-256 pinned copy (parakeet.ts isPinnedCli) is unchanged.
19
+
20
+ import { execFile } from 'node:child_process'
21
+ import { createHash } from 'node:crypto'
22
+ import { constants as fsConstants, createReadStream, fstatSync, openSync, readSync, closeSync, statSync } from 'node:fs'
23
+ import { homedir } from 'node:os'
24
+ import { basename, join } from 'node:path'
25
+ import { pipeline } from 'node:stream/promises'
26
+ import { Transform } from 'node:stream'
27
+ import { PARAKEET_CLI } from './parakeet-manifest.js'
28
+
29
+ /** The COS Developer ID team (read from /Applications/COS Control.app with codesign -dv, 2026-10-16). */
30
+ export const COS_TEAM_ID = 'NV3X46LLCR'
31
+ export const CODESIGN = '/usr/bin/codesign'
32
+ export const TRUST_REQUIREMENT = `=anchor apple generic and certificate leaf[subject.OU] = "${COS_TEAM_ID}"`
33
+ /** The codesign identifier and embedded CFBundleIdentifier of COS Control's build (agreed with Control). */
34
+ export const CLI_IDENTIFIER = 'com.gotcos.control.fluidaudiocli'
35
+
36
+ export type CliTrustReason = 'missing' | 'unsigned' | 'signature_invalid' | 'wrong_team' | 'wrong_identifier' | 'wrong_version'
37
+
38
+ export type CliTrust =
39
+ | { trusted: true; trust: 'developer-id'; path: string; teamId: string; version: string; versionSource: 'info-plist'; sha256: string; size: number }
40
+ | { trusted: false; path: string; reason: CliTrustReason; detail: string }
41
+
42
+ export interface CliTrustDeps {
43
+ /** codesign -dv: what it prints (stderr), and its exit code. */
44
+ display: (path: string) => Promise<{ code: number; output: string }>
45
+ /** codesign --verify --strict [-R requirement]. */
46
+ verify: (path: string, requirement?: string) => Promise<{ code: number; output: string }>
47
+ /** The Info.plist embedded in the Mach-O, or null. */
48
+ embeddedPlist: (path: string) => string | null
49
+ sha256: (path: string) => Promise<string>
50
+ }
51
+
52
+ function run(command: string, args: string[], timeoutMs: number): Promise<{ code: number; output: string }> {
53
+ return new Promise(resolve => {
54
+ execFile(command, args, { timeout: timeoutMs, maxBuffer: 256 * 1024, env: { PATH: '/usr/bin:/bin' } }, (error, stdout, stderr) => {
55
+ const code = error ? (typeof (error as { code?: unknown }).code === 'number' ? (error as { code: number }).code : 1) : 0
56
+ resolve({ code, output: `${stdout ?? ''}${stderr ?? ''}` })
57
+ })
58
+ })
59
+ }
60
+
61
+ async function sha256File(path: string): Promise<string> {
62
+ const hash = createHash('sha256')
63
+ await pipeline(createReadStream(path), new Transform({ transform(chunk, _e, cb) { hash.update(chunk); cb(null) } }))
64
+ return hash.digest('hex')
65
+ }
66
+
67
+ export const defaultCliTrustDeps: CliTrustDeps = {
68
+ display: path => run(CODESIGN, ['-dv', path], 10_000),
69
+ verify: (path, requirement) => run(CODESIGN, ['--verify', '--strict', ...(requirement ? ['-R', requirement] : []), path], 15_000),
70
+ embeddedPlist: path => readEmbeddedInfoPlist(path),
71
+ sha256: sha256File,
72
+ }
73
+
74
+ // ── Mach-O __TEXT,__info_plist ────────────────────────────────────────────────
75
+
76
+ const MH_MAGIC_64 = 0xfeedfacf
77
+ const FAT_MAGIC = 0xcafebabe
78
+ const LC_SEGMENT_64 = 0x19
79
+ const CPU_TYPE_ARM64 = 0x0100000c
80
+
81
+ function readAt(fd: number, offset: number, length: number): Buffer {
82
+ const buf = Buffer.alloc(length)
83
+ const n = readSync(fd, buf, 0, length, offset)
84
+ return buf.subarray(0, n)
85
+ }
86
+
87
+ function cstr(buf: Buffer, start: number): string {
88
+ const raw = buf.subarray(start, start + 16)
89
+ const end = raw.indexOf(0)
90
+ return raw.subarray(0, end < 0 ? 16 : end).toString('ascii')
91
+ }
92
+
93
+ /** The __TEXT,__info_plist section of a thin 64-bit or fat Mach-O (arm64 slice), or null. */
94
+ export function readEmbeddedInfoPlist(path: string): string | null {
95
+ let fd: number
96
+ // Non-blocking and no-follow: the path can come from a client, and a FIFO would
97
+ // block this synchronous open, and with it the whole server (hazard-invariants.test.ts).
98
+ try { fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW) } catch { return null }
99
+ try { if (!fstatSync(fd).isFile()) { closeSync(fd); return null } } catch { closeSync(fd); return null }
100
+ try {
101
+ let base = 0
102
+ const head = readAt(fd, 0, 8)
103
+ if (head.length < 8) return null
104
+ if (head.readUInt32BE(0) === FAT_MAGIC) {
105
+ const count = head.readUInt32BE(4)
106
+ if (count > 16) return null
107
+ const arches = readAt(fd, 8, count * 20)
108
+ base = -1
109
+ for (let i = 0; i < count; i++) {
110
+ if (arches.readUInt32BE(i * 20) === CPU_TYPE_ARM64) { base = arches.readUInt32BE(i * 20 + 8); break }
111
+ }
112
+ if (base < 0) return null
113
+ }
114
+ const header = readAt(fd, base, 32)
115
+ if (header.length < 32 || header.readUInt32LE(0) !== MH_MAGIC_64) return null
116
+ const ncmds = header.readUInt32LE(16)
117
+ const sizeofcmds = header.readUInt32LE(20)
118
+ if (sizeofcmds > 4 * 1024 * 1024) return null
119
+ const cmds = readAt(fd, base + 32, sizeofcmds)
120
+ let off = 0
121
+ for (let i = 0; i < ncmds && off + 8 <= cmds.length; i++) {
122
+ const cmd = cmds.readUInt32LE(off)
123
+ const cmdsize = cmds.readUInt32LE(off + 4)
124
+ if (cmdsize < 8) return null
125
+ if (cmd === LC_SEGMENT_64 && cstr(cmds, off + 8) === '__TEXT') {
126
+ const nsects = cmds.readUInt32LE(off + 64)
127
+ for (let s = 0; s < nsects; s++) {
128
+ const sec = off + 72 + s * 80
129
+ if (sec + 80 > cmds.length) return null
130
+ if (cstr(cmds, sec) !== '__info_plist') continue
131
+ const size = Number(cmds.readBigUInt64LE(sec + 40))
132
+ const offset = cmds.readUInt32LE(sec + 48)
133
+ if (size <= 0 || size > 64 * 1024) return null
134
+ return readAt(fd, base + offset, size).toString('utf8').replace(/\0+$/, '')
135
+ }
136
+ }
137
+ off += cmdsize
138
+ }
139
+ return null
140
+ } catch { return null } finally { closeSync(fd) }
141
+ }
142
+
143
+ function plistString(xml: string, key: string): string | null {
144
+ const m = new RegExp(`<key>${key}</key>\\s*<string>([^<]{1,128})</string>`).exec(xml)
145
+ return m ? m[1].trim() : null
146
+ }
147
+ /** CFBundleShortVersionString from an XML plist. */
148
+ export function plistVersion(xml: string): string | null { return plistString(xml, 'CFBundleShortVersionString') }
149
+ export function plistIdentifier(xml: string): string | null { return plistString(xml, 'CFBundleIdentifier') }
150
+
151
+ /** 0.17.4 as a whole token, so 0.17.40 or 10.17.4 never pass. */
152
+ export function versionMatches(text: string, want: string = PARAKEET_CLI.version): boolean {
153
+ const escaped = want.replace(/\./g, '\\.')
154
+ return new RegExp(`(^|[^0-9.])v?${escaped}(?![0-9]|\\.[0-9])`).test(text)
155
+ }
156
+
157
+ export function parseCodesignDisplay(output: string): { teamId: string | null; identifier: string | null; adhoc: boolean; unsigned: boolean; plistBound: boolean } {
158
+ const team = /^TeamIdentifier=(.+)$/m.exec(output)?.[1]?.trim() ?? null
159
+ return {
160
+ teamId: team && team !== 'not set' ? team : null,
161
+ identifier: /^Identifier=(.+)$/m.exec(output)?.[1]?.trim() ?? null,
162
+ adhoc: /^Signature=adhoc$/m.test(output) || /flags=0x[0-9a-f]*\([^)]*adhoc/.test(output),
163
+ unsigned: /code object is not signed at all/i.test(output),
164
+ plistBound: /^Info\.plist entries=\d+/m.test(output),
165
+ }
166
+ }
167
+
168
+ /** Every proof, in order; the first that fails names the reason. Never throws. */
169
+ export async function checkDeveloperIdCli(path: string, deps: CliTrustDeps = defaultCliTrustDeps): Promise<CliTrust> {
170
+ const no = (reason: CliTrustReason, detail: string): CliTrust => ({ trusted: false, path, reason, detail })
171
+ let size: number
172
+ try { const st = statSync(path); if (!st.isFile()) return no('missing', 'not a file'); size = st.size } catch { return no('missing', 'not found') }
173
+ try {
174
+ const shown = await deps.display(path)
175
+ const d = parseCodesignDisplay(shown.output)
176
+ if (d.unsigned || shown.code !== 0) return no('unsigned', 'the speech tool is not signed')
177
+ if (d.adhoc || !d.teamId) return no('unsigned', 'the speech tool has no Developer ID signature')
178
+ const strict = await deps.verify(path)
179
+ if (strict.code !== 0) return no('signature_invalid', strict.output.trim().split('\n').slice(-1)[0]?.slice(0, 200) || 'the signature does not verify')
180
+ if (d.teamId !== COS_TEAM_ID) return no('wrong_team', `signed by team ${d.teamId}`)
181
+ const anchored = await deps.verify(path, TRUST_REQUIREMENT)
182
+ if (anchored.code !== 0) return no('wrong_team', 'the signature does not chain to Apple for the COS team')
183
+ if (d.identifier !== CLI_IDENTIFIER) return no('wrong_identifier', `signed as ${d.identifier ?? 'no identifier'}`)
184
+ // Only a plist the signature binds counts; the binary is never run to ask its version.
185
+ const plist = d.plistBound ? deps.embeddedPlist(path) : null
186
+ if (!plist) return no('wrong_version', 'no signed Info.plist in the speech tool')
187
+ const bundleId = plistIdentifier(plist)
188
+ if (bundleId !== CLI_IDENTIFIER) return no('wrong_identifier', `its Info.plist names ${bundleId ?? 'no identifier'}`)
189
+ const named = plistVersion(plist)
190
+ if (!named || !versionMatches(named) || named.replace(/^v/, '') !== PARAKEET_CLI.version) return no('wrong_version', `version ${named ?? 'unknown'}, not FluidAudio ${PARAKEET_CLI.version}`)
191
+ const sha256 = await deps.sha256(path)
192
+ // The bytes hashed must be the bytes checked.
193
+ if (statSync(path).size !== size) return no('signature_invalid', 'the file changed while it was checked')
194
+ return { trusted: true, trust: 'developer-id', path, teamId: COS_TEAM_ID, version: PARAKEET_CLI.version, versionSource: 'info-plist', sha256, size }
195
+ } catch (error) {
196
+ return no('signature_invalid', error instanceof Error ? error.message.slice(0, 200) : String(error))
197
+ }
198
+ }
199
+
200
+ // ── Where COS Control's copy is, and a per-process cache for GET ──────────────
201
+
202
+ const HELPER_REL = join('COS Control.app', 'Contents', 'Helpers', 'fluidaudiocli')
203
+
204
+ /** COS Control in /Applications or ~/Applications, plus any path Control registered. */
205
+ export function controlCliCandidates(home: string = homedir()): string[] {
206
+ return [...offered, join(applicationsRoot, HELPER_REL), join(home, 'Applications', HELPER_REL)]
207
+ }
208
+ let applicationsRoot = '/Applications'
209
+ /** Tests only: keep the real /Applications out of a test's view. */
210
+ export function __setApplicationsRootForTests(root: string | null): void { applicationsRoot = root ?? '/Applications' }
211
+
212
+ const offered: string[] = []
213
+ /** An absolute path whose file name is fluidaudiocli. Anything else is never checked or run. */
214
+ export function isCliPathShape(path: unknown): path is string {
215
+ return typeof path === 'string' && path.startsWith('/') && path.length <= 1024 && !path.includes('\0') && basename(path) === 'fluidaudiocli'
216
+ }
217
+ /** Control tells the server where its bundled CLI is (POST /api/stt-settings/parakeet/cli). */
218
+ export function offerControlCli(path: string): void {
219
+ if (!isCliPathShape(path)) return
220
+ if (!offered.includes(path)) offered.unshift(path)
221
+ if (offered.length > 4) offered.length = 4
222
+ }
223
+
224
+ const cache = new Map<string, CliTrust>()
225
+ const pending = new Map<string, Promise<CliTrust>>()
226
+ function cacheKey(path: string): string | null {
227
+ try { const st = statSync(path); return st.isFile() ? `${path}\0${st.size}\0${st.mtimeMs}\0${st.ino}` : null } catch { return null }
228
+ }
229
+
230
+ /** Check (once per path, size, mtime and inode) and remember. */
231
+ export function trustCli(path: string, deps: CliTrustDeps = defaultCliTrustDeps): Promise<CliTrust> {
232
+ const key = cacheKey(path)
233
+ if (!key) return Promise.resolve({ trusted: false, path, reason: 'missing', detail: 'not found' })
234
+ const known = cache.get(key)
235
+ if (known) return Promise.resolve(known)
236
+ const running = pending.get(key)
237
+ if (running) return running
238
+ const job = checkDeveloperIdCli(path, deps).then(r => { cache.set(key, r); return r }).finally(() => pending.delete(key))
239
+ pending.set(key, job)
240
+ return job
241
+ }
242
+
243
+ /** Synchronous, for GET: the known result for this file, null while unchecked (a check starts). */
244
+ export function knownCliTrust(path: string): CliTrust | null {
245
+ const key = cacheKey(path)
246
+ if (!key) return { trusted: false, path, reason: 'missing', detail: 'not found' }
247
+ const known = cache.get(key)
248
+ if (known) return known
249
+ void trustCli(path)
250
+ return null
251
+ }
252
+
253
+ /** The first trusted candidate, or null. */
254
+ export async function findTrustedCli(candidates: string[] = controlCliCandidates(), deps?: CliTrustDeps): Promise<Extract<CliTrust, { trusted: true }> | null> {
255
+ for (const c of candidates) {
256
+ const r = await trustCli(c, deps)
257
+ if (r.trusted) return r
258
+ }
259
+ return null
260
+ }
261
+
262
+ export function __resetCliTrustForTests(): void { cache.clear(); pending.clear(); offered.length = 0; applicationsRoot = '/Applications' }