@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/package.json +1 -1
  3. package/server/index.ts +5 -0
  4. package/server/lib/ask-session-id.ts +5 -0
  5. package/server/lib/decision-provider.ts +163 -0
  6. package/server/lib/decision-settings.ts +72 -0
  7. package/server/lib/diarizer-backend.ts +24 -2
  8. package/server/lib/dictation-benchmark.ts +197 -0
  9. package/server/lib/dictation-stt.ts +61 -2
  10. package/server/lib/glossary-learn.ts +221 -0
  11. package/server/lib/imported-library-rows.ts +62 -4
  12. package/server/lib/jev.ts +3 -3
  13. package/server/lib/lens-gist-engines.ts +7 -2
  14. package/server/lib/live-cos-ask.ts +2 -1
  15. package/server/lib/mac-recording-asr.ts +59 -0
  16. package/server/lib/mac-recording.ts +111 -0
  17. package/server/lib/meeting-actions.ts +1 -0
  18. package/server/lib/meeting-ask-follow-up.ts +2 -2
  19. package/server/lib/meeting-ask-runner.ts +26 -5
  20. package/server/lib/meeting-ask-store.ts +2 -1
  21. package/server/lib/meeting-batch-transcribe.ts +19 -4
  22. package/server/lib/meeting-context-keys.ts +7 -4
  23. package/server/lib/meeting-parse.ts +2 -0
  24. package/server/lib/meeting-store.ts +62 -9
  25. package/server/lib/parakeet-cli-trust.ts +262 -0
  26. package/server/lib/parakeet.ts +156 -29
  27. package/server/lib/session-recommendation.ts +12 -9
  28. package/server/lib/speaker-embeddings.ts +2 -2
  29. package/server/lib/stranded-session-actions.ts +15 -0
  30. package/server/lib/stt-runtime.ts +43 -11
  31. package/server/lib/stt-settings.ts +88 -3
  32. package/server/lib/subq-key.ts +38 -0
  33. package/server/lib/transcribe-audio.ts +87 -7
  34. package/server/lib/work-activity.ts +2 -2
  35. package/server/lib/work-chat.ts +113 -13
  36. package/server/lib/work-completion.ts +11 -8
  37. package/server/lib/work-intake-store.ts +2 -1
  38. package/server/lib/work-search.ts +32 -12
  39. package/server/routes/client-instance.ts +1 -1
  40. package/server/routes/decision-provider.ts +36 -0
  41. package/server/routes/glossary.ts +143 -11
  42. package/server/routes/jev.ts +3 -2
  43. package/server/routes/meeting.ts +44 -12
  44. package/server/routes/meetings.ts +42 -4
  45. package/server/routes/stt-settings.ts +55 -4
  46. package/server/routes/subq-key.ts +27 -0
  47. package/server/routes/transcribe-stream.ts +115 -9
  48. package/server/routes/transcribe.ts +74 -6
  49. package/server/routes/work-chat.ts +10 -4
  50. package/server/routes/work-search.ts +2 -2
