@gotcos/glasses-server 6.74.0 → 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,15 @@
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
+
1
13
  ## 6.74.0 - Mac dictation works for every user, and Parakeet installs from COS Control (local candidate)
2
14
 
3
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.
@@ -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.74.0",
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)
@@ -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
+
@@ -0,0 +1,639 @@
1
+ // Live cues v2 (MVP, default off): Jev gate -> rank-fusion memory -> Jev relevance judge -> ONE tools-off Claude writer.
2
+ //
3
+ // Evidence and thresholds: docs/PLAN-live-cues-v2-mvp.md. Every recurring model caller must answer how it stops; here:
4
+ // the master switch COS_LIVE_CUES_V2=1 (default off; independent of the v1 COS_LIVE_CUES flag, so turning v2 off can never
5
+ // re-enable the v1 Composer path), a per-meeting cap (10 cues, 20 writer calls; persisted, so a restart does not reset
6
+ // it), a weekly writer ceiling checked BEFORE the gate and charged before the call, never refunded (local week in the zone
7
+ // stored at first use), the Jev client's own daily token cap and breaker, a 3-failure breaker per meeting (cancel, pause,
8
+ // end, stale and maintenance are NOT failures), single flight per meeting, a 20 s cadence floor, a 45 s cooldown after a
9
+ // cue, a 45 s wall per evaluation, and no evaluation while a user's own query runs. Each evaluation writes one trace row
10
+ // without transcript text; a skip (paused, breaker, meeting cap, weekly budget, busy) writes one `skip` row per reason
11
+ // per meeting at most every 5 minutes, so the trace can tell those apart from silence.
12
+
13
+ import { spawn, type ChildProcess } from 'node:child_process'
14
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
15
+ import { createHash, randomUUID } from 'node:crypto'
16
+ import { dirname, resolve } from 'node:path'
17
+ import { emitDisplay, onDisplay } from './display-bus.js'
18
+ import { acquireMaintenanceWork, MaintenanceLifecycleError, maintenanceLifecycle, type MaintenanceWorkLease } from './maintenance-lifecycle.js'
19
+ import { JevClient, JevError, estimateJevTokens, resolveJevKey, type JevAnswers } from './jev.js'
20
+ import { LensGistLimitError, runLensGistEngine } from './lens-gist-engines.js'
21
+ import { resolveProviderBinary } from './provider-binary.js'
22
+ import { COS_SCRIPTS_DIR, PYTHON_BIN } from './python-bridge.js'
23
+ import { dataPath } from './data-dir.js'
24
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
25
+ import { terminateProviderProcess } from './provider-process-lifecycle.js'
26
+ import { CUE_GATE_QUESTIONS, CUE_JUDGE_INSTRUCTIONS, CUE_JUDGE_NONE, CUE_WRITER_SYSTEM_V2, CUE_WRITER_SYSTEM_V3 } from './live-cues-v2-prompts.js'
27
+ import {
28
+ CUE_DETAIL_MAX, CUE_FEEDBACK_REASON_MAX, CUE_HEADLINE_MAX, LIVE_CUES_V2_PROTOCOL, isLiveCueV2,
29
+ type CueKind, type CueSourceRef, type LiveCueV1Compat, type LiveCueV2, type LiveCuesV2Capability,
30
+ type LiveCuesV2SessionStatus, type LiveCuesV2StatusResponse, type LiveCuesV2StopReason,
31
+ } from '../../shared/live-cues-v2.js'
32
+
33
+ // ── Limits ──────────────────────────────────────────────────────────────────
34
+ export const LIVE_CUES_V2_LIMITS = {
35
+ minNewWords: 40,
36
+ minIntervalMs: 20_000,
37
+ cooldownAfterCueMs: 45_000,
38
+ /** A cue is stale when the room moved on while it was being written: more than ~24 s of speech at ~2.5 words/s,
39
+ * or more than 30 s of wall time (the pipeline's worst case is ~28 s: 20 s writer, 6 s memory, two Jev calls). */
40
+ staleWords: 60,
41
+ staleMs: 30_000,
42
+ windowWords: 400,
43
+ windowChars: 2_200,
44
+ maxCuesPerMeeting: 10,
45
+ maxWriterCallsPerMeeting: 20,
46
+ defaultWeeklyWriterCalls: 300,
47
+ breakerFailures: 3,
48
+ judgeKeep: 0.8,
49
+ judgeMaxNotes: 2,
50
+ defaultGate: 0.8,
51
+ memoryTimeoutMs: 6_000,
52
+ /** Hard wall for one evaluation, far under Control's 90 s drain (a held live_cue_pipeline lease past it fails Update). */
53
+ pipelineWallMs: 45_000,
54
+ /** A skip reason is traced at most once per meeting per window of this length. */
55
+ skipTraceEveryMs: 5 * 60_000,
56
+ writerTimeoutMs: 20_000,
57
+ writerMaxOutputTokens: 600,
58
+ sessionTtlMs: 4 * 60 * 60_000,
59
+ tombstoneTtlMs: 7 * 24 * 60 * 60_000,
60
+ counterTtlMs: 24 * 60 * 60_000,
61
+ /** ~1.2k gate tokens per evaluation, at most 3 a minute: ~216k an hour, so 1.5M covers ~7 meeting hours (~$0.06). */
62
+ defaultJevDailyTokens: 1_500_000,
63
+ } as const
64
+
65
+ export function liveCuesV2Enabled(env: NodeJS.ProcessEnv = process.env): boolean {
66
+ return env.COS_LIVE_CUES_V2 === '1'
67
+ }
68
+ export function liveCuesV2Gate(env: NodeJS.ProcessEnv = process.env): number {
69
+ const raw = Number(env.COS_LIVE_CUES_V2_GATE)
70
+ return Number.isFinite(raw) && raw >= 0.3 && raw <= 0.99 ? raw : LIVE_CUES_V2_LIMITS.defaultGate
71
+ }
72
+ export function liveCuesV2Model(env: NodeJS.ProcessEnv = process.env): string {
73
+ const raw = (env.COS_LIVE_CUES_V2_MODEL ?? '').trim().toLowerCase()
74
+ if (raw === 'haiku') return 'claude-haiku-5-5'
75
+ if (raw === 'sonnet' || raw === '') return 'claude-sonnet-5-5'
76
+ return /^claude-[a-z0-9.-]{3,40}$/.test(raw) ? raw : 'claude-sonnet-5-5'
77
+ }
78
+ export function liveCuesV2WriterSystem(env: NodeJS.ProcessEnv = process.env): { version: 'v2' | 'v3'; system: string } {
79
+ return (env.COS_LIVE_CUES_V2_PROMPT ?? '').trim() === 'v2' ? { version: 'v2', system: CUE_WRITER_SYSTEM_V2 } : { version: 'v3', system: CUE_WRITER_SYSTEM_V3 }
80
+ }
81
+ export function liveCuesV2WeeklyCap(env: NodeJS.ProcessEnv = process.env): number {
82
+ const raw = Number(env.COS_LIVE_CUES_V2_WEEKLY)
83
+ return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : LIVE_CUES_V2_LIMITS.defaultWeeklyWriterCalls
84
+ }
85
+ function jevDailyCap(): number {
86
+ const raw = Number(process.env.COS_LIVE_CUES_JEV_DAILY_TOKENS)
87
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : LIVE_CUES_V2_LIMITS.defaultJevDailyTokens
88
+ }
89
+
90
+ // ── Python-style JSON (the replay built every prompt with json.dumps; keep byte parity) ──
91
+ export function pyJson(value: unknown): string {
92
+ if (value === null || value === undefined) return 'null'
93
+ if (typeof value === 'string') return JSON.stringify(value).replace(/[\u007f-￿]/g, ch => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'))
94
+ if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'null'
95
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
96
+ if (Array.isArray(value)) return '[' + value.map(pyJson).join(', ') + ']'
97
+ return '{' + Object.entries(value as Record<string, unknown>).filter(([, v]) => v !== undefined).map(([k, v]) => `${pyJson(k)}: ${pyJson(v)}`).join(', ') + '}'
98
+ }
99
+
100
+ // ── Pure helpers (exported for tests) ───────────────────────────────────────
101
+ /** A note as the writer sees it ({id, date, title, notes}; the same keys the replay sent). `key` is the stable meeting
102
+ * identity from cue_memory.py; it never reaches the prompt. */
103
+ export interface MemoryNote { id: string; date: string; title: string; notes: string; key?: string }
104
+
105
+ /** A cue's headline: the first sentence when it fits, otherwise cut at a word with an ellipsis. Never empty. */
106
+ export function cueHeadline(detail: string): string {
107
+ const clean = detail.replace(/\s+/g, ' ').trim()
108
+ const first = (clean.split(/(?<=[.!?])\s/)[0] ?? '').replace(/[.]$/, '').trim()
109
+ if (first && first.length <= CUE_HEADLINE_MAX) return first
110
+ const cut = clean.slice(0, CUE_HEADLINE_MAX - 1)
111
+ const space = cut.lastIndexOf(' ')
112
+ const head = (space > 30 ? cut.slice(0, space) : cut).replace(/[,;:.\s]+$/, '')
113
+ return head ? head + '…' : clean.slice(0, CUE_HEADLINE_MAX)
114
+ }
115
+
116
+ const NUMBER = /\$?\d[\d,]*(?:\.\d+)?%?k?/gi
117
+ /** Every multi-digit number (or any dollar amount) in the cue must appear in the window or the memory notes, dates included.
118
+ * A substring check: it catches invented figures and dates, not a number that happens to sit inside a longer one. */
119
+ export function unsupportedNumbers(cue: string, window: string, memory: MemoryNote[]): string[] {
120
+ const hay = (window + ' ' + memory.map(m => `${m.notes} ${m.date}`).join(' ')).toLowerCase().replace(/[$,%]/g, '')
121
+ const bad: string[] = []
122
+ for (const n of cue.match(NUMBER) ?? []) {
123
+ const digits = n.toLowerCase().replace(/[$,%]/g, '').replace(/k$/, '')
124
+ if (digits.length < 2 && !n.startsWith('$')) continue
125
+ if (digits && !hay.includes(digits)) bad.push(n)
126
+ }
127
+ return bad
128
+ }
129
+
130
+ export interface ParsedCue { detail: string; kind: CueKind; memoryIds: string[]; numberFlags: string[] }
131
+ export type CueRejection = 'null' | 'unparseable' | 'empty' | 'too_long' | 'unsupported_number'
132
+ /** `enforceNumbers` withholds a cue whose numbers are not in the source. Off by default: on the held-out replay the
133
+ * guard fired on 3 v3 cues and only 1 was unsupported (ASR writes "300 000" and "a hundred"), so it is traced, not enforced. */
134
+ export function parseWriterCue(raw: string, fallbackKind: CueKind, window: string, kept: MemoryNote[], enforceNumbers = false): ParsedCue | CueRejection {
135
+ const text = raw.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim()
136
+ if (!text || text === 'null') return 'null'
137
+ const start = text.indexOf('{'), end = text.lastIndexOf('}')
138
+ if (start < 0 || end < start) return 'unparseable'
139
+ let value: Record<string, unknown>
140
+ try { value = JSON.parse(text.slice(start, end + 1)) as Record<string, unknown> } catch { return 'unparseable' }
141
+ const cue = typeof value.cue === 'string' ? value.cue.replace(/[—–]/g, '-').replace(/\s+/g, ' ').trim() : ''
142
+ if (!cue) return 'empty'
143
+ if (cue.length > CUE_DETAIL_MAX) return 'too_long'
144
+ const numberFlags = unsupportedNumbers(cue, window, kept)
145
+ if (enforceNumbers && numberFlags.length) return 'unsupported_number'
146
+ const kind: CueKind = value.kind === 'reinforce' || value.kind === 'challenge' ? value.kind : fallbackKind
147
+ const ids = new Set(kept.map(m => m.id))
148
+ const memoryIds = Array.isArray(value.memoryIds) ? [...new Set(value.memoryIds.filter((x): x is string => typeof x === 'string' && ids.has(x)))] : []
149
+ return { detail: cue, kind, memoryIds, numberFlags }
150
+ }
151
+
152
+ /** A stable, opaque id for a past meeting (the same meeting gets the same id in every cue). */
153
+ export function sourceId(note: MemoryNote): string {
154
+ return 'mtg_' + createHash('sha256').update(note.key || `${note.date}|${note.title}`).digest('hex').slice(0, 12)
155
+ }
156
+
157
+ /** Local Monday (YYYY-MM-DD) in `zone`. Intl, never toISOString: a UTC key flips the week at 19:00 Central. */
158
+ export function localWeekStart(now: Date, zone: string): string {
159
+ const parts = new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short' }).formatToParts(now)
160
+ const get = (t: string) => parts.find(p => p.type === t)?.value ?? ''
161
+ const dow = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].indexOf(get('weekday'))
162
+ const local = new Date(Date.UTC(Number(get('year')), Number(get('month')) - 1, Number(get('day'))))
163
+ local.setUTCDate(local.getUTCDate() - Math.max(0, dow))
164
+ return local.toISOString().slice(0, 10)
165
+ }
166
+
167
+ // ── Durable state: weekly ceiling, tombstones, pauses, per-meeting counters ─────
168
+ interface MeetingCounters { cues: number; writerCalls: number; at: number }
169
+ interface DurableState {
170
+ zone: string; weekStart: string; writerCalls: number
171
+ ended: Record<string, number>; paused: Record<string, boolean>; pauseSeq: Record<string, number>
172
+ meetings: Record<string, MeetingCounters>
173
+ }
174
+ function validZone(zone: unknown): string | null {
175
+ if (typeof zone !== 'string' || !zone) return null
176
+ try { new Intl.DateTimeFormat('en-CA', { timeZone: zone }); return zone } catch { return null }
177
+ }
178
+ export class LiveCuesV2Store {
179
+ constructor(private readonly file: string, private readonly now: () => Date) {}
180
+ read(): DurableState {
181
+ const nowMs = this.now().getTime()
182
+ try {
183
+ let raw: unknown = {}
184
+ if (existsSync(this.file)) raw = JSON.parse(readFileSync(this.file, 'utf8'))
185
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('not an object')
186
+ const s = raw as Partial<DurableState>
187
+ const zone = validZone(s.zone) ?? (s.zone === undefined ? (validZone(Intl.DateTimeFormat().resolvedOptions().timeZone) ?? 'UTC') : null)
188
+ if (!zone) throw new Error('invalid zone')
189
+ const week = localWeekStart(this.now(), zone)
190
+ const keep = <T>(m: unknown, ok: (v: T) => boolean): Record<string, T> =>
191
+ Object.fromEntries(Object.entries(m && typeof m === 'object' ? m as Record<string, T> : {}).filter(([, v]) => ok(v)))
192
+ const ended = keep<number>(s.ended, at => typeof at === 'number' && at >= nowMs - LIVE_CUES_V2_LIMITS.tombstoneTtlMs)
193
+ const meetings = keep<MeetingCounters>(s.meetings, c => !!c && typeof c.at === 'number' && c.at >= nowMs - LIVE_CUES_V2_LIMITS.counterTtlMs)
194
+ const live = (id: string) => id in meetings || !(id in ended)
195
+ const paused = keep<boolean>(s.paused, v => v === true)
196
+ const pauseSeq = keep<number>(s.pauseSeq, v => typeof v === 'number' && Number.isFinite(v))
197
+ for (const id of Object.keys(paused)) if (!live(id) && !(id in meetings)) delete paused[id]
198
+ const sameWeek = s.weekStart === week
199
+ const futureWeek = typeof s.weekStart === 'string' && s.weekStart > week
200
+ const calls = Number(s.writerCalls ?? 0)
201
+ // A future week key or a negative/non-numeric count fails toward NOT spending: the week is saturated.
202
+ const saturated = futureWeek || (sameWeek && !(calls >= 0))
203
+ return { zone, weekStart: week, writerCalls: saturated ? Number.MAX_SAFE_INTEGER : sameWeek ? calls : 0, ended, paused, pauseSeq, meetings }
204
+ } catch {
205
+ // An unreadable file fails closed for this week (no spending) but keeps cues off nothing else.
206
+ const zone = validZone(Intl.DateTimeFormat().resolvedOptions().timeZone) ?? 'UTC'
207
+ return { zone, weekStart: localWeekStart(this.now(), zone), writerCalls: Number.MAX_SAFE_INTEGER, ended: {}, paused: {}, pauseSeq: {}, meetings: {} }
208
+ }
209
+ }
210
+ private write(s: DurableState): void {
211
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
212
+ durableAtomicWriteFileSync(this.file, JSON.stringify(s) + '\n', { mode: 0o600 })
213
+ }
214
+ /** Charge one writer call BEFORE the call. Never refunded. False when the week is spent. */
215
+ charge(cap: number, sessionId: string): boolean {
216
+ const s = this.read()
217
+ if (s.writerCalls >= cap) return false
218
+ s.writerCalls += 1
219
+ const m = s.meetings[sessionId] ?? { cues: 0, writerCalls: 0, at: 0 }
220
+ s.meetings[sessionId] = { ...m, writerCalls: m.writerCalls + 1, at: this.now().getTime() }
221
+ this.write(s)
222
+ return true
223
+ }
224
+ recordCue(sessionId: string): void {
225
+ const s = this.read()
226
+ const m = s.meetings[sessionId] ?? { cues: 0, writerCalls: 0, at: 0 }
227
+ s.meetings[sessionId] = { ...m, cues: m.cues + 1, at: this.now().getTime() }
228
+ this.write(s)
229
+ }
230
+ counters(sessionId: string): { cues: number; writerCalls: number } {
231
+ const m = this.read().meetings[sessionId]
232
+ return { cues: m?.cues ?? 0, writerCalls: m?.writerCalls ?? 0 }
233
+ }
234
+ weeklyUsed(): number { return this.read().writerCalls }
235
+ end(sessionId: string): void { const s = this.read(); s.ended[sessionId] = this.now().getTime(); delete s.paused[sessionId]; this.write(s) }
236
+ isEnded(sessionId: string): boolean { return sessionId in this.read().ended }
237
+ /** Applies only when `seq` is absent or newer than the last applied one. */
238
+ setPaused(sessionId: string, paused: boolean, seq?: number): { applied: boolean; paused: boolean; pauseSeq: number } {
239
+ const s = this.read()
240
+ const last = s.pauseSeq[sessionId] ?? 0
241
+ if (seq !== undefined && seq <= last) return { applied: false, paused: s.paused[sessionId] === true, pauseSeq: last }
242
+ if (paused) s.paused[sessionId] = true; else delete s.paused[sessionId]
243
+ if (seq !== undefined) s.pauseSeq[sessionId] = seq
244
+ this.write(s)
245
+ return { applied: true, paused, pauseSeq: seq ?? last }
246
+ }
247
+ isPaused(sessionId: string): boolean { return this.read().paused[sessionId] === true }
248
+ pauseSeq(sessionId: string): number { return this.read().pauseSeq[sessionId] ?? 0 }
249
+ }
250
+
251
+ // ── Dependencies (real by default, faked in tests) ──────────────────────────
252
+ export interface GateResult { pCue: number; kind: CueKind; lookup: Record<string, number> | null; tokens: number }
253
+ export interface MemoryResult { notes: MemoryNote[]; denseError?: string; indexAgeH?: number }
254
+ export interface LiveCuesV2Deps {
255
+ gate(title: string, window: string, signal: AbortSignal): Promise<GateResult>
256
+ memory(window: string, before: string, signal: AbortSignal): Promise<MemoryResult>
257
+ judge(title: string, window: string, notes: MemoryNote[], signal: AbortSignal): Promise<{ probs: Record<string, number>; tokens: number }>
258
+ writer(model: string, system: string, prompt: string, signal: AbortSignal): Promise<{ text: string; costUsd?: number; inputTokens?: number; outputTokens?: number; model?: string }>
259
+ emit(type: 'coaching_nudge' | 'coaching_nudge_v2', data: Record<string, unknown>): void
260
+ lease(): MaintenanceWorkLease | null
261
+ /** True while the user's own query runs (Ask, a durable query): cues wait rather than compete for the provider. */
262
+ busy(): boolean
263
+ /** The cue Jev client's own state, for an honest capability. */
264
+ jevState(): { breakerOpen: boolean; capReached: boolean }
265
+ warmMemory(): void
266
+ now(): Date
267
+ store: LiveCuesV2Store
268
+ trace(row: Record<string, unknown>): void
269
+ env: NodeJS.ProcessEnv
270
+ }
271
+
272
+ function cleanWindow(text: string): string {
273
+ return text.replace(/^\[\d+\]\s*/gm, '').replace(/\*\*?Speaker \d+:?\*\*?:?|Speaker \d+:/g, '')
274
+ }
275
+
276
+ function realDeps(): LiveCuesV2Deps {
277
+ const jev = new JevClient(fetch, () => new Date(), dataPath('jev-cues-usage.json'), jevDailyCap)
278
+ const traceFile = dataPath('live-cues-trace.jsonl')
279
+ return {
280
+ async gate(title, window, signal) {
281
+ const state = { meetingTitle: title, window: window.slice(-LIVE_CUES_V2_LIMITS.windowChars) }
282
+ const res: JevAnswers = await jev.ask(state, CUE_GATE_QUESTIONS as unknown as Record<string, unknown>, estimateJevTokens({ state, CUE_GATE_QUESTIONS }), { signal })
283
+ const a = res.answers ?? {}
284
+ const kind = a.kind?.choice === 'reinforce' ? 'reinforce' : 'challenge'
285
+ return { pCue: Number(a.act?.probabilities?.cue ?? 0), kind, lookup: a.lookup?.probabilities ?? null, tokens: res.inputTokens }
286
+ },
287
+ memory: (window, before, signal) => cueMemory(window, before, signal),
288
+ async judge(title, window, notes, signal) {
289
+ const criteria: Record<string, string> = Object.fromEntries(notes.map(m => [m.id, `${m.date} ${m.title}: ${m.notes.slice(0, 300)}`]))
290
+ criteria.none = CUE_JUDGE_NONE
291
+ const state = { meetingTitle: title, window: window.slice(-LIVE_CUES_V2_LIMITS.windowChars) }
292
+ const questions = { same: { type: 'choice', instructions: CUE_JUDGE_INSTRUCTIONS, criteria } }
293
+ const res = await jev.ask(state, questions, estimateJevTokens({ state, questions }), { signal })
294
+ return { probs: res.answers?.same?.probabilities ?? {}, tokens: res.inputTokens }
295
+ },
296
+ async writer(model, system, prompt, signal) {
297
+ const run = await runLensGistEngine('claude', model, prompt, { system, timeoutMs: LIVE_CUES_V2_LIMITS.writerTimeoutMs, source: 'live-cues-v2', maxOutputTokens: LIVE_CUES_V2_LIMITS.writerMaxOutputTokens, signal })
298
+ return { text: run.text, costUsd: run.usage?.costUsd, inputTokens: run.usage?.inputTokens, outputTokens: run.usage?.outputTokens, model: run.model }
299
+ },
300
+ emit: (type, data) => emitDisplay({ type, data }),
301
+ lease: () => acquireMaintenanceWork('live_cue_pipeline'),
302
+ busy() {
303
+ const active = maintenanceLifecycle.snapshot().activeByKind as Record<string, number>
304
+ return ['durable_query', 'legacy_query', 'openai_query', 'meeting_ask'].some(kind => (active[kind] ?? 0) > 0)
305
+ },
306
+ jevState() {
307
+ const st = jev.status()
308
+ return { breakerOpen: !!st.breakerOpenUntil, capReached: st.usedToday >= st.dailyCap }
309
+ },
310
+ warmMemory: () => warmCueMemory(),
311
+ now: () => new Date(),
312
+ store: new LiveCuesV2Store(dataPath('live-cues-v2-state.json'), () => new Date()),
313
+ trace(row) {
314
+ try { mkdirSync(dirname(traceFile), { recursive: true, mode: 0o700 }); appendFileSync(traceFile, JSON.stringify(row) + '\n', { mode: 0o600 }) } catch { /* trace is best effort */ }
315
+ },
316
+ env: process.env,
317
+ }
318
+ }
319
+
320
+ export interface CueMemoryPaths { python: string; script: string; cwd: string }
321
+ function cueMemoryPaths(): CueMemoryPaths | null {
322
+ const python = PYTHON_BIN, scriptsDir = COS_SCRIPTS_DIR
323
+ if (!python || !scriptsDir) return null
324
+ const script = resolve(scriptsDir, 'cue_memory.py')
325
+ return existsSync(script) && existsSync(python) ? { python, script, cwd: scriptsDir } : null
326
+ }
327
+
328
+ /** Starts a background index refresh when the cache is stale (non-blocking; the script locks and skips if fresh). */
329
+ export function warmCueMemory(): void {
330
+ const p = cueMemoryPaths()
331
+ if (!p) return
332
+ try {
333
+ const proc = spawn(p.python, [p.script, '--warm'], { cwd: p.cwd, detached: true, stdio: 'ignore' })
334
+ proc.on('error', () => { /* warm is best effort */ })
335
+ proc.unref()
336
+ } catch { /* warm is best effort */ }
337
+ }
338
+
339
+ /** Rank-fusion retrieval through COS scripts/cue_memory.py. Transcript text goes over stdin, never argv. Non-fatal. */
340
+ export function cueMemory(window: string, before: string, signal: AbortSignal, p: CueMemoryPaths | null = cueMemoryPaths(), timeoutMs: number = LIVE_CUES_V2_LIMITS.memoryTimeoutMs): Promise<MemoryResult> {
341
+ if (!p) return Promise.resolve({ notes: [], denseError: 'no_cue_memory_script' })
342
+ const paths = p
343
+ return new Promise(resolveNotes => {
344
+ let proc: ChildProcess, out = '', settled = false
345
+ const done = (r: MemoryResult) => { if (settled) return; settled = true; clearTimeout(timer); signal.removeEventListener('abort', onAbort); resolveNotes(r) }
346
+ const kill = () => { if (proc) void terminateProviderProcess(proc).catch(() => {}) }
347
+ const onAbort = () => { kill(); done({ notes: [] }) }
348
+ const timer = setTimeout(() => { kill(); done({ notes: [], denseError: 'memory_timeout' }) }, timeoutMs)
349
+ try {
350
+ proc = spawn(paths.python, [paths.script, '--stdin'], { cwd: paths.cwd, detached: true, stdio: ['pipe', 'pipe', 'ignore'] })
351
+ } catch { done({ notes: [], denseError: 'spawn_failed' }); return }
352
+ signal.addEventListener('abort', onAbort, { once: true })
353
+ proc.stdout?.on('data', b => { out += String(b); if (out.length > 200_000) { kill(); done({ notes: [], denseError: 'output_limit' }) } })
354
+ proc.on('error', () => done({ notes: [], denseError: 'spawn_failed' }))
355
+ proc.on('close', () => {
356
+ try {
357
+ const res = JSON.parse(out) as { notes?: MemoryNote[]; timing?: { dense_error?: string }; index?: { ageHours?: number } }
358
+ const notes = (res.notes ?? []).filter(n => n && typeof n.id === 'string' && typeof n.notes === 'string').slice(0, 4)
359
+ done({ notes, denseError: res.timing?.dense_error, indexAgeH: res.index?.ageHours })
360
+ } catch { done({ notes: [], denseError: 'bad_output' }) }
361
+ })
362
+ proc.stdin?.on('error', () => { /* child exited early */ })
363
+ proc.stdin?.end(JSON.stringify({ queries: [window.slice(-600)], keywordText: window, before, top: 4 }))
364
+ })
365
+ }
366
+
367
+ // ── Engine ──────────────────────────────────────────────────────────────────
368
+ interface Session {
369
+ sessionId: string
370
+ title: string
371
+ armedAt: number
372
+ lastSeenAt: number
373
+ chunks: string[]
374
+ wordsSinceEval: number
375
+ lastEvalAt: number
376
+ lastCueAt: number
377
+ inFlight: AbortController | null
378
+ cues: LiveCueV2[]
379
+ evaluations: number
380
+ failures: number
381
+ breaker: boolean
382
+ /** The last reason cues were not produced, cleared by the next evaluation that runs the writer. */
383
+ stopReason: LiveCuesV2StopReason | null
384
+ skipTracedAt: Record<string, number>
385
+ }
386
+
387
+ export class LiveCuesV2ArmError extends Error {
388
+ constructor(readonly code: string, readonly status: number, message: string) { super(message); this.name = 'LiveCuesV2ArmError' }
389
+ }
390
+
391
+ function isCancel(error: unknown, signal: AbortSignal): boolean {
392
+ return signal.aborted || (error instanceof Error && error.name === 'AbortError')
393
+ }
394
+
395
+ export class LiveCuesV2Engine {
396
+ private readonly sessions = new Map<string, Session>()
397
+ constructor(private readonly deps: LiveCuesV2Deps) {}
398
+
399
+ capability(): LiveCuesV2Capability {
400
+ const model = liveCuesV2Model(this.deps.env)
401
+ const off = (reason: string): LiveCuesV2Capability => ({ available: false, reason, protocol: LIVE_CUES_V2_PROTOCOL, model })
402
+ try {
403
+ if (!liveCuesV2Enabled(this.deps.env)) return off('master_flag_off')
404
+ if (this.deps.env.ANTHROPIC_API_KEY && this.deps.env.COS_ALLOW_API_FALLBACK !== '1') return off('api_key_in_env')
405
+ if (!resolveJevKey()) return off('jev_not_configured')
406
+ if (!resolveProviderBinary('claude').ok) return off('claude_not_found')
407
+ if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env)) return off('budget_exhausted')
408
+ const jev = this.deps.jevState()
409
+ if (jev.capReached) return off('jev_cap_reached')
410
+ if (jev.breakerOpen) return off('jev_breaker_open')
411
+ return { available: true, protocol: LIVE_CUES_V2_PROTOCOL, model }
412
+ } catch {
413
+ return off('unavailable')
414
+ }
415
+ }
416
+
417
+ /** Idempotent. A repeat start for an armed meeting keeps its counters; an ended meeting cannot be re-armed. */
418
+ arm(sessionId: string): { armed: true; sessionId: string; protocol: 2; cuesGenerated: number; paused: boolean; pauseSeq: number } {
419
+ this.reap()
420
+ const cap = this.capability()
421
+ if (!cap.available) throw new LiveCuesV2ArmError(cap.reason ?? 'unavailable', cap.reason === 'master_flag_off' ? 403 : 503, `Live cues v2 unavailable: ${cap.reason}.`)
422
+ if (this.deps.store.isEnded(sessionId)) throw new LiveCuesV2ArmError('meeting_ended', 409, 'This meeting has ended; live cues cannot be re-armed for it.')
423
+ const existing = this.sessions.get(sessionId)
424
+ const nowMs = this.deps.now().getTime()
425
+ const answer = (cues: number) => ({ armed: true as const, sessionId, protocol: 2 as const, cuesGenerated: cues, paused: this.deps.store.isPaused(sessionId), pauseSeq: this.deps.store.pauseSeq(sessionId) })
426
+ if (existing) { existing.lastSeenAt = nowMs; return answer(existing.cues.length) }
427
+ const d = this.deps.now()
428
+ const title = `G2 Recording ${d.toLocaleDateString('en-CA')} ${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`
429
+ this.sessions.set(sessionId, { sessionId, title, armedAt: nowMs, lastSeenAt: nowMs, chunks: [], wordsSinceEval: 0, lastEvalAt: 0, lastCueAt: 0,
430
+ inFlight: null, cues: [], evaluations: 0, failures: 0, breaker: false, stopReason: null, skipTracedAt: {} })
431
+ this.deps.warmMemory()
432
+ return answer(this.deps.store.counters(sessionId).cues)
433
+ }
434
+
435
+ /** Stop or save: tombstone FIRST (so trailing chunks cannot revive it), then abort any evaluation in flight. */
436
+ end(sessionId: string): { nudgesGenerated: number; nudges: Array<Record<string, unknown>>; protocol: 2; cues: LiveCueV2[] } {
437
+ this.deps.store.end(sessionId)
438
+ const s = this.sessions.get(sessionId)
439
+ s?.inFlight?.abort()
440
+ const cues = s ? [...s.cues] : []
441
+ return { nudgesGenerated: cues.length, protocol: 2, cues,
442
+ nudges: cues.map(c => ({ nudge: c.headline, type: c.kind, priority: 2, timestamp: c.createdAt, cueId: c.cueId })) }
443
+ }
444
+
445
+ setPaused(sessionId: string, paused: boolean, seq?: number): { ok: true; paused: boolean; applied: boolean; pauseSeq: number } {
446
+ const r = this.deps.store.setPaused(sessionId, paused, seq)
447
+ if (r.applied && paused) this.sessions.get(sessionId)?.inFlight?.abort()
448
+ return { ok: true, ...r }
449
+ }
450
+
451
+ status(sessionId: string): LiveCuesV2StatusResponse {
452
+ const s = this.sessions.get(sessionId)
453
+ const ended = this.deps.store.isEnded(sessionId)
454
+ const paused = this.deps.store.isPaused(sessionId)
455
+ if (!s && !ended) return { protocol: 2, enabled: liveCuesV2Enabled(this.deps.env), session: null }
456
+ const counters = this.deps.store.counters(sessionId)
457
+ const reason = s ? this.stopReasonOf(s, counters) : null
458
+ const session: LiveCuesV2SessionStatus = {
459
+ sessionId, state: ended ? 'ended' : paused ? 'paused' : reason ? 'stopped' : 'active', ...(reason && !ended && !paused ? { reason } : {}),
460
+ paused, ended, pauseSeq: this.deps.store.pauseSeq(sessionId), cues: s ? [...s.cues] : [], evaluations: s?.evaluations ?? 0, writerCalls: counters.writerCalls,
461
+ }
462
+ return { protocol: 2, enabled: liveCuesV2Enabled(this.deps.env), session }
463
+ }
464
+
465
+ private stopReasonOf(s: Session, counters: { cues: number; writerCalls: number }): LiveCuesV2StopReason | null {
466
+ const L = LIVE_CUES_V2_LIMITS
467
+ if (s.breaker) return 'breaker_open'
468
+ if (counters.cues >= L.maxCuesPerMeeting || counters.writerCalls >= L.maxWriterCallsPerMeeting) return 'meeting_cap'
469
+ if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env)) return 'budget_exhausted'
470
+ return s.stopReason
471
+ }
472
+
473
+ recordFeedback(input: { sessionId: string; cueId: string; vote: 'up' | 'down'; reason?: string }): { ok: true; knownCue: boolean } {
474
+ const knownCue = !!this.sessions.get(input.sessionId)?.cues.some(c => c.cueId === input.cueId)
475
+ const reason = (input.reason ?? '').replace(/\s+/g, ' ').trim().slice(0, CUE_FEEDBACK_REASON_MAX)
476
+ this.deps.trace({ kind: 'feedback', at: this.deps.now().toISOString(), sessionId: input.sessionId, cueId: input.cueId, vote: input.vote, knownCue, ...(reason ? { reason } : {}) })
477
+ return { ok: true, knownCue }
478
+ }
479
+
480
+ /** From the ASR path: fire-and-forget, never awaited. All gates live here. */
481
+ async feed(sessionId: string, text: string, asrDegraded = false): Promise<void> {
482
+ if (!liveCuesV2Enabled(this.deps.env)) return
483
+ if (this.deps.store.isEnded(sessionId)) return // tombstone before any auto-arm
484
+ let s = this.sessions.get(sessionId)
485
+ if (!s) {
486
+ if (this.deps.env.COS_LIVE_CUES_AUTO !== '1') return
487
+ try { this.arm(sessionId) } catch { return }
488
+ s = this.sessions.get(sessionId)
489
+ if (!s) return
490
+ }
491
+ const nowMs = this.deps.now().getTime()
492
+ s.lastSeenAt = nowMs
493
+ const words = text.trim().split(/\s+/).filter(Boolean)
494
+ if (words.length) {
495
+ s.chunks.push(text.trim())
496
+ s.wordsSinceEval += words.length
497
+ let total = s.chunks.reduce((n, c) => n + c.split(/\s+/).length, 0)
498
+ while (s.chunks.length > 1 && total > LIVE_CUES_V2_LIMITS.windowWords) { total -= s.chunks[0].split(/\s+/).length; s.chunks.shift() }
499
+ }
500
+ const L = LIVE_CUES_V2_LIMITS
501
+ if (s.inFlight || asrDegraded) return
502
+ if (s.breaker) return this.traceSkip(s, 'breaker_open', nowMs)
503
+ if (this.deps.store.isPaused(sessionId)) return this.traceSkip(s, 'paused', nowMs)
504
+ if (s.wordsSinceEval < L.minNewWords) return
505
+ if (nowMs - s.lastEvalAt < L.minIntervalMs || nowMs - s.lastCueAt < L.cooldownAfterCueMs) return
506
+ const counters = this.deps.store.counters(sessionId)
507
+ if (counters.cues >= L.maxCuesPerMeeting || counters.writerCalls >= L.maxWriterCallsPerMeeting) return this.traceSkip(s, 'meeting_cap', nowMs)
508
+ if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env)) return this.traceSkip(s, 'budget_exhausted', nowMs) // before the gate
509
+ if (this.deps.busy()) return this.traceSkip(s, 'busy', nowMs)
510
+ await this.evaluate(s)
511
+ }
512
+
513
+ private traceSkip(s: Session, reason: string, nowMs: number): void {
514
+ if (nowMs - (s.skipTracedAt[reason] ?? 0) < LIVE_CUES_V2_LIMITS.skipTraceEveryMs) return
515
+ s.skipTracedAt[reason] = nowMs
516
+ this.deps.trace({ kind: 'skip', at: new Date(nowMs).toISOString(), sessionId: s.sessionId, reason })
517
+ }
518
+
519
+ private async evaluate(s: Session): Promise<void> {
520
+ const L = LIVE_CUES_V2_LIMITS
521
+ const started = this.deps.now().getTime()
522
+ const abort = new AbortController()
523
+ s.inFlight = abort
524
+ s.evaluations += 1
525
+ s.lastEvalAt = started
526
+ s.wordsSinceEval = 0
527
+ const window = cleanWindow(s.chunks.join('\n')).slice(-L.windowChars)
528
+ const trace: Record<string, unknown> = { kind: 'evaluation', at: new Date(started).toISOString(), sessionId: s.sessionId, n: s.evaluations }
529
+ let lease: MaintenanceWorkLease | null = null
530
+ let wallHit = false
531
+ const wall = setTimeout(() => { wallHit = true; abort.abort() }, L.pipelineWallMs)
532
+ wall.unref?.()
533
+ try {
534
+ try { lease = this.deps.lease() } catch (e) { if (e instanceof MaintenanceLifecycleError) { trace.outcome = 'maintenance'; return } throw e }
535
+ const t0 = Date.now()
536
+ const gate = await this.deps.gate(s.title, window, abort.signal)
537
+ const pCue = Number.isFinite(gate.pCue) ? gate.pCue : 0
538
+ Object.assign(trace, { gateMs: Date.now() - t0, pCue: round(pCue), cueKind: gate.kind, lookup: gate.lookup, gateTokens: gate.tokens })
539
+ if (pCue < liveCuesV2Gate(this.deps.env)) { trace.outcome = 'gate_silent'; s.stopReason = null; return }
540
+ if (abort.signal.aborted) { trace.outcome = 'cancelled'; return }
541
+
542
+ const t1 = Date.now()
543
+ const before = new Date(started + 86_400_000).toLocaleDateString('en-CA')
544
+ const mem: MemoryResult = await this.deps.memory(window, before, abort.signal).catch((): MemoryResult => ({ notes: [], denseError: 'memory_threw' }))
545
+ Object.assign(trace, { memoryMs: Date.now() - t1, candidates: mem.notes.length, ...(mem.denseError ? { memoryError: mem.denseError } : {}), ...(mem.indexAgeH !== undefined ? { indexAgeH: mem.indexAgeH } : {}) })
546
+ let kept: MemoryNote[] = []
547
+ if (mem.notes.length && !abort.signal.aborted) {
548
+ const t2 = Date.now()
549
+ const judged = await this.deps.judge(s.title, window, mem.notes, abort.signal)
550
+ Object.assign(trace, { judgeMs: Date.now() - t2, judgeTokens: judged.tokens })
551
+ kept = mem.notes.filter(m => (judged.probs[m.id] ?? 0) >= L.judgeKeep).slice(0, L.judgeMaxNotes).map((m, i) => ({ ...m, id: `M${i + 1}` }))
552
+ }
553
+ trace.kept = kept.length
554
+ if (abort.signal.aborted) { trace.outcome = 'cancelled'; return }
555
+
556
+ if (!this.deps.store.charge(liveCuesV2WeeklyCap(this.deps.env), s.sessionId)) { trace.outcome = 'budget_exhausted'; s.stopReason = 'budget_exhausted'; return }
557
+ const { version, system } = liveCuesV2WriterSystem(this.deps.env)
558
+ const model = liveCuesV2Model(this.deps.env)
559
+ const previous = s.cues.slice(-3).map(c => c.detail)
560
+ const promptMemory = kept.map(m => ({ id: m.id, date: m.date, title: m.title, notes: m.notes })) // the replay's exact keys
561
+ const prompt = pyJson({ meetingTitle: s.title, liveIssue: '', suggestedKind: gate.kind, previousCues: previous, memory: promptMemory, window })
562
+ const t3 = Date.now()
563
+ const out = await this.deps.writer(model, system, prompt, abort.signal)
564
+ Object.assign(trace, { writerMs: Date.now() - t3, prompt: version, model: out.model ?? model, writerUsd: out.costUsd, writerIn: out.inputTokens, writerOut: out.outputTokens })
565
+ if (abort.signal.aborted) { trace.outcome = 'cancelled'; return }
566
+ s.failures = 0
567
+ s.stopReason = null
568
+
569
+ const parsed = parseWriterCue(out.text, gate.kind, window, kept, this.deps.env.COS_LIVE_CUES_V2_NUMBER_GUARD === 'withhold')
570
+ if (typeof parsed === 'string') { trace.outcome = parsed === 'null' ? 'writer_null' : `withheld_${parsed}`; return }
571
+ const elapsed = this.deps.now().getTime() - started
572
+ if (s.wordsSinceEval > L.staleWords || elapsed > L.staleMs) { trace.outcome = 'stale'; return }
573
+ if (this.deps.store.isEnded(s.sessionId) || this.deps.store.isPaused(s.sessionId)) { trace.outcome = 'cancelled'; return }
574
+
575
+ const sources: CueSourceRef[] = parsed.memoryIds.map(id => kept.find(m => m.id === id)).filter((m): m is MemoryNote => !!m)
576
+ .map(m => ({ id: sourceId(m), date: m.date, title: m.title }))
577
+ const cue: LiveCueV2 = { protocol: 2, cueId: randomUUID(), sessionId: s.sessionId, headline: cueHeadline(parsed.detail), detail: parsed.detail,
578
+ kind: parsed.kind, sources, gate: round(pCue), createdAt: this.deps.now().getTime(), model: out.model ?? model }
579
+ if (!isLiveCueV2(cue)) { trace.outcome = 'withheld_invalid'; return } // never emit what the companion would drop
580
+ s.cues.push(cue)
581
+ s.lastCueAt = cue.createdAt
582
+ this.deps.store.recordCue(s.sessionId)
583
+ this.deps.emit('coaching_nudge_v2', cue as unknown as Record<string, unknown>)
584
+ const v1: LiveCueV1Compat = { nudge: cue.headline, type: cue.kind, priority: 2, cueId: cue.cueId, sessionId: s.sessionId, protocol: 2 }
585
+ this.deps.emit('coaching_nudge', v1 as unknown as Record<string, unknown>)
586
+ Object.assign(trace, { outcome: 'cue', cueId: cue.cueId, cueChars: cue.detail.length, sources: sources.length, ...(parsed.numberFlags.length ? { numberFlags: parsed.numberFlags.length } : {}) })
587
+ } catch (error) {
588
+ if (wallHit) { trace.outcome = 'failure'; trace.error = 'wall_timeout'; s.failures += 1; if (s.failures >= L.breakerFailures) s.breaker = true; return }
589
+ if (isCancel(error, abort.signal)) { trace.outcome = 'cancelled'; return }
590
+ if (error instanceof JevError && ['jev_cap_reached', 'jev_breaker_open', 'jev_not_configured'].includes(error.code)) {
591
+ trace.outcome = error.code
592
+ s.stopReason = error.code as LiveCuesV2StopReason
593
+ return
594
+ }
595
+ trace.outcome = 'failure'
596
+ trace.error = error instanceof JevError ? error.code : error instanceof LensGistLimitError ? 'writer_quota' : 'writer_error'
597
+ s.failures += 1
598
+ if (error instanceof LensGistLimitError || s.failures >= L.breakerFailures) s.breaker = true
599
+ } finally {
600
+ clearTimeout(wall)
601
+ if (wallHit && trace.outcome === 'cancelled') { trace.outcome = 'failure'; trace.error = 'wall_timeout'; s.failures += 1; if (s.failures >= L.breakerFailures) s.breaker = true }
602
+ s.inFlight = null
603
+ try { lease?.release() } catch { /* released */ }
604
+ trace.totalMs = this.deps.now().getTime() - started
605
+ this.deps.trace(trace)
606
+ }
607
+ }
608
+
609
+ private reap(): void {
610
+ const nowMs = this.deps.now().getTime()
611
+ for (const [id, s] of this.sessions) if (!s.inFlight && nowMs - s.lastSeenAt > LIVE_CUES_V2_LIMITS.sessionTtlMs) this.sessions.delete(id)
612
+ }
613
+
614
+ shutdown(): void { for (const s of this.sessions.values()) s.inFlight?.abort(); this.sessions.clear() }
615
+ }
616
+
617
+ function round(n: number): number { return Math.round(n * 1000) / 1000 }
618
+
619
+ // ── Singleton wiring ────────────────────────────────────────────────────────
620
+ let engine: LiveCuesV2Engine | null = null
621
+ let unsubscribe: (() => void) | null = null
622
+ export function liveCuesV2(): LiveCuesV2Engine {
623
+ if (!engine) {
624
+ engine = new LiveCuesV2Engine(realDeps())
625
+ // A saved meeting ends its cues even when the phone never sent Stop (orphan recovery, Control save).
626
+ unsubscribe = onDisplay(event => {
627
+ const sid = event.type === 'recording_stop' ? event.data?.sessionId : null
628
+ if (typeof sid === 'string' && sid && liveCuesV2Enabled()) engine?.end(sid)
629
+ })
630
+ }
631
+ return engine
632
+ }
633
+ export function liveCuesV2Capability(): LiveCuesV2Capability { return liveCuesV2().capability() }
634
+ /** What /api/health, /api/models and the status route advertise: NOTHING while v2 is switched off, so a 6.10.642
635
+ * companion treats the server exactly like a v1 server (v1 cues keep their own capability and indicator). */
636
+ export function liveCuesV2Advertised(env: NodeJS.ProcessEnv = process.env): LiveCuesV2Capability | undefined {
637
+ return liveCuesV2Enabled(env) ? liveCuesV2Capability() : undefined
638
+ }
639
+ export function shutdownLiveCuesV2(): void { engine?.shutdown(); unsubscribe?.(); unsubscribe = null; engine = null }
@@ -28,9 +28,9 @@ export const displayRouter = Router()
28
28
  * The projection is the contract: whatever the emitter grows later, only the fields
