@gotcos/glasses-server 6.73.14 → 6.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 }