@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 +12 -0
- package/managed-runtime-contract.json +7 -0
- package/package.json +1 -1
- package/server/index.ts +2 -0
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/jev.ts +12 -2
- package/server/lib/live-cues-engine.ts +3 -0
- package/server/lib/live-cues-v2-prompts.ts +41 -0
- package/server/lib/live-cues-v2.ts +639 -0
- package/server/routes/display.ts +2 -2
- package/server/routes/health.ts +3 -0
- package/server/routes/live-cues.ts +48 -3
- package/server/routes/transcribe-stream.ts +2 -1
- package/shared/live-cues-v2.ts +77 -0
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.
|
|
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 {
|
|
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 {
|
|
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 }
|
package/server/routes/display.ts
CHANGED
|
@@ -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
|
|
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`
|
|
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
|
*
|
package/server/routes/health.ts
CHANGED
|
@@ -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 ||
|
|
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', (
|
|
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
|
-
|
|
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
|
+
}
|