29
29
  * named here can ever leave.
30
30
  *
31
- * The other ten members of the union all carry user content: `transcript_chunk` and
31
+ * The other eleven members of the union all carry user content: `transcript_chunk` and
32
32
  * `prompt_transcript` carry meeting speech with speaker labels, `chunk`/`done` carry
33
- * answer text, `session_restore` carries conversation state, `coaching_nudge` carries
33
+ * answer text, `session_restore` carries conversation state, `coaching_nudge` and `coaching_nudge_v2` carry
34
34
  * derived guidance, `start` carries session metadata, `tool_status` carries a message,
35
35
  * `error` carries error text, and `recording_start` is covered by its own note below.
36
36
  *
@@ -68,6 +68,7 @@ import { getServerGenerationId } from '../lib/managed-runtime.js'
68
68
  import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
69
69
  import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
70
70
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
71
+ import { liveCuesV2Advertised } from '../lib/live-cues-v2.js'
71
72
  import { getMeetingSyncSnapshot } from '../lib/meeting-batch-progress.js'
72
73
  import { listUnsavedCaptures } from '../lib/unsaved-audio-quarantine.js'
73
74
  import { getStrandedCaptures } from './transcribe-stream.js'
@@ -495,6 +496,7 @@ healthRouter.get('/health', async (_req, res) => {
495
496
  },
496
497
  cliDebug: CLI_DEBUG_CAPABILITY,
497
498
  liveCues,
499
+ ...(liveCuesV2Advertised() ? { liveCuesV2: liveCuesV2Advertised() } : {}),
498
500
  richMedia: {
499
501
  text: true,
500
502
  pdf: richMedia.pdf,
@@ -668,6 +670,7 @@ healthRouter.get('/models', async (req, res) => {
668
670
  // THIS surface, so a value present only on /api/health leaves the
669
671
  // live-cues indicator blind.
670
672
  liveCues: liveCuesCapability(),
673
+ ...(liveCuesV2Advertised() ? { liveCuesV2: liveCuesV2Advertised() } : {}),
671
674
  richMedia: {
672
675
  video: richMedia.video,
673
676
  maxPromptVisuals: MAX_MODEL_IMAGE_INPUTS,
@@ -14,6 +14,14 @@ import {
14
14
  LiveCuesArmError,
15
15
  } from '../lib/live-cues-engine.js'
16
16
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
17
+ import { LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled } from '../lib/live-cues-v2.js'
18
+
19
+ const SESSION_ID = /^[A-Za-z0-9:_-]{3,96}$/
20
+ const CUE_ID = /^[A-Za-z0-9-]{8,64}$/
21
+ function bodySessionId(body: unknown): string {
22
+ const raw = (body as { sessionId?: unknown })?.sessionId
23
+ return typeof raw === 'string' ? raw.trim() : ''
24
+ }
17
25
 
18
26
  export const liveCuesRouter = Router()
19
27
 
@@ -23,13 +31,17 @@ liveCuesRouter.post('/live-cues/start', async (req, res) => {
23
31
  : ''
24
32
  // A start with no sessionId is refused, never silently armed on a global
25
33
  // counter — the per-meeting cap is only real when it is session-scoped.
26
- if (!sessionId || !/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
34
+ if (!sessionId || !SESSION_ID.test(sessionId)) {
27
35
  return res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
28
36
  }
29
37
  try {
38
+ if (liveCuesV2Enabled()) return res.json(liveCuesV2().arm(sessionId))
30
39
  const result = await armLiveCues(sessionId)
31
40
  return res.json(result)
32
41
  } catch (error) {
42
+ if (error instanceof LiveCuesV2ArmError) {
43
+ return res.status(error.status).json({ error: error.message, reason: error.code, protocol: 2 })
44
+ }
33
45
  if (error instanceof LiveCuesArmError) {
34
46
  return res.status(error.status).json({ error: error.message, reason: error.code })
35
47
  }
@@ -41,18 +53,51 @@ liveCuesRouter.post('/live-cues/stop', (req, res) => {
41
53
  const sessionId = typeof (req.body as { sessionId?: unknown })?.sessionId === 'string'
42
54
  ? String((req.body as { sessionId: string }).sessionId).trim()
43
55
  : ''
44
- if (!sessionId) {
56
+ if (!sessionId || (liveCuesV2Enabled() && !SESSION_ID.test(sessionId))) {
45
57
  return res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
46
58
  }
47
59
  // Shape matches the existing client stop handler: it reads nudgesGenerated
48
60
  // for its log line and hydrates meetingNudgeHistory from nudges when SSE
49
61
  // missed events (the 200-event replay buffer is flooded by transcript_chunk).
62
+ if (liveCuesV2Enabled()) return res.json(liveCuesV2().end(sessionId))
50
63
  return res.json(disarmLiveCues(sessionId))
51
64
  })
52
65
 
53
- liveCuesRouter.get('/live-cues/status', (_req, res) => {
66
+ liveCuesRouter.get('/live-cues/status', (req, res) => {
67
+ const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId.trim() : ''
68
+ // v2 with a sessionId answers the per-meeting shape; without one the v1 shape is unchanged (plus v2 capability).
69
+ if (sessionId && liveCuesV2Enabled()) {
70
+ if (!SESSION_ID.test(sessionId)) return res.status(400).json({ error: 'invalid sessionId', reason: 'invalid_session_id' })
71
+ return res.json(liveCuesV2().status(sessionId))
72
+ }
54
73
  res.json({
55
74
  capability: liveCuesCapability(),
56
75
  sessions: getLiveCuesStatus(),
76
+ ...(liveCuesV2Advertised() ? { v2: liveCuesV2Advertised() } : {}),
57
77
  })
58
78
  })
79
+
80
+ // POST /live-cues/pause { sessionId, paused, seq? } -> { ok, paused, applied, pauseSeq } (v2 only; the phone keeps its own local pause)
81
+ liveCuesRouter.post('/live-cues/pause', (req, res) => {
82
+ const sessionId = bodySessionId(req.body)
83
+ const paused = (req.body as { paused?: unknown })?.paused
84
+ const seq = (req.body as { seq?: unknown })?.seq
85
+ if (!SESSION_ID.test(sessionId) || typeof paused !== 'boolean' || (seq !== undefined && !(typeof seq === 'number' && Number.isSafeInteger(seq) && seq > 0))) {
86
+ return res.status(400).json({ error: 'sessionId and boolean paused required', reason: 'invalid_request' })
87
+ }
88
+ if (!liveCuesV2Enabled()) return res.status(404).json({ error: 'live cues v2 is off', reason: 'v2_disabled' })
89
+ return res.json(liveCuesV2().setPaused(sessionId, paused, seq as number | undefined))
90
+ })
91
+
92
+ // POST /live-cues/feedback { sessionId, cueId, vote: 'up'|'down', reason? } -> { ok, knownCue }
93
+ liveCuesRouter.post('/live-cues/feedback', (req, res) => {
94
+ const body = (req.body ?? {}) as { cueId?: unknown; vote?: unknown; reason?: unknown }
95
+ const sessionId = bodySessionId(req.body)
96
+ const cueId = typeof body.cueId === 'string' ? body.cueId.trim() : ''
97
+ if (!SESSION_ID.test(sessionId) || !CUE_ID.test(cueId) || (body.vote !== 'up' && body.vote !== 'down')
98
+ || (body.reason !== undefined && typeof body.reason !== 'string')) {
99
+ return res.status(400).json({ error: 'sessionId, cueId and vote (up|down) required', reason: 'invalid_request' })
100
+ }
101
+ if (!liveCuesV2Enabled()) return res.status(404).json({ error: 'live cues v2 is off', reason: 'v2_disabled' })
102
+ return res.json(liveCuesV2().recordFeedback({ sessionId, cueId, vote: body.vote, reason: body.reason as string | undefined }))
103
+ })
@@ -2443,7 +2443,8 @@ async function processStreamChunk(opts: {
2443
2443
  // block on a cue, and no LLM runs on this path. .catch() is required — a
2444
2444
  // bare `void` on a rejecting promise is an unhandled rejection, which Node
2445
2445
  // throws on by default. All gates live inside the feed.
2446
- void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
2446
+ // The ASR await above lets a late chunk land after Stop or save; a closed session never feeds cues.
2447
+ if (!isSessionDeleted(sessionId)) void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
2447
2448
 
2448
2449
  console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} gate=${quiet.gate}${quiet.speechSec === null ? '' : ` speech=${quiet.speechSec.toFixed(2)}s`} fe=${stt.frontEnd} | ${asrProvider} | "${trimmedText.slice(0, 50)}"`)
2449
2450
  return canonicalChunkResponse(chunk, sessionId, chunkIndex)
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Live cues v2 wire contract (server 6.75 candidate, companion 6.10.641 candidate).
3
+ * Additive to v1: old clients keep receiving `coaching_nudge` and ignore the new named event.
4
+ * The companion keeps a byte-identical copy at src/lib/live-cues-v2-contract.ts.
5
+ */
6
+ export const LIVE_CUES_V2_PROTOCOL = 2 as const
7
+ export const CUE_HEADLINE_MAX = 64
8
+ export const CUE_DETAIL_MAX = 220
9
+ export const CUE_FEEDBACK_REASON_MAX = 200
10
+
11
+ export type CueKind = 'reinforce' | 'challenge'
12
+
13
+ /** A past meeting the cue drew on (0 to 2 per cue). */
14
+ export interface CueSourceRef { id: string; date: string; title: string }
15
+
16
+ /** SSE named event `coaching_nudge_v2` (emitted BEFORE its v1 `coaching_nudge` twin), and the rows of GET /api/live-cues/status?sessionId=. */
17
+ export interface LiveCueV2 {
18
+ protocol: 2
19
+ cueId: string
20
+ sessionId: string
21
+ /** <= 64 chars: the lens footer line. */
22
+ headline: string
23
+ /** <= 220 chars: the full cue, shown on tap and in the phone Cues sheet. */
24
+ detail: string
25
+ kind: CueKind
26
+ sources: CueSourceRef[]
27
+ /** Admission probability from the gate, 0 to 1. */
28
+ gate: number
29
+ createdAt: number
30
+ model: string
31
+ }
32
+
33
+ /** v1 `coaching_nudge` as the v2 engine emits it for old clients (headline only). */
34
+ export interface LiveCueV1Compat { nudge: string; type: CueKind; priority: 2; cueId: string; sessionId: string; protocol: 2 }
35
+
36
+ /** `reason` when unavailable: master_flag_off, api_key_in_env, jev_not_configured, claude_not_found, budget_exhausted,
37
+ * jev_cap_reached, jev_breaker_open. */
38
+ export interface LiveCuesV2Capability { available: boolean; reason?: string; protocol: 2; model: string }
39
+
40
+ /** Why cues are not coming for this meeting. `active` means evaluations are running normally (silence is a choice). */
41
+ export type LiveCuesV2SessionState = 'active' | 'paused' | 'ended' | 'stopped'
42
+ export type LiveCuesV2StopReason = 'breaker_open' | 'meeting_cap' | 'budget_exhausted' | 'jev_cap_reached' | 'jev_breaker_open' | 'jev_not_configured'
43
+
44
+ export interface LiveCuesV2SessionStatus {
45
+ sessionId: string
46
+ state: LiveCuesV2SessionState
47
+ /** Present when state is `stopped`. */
48
+ reason?: LiveCuesV2StopReason
49
+ paused: boolean
50
+ ended: boolean
51
+ /** The pause request sequence the server last applied (0 when none). */
52
+ pauseSeq: number
53
+ cues: LiveCueV2[]
54
+ evaluations: number
55
+ writerCalls: number
56
+ }
57
+
58
+ /** GET /api/live-cues/status?sessionId=X (v2 server). Without sessionId the v1 array shape is unchanged. */
59
+ export interface LiveCuesV2StatusResponse { protocol: 2; enabled: boolean; session: LiveCuesV2SessionStatus | null }
60
+
61
+ /** POST /api/live-cues/pause -> { ok, paused, applied, pauseSeq }. `seq` is a per-meeting counter from the phone; the server
62
+ * ignores a request whose seq is not newer than the last applied one (applied: false) so out-of-order posts cannot win. */
63
+ export interface LiveCuesPauseRequest { sessionId: string; paused: boolean; seq?: number }
64
+ /** POST /api/live-cues/feedback */
65
+ export interface LiveCuesFeedbackRequest { sessionId: string; cueId: string; vote: 'up' | 'down'; reason?: string }
66
+
67
+ export function isLiveCueV2(value: unknown): value is LiveCueV2 {
68
+ if (!value || typeof value !== 'object') return false
69
+ const v = value as Record<string, unknown>
70
+ return v.protocol === 2 && typeof v.cueId === 'string' && v.cueId.length > 0 && typeof v.sessionId === 'string'
71
+ && typeof v.headline === 'string' && v.headline.length > 0 && v.headline.length <= CUE_HEADLINE_MAX
72
+ && typeof v.detail === 'string' && v.detail.length > 0 && v.detail.length <= CUE_DETAIL_MAX
73
+ && (v.kind === 'reinforce' || v.kind === 'challenge')
74
+ && Array.isArray(v.sources) && v.sources.length <= 2
75
+ && v.sources.every(s => !!s && typeof (s as CueSourceRef).id === 'string' && typeof (s as CueSourceRef).date === 'string' && typeof (s as CueSourceRef).title === 'string')
76
+ && typeof v.gate === 'number' && typeof v.createdAt === 'number' && typeof v.model === 'string'
77
+ }