@gotcos/glasses-server 6.74.0 → 6.76.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/managed-runtime-contract.json +7 -0
- package/package.json +1 -1
- package/server/index.ts +2 -0
- package/server/lib/display-bus.ts +3 -1
- package/server/lib/jev.ts +12 -2
- package/server/lib/live-cos-ask.ts +77 -14
- package/server/lib/live-cues-engine.ts +3 -0
- package/server/lib/live-cues-v2-prompts.ts +41 -0
- package/server/lib/live-cues-v2.ts +639 -0
- package/server/lib/meeting-ask-owner.ts +144 -0
- package/server/lib/meeting-ask-runtime.ts +28 -6
- package/server/lib/meeting-stop-requests.ts +123 -0
- package/server/routes/display.ts +2 -2
- package/server/routes/health.ts +3 -0
- package/server/routes/live-cues.ts +48 -3
- package/server/routes/meeting-ask.ts +11 -1
- package/server/routes/meeting.ts +112 -0
- package/server/routes/transcribe-stream.ts +2 -1
- package/shared/live-cues-v2.ts +77 -0
- package/shared/meeting-ask.ts +23 -1
|
@@ -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 }
|