@gotcos/glasses-server 6.76.1 → 6.77.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,63 @@
1
+ // Live cues v2 runtime switch (6.76.2). COS Control offers an on/off toggle; the choice is kept in this server's own data
2
+ // dir and applies without a restart. Resolution order:
3
+ // 1. env COS_LIVE_CUES_V2=1 forces v2 on (source 'env'); a request to turn it off is refused (409 env_forced).
4
+ // 2. the saved setting (source 'setting').
5
+ // 3. off (source 'default').
6
+ // Only the exact value '1' forces. Any other env value (unset, '0', 'true') defers to the setting, as it always read as off.
7
+ // An unreadable or malformed settings file reads as no setting, so it fails toward off, never toward spending.
8
+
9
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
10
+ import { dirname } from 'node:path'
11
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
12
+ import { dataPath } from './data-dir.js'
13
+
14
+ export type LiveCuesV2SettingSource = 'env' | 'setting' | 'default'
15
+
16
+ /** What the engine and the routes need from the store: the saved choice, or null when there is none. */
17
+ export interface LiveCuesV2SettingReader { stored(): boolean | null }
18
+
19
+ export class LiveCuesV2Settings implements LiveCuesV2SettingReader {
20
+ /** Loaded once; only this server writes the file, and every write goes through save(). */
21
+ private cache: { value: boolean | null } | null = null
22
+ constructor(readonly file: string) {}
23
+
24
+ stored(): boolean | null {
25
+ if (this.cache) return this.cache.value
26
+ let value: boolean | null = null
27
+ try {
28
+ if (existsSync(this.file)) {
29
+ const raw = JSON.parse(readFileSync(this.file, 'utf8')) as unknown
30
+ if (raw && typeof raw === 'object' && !Array.isArray(raw) && typeof (raw as { enabled?: unknown }).enabled === 'boolean') {
31
+ value = (raw as { enabled: boolean }).enabled
32
+ }
33
+ }
34
+ } catch { value = null }
35
+ this.cache = { value }
36
+ return value
37
+ }
38
+
39
+ /** Atomic and durable. The in-memory value changes only after the file is written, so a failed write changes nothing. */
40
+ save(enabled: boolean, now: Date = new Date()): void {
41
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
42
+ durableAtomicWriteFileSync(this.file, JSON.stringify({ schema: 1, enabled, updatedAt: now.toISOString() }) + '\n', { mode: 0o600 })
43
+ this.cache = { value: enabled }
44
+ }
45
+ }
46
+
47
+ export function liveCuesV2EnvForced(env: NodeJS.ProcessEnv = process.env): boolean {
48
+ return env.COS_LIVE_CUES_V2 === '1'
49
+ }
50
+
51
+ export function resolveLiveCuesV2(env: NodeJS.ProcessEnv, settings: LiveCuesV2SettingReader | null): { enabled: boolean; source: LiveCuesV2SettingSource } {
52
+ if (liveCuesV2EnvForced(env)) return { enabled: true, source: 'env' }
53
+ let stored: boolean | null = null
54
+ try { stored = settings ? settings.stored() : null } catch { stored = null }
55
+ if (stored !== null) return { enabled: stored, source: 'setting' }
56
+ return { enabled: false, source: 'default' }
57
+ }
58
+
59
+ let singleton: LiveCuesV2Settings | null = null
60
+ export function liveCuesV2Settings(): LiveCuesV2Settings {
61
+ if (!singleton) singleton = new LiveCuesV2Settings(dataPath('live-cues-v2-settings.json'))
62
+ return singleton
63
+ }
@@ -9,6 +9,12 @@
9
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
10
  // without transcript text; a skip (paused, breaker, meeting cap, weekly budget, busy) writes one `skip` row per reason
11
11
  // per meeting at most every 5 minutes, so the trace can tell those apart from silence.
12
+ //
13
+ // 6.76.2: the switch is also a runtime setting (live-cues-v2-settings.ts; env COS_LIVE_CUES_V2=1 still forces it on), the
14
+ // gate and the judge run on Jev, or on SubQ when COS_DECISION_PROVIDER_CUES=subq or SubQ is the only key (each with its
15
+ // own daily cap and breaker; resolveCuesDecisionProvider), and
16
+ // when cue_memory.py is unavailable, fails or times out the memory arm searches the owner's own COS notes and meetings
17
+ // instead (live-cues-v2-notes.ts; local keyword search, no model call). Each evaluation traces `cueMemorySource`.
12
18
 
