@gotcos/glasses-server 6.17.0 → 6.18.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,167 @@
1
+ // Dedicated Composer spawn for Live Cues.
2
+ //
3
+ // callCursorStreaming is deliberately NOT used: per call it writes conversation
4
+ // history (getOrCreateSession/addExchange), logs token audit as 'g2-query'
5
+ // (the ledger that measures the user's own usage), registers a run in the
6
+ // Cursor run ledger, fires a Telegram push via notifyExchange, and resolves
7
+ // with the session id instead of the answer. A cue loop must perturb none of
8
+ // that. This module reuses only the pure exported helpers.
9
+
10
+ import { spawn, type ChildProcess } from 'node:child_process'
11
+ import {
12
+ buildCursorAgentArgs,
13
+ extractCursorResponseText,
14
+ } from './cursor-bridge.js'
15
+ import { resolveAgentBinary } from './cursor-model-catalog.js'
16
+ import { classifyCursorError, getCursorExecutionCwd } from './cursor-run-ledger.js'
17
+ import { terminalProviderAuthFailure } from './provider-terminal-error.js'
18
+ import { terminateProviderProcess } from './provider-process-lifecycle.js'
19
+ import { logTokenAudit } from './token-audit.js'
20
+
21
+ export type LiveCuesCaller = 'live-cues-planner' | 'live-cues-insight'
22
+
23
+ export interface ComposerAskSuccess {
24
+ ok: true
25
+ text: string
26
+ durationMs: number
27
+ }
28
+
29
+ export interface ComposerAskFailure {
30
+ ok: false
31
+ reason: string
32
+ authFailure: boolean
33
+ /** False means the process tree could not be proven dead. The caller must
34
+ * RETAIN its maintenance lease in that case (provider-process-lifecycle
35
+ * contract) — releasing early could let Control restart the server while a
36
+ * Cursor subprocess is still alive. */
37
+ treeClosed: boolean
38
+ durationMs: number
39
+ }
40
+
41
+ export type ComposerAskResult = ComposerAskSuccess | ComposerAskFailure
42
+
43
+ /** One stateless Composer ask. Fresh spawn, no session, no resume, no history. */
44
+ export async function composerAsk(input: {
45
+ prompt: string
46
+ modelId: string
47
+ caller: LiveCuesCaller
48
+ timeoutMs: number
49
+ /** Lets the engine track the live tree so gracefulShutdown can kill it. */
50
+ onProcess?: (proc: ChildProcess) => void
51
+ }): Promise<ComposerAskResult> {
52
+ const startedAt = Date.now()
53
+ const agentBinary = resolveAgentBinary()
54
+ if (!agentBinary) {
55
+ return { ok: false, reason: 'cursor.cli_unavailable', authFailure: false, treeClosed: true, durationMs: 0 }
56
+ }
57
+
58
+ const workspace = getCursorExecutionCwd()
59
+ const args = buildCursorAgentArgs({ workspace, modelId: input.modelId, executionMode: 'ask' })
60
+ const env = { ...process.env }
61
+ delete env.CLAUDECODE
62
+
63
+ return new Promise<ComposerAskResult>(resolve => {
64
+ let settled = false
65
+ let fullText = ''
66
+ let stderr = ''
67
+ let lineBuffer = ''
68
+ let timedOut = false
69
+
70
+ // detached: the CLI gets its own process group so a timeout kill reaches
71
+ // tool grandchildren. This is NEW relative to cursor-bridge (which spawns
72
+ // attached) — the shutdown path in the engine must also kill this tree.
73
+ const proc = spawn(agentBinary, args, {
74
+ stdio: ['pipe', 'pipe', 'pipe'],
75
+ env,
76
+ cwd: workspace,
77
+ detached: true,
78
+ })
79
+ input.onProcess?.(proc)
80
+
81
+ const finish = (result: ComposerAskResult) => {
82
+ if (settled) return
83
+ settled = true
84
+ clearTimeout(timer)
85
+ resolve(result)
86
+ }
87
+
88
+ const timer = setTimeout(() => {
89
+ timedOut = true
90
+ void terminateProviderProcess(proc).then(termination => {
91
+ finish({
92
+ ok: false,
93
+ reason: 'cursor.timeout',
94
+ authFailure: false,
95
+ treeClosed: termination.closed,
96
+ durationMs: Date.now() - startedAt,
97
+ })
98
+ })
99
+ }, Math.max(1_000, input.timeoutMs))
100
+
101
+ proc.on('error', () => {
102
+ finish({
103
+ ok: false,
104
+ reason: 'cursor.cli_unavailable',
105
+ authFailure: false,
106
+ treeClosed: !proc.pid,
107
+ durationMs: Date.now() - startedAt,
108
+ })
109
+ })
110
+
111
+ proc.stderr?.on('data', chunk => { stderr = (stderr + String(chunk)).slice(-2_000) })
112
+ proc.stdout?.on('data', chunk => {
113
+ lineBuffer += String(chunk)
114
+ let newlineIndex = lineBuffer.indexOf('\n')
115
+ while (newlineIndex !== -1) {
116
+ const line = lineBuffer.slice(0, newlineIndex).trim()
117
+ lineBuffer = lineBuffer.slice(newlineIndex + 1)
118
+ if (line) {
119
+ try {
120
+ fullText += extractCursorResponseText(JSON.parse(line))
121
+ } catch { /* non-JSON noise on stdout is ignored */ }
122
+ }
123
+ newlineIndex = lineBuffer.indexOf('\n')
124
+ }
125
+ })
126
+
127
+ proc.on('close', code => {
128
+ if (timedOut) return // the timeout branch owns the result
129
+ const durationMs = Date.now() - startedAt
130
+ const text = fullText.trim()
131
+ // Cursor can report auth failures as successful output with exit 0
132
+ // (provider-terminal-error.ts) — exit code alone cannot catch it, and a
133
+ // missed check would render "authentication required" as a coaching cue.
134
+ const authError = terminalProviderAuthFailure('cursor', text, stderr)
135
+ if (authError) {
136
+ finish({ ok: false, reason: 'cursor.auth_error', authFailure: true, treeClosed: true, durationMs })
137
+ return
138
+ }
139
+ if (code !== 0) {
140
+ finish({
141
+ ok: false,
142
+ reason: classifyCursorError(stderr || text || `exit ${code}`),
143
+ authFailure: false,
144
+ treeClosed: true,
145
+ durationMs,
146
+ })
147
+ return
148
+ }
149
+ if (!text) {
150
+ finish({ ok: false, reason: 'cursor.empty_response', authFailure: false, treeClosed: true, durationMs })
151
+ return
152
+ }
153
+ logTokenAudit({
154
+ source: 'live-cues',
155
+ model: 'cursor-composer',
156
+ inputChars: input.prompt.length,
157
+ outputChars: text.length,
158
+ durationMs,
159
+ caller: input.caller,
160
+ })
161
+ finish({ ok: true, text, durationMs })
162
+ })
163
+
164
+ proc.stdin?.write(input.prompt)
165
+ proc.stdin?.end()
166
+ })
167
+ }
@@ -0,0 +1,461 @@
1
+ // Live Cues engine — arm/disarm, transcript window, gates, single-flight,
2
+ // per-meeting counters, breaker, and the pipeline itself.
3
+ //
4
+ // Cost containment is the design center. Every recurring LLM caller must
5
+ // answer how it stops; here the answers are: per-meeting cap (8), single-flight,
6
+ // 60s floor, 30s cooldown, consecutive-failure breaker, weekly ceiling, the
7
+ // LightRAG reserve, and the COS_LIVE_CUES master switch. Every skip logs its
8
+ // reason — a silent cap is the same defect class as a silent fallback.
9
+
10
+ import type { ChildProcess } from 'node:child_process'
11
+ import { existsSync, readFileSync } from 'node:fs'
12
+ import { emitDisplay } from './display-bus.js'
13
+ import {
14
+ acquireMaintenanceWork,
15
+ MaintenanceLifecycleError,
16
+ maintenanceLifecycle,
17
+ type MaintenanceWorkLease,
18
+ } from './maintenance-lifecycle.js'
19
+ import { getCursorModelCatalog, resolveCursorModelOption } from './cursor-model-catalog.js'
20
+ import { CURSOR_COMPOSER_MODEL } from '../../shared/model-preference.js'
21
+ import { composerAsk } from './live-cues-cursor.js'
22
+ import { lightragExploreHop, semanticSearchHop } from './live-cues-memory.js'
23
+ import {
24
+ buildInsightPrompt,
25
+ buildPlannerPrompt,
26
+ isInsightResult,
27
+ isPlannerResult,
28
+ parseJsonReply,
29
+ } from './live-cues-prompt.js'
30
+ import {
31
+ liveCuesCapability,
32
+ liveCuesEnabled,
33
+ liveCuesGraphEnabled,
34
+ liveCuesModelSupported,
35
+ registerLiveCuesBudgetProbe,
36
+ } from './live-cues-capability.js'
37
+ import { terminateProviderProcess } from './provider-process-lifecycle.js'
38
+ import { atomicWriteFileSync } from './atomic-fs.js'
39
+ import { dataPath } from './data-dir.js'
40
+
41
+ // ── Gates ────────────────────────────────────────────────────────────────────
42
+ const MIN_BUFFER_WORDS = 40
43
+ const FLOOR_BETWEEN_STARTS_MS = 60_000
44
+ const COOLDOWN_AFTER_CUE_MS = 30_000
45
+ const MAX_PIPELINES_PER_MEETING = 8
46
+ // Must stay under COS Control's 90s drain timeout: a held live_cue_pipeline
47
+ // lease past the drain window hard-fails every Update Server to Repair.
48
+ const PIPELINE_WALL_MS = 60_000
49
+ const STALE_CUE_WORDS = 120
50
+ const BUFFER_CAP_WORDS = 400
51
+ const SESSION_TTL_MS = 4 * 60 * 60_000
52
+ const MAX_CONSECUTIVE_FAILURES = 3
53
+ const INSIGHT_RESERVE_MS = 15_000
54
+ const STAGE_MAX_MS = 15_000
55
+ const WEEKLY_COMPOSER_CEILING = 250
56
+
57
+ // Signal pre-filter, ported by symbol from the app engine's COACHING_SIGNALS
58
+ // (coaching-engine.ts:178-185 — six patterns; commitments, metrics, agreement,
59
+ // action items, promises, substantive questions).
60
+ const COACHING_SIGNALS = [
61
+ /\b(i'll|we'll|we should|let's|i will|i can|by friday|by monday|next week|tomorrow|deadline|due)\b/i,
62
+ /\b(how much|what's the|numbers?|percent|budget|revenue|pipeline|conversion|leads?|opps?)\b/i,
63
+ /\b(agree|sounds good|yeah let's|sure thing|okay let's|go ahead|approved?)\b/i,
64
+ /\b(action item|follow up|circle back|check in|send me|share the|can you)\b/i,
65
+ /\b(committed?|promised?|guarantee|timeline|milestone|deliverable)\b/i,
66
+ /\?(.{5,})/,
67
+ ]
68
+
69
+ export interface LiveCueNudge {
70
+ nudge: string
71
+ type: string
72
+ priority: number
73
+ timestamp: number
74
+ degraded?: boolean
75
+ degradationReason?: string
76
+ }
77
+
78
+ interface LiveCueSession {
79
+ sessionId: string
80
+ armedAt: number
81
+ lastSeenAt: number
82
+ modelId: string
83
+ pipelinesUsed: number
84
+ inFlight: boolean
85
+ lastPipelineStartAt: number
86
+ lastCueAt: number
87
+ consecutiveFailures: number
88
+ breakerTripped: boolean
89
+ bufferWords: string[]
90
+ wordsSincePipelineStart: number
91
+ nudges: LiveCueNudge[]
92
+ activeProcs: Set<ChildProcess>
93
+ lease: MaintenanceWorkLease | null
94
+ leaseHeldOpen: boolean
95
+ }
96
+
97
+ export class LiveCuesArmError extends Error {
98
+ constructor(readonly code: string, readonly status: number, message: string) {
99
+ super(message)
100
+ this.name = 'LiveCuesArmError'
101
+ }
102
+ }
103
+
104
+ const sessions = new Map<string, LiveCueSession>()
105
+
106
+ function skip(sessionId: string, reason: string, detail = ''): void {
107
+ console.log(`[live-cues] skip: ${reason}${detail ? ` ${detail}` : ''} (${sessionId})`)
108
+ }
109
+
110
+ // ── Weekly Composer ceiling (persisted; survives restarts) ───────────────────
111
+ interface WeeklyBudget { weekStart: string; composerCalls: number }
112
+
113
+ function currentWeekStart(): string {
114
+ const now = new Date()
115
+ const day = now.getDay()
116
+ const monday = new Date(now)
117
+ monday.setDate(now.getDate() - ((day + 6) % 7))
118
+ return monday.toISOString().slice(0, 10)
119
+ }
120
+
121
+ function budgetPath(): string {
122
+ return dataPath('live-cues-budget.json')
123
+ }
124
+
125
+ function readWeeklyBudget(): WeeklyBudget {
126
+ try {
127
+ if (existsSync(budgetPath())) {
128
+ const parsed = JSON.parse(readFileSync(budgetPath(), 'utf-8')) as WeeklyBudget
129
+ if (parsed.weekStart === currentWeekStart()) return parsed
130
+ }
131
+ } catch { /* corrupted budget file resets */ }
132
+ return { weekStart: currentWeekStart(), composerCalls: 0 }
133
+ }
134
+
135
+ function recordComposerCalls(count: number): void {
136
+ const budget = readWeeklyBudget()
137
+ budget.composerCalls += count
138
+ try {
139
+ atomicWriteFileSync(budgetPath(), JSON.stringify(budget))
140
+ } catch { /* budget persistence is best-effort */ }
141
+ }
142
+
143
+ export function weeklyBudgetExhausted(): boolean {
144
+ return readWeeklyBudget().composerCalls >= WEEKLY_COMPOSER_CEILING
145
+ }
146
+
147
+ registerLiveCuesBudgetProbe(weeklyBudgetExhausted)
148
+
149
+ // ── Session lifecycle ────────────────────────────────────────────────────────
150
+ function reapStaleSessions(): void {
151
+ const now = Date.now()
152
+ for (const [sessionId, session] of sessions) {
153
+ if (!session.inFlight && now - session.lastSeenAt > SESSION_TTL_MS) {
154
+ sessions.delete(sessionId)
155
+ }
156
+ }
157
+ }
158
+
159
+ /** Idempotent arm. A repeat start for an armed session returns the EXISTING
160
+ * counter and never resets — reconnect fires this repeatedly by design. */
161
+ export async function armLiveCues(sessionId: string): Promise<{ armed: true; sessionId: string; pipelinesUsed: number }> {
162
+ reapStaleSessions()
163
+ if (!liveCuesEnabled()) throw new LiveCuesArmError('disabled', 403, 'Live cues are disabled (COS_LIVE_CUES).')
164
+ if (!liveCuesModelSupported()) {
165
+ throw new LiveCuesArmError('live_cues_model_unsupported', 403, 'Only cursor-composer is supported for live cues.')
166
+ }
167
+ const existing = sessions.get(sessionId)
168
+ if (existing) {
169
+ existing.lastSeenAt = Date.now()
170
+ return { armed: true, sessionId, pipelinesUsed: existing.pipelinesUsed }
171
+ }
172
+ const capability = liveCuesCapability()
173
+ if (!capability.available) {
174
+ throw new LiveCuesArmError(capability.reason ?? 'unavailable', 503, `Live cues unavailable: ${capability.reason}.`)
175
+ }
176
+ // Resolve the catalog ONCE at arm time. The per-pipeline path reads only the
177
+ // cached id: getCursorModelCatalog() short-circuits on isCursorProviderReady()
178
+ // (which needs BOTH slots), so an unresolved grok slot would otherwise
179
+ // re-spawn `agent models` (7s) on every ask.
180
+ await getCursorModelCatalog()
181
+ const option = resolveCursorModelOption(CURSOR_COMPOSER_MODEL)
182
+ if (!option?.id) throw new LiveCuesArmError('no_composer', 503, 'Composer model id did not resolve.')
183
+ sessions.set(sessionId, {
184
+ sessionId,
185
+ armedAt: Date.now(),
186
+ lastSeenAt: Date.now(),
187
+ modelId: option.id,
188
+ pipelinesUsed: 0,
189
+ inFlight: false,
190
+ lastPipelineStartAt: 0,
191
+ lastCueAt: 0,
192
+ consecutiveFailures: 0,
193
+ breakerTripped: false,
194
+ bufferWords: [],
195
+ wordsSincePipelineStart: 0,
196
+ nudges: [],
197
+ activeProcs: new Set(),
198
+ lease: null,
199
+ leaseHeldOpen: false,
200
+ })
201
+ console.log(`[live-cues] armed ${sessionId} (model ${option.id})`)
202
+ return { armed: true, sessionId, pipelinesUsed: 0 }
203
+ }
204
+
205
+ export function disarmLiveCues(sessionId: string): { nudgesGenerated: number; nudges: LiveCueNudge[] } {
206
+ const session = sessions.get(sessionId)
207
+ if (!session) return { nudgesGenerated: 0, nudges: [] }
208
+ const result = { nudgesGenerated: session.nudges.length, nudges: [...session.nudges] }
209
+ if (!session.inFlight) sessions.delete(sessionId)
210
+ else session.lastSeenAt = 0 // reaped once the pipeline settles
211
+ console.log(`[live-cues] disarmed ${sessionId} (${result.nudgesGenerated} nudges)`)
212
+ return result
213
+ }
214
+
215
+ export function getLiveCuesStatus(): Array<{
216
+ sessionId: string
217
+ pipelinesUsed: number
218
+ inFlight: boolean
219
+ breakerTripped: boolean
220
+ nudgesGenerated: number
221
+ }> {
222
+ return [...sessions.values()].map(session => ({
223
+ sessionId: session.sessionId,
224
+ pipelinesUsed: session.pipelinesUsed,
225
+ inFlight: session.inFlight,
226
+ breakerTripped: session.breakerTripped,
227
+ nudgesGenerated: session.nudges.length,
228
+ }))
229
+ }
230
+
231
+ // ── Feed ─────────────────────────────────────────────────────────────────────
232
+ function userQueryInFlight(): boolean {
233
+ const activeByKind = maintenanceLifecycle.snapshot().activeByKind as Record<string, number>
234
+ return (activeByKind.durable_query ?? 0) > 0
235
+ || (activeByKind.legacy_query ?? 0) > 0
236
+ || (activeByKind.openai_query ?? 0) > 0
237
+ }
238
+
239
+ /** Fire-and-forget from the ASR path. Declared async so every throw becomes a
240
+ * rejection; the call site's .catch(() => {}) absorbs it (a bare `void` on a
241
+ * rejecting promise is an unhandled rejection, which Node throws on). */
242
+ export async function feedLiveCueTranscript(
243
+ sessionId: string,
244
+ text: string,
245
+ asrDegraded = false,
246
+ ): Promise<void> {
247
+ let session = sessions.get(sessionId)
248
+ if (!session) {
249
+ if (process.env.COS_LIVE_CUES_AUTO === '1' && liveCuesEnabled()) {
250
+ try {
251
+ await armLiveCues(sessionId)
252
+ session = sessions.get(sessionId)
253
+ } catch {
254
+ return
255
+ }
256
+ }
257
+ if (!session) return
258
+ }
259
+ session.lastSeenAt = Date.now()
260
+
261
+ const words = text.trim().split(/\s+/).filter(Boolean)
262
+ if (words.length) {
263
+ session.bufferWords.push(...words)
264
+ session.wordsSincePipelineStart += words.length
265
+ if (session.bufferWords.length > BUFFER_CAP_WORDS) {
266
+ session.bufferWords.splice(0, session.bufferWords.length - BUFFER_CAP_WORDS)
267
+ }
268
+ }
269
+
270
+ // Gates — single-flight FIRST, then cheap checks, each logged on skip.
271
+ if (session.inFlight) { skip(sessionId, 'pipeline_in_flight'); return }
272
+ if (session.breakerTripped) { skip(sessionId, 'breaker_tripped'); return }
273
+ if (asrDegraded) { skip(sessionId, 'capture_degraded'); return }
274
+ if (session.pipelinesUsed >= MAX_PIPELINES_PER_MEETING) { skip(sessionId, 'meeting_cap'); return }
275
+ if (session.bufferWords.length < MIN_BUFFER_WORDS) { skip(sessionId, 'word_floor', `${session.bufferWords.length}w`); return }
276
+ const now = Date.now()
277
+ if (now - session.lastPipelineStartAt < FLOOR_BETWEEN_STARTS_MS) { skip(sessionId, 'floor_60s'); return }
278
+ if (now - session.lastCueAt < COOLDOWN_AFTER_CUE_MS) { skip(sessionId, 'cooldown'); return }
279
+ const windowText = session.bufferWords.join(' ')
280
+ if (!COACHING_SIGNALS.some(pattern => pattern.test(windowText))) { skip(sessionId, 'signal_prefilter'); return }
281
+ if (weeklyBudgetExhausted()) { skip(sessionId, 'budget_exhausted'); return }
282
+ if (userQueryInFlight()) { skip(sessionId, 'provider_busy'); return }
283
+
284
+ await runPipeline(session, windowText)
285
+ }
286
+
287
+ // ── Pipeline ─────────────────────────────────────────────────────────────────
288
+ async function runPipeline(session: LiveCueSession, windowText: string): Promise<void> {
289
+ // Counter and stamps move at START, not completion — two chunks racing a
290
+ // completion-time stamp both pass the floor and both launch.
291
+ session.inFlight = true
292
+ session.pipelinesUsed += 1
293
+ session.lastPipelineStartAt = Date.now()
294
+ session.wordsSincePipelineStart = 0
295
+ session.bufferWords = []
296
+
297
+ let lease: MaintenanceWorkLease | null = null
298
+ try {
299
+ lease = acquireMaintenanceWork('live_cue_pipeline')
300
+ } catch (error) {
301
+ session.inFlight = false
302
+ if (error instanceof MaintenanceLifecycleError) {
303
+ skip(session.sessionId, 'maintenance_drain_active')
304
+ return
305
+ }
306
+ throw error
307
+ }
308
+ session.lease = lease
309
+
310
+ const deadline = Date.now() + PIPELINE_WALL_MS
311
+ const remaining = () => deadline - Date.now()
312
+ const onProcess = (proc: ChildProcess) => {
313
+ session.activeProcs.add(proc)
314
+ proc.once('close', () => session.activeProcs.delete(proc))
315
+ }
316
+ let treeProvenClosed = true
317
+ let composerCalls = 0
318
+
319
+ try {
320
+ // Stage 1 — planner
321
+ const planner = await composerAsk({
322
+ prompt: buildPlannerPrompt(windowText),
323
+ modelId: session.modelId,
324
+ caller: 'live-cues-planner',
325
+ timeoutMs: Math.min(STAGE_MAX_MS, remaining() - INSIGHT_RESERVE_MS),
326
+ onProcess,
327
+ })
328
+ composerCalls += 1
329
+ if (!planner.ok) {
330
+ treeProvenClosed = planner.treeClosed
331
+ recordFailure(session, `planner:${planner.reason}`)
332
+ return
333
+ }
334
+ const plan = parseJsonReply(planner.text, isPlannerResult)
335
+ if (!plan) { recordSuccessNoCue(session, 'planner_null'); return }
336
+
337
+ // Stage 2 — Qdrant
338
+ let degraded = false
339
+ let degradationReason: string | undefined
340
+ let snippets: string[] = []
341
+ if (remaining() > INSIGHT_RESERVE_MS) {
342
+ const hop1 = await semanticSearchHop(plan.query)
343
+ if (hop1.ok) snippets = hop1.snippets
344
+ else { degraded = true; degradationReason = hop1.reason }
345
+ } else {
346
+ degraded = true
347
+ degradationReason = 'wall_budget'
348
+ }
349
+
350
+ // Stage 3 — LightRAG (optional, budgeted, reserve-guarded)
351
+ let graphContext: string | null = null
352
+ if (liveCuesGraphEnabled() && plan.entity && remaining() > INSIGHT_RESERVE_MS + 5_000) {
353
+ const hop2 = await lightragExploreHop(plan.entity, onProcess)
354
+ if (hop2.ok) graphContext = hop2.text
355
+ else {
356
+ treeProvenClosed = treeProvenClosed && hop2.treeClosed
357
+ degraded = true
358
+ degradationReason = degradationReason ?? hop2.reason
359
+ }
360
+ } else if (liveCuesGraphEnabled() && plan.entity) {
361
+ degraded = true
362
+ degradationReason = degradationReason ?? 'wall_budget'
363
+ }
364
+
365
+ // Stage 4 — insight
366
+ const insightBudget = Math.min(INSIGHT_RESERVE_MS, remaining())
367
+ if (insightBudget < 3_000) { recordFailure(session, 'wall_budget'); return }
368
+ const insight = await composerAsk({
369
+ prompt: buildInsightPrompt({ transcriptWindow: windowText, memorySnippets: snippets, graphContext }),
370
+ modelId: session.modelId,
371
+ caller: 'live-cues-insight',
372
+ timeoutMs: insightBudget,
373
+ onProcess,
374
+ })
375
+ composerCalls += 1
376
+ if (!insight.ok) {
377
+ treeProvenClosed = treeProvenClosed && insight.treeClosed
378
+ recordFailure(session, `insight:${insight.reason}`)
379
+ return
380
+ }
381
+ const cue = parseJsonReply(insight.text, isInsightResult)
382
+ if (!cue) { recordSuccessNoCue(session, 'insight_null'); return }
383
+
384
+ // Staleness: a cue about a topic the room has left reads as broken.
385
+ if (session.wordsSincePipelineStart > STALE_CUE_WORDS) {
386
+ skip(session.sessionId, 'cue_stale', `${session.wordsSincePipelineStart}w advanced`)
387
+ session.consecutiveFailures = 0
388
+ return
389
+ }
390
+
391
+ const nudge: LiveCueNudge = {
392
+ nudge: cue.nudge.slice(0, 85),
393
+ type: cue.type.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'insight',
394
+ priority: Math.max(1, Math.min(3, Math.round(cue.priority))),
395
+ timestamp: Date.now(),
396
+ ...(degraded ? { degraded: true, degradationReason } : {}),
397
+ }
398
+ session.nudges.push(nudge)
399
+ session.lastCueAt = Date.now()
400
+ session.consecutiveFailures = 0
401
+ emitDisplay({
402
+ type: 'coaching_nudge',
403
+ data: {
404
+ nudge: nudge.nudge,
405
+ type: nudge.type,
406
+ priority: nudge.priority,
407
+ ...(nudge.degraded ? { degraded: true, degradationReason: nudge.degradationReason } : {}),
408
+ },
409
+ })
410
+ console.log(`[live-cues] cue ${session.pipelinesUsed}/${MAX_PIPELINES_PER_MEETING} (${nudge.type}${degraded ? ', degraded' : ''}): "${nudge.nudge}"`)
411
+ } finally {
412
+ if (composerCalls > 0) recordComposerCalls(composerCalls)
413
+ session.inFlight = false
414
+ if (session.lastSeenAt === 0) sessions.delete(session.sessionId) // disarmed mid-flight
415
+ // provider-process-lifecycle contract: release the lease ONLY when the
416
+ // process tree is proven dead. Otherwise hold it so Control cannot restart
417
+ // the server over a live subprocess, and log loudly.
418
+ if (treeProvenClosed) {
419
+ lease.release()
420
+ session.lease = null
421
+ } else {
422
+ session.leaseHeldOpen = true
423
+ console.error(`[live-cues] lease HELD OPEN for ${session.sessionId}: process tree not proven dead`)
424
+ }
425
+ }
426
+ }
427
+
428
+ function recordFailure(session: LiveCueSession, reason: string): void {
429
+ session.consecutiveFailures += 1
430
+ console.warn(`[live-cues] pipeline failed (${reason}) ${session.consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES} (${session.sessionId})`)
431
+ if (session.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
432
+ session.breakerTripped = true
433
+ console.warn(`[live-cues] breaker tripped — cues disabled for ${session.sessionId}`)
434
+ }
435
+ }
436
+
437
+ function recordSuccessNoCue(session: LiveCueSession, reason: string): void {
438
+ session.consecutiveFailures = 0
439
+ console.log(`[live-cues] no cue (${reason}) (${session.sessionId})`)
440
+ }
441
+
442
+ // ── Shutdown ─────────────────────────────────────────────────────────────────
443
+ /** Called from gracefulShutdown inside its 8s force-exit budget. Single-flight
444
+ * guarantees at most one live tree per session. */
445
+ export async function shutdownLiveCues(): Promise<void> {
446
+ const terminations: Promise<unknown>[] = []
447
+ for (const session of sessions.values()) {
448
+ for (const proc of session.activeProcs) {
449
+ terminations.push(terminateProviderProcess(proc))
450
+ }
451
+ }
452
+ if (terminations.length) {
453
+ console.log(`[live-cues] shutdown: terminating ${terminations.length} in-flight process tree(s)`)
454
+ await Promise.allSettled(terminations)
455
+ }
456
+ for (const session of sessions.values()) {
457
+ session.lease?.release()
458
+ session.lease = null
459
+ }
460
+ sessions.clear()
461
+ }