@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.
- package/CHANGELOG.md +40 -0
- package/managed-runtime-contract.json +8 -0
- package/package.json +1 -1
- package/server/index.ts +6 -1
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +41 -7
- package/server/lib/live-cues-v2-notes.ts +152 -0
- package/server/lib/live-cues-v2-settings.ts +63 -0
- package/server/lib/live-cues-v2.ts +198 -38
- package/server/lib/transcript-cleanup-context.ts +234 -0
- package/server/lib/transcript-cleanup-ledger.ts +304 -0
- package/server/lib/transcript-cleanup-model.ts +217 -0
- package/server/lib/transcript-cleanup-screen.ts +249 -0
- package/server/lib/transcript-cleanup-spans.ts +358 -0
- package/server/lib/transcript-cleanup-state.ts +201 -0
- package/server/lib/transcript-cleanup-suggestions.ts +126 -0
- package/server/lib/transcript-cleanup-text.ts +200 -0
- package/server/lib/transcript-cleanup.ts +427 -0
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +3 -0
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/meeting.ts +65 -0
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- package/server/scripts/live-cues-v2-notes-mutation-gate.mjs +170 -0
- package/server/scripts/transcript-cleanup-mutation-gate.mjs +219 -0
- package/shared/live-cues-v2.ts +9 -4
|
@@ -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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
264
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
409
|
-
if (
|
|
410
|
-
|
|
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:
|
|
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:
|
|
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 (!
|
|
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
|
-
|
|
545
|
-
Object.assign(trace, { memoryMs: Date.now() - t1,
|
|
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
|
|
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
|
-
|
|
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 {
|