@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.
@@ -20,7 +20,7 @@ import { execFileSync, spawn as nodeSpawn, type ChildProcess, type SpawnOptions
20
20
  import { createHash, randomBytes } from 'node:crypto'
21
21
  import {
22
22
  chmodSync, copyFileSync, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync,
23
- readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync,
23
+ readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, statfsSync, writeFileSync,
24
24
  } from 'node:fs'
25
25
  import { homedir, setPriority, tmpdir } from 'node:os'
26
26
  import { dirname, join, resolve } from 'node:path'
@@ -29,6 +29,7 @@ import { Readable, Transform } from 'node:stream'
29
29
  import { durableAtomicWriteFileSync } from './atomic-fs.js'
30
30
  import { dataPath } from './data-dir.js'
31
31
  import { PARAKEET_CLI, PARAKEET_MODELS, type PinnedModel } from './parakeet-manifest.js'
32
+ import { checkDeveloperIdCli, controlCliCandidates, COS_TEAM_ID, findTrustedCli, isCliPathShape, type CliTrustDeps, type CliTrustReason } from './parakeet-cli-trust.js'
32
33
  import type { HypWord } from './stt-resolver.js'
33
34
 
34
35
  export type ParakeetModelId = 'parakeet-ultra'
@@ -57,18 +58,23 @@ function pinned(id: ParakeetModelId): PinnedModel {
57
58
  // ── Install state ─────────────────────────────────────────────────────────────
58
59
 
59
60
  export type InstallPhase = 'idle' | 'downloading' | 'verifying' | 'installed' | 'failed'
61
+ /** 6.74.0: why an install failed, for a client that picks the action from a code. */
62
+ export type InstallErrorCode = 'cancelled' | 'insufficient_disk' | 'sha_mismatch' | 'download_failed' | 'stalled' | 'cli_untrusted' | 'cli_missing' | 'failed'
60
63
  export interface InstallProgress {
61
64
  state: InstallPhase
62
65
  bytesDone: number
63
66
  bytesTotal: number
64
67
  error: string | null
68
+ errorCode?: InstallErrorCode | null
65
69
  /** Where the bytes came from: files copied from a verified local copy, and files downloaded. */
66
70
  source?: { filesLocal: number; filesDownloaded: number; localSources: string[] }
67
71
  }
68
72
 
69
73
  export interface InstallReceipt {
70
74
  schema: 1
71
- cli: { version: string; sha256: string }
75
+ /** trust (6.74.0): 'sha256' (absent before 6.74.0) is the pinned upstream bytes;
76
+ * 'developer-id' is COS Control's signed build, its own hash and size recorded at install. */
77
+ cli: { version: string; sha256: string; size?: number; trust?: 'sha256' | 'developer-id'; teamId?: string }
72
78
  models: Array<{ id: string; revision: string; bytes: number }>
73
79
  bytes: number
74
80
  installedAt: string
@@ -94,7 +100,7 @@ export function parakeetInstalled(now = Date.now()): { installed: boolean; bytes
94
100
  try {
95
101
  const receipt = JSON.parse(readFileSync(receiptPath(), 'utf8')) as InstallReceipt
96
102
  const sizeOk = (path: string, size: number) => { try { return statSync(path).size === size } catch { return false } }
97
- const cliOk = receipt?.schema === 1 && receipt.cli?.sha256 === PARAKEET_CLI.sha256 && sizeOk(parakeetCliPath(), PARAKEET_CLI.size)
103
+ const cliOk = receipt?.schema === 1 && receiptCliOk(receipt.cli, sizeOk)
98
104
  const modelsOk = PARAKEET_MODELS.every(m =>
99
105
  receipt.models?.some(r => r.id === m.id && r.revision === m.revision)
100
106
  && m.files.every(f => sizeOk(join(parakeetModelDir(m.id as ParakeetModelId), f.path), f.size)))
@@ -104,6 +110,26 @@ export function parakeetInstalled(now = Date.now()): { installed: boolean; bytes
104
110
  return value
105
111
  }
106
112
 
113
+ /** The receipt's CLI is one this server trusts, and the installed file has its size. */
114
+ function receiptCliOk(cli: InstallReceipt['cli'] | undefined, sizeOk: (path: string, size: number) => boolean): boolean {
115
+ if (!cli || cli.version !== PARAKEET_CLI.version) return false
116
+ if (cli.trust === 'developer-id') {
117
+ return cli.teamId === COS_TEAM_ID && typeof cli.sha256 === 'string' && /^[0-9a-f]{64}$/.test(cli.sha256)
118
+ && typeof cli.size === 'number' && sizeOk(parakeetCliPath(), cli.size)
119
+ }
120
+ return cli.sha256 === PARAKEET_CLI.sha256 && sizeOk(parakeetCliPath(), PARAKEET_CLI.size)
121
+ }
122
+
123
+ function readReceipt(): InstallReceipt | null {
124
+ try { return JSON.parse(readFileSync(receiptPath(), 'utf8')) as InstallReceipt } catch { return null }
125
+ }
126
+
127
+ /** 6.74.0: how the installed CLI was trusted, for GET. */
128
+ export function installedCliTrust(): 'sha256' | 'developer-id' | null {
129
+ const cli = readReceipt()?.cli
130
+ return cli ? cli.trust ?? 'sha256' : null
131
+ }
132
+
107
133
  export function resetParakeetInstallCache(): void { installedCache = null; cliHashCache.clear() }
108
134
 
109
135
  async function sha256File(path: string): Promise<string> {
@@ -185,7 +211,32 @@ function modelDirCandidates(model: PinnedModel, sources: string[]): string[] {
185
211
  }
186
212
 
187
213
  export class ParakeetInstallError extends Error {
188
- constructor(message: string) { super(message); this.name = 'ParakeetInstallError' }
214
+ constructor(message: string, readonly code: InstallErrorCode = 'failed') { super(message); this.name = 'ParakeetInstallError' }
215
+ }
216
+
217
+ /** 6.74.0: an install needs this much free space where it installs (654 MB of model,
218
+ * the CLI, a staging copy of each file before the swap, and headroom). */
219
+ export const INSTALL_MIN_FREE_BYTES = 1_500_000_000
220
+
221
+ /** Free bytes on the volume holding `dir` (its nearest existing ancestor), or null when unknown. */
222
+ export function freeBytesAt(dir: string): number | null {
223
+ let probe = resolve(dir)
224
+ for (let i = 0; i < 64; i++) {
225
+ if (existsSync(probe)) {
226
+ try { const st = statfsSync(probe); return Number(st.bavail) * Number(st.bsize) } catch { return null }
227
+ }
228
+ const up = dirname(probe)
229
+ if (up === probe) return null
230
+ probe = up
231
+ }
232
+ return null
233
+ }
234
+
235
+ /** The disk precheck the install route and the installer share. */
236
+ export function parakeetDiskCheck(freeBytes: (dir: string) => number | null = freeBytesAt, required = INSTALL_MIN_FREE_BYTES): { ok: boolean; freeBytes: number | null; requiredBytes: number } {
237
+ const free = freeBytes(parakeetRoot())
238
+ // Unknown free space does not block: the per-file writes still fail plainly on a full disk.
239
+ return { ok: free === null || free >= required, freeBytes: free, requiredBytes: required }
189
240
  }
190
241
 
191
242
  type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<{ ok: boolean; status: number; body: ReadableStream<Uint8Array> | null }>
@@ -201,6 +252,12 @@ export interface InstallOptions {
201
252
  cliCandidates?: string[]
202
253
  fetchImpl?: FetchLike
203
254
  idleTimeoutMs?: number
255
+ /** 6.74.0, tests: codesign and version probes for a Developer ID CLI. */
256
+ trustDeps?: CliTrustDeps
257
+ /** 6.74.0, tests: where COS Control's signed CLI may be (defaults to /Applications and ~/Applications). */
258
+ controlCliCandidates?: string[]
259
+ /** 6.74.0, tests: free space probe. */
260
+ freeBytes?: (dir: string) => number | null
204
261
  }
205
262
 
206
263
  /** Start the install, or join the one already running. */
@@ -216,27 +273,53 @@ export function parakeetInstallRunning(): boolean { return installing !== null }
216
273
  /** Stop a running install; nothing partial is kept. */
217
274
  export function cancelParakeetInstall(): boolean {
218
275
  if (!installAbort) return false
219
- installAbort.abort(new ParakeetInstallError('The install was cancelled.'))
276
+ installAbort.abort(new ParakeetInstallError('The install was cancelled.', 'cancelled'))
220
277
  return true
221
278
  }
222
279
 
223
280
  async function runInstall(options: InstallOptions, signal: AbortSignal): Promise<void> {
224
281
  const root = parakeetRoot()
225
282
  const localSources = [...(options.localSources ?? []), ...defaultLocalSources()]
226
- progress = { state: 'verifying', bytesDone: 0, bytesTotal: TOTAL_BYTES, error: null, source: { filesLocal: 0, filesDownloaded: 0, localSources: [] } }
227
- mkdirSync(root, { recursive: true, mode: 0o700 })
228
- const staging = join(root, `.staging-${randomBytes(6).toString('hex')}`)
229
- mkdirSync(join(staging, 'bin'), { recursive: true, mode: 0o700 })
283
+ progress = { state: 'verifying', bytesDone: 0, bytesTotal: TOTAL_BYTES, error: null, errorCode: null, source: { filesLocal: 0, filesDownloaded: 0, localSources: [] } }
230
284
  const usedSources = new Set<string>()
285
+ let staging: string | null = null
231
286
  try {
232
- // 1. The CLI. FluidAudio publishes no binary, so a pinned local copy is the only source.
287
+ // 0. Disk precheck (6.74.0), before anything is written.
288
+ const disk = parakeetDiskCheck(options.freeBytes)
289
+ if (!disk.ok) {
290
+ throw new ParakeetInstallError(`Parakeet needs ${formatGb(disk.requiredBytes)} free on this Mac; ${formatGb(disk.freeBytes ?? 0)} is free. Free some space, then install again. Whisper keeps working.`, 'insufficient_disk')
291
+ }
292
+ mkdirSync(root, { recursive: true, mode: 0o700 })
293
+ staging = join(root, `.staging-${randomBytes(6).toString('hex')}`)
294
+ mkdirSync(join(staging, 'bin'), { recursive: true, mode: 0o700 })
295
+
296
+ // 1. The CLI. FluidAudio publishes no binary: a SHA-256 pinned local copy, or
297
+ // (6.74.0) the build COS Control ships signed with the COS Developer ID.
233
298
  const cliTarget = join(staging, 'bin', 'fluidaudiocli')
234
299
  const cli = await findPinnedCli([...(options.cliCandidates ?? []), ...(options.localSources ?? []).map(src => fileSize(src) !== null ? src : join(src, 'fluidaudiocli')), ...defaultCliCandidates()])
235
- if (!cli) {
236
- throw new ParakeetInstallError(`FluidAudio CLI ${PARAKEET_CLI.version} is not on this Mac. It is the same tool speaker separation uses; put it at ~/.cos-glasses/bin/fluidaudiocli, then install again.`)
300
+ let receiptCli: InstallReceipt['cli']
301
+ if (cli) {
302
+ copyFileSync(cli, cliTarget)
303
+ if (await sha256File(cliTarget) !== PARAKEET_CLI.sha256) throw new ParakeetInstallError('The FluidAudio CLI changed while it was being copied. Try again.')
304
+ receiptCli = { version: PARAKEET_CLI.version, sha256: PARAKEET_CLI.sha256, size: PARAKEET_CLI.size, trust: 'sha256' }
305
+ } else {
306
+ const signedCandidates = [...new Set([...(options.cliCandidates ?? []).filter(isCliPathShape), ...(options.controlCliCandidates ?? controlCliCandidates())])]
307
+ const signed = await findTrustedCli(signedCandidates, options.trustDeps)
308
+ if (!signed) {
309
+ const seen = signedCandidates.find(path => fileSize(path) !== null)
310
+ if (seen) {
311
+ const why = await checkDeveloperIdCli(seen, options.trustDeps)
312
+ const reason: CliTrustReason = why.trusted ? 'signature_invalid' : why.reason
313
+ throw new ParakeetInstallError(`The speech tool in COS Control did not pass its check (${reason}). Reinstall COS Control, then install again.`, 'cli_untrusted')
314
+ }
315
+ throw new ParakeetInstallError(`The speech tool Parakeet needs is not on this Mac. Update COS Control, which includes it, then install again.`, 'cli_missing')
316
+ }
317
+ copyFileSync(signed.path, cliTarget)
318
+ // Prove the copy, not only the source: the installed bytes must be signed and unchanged.
319
+ const copy = await checkDeveloperIdCli(cliTarget, options.trustDeps)
320
+ if (!copy.trusted || copy.sha256 !== signed.sha256) throw new ParakeetInstallError('The speech tool changed while it was being copied. Try again.', 'cli_untrusted')
321
+ receiptCli = { version: PARAKEET_CLI.version, sha256: copy.sha256, size: copy.size, trust: 'developer-id', teamId: COS_TEAM_ID }
237
322
  }
238
- copyFileSync(cli, cliTarget)
239
- if (await sha256File(cliTarget) !== PARAKEET_CLI.sha256) throw new ParakeetInstallError('The FluidAudio CLI changed while it was being copied. Try again.')
240
323
  chmodSync(cliTarget, 0o755)
241
324
  progress.bytesDone += PARAKEET_CLI.size
242
325
 
@@ -246,7 +329,7 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
246
329
  for (const model of PARAKEET_MODELS) {
247
330
  const candidates = modelDirCandidates(model, localSources)
248
331
  for (const file of model.files) {
249
- if (signal.aborted) throw new ParakeetInstallError('The install was cancelled.')
332
+ if (signal.aborted) throw new ParakeetInstallError('The install was cancelled.', 'cancelled')
250
333
  const target = join(staging, 'models', model.id, file.path)
251
334
  mkdirSync(dirname(target), { recursive: true, mode: 0o700 })
252
335
  let placed = false
@@ -268,12 +351,12 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
268
351
  const url = `https://huggingface.co/${model.repo}/resolve/${model.revision}/${file.path.split('/').map(encodeURIComponent).join('/')}`
269
352
  // One controller per file: user cancel, or no bytes for idleMs.
270
353
  const ctl = new AbortController()
271
- const onCancel = () => ctl.abort(new ParakeetInstallError('The install was cancelled.'))
354
+ const onCancel = () => ctl.abort(new ParakeetInstallError('The install was cancelled.', 'cancelled'))
272
355
  signal.addEventListener('abort', onCancel, { once: true })
273
356
  let idle: ReturnType<typeof setTimeout> | null = null
274
357
  const arm = () => {
275
358
  if (idle) clearTimeout(idle)
276
- idle = setTimeout(() => ctl.abort(new ParakeetInstallError(`The download stalled (no data for ${Math.round(idleMs / 1000)} s). Nothing was installed; try again.`)), idleMs)
359
+ idle = setTimeout(() => ctl.abort(new ParakeetInstallError(`The download stalled (no data for ${Math.round(idleMs / 1000)} s). Nothing was installed; try again.`, 'stalled')), idleMs)
277
360
  idle.unref?.()
278
361
  }
279
362
  arm()
@@ -281,18 +364,20 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
281
364
  let written = 0
282
365
  try {
283
366
  const res = await fetchImpl(url, { signal: ctl.signal })
284
- if (!res.ok || !res.body) throw new ParakeetInstallError(`Download failed (${res.status}) for ${model.id} ${file.path}.`)
367
+ if (!res.ok || !res.body) throw new ParakeetInstallError(`Download failed (${res.status}) for ${model.id} ${file.path}.`, 'download_failed')
285
368
  const counter = new Transform({ transform(chunk, _e, cb) {
286
369
  arm()
287
370
  written += chunk.length
288
- if (written > file.size) { cb(new ParakeetInstallError(`${model.id} ${file.path} is larger than the pinned size.`)); return }
371
+ if (written > file.size) { cb(new ParakeetInstallError(`${model.id} ${file.path} is larger than the pinned size.`, 'sha_mismatch')); return }
289
372
  progress.bytesDone += chunk.length
290
373
  cb(null, chunk)
291
374
  } })
292
375
  await pipeline(Readable.fromWeb(res.body as any), counter, createWriteStream(partial, { mode: 0o600 }), { signal: ctl.signal })
293
376
  } catch (error) {
294
377
  const why = ctl.signal.reason
295
- throw why instanceof ParakeetInstallError ? why : error
378
+ if (why instanceof ParakeetInstallError) throw why
379
+ if (error instanceof ParakeetInstallError) throw error
380
+ throw new ParakeetInstallError(`Download failed for ${model.id} ${file.path}: ${error instanceof Error ? error.message : String(error)}`, 'download_failed')
296
381
  } finally {
297
382
  if (idle) clearTimeout(idle)
298
383
  signal.removeEventListener('abort', onCancel)
@@ -303,7 +388,7 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
303
388
  }
304
389
  progress.state = 'verifying'
305
390
  if (fileSize(target) !== file.size || await sha256File(target) !== file.sha256) {
306
- throw new ParakeetInstallError(`${model.id} ${file.path} does not match its pinned SHA-256. Nothing was installed.`)
391
+ throw new ParakeetInstallError(`${model.id} ${file.path} does not match its pinned SHA-256. Nothing was installed.`, 'sha_mismatch')
307
392
  }
308
393
  progress.bytesDone += file.size
309
394
  }
@@ -312,7 +397,7 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
312
397
 
313
398
  const receipt: InstallReceipt = {
314
399
  schema: 1,
315
- cli: { version: PARAKEET_CLI.version, sha256: PARAKEET_CLI.sha256 },
400
+ cli: receiptCli,
316
401
  models: PARAKEET_MODELS.map(m => ({ id: m.id, revision: m.revision, bytes: m.files.reduce((n, f) => n + f.size, 0) })),
317
402
  bytes: TOTAL_BYTES,
318
403
  installedAt: new Date().toISOString(),
@@ -324,20 +409,23 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
324
409
  const current = currentDir()
325
410
  const old = existsSync(current) ? join(root, `.old-${Date.now()}-${randomBytes(3).toString('hex')}`) : null
326
411
  if (old) renameSync(current, old)
327
- renameSync(staging, current)
412
+ renameSync(staging!, current)
328
413
  if (old) rmSync(old, { recursive: true, force: true })
329
414
  mkdirSync(parakeetHome(), { recursive: true, mode: 0o700 })
330
415
  resetParakeetInstallCache()
331
416
  for (const id of warm.keys()) warm.delete(id)
332
- progress = { ...progress, state: 'installed', bytesDone: TOTAL_BYTES, bytesTotal: TOTAL_BYTES, error: null }
417
+ progress = { ...progress, state: 'installed', bytesDone: TOTAL_BYTES, bytesTotal: TOTAL_BYTES, error: null, errorCode: null }
333
418
  } catch (error) {
334
- rmSync(staging, { recursive: true, force: true })
419
+ if (staging) rmSync(staging, { recursive: true, force: true })
335
420
  const message = error instanceof ParakeetInstallError ? error.message : `Install failed: ${error instanceof Error ? error.message : String(error)}`
336
- progress = { ...progress, state: 'failed', bytesTotal: TOTAL_BYTES, error: message, source: progress.source ? { ...progress.source, localSources: [...usedSources] } : undefined }
337
- throw new ParakeetInstallError(message)
421
+ const code: InstallErrorCode = error instanceof ParakeetInstallError ? error.code : 'failed'
422
+ progress = { ...progress, state: 'failed', bytesTotal: TOTAL_BYTES, error: message, errorCode: code, source: progress.source ? { ...progress.source, localSources: [...usedSources] } : undefined }
423
+ throw new ParakeetInstallError(message, code)
338
424
  }
339
425
  }
340
426
 
427
+ function formatGb(bytes: number): string { return `${(bytes / 1e9).toFixed(1)} GB` }
428
+
341
429
  /**
342
430
  * Boot: put back an install a crash left mid-swap (no `current`, an `.old-*`
343
431
  * present), then remove staging and old directories.
@@ -392,6 +480,8 @@ export interface ParakeetRunOptions {
392
480
  spawnImpl?: SpawnLike
393
481
  /** Tests only: a different sandbox-exec path. There is no environment override. */
394
482
  sandboxExec?: string
483
+ /** 6.74.0 benchmark: called with the child's pid once it is spawned (peak memory sampling). */
484
+ onSpawn?: (pid: number) => void
395
485
  }
396
486
 
397
487
  const liveChildren = new Set<ChildProcess>()
@@ -537,12 +627,48 @@ export function transcribeParakeet(wav: Buffer, options: ParakeetRunOptions): Pr
537
627
 
538
628
  async function runParakeet(wav: Buffer, options: ParakeetRunOptions): Promise<ParakeetResult> {
539
629
  // The installed CLI's full hash, once per process (and again if it changes on disk).
540
- if (!options.spawnImpl && !(await isPinnedCli(parakeetCliPath()))) {
541
- throw new ParakeetError('not_installed', 'the installed FluidAudio CLI no longer matches its pinned SHA-256')
630
+ if (!options.spawnImpl && !(await installedCliIntact())) {
631
+ throw new ParakeetError('not_installed', 'the installed FluidAudio CLI no longer matches the hash recorded when it was installed')
542
632
  }
543
633
  return runParakeetChecked(wav, options)
544
634
  }
545
635
 
636
+ /**
637
+ * 6.74.0, for the diarizer's CLI lookup (synchronous, read on health): the GUI-installed
638
+ * CLI is trusted only when the install is complete and its bytes are known to match the
639
+ * receipt (the pinned hash, or the Developer ID build's recorded hash). null while that
640
+ * hash has not been checked in this process; a check starts in the background.
641
+ */
642
+ export function installedCliTrustedNow(): boolean | null {
643
+ if (!parakeetInstalled().installed) return false
644
+ const path = parakeetCliPath()
645
+ const st = fileStat(path)
646
+ if (!st) return false
647
+ const cli = readReceipt()?.cli
648
+ const key = cli?.trust === 'developer-id'
649
+ ? `${path}\0${st.size}\0${st.mtimeMs}\0${cli.sha256}`
650
+ : `${path}\0${st.size}\0${st.mtimeMs}`
651
+ const known = cliHashCache.get(key)
652
+ if (known !== undefined) return known
653
+ void installedCliIntact(path)
654
+ return null
655
+ }
656
+
657
+ /** The installed CLI is byte for byte what the install verified: the pinned hash, or
658
+ * (6.74.0) the hash of the Developer ID build recorded in the receipt. */
659
+ export async function installedCliIntact(path: string = parakeetCliPath()): Promise<boolean> {
660
+ const cli = readReceipt()?.cli
661
+ if (cli?.trust !== 'developer-id') return isPinnedCli(path)
662
+ const st = fileStat(path)
663
+ if (!st || st.size !== cli.size || cli.teamId !== COS_TEAM_ID) return false
664
+ const key = `${path}\0${st.size}\0${st.mtimeMs}\0${cli.sha256}`
665
+ const known = cliHashCache.get(key)
666
+ if (known !== undefined) return known
667
+ const ok = await sha256File(path).then(h => h === cli.sha256, () => false)
668
+ cliHashCache.set(key, ok)
669
+ return ok
670
+ }
671
+
546
672
  function runParakeetChecked(wav: Buffer, options: ParakeetRunOptions): Promise<ParakeetResult> {
547
673
  const started = Date.now()
548
674
  if (!parakeetInstalled().installed) return Promise.reject(new ParakeetError('not_installed', 'Parakeet is not installed'))
@@ -591,6 +717,7 @@ function runParakeetChecked(wav: Buffer, options: ParakeetRunOptions): Promise<P
591
717
  options.signal?.addEventListener('abort', onAbort, { once: true })
592
718
  liveChildren.add(child)
593
719
  child.once('close', () => liveChildren.delete(child))
720
+ if (child.pid && options.onSpawn) { try { options.onSpawn(child.pid) } catch { /* sampling only */ } }
594
721
  if (child.pid && options.lane !== 'preview' && options.lane !== 'interactive') {
595
722
  try { setPriority(child.pid, 10) } catch { /* runs at normal priority */ }
596
723
  }
@@ -11,9 +11,10 @@ import {
11
11
  import { transcribeWhisperMeetingPreview } from './whisper-preview.js'
12
12
  import { asrFrontEnd } from './stt-live.js'
13
13
  import {
14
- getInstallProgress, parakeetInstalled, parakeetInstallRunning, parakeetWarmState, pinnedCliKnown,
14
+ getInstallProgress, installedCliTrust, parakeetInstalled, parakeetInstallRunning, parakeetWarmState, pinnedCliKnown,
15
15
  PARAKEET_VERSION_LABEL, transcribeParakeet, warmParakeet, type ParakeetModelId,
16
16
  } from './parakeet.js'
17
+ import { controlCliCandidates, knownCliTrust, type CliTrust } from './parakeet-cli-trust.js'
17
18
  import {
18
19
  effectiveStt, readSttSettings, type EffectiveStt, type FrontEndId, type ParakeetStatus,
19
20
  type SttCapabilities,
@@ -36,6 +37,8 @@ export const PREVIEW_BREAKER_MAX_MS = 10 * 60_000
36
37
  let recordingLiveProbe: () => boolean = () => false
37
38
  export function setRecordingLiveProbe(probe: () => boolean): void { recordingLiveProbe = probe }
38
39
  function recordingLive(): boolean { try { return recordingLiveProbe() } catch { return true } }
40
+ /** 6.74.0: the benchmark route refuses while a recording is live. Fails closed. */
41
+ export function isRecordingLive(): boolean { return recordingLive() }
39
42
 
40
43
  // ── Preview breaker and counters ─────────────────────────────────────────────
41
44
  const previewStats = { parakeet: 0, parakeetMisses: 0, turboFallbacks: 0, turbo: 0, nonTurboWithheld: 0, empty: 0 }
@@ -93,32 +96,60 @@ export function currentEffectiveStt(): EffectiveStt {
93
96
  return effectiveStt(readSttSettings(), currentSttCapabilities())
94
97
  }
95
98
 
99
+ /** 6.74.0: which CLI an install would use. A SHA-256 pinned hand copy, else COS Control's
100
+ * signed build. true or false once known, null while a check runs (GET never blocks). */
101
+ export function installCliKnown(): { known: boolean | null; trust: 'sha256' | 'developer-id' | null; untrusted: CliTrust | null } {
102
+ const pinned = pinnedCliKnown()
103
+ if (pinned === true) return { known: true, trust: 'sha256', untrusted: null }
104
+ let unknown = pinned === null
105
+ let untrusted: CliTrust | null = null
106
+ for (const path of controlCliCandidates()) {
107
+ const t = knownCliTrust(path)
108
+ if (t === null) { unknown = true; continue }
109
+ if (t.trusted) return { known: true, trust: 'developer-id', untrusted: null }
110
+ if (t.reason !== 'missing' && !untrusted) untrusted = t
111
+ }
112
+ return { known: unknown ? null : false, trust: null, untrusted }
113
+ }
114
+
115
+ const UNTRUSTED_NOTE: Record<string, string> = {
116
+ unsigned: 'is not signed',
117
+ signature_invalid: 'has a signature that does not verify',
118
+ wrong_team: 'is not signed by COS',
119
+ wrong_version: 'is not the FluidAudio 0.17.4 build Parakeet needs',
120
+ }
121
+
96
122
  export function parakeetStatus(live: boolean): ParakeetStatus {
97
123
  const installed = parakeetInstalled()
98
124
  const progress = getInstallProgress()
99
125
  const running = parakeetInstallRunning()
100
126
  const sandbox = existsSync('/usr/bin/sandbox-exec')
101
127
  const appleSilicon = process.platform === 'darwin' && process.arch === 'arm64'
102
- // A button that can never succeed is never offered: the pinned CLI must be on this Mac.
103
- const cli = appleSilicon ? pinnedCliKnown() : false
104
- const installable = !installed.installed && !running && sandbox && appleSilicon && cli === true
128
+ // A button that can never succeed is never offered: a trusted CLI must be on this Mac,
129
+ // a SHA-256 pinned copy or (6.74.0) the signed one COS Control ships.
130
+ const cli = appleSilicon ? installCliKnown() : { known: false, trust: null, untrusted: null }
131
+ const installable = !installed.installed && !running && sandbox && appleSilicon && cli.known === true
105
132
  const warm = parakeetWarmState(PREVIEW_PARAKEET_MODEL)
133
+ const untrustedReason = cli.untrusted && !cli.untrusted.trusted ? cli.untrusted.reason : null
106
134
  const note = installed.installed
107
135
  ? 'Installed. Runs offline on the Neural Engine.'
108
136
  : running
109
- ? 'Installing.'
137
+ ? 'Installing. Dictation uses Whisper until it is ready.'
110
138
  : !sandbox || !appleSilicon
111
139
  ? 'Parakeet needs an Apple silicon Mac.'
112
- : cli === false
113
- ? 'Parakeet needs FluidAudio fluidaudiocli 0.17.4 at ~/.cos-glasses/bin (the speaker-separation tool); this Mac does not have that build.'
114
- : cli === null
115
- ? 'Checking this Mac for the FluidAudio CLI.'
140
+ : cli.known === false
141
+ ? untrustedReason
142
+ ? `The speech tool in COS Control ${UNTRUSTED_NOTE[untrustedReason] ?? 'did not pass its check'}. Reinstall COS Control, then install Parakeet.`
143
+ : 'Parakeet needs the speech tool that comes with COS Control. Update COS Control, then install Parakeet here.'
144
+ : cli.known === null
145
+ ? 'Checking this Mac for the speech tool.'
116
146
  : live
117
147
  ? 'Install after the live recording is saved.'
118
- : 'About 654 MB. Installs the pinned model and checks every file.'
148
+ : 'Install Parakeet (654 MB). It downloads the pinned model, checks every file and runs offline. Dictation uses Whisper until it is ready.'
119
149
  return {
120
150
  installed: installed.installed,
121
151
  installable,
152
+ cli: { trust: installed.installed ? installedCliTrust() : cli.trust, reason: untrustedReason },
122
153
  version: PARAKEET_VERSION_LABEL,
123
154
  bytes: installed.installed ? installed.bytes : progress.bytesDone,
124
155
  progress: { ...progress, state: installed.installed && !running ? 'installed' : progress.state },
@@ -139,7 +170,8 @@ export const WARM_RETRY_MS = 120_000
139
170
  export function ensurePreviewWarm(now = Date.now()): 'started' | 'skipped' | 'deferred' {
140
171
  const settings = readSttSettings()
141
172
  // 6.73.0: a Parakeet HQ model also warms, because dictation final text uses it only warm.
142
- const wanted = settings.preview === 'parakeet' || settings.hq === 'parakeet-ultra'
173
+ // 6.74.0: so does Mac dictation on auto or parakeet-ultra (the default is auto).
174
+ const wanted = settings.preview === 'parakeet' || settings.hq === 'parakeet-ultra' || settings.dictation !== 'whisper'
143
175
  if (!wanted || !parakeetInstalled().installed) return 'skipped'
144
176
  const w = parakeetWarmState(PREVIEW_PARAKEET_MODEL)
145
177
  if (w.state === 'warm' || w.state === 'warming') return 'skipped'
@@ -19,12 +19,15 @@ export const HQ_IDS = ['large-v3', 'large-v3-legacy', 'parakeet-ultra'] as const
19
19
  export const SECOND_OPINION_IDS = ['off', 'parakeet-ultra', 'large-v3'] as const
20
20
  export const FRONT_END_IDS = ['highpass', 'full'] as const
21
21
  export const QUIET_GATE_IDS = ['silero', 'rms'] as const
22
+ /** 6.74.0: the engine for Mac dictation (COS Control). It does not follow the Preview model. */
23
+ export const DICTATION_IDS = ['auto', 'parakeet-ultra', 'whisper'] as const
22
24
 
23
25
  export type PreviewId = typeof PREVIEW_IDS[number]
24
26
  export type HqId = typeof HQ_IDS[number]
25
27
  export type SecondOpinionId = typeof SECOND_OPINION_IDS[number]
26
28
  export type FrontEndId = typeof FRONT_END_IDS[number]
27
29
  export type QuietGateId = typeof QUIET_GATE_IDS[number]
30
+ export type DesktopDictationId = typeof DICTATION_IDS[number]
28
31
 
29
32
  export interface SttSettings {
30
33
  preview: PreviewId
@@ -33,6 +36,8 @@ export interface SttSettings {
33
36
  resolver: boolean
34
37
  frontEnd: FrontEndId
35
38
  quietGate: QuietGateId
39
+ /** 6.74.0: Mac dictation engine. auto = Parakeet Ultra when installed and warm, else local Whisper. */
40
+ dictation: DesktopDictationId
36
41
  }
37
42
 
38
43
  /** Items 1 to 3 on. Second opinion off and the resolver off: on the shipped
@@ -47,6 +52,9 @@ export const DEFAULT_STT_SETTINGS: Readonly<SttSettings> = Object.freeze({
47
52
  resolver: false,
48
53
  frontEnd: 'highpass',
49
54
  quietGate: 'silero',
55
+ // Every install before 6.74.0 has no key and reads as auto, including Macs that
56
+ // chose Parakeet: auto keeps Parakeet wherever it is installed and warm.
57
+ dictation: 'auto',
50
58
  })
51
59
 
52
60
  export const STT_SETTINGS_FILE = 'stt-settings.json'
@@ -66,6 +74,7 @@ export function normalizeStoredSettings(raw: unknown): SttSettings {
66
74
  resolver: typeof o.resolver === 'boolean' ? o.resolver : DEFAULT_STT_SETTINGS.resolver,
67
75
  frontEnd: pick(FRONT_END_IDS, o.frontEnd, DEFAULT_STT_SETTINGS.frontEnd),
68
76
  quietGate: pick(QUIET_GATE_IDS, o.quietGate, DEFAULT_STT_SETTINGS.quietGate),
77
+ dictation: pick(DICTATION_IDS, o.dictation, DEFAULT_STT_SETTINGS.dictation),
69
78
  }
70
79
  }
71
80
 
@@ -209,7 +218,7 @@ export class SttSettingsError extends Error {
209
218
  constructor(message: string) { super(message); this.name = 'SttSettingsError' }
210
219
  }
211
220
 
212
- const KEYS = ['preview', 'hq', 'secondOpinion', 'resolver', 'frontEnd', 'quietGate'] as const
221
+ const KEYS = ['preview', 'hq', 'secondOpinion', 'resolver', 'frontEnd', 'quietGate', 'dictation'] as const
213
222
 
214
223
  /**
215
224
  * Apply a partial PUT. Throws SttSettingsError with a plain sentence for an
@@ -238,12 +247,14 @@ export function applySettingsPatch(current: SttSettings, body: unknown, c: SttCa
238
247
  const second = choose('secondOpinion', SECOND_OPINION_IDS, secondOpinionOptions(c), 'Second opinion')
239
248
  const frontEnd = choose('frontEnd', FRONT_END_IDS, FRONT_END_OPTIONS, 'Front end')
240
249
  const quietGate = choose('quietGate', QUIET_GATE_IDS, QUIET_GATE_OPTIONS, 'Quiet gate')
250
+ const dictation = choose('dictation', DICTATION_IDS, desktopDictationOptions(c), 'Dictation engine')
241
251
  if ('resolver' in patch && typeof patch.resolver !== 'boolean') throw new SttSettingsError('resolver must be true or false.')
242
252
  if (preview) next.preview = preview
243
253
  if (hq) next.hq = hq
244
254
  if (second) next.secondOpinion = second
245
255
  if (frontEnd) next.frontEnd = frontEnd
246
256
  if (quietGate) next.quietGate = quietGate
257
+ if (dictation) next.dictation = dictation
247
258
  if (typeof patch.resolver === 'boolean') next.resolver = patch.resolver
248
259
  if (engineFamily(next.hq) === engineFamily(next.secondOpinion)) {
249
260
  if (second) throw new SttSettingsError('The second opinion must be a different engine from the HQ model.')
@@ -370,6 +381,75 @@ export const DICTATION_REASON_TEXT: Record<string, string> = {
370
381
  dictation_disabled_by_env: 'Dictation is held on Whisper on this server (COS_DICTATION_FOLLOWS_STT=0).',
371
382
  }
372
383
 
384
+ // ── Mac dictation (6.74.0) ────────────────────────────────────────────────────
385
+ //
386
+ // COS Control's dictation has its own setting and never follows the Preview model.
387
+ // auto: Parakeet Ultra when installed, warm and its breaker closed, otherwise the
388
+ // local Whisper live worker for that decode (and a warm-up starts). Never the cloud.
389
+ // parakeet-ultra: Parakeet only, as 6.73.x did; when it cannot run the request fails.
390
+ // whisper: local Whisper only.
391
+
392
+ /** Whisper weights for the live worker are on this Mac. */
393
+ export function desktopWhisperPresent(c: SttCapabilities): boolean {
394
+ return c.turboModel || c.liveLargeV3
395
+ }
396
+
397
+ export function desktopDictationOptions(c: SttCapabilities): SttOption[] {
398
+ const whisper = desktopWhisperPresent(c)
399
+ return [
400
+ { id: 'auto', label: 'Automatic', available: true, experimental: false,
401
+ note: 'Parakeet Ultra when it is installed and ready, otherwise Whisper on this Mac. Audio never leaves this Mac.' },
402
+ { id: 'parakeet-ultra', label: 'Parakeet Ultra', available: c.parakeetInstalled, experimental: false,
403
+ note: c.parakeetInstalled ? 'Parakeet only. While it warms up or is busy, dictation fails instead of using Whisper.' : 'Install Parakeet on this Mac first.' },
404
+ { id: 'whisper', label: 'Whisper', available: whisper, experimental: false,
405
+ note: whisper ? 'Whisper on this Mac only, with the names in your COS dictionary.' : 'Whisper weights are missing on this Mac.' },
406
+ ]
407
+ }
408
+
409
+ export interface DesktopDictationPlan extends DictationPlan {
410
+ setting: DesktopDictationId
411
+ /** Whisper may decode this request: auto and whisper, never parakeet-ultra. */
412
+ whisperAllowed: boolean
413
+ }
414
+
415
+ /** Pure: what a Mac dictation decode uses. The engine is chosen once, here. */
416
+ export function desktopDictationPlan(slot: DictationSlot, settings: SttSettings, c: SttCapabilities): DesktopDictationPlan {
417
+ const setting = settings.dictation
418
+ const base = { slot, frontEnd: settings.frontEnd, setting, whisperAllowed: setting !== 'parakeet-ultra' }
419
+ const whisperKey = slot === 'final' ? 'final:large-v3' : `${slot}:turbo`
420
+ if (setting === 'whisper') return { ...base, engine: 'whisper', reason: null, key: whisperKey }
421
+ const reason = c.dictationDisabledByEnv ? 'dictation_disabled_by_env'
422
+ : !c.parakeetInstalled ? 'parakeet_not_installed'
423
+ : c.parakeetPreviewBreakerOpen ? 'parakeet_breaker_open'
424
+ : c.parakeetPreviewWarm ? null
425
+ : c.parakeetPreviewWarmFailed ? 'parakeet_warm_failed'
426
+ : 'parakeet_warming'
427
+ if (reason) return { ...base, engine: 'whisper', reason, key: whisperKey }
428
+ return { ...base, engine: 'parakeet-ultra', reason: null, key: slot === 'final' ? 'final:parakeet-ultra' : `${slot}:parakeet` }
429
+ }
430
+
431
+ const reasonText = (code: string) => REASON_TEXT[code] ?? DICTATION_REASON_TEXT[code] ?? code
432
+
433
+ export function desktopDictationView(settings: SttSettings, c: SttCapabilities) {
434
+ const plan = desktopDictationPlan('fast', settings, c)
435
+ const effective: DictationEngine | 'none' = plan.engine === 'parakeet-ultra' ? 'parakeet-ultra'
436
+ : plan.whisperAllowed && desktopWhisperPresent(c) ? 'whisper' : 'none'
437
+ const code = plan.reason
438
+ const note = effective === 'parakeet-ultra' ? 'Mac dictation uses Parakeet Ultra on this Mac.'
439
+ : effective === 'whisper'
440
+ ? settings.dictation === 'auto' && code ? `Mac dictation uses Whisper on this Mac for now. ${reasonText(code)}` : 'Mac dictation uses Whisper on this Mac.'
441
+ : settings.dictation === 'parakeet-ultra' ? `Mac dictation waits for Parakeet.${code ? ` ${reasonText(code)}` : ''}`
442
+ : 'No dictation engine is ready on this Mac. Install Parakeet, or the Whisper weights.'
443
+ return {
444
+ selected: settings.dictation,
445
+ effective,
446
+ reason: code ? reasonText(code) : null,
447
+ reasonCode: code,
448
+ options: desktopDictationOptions(c),
449
+ note,
450
+ }
451
+ }
452
+
373
453
  function dictationSlotView(plan: DictationPlan, follows: 'preview' | 'hq') {
374
454
  const code = plan.reason
375
455
  return { engine: plan.engine, follows, reason: code ? (REASON_TEXT[code] ?? DICTATION_REASON_TEXT[code] ?? code) : null, reasonCode: code }
@@ -381,7 +461,8 @@ export function dictationView(settings: SttSettings, c: SttCapabilities) {
381
461
  const final = dictationPlan('final', settings, c)
382
462
  const anyParakeet = settings.preview === 'parakeet' || settings.hq === 'parakeet-ultra'
383
463
  const name = (p: DictationPlan) => p.engine === 'parakeet-ultra' ? 'Parakeet Ultra' : 'Whisper'
384
- const note = `Dictation follows these settings: the live preview and Fast mode use the Preview model (now ${name(preview)}), the final text uses the HQ model (now ${name(final)}). `
464
+ const note = `Glasses and phone dictation follow these settings: the live preview and Fast mode use the Preview model (now ${name(preview)}), the final text uses the HQ model (now ${name(final)}). `
465
+ + 'Mac dictation has its own engine setting. '
385
466
  + 'Dictation never runs the second opinion or the resolver, and the quiet gate never drops a spoken prompt.'
386
467
  return {
387
468
  preview: dictationSlotView(preview, 'preview'),
@@ -391,6 +472,8 @@ export function dictationView(settings: SttSettings, c: SttCapabilities) {
391
472
  quietGate: 'none' as const,
392
473
  secondOpinion: 'off' as const,
393
474
  note,
475
+ /** 6.74.0: COS Control's Mac dictation (POST /api/transcribe?requiredEngine=local). */
476
+ desktop: desktopDictationView(settings, c),
394
477
  }
395
478
  }
396
479
 
@@ -399,7 +482,9 @@ export interface ParakeetStatus {
399
482
  installable: boolean
400
483
  version: string
401
484
  bytes: number
402
- progress: { state: string; bytesDone: number; bytesTotal: number; error: string | null; source?: unknown }
485
+ progress: { state: string; bytesDone: number; bytesTotal: number; error: string | null; errorCode?: string | null; source?: unknown }
486
+ /** 6.74.0: how the CLI is (or would be) trusted, and why COS Control's copy was refused. */
487
+ cli?: { trust: 'sha256' | 'developer-id' | null; reason: string | null }
403
488
  warm: { preview: string; error?: string; at?: string }
404
489
  preview?: { breaker: { open: boolean; retryAt: string | null; misses: number }; counters: Record<string, number> }
405
490
  note: string