13
19
  import { spawn, type ChildProcess } from 'node:child_process'
14
20
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
@@ -17,16 +23,23 @@ import { dirname, resolve } from 'node:path'
17
23
  import { emitDisplay, onDisplay } from './display-bus.js'
18
24
  import { acquireMaintenanceWork, MaintenanceLifecycleError, maintenanceLifecycle, type MaintenanceWorkLease } from './maintenance-lifecycle.js'
19
25
  import { JevClient, JevError, estimateJevTokens, resolveJevKey, type JevAnswers } from './jev.js'
26
+ import { DecisionProviderError, SubqDecisionClient, trackSubqDecisionClient, type DecisionClient } from './decision-provider.js'
27
+ import type { DecisionProvider } from './decision-settings.js'
28
+ import { resolveSubqKey } from './subq-key.js'
29
+ import { liveCuesV2EnvForced, liveCuesV2Settings, resolveLiveCuesV2, type LiveCuesV2SettingReader, type LiveCuesV2SettingSource } from './live-cues-v2-settings.js'
30
+ import { notesMemory } from './live-cues-v2-notes.js'
31
+ import { meetingLibraryConfigured } from './cos-operations-meetings.js'
32
+ import { getMeetingStore } from './meeting-store.js'
20
33
  import { LensGistLimitError, runLensGistEngine } from './lens-gist-engines.js'
21
34
  import { resolveProviderBinary } from './provider-binary.js'
22
- import { COS_SCRIPTS_DIR, PYTHON_BIN } from './python-bridge.js'
35
+ import { COS_SCRIPTS_DIR, PYTHON_BIN, contextSourceAvailable } from './python-bridge.js'
23
36
  import { dataPath } from './data-dir.js'
24
37
  import { durableAtomicWriteFileSync } from './atomic-fs.js'
25
38
  import { terminateProviderProcess } from './provider-process-lifecycle.js'
26
39
  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
40
  import {
28
41
  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,
42
+ type CueKind, type CueSourceRef, type LiveCueV1Compat, type LiveCueV2, type LiveCuesV2Capability, type LiveCuesV2MemorySource,
30
43
  type LiveCuesV2SessionStatus, type LiveCuesV2StatusResponse, type LiveCuesV2StopReason,
31
44
  } from '../../shared/live-cues-v2.js'
32
45
 
@@ -60,10 +73,14 @@ export const LIVE_CUES_V2_LIMITS = {
60
73
  counterTtlMs: 24 * 60 * 60_000,
61
74
  /** ~1.2k gate tokens per evaluation, at most 3 a minute: ~216k an hour, so 1.5M covers ~7 meeting hours (~$0.06). */
62
75
  defaultJevDailyTokens: 1_500_000,
76
+ /** The same ceiling for SubQ, on its own ledger (subq-cues-usage.json). SubQ reserves a byte-based estimate before each
77
+ * call and records the input tokens it reports. */
78
+ defaultSubqDailyTokens: 1_500_000,
63
79
  } as const
64
80
 
