@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.
- package/.env.example +21 -0
- package/CHANGELOG.md +43 -0
- package/managed-runtime-contract.json +6 -1
- package/package.json +2 -2
- package/server/index.ts +12 -0
- package/server/lib/g2-enrichment-runner.ts +179 -0
- package/server/lib/g2-ops-handoff.ts +136 -0
- package/server/lib/live-cues-capability.ts +83 -0
- package/server/lib/live-cues-cursor.ts +167 -0
- package/server/lib/live-cues-engine.ts +461 -0
- package/server/lib/live-cues-memory.ts +202 -0
- package/server/lib/live-cues-prompt.ts +98 -0
- package/server/lib/maintenance-lifecycle.ts +6 -0
- package/server/lib/meeting-store.ts +10 -1
- package/server/routes/health.ts +10 -0
- package/server/routes/live-cues.ts +58 -0
- package/server/routes/meeting.ts +43 -12
- package/server/routes/transcribe-stream.ts +17 -2
|
@@ -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
|
+
}
|