package/CHANGELOG.md CHANGED
@@ -1,3 +1,86 @@
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
+
22
+ ## 6.73.14 — Saved Mac recordings appear in Meetings; Work chat keeps a plan with a stray item (local candidate)
23
+
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.
25
+ - QA before release: the store matches a Mac session from the sidecar's 4 KB head before it opens any markdown, so a list of Mac rows no longer reads the store's G2 recordings (617 files, about 16 MB, on every Control poll, including `?day=` every 3 s during a hand-off); a `day` with no `month` reads only that month's folder. Store rows carry their start `time` ("HH:MM", from the Time line, else the Date line, else the filename), so a Mac recording sorts by its time among the same day's operations rows instead of at 00:00, where the 20-row list could cut it. A Mac recording whose session an operations row or a merged scribe already holds is left out of the day dots as it is of the rows, so an operations copy counts once.
26
+ - Not changed: Mac recordings still skip the operations pipeline (search, vector and graph index, Work intake). That is a product decision, not this fix.
27
+ - Work chat cleans the planner's `contextItemIDs` instead of refusing the plan: unknown ids drop, repeats keep once, anything that is not a list of strings sends every item, and so does a list none of whose ids Control supplied; an empty list sends the fixed three. The count of unknown ids is logged (`[work-chat]`), never the ids. One stray id used to discard a whole high-effort plan (QA 2026-10-15).
28
+ - Pair with Control 0.5.313. Transcription-candidate and cue flags stay disabled. No public publication is implied.
29
+
30
+ ## 6.73.13 — Work chat picks each assignment's context (local candidate)
31
+
32
+ - Work chat input accepts optional `items` ({id, kind, label, estTokens}; ids and estimates only, never contents; at most 40; your request, the task and COS instructions required). The planner returns `contextItemIDs` per assignment, a subset of the supplied ids; the fixed three are always added and Control's order is kept; omitted means every item. GET `/api/work/chat` advertises `contextItems: true` and `maxContextItems: 40`. The planner is asked for the smallest set each assignment needs.
33
+ - Work chat's output budget rises from 4,086 to 4,946 tokens so three full selections fit (derived in lib/work-chat.ts).
34
+ - Pair with Control 0.5.312 (Work Context Meter, Plan and send). Transcription-candidate and cue flags stay disabled. No public publication is implied.
35
+
36
+ ## 6.73.12 — Atomic dictionary edits and Work chat planner effort (local candidate)
37
+
38
+ - Work chat plans at a chosen effort: `low`, `medium` or `high` (default `high`, Miles 2026-10-15). GET `/api/work/chat` advertises `efforts`; the answer reports the effort used. Anything else is a 400 before any model call. Higher effort raises cost per plan; still one call per Plan click.
39
+ - Work chat now thinks at medium and high (6,000 and 16,000 thinking tokens) with an output budget derived from its largest valid plan (4,086 tokens) and a 180-second deadline. Before, every plan ran with thinking off and a 1,400-token cap that could cut a full plan into a 503. Lens gist and Meeting Ask keep exactly their old settings (1,400 / 0 / 45 s, low).
40
+
41
+ - Add `POST /api/transcription-glossary/vocabulary/add` `{term}`, `/vocabulary/remove` `{term}` and `/correction/remove` `{heard}`. Each changes one item inside the same write chain as PUT and learn, so COS Control's Dictionary pane no longer reads the whole dictionary and writes it back (a Fix a word landing in between could be lost). Same limits as learn: 500 terms, 100 UTF-16 units, no URL, email or path in the vocabulary. Hand edits are not logged for undo.
42
+ - Pair with Control 0.5.310. Older servers keep reading and adding corrections from Control; single-word edits, Fix a word and Undo need 6.73.12. Transcription-candidate and cue flags stay disabled. No public publication is implied.
43
+
44
+ ## 6.73.11 — Fix a word: the dictionary learns (local candidate)
45
+
46
+ - Add `POST /api/transcription-glossary/correction` `{heard, written, source}`: learns ONE correction and adds the spelling to the vocabulary in one write, serialized with PUT. A case variant of an existing key is replaced, not duplicated. Same limits as PUT: 500 terms, 100 characters, no URL, email or path spellings (they would join the decoder prompt). `source` is `dictation`, `meeting`, `g2` or `manual`.
47
+ - Add `GET /api/transcription-glossary/recent`: what COS learned, newest first, with source and time. The log lives in the data dir (`transcription-corrections.json`, last 200, atomic, 0600); the dictionary stays in `.cos-profile.json`.
48
+ - Add `POST /api/transcription-glossary/correction/undo` `{id}`: reverts exactly that entry, puts back a replaced case variant, and removes the spelling only if no remaining correction writes it. Refuses with 409 when a newer entry owns the word or it was edited since.
49
+ - Live, meeting and dictation transcription pick a learned word up on the next sentence (decoder and vocab-echo caches are cleared). Pair with Control 0.5.309. 6.73.10 is the Work chat candidate on its own branch. Transcription-candidate and cue flags stay disabled. No public publication is implied.
50
+
51
+ ## 6.73.10 — Choose the Work chat planner (local candidate)
52
+
53
+ - Accept an optional `planner` (`sonnet` or `opus`) on `POST /api/work/chat` and run that Claude model, still tools-off. Anything else is a 400 before any model call. Clients that send none keep Sonnet. `GET /api/work/chat` now advertises `planners: ["sonnet","opus"]` and keeps `planner: "sonnet"` as the default; the answer's `model` names what ran. The choice selects the model only and never reaches the prompt.
54
+ - Cost: still one model call per user action, serialized by the existing busy lock (a second request gets 409). Opus costs more per call than Sonnet; nothing new runs on a schedule.
55
+ - Planning still never dispatches. Ships with Control 0.5.309, which reviews every planned assignment before anything is sent. Transcription-candidate and cue flags stay disabled. No public publication is implied.
56
+
57
+ ## 6.73.9 — Keep stalled Mac captures recoverable (local candidate)
58
+
59
+ - Never auto-save or expire a Mac capture (`mac_<uuid>`). COS Control keeps every chunk on the Mac and delivers or recovers it. The 4 h stranded sweep posted the save without the server pin a Mac save requires, read the 400 as final and closed the session; boot recovery closed any session idle past 4 h the same way. A capture stalled at 51 of 1,200 chunks (2026-10-15) would have lost its server-side recovery at 20:12. Both paths now leave Mac captures open (`restoresAtBoot`, sweep guard).
60
+ - Note for 6.73.6 to 6.73.8: the decision provider switch (Jev or SubQ) for Work search, session suggestions and completion checks, with authenticated `/api/decision-provider` and `/api/subq-key` routes, a private 0600 SubQ key file, and `COS_SUBQ_DAILY_TOKENS` (default 1M) capping each workflow's daily usage separately. SubQ sends the decision prompt to SubQ's API. Meeting intake (`work_intake.py`) stays on Jev.
61
+ - Known limitation: in a saved Mac meeting's full-quality Parakeet pass, a window with no words over detected speech still abandons that pass; the meeting saves with its live transcript.
62
+ - Pair with Control 0.5.307. Transcription-candidate and cue flags stay disabled. No public publication is implied.
63
+
64
+ ## 6.73.8 — Live Ask on Mac recordings (local candidate)
65
+
66
+ - Accept Mac capture ids (`mac_<uuid>`, the id the Mac recording route issues) in live COS Ask, Ask history, files and orientation, and Work intake Ask evidence. Every Ask on a Mac recording failed with `invalid_session_id`; found in a live meeting 2026-10-15. One shared pattern in `lib/ask-session-id.ts`; traversal-shaped and upper-case ids stay refused.
67
+ - Record Parakeet's no-words answer over detected speech on a Mac chunk as an empty chunk with a `[mac-asr]` warning, instead of a retryable 503. Replays of the same audio failed forever and froze every later chunk of a live meeting (2026-10-15, chunk 51). Real Parakeet failures stay retryable with no Whisper fallback; the saved meeting still re-transcribes the full audio with strict Parakeet.
68
+ - Pair with Control 0.5.306. Transcription-candidate and cue flags stay disabled. No public publication is implied.
69
+
70
+ ## 6.73.7 — Strict local desktop dictation (local candidate)
71
+
72
+ - Advertise the desktop dictation capability before microphone audio is transferred.
73
+ - Honor an explicit Parakeet Ultra requirement through the existing COS dictionary and vocabulary pipeline; return a retryable error rather than substituting Whisper or cloud transcription.
74
+ - Bound streamed desktop audio to two minutes of canonical PCM16 WAV data. Preserve legacy transcription behavior for callers without the engine requirement.
75
+ - Pair with Control 0.5.303 for microphone dictation and shared Mac live meeting views. Retain the recording/onboarding/SubQ changes from 6.73.6 and keep transcription-candidate/cue flags disabled. No public publication is implied.
76
+
77
+ ## 6.73.6 — Manual Mac recording transport (local candidate)
78
+
79
+ - Register selected-app Mac system-audio capture with immutable source identity and high-confidence speaker policy before admitting audio.
80
+ - Persist canonical PCM chunk receipts, exact timing and capture provenance through retry, save and meeting context. Reject missing chunks, conflicting retries and source substitution.
81
+ - Keep Mac playback out of automatic G2/Fireflies merges and voice training. Glasses are optional for the matched Control 0.5.301 pilot.
82
+ - Preserve transcription settings and disabled transcription-candidate/cue flags. Signed-host capture and permission acceptance remain local installation gates; this entry does not indicate public availability.
83
+
1
84
  ## 6.73.5 — Tasks from saved meetings (local candidate)
