@gotcos/glasses-server 6.73.14 → 6.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,36 @@
1
+ ## 6.75.0 - Live cues v2 MVP: Jev gate, rank-fusion memory, one writer (candidate, default off)
2
+
3
+ - New live cues pipeline behind `COS_LIVE_CUES_V2=1` (default off; independent of the v1 `COS_LIVE_CUES` flag, so switching v2 off never re-enables the v1 Composer path): a Jev gate decides whether a moment deserves a cue (P(cue) >= `COS_LIVE_CUES_V2_GATE`, default 0.8), `cue_memory.py` in COS scripts ranks past meetings by rank fusion (BM25 first, then Qdrant dense inside a 3 s budget; no LLM), a Jev judge keeps at most two notes about the same client or project (P >= 0.8), and ONE tools-off Claude call writes the cue (`COS_LIVE_CUES_V2_MODEL`, default Sonnet 5.5; writer rules v3, `COS_LIVE_CUES_V2_PROMPT=v2` restores v2). Prompt strings are generated from the replay sources (`cue_rrf_replay/generate_server_prompts.py`) and pinned by sha256 in the tests.
4
+ - Evidence, and the configuration it was measured in: held-out replay (112 windows, labels frozen before outputs) at gate 0.6 with writer v2: useful on required moments 26/29, unwanted 6/44, p50 7.2 s. The shipped defaults differ: gate 0.8 (pooled dev + held-out: 110/113 silent windows kept silent, 51/59 required admitted) and writer v3 (on the held-out windows after the fact, so development data: unsupported claims 18 to 11). Real-path latency through the server (Jev, `cue_memory.py`, Sonnet): 8.4 to 17.5 s per cue, the first call cold.
5
+ - New SSE event `coaching_nudge_v2` (`shared/live-cues-v2.ts`: cueId, headline <= 64, detail <= 220, kind, sources with stable meeting ids, gate, model), emitted before its v1 `coaching_nudge` twin (headline only) for old companions; every v2 cue is checked against the shared validator before it is emitted. Ticketless subscribers receive neither.
6
+ - Routes: `GET /api/live-cues/status?sessionId=` (per-meeting `state` active, paused, ended or stopped with a `reason`), `POST /api/live-cues/pause` (sequenced: an older `seq` never wins), `POST /api/live-cues/feedback`. `capabilities.liveCuesV2` on `/api/health` and `/api/models` (and `v2` on the status route) only while v2 is switched on, so with v2 off a companion treats the server as v1; reasons when unavailable include `budget_exhausted`, `jev_cap_reached` and `jev_breaker_open`. v2 needs companion 6.10.643 or later (6.10.640 reads only the v1 capability).
7
+ - How it stops: 10 cues and 20 writer calls per meeting (persisted, so a restart does not reset them), 300 writer calls per local week (`COS_LIVE_CUES_V2_WEEKLY`; zone stored at first use; checked before the gate and charged before the writer, never refunded; an unreadable ledger fails closed for the week), Jev daily cap `COS_LIVE_CUES_JEV_DAILY_TOKENS` (default 1.5M, about 7 meeting hours) on its own ledger `jev-cues-usage.json`, a breaker after 3 failures (writer, Jev gate or judge errors, or the 45 s wall) without a successful writer call in between (cancel, pause, stale and maintenance are not failures; a writer quota error opens it at once), a 45 s wall per evaluation, single flight, 20 s cadence, 45 s cooldown, and no evaluation while the user's own query runs. Refuses to run with `ANTHROPIC_API_KEY` in the environment unless `COS_ALLOW_API_FALLBACK=1`. A caller's abort of a Jev request no longer counts toward the Jev client's breaker.
8
+ - Stop and save tombstone the meeting (7 days) so trailing chunks never re-arm it; a chunk whose session closed during ASR no longer feeds cues (v1 included).
9
+ - Logs without transcript text: `live-cues-trace.jsonl`, one row per evaluation (gate probability, kept memory, memory errors and index age, timings, cost, outcome), one `skip` row per reason per meeting at most every 5 minutes (paused, breaker, meeting cap, weekly budget, busy), and feedback rows (cueId, vote, and the user's own optional reason). The number guard is traced, not enforced, unless `COS_LIVE_CUES_V2_NUMBER_GUARD=withhold`.
10
+ - Tests: `live-cues-v2.test.ts` (39) and `live-cues-v2-route.test.ts` (2); 32 guards mutation-tested, each mutation killed. COS `tests/test_cue_memory.py` (6; 4 guards mutation-tested).
11
+ - Includes 6.74.0. Pair with companion 6.10.643 for the v2 lens and phone surfaces. Switched on only by `COS_LIVE_CUES_V2=1` in the managed server environment; with it off this server behaves as 6.74.0 for live cues.
12
+
13
+ ## 6.74.0 - Mac dictation works for every user, and Parakeet installs from COS Control (local candidate)
14
+
15
+ - 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.
16
+ - `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.
17
+ - `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.
18
+ - `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.
19
+ - 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.
20
+ - `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`.
21
+ - 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.
22
+ - `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.
23
+ - 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.
24
+ - Pair with COS Control 0.5.317. Includes 6.73.15. Transcription-candidate and cue flags stay disabled. No public publication is implied.
25
+
26
+ ## 6.73.15 — Work chat knows which sessions still have earlier work open, and takes reference sessions (local candidate)
27
+
28
+ - 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.
29
+ - 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.
30
+ - 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.
31
+ - 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.
32
+ - 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.
33
+
1
34
  ## 6.73.14 — Saved Mac recordings appear in Meetings; Work chat keeps a plan with a stray item (local candidate)
2
35
 
3
36
  - 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.
@@ -20,6 +20,13 @@
20
20
  "COS_LIVE_CUES_GRAPH",
21
21
  "COS_LIVE_CUES_LIGHTRAG_RESERVE",
22
22
  "COS_LIVE_CUES_AUTO",
23
+ "COS_LIVE_CUES_V2",
24
+ "COS_LIVE_CUES_V2_GATE",
25
+ "COS_LIVE_CUES_V2_MODEL",
26
+ "COS_LIVE_CUES_V2_PROMPT",
27
+ "COS_LIVE_CUES_V2_WEEKLY",
28
+ "COS_LIVE_CUES_V2_NUMBER_GUARD",
29
+ "COS_LIVE_CUES_JEV_DAILY_TOKENS",
23
30
  "COS_BATCH_HQ_METAL",
24
31
  "COS_BATCH_HQ_FORCE_CPU",
25
32
  "COS_BATCH_HQ_TIMESTAMPS",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.73.14",
3
+ "version": "6.75.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
@@ -120,6 +120,7 @@ import { memoryRouter } from './routes/memory.js'
120
120
  import { threadsRouter } from './routes/threads.js'
121
121
  import { skillsRouter } from './routes/skills.js'
122
122
  import { shutdownLiveCues } from './lib/live-cues-engine.js'
123
+ import { shutdownLiveCuesV2 } from './lib/live-cues-v2.js'
123
124
  import { prewarmContext } from './lib/context-builder.js'
124
125
  import { preWarmCLI } from './lib/claude-bridge.js'
125
126
  import { sweepStaleTurnRequests } from './lib/turn-request.js'
@@ -1176,6 +1177,7 @@ async function gracefulShutdown(): Promise<void> {
1176
1177
  // explicitly inside the 8s force-exit budget. Single-flight guarantees at
1177
1178
  // most one tree per session, which fits.
1178
1179
  try {
1180
+ try { shutdownLiveCuesV2() } catch (err) { console.error('[shutdown] live cues v2:', err) }
1179
1181
  await shutdownLiveCues()
1180
1182
  } catch (error) {
1181
1183
  console.error('[live-cues] shutdown termination failed:', error)
@@ -18,6 +18,8 @@ import { execFileSync } from 'node:child_process'
18
18
  import { chmodSync, existsSync, lstatSync, mkdirSync, readlinkSync, statSync, symlinkSync, unlinkSync } from 'node:fs'
19
19
  import { homedir } from 'node:os'
20
20
  import { join } from 'node:path'
21
+ import { installedCliTrustedNow, parakeetCliPath, pinnedCliKnown } from './parakeet.js'
22
+ import { controlCliCandidates, knownCliTrust } from './parakeet-cli-trust.js'
21
23
 
22
24
  export type DiarizerMode = 'nemotron' | 'embedding'
23
25
 
@@ -57,7 +59,7 @@ export function nemotronCliPath(env: NodeJS.ProcessEnv = process.env, home: stri
57
59
  const key = env.PATH ?? ''
58
60
  const now = Date.now()
59
61
  if (whichCache && whichCache.key === key && now - whichCache.at < WHICH_CACHE_MS) {
60
- return whichCache.value && existsSync(whichCache.value) ? whichCache.value : null
62
+ return whichCache.value && existsSync(whichCache.value) ? whichCache.value : trustedParakeetCli(home)
61
63
  }
62
64
  let value: string | null = null
63
65
  try {
@@ -67,7 +69,27 @@ export function nemotronCliPath(env: NodeJS.ProcessEnv = process.env, home: stri
67
69
  value = null
68
70
  }
69
71
  whichCache = { key, at: now, value }
70
- return value
72
+ return value ?? trustedParakeetCli(home)
73
+ }
74
+
75
+ /**
76
+ * 6.74.0, after every earlier location: the Parakeet CLI a GUI install put under
77
+ * <data>/stt/parakeet/current/bin, then COS Control's bundled copy (Contents/Helpers).
78
+ * Each only when trusted (a verified install receipt, or a Developer ID build of the COS
79
+ * team, or the SHA-256 pinned bytes). Never runs anything; an unchecked file is skipped
80
+ * until its background check lands, so a health read never blocks.
81
+ */
82
+ export function trustedParakeetCli(home: string = homedir()): string | null {
83
+ try {
84
+ if (installedCliTrustedNow() === true) return parakeetCliPath()
85
+ } catch { /* not installed */ }
86
+ for (const path of controlCliCandidates(home)) {
87
+ try {
88
+ if (!existsSync(path)) continue
89
+ if (knownCliTrust(path)?.trusted || pinnedCliKnown([path]) === true) return path
90
+ } catch { /* next */ }
91
+ }
92
+ return null
71
93
  }
72
94
 
73
95
  const WHICH_CACHE_MS = 60_000
@@ -0,0 +1,197 @@
1
+ // 6.74.0: the on-device dictation check COS Control's onboarding runs on its pinned
2
+ // 30 s sample. Each installed local engine decodes the same WAV once; the result is
3
+ // speed (real-time factor), peak memory where it can be measured, and the text.
4
+ // Control scores accuracy against its own reference transcript.
5
+ //
6
+ // One clip is a sanity check on this Mac, not a benchmark of the engine. Bounded:
7
+ // one run at a time, never while a recording is live, a timeout per engine and in all.
8
+ // No cloud and no LLM: Whisper is the local live worker, Parakeet the sandboxed CLI.
9
+
10
+ import { execFile } from 'node:child_process'
11
+ import { applyCorrections, getWhisperCommitCapability, transcribeLocal } from './whisper-local.js'
12
+ import { parakeetInstalled, parakeetWarmState, transcribeParakeet, warmParakeet, FIRST_LOAD_TIMEOUT_MS } from './parakeet.js'
13
+ import { isRecordingLive, PREVIEW_PARAKEET_MODEL } from './stt-runtime.js'
14
+ import { parakeetDictationTimeoutMs, isPcm16kMonoWav } from './dictation-stt.js'
15
+
16
+ export const BENCHMARK_ENGINES = ['whisper', 'parakeet-ultra'] as const
17
+ export type BenchmarkEngine = typeof BENCHMARK_ENGINES[number]
18
+ /** One engine (including a Parakeet cold start) gets this long. */
19
+ export const BENCHMARK_ENGINE_TIMEOUT_MS = 60_000
20
+ /** The whole run gets this long. */
21
+ export const BENCHMARK_TOTAL_TIMEOUT_MS = 150_000
22
+ export const BENCHMARK_MAX_BYTES = 3_840_044
23
+
24
+ export interface BenchmarkEngineResult {
25
+ engine: BenchmarkEngine
26
+ /** whisper: the live worker model (large-v3-turbo or large-v3); parakeet-ultra: the pinned model. */
27
+ model: string
28
+ ran: boolean
29
+ skipped?: 'not_installed' | 'unavailable' | 'busy' | 'timeout' | 'total_timeout'
30
+ error?: string
31
+ /** Wall time of the decode (warm for Parakeet). */
32
+ elapsedMs?: number
33
+ /** elapsed seconds / audio seconds; below 1 is faster than real time. */
34
+ rtf?: number
35
+ /** Peak resident memory of the decoding process, when this server can sample it. */
36
+ peakRssBytes: number | null
37
+ /** Parakeet only: time to compile and warm the model first, when it was cold. */
38
+ warmupMs?: number
39
+ /** The engine's text after the COS dictionary corrections, before the hallucination filters. */
40
+ text?: string
41
+ }
42
+
43
+ export interface BenchmarkResult { audioSeconds: number; results: BenchmarkEngineResult[] }
44
+
45
+ export class BenchmarkRefusedError extends Error {
46
+ constructor(readonly status: number, readonly reason: 'benchmark_running' | 'recording_live' | 'audio_not_pcm_wav' | 'audio_too_large' | 'invalid_engine', message: string) {
47
+ super(message)
48
+ this.name = 'BenchmarkRefusedError'
49
+ }
50
+ }
51
+
52
+ export interface BenchmarkDeps {
53
+ recordingLive: () => boolean
54
+ whisperPresent: () => boolean
55
+ whisperModel: () => string
56
+ whisper: (audio: Buffer, signal: AbortSignal) => Promise<{ text: string }>
57
+ parakeetInstalled: () => boolean
58
+ parakeetWarm: () => boolean
59
+ /** Compile and warm Parakeet; resolves true when warm. */
60
+ warmParakeet: () => Promise<boolean>
61
+ parakeet: (audio: Buffer, timeoutMs: number, onSpawn: (pid: number) => void, signal: AbortSignal) => Promise<{ text: string }>
62
+ /** Resident set size of a pid in bytes, or null. */
63
+ rss: (pid: number) => Promise<number | null>
64
+ now: () => number
65
+ engineTimeoutMs: number
66
+ totalTimeoutMs: number
67
+ }
68
+
69
+ function psRss(pid: number): Promise<number | null> {
70
+ return new Promise(resolve => {
71
+ execFile('/bin/ps', ['-o', 'rss=', '-p', String(pid)], { timeout: 1_000 }, (error, stdout) => {
72
+ const kb = Number(String(stdout ?? '').trim())
73
+ resolve(error || !Number.isFinite(kb) || kb <= 0 ? null : kb * 1024)
74
+ })
75
+ })
76
+ }
77
+
78
+ export const defaultBenchmarkDeps: BenchmarkDeps = {
79
+ recordingLive: () => isRecordingLive(),
80
+ whisperPresent: () => getWhisperCommitCapability().configured,
81
+ whisperModel: () => getWhisperCommitCapability().effectiveModel,
82
+ // The live worker, as Mac dictation runs it: the vocabulary prompt on, the breaker untouched.
83
+ whisper: async audio => ({ text: (await transcribeLocal(audio, undefined, undefined, { affectsCircuit: false })).text }),
84
+ parakeetInstalled: () => parakeetInstalled().installed,
85
+ parakeetWarm: () => parakeetWarmState(PREVIEW_PARAKEET_MODEL).state === 'warm',
86
+ warmParakeet: () => warmParakeet(PREVIEW_PARAKEET_MODEL),
87
+ parakeet: async (audio, timeoutMs, onSpawn, signal) => {
88
+ const r = await transcribeParakeet(audio, { model: PREVIEW_PARAKEET_MODEL, lane: 'interactive', timeoutMs, onSpawn, signal })
89
+ return { text: applyCorrections(r.text) }
90
+ },
91
+ rss: psRss,
92
+ now: () => performance.now(),
93
+ engineTimeoutMs: BENCHMARK_ENGINE_TIMEOUT_MS,
94
+ totalTimeoutMs: BENCHMARK_TOTAL_TIMEOUT_MS,
95
+ }
96
+
97
+ let running = false
98
+ export function benchmarkRunning(): boolean { return running }
99
+ export function __resetBenchmarkForTests(): void { running = false }
100
+
101
+ class Timeout extends Error { constructor(readonly which: 'timeout' | 'total_timeout') { super(which) } }
102
+
103
+ /** Resolve with the promise, or reject with Timeout after ms. Aborts ctl on timeout. */
104
+ function within<T>(promise: Promise<T>, ms: number, which: 'timeout' | 'total_timeout', ctl: AbortController): Promise<T> {
105
+ return new Promise<T>((resolve, reject) => {
106
+ const timer = setTimeout(() => { ctl.abort(); reject(new Timeout(which)) }, Math.max(0, ms))
107
+ timer.unref?.()
108
+ promise.then(v => { clearTimeout(timer); resolve(v) }, e => { clearTimeout(timer); reject(e) })
109
+ })
110
+ }
111
+
112
+ export function parseBenchmarkEngines(raw: unknown): BenchmarkEngine[] | null {
113
+ if (raw === undefined || raw === '') return [...BENCHMARK_ENGINES]
114
+ if (typeof raw !== 'string') return null
115
+ const list = raw.split(',').map(s => s.trim()).filter(Boolean)
116
+ if (!list.length || list.some(e => !(BENCHMARK_ENGINES as readonly string[]).includes(e))) return null
117
+ return [...new Set(list)] as BenchmarkEngine[]
118
+ }
119
+
120
+ export async function runDictationBenchmark(audio: Buffer, engines: BenchmarkEngine[] = [...BENCHMARK_ENGINES], deps: BenchmarkDeps = defaultBenchmarkDeps): Promise<BenchmarkResult> {
121
+ if (audio.length > BENCHMARK_MAX_BYTES) throw new BenchmarkRefusedError(413, 'audio_too_large', 'The sample is longer than two minutes.')
122
+ if (!isPcm16kMonoWav(audio)) throw new BenchmarkRefusedError(400, 'audio_not_pcm_wav', 'The sample must be 16 kHz mono 16-bit WAV.')
123
+ if (running) throw new BenchmarkRefusedError(409, 'benchmark_running', 'A dictation check is already running.')
124
+ if (deps.recordingLive()) throw new BenchmarkRefusedError(409, 'recording_live', 'A recording is live. Run the check after it is saved.')
125
+ running = true
126
+ try {
127
+ const audioSeconds = Math.max(0, (audio.length - 44) / 32_000)
128
+ const started = deps.now()
129
+ const results: BenchmarkEngineResult[] = []
130
+ for (const engine of engines) {
131
+ const left = deps.totalTimeoutMs - (deps.now() - started)
132
+ const model = engine === 'whisper' ? deps.whisperModel() : 'parakeet-ultra'
133
+ if (left <= 0) { results.push({ engine, model, ran: false, skipped: 'total_timeout', peakRssBytes: null }); continue }
134
+ // A recording that started during the run stops it: the live decoders come first.
135
+ if (deps.recordingLive()) throw new BenchmarkRefusedError(409, 'recording_live', 'A recording started. Run the check after it is saved.')
136
+ const budget = Math.min(deps.engineTimeoutMs, left)
137
+ const which = budget < deps.engineTimeoutMs ? 'total_timeout' as const : 'timeout' as const
138
+ results.push(engine === 'whisper' ? await runWhisper(audio, audioSeconds, model, budget, which, deps) : await runParakeet(audio, audioSeconds, budget, which, deps))
139
+ }
140
+ return { audioSeconds, results }
141
+ } finally {
142
+ running = false
143
+ }
144
+ }
145
+
146
+ /** One line, single spaces: Control tokenizes for its word error rate. */
147
+ const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
148
+ const rtfOf = (ms: number, seconds: number) => seconds > 0 ? Math.round((ms / 1000 / seconds) * 1000) / 1000 : 0
149
+
150
+ async function runWhisper(audio: Buffer, seconds: number, model: string, budget: number, which: 'timeout' | 'total_timeout', deps: BenchmarkDeps): Promise<BenchmarkEngineResult> {
151
+ // The live worker is shared and always resident, so its peak is not this decode's.
152
+ const base = { engine: 'whisper' as const, model, peakRssBytes: null }
153
+ if (!deps.whisperPresent()) return { ...base, ran: false, skipped: 'not_installed' }
154
+ const ctl = new AbortController()
155
+ const t0 = deps.now()
156
+ try {
157
+ const r = await within(deps.whisper(audio, ctl.signal), budget, which, ctl)
158
+ const elapsedMs = Math.round(deps.now() - t0)
159
+ return { ...base, ran: true, elapsedMs, rtf: rtfOf(elapsedMs, seconds), text: oneLine(r.text) }
160
+ } catch (error) {
161
+ if (error instanceof Timeout) return { ...base, ran: false, skipped: error.which }
162
+ return { ...base, ran: false, skipped: 'unavailable', error: error instanceof Error ? error.message.slice(0, 200) : String(error) }
163
+ }
164
+ }
165
+
166
+ async function runParakeet(audio: Buffer, seconds: number, budget: number, which: 'timeout' | 'total_timeout', deps: BenchmarkDeps): Promise<BenchmarkEngineResult> {
167
+ const base = { engine: 'parakeet-ultra' as const, model: 'parakeet-ultra', peakRssBytes: null as number | null }
168
+ if (!deps.parakeetInstalled()) return { ...base, ran: false, skipped: 'not_installed' }
169
+ const ctl = new AbortController()
170
+ const t0 = deps.now()
171
+ let warmupMs: number | undefined
172
+ try {
173
+ if (!deps.parakeetWarm()) {
174
+ const ok = await within(deps.warmParakeet(), Math.min(budget, FIRST_LOAD_TIMEOUT_MS), which, ctl)
175
+ warmupMs = Math.round(deps.now() - t0)
176
+ if (!ok) return { ...base, ran: false, skipped: 'unavailable', warmupMs, error: 'Parakeet did not warm up on this Mac.' }
177
+ }
178
+ const left = budget - (deps.now() - t0)
179
+ if (left <= 0) return { ...base, ran: false, skipped: which, ...(warmupMs !== undefined ? { warmupMs } : {}) }
180
+ let peak: number | null = null
181
+ let sampler: ReturnType<typeof setInterval> | null = null
182
+ const sample = (pid: number) => { void deps.rss(pid).then(v => { if (v !== null && (peak === null || v > peak)) peak = v }) }
183
+ const onSpawn = (pid: number) => { sample(pid); sampler = setInterval(() => sample(pid), 50); sampler.unref?.() }
184
+ const t1 = deps.now()
185
+ try {
186
+ const r = await within(deps.parakeet(audio, Math.min(left, parakeetDictationTimeoutMs(audio) * 4), onSpawn, ctl.signal), left, which, ctl)
187
+ const elapsedMs = Math.round(deps.now() - t1)
188
+ return { ...base, ran: true, elapsedMs, rtf: rtfOf(elapsedMs, seconds), peakRssBytes: peak, ...(warmupMs !== undefined ? { warmupMs } : {}), text: oneLine(r.text) }
189
+ } finally {
190
+ if (sampler) clearInterval(sampler)
191
+ }
192
+ } catch (error) {
193
+ if (error instanceof Timeout) return { ...base, ran: false, skipped: error.which, ...(warmupMs !== undefined ? { warmupMs } : {}) }
194
+ const busy = (error as { reason?: string })?.reason === 'busy'
195
+ return { ...base, ran: false, skipped: busy ? 'busy' : 'unavailable', ...(warmupMs !== undefined ? { warmupMs } : {}), error: error instanceof Error ? error.message.slice(0, 200) : String(error) }
196
+ }
197
+ }
@@ -15,11 +15,13 @@
15
15
  import { transcribeParakeet, ParakeetError } from './parakeet.js'
16
16
  import { asrFrontEnd } from './stt-live.js'
17
17
  import {
18
- currentSttCapabilities, ensurePreviewWarm, noteParakeetDictationHit, noteParakeetDictationMiss,
18
+ currentSttCapabilities, ensurePreviewWarm, installCliKnown, noteParakeetDictationHit, noteParakeetDictationMiss,
19
19
  PARAKEET_PREVIEW_TIMEOUT_MS, PREVIEW_PARAKEET_MODEL,
20
20
  } from './stt-runtime.js'
21
+ import { getWhisperHealth } from './whisper-local.js'
22
+ import { parakeetInstallRunning } from './parakeet.js'
21
23
  import {
22
- dictationPlan, readSttSettings, type DictationPlan, type DictationSlot, type FrontEndId,
24
+ desktopDictationPlan, dictationPlan, readSttSettings, type DesktopDictationPlan, type DictationPlan, type DictationSlot, type FrontEndId,
23
25
  } from './stt-settings.js'
24
26
 
25
27
  /** The plan for a dictation decode starting now. A Parakeet selection that is
@@ -37,6 +39,63 @@ export function currentDictationPlan(slot: DictationSlot): DictationPlan {
37
39
  return plan
38
40
  }
39
41
 
42
+ /** 6.74.0: the plan for a Mac dictation decode starting now (its own setting, not the
43
+ * Preview model). A Parakeet that is installed but cold starts warming here. */
44
+ export function currentDesktopDictationPlan(slot: DictationSlot): DesktopDictationPlan {
45
+ try {
46
+ const plan = desktopDictationPlan(slot, readSttSettings(), currentSttCapabilities())
47
+ if (plan.reason === 'parakeet_warming' || plan.reason === 'parakeet_warm_failed') ensurePreviewWarm()
48
+ return plan
49
+ } catch (error) {
50
+ console.warn(`[stt] Mac dictation settings unreadable, using Whisper: ${error instanceof Error ? error.message : error}`)
51
+ return { slot, engine: 'whisper', reason: null, frontEnd: 'highpass', key: slot === 'final' ? 'final:large-v3' : `${slot}:turbo`, setting: 'auto', whisperAllowed: true }
52
+ }
53
+ }
54
+
55
+ /** The one action a Mac dictation failure offers (see the contract). */
56
+ export type DesktopDictationAction = 'install_parakeet' | 'use_whisper' | 'retry_when_warm' | 'open_transcription_settings'
57
+ export interface DesktopFailureHint { action: DesktopDictationAction; retryable: boolean; detail: string }
58
+ export interface DesktopFailureFacts {
59
+ /** no_local_engine: nothing local could run. parakeet_unavailable: the setting is parakeet-ultra. */
60
+ kind: 'no_local_engine' | 'parakeet_unavailable'
61
+ /** The Parakeet reason code (plan or decode), when there is one. */
62
+ parakeetReason: string | null
63
+ parakeetInstallable: boolean
64
+ whisperStarting: boolean
65
+ whisperPresent: boolean
66
+ }
67
+
68
+ /** Pure. Retry is offered only when waiting can make it succeed. */
69
+ export function desktopFailureHint(f: DesktopFailureFacts): DesktopFailureHint {
70
+ const waiting = f.parakeetReason === 'parakeet_warming' || f.parakeetReason === 'parakeet_busy'
71
+ if (f.kind === 'parakeet_unavailable') {
72
+ const detail = f.parakeetReason ?? 'parakeet_failed'
73
+ if (detail === 'parakeet_not_installed') return { action: f.parakeetInstallable ? 'install_parakeet' : 'use_whisper', retryable: false, detail }
74
+ if (waiting) return { action: 'retry_when_warm', retryable: true, detail }
75
+ return { action: 'use_whisper', retryable: false, detail }
76
+ }
77
+ if (f.whisperStarting) return { action: 'retry_when_warm', retryable: true, detail: 'whisper_starting' }
78
+ const detail = f.whisperPresent ? 'whisper_unavailable' : 'whisper_missing'
79
+ if (waiting) return { action: 'retry_when_warm', retryable: true, detail }
80
+ if (f.parakeetReason === 'parakeet_not_installed' && f.parakeetInstallable) return { action: 'install_parakeet', retryable: false, detail }
81
+ return { action: 'open_transcription_settings', retryable: false, detail }
82
+ }
83
+
84
+ /** Production facts for desktopFailureHint. Never throws. */
85
+ export function currentDesktopFailureHint(kind: DesktopFailureFacts['kind'], parakeetReason: string | null): DesktopFailureHint {
86
+ let whisperStarting = false, whisperPresent = true, parakeetInstallable = false
87
+ try {
88
+ const h = getWhisperHealth()
89
+ whisperStarting = h.restarting || h.startupState === 'preflight' || h.startupState === 'loading'
90
+ whisperPresent = h.serverConfigured
91
+ } catch { /* unknown: treat as present and not starting */ }
92
+ try {
93
+ const installing = parakeetInstallRunning()
94
+ parakeetInstallable = installing || (process.platform === 'darwin' && process.arch === 'arm64' && installCliKnown().known === true)
95
+ } catch { /* not installable */ }
96
+ return desktopFailureHint({ kind, parakeetReason, parakeetInstallable, whisperStarting, whisperPresent })
97
+ }
98
+
40
99
  /** Parakeet reads 16 kHz mono PCM16 WAV. The browser recorder posts WebM, which stays on Whisper. */
41
100
  export function isPcm16kMonoWav(audio: Buffer): boolean {
42
101
  if (audio.length < 44 || audio.toString('ascii', 0, 4) !== 'RIFF' || audio.toString('ascii', 8, 12) !== 'WAVE') return false
@@ -8,7 +8,7 @@ const bus = new EventEmitter()
8
8
  bus.setMaxListeners(20) // Multiple glasses clients
9
9
 
10
10
  export interface DisplayEvent {
11
- type: 'chunk' | 'done' | 'error' | 'tool_status' | 'start' | 'session_restore' | 'transcript_chunk' | 'prompt_transcript' | 'recording_start' | 'recording_stop' | 'coaching_nudge'
11
+ type: 'chunk' | 'done' | 'error' | 'tool_status' | 'start' | 'session_restore' | 'transcript_chunk' | 'prompt_transcript' | 'recording_start' | 'recording_stop' | 'coaching_nudge' | 'coaching_nudge_v2'
12
12
  data: Record<string, unknown>
13
13
  }
14
14
 
package/server/lib/jev.ts CHANGED
@@ -138,12 +138,19 @@ export class JevClient {
138
138
  res = await this.fetchImpl(`${JEV_ENDPOINT}/systemone`, { method: 'POST', signal: AbortSignal.any([AbortSignal.timeout(JEV_LIMITS.timeoutMs), ...(options?.signal ? [options.signal] : [])]),
139
139
  headers: { Authorization: `Bearer ${key.key}`, 'Content-Type': 'application/json' },
140
140
  body: JSON.stringify({ state, model: JEV_MODEL, questions }) })
141
- } catch { return this.fail('jev_unavailable', true) }
141
+ } catch {
142
+ // A caller's own cancel (pause, stop, shutdown) is not a Jev failure: it must not grow the breaker.
143
+ if (options?.signal?.aborted) throw jevCancelled()
144
+ return this.fail('jev_unavailable', true)
145
+ }
142
146
  if (res.status === 401 || res.status === 403 || res.status === 402) return this.fail('jev_key_rejected', true)
143
147
  if (res.status === 400 || res.status === 413 || res.status === 422) return this.fail('jev_request_rejected', false)
144
148
  if (!res.ok) return this.fail('jev_unavailable', true)
145
149
  let body: { answers?: JevAnswers['answers']; model?: string; usage?: { input_tokens?: number } }
146
- try { body = await res.json() } catch { return this.fail('jev_bad_answer', true) }
150
+ try { body = await res.json() } catch {
151
+ if (options?.signal?.aborted) throw jevCancelled()
152
+ return this.fail('jev_bad_answer', true)
153
+ }
147
154
  const used = Number(body.usage?.input_tokens) || estimate // count something even if usage is missing
148
155
  this.record(used)
149
156
  if (!body.answers || typeof body.answers !== 'object') return this.fail('jev_bad_answer', true)
@@ -152,6 +159,9 @@ export class JevClient {
152
159
  }
153
160
  }
154
161
 
162
+ /** Thrown only when the caller's signal aborted; never counted toward the breaker. */
163
+ export function jevCancelled(): Error { const e = new Error('cancelled'); e.name = 'AbortError'; return e }
164
+
155
165
  let shared: JevClient | null = null
156
166
  export function sharedJevClient(): JevClient { return shared ??= new JevClient() }
157
167
  /** Jev bills by input size; about 2.6 characters per token, measured on COS meeting and task text. */
@@ -37,6 +37,7 @@ import {
37
37
  import { terminateProviderProcess } from './provider-process-lifecycle.js'
38
38
  import { atomicWriteFileSync } from './atomic-fs.js'
39
39
  import { dataPath } from './data-dir.js'
40
+ import { liveCuesV2, liveCuesV2Enabled } from './live-cues-v2.js'
40
41
 
41
42
  // ── Gates ────────────────────────────────────────────────────────────────────
42
43
  const MIN_BUFFER_WORDS = 40
@@ -244,6 +245,8 @@ export async function feedLiveCueTranscript(
244
245
  text: string,
245
246
  asrDegraded = false,
246
247
  ): Promise<void> {
248
+ // v2 (COS_LIVE_CUES_V2=1) owns the feed when enabled; the Composer v1 path below is unchanged otherwise.
249
+ if (liveCuesV2Enabled()) return liveCuesV2().feed(sessionId, text, asrDegraded)
247
250
  let session = sessions.get(sessionId)
248
251
  if (!session) {
249
252
  if (process.env.COS_LIVE_CUES_AUTO === '1' && liveCuesEnabled()) {
@@ -0,0 +1,41 @@
1
+ // GENERATED from operations/personal/wk42_2026/cue_rrf_replay (prompts.py, jev_gate.py, run_v42.py) on 2026-10-17.
2
+ // These strings are the exact ones the dev replay (120 windows) and the held-out replay (112 windows) measured.
3
+ // Do not hand-edit: change the replay source, re-run the replay, then regenerate.
4
+
5
+ export const CUE_GATE_QUESTIONS = {
6
+ "act": {
7
+ "type": "choice",
8
+ "instructions": "Should the wearer's smart glasses show a live cue about the last part of the conversation in `window`?",
9
+ "criteria": {
10
+ "cue": "Live work is on the table right now: a decision being made or weighed (work or life), a commitment, a missing owner or deadline, a contradiction, a number or date that matters, or a plan being set.",
11
+ "silent": "Small talk, catching up, family or social conversation with no decision, storytelling, TV, video, podcast or news commentary, bedtime or household chatter, logistics with nothing to decide, or speech too garbled to tell."
12
+ }
13
+ },
14
+ "lookup": {
15
+ "type": "choice",
16
+ "instructions": "What extra information would most improve a cue about the last part of `window`?",
17
+ "criteria": {
18
+ "none": "Nothing extra; the conversation itself has what is needed, or no cue is needed.",
19
+ "past_meetings": "The wearer's own past meetings or notes about the same client, project, brand or person.",
20
+ "public_fact": "A public fact to check: a price, statistic, law, news event or published number."
21
+ }
22
+ },
23
+ "kind": {
24
+ "type": "choice",
25
+ "instructions": "If a cue were shown about the last part of `window`, which kind would help the wearer most?",
26
+ "criteria": {
27
+ "reinforce": "They are on the right track; name the evidence and the next step.",
28
+ "challenge": "There is a flaw, a missing piece, or a contradiction to raise."
29
+ }
30
+ }
31
+ } as const
32
+
33
+ export const CUE_JUDGE_INSTRUCTIONS = "Which past meeting note is about the same client, project, brand or decision as the last part of `window`? Choose none if no note is."
34
+ export const CUE_JUDGE_NONE = "None of these notes is about the same client, project, brand or decision as the conversation."
35
+
36
+ /** Writer v2: measured on dev (A9) and held-out (useful on required 26/29). */
37
+ export const CUE_WRITER_SYSTEM_V2 = "You write one live cue for the wearer of smart glasses during a conversation. You work for the wearer.\nWrite ONE cue the wearer can act on in the next minute.\n1. One or two sentences, at most 200 characters, built around one specific taken from the window or memory: a number, name, date, or concrete next step.\n2. Every name, number, date, owner and target in the cue must appear in the window or memory. Never invent a deadline, owner, number, cap or test date.\n If one is missing, say that it is missing (for example: no owner or date was set for X) instead of choosing one.\n3. Two kinds. reinforce: they are on track; name the evidence and the next step. challenge: the flaw, the missing piece, or the contradiction.\n Check previousCues: do not only criticize, and never repeat a previous cue. No empty praise.\n4. Add something the room has not said: the missing owner, deadline or criterion, a risk, a contradiction, or a past decision from memory.\n If the cue would only restate what was just said, output null.\n5. Use a memory note only when it is about the same client, project, brand or person as the window. Otherwise ignore memory completely.\n Memory notes are past claims written at a point in time, not facts. Cite the date, keep their numbers exact, and never stretch what they say.\n6. Never make the wearer serve you. Never ask them to clarify or confirm what was said. Never mention the transcript, audio, hearing, or speakers.\n A question is allowed only as the decision the room must settle, phrased as advice.\n7. Speech-to-text is noisy. Never build a cue on a word that looks misheard. When someone corrects themselves, the last version wins.\n Do not reopen something the room already settled.\n8. Name a person only if the window or memory names them. Plain text: no markdown, no em dashes. Output null for small talk, noise, entertainment, or repeats.\nSchema: {\"cue\":\"<=200 chars\",\"kind\":\"reinforce|challenge\",\"memoryIds\":[\"M1\"],\"specific\":\"the specific fact used, copied\"} or the literal null.\nOutput one line of minified JSON only."
38
+
39
+ /** Writer v3: v2 plus the volunteered-owner and attribution rules, measured on the held-out windows after the fact (development data). */
40
+ export const CUE_WRITER_SYSTEM_V3 = "You write one live cue for the wearer of smart glasses during a conversation. You work for the wearer.\nWrite ONE cue the wearer can act on in the next minute.\n1. One or two sentences, at most 200 characters, built around one specific taken from the window or memory: a number, name, date, or concrete next step.\n2. Every name, number, date, owner and target in the cue must appear in the window or memory. Never invent a deadline, owner, number, cap or test date.\n If one is missing, say that it is missing instead of choosing one. It is missing only when nobody took it on: if anyone said they will do it (I'll fix it, we'll send it), it is not missing and you must not claim it is.\n Do not say who promised or said something unless that window line names them; write the room or you two. Keep numbers exactly as spoken; never round, sum or compare numbers the window did not.\n3. Two kinds. reinforce: they are on track; name the evidence and the next step. challenge: the flaw, the missing piece, or the contradiction.\n Check previousCues: do not only criticize, and never repeat a previous cue. No empty praise.\n4. Add something the room has not said: the missing owner, deadline or criterion, a risk, a contradiction, or a past decision from memory.\n If the cue would only restate what was just said, output null.\n5. Use a memory note only when it is about the same client, project, brand or person as the window. Otherwise ignore memory completely.\n Memory notes are past claims written at a point in time, not facts. Cite the date, keep their numbers exact, and never stretch what they say.\n6. Never make the wearer serve you. Never ask them to clarify or confirm what was said. Never mention the transcript, audio, hearing, or speakers.\n A question is allowed only as the decision the room must settle, phrased as advice.\n7. Speech-to-text is noisy. Never build a cue on a word that looks misheard. When someone corrects themselves, the last version wins.\n Do not reopen something the room already settled.\n8. Name a person only if the window or memory names them. Plain text: no markdown, no em dashes. Output null for small talk, noise, entertainment, or repeats.\nSchema: {\"cue\":\"<=200 chars\",\"kind\":\"reinforce|challenge\",\"memoryIds\":[\"M1\"],\"specific\":\"the specific fact used, copied\"} or the literal null.\nOutput one line of minified JSON only."
41
+