@gotcos/glasses-server 6.73.5 → 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 +83 -0
- package/package.json +1 -1
- package/server/index.ts +5 -0
- package/server/lib/ask-session-id.ts +5 -0
- package/server/lib/decision-provider.ts +163 -0
- package/server/lib/decision-settings.ts +72 -0
- package/server/lib/diarizer-backend.ts +24 -2
- package/server/lib/dictation-benchmark.ts +197 -0
- package/server/lib/dictation-stt.ts +61 -2
- package/server/lib/glossary-learn.ts +221 -0
- package/server/lib/imported-library-rows.ts +62 -4
- package/server/lib/jev.ts +3 -3
- package/server/lib/lens-gist-engines.ts +7 -2
- package/server/lib/live-cos-ask.ts +2 -1
- package/server/lib/mac-recording-asr.ts +59 -0
- package/server/lib/mac-recording.ts +111 -0
- package/server/lib/meeting-actions.ts +1 -0
- package/server/lib/meeting-ask-follow-up.ts +2 -2
- package/server/lib/meeting-ask-runner.ts +26 -5
- package/server/lib/meeting-ask-store.ts +2 -1
- package/server/lib/meeting-batch-transcribe.ts +19 -4
- package/server/lib/meeting-context-keys.ts +7 -4
- package/server/lib/meeting-parse.ts +2 -0
- package/server/lib/meeting-store.ts +62 -9
- package/server/lib/parakeet-cli-trust.ts +262 -0
- package/server/lib/parakeet.ts +156 -29
- package/server/lib/session-recommendation.ts +12 -9
- package/server/lib/speaker-embeddings.ts +2 -2
- package/server/lib/stranded-session-actions.ts +15 -0
- package/server/lib/stt-runtime.ts +43 -11
- package/server/lib/stt-settings.ts +88 -3
- package/server/lib/subq-key.ts +38 -0
- package/server/lib/transcribe-audio.ts +87 -7
- package/server/lib/work-activity.ts +2 -2
- package/server/lib/work-chat.ts +113 -13
- package/server/lib/work-completion.ts +11 -8
- package/server/lib/work-intake-store.ts +2 -1
- package/server/lib/work-search.ts +32 -12
- package/server/routes/client-instance.ts +1 -1
- package/server/routes/decision-provider.ts +36 -0
- package/server/routes/glossary.ts +143 -11
- package/server/routes/jev.ts +3 -2
- package/server/routes/meeting.ts +44 -12
- package/server/routes/meetings.ts +42 -4
- package/server/routes/stt-settings.ts +55 -4
- package/server/routes/subq-key.ts +27 -0
- package/server/routes/transcribe-stream.ts +115 -9
- package/server/routes/transcribe.ts +74 -6
- package/server/routes/work-chat.ts +10 -4
- package/server/routes/work-search.ts +2 -2
package/server/lib/parakeet.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
236
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
337
|
-
|
|
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
|
|
541
|
-
throw new ParakeetError('not_installed', 'the installed FluidAudio CLI no longer matches
|
|
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
|
}
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* checked on Miles's live sessions on 2026-09-28 (a 1:1 task went New, a server bug Continued its server session at
|
|
6
6
|
* 0.91, a hardware-page task Forked the hardware session). A recommendation is advice: nothing is selected or sent.
|
|
7
7
|
*/
|
|
8
|
+
import { type DecisionClient, isDecisionFailure } from './decision-provider.js'
|
|
8
9
|
import { createHash } from 'node:crypto'
|
|
9
|
-
import { estimateJevTokens, JevError
|
|
10
|
+
import { estimateJevTokens, JevError } from './jev.js'
|
|
10
11
|
import { FORKABLE_PROVIDERS } from './agent-session-binding-store.js'
|
|
11
12
|
|
|
12
13
|
export const CONTINUE_AT = 0.5 // P(a session is already doing this exact task)
|
|
@@ -16,7 +17,7 @@ export const RECOMMENDATION_LIMITS = { sessions: 80, title: 120, summary: 240, c
|
|
|
16
17
|
export interface CandidateSession { id: string; provider: string; title: string; summary?: string; updated?: string }
|
|
17
18
|
export interface RecommendationInput { task: { text: string; domain: string; meetings: string[] }; sessions: CandidateSession[] }
|
|
18
19
|
export type Recommendation =
|
|
19
|
-
| { provider: 'jev'; model: string; action: 'continue' | 'fork' | 'new'; sessionId: string | null; confidence: number; reason: string;
|
|
20
|
+
| { provider: 'jev' | 'subq'; model: string; action: 'continue' | 'fork' | 'new'; sessionId: string | null; confidence: number; reason: string;
|
|
20
21
|
alternatives: Array<{ sessionId: string; p: number }>; cached: boolean }
|
|
21
22
|
| { provider: 'none'; reason: string }
|
|
22
23
|
|
|
@@ -41,7 +42,7 @@ function ranked(probabilities: Record<string, number> | undefined, offered: Set<
|
|
|
41
42
|
|
|
42
43
|
/** Pure policy over Jev's two answers. Exported for tests. */
|
|
43
44
|
export function decide(best: Record<string, number>, exact: Record<string, number>, sessions: CandidateSession[]):
|
|
44
|
-
Omit<Extract<Recommendation, { provider: 'jev' }>, 'provider' | 'model' | 'cached'> {
|
|
45
|
+
Omit<Extract<Recommendation, { provider: 'jev' | 'subq' }>, 'provider' | 'model' | 'cached'> {
|
|
45
46
|
const offered = new Set(sessions.map((_, i) => `s${i}`))
|
|
46
47
|
const bestRank = ranked(best, offered), exactRank = ranked(exact, offered)
|
|
47
48
|
const idOf = (key: string) => sessions[Number(key.slice(1))].id
|
|
@@ -78,13 +79,15 @@ export function parseCandidates(raw: unknown): CandidateSession[] | null {
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
export class SessionRecommender {
|
|
81
|
-
private cache = new Map<string, { at: number; value: Extract<Recommendation, { provider: 'jev' }> }>()
|
|
82
|
-
constructor(private readonly jev:
|
|
82
|
+
private cache = new Map<string, { at: number; value: Extract<Recommendation, { provider: 'jev' | 'subq' }> }>()
|
|
83
|
+
constructor(private readonly jev: DecisionClient, private readonly now: () => number = () => Date.now()) {}
|
|
83
84
|
|
|
84
85
|
async recommend(input: RecommendationInput): Promise<Recommendation> {
|
|
85
86
|
const sessions = input.sessions
|
|
86
87
|
if (!sessions.length) return { provider: 'none', reason: 'no_sessions' }
|
|
87
|
-
|
|
88
|
+
let client: DecisionClient
|
|
89
|
+
try { client = this.jev.snapshot?.() ?? this.jev } catch (e) { if (isDecisionFailure(e)) return { provider: 'none', reason: e.code }; throw e }
|
|
90
|
+
const key = createHash('sha256').update((client.provider ?? 'jev') + '\0' + JSON.stringify(input)).digest('hex')
|
|
88
91
|
const hit = this.cache.get(key)
|
|
89
92
|
if (hit && this.now() - hit.at < RECOMMENDATION_LIMITS.cacheMs) return { ...hit.value, cached: true }
|
|
90
93
|
const state = { task: { text: clip(input.task.text, 1200), domain: input.task.domain, meetings: input.task.meetings.map(t => clip(t, 160)).slice(0, 8) },
|
|
@@ -95,15 +98,15 @@ export class SessionRecommender {
|
|
|
95
98
|
exact: { type: 'choice', criteria: { ...options, none: 'No listed session is doing this task.' }, instructions: EXACT },
|
|
96
99
|
}
|
|
97
100
|
try {
|
|
98
|
-
const reply = await
|
|
101
|
+
const reply = await client.ask(state, questions, estimateJevTokens({ state, questions }))
|
|
99
102
|
const best = reply.answers.best?.probabilities, exact = reply.answers.exact?.probabilities
|
|
100
103
|
if (!best || !exact) throw new JevError('jev_bad_answer')
|
|
101
|
-
const value = { provider: 'jev' as const, model: reply.model ?? 'jev', cached: false, ...decide(best, exact, sessions) }
|
|
104
|
+
const value = { provider: reply.provider ?? 'jev' as const, model: reply.model ?? 'jev', cached: false, ...decide(best, exact, sessions) }
|
|
102
105
|
if (this.cache.size >= RECOMMENDATION_LIMITS.cacheEntries) this.cache.delete(this.cache.keys().next().value!)
|
|
103
106
|
this.cache.set(key, { at: this.now(), value })
|
|
104
107
|
return value
|
|
105
108
|
} catch (e) {
|
|
106
|
-
if (e
|
|
109
|
+
if (isDecisionFailure(e)) return { provider: 'none', reason: e.code }
|
|
107
110
|
throw e
|
|
108
111
|
}
|
|
109
112
|
}
|
|
@@ -376,7 +376,7 @@ export function identifySpeaker(
|
|
|
376
376
|
expectedSpeakers?: string[],
|
|
377
377
|
/** 6.61.0: `event` tags the calibration rows, e.g. 'nemotron-track' for one
|
|
378
378
|
* Nemotron track's audio, so they are never read as whole-chunk checks. */
|
|
379
|
-
options: { event?: string; minSimilarity?: number } = {},
|
|
379
|
+
options: { event?: string; minSimilarity?: number; prioritizeOwner?: boolean } = {},
|
|
380
380
|
): { speaker: string; similarity: number; embedding?: Float32Array } | null {
|
|
381
381
|
if (!extractor || !manager) return null
|
|
382
382
|
const minimum = Number.isFinite(options.minSimilarity) ? Math.max(0, Math.min(1, options.minSimilarity!)) : 0
|
|
@@ -387,7 +387,7 @@ export function identifySpeaker(
|
|
|
387
387
|
|
|
388
388
|
// If the wearer is enrolled, verify against them first (they're wearing the glasses)
|
|
389
389
|
const owner = getOwnerSpeakerLabel()
|
|
390
|
-
if (manager.contains(owner)) {
|
|
390
|
+
if (options.prioritizeOwner !== false && manager.contains(owner)) {
|
|
391
391
|
const isOwner = manager.verify({ name: owner, v: embedding, threshold: Math.max(VERIFY_THRESHOLD, minimum) })
|
|
392
392
|
if (isOwner) {
|
|
393
393
|
const similarity = computeCosineSimilarity(embedding, owner)
|
|
@@ -30,6 +30,7 @@ import { resolve } from 'node:path'
|
|
|
30
30
|
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
31
31
|
import { forgetSessionHeartbeat } from './session-heartbeats.js'
|
|
32
32
|
import { classifyStrandedSession, type SessionHeartbeat } from './stranded-sessions.js'
|
|
33
|
+
import { MAC_ASK_SESSION as MAC_CAPTURE } from './ask-session-id.js'
|
|
33
34
|
|
|
34
35
|
/** Title given to a capture the sweeper saves on the user's behalf. */
|
|
35
36
|
export const STRANDED_PROMOTE_TITLE = 'Auto-saved capture'
|
|
@@ -184,6 +185,15 @@ export interface StrandedSweepResult {
|
|
|
184
185
|
* that decides whether a recording ever becomes a meeting cannot be covered by
|
|
185
186
|
* source-shape assertions alone.
|
|
186
187
|
*/
|
|
188
|
+
/**
|
|
189
|
+
* Boot recovery: does a persisted session come back open? Idle past the retention closes it as expired, except a
|
|
190
|
+
* Mac capture, whose audio COS Control still holds and may deliver after this restart (6.73.9, same incident as the
|
|
191
|
+
* sweep guard below).
|
|
192
|
+
*/
|
|
193
|
+
export function restoresAtBoot(sessionId: string, idleMs: number, retentionMs: number): boolean {
|
|
194
|
+
return MAC_CAPTURE.test(sessionId) || idleMs < retentionMs
|
|
195
|
+
}
|
|
196
|
+
|
|
187
197
|
export function sweepStrandedSessions(input: StrandedSweepInput): StrandedSweepResult {
|
|
188
198
|
const result: StrandedSweepResult = { drafted: [], promoted: [], live: [] }
|
|
189
199
|
// Only the process that owns the API can save anything, and only it should be
|
|
@@ -191,6 +201,11 @@ export function sweepStrandedSessions(input: StrandedSweepInput): StrandedSweepR
|
|
|
191
201
|
// imported the module, so the pass does nothing rather than half-acting.
|
|
192
202
|
if (!input.token) return result
|
|
193
203
|
for (const [sessionId, session] of input.sessions) {
|
|
204
|
+
// 6.73.9: a Mac capture belongs to COS Control, which keeps every chunk on the Mac and delivers or recovers it.
|
|
205
|
+
// The unattended save cannot carry the server pin a Mac save requires, so it got a 400, read that as final and
|
|
206
|
+
// closed the session as expired: a capture stuck at 51 of 1,200 chunks (2026-10-15) would have lost its
|
|
207
|
+
// server-side recovery at the 4 h mark. Never draft or promote one.
|
|
208
|
+
if (MAC_CAPTURE.test(sessionId)) { result.live.push(sessionId); continue }
|
|
194
209
|
const verdict = classifyStrandedSession(
|
|
195
210
|
{ lastActivityAt: session.lastActivityAt, heartbeat: input.getHeartbeat(sessionId) },
|
|
196
211
|
input.now,
|
|
@@ -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:
|
|
103
|
-
|
|
104
|
-
const
|
|
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
|
-
?
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
: '
|
|
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
|
-
|
|
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'
|