2
85
 
3
86
  - Add authenticated, bounded creation of a task and its exact source meeting in one canonical locked write.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.73.5",
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": {
package/server/index.ts CHANGED
@@ -148,6 +148,8 @@ import { createWorkReviewsRouter } from './routes/work-reviews.js'
148
148
  import { createWorkBoardRouter } from './routes/work-board.js'
149
149
  import { createWorkIntakeRouter } from './routes/work-intake.js'
150
150
  import { createWorkHandoffRequestsRouter } from './routes/work-handoff-requests.js'
151
+ import { createSubqKeyRouter } from './routes/subq-key.js'
152
+ import { createDecisionProviderRouter } from './routes/decision-provider.js'
151
153
  import { createJevRouter } from './routes/jev.js'
152
154
  import { createWorkChatRouter } from './routes/work-chat.js'
153
155
  import { createWorkSearchRouter } from './routes/work-search.js'
@@ -384,6 +386,7 @@ app.use('/api', (req, res, next) => {
384
386
  || req.path === '/diagnostics/provider-proof'
385
387
  || req.path === '/transcribe'
386
388
  || req.path.startsWith('/transcribe-stream')
389
+ || req.path.startsWith('/mac-recording/')
387
390
  || req.path === '/meeting/save'
388
391
  // The import run answers 202 and keeps working. It takes its own
389
392
  // maintenance lease per page and checks admissions itself, so the global
@@ -798,6 +801,8 @@ app.use('/api', createWorkIntakeRouter({ store: workIntakeStore }))
798
801
  // (6.57.1), and the Work completion check (6.58.0).
799
802
  app.use('/api', createJevRouter(workReviewRuntime ? { review: async id => workReviewRuntime.peek(id) } : {}))
800
803
  // Work search by meaning (6.65.0, Control 0.5.259): one Jev Choice over the board's cards, with its own cap, switch and breaker.
804
+ app.use('/api', createDecisionProviderRouter())
805
+ app.use('/api', createSubqKeyRouter())
801
806
  app.use('/api', createWorkSearchRouter())
802
807
  app.use('/api', createWorkChatRouter())
803
808
  app.use('/api', queryRouter)
@@ -0,0 +1,5 @@
1
+ // Which recordings may open live Ask, its history and Work evidence. 6.73.8: a Mac capture
2
+ // (mac_<uuid>, lib/mac-recording.ts MAC_CAPTURE_ID) uses the same Ask as a G2 meeting; before this
3
+ // every Mac request and saved-question read failed with invalid_session_id.
4
+ export const MAC_ASK_SESSION = /^mac_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
5
+ export const ASK_SESSION = /^(?:meeting_[A-Za-z0-9_-]{1,89}|mac_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/
@@ -0,0 +1,163 @@
1
+ /** Explicit classifier routing. No retry or cross-provider fallback; generative engines do not use this seam. */
2
+ import { lstatSync, mkdirSync, readFileSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+ import { JevClient, JevError, type JevAnswers } from './jev.js'
5
+ import { dataPath } from './data-dir.js'
6
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
7
+ import { redactSecretText } from './activity-preview.js'
8
+ import { secureExistingPrivateFile } from './secure-user-config.js'
9
+
10
+ import { resolveSubqKey, SUBQ_MODEL } from './subq-key.js'
11
+ export { SUBQ_MODEL } from './subq-key.js'
12
+ import { resolveDecisionSelection, type DecisionProvider, type DecisionUseCase } from './decision-settings.js'
13
+ export type { DecisionProvider, DecisionUseCase } from './decision-settings.js'
14
+ export interface DecisionImage { mediaType: 'image/png' | 'image/jpeg' | 'image/webp'; base64: string }
15
+ export interface DecisionOptions { signal?: AbortSignal; images?: DecisionImage[] }
16
+ export interface DecisionClient { readonly provider?: DecisionProvider; snapshot?: () => DecisionClient; ask(state: unknown, questions: Record<string, unknown>, estimate: number, options?: DecisionOptions): Promise<JevAnswers> }
17
+ export class DecisionProviderError extends Error {
18
+ constructor(readonly code: 'decision_invalid_provider' | 'decision_not_configured' | 'decision_cap_reached' | 'decision_breaker_open' |
19
+ 'decision_key_rejected' | 'decision_credits_required' | 'decision_rate_limited' | 'decision_request_rejected' | 'decision_unavailable' |
20
+ 'decision_bad_answer' | 'decision_cancelled' | 'decision_images_unsupported' | 'decision_usage_unavailable') { super(code) }
21
+ }
22
+ export const SUBQ_ENDPOINT = 'https://api.subq.ai/v1/systemone'
23
+ export const SUBQ_LIMITS = { timeoutMs: 20_000, dailyTokens: 1_000_000, breakerFailures: 3, breakerMs: 3_600_000, maxBodyBytes: 13 * 1024 * 1024 } as const
24
+
25
+ /** No key probing, and no unknown value silently treated as Jev. Per-use-case settings override the global switch. */
26
+ export function selectedDecisionProvider(useCase: DecisionUseCase, env: NodeJS.ProcessEnv = process.env): DecisionProvider {
27
+ try { return resolveDecisionSelection(useCase, env).provider } catch { throw new DecisionProviderError('decision_invalid_provider') }
28
+ }
29
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
30
+ const probability = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1
31
+ function clean(value: unknown): unknown {
32
+ if (typeof value === 'string') return redactSecretText(value).replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]')
33
+ .replace(/\b\d{3}[-. ]\d{3}[-. ]\d{4}\b/g, '[phone]')
34
+ if (Array.isArray(value)) return value.map(clean)
35
+ if (isRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, v]) => [String(clean(key)), clean(v)]))
36
+ return value
37
+ }
38
+ /** Validate locally. Image bytes are never logged, persisted, fetched by URL, or inferred from a user's screen. */
39
+ function requestBody(state: unknown, questions: Record<string, unknown>, images: DecisionImage[] = []): string {
40
+ const entries = Object.entries(questions)
41
+ if (!entries.length || entries.length > 16 || entries.some(([k, q]) => !k || k.length > 100 || !isRecord(q) || typeof q.instructions !== 'string' ||
42
+ !['noul', 'choice', 'score'].includes(String(q.type)) || (q.type === 'choice' && (!isRecord(q.criteria) || Object.keys(q.criteria).length < 2 || Object.keys(q.criteria).length > 255)) ||
43
+ (q.type === 'score' && (!Array.isArray(q.criteria) || q.criteria.length < 2 || q.criteria.length > 10)))) throw new DecisionProviderError('decision_request_rejected')
44
+ let total = 0
45
+ if (images.length > 4) throw new DecisionProviderError('decision_request_rejected')
46
+ for (const image of images) {
47
+ if (!['image/png', 'image/jpeg', 'image/webp'].includes(image.mediaType) || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(image.base64)) throw new DecisionProviderError('decision_request_rejected')
48
+ const bytes = Buffer.from(image.base64, 'base64'); total += bytes.length
49
+ const valid = image.mediaType === 'image/png' ? bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')) :
50
+ image.mediaType === 'image/jpeg' ? bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 : bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP'
51
+ if (!valid || bytes.length > 4 * 1024 * 1024 || total > 8 * 1024 * 1024) throw new DecisionProviderError('decision_request_rejected')
52
+ }
53
+ const body = JSON.stringify({ model: SUBQ_MODEL, state: clean(state), questions: clean(questions), ...(images.length ? { images } : {}) })
54
+ if (Buffer.byteLength(body) > SUBQ_LIMITS.maxBodyBytes) throw new DecisionProviderError('decision_request_rejected')
55
+ return body
56
+ }
57
+
58
+ /** Strict schema at the provider boundary, before any workflow threshold can turn it into advice. */
59
+ export function validateDecisionAnswers(raw: unknown, questions: Record<string, unknown>): JevAnswers['answers'] {
60
+ if (!isRecord(raw) || Object.keys(raw).length !== Object.keys(questions).length) throw new DecisionProviderError('decision_bad_answer')
61
+ for (const [name, spec] of Object.entries(questions)) {
62
+ const q = spec as Record<string, unknown>, a = raw[name]
63
+ if (!isRecord(a) || a.type !== q.type) throw new DecisionProviderError('decision_bad_answer')
64
+ if (q.type === 'noul') { if (!probability(a.noul)) throw new DecisionProviderError('decision_bad_answer'); continue }
65
+ const criteria = q.criteria as Record<string, unknown> | unknown[]
66
+ const labels = Object.keys(criteria), probs = a.probabilities
67
+ if (!isRecord(probs) || Object.keys(probs).length !== labels.length || labels.some(k => !probability(probs[k])) ||
68
+ Math.abs(Object.values(probs).reduce<number>((sum, p) => sum + Number(p), 0) - 1) > 0.02 || !probability(a.confidence)) throw new DecisionProviderError('decision_bad_answer')
69
+ if (q.type === 'choice' && (typeof a.choice !== 'string' || !labels.includes(a.choice))) throw new DecisionProviderError('decision_bad_answer')
70
+ if (q.type === 'score' && (typeof a.score !== 'number' || !Number.isFinite(a.score) || a.score < 0 || a.score > labels.length - 1 || !isRecord(a.legend) || labels.some(k => !(k in (a.legend as Record<string, unknown>))))) throw new DecisionProviderError('decision_bad_answer')
71
+ }
72
+ return raw as JevAnswers['answers']
73
+ }
74
+
75
+ export class SubqDecisionClient implements DecisionClient {
76
+ readonly provider = 'subq' as const
77
+ private failures = 0
78
+ private breakerUntil = 0
79
+ private reserved = 0
80
+ constructor(private readonly fetchImpl: typeof fetch = fetch, private readonly now: () => Date = () => new Date(),
81
+ private readonly usageFile = dataPath('subq-decisions-usage.json'), private readonly key = () => resolveSubqKey()?.key,
82
+ private readonly cap = () => { const n = Number(process.env.COS_SUBQ_DAILY_TOKENS); return Number.isFinite(n) && n > 0 ? Math.floor(n) : SUBQ_LIMITS.dailyTokens },
83
+ private readonly timeoutMs: number = SUBQ_LIMITS.timeoutMs) {}
84
+ resetForNewKey(): void { this.failures = 0; this.breakerUntil = 0 }
85
+ private ledger(): Record<string, number> {
86
+ try {
87
+ const stat = lstatSync(this.usageFile)
88
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('unsafe')
89
+ } catch (e) {
90
+ if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT') return {}
91
+ throw new DecisionProviderError('decision_usage_unavailable')
92
+ }
93
+ try {
94
+ secureExistingPrivateFile(this.usageFile)
95
+ const value: unknown = JSON.parse(readFileSync(this.usageFile, 'utf8'))
96
+ if (!isRecord(value) || Object.entries(value).some(([day, count]) => !/^\d{4}-\d{2}-\d{2}$/.test(day) || typeof count !== 'number' || !Number.isFinite(count) || count < 0 || count > Number.MAX_SAFE_INTEGER)) throw new Error('invalid')
97
+ return value as Record<string, number>
98
+ } catch { throw new DecisionProviderError('decision_usage_unavailable') }
99
+ }
100
+ private day(): string { return this.now().toISOString().slice(0, 10) }
101
+ private record(tokens: number): void {
102
+ try {
103
+ const ledger = this.ledger(); ledger[this.day()] = (ledger[this.day()] ?? 0) + tokens
104
+ mkdirSync(dirname(this.usageFile), { recursive: true, mode: 0o700 })
105
+ durableAtomicWriteFileSync(this.usageFile, JSON.stringify(ledger) + '\n', { mode: 0o600 })
106
+ } catch { throw new DecisionProviderError('decision_usage_unavailable') }
107
+ }
108
+ private fail(code: DecisionProviderError['code'], breaker = false): never {
109
+ if (breaker && ++this.failures >= SUBQ_LIMITS.breakerFailures) { this.breakerUntil = this.now().getTime() + SUBQ_LIMITS.breakerMs; this.failures = 0 }
110
+ throw new DecisionProviderError(code)
111
+ }
112
+ async ask(state: unknown, questions: Record<string, unknown>, _estimate: number, options: DecisionOptions = {}): Promise<JevAnswers> {
113
+ if (options.signal?.aborted) return this.fail('decision_cancelled')
114
+ const key = this.key(); if (!key) return this.fail('decision_not_configured')
115
+ if (this.breakerUntil > this.now().getTime()) return this.fail('decision_breaker_open')
116
+ const body = requestBody(state, questions, options.images)
117
+ // Reserve a conservative byte-based estimate, including encoded images, before concurrent requests can enter.
118
+ const estimate = Buffer.byteLength(body) + 1
119
+ if ((Number(this.ledger()[this.day()]) || 0) + this.reserved + estimate > this.cap()) return this.fail('decision_cap_reached')
120
+ this.reserved += estimate
121
+ try {
122
+ let res: Response
123
+ try { res = await this.fetchImpl(SUBQ_ENDPOINT, { method: 'POST', redirect: 'error', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
124
+ body, signal: AbortSignal.any([AbortSignal.timeout(this.timeoutMs), ...(options.signal ? [options.signal] : [])]) }) }
125
+ catch { this.record(estimate); return this.fail(options.signal?.aborted ? 'decision_cancelled' : 'decision_unavailable', !options.signal?.aborted) }
126
+ if (res.status === 401 || res.status === 403) return this.fail('decision_key_rejected')
127
+ if (res.status === 402) return this.fail('decision_credits_required')
128
+ if (res.status === 429) return this.fail('decision_rate_limited')
129
+ if ([400, 413, 422].includes(res.status)) return this.fail('decision_request_rejected')
130
+ if (!res.ok) return this.fail('decision_unavailable', true)
131
+ // Count conservative usage even when a successful response is unreadable. No raw provider error is echoed.
132
+ let raw: unknown
133
+ try { raw = await res.json() } catch { this.record(estimate); return this.fail('decision_bad_answer', true) }
134
+ const used = isRecord(raw) && isRecord(raw.usage) && typeof raw.usage.input_tokens === 'number' && Number.isFinite(raw.usage.input_tokens) && raw.usage.input_tokens > 0 ? raw.usage.input_tokens : estimate
135
+ this.record(used)
136
+ if (!isRecord(raw) || raw.model !== SUBQ_MODEL) return this.fail('decision_bad_answer', true)
137
+ let answers: JevAnswers['answers']
138
+ try { answers = validateDecisionAnswers(raw.answers, questions) } catch { return this.fail('decision_bad_answer', true) }
139
+ this.failures = 0
140
+ return { provider: 'subq', answers, model: SUBQ_MODEL, inputTokens: used }
141
+ } finally { this.reserved -= estimate }
142
+ }
143
+ }
144
+
145
+ const configuredSubqClients = new Set<SubqDecisionClient>()
146
+ export function resetSubqDecisionClients(): void { configuredSubqClients.forEach(client => client.resetForNewKey()) }
147
+ /** Snapshot selection per workflow request. Separate clients and cache identities prevent stale cross-provider answers. */
148
+ export function createDecisionClient(useCase: DecisionUseCase, jev: Pick<JevClient, 'ask'> = new JevClient(), env: NodeJS.ProcessEnv = process.env): DecisionClient {
149
+ const clients: Partial<Record<DecisionProvider, DecisionClient>> = {}
150
+ const snapshot = (): DecisionClient => {
151
+ const selected = selectedDecisionProvider(useCase, env)
152
+ if (!clients[selected]) clients[selected] = selected === 'subq' ? new SubqDecisionClient(fetch, () => new Date(), dataPath(`subq-${useCase}-usage.json`)) : {
153
+ provider: 'jev', async ask(state, questions, estimate, options) {
154
+ if (options?.images?.length) throw new DecisionProviderError('decision_images_unsupported')
155
+ return { ...await jev.ask(state, questions, estimate, options), provider: 'jev' }
156
+ },
157
+ }
158
+ if (selected === 'subq') configuredSubqClients.add(clients[selected] as SubqDecisionClient)
159
+ return clients[selected]!
160
+ }
161
+ return { snapshot, ask: (state, questions, estimate, options) => snapshot().ask(state, questions, estimate, options) }
162
+ }
163
+ export function isDecisionFailure(error: unknown): error is JevError | DecisionProviderError { return error instanceof JevError || error instanceof DecisionProviderError }
@@ -0,0 +1,72 @@
1
+ /** Private, durable classifier selection. No key storage or account/network changes. */
2
+ import { createHash } from 'node:crypto'
3
+ import { lstatSync, readFileSync } from 'node:fs'
4
+ import { dirname } from 'node:path'
5
+ import { dataPath } from './data-dir.js'
6
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
7
+ import { secureExistingPrivateFile, securePrivateDirectory } from './secure-user-config.js'
8
+
9
+ export type DecisionProvider = 'jev' | 'subq'
10
+ export const DECISION_WORKFLOWS = ['session', 'completion', 'search'] as const
11
+ export type DecisionUseCase = typeof DECISION_WORKFLOWS[number]
12
+ export interface DecisionSettings { schema: 1; provider?: DecisionProvider; overrides: Partial<Record<DecisionUseCase, DecisionProvider>> }
13
+ export class DecisionSettingsError extends Error {
14
+ constructor(readonly code: 'invalid_decision_settings' | 'decision_settings_unavailable' | 'decision_invalid_provider' | 'decision_settings_conflict') { super(code) }
15
+ }
16
+ const record = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
17
+ const provider = (v: unknown): v is DecisionProvider => v === 'jev' || v === 'subq'
18
+ const settingsFile = () => dataPath('decision-provider.json')
19
+ export function readDecisionSettings(file = settingsFile()): DecisionSettings {
20
+ // Only an absent directory entry means defaults. A dangling symlink or a file
21
+ // disappearing after this check must not silently change the selected provider.
22
+ try {
23
+ const stat = lstatSync(file)
24
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('unsafe')
25
+ } catch (e) {
26
+ if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT') return { schema: 1, overrides: {} }
27
+ throw new DecisionSettingsError('decision_settings_unavailable')
28
+ }
29
+ try {
30
+ secureExistingPrivateFile(file)
31
+ const value = JSON.parse(readFileSync(file, 'utf8'))
32
+ if (!record(value) || value.schema !== 1 || !record(value.overrides) || Object.keys(value).some(k => !['schema','provider','overrides'].includes(k)) ||
33
+ (value.provider !== undefined && !provider(value.provider)) || Object.entries(value.overrides).some(([key,v]) => !DECISION_WORKFLOWS.includes(key as DecisionUseCase) || !provider(v))) throw new Error('invalid')
34
+ return value as unknown as DecisionSettings
35
+ } catch {
36
+ throw new DecisionSettingsError('decision_settings_unavailable')
37
+ }
38
+ }
39
+ export function decisionSettingsRevision(settings: DecisionSettings): string {
40
+ return createHash('sha256').update(JSON.stringify([settings.provider ?? null, ...DECISION_WORKFLOWS.map(w => settings.overrides[w] ?? null)])).digest('hex')
41
+ }
42
+ export function patchDecisionSettings(current: DecisionSettings, raw: unknown): DecisionSettings {
43
+ if (!record(raw) || !Object.keys(raw).length || Object.keys(raw).some(k => !['provider','overrides','expectedRevision'].includes(k)) || typeof raw.expectedRevision !== 'string' || !/^[a-f0-9]{64}$/.test(raw.expectedRevision) || (!('provider' in raw) && !('overrides' in raw)) ||
44
+ ('provider' in raw && raw.provider !== null && !provider(raw.provider)) ||
45
+ ('overrides' in raw && (!record(raw.overrides) || !Object.keys(raw.overrides).length || Object.entries(raw.overrides).some(([k,v]) => !DECISION_WORKFLOWS.includes(k as DecisionUseCase) || (v !== null && !provider(v)))))) {
46
+ throw new DecisionSettingsError('invalid_decision_settings')
47
+ }
48
+ if (raw.expectedRevision !== decisionSettingsRevision(current)) throw new DecisionSettingsError('decision_settings_conflict')
49
+ const next: DecisionSettings = { ...current, overrides: { ...current.overrides } }
50
+ if ('provider' in raw) { if (raw.provider === null) delete next.provider; else next.provider = raw.provider as DecisionProvider }
51
+ if (record(raw.overrides)) for (const [key,value] of Object.entries(raw.overrides)) {
52
+ if (value === null) delete next.overrides[key as DecisionUseCase]; else next.overrides[key as DecisionUseCase] = value as DecisionProvider
53
+ }
54
+ return next
55
+ }
56
+ export function writeDecisionSettings(next: DecisionSettings, file = settingsFile()): void {
57
+ securePrivateDirectory(dirname(file)); secureExistingPrivateFile(file)
58
+ durableAtomicWriteFileSync(file, JSON.stringify(next) + '\n', { mode: 0o600 })
59
+ }
60
+ export function resolveDecisionSelection(useCase: DecisionUseCase, env: NodeJS.ProcessEnv = process.env, settings = readDecisionSettings()): { provider: DecisionProvider; source: 'config' | 'env' | 'default' } {
61
+ const workflowEnv = env[`COS_DECISION_PROVIDER_${useCase.toUpperCase()}`]
62
+ const choice = settings.overrides[useCase] ?? workflowEnv ?? settings.provider ?? env.COS_DECISION_PROVIDER ?? 'jev'
63
+ const selected = choice.trim().toLowerCase()
64
+ if (!provider(selected)) throw new DecisionSettingsError('decision_invalid_provider')
65
+ return { provider: selected, source: settings.overrides[useCase] !== undefined ? 'config' : workflowEnv !== undefined ? 'env' : settings.provider !== undefined ? 'config' : env.COS_DECISION_PROVIDER !== undefined ? 'env' : 'default' }
66
+ }
67
+ export function decisionSettingsView(settings: DecisionSettings, configured: Record<DecisionProvider, boolean>, env: NodeJS.ProcessEnv = process.env) {
68
+ const global = resolveDecisionSelection('session', { COS_DECISION_PROVIDER: env.COS_DECISION_PROVIDER }, { schema: 1, provider: settings.provider, overrides: {} })
69
+ const selections = Object.fromEntries(DECISION_WORKFLOWS.map(w => [w, resolveDecisionSelection(w, env, settings)])) as Record<DecisionUseCase, ReturnType<typeof resolveDecisionSelection>>
70
+ return { schema: 1 as const, revision: decisionSettingsRevision(settings), provider: global.provider, overrides: settings.overrides, effective: Object.fromEntries(DECISION_WORKFLOWS.map(w => [w,selections[w].provider])),
71
+ sources: Object.fromEntries(DECISION_WORKFLOWS.map(w => [w,selections[w].source])), configured, saved: { provider: settings.provider ?? null, overrides: settings.overrides } }
72
+ }
@@ -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
+ }