65
- export function liveCuesV2Enabled(env: NodeJS.ProcessEnv = process.env): boolean {
66
- return env.COS_LIVE_CUES_V2 === '1'
81
+ /** On when env COS_LIVE_CUES_V2=1 forces it, else when the saved runtime setting says so (6.76.2). Off by default. */
82
+ export function liveCuesV2Enabled(env: NodeJS.ProcessEnv = process.env, settings: LiveCuesV2SettingReader | null = liveCuesV2Settings()): boolean {
83
+ return resolveLiveCuesV2(env, settings).enabled
67
84
  }
68
85
  export function liveCuesV2Gate(env: NodeJS.ProcessEnv = process.env): number {
69
86
  const raw = Number(env.COS_LIVE_CUES_V2_GATE)
@@ -86,6 +103,29 @@ function jevDailyCap(): number {
86
103
  const raw = Number(process.env.COS_LIVE_CUES_JEV_DAILY_TOKENS)
87
104
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : LIVE_CUES_V2_LIMITS.defaultJevDailyTokens
88
105
  }
106
+ function subqDailyCap(): number {
107
+ const raw = Number(process.env.COS_LIVE_CUES_SUBQ_DAILY_TOKENS)
108
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : LIVE_CUES_V2_LIMITS.defaultSubqDailyTokens
109
+ }
110
+
111
+ /**
112
+ * Which classifier runs the gate and the judge. Cues do NOT follow COS Control's saved decision provider or
113
+ * COS_DECISION_PROVIDER (Miles 2026-10-17): the 0.8 gate and judge thresholds were calibrated on Jev, there is no SubQ
114
+ * replay, and SubQ would receive the live transcript window. SubQ runs cues only when env COS_DECISION_PROVIDER_CUES=subq
115
+ * says so, or when SubQ is the only decision key on this Mac; otherwise Jev. Any other value of that env throws
116
+ * decision_invalid_provider: never silently Jev.
117
+ */
118
+ export function resolveCuesDecisionProvider(env: NodeJS.ProcessEnv = process.env,
119
+ keys: { jev: () => boolean; subq: () => boolean } = { jev: () => !!resolveJevKey(), subq: () => !!resolveSubqKey() }): { provider: DecisionProvider; source: 'env' | 'only_key' | 'default' } {
120
+ const raw = env.COS_DECISION_PROVIDER_CUES
121
+ if (raw !== undefined) {
122
+ const v = raw.trim().toLowerCase()
123
+ if (v !== 'jev' && v !== 'subq') throw new DecisionProviderError('decision_invalid_provider')
124
+ return { provider: v, source: 'env' }
125
+ }
126
+ if (!keys.jev() && keys.subq()) return { provider: 'subq', source: 'only_key' }
127
+ return { provider: 'jev', source: 'default' }
128
+ }
89
129
 
90
130
  // ── Python-style JSON (the replay built every prompt with json.dumps; keep byte parity) ──
91
131
  export function pyJson(value: unknown): string {
@@ -251,17 +291,31 @@ export class LiveCuesV2Store {
251
291
  // ── Dependencies (real by default, faked in tests) ──────────────────────────
252
292
  export interface GateResult { pCue: number; kind: CueKind; lookup: Record<string, number> | null; tokens: number }
253
293
  export interface MemoryResult { notes: MemoryNote[]; denseError?: string; indexAgeH?: number }
294
+ /** cueMemory() codes that mean the script did not answer (missing, failed or timed out), so the notes fallback runs. A dense
295
+ * error the script itself reports (its keyword arm still answered) is not one, nor is an empty answer: on Miles's Mac an
296
+ * empty cue_memory answer means "no related meeting", and searching his notes instead would change that path. */
297
+ export const CUE_MEMORY_FAILURES: ReadonlySet<string> = new Set(['no_cue_memory_script', 'memory_timeout', 'spawn_failed', 'output_limit', 'bad_output', 'memory_threw'])
298
+ export function cueMemoryFailed(mem: MemoryResult): boolean { return !!mem.denseError && CUE_MEMORY_FAILURES.has(mem.denseError) }
254
299
  export interface LiveCuesV2Deps {
255
- gate(title: string, window: string, signal: AbortSignal): Promise<GateResult>
300
+ gate(title: string, window: string, signal: AbortSignal, provider: DecisionProvider): Promise<GateResult>
301
+ /** cue_memory.py (Miles's COS scripts). */
256
302
  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 }>
303
+ /** The owner's own COS notes and meetings, used only when `memory` did not answer. Local search, no model call. */
304
+ notes(window: string, before: string, signal: AbortSignal, sessionId: string): Promise<MemoryResult>
305
+ judge(title: string, window: string, notes: MemoryNote[], signal: AbortSignal, provider: DecisionProvider): Promise<{ probs: Record<string, number>; tokens: number }>
258
306
  writer(model: string, system: string, prompt: string, signal: AbortSignal): Promise<{ text: string; costUsd?: number; inputTokens?: number; outputTokens?: number; model?: string }>
259
307
  emit(type: 'coaching_nudge' | 'coaching_nudge_v2', data: Record<string, unknown>): void
260
308
  lease(): MaintenanceWorkLease | null
261
309
  /** True while the user's own query runs (Ask, a durable query): cues wait rather than compete for the provider. */
262
310
  busy(): boolean
263
- /** The cue Jev client's own state, for an honest capability. */
264
- jevState(): { breakerOpen: boolean; capReached: boolean }
311
+ /** The configured decision provider for cues. Throws DecisionProviderError('decision_invalid_provider'). */
312
+ decisionProvider(): DecisionProvider
313
+ /** That provider's cue client: key present, daily cap, breaker. For an honest capability. */
314
+ decisionState(provider: DecisionProvider): { configured: boolean; breakerOpen: boolean; capReached: boolean }
315
+ /** Where past notes would come from now, for the capability. */
316
+ memorySource(): LiveCuesV2MemorySource
317
+ /** The saved runtime switch (null when none). */
318
+ settings: LiveCuesV2SettingReader
265
319
  warmMemory(): void
266
320
  now(): Date
267
321
  store: LiveCuesV2Store
@@ -273,26 +327,69 @@ function cleanWindow(text: string): string {
273
327
  return text.replace(/^\[\d+\]\s*/gm, '').replace(/\*\*?Speaker \d+:?\*\*?:?|Speaker \d+:/g, '')
274
328
  }
275
329
 
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')
330
+ /** The gate and the judge over any decision client. Jev and SubQ take the same request (state plus `choice` questions with
331
+ * keyed criteria) and answer the same shape (answers.<name>.probabilities and .choice); SubQ also validates every answer
332
+ * against the questions before it returns. Prompts are byte-identical on both. */
333
+ export function cueDecisionCalls(client: Pick<DecisionClient, 'ask'>) {
279
334
  return {
280
- async gate(title, window, signal) {
335
+ async gate(title: string, window: string, signal: AbortSignal): Promise<GateResult> {
281
336
  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 })
337
+ const res: JevAnswers = await client.ask(state, CUE_GATE_QUESTIONS as unknown as Record<string, unknown>, estimateJevTokens({ state, CUE_GATE_QUESTIONS }), { signal })
283
338
  const a = res.answers ?? {}
284
339
  const kind = a.kind?.choice === 'reinforce' ? 'reinforce' : 'challenge'
285
340
  return { pCue: Number(a.act?.probabilities?.cue ?? 0), kind, lookup: a.lookup?.probabilities ?? null, tokens: res.inputTokens }
286
341
  },
287
- memory: (window, before, signal) => cueMemory(window, before, signal),
288
- async judge(title, window, notes, signal) {
342
+ async judge(title: string, window: string, notes: MemoryNote[], signal: AbortSignal): Promise<{ probs: Record<string, number>; tokens: number }> {
289
343
  const criteria: Record<string, string> = Object.fromEntries(notes.map(m => [m.id, `${m.date} ${m.title}: ${m.notes.slice(0, 300)}`]))
290
344
  criteria.none = CUE_JUDGE_NONE
291
345
  const state = { meetingTitle: title, window: window.slice(-LIVE_CUES_V2_LIMITS.windowChars) }
292
346
  const questions = { same: { type: 'choice', instructions: CUE_JUDGE_INSTRUCTIONS, criteria } }
293
- const res = await jev.ask(state, questions, estimateJevTokens({ state, questions }), { signal })
347
+ const res = await client.ask(state, questions, estimateJevTokens({ state, questions }), { signal })
294
348
  return { probs: res.answers?.same?.probabilities ?? {}, tokens: res.inputTokens }
295
349
  },
350
+ }
351
+ }
352
+
353
+ /** Is there anything of the owner's own to search when cue_memory.py is absent: the memory bridge or memory files, a
354
+ * configured meeting library, or this server's own saved recordings. */
355
+ function notesAvailable(): boolean {
356
+ try { if (contextSourceAvailable() !== null) return true } catch { /* next */ }
357
+ try { if (meetingLibraryConfigured()) return true } catch { /* next */ }
358
+ try { return getMeetingStore().listMonths().length > 0 } catch { return false }
359
+ }
360
+
361
+ function realDeps(): LiveCuesV2Deps {
362
+ const jev = new JevClient(fetch, () => new Date(), dataPath('jev-cues-usage.json'), jevDailyCap)
363
+ const subq = trackSubqDecisionClient(new SubqDecisionClient(fetch, () => new Date(), dataPath('subq-cues-usage.json'), () => resolveSubqKey()?.key, subqDailyCap))
364
+ const calls = (provider: DecisionProvider) => cueDecisionCalls(provider === 'subq' ? subq : jev)
365
+ const traceFile = dataPath('live-cues-trace.jsonl')
366
+ let memorySourceCache: { at: number; value: LiveCuesV2MemorySource } | null = null
367
+ return {
368
+ gate: (title, window, signal, provider) => calls(provider).gate(title, window, signal),
369
+ memory: (window, before, signal) => cueMemory(window, before, signal),
370
+ async notes(window, before, signal, sessionId) {
371
+ const r = await notesMemory(window, before, signal, { sessionId })
372
+ return { notes: r.notes, ...(r.error ? { denseError: r.error } : {}) }
373
+ },
374
+ judge: (title, window, notes, signal, provider) => calls(provider).judge(title, window, notes, signal),
375
+ decisionProvider: () => resolveCuesDecisionProvider(process.env).provider,
376
+ decisionState(provider) {
377
+ if (provider === 'subq') {
378
+ const st = subq.status()
379
+ return { configured: st.configured, breakerOpen: st.breakerOpenUntil !== null, capReached: st.usedToday >= st.dailyCap }
380
+ }
381
+ const st = jev.status()
382
+ return { configured: !!resolveJevKey(), breakerOpen: !!st.breakerOpenUntil, capReached: st.usedToday >= st.dailyCap }
383
+ },
384
+ memorySource() {
385
+ // /api/health polls the capability: probe the disk at most once a minute.
386
+ const nowMs = Date.now()
387
+ if (memorySourceCache && nowMs - memorySourceCache.at < 60_000) return memorySourceCache.value
388
+ const value: LiveCuesV2MemorySource = cueMemoryPaths() ? 'cue_memory' : notesAvailable() ? 'notes' : 'none'
389
+ memorySourceCache = { at: nowMs, value }
390
+ return value
391
+ },
392
+ settings: liveCuesV2Settings(),
296
393
  async writer(model, system, prompt, signal) {
297
394
  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
395
  return { text: run.text, costUsd: run.usage?.costUsd, inputTokens: run.usage?.inputTokens, outputTokens: run.usage?.outputTokens, model: run.model }
@@ -303,10 +400,6 @@ function realDeps(): LiveCuesV2Deps {
303
400
  const active = maintenanceLifecycle.snapshot().activeByKind as Record<string, number>
304
401
  return ['durable_query', 'legacy_query', 'openai_query', 'meeting_ask'].some(kind => (active[kind] ?? 0) > 0)
305
402
  },
306
- jevState() {
307
- const st = jev.status()
308
- return { breakerOpen: !!st.breakerOpenUntil, capReached: st.usedToday >= st.dailyCap }
309
- },
310
403
  warmMemory: () => warmCueMemory(),
311
404
  now: () => new Date(),
312
405
  store: new LiveCuesV2Store(dataPath('live-cues-v2-state.json'), () => new Date()),
@@ -392,23 +485,40 @@ function isCancel(error: unknown, signal: AbortSignal): boolean {
392
485
  return signal.aborted || (error instanceof Error && error.name === 'AbortError')
393
486
  }
394
487
 
488
+ /** Decision-provider codes that stop cues quietly with a reason (not a failure, so not toward the meeting breaker). The
489
+ * provider's own breaker and cap already bound these; counting them would also end the meeting's cues. */
490
+ export const QUIET_DECISION_STOPS: ReadonlySet<string> = new Set(['jev_cap_reached', 'jev_breaker_open', 'jev_not_configured',
491
+ 'decision_cap_reached', 'decision_breaker_open', 'decision_not_configured', 'decision_invalid_provider'])
492
+ /** Abort reason when v2 is switched off at runtime. */
493
+ export const V2_OFF = 'v2_off'
494
+
395
495
  export class LiveCuesV2Engine {
396
496
  private readonly sessions = new Map<string, Session>()
397
497
  constructor(private readonly deps: LiveCuesV2Deps) {}
398
498
 
499
+ private enabled(): boolean { return liveCuesV2Enabled(this.deps.env, this.deps.settings) }
500
+
399
501
  capability(): LiveCuesV2Capability {
400
502
  const model = liveCuesV2Model(this.deps.env)
401
- const off = (reason: string): LiveCuesV2Capability => ({ available: false, reason, protocol: LIVE_CUES_V2_PROTOCOL, model })
503
+ let memory: LiveCuesV2MemorySource = 'none'
504
+ try { memory = this.deps.memorySource() } catch { memory = 'none' }
505
+ let provider: DecisionProvider | null = null
506
+ try { provider = this.deps.decisionProvider() } catch { provider = null }
507
+ const tail = { protocol: LIVE_CUES_V2_PROTOCOL, model, memory, ...(provider ? { decisionProvider: provider } : {}) }
508
+ const off = (reason: string): LiveCuesV2Capability => ({ available: false, reason, ...tail })
402
509
  try {
403
- if (!liveCuesV2Enabled(this.deps.env)) return off('master_flag_off')
510
+ if (!this.enabled()) return off('master_flag_off')
404
511
  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')
512
+ if (!provider) return off('decision_invalid_provider')
513
+ const st = this.deps.decisionState(provider)
514
+ // jev_not_configured keeps its 6.75 meaning (Jev chosen, no Jev key) only when a SubQ key exists; with no key for
515
+ // the chosen provider otherwise, the reason is decision_not_configured.
516
+ if (!st.configured) return off(provider === 'jev' && this.deps.decisionState('subq').configured ? 'jev_not_configured' : 'decision_not_configured')
406
517
  if (!resolveProviderBinary('claude').ok) return off('claude_not_found')
407
518
  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 }
519
+ if (st.capReached) return off(provider === 'jev' ? 'jev_cap_reached' : 'decision_cap_reached')
520
+ if (st.breakerOpen) return off(provider === 'jev' ? 'jev_breaker_open' : 'decision_breaker_open')
521
+ return { available: true, ...tail }
412
522
  } catch {
413
523
  return off('unavailable')
414
524
  }
@@ -452,14 +562,14 @@ export class LiveCuesV2Engine {
452
562
  const s = this.sessions.get(sessionId)
453
563
  const ended = this.deps.store.isEnded(sessionId)
454
564
  const paused = this.deps.store.isPaused(sessionId)
455
- if (!s && !ended) return { protocol: 2, enabled: liveCuesV2Enabled(this.deps.env), session: null }
565
+ if (!s && !ended) return { protocol: 2, enabled: this.enabled(), session: null }
456
566
  const counters = this.deps.store.counters(sessionId)
457
567
  const reason = s ? this.stopReasonOf(s, counters) : null
458
568
  const session: LiveCuesV2SessionStatus = {
459
569
  sessionId, state: ended ? 'ended' : paused ? 'paused' : reason ? 'stopped' : 'active', ...(reason && !ended && !paused ? { reason } : {}),
460
570
  paused, ended, pauseSeq: this.deps.store.pauseSeq(sessionId), cues: s ? [...s.cues] : [], evaluations: s?.evaluations ?? 0, writerCalls: counters.writerCalls,
461
571
  }
462
- return { protocol: 2, enabled: liveCuesV2Enabled(this.deps.env), session }
572
+ return { protocol: 2, enabled: this.enabled(), session }
463
573
  }
464
574
 
465
575
  private stopReasonOf(s: Session, counters: { cues: number; writerCalls: number }): LiveCuesV2StopReason | null {
@@ -479,7 +589,7 @@ export class LiveCuesV2Engine {
479
589
 
480
590
  /** From the ASR path: fire-and-forget, never awaited. All gates live here. */
481
591
  async feed(sessionId: string, text: string, asrDegraded = false): Promise<void> {
482
- if (!liveCuesV2Enabled(this.deps.env)) return
592
+ if (!this.enabled()) return
483
593
  if (this.deps.store.isEnded(sessionId)) return // tombstone before any auto-arm
484
594
  let s = this.sessions.get(sessionId)
485
595
  if (!s) {
@@ -532,8 +642,11 @@ export class LiveCuesV2Engine {
532
642
  wall.unref?.()
533
643
  try {
534
644
  try { lease = this.deps.lease() } catch (e) { if (e instanceof MaintenanceLifecycleError) { trace.outcome = 'maintenance'; return } throw e }
645
+ // One provider per evaluation: a settings change applies from the next one, never between the gate and the judge.
646
+ const provider = this.deps.decisionProvider()
647
+ trace.decisionProvider = provider
535
648
  const t0 = Date.now()
536
- const gate = await this.deps.gate(s.title, window, abort.signal)
649
+ const gate = await this.deps.gate(s.title, window, abort.signal, provider)
537
650
  const pCue = Number.isFinite(gate.pCue) ? gate.pCue : 0
538
651
  Object.assign(trace, { gateMs: Date.now() - t0, pCue: round(pCue), cueKind: gate.kind, lookup: gate.lookup, gateTokens: gate.tokens })
539
652
  if (pCue < liveCuesV2Gate(this.deps.env)) { trace.outcome = 'gate_silent'; s.stopReason = null; return }
@@ -541,12 +654,22 @@ export class LiveCuesV2Engine {
541
654
 
542
655
  const t1 = Date.now()
543
656
  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 } : {}) })
657
+ let mem: MemoryResult = await this.deps.memory(window, before, abort.signal).catch((): MemoryResult => ({ notes: [], denseError: 'memory_threw' }))
658
+ Object.assign(trace, { memoryMs: Date.now() - t1, ...(mem.denseError ? { memoryError: mem.denseError } : {}), ...(mem.indexAgeH !== undefined ? { indexAgeH: mem.indexAgeH } : {}) })
659
+ let source: LiveCuesV2MemorySource = 'cue_memory'
660
+ if (cueMemoryFailed(mem) && !abort.signal.aborted) {
661
+ // cue_memory.py did not answer: search the owner's own COS (no model call), inside its own 3 s budget.
662
+ const tn = Date.now()
663
+ const found: MemoryResult = await this.deps.notes(window, before, abort.signal, s.sessionId).catch((): MemoryResult => ({ notes: [], denseError: 'notes_threw' }))
664
+ Object.assign(trace, { notesMs: Date.now() - tn, ...(found.denseError ? { notesError: found.denseError } : {}) })
665
+ mem = { notes: found.notes }
666
+ source = 'notes'
667
+ }
668
+ Object.assign(trace, { candidates: mem.notes.length, cueMemorySource: mem.notes.length ? source : 'none' })
546
669
  let kept: MemoryNote[] = []
547
670
  if (mem.notes.length && !abort.signal.aborted) {
548
671
  const t2 = Date.now()
549
- const judged = await this.deps.judge(s.title, window, mem.notes, abort.signal)
672
+ const judged = await this.deps.judge(s.title, window, mem.notes, abort.signal, provider)
550
673
  Object.assign(trace, { judgeMs: Date.now() - t2, judgeTokens: judged.tokens })
551
674
  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
675
  }
@@ -570,7 +693,7 @@ export class LiveCuesV2Engine {
570
693
  if (typeof parsed === 'string') { trace.outcome = parsed === 'null' ? 'writer_null' : `withheld_${parsed}`; return }
571
694
  const elapsed = this.deps.now().getTime() - started
572
695
  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 }
696
+ if (this.deps.store.isEnded(s.sessionId) || this.deps.store.isPaused(s.sessionId) || !this.enabled()) { trace.outcome = 'cancelled'; return }
574
697
 
575
698
  const sources: CueSourceRef[] = parsed.memoryIds.map(id => kept.find(m => m.id === id)).filter((m): m is MemoryNote => !!m)
576
699
  .map(m => ({ id: sourceId(m), date: m.date, title: m.title }))
@@ -587,18 +710,19 @@ export class LiveCuesV2Engine {
587
710
  } catch (error) {
588
711
  if (wallHit) { trace.outcome = 'failure'; trace.error = 'wall_timeout'; s.failures += 1; if (s.failures >= L.breakerFailures) s.breaker = true; return }
589
712
  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)) {
713
+ if ((error instanceof JevError || error instanceof DecisionProviderError) && QUIET_DECISION_STOPS.has(error.code)) {
591
714
  trace.outcome = error.code
592
715
  s.stopReason = error.code as LiveCuesV2StopReason
593
716
  return
594
717
  }
595
718
  trace.outcome = 'failure'
596
- trace.error = error instanceof JevError ? error.code : error instanceof LensGistLimitError ? 'writer_quota' : 'writer_error'
719
+ trace.error = error instanceof JevError || error instanceof DecisionProviderError ? error.code : error instanceof LensGistLimitError ? 'writer_quota' : 'writer_error'
597
720
  s.failures += 1
598
721
  if (error instanceof LensGistLimitError || s.failures >= L.breakerFailures) s.breaker = true
599
722
  } finally {
600
723
  clearTimeout(wall)
601
724
  if (wallHit && trace.outcome === 'cancelled') { trace.outcome = 'failure'; trace.error = 'wall_timeout'; s.failures += 1; if (s.failures >= L.breakerFailures) s.breaker = true }
725
+ if (trace.outcome === 'cancelled' && abort.signal.reason === V2_OFF) trace.cancelReason = V2_OFF
602
726
  s.inFlight = null
603
727
  try { lease?.release() } catch { /* released */ }
604
728
  trace.totalMs = this.deps.now().getTime() - started
@@ -612,6 +736,18 @@ export class LiveCuesV2Engine {
612
736
  }
613
737
 
614
738
  shutdown(): void { for (const s of this.sessions.values()) s.inFlight?.abort(); this.sessions.clear() }
739
+
740
+ /** v2 switched off at runtime (6.76.2): abort every evaluation in flight, so no half-written cue is emitted and every
741
+ * lease is released. Sessions are kept (no new evaluation starts while off), so switching back on resumes a running
742
+ * meeting from its next evaluation. */
743
+ suspend(): number {
744
+ let aborted = 0
745
+ for (const s of this.sessions.values()) if (s.inFlight) { s.inFlight.abort(V2_OFF); aborted += 1 }
746
+ return aborted
747
+ }
748
+
749
+ /** Was this meeting armed under v2 in this process? Its stop and status stay v2 even after v2 is switched off. */
750
+ knows(sessionId: string): boolean { return this.sessions.has(sessionId) }
615
751
  }
616
752
 
617
753
  function round(n: number): number { return Math.round(n * 1000) / 1000 }
@@ -625,12 +761,36 @@ export function liveCuesV2(): LiveCuesV2Engine {
625
761
  // A saved meeting ends its cues even when the phone never sent Stop (orphan recovery, Control save).
626
762
  unsubscribe = onDisplay(event => {
627
763
  const sid = event.type === 'recording_stop' ? event.data?.sessionId : null
628
- if (typeof sid === 'string' && sid && liveCuesV2Enabled()) engine?.end(sid)
764
+ // Also when v2 was switched off mid-meeting: a meeting it armed still ends, so it never lingers as active.
765
+ if (typeof sid === 'string' && sid && (liveCuesV2Enabled() || engine?.knows(sid))) engine?.end(sid)
629
766
  })
630
767
  }
631
768
  return engine
632
769
  }
770
+ /** The engine if it exists, without creating one (a v1-only server never builds the v2 clients). */
771
+ export function peekLiveCuesV2(): LiveCuesV2Engine | null { return engine }
633
772
  export function liveCuesV2Capability(): LiveCuesV2Capability { return liveCuesV2().capability() }
773
+
774
+ /** GET/POST /api/live-cues/v2/settings (COS Control's toggle). */
775
+ export interface LiveCuesV2SettingsResponse { enabled: boolean; source: LiveCuesV2SettingSource; capability: LiveCuesV2Capability }
776
+ export function liveCuesV2SettingsView(env: NodeJS.ProcessEnv = process.env): LiveCuesV2SettingsResponse {
777
+ const r = resolveLiveCuesV2(env, liveCuesV2Settings())
778
+ return { enabled: r.enabled, source: r.source, capability: liveCuesV2Capability() }
779
+ }
780
+ /** Save the switch. Off while env forces it on is refused and changes nothing. Turning it off ends cue evaluation for running
781
+ * meetings at once; turning it on applies from each meeting's next evaluation. */
782
+ export function setLiveCuesV2Enabled(enabled: boolean, env: NodeJS.ProcessEnv = process.env): { ok: true; view: LiveCuesV2SettingsResponse } | { ok: false; error: 'env_forced' } {
783
+ if (!enabled && liveCuesV2EnvForced(env)) return { ok: false, error: 'env_forced' }
784
+ const wasOn = liveCuesV2Enabled(env)
785
+ liveCuesV2Settings().save(enabled)
786
+ if (wasOn && !liveCuesV2Enabled(env)) {
787
+ const aborted = engine?.suspend() ?? 0
788
+ console.log(`[live-cues-v2] switched off by setting; ${aborted} evaluation(s) stopped`)
789
+ } else if (!wasOn && liveCuesV2Enabled(env)) {
790
+ console.log('[live-cues-v2] switched on by setting')
791
+ }
792
+ return { ok: true, view: liveCuesV2SettingsView(env) }
793
+ }
634
794
  /** What /api/health, /api/models and the status route advertise: NOTHING while v2 is switched off, so a 6.10.642
635
795
  * companion treats the server exactly like a v1 server (v1 cues keep their own capability and indicator). */
636
796
  export function liveCuesV2Advertised(env: NodeJS.ProcessEnv = process.env): LiveCuesV2Capability | undefined {