@gotcos/glasses-server 6.95.0 → 6.98.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 +157 -0
- package/README.md +4 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +12 -0
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +44 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +453 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +570 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +46 -27
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-speaker-review.ts +187 -21
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- package/server/lib/session-find-ask.ts +288 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-shadow-decide.ts +59 -22
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +2 -2
- package/server/lib/speaker-shadow-worker.ts +50 -15
- package/server/lib/speaker-shadow.ts +80 -19
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +10 -5
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +515 -61
- package/server/routes/session-find.ts +73 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +28 -18
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- package/shared/meeting-ask.ts +11 -0
|
@@ -15,6 +15,19 @@
|
|
|
15
15
|
// own daily cap and breaker; resolveCuesDecisionProvider), and
|
|
16
16
|
// when cue_memory.py is unavailable, fails or times out the memory arm searches the owner's own COS notes and meetings
|
|
17
17
|
// instead (live-cues-v2-notes.ts; local keyword search, no model call). Each evaluation traces `cueMemorySource`.
|
|
18
|
+
//
|
|
19
|
+
// Live cues v3: `grounding: grounded` (a setting, default classic; COS_LIVE_CUES_GROUNDING=classic forces the old path) swaps
|
|
20
|
+
// the memory and judge steps for grounded retrieval: cue_memory.py and the owner's notes in parallel, plus a best-matching
|
|
21
|
+
// passage of the files attached to the meeting, minus this conversation's own copies and other domains' personal notes, judged
|
|
22
|
+
// by one yes/no question per candidate (live-cues-v2-grounded.ts, live-cues-v2-judge-b.ts). With classic nothing in an
|
|
23
|
+
// evaluation changes: the same Jev requests, writer prompt, trace row and cue, pinned by live-cues-v2-classic-pin.test.ts.
|
|
24
|
+
//
|
|
25
|
+
// Live cues v3, general cues: with cueMode `general` or `all` a server-wide runner (live-cues-general.ts) now and then looks up one
|
|
26
|
+
// public fact for a Mac meeting and hands it to emitInsight() below. This engine only HOSTS it: it lists the extended meetings,
|
|
27
|
+
// emits the insight, and passes end, pause, suspend and a mode change on as aborts. An insight is kept apart from the focused
|
|
28
|
+
// state: its own list (`insights`), no v1 twin, and it never touches the focused counters, cooldown, lastCueAt, inFlight,
|
|
29
|
+
// failures or breaker. `general` mode turns the FOCUSED cues off for Mac meetings (a G2 meeting keeps them: it cannot carry an
|
|
30
|
+
// insight); `all` runs both.
|
|
18
31
|
|
|
19
32
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
20
33
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
@@ -27,26 +40,41 @@ import { DecisionProviderError, SubqDecisionClient, trackSubqDecisionClient, typ
|
|
|
27
40
|
import type { DecisionProvider } from './decision-settings.js'
|
|
28
41
|
import { resolveSubqKey } from './subq-key.js'
|
|
29
42
|
import {
|
|
30
|
-
LIVE_CUES_V2_LIMIT_BOUNDS, LIVE_CUES_V2_LIMIT_NAMES, liveCuesV2EnvForced, liveCuesV2Settings,
|
|
31
|
-
|
|
43
|
+
LIVE_CUES_GENERAL_LIMIT_BOUNDS, LIVE_CUES_GENERAL_LIMIT_NAMES, LIVE_CUES_V2_LIMIT_BOUNDS, LIVE_CUES_V2_LIMIT_NAMES, liveCuesV2EnvForced, liveCuesV2Settings,
|
|
44
|
+
resolveLiveCuesCueMode, resolveLiveCuesGrounding, resolveLiveCuesV2,
|
|
45
|
+
type LiveCuesCueMode, type LiveCuesGeneralLimitName, type LiveCuesGeneralStoredLimits, type LiveCuesGrounding, type LiveCuesV2LimitName, type LiveCuesV2Mode,
|
|
46
|
+
type LiveCuesV2SettingReader, type LiveCuesV2SettingSource, type LiveCuesV2StoredLimits,
|
|
32
47
|
} from './live-cues-v2-settings.js'
|
|
33
48
|
import { notesMemory } from './live-cues-v2-notes.js'
|
|
49
|
+
import { GeneralCueRunner, realGeneralDeps, type GeneralCueCandidate, type GeneralCueDeps, type GeneralCueHost, type GeneralCueSettingsReader, type GeneralCueStatus, type GeneralInsight } from './live-cues-general.js'
|
|
50
|
+
import { generalTriggerCalls } from './live-cues-general-prompts.js'
|
|
51
|
+
import { isGenericSpeakerLabel } from './live-cues-general-topic.js'
|
|
52
|
+
import { publicHttpsUrl } from './public-https-url.js'
|
|
53
|
+
import { DATA_FACTS_FILE, DATA_WRITER_ADDENDUM, dataArmGate, dataCandidate, dataFactsReader, isQuiltDomain, quiltTitleSignal, type DataFactsRead } from './live-cues-v2-data.js'
|
|
54
|
+
import { GROUNDED_LIMITS, SessionShingles, groundedCandidates, groundedSource, selfIdSet, usableFiles, type GroundedArm, type GroundedFile, type GroundedNote } from './live-cues-v2-grounded.js'
|
|
55
|
+
import { JUDGE_B_WINDOW_CHARS, judgeBQuestions, parseJudgeB, selectJudgeB } from './live-cues-v2-judge-b.js'
|
|
34
56
|
import { meetingLibraryConfigured } from './cos-operations-meetings.js'
|
|
35
57
|
import { getMeetingStore } from './meeting-store.js'
|
|
58
|
+
import { MeetingAskStore } from './meeting-ask-store.js'
|
|
59
|
+
import { getOwnerHomeArea } from './profile.js'
|
|
36
60
|
import { LensGistLimitError, runLensGistEngine } from './lens-gist-engines.js'
|
|
37
61
|
import { resolveProviderBinary } from './provider-binary.js'
|
|
38
62
|
import { COS_SCRIPTS_DIR, PYTHON_BIN, contextSourceAvailable } from './python-bridge.js'
|
|
39
63
|
import { dataPath } from './data-dir.js'
|
|
64
|
+
import { webLedger, type WebTodayView } from './web-lookup-ledger.js'
|
|
40
65
|
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
41
66
|
import { terminateProviderProcess } from './provider-process-lifecycle.js'
|
|
42
67
|
import { cueBefore, liveCueVerdicts, type LiveCueLogRow } from './live-cue-verdicts.js'
|
|
43
|
-
import { repeatOf, wouldWithhold } from './live-cues-filter.js'
|
|
68
|
+
import { leadRepeatOf, repeatOf, wouldWithhold } from './live-cues-filter.js'
|
|
44
69
|
import { errMsg } from './utils.js'
|
|
45
70
|
import { CUE_GATE_QUESTIONS, CUE_JUDGE_INSTRUCTIONS, CUE_JUDGE_NONE, CUE_WRITER_SYSTEM_V2, CUE_WRITER_SYSTEM_V3 } from './live-cues-v2-prompts.js'
|
|
46
71
|
import { CUE_WRITER_SYSTEM_V4 } from './live-cues-v2-prompts-kinds.js'
|
|
72
|
+
import { KNOWN_NAMES_USE, knownNamesFor } from './live-cues-v2-names.js'
|
|
73
|
+
import { paceAllowed, paceHorizon } from './live-cues-v2-pacing.js'
|
|
74
|
+
import { OpenItemFold, openItemOf, openItemsDetail, openItemsHeadline, type FlushPlan } from './live-cues-v2-open-items.js'
|
|
47
75
|
import { MAC_CAPTURE_ID } from './mac-recording.js'
|
|
48
76
|
import {
|
|
49
|
-
CUE_DETAIL_MAX, CUE_FEEDBACK_REASON_MAX, CUE_HEADLINE_MAX, CUE_KINDS, LEGACY_CUE_KINDS, LIVE_CUES_V2_PROTOCOL, isLiveCueV2, legacyCueKind,
|
|
77
|
+
CUE_DETAIL_MAX, CUE_FEEDBACK_REASON_MAX, CUE_HEADLINE_MAX, CUE_KINDS, LEGACY_CUE_KINDS, LIVE_CUES_V2_PROTOCOL, isCueKind, isLiveCueV2, legacyCueKind,
|
|
50
78
|
type CueKind, type CueKindSet, type CueSourceRef, type LiveCueV1Compat, type LiveCueV2, type LiveCuesV2Capability, type LiveCuesV2MemorySource,
|
|
51
79
|
type LiveCuesV2SessionStatus, type LiveCuesV2StatusResponse, type LiveCuesV2StopReason,
|
|
52
80
|
} from '../../shared/live-cues-v2.js'
|
|
@@ -69,6 +97,8 @@ export const LIVE_CUES_V2_LIMITS = {
|
|
|
69
97
|
defaultWeeklyWriterCalls: 750,
|
|
70
98
|
breakerFailures: 3,
|
|
71
99
|
judgeKeep: 0.8,
|
|
100
|
+
/** Grounded cues (live cues v3): a candidate's own yes probability must reach this, from the replay of 2026-11-01. */
|
|
101
|
+
judgeYes: 0.65,
|
|
72
102
|
judgeMaxNotes: 2,
|
|
73
103
|
defaultGate: 0.8,
|
|
74
104
|
memoryTimeoutMs: 6_000,
|
|
@@ -155,12 +185,43 @@ export function resolveLiveCuesV2Limits(env: NodeJS.ProcessEnv = process.env, se
|
|
|
155
185
|
}
|
|
156
186
|
return out
|
|
157
187
|
}
|
|
188
|
+
/** Live cues v3: the six general-cue limits, resolved like the v2 ones: an env value in bounds wins and locks, else the saved
|
|
189
|
+
* value, else the default. An env value out of bounds is ignored with one log line per value. */
|
|
190
|
+
export type LiveCuesGeneralLimits = Record<LiveCuesGeneralLimitName, LiveCuesV2Limit>
|
|
191
|
+
export function resolveLiveCuesGeneralLimits(env: NodeJS.ProcessEnv = process.env, settings: LiveCuesV2SettingReader | null = liveCuesV2Settings()): LiveCuesGeneralLimits {
|
|
192
|
+
let saved: LiveCuesGeneralStoredLimits = {}
|
|
193
|
+
try { saved = settings?.generalLimits?.() ?? {} } catch { saved = {} }
|
|
194
|
+
const out = {} as LiveCuesGeneralLimits
|
|
195
|
+
for (const name of LIVE_CUES_GENERAL_LIMIT_NAMES) {
|
|
196
|
+
const b = LIVE_CUES_GENERAL_LIMIT_BOUNDS[name]
|
|
197
|
+
let e: number | null
|
|
198
|
+
if (b.integer) e = envWhole(env, b.env, b.min, b.max)
|
|
199
|
+
else {
|
|
200
|
+
const raw = env[b.env]
|
|
201
|
+
const n = raw === undefined || raw.trim() === '' ? null : Number(raw)
|
|
202
|
+
e = n === null ? null : n >= b.min && n <= b.max ? n : knobFallback<number | null>(b.env, raw!, null, `a number from ${b.min} to ${b.max}`)
|
|
203
|
+
}
|
|
204
|
+
const v = saved[name]
|
|
205
|
+
out[name] = e !== null ? { value: e, source: 'env', locked: true, min: b.min, max: b.max, default: b.default }
|
|
206
|
+
: v !== undefined ? { value: v, source: 'setting', locked: false, min: b.min, max: b.max, default: b.default }
|
|
207
|
+
: { value: b.default, source: 'default', locked: false, min: b.min, max: b.max, default: b.default }
|
|
208
|
+
}
|
|
209
|
+
return out
|
|
210
|
+
}
|
|
158
211
|
export function liveCuesV2JudgeKeep(env: NodeJS.ProcessEnv = process.env): number {
|
|
159
212
|
const raw = env.COS_LIVE_CUES_V2_JUDGE_KEEP
|
|
160
213
|
if (raw === undefined || raw.trim() === '') return LIVE_CUES_V2_LIMITS.judgeKeep
|
|
161
214
|
const keep = Number(raw)
|
|
162
215
|
return keep >= 0.5 && keep <= 0.95 ? keep : knobFallback('COS_LIVE_CUES_V2_JUDGE_KEEP', raw, LIVE_CUES_V2_LIMITS.judgeKeep, 'a number from 0.5 to 0.95')
|
|
163
216
|
}
|
|
217
|
+
/** COS_LIVE_CUES_V2_JUDGE_YES: the grounded judge's yes threshold, 0.5 to 0.95. Unset or empty is 0.65 silently; anything else
|
|
218
|
+
* outside the range is 0.65 with one log line per value (the same rule as JUDGE_KEEP). */
|
|
219
|
+
export function liveCuesV2JudgeYes(env: NodeJS.ProcessEnv = process.env): number {
|
|
220
|
+
const raw = env.COS_LIVE_CUES_V2_JUDGE_YES
|
|
221
|
+
if (raw === undefined || raw.trim() === '') return LIVE_CUES_V2_LIMITS.judgeYes
|
|
222
|
+
const yes = Number(raw)
|
|
223
|
+
return yes >= 0.5 && yes <= 0.95 ? yes : knobFallback('COS_LIVE_CUES_V2_JUDGE_YES', raw, LIVE_CUES_V2_LIMITS.judgeYes, 'a number from 0.5 to 0.95')
|
|
224
|
+
}
|
|
164
225
|
/** COS_LIVE_CUES_V2_REPEAT_OVERLAP: the repeat rule's threshold, 0.3 to 0.9, or null (off, today's behaviour) when unset
|
|
165
226
|
* or empty. Any other value leaves the rule off, with one log line per value. RESULTS_filter.md: 0.5 withheld no useful
|
|
166
227
|
* cue on any receipt; 0.4 withheld three. */
|
|
@@ -170,6 +231,11 @@ export function liveCuesV2RepeatOverlap(env: NodeJS.ProcessEnv = process.env): n
|
|
|
170
231
|
const t = Number(raw)
|
|
171
232
|
return t >= 0.3 && t <= 0.9 ? t : knobFallback<number | null>('COS_LIVE_CUES_V2_REPEAT_OVERLAP', raw, null, 'a number from 0.3 to 0.9 (the repeat rule stays off)')
|
|
172
233
|
}
|
|
234
|
+
/** 6.97.8: the four lens-tuning rules (lead repeat, pacing, open items, known names) are on unless their switch is exactly `0`.
|
|
235
|
+
* A kill switch for the Mac, not a setting: an unset, empty or any other value leaves the rule on. */
|
|
236
|
+
export function liveCuesV2Rule(name: 'LEAD_REPEAT' | 'PACING' | 'OPEN_ITEMS' | 'KNOWN_NAMES', env: NodeJS.ProcessEnv = process.env): boolean {
|
|
237
|
+
return (env[`COS_LIVE_CUES_V2_${name}`] ?? '').trim() !== '0'
|
|
238
|
+
}
|
|
173
239
|
export function liveCuesV2Model(env: NodeJS.ProcessEnv = process.env): string {
|
|
174
240
|
const raw = (env.COS_LIVE_CUES_V2_MODEL ?? '').trim().toLowerCase()
|
|
175
241
|
if (raw === 'haiku') return 'claude-haiku-5-5'
|
|
@@ -182,6 +248,11 @@ export function liveCuesV2WriterSystem(env: NodeJS.ProcessEnv = process.env, kin
|
|
|
182
248
|
if (kinds === 'extended') return { version: 'v4', system: CUE_WRITER_SYSTEM_V4 }
|
|
183
249
|
return (env.COS_LIVE_CUES_V2_PROMPT ?? '').trim() === 'v2' ? { version: 'v2', system: CUE_WRITER_SYSTEM_V2 } : { version: 'v3', system: CUE_WRITER_SYSTEM_V3 }
|
|
184
250
|
}
|
|
251
|
+
/** 6.97.9: the Mac-only machinery (the general web runner and its insights, cue mode `general` switching the focused cues off, the open-items
|
|
252
|
+
* fold) keys on the session being a Mac recording, never on its kind set: a G2 meeting may be armed with all five kinds (the phone does)
|
|
253
|
+
* and gets the five kinds and writer v4, nothing else of the Mac's. */
|
|
254
|
+
const macRecording = (s: { sessionId: string }): boolean => MAC_CAPTURE_ID.test(s.sessionId)
|
|
255
|
+
|
|
185
256
|
/** 6.86.0: which kinds a meeting gets when its armer does not say. A Mac recording (`mac_` capture id) is read only by COS
|
|
186
257
|
* Control, which shows any kind, so it gets the extended set; every other meeting keeps the two legacy kinds, because a
|
|
187
258
|
* companion that validates `kind` drops a cue whose kind it does not know. */
|
|
@@ -238,7 +309,12 @@ export function pyJson(value: unknown): string {
|
|
|
238
309
|
// ── Pure helpers (exported for tests) ───────────────────────────────────────
|
|
239
310
|
/** A note as the writer sees it ({id, date, title, notes}; the same keys the replay sent). `key` is the stable meeting
|
|
240
311
|
* identity from cue_memory.py; it never reaches the prompt. */
|
|
241
|
-
export interface MemoryNote {
|
|
312
|
+
export interface MemoryNote {
|
|
313
|
+
id: string; date: string; title: string; notes: string; key?: string
|
|
314
|
+
/** Live cues v3, set only on grounded candidates: the meeting domain the note was filed under, the record, memory or file it
|
|
315
|
+
* is (a cue source's locator), and the session it was recorded from. None of them reaches a prompt. */
|
|
316
|
+
domain?: string; recordId?: string; memoryId?: string; fileId?: string; sessionId?: string
|
|
317
|
+
}
|
|
242
318
|
|
|
243
319
|
/** A cue's headline: the first sentence when it fits, otherwise cut at a word with an ellipsis. Never empty. */
|
|
244
320
|
export function cueHeadline(detail: string): string {
|
|
@@ -265,7 +341,8 @@ export function unsupportedNumbers(cue: string, window: string, memory: MemoryNo
|
|
|
265
341
|
return bad
|
|
266
342
|
}
|
|
267
343
|
|
|
268
|
-
|
|
344
|
+
/** `open` (6.97.8): the writer's own item when the cue's main point is a missing owner or date (the extended-kind prompt asks for it). */
|
|
345
|
+
export interface ParsedCue { detail: string; kind: CueKind; memoryIds: string[]; numberFlags: string[]; open?: string }
|
|
269
346
|
export type CueRejection = 'null' | 'unparseable' | 'empty' | 'too_long' | 'unsupported_number'
|
|
270
347
|
/** `enforceNumbers` withholds a cue whose numbers are not in the source. Off by default: on the held-out replay the
|
|
271
348
|
* guard fired on 3 v3 cues and only 1 was unsupported (ASR writes "300 000" and "a hundred"), so it is traced, not enforced. */
|
|
@@ -287,7 +364,7 @@ export function parseWriterCue(raw: string, fallbackKind: CueKind, window: strin
|
|
|
287
364
|
const kind: CueKind = typeof value.kind === 'string' && (allowed as readonly string[]).includes(value.kind) ? value.kind as CueKind : fallbackKind
|
|
288
365
|
const ids = new Set(kept.map(m => m.id))
|
|
289
366
|
const memoryIds = Array.isArray(value.memoryIds) ? [...new Set(value.memoryIds.filter((x): x is string => typeof x === 'string' && ids.has(x)))] : []
|
|
290
|
-
return { detail: cue, kind, memoryIds, numberFlags }
|
|
367
|
+
return { detail: cue, kind, memoryIds, numberFlags, ...(typeof value.open === 'string' && value.open.trim() ? { open: value.open } : {}) }
|
|
291
368
|
}
|
|
292
369
|
|
|
293
370
|
/** A stable, opaque id for a past meeting (the same meeting gets the same id in every cue). */
|
|
@@ -306,7 +383,8 @@ export function localWeekStart(now: Date, zone: string): string {
|
|
|
306
383
|
}
|
|
307
384
|
|
|
308
385
|
// ── Durable state: weekly ceiling, tombstones, pauses, per-meeting counters ─────
|
|
309
|
-
|
|
386
|
+
/** `since` (6.97.8): when the meeting's first writer call was charged, so a restarted server keeps the pacing clock. Absent in older files. */
|
|
387
|
+
interface MeetingCounters { cues: number; writerCalls: number; at: number; since?: number }
|
|
310
388
|
/** 6.79.2: usage on the local calendar day (in the stored zone), for the daily writer ceiling and COS Control's readout. */
|
|
311
389
|
export interface LiveCuesV2DayUsage { writerCalls: number; writerTokensIn: number; writerTokensOut: number; cues: number; jevTokens: number }
|
|
312
390
|
const DAY_FIELDS = ['writerCalls', 'writerTokensIn', 'writerTokensOut', 'cues', 'jevTokens'] as const
|
|
@@ -384,7 +462,8 @@ export class LiveCuesV2Store {
|
|
|
384
462
|
s.writerCalls += 1
|
|
385
463
|
s.today.writerCalls += 1
|
|
386
464
|
const m = s.meetings[sessionId] ?? { cues: 0, writerCalls: 0, at: 0 }
|
|
387
|
-
|
|
465
|
+
const nowMs = this.now().getTime()
|
|
466
|
+
s.meetings[sessionId] = { ...m, writerCalls: m.writerCalls + 1, at: nowMs, since: typeof m.since === 'number' ? m.since : nowMs }
|
|
388
467
|
this.write(s)
|
|
389
468
|
return true
|
|
390
469
|
}
|
|
@@ -412,6 +491,19 @@ export class LiveCuesV2Store {
|
|
|
412
491
|
return { cues: m?.cues ?? 0, writerCalls: m?.writerCalls ?? 0 }
|
|
413
492
|
}
|
|
414
493
|
/** The durable per-meeting counters, or null once they expired (24 h) or never existed. */
|
|
494
|
+
/** 6.97.8: remember when a meeting began, at arm, so the pacing clock is the meeting's and not the first writer call's. Once. */
|
|
495
|
+
noteArm(sessionId: string): void {
|
|
496
|
+
const s = this.read()
|
|
497
|
+
if (s.meetings[sessionId]) return
|
|
498
|
+
const nowMs = this.now().getTime()
|
|
499
|
+
s.meetings[sessionId] = { cues: 0, writerCalls: 0, at: nowMs, since: nowMs }
|
|
500
|
+
this.write(s)
|
|
501
|
+
}
|
|
502
|
+
/** When this meeting began (6.97.8), or null for a meeting from an older file. */
|
|
503
|
+
meetingSince(sessionId: string): number | null {
|
|
504
|
+
const since = this.read().meetings[sessionId]?.since
|
|
505
|
+
return typeof since === 'number' && Number.isFinite(since) ? since : null
|
|
506
|
+
}
|
|
415
507
|
meetingCounters(sessionId: string): { cues: number; writerCalls: number } | null {
|
|
416
508
|
const m = this.read().meetings[sessionId]
|
|
417
509
|
return m ? { cues: m.cues, writerCalls: m.writerCalls } : null
|
|
@@ -452,14 +544,34 @@ export const CUE_MEMORY_FAILURES: ReadonlySet<string> = new Set(['no_cue_memory_
|
|
|
452
544
|
export function cueMemoryFailed(mem: MemoryResult): boolean { return !!mem.denseError && CUE_MEMORY_FAILURES.has(mem.denseError) }
|
|
453
545
|
export interface LiveCuesV2Deps {
|
|
454
546
|
gate(title: string, window: string, signal: AbortSignal, provider: DecisionProvider): Promise<GateResult>
|
|
455
|
-
/** cue_memory.py (Miles's COS scripts).
|
|
456
|
-
|
|
547
|
+
/** cue_memory.py (Miles's COS scripts). `top` (grounded cues) asks for more than the usual four, so that excluded copies of
|
|
548
|
+
* this meeting still leave four; classic never passes it. */
|
|
549
|
+
memory(window: string, before: string, signal: AbortSignal, top?: number): Promise<MemoryResult>
|
|
457
550
|
/** The owner's own COS notes and meetings, used only when `memory` did not answer. Local search, no model call. */
|
|
458
551
|
notes(window: string, before: string, signal: AbortSignal, sessionId: string): Promise<MemoryResult>
|
|
459
552
|
judge(title: string, window: string, notes: MemoryNote[], signal: AbortSignal, provider: DecisionProvider): Promise<{ probs: Record<string, number>; tokens: number }>
|
|
460
553
|
writer(model: string, system: string, prompt: string, signal: AbortSignal): Promise<{ text: string; costUsd?: number; inputTokens?: number; outputTokens?: number; model?: string }>
|
|
461
554
|
emit(type: 'coaching_nudge' | 'coaching_nudge_v2', data: Record<string, unknown>): void
|
|
462
555
|
lease(): MaintenanceWorkLease | null
|
|
556
|
+
/** Live cues v3, grounded cues only (absent means none): the files attached to this meeting (MeetingAskStore.files). May throw. */
|
|
557
|
+
files?(sessionId: string): GroundedFile[]
|
|
558
|
+
/** Live cues, grounded only (absent means no data arm): the Tableau facts file, read and checked for staleness. Never throws. */
|
|
559
|
+
dataFacts?(): DataFactsRead
|
|
560
|
+
/** The user-confirmed context of this meeting: its domain and calendar title (MeetingAskStore.orientation). May throw. */
|
|
561
|
+
orientation?(sessionId: string): { domain: string | null; title: string | null }
|
|
562
|
+
/** 6.97.8 pacing: the length of the calendar match for this meeting in ms, or null. Separate from `orientation`, which classic cues never read. May throw. */
|
|
563
|
+
meetingLength?(sessionId: string): number | null
|
|
564
|
+
/** Live cues v3, general cues (absent means none: no runner is built): the general runner's dependencies. */
|
|
565
|
+
general?: GeneralCueDeps
|
|
566
|
+
/** 6.97.8: the spellings of names the writer may use (the meeting's speakers, enrolled voices, the owner, the owner's vocabulary
|
|
567
|
+
* names). Absent means none: the writer prompt is then byte for byte what it was. May throw or reject; a failure means none. */
|
|
568
|
+
knownNames?(speakers: readonly string[]): Promise<string[]> | string[]
|
|
569
|
+
/** Ask Public topics consent (MeetingAskStore.publicTopics), which general cues need. May throw. */
|
|
570
|
+
publicTopics?(): boolean
|
|
571
|
+
/** The owner's city-level home area (profile home_city), or null. May throw. */
|
|
572
|
+
homeArea?(): string | null
|
|
573
|
+
/** The grounded judge: one yes/no question per candidate (judge B) and the `same` choice as a diagnostic. */
|
|
574
|
+
judgeB?(title: string, window: string, cands: GroundedNote[], signal: AbortSignal, provider: DecisionProvider): Promise<{ yes: Record<string, number>; same: Record<string, number>; tokens: number }>
|
|
463
575
|
/** True while the user's own query runs (Ask, a durable query): cues wait rather than compete for the provider. */
|
|
464
576
|
busy(): boolean
|
|
465
577
|
/** The configured decision provider for cues. Throws DecisionProviderError('decision_invalid_provider'). */
|
|
@@ -504,6 +616,13 @@ export function cueDecisionCalls(client: Pick<DecisionClient, 'ask'>) {
|
|
|
504
616
|
const res = await client.ask(state, questions, estimateJevTokens({ state, questions }), { signal })
|
|
505
617
|
return { probs: res.answers?.same?.probabilities ?? {}, tokens: res.inputTokens }
|
|
506
618
|
},
|
|
619
|
+
/** Grounded cues: ONE request, a yes/no question per candidate plus the `same` choice (live-cues-v2-judge-b.ts). */
|
|
620
|
+
async judgeB(title: string, window: string, cands: GroundedNote[], signal: AbortSignal): Promise<{ yes: Record<string, number>; same: Record<string, number>; tokens: number }> {
|
|
621
|
+
const state = { meetingTitle: title, window: window.slice(-JUDGE_B_WINDOW_CHARS) }
|
|
622
|
+
const questions = judgeBQuestions(cands)
|
|
623
|
+
const res = await client.ask(state, questions, estimateJevTokens({ state, questions }), { signal })
|
|
624
|
+
return { ...parseJudgeB(res.answers, cands.map(c => c.id)), tokens: res.inputTokens }
|
|
625
|
+
},
|
|
507
626
|
}
|
|
508
627
|
}
|
|
509
628
|
|
|
@@ -520,15 +639,30 @@ function realDeps(): LiveCuesV2Deps {
|
|
|
520
639
|
const subq = trackSubqDecisionClient(new SubqDecisionClient(fetch, () => new Date(), dataPath('subq-cues-usage.json'), () => resolveSubqKey()?.key, subqDailyCap))
|
|
521
640
|
const calls = (provider: DecisionProvider) => cueDecisionCalls(provider === 'subq' ? subq : jev)
|
|
522
641
|
const traceFile = dataPath('live-cues-trace.jsonl')
|
|
642
|
+
const trace = (row: Record<string, unknown>) => {
|
|
643
|
+
try { mkdirSync(dirname(traceFile), { recursive: true, mode: 0o700 }); appendFileSync(traceFile, JSON.stringify(row) + '\n', { mode: 0o600 }) } catch { /* trace is best effort */ }
|
|
644
|
+
}
|
|
523
645
|
let memorySourceCache: { at: number; value: LiveCuesV2MemorySource } | null = null
|
|
524
646
|
return {
|
|
525
647
|
gate: (title, window, signal, provider) => calls(provider).gate(title, window, signal),
|
|
526
|
-
memory: (window, before, signal) => cueMemory(window, before, signal),
|
|
648
|
+
memory: (window, before, signal, top) => cueMemory(window, before, signal, undefined, undefined, top),
|
|
527
649
|
async notes(window, before, signal, sessionId) {
|
|
528
650
|
const r = await notesMemory(window, before, signal, { sessionId })
|
|
529
651
|
return { notes: r.notes, ...(r.error ? { denseError: r.error } : {}) }
|
|
530
652
|
},
|
|
531
653
|
judge: (title, window, notes, signal, provider) => calls(provider).judge(title, window, notes, signal),
|
|
654
|
+
judgeB: (title, window, cands, signal, provider) => calls(provider).judgeB(title, window, cands, signal),
|
|
655
|
+
files: sessionId => new MeetingAskStore().files(sessionId),
|
|
656
|
+
dataFacts: dataFactsReader(dataPath(DATA_FACTS_FILE)),
|
|
657
|
+
meetingLength(sessionId) {
|
|
658
|
+
const cal = new MeetingAskStore().orientation(sessionId).calendar
|
|
659
|
+
const start = Date.parse(cal?.start ?? ''), end = Date.parse(cal?.end ?? '')
|
|
660
|
+
return Number.isFinite(start) && Number.isFinite(end) && end > start ? end - start : null
|
|
661
|
+
},
|
|
662
|
+
orientation(sessionId) {
|
|
663
|
+
const o = new MeetingAskStore().orientation(sessionId)
|
|
664
|
+
return { domain: o.domain, title: o.calendar?.title ?? null }
|
|
665
|
+
},
|
|
532
666
|
decisionProvider: () => resolveCuesDecisionProvider(process.env).provider,
|
|
533
667
|
decisionState(provider) {
|
|
534
668
|
if (provider === 'subq') {
|
|
@@ -560,9 +694,17 @@ function realDeps(): LiveCuesV2Deps {
|
|
|
560
694
|
warmMemory: () => warmCueMemory(),
|
|
561
695
|
now: () => new Date(),
|
|
562
696
|
store: new LiveCuesV2Store(dataPath('live-cues-v2-state.json'), () => new Date()),
|
|
563
|
-
trace
|
|
564
|
-
|
|
565
|
-
|
|
697
|
+
trace,
|
|
698
|
+
general: realGeneralDeps({
|
|
699
|
+
// The trigger is one more request on the cues' own decision client, so it shares their daily cap and breaker.
|
|
700
|
+
jevTrigger: (title, window, signal) => generalTriggerCalls(resolveCuesDecisionProvider(process.env).provider === 'subq' ? subq : jev).trigger(title, window, signal),
|
|
701
|
+
writerModel: () => liveCuesV2Model(process.env),
|
|
702
|
+
statePath: dataPath('live-cues-general-state.json'),
|
|
703
|
+
trace: row => trace(row as unknown as Record<string, unknown>),
|
|
704
|
+
}),
|
|
705
|
+
knownNames: speakers => knownNamesFor(speakers),
|
|
706
|
+
publicTopics: () => new MeetingAskStore().publicTopics(),
|
|
707
|
+
homeArea: () => getOwnerHomeArea(),
|
|
566
708
|
cueLog: row => liveCueVerdicts().logCue(row),
|
|
567
709
|
env: process.env,
|
|
568
710
|
}
|
|
@@ -588,7 +730,7 @@ export function warmCueMemory(): void {
|
|
|
588
730
|
}
|
|
589
731
|
|
|
590
732
|
/** Rank-fusion retrieval through COS scripts/cue_memory.py. Transcript text goes over stdin, never argv. Non-fatal. */
|
|
591
|
-
export function cueMemory(window: string, before: string, signal: AbortSignal, p: CueMemoryPaths | null = cueMemoryPaths(), timeoutMs: number = LIVE_CUES_V2_LIMITS.memoryTimeoutMs): Promise<MemoryResult> {
|
|
733
|
+
export function cueMemory(window: string, before: string, signal: AbortSignal, p: CueMemoryPaths | null = cueMemoryPaths(), timeoutMs: number = LIVE_CUES_V2_LIMITS.memoryTimeoutMs, top = 4): Promise<MemoryResult> {
|
|
592
734
|
if (!p) return Promise.resolve({ notes: [], denseError: 'no_cue_memory_script' })
|
|
593
735
|
const paths = p
|
|
594
736
|
return new Promise(resolveNotes => {
|
|
@@ -606,16 +748,20 @@ export function cueMemory(window: string, before: string, signal: AbortSignal, p
|
|
|
606
748
|
proc.on('close', () => {
|
|
607
749
|
try {
|
|
608
750
|
const res = JSON.parse(out) as { notes?: MemoryNote[]; timing?: { dense_error?: string }; index?: { ageHours?: number } }
|
|
609
|
-
const notes = (res.notes ?? []).filter(n => n && typeof n.id === 'string' && typeof n.notes === 'string').slice(0,
|
|
751
|
+
const notes = (res.notes ?? []).filter(n => n && typeof n.id === 'string' && typeof n.notes === 'string').slice(0, top)
|
|
610
752
|
done({ notes, denseError: res.timing?.dense_error, indexAgeH: res.index?.ageHours })
|
|
611
753
|
} catch { done({ notes: [], denseError: 'bad_output' }) }
|
|
612
754
|
})
|
|
613
755
|
proc.stdin?.on('error', () => { /* child exited early */ })
|
|
614
|
-
proc.stdin?.end(JSON.stringify({ queries: [window.slice(-600)], keywordText: window, before, top
|
|
756
|
+
proc.stdin?.end(JSON.stringify({ queries: [window.slice(-600)], keywordText: window, before, top }))
|
|
615
757
|
})
|
|
616
758
|
}
|
|
617
759
|
|
|
618
760
|
// ── Engine ──────────────────────────────────────────────────────────────────
|
|
761
|
+
/** What grounded retrieval hands the rest of an evaluation: the candidates the judge saw, the ones it kept (best first), where
|
|
762
|
+
* the notes came from, the title the writer is given, and which arms supplied a candidate. */
|
|
763
|
+
interface GroundedRun { candidates: GroundedNote[]; kept: GroundedNote[]; source: LiveCuesV2MemorySource; writerTitle: string; arms: GroundedArm[] }
|
|
764
|
+
|
|
619
765
|
interface Session {
|
|
620
766
|
sessionId: string
|
|
621
767
|
title: string
|
|
@@ -635,7 +781,27 @@ interface Session {
|
|
|
635
781
|
skipTracedAt: Record<string, number>
|
|
636
782
|
/** 6.86.0: the kinds this meeting's cues may carry, fixed at arm. */
|
|
637
783
|
kinds: CueKindSet
|
|
784
|
+
/** Live cues v3: cleaned characters fed since arm. Monotonic (the chunk window is capped, this is not): the general runner
|
|
785
|
+
* measures "about 90 s of new speech" with it. */
|
|
786
|
+
charsFed: number
|
|
787
|
+
/** Live cues v3: the speaker names the ASR path reported (at most SPEAKER_NAMES_MAX), kept for the general runner's name
|
|
788
|
+
* gate. Stored only: the focused path never reads them. */
|
|
789
|
+
speakers: Set<string>
|
|
790
|
+
/** Live cues v3: this meeting's insights. Apart from `cues` on purpose: previousCues, the repeat rule and the shadow template
|
|
791
|
+
* rules read `cues`, and an insight must not change what a focused cue sees. */
|
|
792
|
+
insights: LiveCueV2[]
|
|
793
|
+
/** Live cues v3: the 6-word sequences of everything said since arm, fed only while grounding is on (the self-copy check). */
|
|
794
|
+
said: SessionShingles
|
|
795
|
+
/** 6.97.8: the open-item cues held for a combined cue. Apart from `cues`: a held item was never shown. */
|
|
796
|
+
fold: OpenItemFold
|
|
797
|
+
/** 6.97.8 pacing: the cap seen on the last feed, the cue count a raised cap resumes from, and the calendar length read at most once a minute. */
|
|
798
|
+
lastCap: number
|
|
799
|
+
paceFloor: number
|
|
800
|
+
horizon: { at: number; ms: number | null }
|
|
638
801
|
}
|
|
802
|
+
/** A session's status row, plus (live cues v3) the general-cue state for an extended meeting. */
|
|
803
|
+
export type LiveCuesV2SessionStatusV3 = LiveCuesV2SessionStatus & { general?: GeneralCueStatus }
|
|
804
|
+
export interface LiveCuesV2StatusResponseV3 { protocol: 2; enabled: boolean; session: LiveCuesV2SessionStatusV3 | null }
|
|
639
805
|
|
|
640
806
|
/** 6.86.0: how a meeting is armed. `kinds` defaults by session (defaultCueKindSet); `resume` makes a restarted server re-arm
|
|
641
807
|
* it on its next chunk (the Mac recording start passes it). */
|
|
@@ -653,14 +819,20 @@ function isCancel(error: unknown, signal: AbortSignal): boolean {
|
|
|
653
819
|
* provider's own breaker and cap already bound these; counting them would also end the meeting's cues. */
|
|
654
820
|
export const QUIET_DECISION_STOPS: ReadonlySet<string> = new Set(['jev_cap_reached', 'jev_breaker_open', 'jev_not_configured',
|
|
655
821
|
'decision_cap_reached', 'decision_breaker_open', 'decision_not_configured', 'decision_invalid_provider'])
|
|
822
|
+
/** How many distinct speaker names a meeting remembers for the general runner's name gate. */
|
|
823
|
+
export const SPEAKER_NAMES_MAX = 200
|
|
656
824
|
/** Abort reason when v2 is switched off at runtime. */
|
|
657
825
|
export const V2_OFF = 'v2_off'
|
|
658
826
|
|
|
659
827
|
export class LiveCuesV2Engine {
|
|
660
828
|
private readonly sessions = new Map<string, Session>()
|
|
661
|
-
|
|
829
|
+
private readonly general: GeneralCueRunner | null
|
|
830
|
+
constructor(private readonly deps: LiveCuesV2Deps) {
|
|
831
|
+
this.general = deps.general ? new GeneralCueRunner(this.generalHost(), this.generalSettings(), deps.general) : null
|
|
832
|
+
}
|
|
662
833
|
|
|
663
834
|
private enabled(): boolean { return liveCuesV2Enabled(this.deps.env, this.deps.settings) }
|
|
835
|
+
private groundingOn(): boolean { return resolveLiveCuesGrounding(this.deps.env, this.deps.settings).value === 'grounded' }
|
|
664
836
|
/** 6.79.2: the spend limits as they stand now (env, else COS Control's saved values, else the defaults). The writer
|
|
665
837
|
* cap per meeting stays twice the cue cap. */
|
|
666
838
|
private limits(): { gate: number; maxCues: number; writerCap: number; cooldownMs: number; dailyWriterCalls: number } {
|
|
@@ -719,7 +891,8 @@ export class LiveCuesV2Engine {
|
|
|
719
891
|
const d = this.deps.now()
|
|
720
892
|
const title = `${mac ? 'Mac' : 'G2'} Recording ${d.toLocaleDateString('en-CA')} ${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`
|
|
721
893
|
this.sessions.set(sessionId, { sessionId, title, armedAt: nowMs, lastSeenAt: nowMs, chunks: [], wordsSinceEval: 0, lastEvalAt: 0, lastCueAt: 0,
|
|
722
|
-
inFlight: null, cues: [], evaluations: 0, failures: 0, breaker: false, stopReason: null, skipTracedAt: {}, kinds })
|
|
894
|
+
inFlight: null, cues: [], evaluations: 0, failures: 0, breaker: false, stopReason: null, skipTracedAt: {}, kinds, charsFed: 0, speakers: new Set(), insights: [], said: new SessionShingles(), fold: new OpenItemFold(), lastCap: 0, paceFloor: 0, horizon: { at: 0, ms: null } })
|
|
895
|
+
this.deps.store.noteArm(sessionId)
|
|
723
896
|
if (opts.resume) this.deps.store.setResume(sessionId, kinds)
|
|
724
897
|
this.deps.warmMemory()
|
|
725
898
|
return answer(this.deps.store.counters(sessionId).cues, kinds)
|
|
@@ -730,28 +903,34 @@ export class LiveCuesV2Engine {
|
|
|
730
903
|
this.deps.store.end(sessionId)
|
|
731
904
|
const s = this.sessions.get(sessionId)
|
|
732
905
|
s?.inFlight?.abort()
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
906
|
+
s?.said.clear()
|
|
907
|
+
this.general?.endSession(sessionId)
|
|
908
|
+
const cues = s ? this.allCues(s) : []
|
|
909
|
+
// v1 readers know two types: an extended kind is shown as its legacy kind there (the cues keep the real one).
|
|
910
|
+
// An insight (live cues v3) has no v1 twin, so it is not counted as a nudge either.
|
|
911
|
+
const nudges = cues.flatMap(c => (isCueKind(c.kind) ? [{ nudge: c.headline, type: legacyCueKind(c.kind), priority: 2, timestamp: c.createdAt, cueId: c.cueId }] : []))
|
|
912
|
+
return { nudgesGenerated: nudges.length, protocol: 2, cues, nudges }
|
|
737
913
|
}
|
|
738
914
|
|
|
739
915
|
setPaused(sessionId: string, paused: boolean, seq?: number): { ok: true; paused: boolean; applied: boolean; pauseSeq: number } {
|
|
740
916
|
const r = this.deps.store.setPaused(sessionId, paused, seq)
|
|
741
|
-
if (r.applied && paused) this.sessions.get(sessionId)?.inFlight?.abort()
|
|
917
|
+
if (r.applied && paused) { this.sessions.get(sessionId)?.inFlight?.abort(); this.general?.abortSession(sessionId, 'paused') }
|
|
742
918
|
return { ok: true, ...r }
|
|
743
919
|
}
|
|
744
920
|
|
|
745
|
-
status(sessionId: string):
|
|
921
|
+
status(sessionId: string): LiveCuesV2StatusResponseV3 {
|
|
746
922
|
const s = this.sessions.get(sessionId)
|
|
747
923
|
const ended = this.deps.store.isEnded(sessionId)
|
|
748
924
|
const paused = this.deps.store.isPaused(sessionId)
|
|
749
925
|
if (!s && !ended) return { protocol: 2, enabled: this.enabled(), session: null }
|
|
750
926
|
const counters = this.deps.store.counters(sessionId)
|
|
751
927
|
const reason = s ? this.stopReasonOf(s, counters) : null
|
|
752
|
-
|
|
928
|
+
let general: GeneralCueStatus | null = null
|
|
929
|
+
if (s) { try { general = this.general?.status(sessionId) ?? null } catch { general = null } } // null for a meeting the runner is not offered (G2)
|
|
930
|
+
const session: LiveCuesV2SessionStatusV3 = {
|
|
753
931
|
sessionId, state: ended ? 'ended' : paused ? 'paused' : reason ? 'stopped' : 'active', ...(reason && !ended && !paused ? { reason } : {}),
|
|
754
|
-
paused, ended, pauseSeq: this.deps.store.pauseSeq(sessionId), cues: s ?
|
|
932
|
+
paused, ended, pauseSeq: this.deps.store.pauseSeq(sessionId), cues: s ? this.allCues(s) : [], evaluations: s?.evaluations ?? 0, writerCalls: counters.writerCalls,
|
|
933
|
+
...(general ? { general } : {}),
|
|
755
934
|
}
|
|
756
935
|
return { protocol: 2, enabled: this.enabled(), session }
|
|
757
936
|
}
|
|
@@ -769,18 +948,20 @@ export class LiveCuesV2Engine {
|
|
|
769
948
|
/** The cue, if this process emitted it and still holds it, under whichever meeting. */
|
|
770
949
|
findCue(cueId: string): LiveCueV2 | null {
|
|
771
950
|
for (const s of this.sessions.values()) { const cue = s.cues.find(c => c.cueId === cueId); if (cue) return cue }
|
|
951
|
+
for (const s of this.sessions.values()) { const cue = s.insights.find(c => c.cueId === cueId); if (cue) return cue }
|
|
772
952
|
return null
|
|
773
953
|
}
|
|
774
954
|
|
|
775
955
|
recordFeedback(input: { sessionId: string; cueId: string; vote: 'up' | 'down'; reason?: string }): { ok: true; knownCue: boolean } {
|
|
776
|
-
const
|
|
956
|
+
const held = this.sessions.get(input.sessionId)
|
|
957
|
+
const knownCue = !!held && (held.cues.some(c => c.cueId === input.cueId) || held.insights.some(c => c.cueId === input.cueId))
|
|
777
958
|
const reason = (input.reason ?? '').replace(/\s+/g, ' ').trim().slice(0, CUE_FEEDBACK_REASON_MAX)
|
|
778
959
|
this.deps.trace({ kind: 'feedback', at: this.deps.now().toISOString(), sessionId: input.sessionId, cueId: input.cueId, vote: input.vote, knownCue, ...(reason ? { reason } : {}) })
|
|
779
960
|
return { ok: true, knownCue }
|
|
780
961
|
}
|
|
781
962
|
|
|
782
963
|
/** From the ASR path: fire-and-forget, never awaited. All gates live here. */
|
|
783
|
-
async feed(sessionId: string, text: string, asrDegraded = false): Promise<void> {
|
|
964
|
+
async feed(sessionId: string, text: string, asrDegraded = false, speakers?: readonly string[]): Promise<void> {
|
|
784
965
|
if (!this.enabled()) return
|
|
785
966
|
if (this.deps.store.isEnded(sessionId)) return // tombstone before any auto-arm
|
|
786
967
|
let s = this.sessions.get(sessionId)
|
|
@@ -795,14 +976,19 @@ export class LiveCuesV2Engine {
|
|
|
795
976
|
}
|
|
796
977
|
const nowMs = this.deps.now().getTime()
|
|
797
978
|
s.lastSeenAt = nowMs
|
|
979
|
+
this.noteSpeakers(s, speakers)
|
|
798
980
|
const words = text.trim().split(/\s+/).filter(Boolean)
|
|
799
981
|
if (words.length) {
|
|
800
982
|
s.chunks.push(text.trim())
|
|
983
|
+
s.fold.noteSaid(text) // 6.97.8: a held open item the room talks about again may be settled
|
|
984
|
+
s.charsFed += cleanWindow(text.trim()).length
|
|
985
|
+
if (this.groundingOn()) s.said.add(cleanWindow(text.trim()))
|
|
801
986
|
s.wordsSinceEval += words.length
|
|
802
987
|
let total = s.chunks.reduce((n, c) => n + c.split(/\s+/).length, 0)
|
|
803
988
|
while (s.chunks.length > 1 && total > LIVE_CUES_V2_LIMITS.windowWords) { total -= s.chunks[0].split(/\s+/).length; s.chunks.shift() }
|
|
804
989
|
}
|
|
805
990
|
const L = LIVE_CUES_V2_LIMITS
|
|
991
|
+
if (this.focusedOff(s)) return // cue mode `general`: this Mac meeting gets insights only
|
|
806
992
|
if (s.inFlight || asrDegraded) return
|
|
807
993
|
if (s.breaker) return this.traceSkip(s, 'breaker_open', nowMs)
|
|
808
994
|
if (this.deps.store.isPaused(sessionId)) return this.traceSkip(s, 'paused', nowMs)
|
|
@@ -811,12 +997,80 @@ export class LiveCuesV2Engine {
|
|
|
811
997
|
if (nowMs - s.lastEvalAt < L.minIntervalMs || nowMs - s.lastCueAt < lim.cooldownMs) return
|
|
812
998
|
const counters = this.deps.store.counters(sessionId)
|
|
813
999
|
if (counters.cues >= lim.maxCues || counters.writerCalls >= lim.writerCap) return this.traceSkip(s, 'meeting_cap', nowMs)
|
|
1000
|
+
// 6.97.8 pacing, on unless COS_LIVE_CUES_V2_PACING=0: before the gate, so a paced-out minute costs no Jev call. The clock starts
|
|
1001
|
+
// at arm (kept in the store, so a restart keeps it). A cap raised mid-meeting resumes at once: one more cue is allowed now.
|
|
1002
|
+
if (liveCuesV2Rule('PACING', this.deps.env)) {
|
|
1003
|
+
if (s.lastCap > 0 && lim.maxCues > s.lastCap) s.paceFloor = counters.cues + 1
|
|
1004
|
+
s.lastCap = lim.maxCues
|
|
1005
|
+
const started = Math.min(s.armedAt, this.deps.store.meetingSince(sessionId) ?? s.armedAt)
|
|
1006
|
+
const allowed = Math.max(s.paceFloor, paceAllowed(lim.maxCues, lim.cooldownMs, nowMs - started, this.paceHorizonMs(s, nowMs)))
|
|
1007
|
+
if (counters.cues >= allowed) return this.traceSkip(s, 'paced', nowMs)
|
|
1008
|
+
}
|
|
1009
|
+
// 6.97.8 open items: a held cue is shown when it is due (a combined cue, or alone after 20 minutes). No model call.
|
|
1010
|
+
if (liveCuesV2Rule('OPEN_ITEMS', this.deps.env)) {
|
|
1011
|
+
const plan = s.fold.next(nowMs)
|
|
1012
|
+
if (plan) return this.showHeld(s, plan, nowMs)
|
|
1013
|
+
}
|
|
814
1014
|
if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env)) return this.traceSkip(s, 'budget_exhausted', nowMs) // before the gate
|
|
815
1015
|
if (this.deps.store.dailyWriterCalls() >= lim.dailyWriterCalls) return this.traceSkip(s, 'daily_cap', nowMs) // before the gate
|
|
816
1016
|
if (this.deps.busy()) return this.traceSkip(s, 'busy', nowMs)
|
|
817
1017
|
await this.evaluate(s)
|
|
818
1018
|
}
|
|
819
1019
|
|
|
1020
|
+
/** The pacing horizon: the calendar match's length when the meeting has one (read at most once a minute), else the default. */
|
|
1021
|
+
private paceHorizonMs(s: Session, nowMs: number): number {
|
|
1022
|
+
if (nowMs - s.horizon.at >= 60_000) {
|
|
1023
|
+
s.horizon.at = nowMs
|
|
1024
|
+
try { s.horizon.ms = this.deps.meetingLength?.(s.sessionId) ?? null } catch { s.horizon.ms = null }
|
|
1025
|
+
}
|
|
1026
|
+
return paceHorizon(s.horizon.ms)
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** Every cue of this meeting the writer has seen, shown or held, oldest first: the writer's previousCues and the repeat rules read it. */
|
|
1030
|
+
private cueHistory(s: Session): LiveCueV2[] {
|
|
1031
|
+
const held = s.fold.history()
|
|
1032
|
+
if (!held.length) return s.cues
|
|
1033
|
+
const seen = new Set<string>(), out: LiveCueV2[] = []
|
|
1034
|
+
for (const c of [...s.cues, ...held].sort((a, b) => a.createdAt - b.createdAt)) if (!seen.has(c.cueId)) { seen.add(c.cueId); out.push(c) }
|
|
1035
|
+
return out
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** 6.97.8: carry out what the fold says is due, without a model call: a combined cue (new row in the cue log), a held cue on its own
|
|
1039
|
+
* (a shown row now, beside its held row), or a stale held cue set aside because the room has talked about it again. */
|
|
1040
|
+
private showHeld(s: Session, plan: FlushPlan, nowMs: number): void {
|
|
1041
|
+
const items = plan.items
|
|
1042
|
+
if (plan.mode === 'drop') {
|
|
1043
|
+
s.fold.markShown(plan, nowMs)
|
|
1044
|
+
this.deps.trace({ kind: 'flush', at: new Date(nowMs).toISOString(), sessionId: s.sessionId, mode: 'drop', items: 1, cueId: items[0].cue.cueId })
|
|
1045
|
+
return
|
|
1046
|
+
}
|
|
1047
|
+
let cue: LiveCueV2, before: string
|
|
1048
|
+
if (plan.mode === 'alone') {
|
|
1049
|
+
cue = { ...items[0].cue, createdAt: nowMs } // the cue as written; re-stamped so the lens still flashes it
|
|
1050
|
+
before = items[0].before
|
|
1051
|
+
} else {
|
|
1052
|
+
const labels = items.map(i => i.label)
|
|
1053
|
+
cue = { protocol: 2, cueId: randomUUID(), sessionId: s.sessionId, headline: openItemsHeadline(labels), detail: openItemsDetail(labels),
|
|
1054
|
+
kind: s.kinds === 'extended' ? 'followUp' : 'challenge', sources: [], gate: round(Math.max(...items.map(i => i.cue.gate))), createdAt: nowMs, model: items[items.length - 1].cue.model }
|
|
1055
|
+
if (!isLiveCueV2(cue)) return
|
|
1056
|
+
before = cueBefore(s.chunks.join('\n').replace(/^\[\d+\]\s*/gm, ''))
|
|
1057
|
+
}
|
|
1058
|
+
// Shown now, so logged now without `held`: history lists it from this row (the held row, if any, stays hidden).
|
|
1059
|
+
const shadow = wouldWithhold(cue.detail, s.cues)
|
|
1060
|
+
const row: LiveCueLogRow = { v: 1, cueId: cue.cueId, sessionId: s.sessionId, meetingTitle: s.title, createdAt: cue.createdAt, kind: cue.kind, headline: cue.headline,
|
|
1061
|
+
detail: cue.detail, gate: cue.gate, sources: cue.sources, model: cue.model, prompt: liveCuesV2WriterSystem(this.deps.env, s.kinds).version, memorySource: 'none', kept: 0,
|
|
1062
|
+
wouldWithhold: shadow, before, armedAt: s.armedAt }
|
|
1063
|
+
try { this.deps.cueLog?.(row) } catch (error) { console.warn(`[live-cues-v2] cue log append failed: ${errMsg(error)}`) }
|
|
1064
|
+
s.fold.markShown(plan, nowMs)
|
|
1065
|
+
s.cues.push(cue)
|
|
1066
|
+
s.lastCueAt = nowMs
|
|
1067
|
+
this.deps.store.recordCue(s.sessionId)
|
|
1068
|
+
this.deps.emit('coaching_nudge_v2', cue as unknown as Record<string, unknown>)
|
|
1069
|
+
const v1: LiveCueV1Compat = { nudge: cue.headline, type: legacyCueKind(cue.kind as CueKind), priority: 2, cueId: cue.cueId, sessionId: s.sessionId, protocol: 2 }
|
|
1070
|
+
this.deps.emit('coaching_nudge', v1 as unknown as Record<string, unknown>)
|
|
1071
|
+
this.deps.trace({ kind: 'flush', at: new Date(nowMs).toISOString(), sessionId: s.sessionId, mode: plan.mode, items: items.length, cueId: cue.cueId })
|
|
1072
|
+
}
|
|
1073
|
+
|
|
820
1074
|
private traceSkip(s: Session, reason: string, nowMs: number): void {
|
|
821
1075
|
if (nowMs - (s.skipTracedAt[reason] ?? 0) < LIVE_CUES_V2_LIMITS.skipTraceEveryMs) return
|
|
822
1076
|
s.skipTracedAt[reason] = nowMs
|
|
@@ -853,9 +1107,18 @@ export class LiveCuesV2Engine {
|
|
|
853
1107
|
|
|
854
1108
|
const t1 = Date.now()
|
|
855
1109
|
const before = new Date(started + 86_400_000).toLocaleDateString('en-CA')
|
|
856
|
-
|
|
857
|
-
|
|
1110
|
+
// Live cues v3: read once per evaluation, so a change of setting applies from the next one.
|
|
1111
|
+
const grounded = this.groundingOn()
|
|
1112
|
+
let mem: MemoryResult = grounded ? { notes: [] } : await this.deps.memory(window, before, abort.signal).catch((): MemoryResult => ({ notes: [], denseError: 'memory_threw' }))
|
|
1113
|
+
if (!grounded) Object.assign(trace, { memoryMs: Date.now() - t1, ...(mem.denseError ? { memoryError: mem.denseError } : {}), ...(mem.indexAgeH !== undefined ? { indexAgeH: mem.indexAgeH } : {}) })
|
|
858
1114
|
let source: LiveCuesV2MemorySource = 'cue_memory'
|
|
1115
|
+
// Grounded: the arms and the judge run here instead of the classic steps below, which then find nothing to do.
|
|
1116
|
+
let run: GroundedRun | null = null
|
|
1117
|
+
if (grounded) {
|
|
1118
|
+
run = await this.groundedRetrieve(s, window, before, abort.signal, provider, trace)
|
|
1119
|
+
mem = { notes: run.candidates }
|
|
1120
|
+
source = run.source
|
|
1121
|
+
}
|
|
859
1122
|
if (cueMemoryFailed(mem) && !abort.signal.aborted) {
|
|
860
1123
|
// cue_memory.py did not answer: search the owner's own COS (no model call), inside its own 3 s budget.
|
|
861
1124
|
const tn = Date.now()
|
|
@@ -865,14 +1128,15 @@ export class LiveCuesV2Engine {
|
|
|
865
1128
|
source = 'notes'
|
|
866
1129
|
}
|
|
867
1130
|
Object.assign(trace, { candidates: mem.notes.length, cueMemorySource: mem.notes.length ? source : 'none' })
|
|
868
|
-
let kept: MemoryNote[] = []
|
|
869
|
-
if (mem.notes.length && !abort.signal.aborted) {
|
|
1131
|
+
let kept: MemoryNote[] = run?.kept ?? []
|
|
1132
|
+
if (!grounded && mem.notes.length && !abort.signal.aborted) {
|
|
870
1133
|
const t2 = Date.now()
|
|
871
1134
|
const judged = await this.deps.judge(s.title, window, mem.notes, abort.signal, provider)
|
|
872
1135
|
Object.assign(trace, { judgeMs: Date.now() - t2, judgeTokens: judged.tokens })
|
|
873
1136
|
kept = mem.notes.filter(m => (judged.probs[m.id] ?? 0) >= liveCuesV2JudgeKeep(this.deps.env)).slice(0, L.judgeMaxNotes).map((m, i) => ({ ...m, id: `M${i + 1}` }))
|
|
874
1137
|
}
|
|
875
1138
|
trace.kept = kept.length
|
|
1139
|
+
if (run) trace.keptIds = kept.map(m => m.id) // grounded ids keep their kind (M1, N1, F1)
|
|
876
1140
|
if (abort.signal.aborted) { trace.outcome = 'cancelled'; return }
|
|
877
1141
|
|
|
878
1142
|
// 6.79.2: limits lowered while this evaluation ran apply before the writer is charged.
|
|
@@ -880,12 +1144,23 @@ export class LiveCuesV2Engine {
|
|
|
880
1144
|
if (counters.cues >= lim.maxCues || counters.writerCalls >= lim.writerCap) { trace.outcome = 'meeting_cap'; return }
|
|
881
1145
|
if (this.deps.store.dailyWriterCalls() >= lim.dailyWriterCalls) { trace.outcome = 'daily_cap'; s.stopReason = 'budget_exhausted'; return }
|
|
882
1146
|
if (!this.deps.store.charge(liveCuesV2WeeklyCap(this.deps.env), s.sessionId)) { trace.outcome = 'budget_exhausted'; s.stopReason = 'budget_exhausted'; return }
|
|
883
|
-
const { version, system } = liveCuesV2WriterSystem(this.deps.env, s.kinds)
|
|
1147
|
+
const { version, system: baseSystem } = liveCuesV2WriterSystem(this.deps.env, s.kinds)
|
|
1148
|
+
// The Tableau scope rule rides on the rules only when a Tableau note was kept; every other cue sees the pinned text unchanged.
|
|
1149
|
+
const system = kept.some(m => (m as GroundedNote).arm === 'data') ? baseSystem + DATA_WRITER_ADDENDUM : baseSystem
|
|
884
1150
|
if (s.kinds === 'extended') trace.kindSet = 'extended'
|
|
885
1151
|
const model = liveCuesV2Model(this.deps.env)
|
|
886
|
-
const
|
|
1152
|
+
const history = this.cueHistory(s)
|
|
1153
|
+
const previous = history.slice(-3).map(c => c.detail)
|
|
887
1154
|
const promptMemory = kept.map(m => ({ id: m.id, date: m.date, title: m.title, notes: m.notes })) // the replay's exact keys
|
|
888
|
-
|
|
1155
|
+
// 6.97.8: the known spellings ride in the prompt data, before the window, only when there are some (and the rule is on).
|
|
1156
|
+
let names: string[] = []
|
|
1157
|
+
if (liveCuesV2Rule('KNOWN_NAMES', this.deps.env) && this.deps.knownNames) {
|
|
1158
|
+
try { names = await this.deps.knownNames([...s.speakers]) } catch { names = []; trace.knownNamesError = true }
|
|
1159
|
+
if (abort.signal.aborted) { trace.outcome = 'cancelled'; return }
|
|
1160
|
+
if (names.length) trace.knownNames = names.length
|
|
1161
|
+
}
|
|
1162
|
+
const prompt = pyJson({ meetingTitle: run?.writerTitle ?? s.title, liveIssue: '', suggestedKind: gate.kind, previousCues: previous, memory: promptMemory,
|
|
1163
|
+
...(names.length ? { knownNames: { use: KNOWN_NAMES_USE, names } } : {}), window })
|
|
889
1164
|
const t3 = Date.now()
|
|
890
1165
|
const out = await this.deps.writer(model, system, prompt, abort.signal)
|
|
891
1166
|
Object.assign(trace, { writerMs: Date.now() - t3, prompt: version, model: out.model ?? model, writerUsd: out.costUsd, writerIn: out.inputTokens, writerOut: out.outputTokens })
|
|
@@ -901,32 +1176,52 @@ export class LiveCuesV2Engine {
|
|
|
901
1176
|
if (this.deps.store.isEnded(s.sessionId) || this.deps.store.isPaused(s.sessionId) || !this.enabled()) { trace.outcome = 'cancelled'; return }
|
|
902
1177
|
|
|
903
1178
|
const sources: CueSourceRef[] = parsed.memoryIds.map(id => kept.find(m => m.id === id)).filter((m): m is MemoryNote => !!m)
|
|
904
|
-
.map(m => ({ id: sourceId(m), date: m.date, title: m.title }))
|
|
905
|
-
|
|
1179
|
+
.map(m => (run ? groundedSource(m as GroundedNote, sourceId(m)) : { id: sourceId(m), date: m.date, title: m.title }))
|
|
1180
|
+
let cue: LiveCueV2 = { protocol: 2, cueId: randomUUID(), sessionId: s.sessionId, headline: cueHeadline(parsed.detail), detail: parsed.detail,
|
|
906
1181
|
kind: parsed.kind, sources, gate: round(pCue), createdAt: this.deps.now().getTime(), model: out.model ?? model }
|
|
907
1182
|
if (!isLiveCueV2(cue)) { trace.outcome = 'withheld_invalid'; return } // never emit what the companion would drop
|
|
908
1183
|
// 6.79.0 repeat rule, off unless COS_LIVE_CUES_V2_REPEAT_OVERLAP is set: a cue that repeats an earlier SHOWN cue of
|
|
909
1184
|
// this meeting is not logged, emitted or counted (the writer call already counted). Ids and the overlap only.
|
|
910
1185
|
const repeatT = liveCuesV2RepeatOverlap(this.deps.env)
|
|
911
|
-
const repeat = repeatT === null ? null : repeatOf(cue.detail,
|
|
1186
|
+
const repeat = repeatT === null ? null : repeatOf(cue.detail, history, repeatT)
|
|
912
1187
|
if (repeat) { Object.assign(trace, { outcome: 'withheld_repeat', repeatOf: repeat.blocker, repeatOverlap: repeat.overlap }); return }
|
|
1188
|
+
// 6.97.8 lead repeat, on unless COS_LIVE_CUES_V2_LEAD_REPEAT=0: a cue that opens by restating a recent shown cue's opening
|
|
1189
|
+
// line (the whole-cue rule misses it when the evidence after the opener differs). Same outcome; repeatRule says which.
|
|
1190
|
+
const lead = liveCuesV2Rule('LEAD_REPEAT', this.deps.env) ? leadRepeatOf(cue.detail, history) : null
|
|
1191
|
+
if (lead) { Object.assign(trace, { outcome: 'withheld_repeat', repeatRule: 'lead', repeatOf: lead.blocker, repeatOverlap: lead.overlap }); return }
|
|
1192
|
+
// 6.97.8 open items, on unless COS_LIVE_CUES_V2_OPEN_ITEMS=0, extended meetings only (writer v4 is the one that returns `open`): a cue the
|
|
1193
|
+
// writer marked as a missing owner or date (and whose wording agrees) is held, not shown. It is logged below with `held`, so history
|
|
1194
|
+
// never lists or counts it, stays in the writer's history, and comes back through showHeld(). The writer call counted; the cooldown
|
|
1195
|
+
// applies; the cue does not count toward the cap.
|
|
1196
|
+
const item = macRecording(s) && s.kinds === 'extended' && liveCuesV2Rule('OPEN_ITEMS', this.deps.env)
|
|
1197
|
+
? openItemOf({ kind: parsed.kind, detail: parsed.detail, cited: sources.length, field: parsed.open }) : null
|
|
913
1198
|
// 6.79.0: logged before it is held or emitted, so a rating that arrives at once finds it. Its own try/catch: a log
|
|
914
1199
|
// failure is traced on this evaluation and is never a writer failure or a step toward the breaker.
|
|
915
1200
|
try {
|
|
916
1201
|
this.deps.cueLog?.({ v: 1, cueId: cue.cueId, sessionId: s.sessionId, meetingTitle: s.title, createdAt: cue.createdAt, armedAt: s.armedAt, kind: cue.kind,
|
|
917
1202
|
headline: cue.headline, detail: cue.detail, gate: cue.gate, sources: cue.sources, model: cue.model, prompt: version,
|
|
918
|
-
memorySource: mem.notes.length ? source : 'none', kept: kept.length, wouldWithhold: wouldWithhold(cue.detail, s.cues), before: cueBefore(said)
|
|
1203
|
+
memorySource: mem.notes.length ? source : 'none', kept: kept.length, wouldWithhold: wouldWithhold(cue.detail, s.cues), before: cueBefore(said),
|
|
1204
|
+
// 'data' stays out of `arms`: an older server's isLogRow rejects an arm it does not know, and the Tableau source is already in `sources`.
|
|
1205
|
+
// `dataNote` says a Tableau note was kept for this cue, so a cue grounded only in it does not read as ungrounded with arms [].
|
|
1206
|
+
...(run ? { arms: run.arms.filter((a): a is Exclude<typeof a, 'data'> => a !== 'data') } : {}),
|
|
1207
|
+
...(kept.some(m => (m as GroundedNote).arm === 'data') ? { dataNote: true as const } : {}), ...(item ? { held: true as const } : {}) })
|
|
919
1208
|
} catch (error) {
|
|
920
1209
|
trace.cueLogError = (error as NodeJS.ErrnoException)?.code ?? 'append_failed'
|
|
921
1210
|
console.warn(`[live-cues-v2] cue log append failed: ${errMsg(error)}`)
|
|
922
1211
|
}
|
|
1212
|
+
if (item) {
|
|
1213
|
+
s.fold.hold(item.label, cue, cue.createdAt, cueBefore(said))
|
|
1214
|
+
s.lastCueAt = cue.createdAt
|
|
1215
|
+
Object.assign(trace, { outcome: 'held_open_item', openWaiting: s.fold.waiting() })
|
|
1216
|
+
return
|
|
1217
|
+
}
|
|
923
1218
|
s.cues.push(cue)
|
|
924
1219
|
s.lastCueAt = cue.createdAt
|
|
925
1220
|
this.deps.store.recordCue(s.sessionId)
|
|
926
1221
|
this.deps.emit('coaching_nudge_v2', cue as unknown as Record<string, unknown>)
|
|
927
|
-
const v1: LiveCueV1Compat = { nudge: cue.headline, type: legacyCueKind(cue.kind), priority: 2, cueId: cue.cueId, sessionId: s.sessionId, protocol: 2 }
|
|
1222
|
+
const v1: LiveCueV1Compat = { nudge: cue.headline, type: legacyCueKind(cue.kind as CueKind), priority: 2, cueId: cue.cueId, sessionId: s.sessionId, protocol: 2 }
|
|
928
1223
|
this.deps.emit('coaching_nudge', v1 as unknown as Record<string, unknown>)
|
|
929
|
-
Object.assign(trace, { outcome: 'cue', cueId: cue.cueId, cueChars: cue.detail.length, sources: sources.length, ...(parsed.numberFlags.length ? { numberFlags: parsed.numberFlags.length } : {}) })
|
|
1224
|
+
Object.assign(trace, { outcome: 'cue', cueId: cue.cueId, cueChars: cue.detail.length, sources: cue.sources.length, ...(parsed.numberFlags.length ? { numberFlags: parsed.numberFlags.length } : {}) })
|
|
930
1225
|
} catch (error) {
|
|
931
1226
|
if (wallHit) { trace.outcome = 'failure'; trace.error = 'wall_timeout'; s.failures += 1; if (s.failures >= L.breakerFailures) s.breaker = true; return }
|
|
932
1227
|
if (isCancel(error, abort.signal)) { trace.outcome = 'cancelled'; return }
|
|
@@ -955,12 +1250,97 @@ export class LiveCuesV2Engine {
|
|
|
955
1250
|
}
|
|
956
1251
|
}
|
|
957
1252
|
|
|
1253
|
+
/** Live cues v3, `grounding: grounded`: the retrieval arms in parallel (cue_memory.py within its 6 s, the owner's notes within
|
|
1254
|
+
* their own 3 s), the files attached to this meeting, then ONE judge request over the candidates that survive the exclusions
|
|
1255
|
+
* (live-cues-v2-grounded.ts). Never throws for a file, orientation or arm failure: those are traced as codes and the meeting
|
|
1256
|
+
* carries on with what it has. The trace gets ids, kinds, probabilities and timings, never a candidate's or the room's text. */
|
|
1257
|
+
private async groundedRetrieve(s: Session, window: string, before: string, signal: AbortSignal, provider: DecisionProvider, trace: Record<string, unknown>): Promise<GroundedRun> {
|
|
1258
|
+
trace.grounding = 'grounded'
|
|
1259
|
+
const codeOf = (e: unknown) => { const r = e as { reason?: unknown; code?: unknown } | null; return typeof r?.reason === 'string' ? r.reason : typeof r?.code === 'string' ? r.code : 'threw' }
|
|
1260
|
+
let meetingDomain: string | null = null, calendarTitle: string | null = null, orientationFailed = false
|
|
1261
|
+
try {
|
|
1262
|
+
const o = this.deps.orientation?.(s.sessionId)
|
|
1263
|
+
meetingDomain = typeof o?.domain === 'string' ? o.domain : null
|
|
1264
|
+
calendarTitle = typeof o?.title === 'string' ? o.title : null
|
|
1265
|
+
} catch (e) { trace.orientationError = codeOf(e); orientationFailed = true }
|
|
1266
|
+
let files: GroundedFile[] = []
|
|
1267
|
+
try { files = usableFiles(this.deps.files?.(s.sessionId)) } catch (e) { trace.filesError = codeOf(e) }
|
|
1268
|
+
|
|
1269
|
+
const timed = async (arm: () => Promise<MemoryResult>, threw: string): Promise<{ res: MemoryResult; ms: number | null }> => {
|
|
1270
|
+
const t = Date.now()
|
|
1271
|
+
try { return { res: await arm(), ms: Date.now() - t } } catch { return { res: { notes: [], denseError: threw }, ms: null } }
|
|
1272
|
+
}
|
|
1273
|
+
const [cm, nt] = await Promise.all([
|
|
1274
|
+
timed(() => this.deps.memory(window, before, signal, GROUNDED_LIMITS.cueMemoryRaw), 'memory_threw'),
|
|
1275
|
+
timed(() => this.deps.notes(window, before, signal, s.sessionId), 'notes_threw'),
|
|
1276
|
+
])
|
|
1277
|
+
Object.assign(trace, { ...(cm.ms !== null ? { memoryMs: cm.ms } : {}), ...(cm.res.denseError ? { memoryError: cm.res.denseError } : {}), ...(cm.res.indexAgeH !== undefined ? { indexAgeH: cm.res.indexAgeH } : {}),
|
|
1278
|
+
...(nt.ms !== null ? { notesMs: nt.ms } : {}), ...(nt.res.denseError ? { notesError: nt.res.denseError } : {}) })
|
|
1279
|
+
|
|
1280
|
+
// What the room has said since arm: every chunk that came while grounding was on, plus the window now (a meeting switched to
|
|
1281
|
+
// grounded part way through has only the window to start from).
|
|
1282
|
+
s.said.add(cleanWindow(s.chunks.join('\n')))
|
|
1283
|
+
const twins = [...this.sessions.keys()].filter(id => id !== s.sessionId)
|
|
1284
|
+
// The Tableau data arm: a local file, no model call. Off (and traced why) when the file is missing, stale or invalid, when the
|
|
1285
|
+
// meeting is not Quilt's, or when the speech does not name a group beside a funnel word or figure. A meeting with no confirmed
|
|
1286
|
+
// domain needs a Quilt signal from this evaluation: the candidates the other arms just returned (after the self and twin
|
|
1287
|
+
// exclusions) and the titles are the two places one can come from, so the others are built first.
|
|
1288
|
+
const today = this.deps.now().toLocaleDateString('en-CA')
|
|
1289
|
+
const common = { cueMemory: cm.res.notes, notes: nt.res.notes, files, transcript: s.said.shingles, window, selfIds: selfIdSet(s.sessionId, twins), meetingDomain, today }
|
|
1290
|
+
let grounded = groundedCandidates(common)
|
|
1291
|
+
const data = this.dataArm(window, { domain: meetingDomain, orientationFailed, titles: [s.title, calendarTitle],
|
|
1292
|
+
memoryQuilt: grounded.candidates.some(c => (c.arm === 'cue_memory' || c.arm === 'notes') && isQuiltDomain(c.domain)) }, today, trace)
|
|
1293
|
+
if (data) grounded = groundedCandidates({ ...common, data })
|
|
1294
|
+
const { candidates, excludedSelf, domainUnknown } = grounded
|
|
1295
|
+
Object.assign(trace, { arms: { cue_memory: cm.ms, notes: nt.ms, files: files.filter(f => f.status === 'ready').length, data: candidates.some(c => c.arm === 'data') ? 1 : 0 }, excludedSelf, domainUnknown })
|
|
1296
|
+
|
|
1297
|
+
const source: LiveCuesV2MemorySource = candidates.some(c => c.arm === 'cue_memory') ? 'cue_memory' : candidates.some(c => c.arm === 'notes') ? 'notes' : 'none'
|
|
1298
|
+
const arms = (['cue_memory', 'notes', 'files', 'data'] as const).filter(a => candidates.some(c => c.arm === a))
|
|
1299
|
+
const title = (calendarTitle ?? '').replace(/\s+/g, ' ').trim().slice(0, 120)
|
|
1300
|
+
const writerTitle = title || s.title
|
|
1301
|
+
let kept: GroundedNote[] = []
|
|
1302
|
+
if (candidates.length && !signal.aborted) {
|
|
1303
|
+
if (!this.deps.judgeB) throw new Error('judge_b_unavailable')
|
|
1304
|
+
const t2 = Date.now()
|
|
1305
|
+
const judged = await this.deps.judgeB(s.title, window, candidates, signal, provider)
|
|
1306
|
+
Object.assign(trace, { judgeMs: Date.now() - t2, judgeTokens: judged.tokens,
|
|
1307
|
+
judge: candidates.map(c => ({ id: c.id, kind: c.kind, yes: round(judged.yes[c.id] ?? 0), same: round(judged.same[c.id] ?? 0) })) })
|
|
1308
|
+
kept = selectJudgeB(candidates, judged.yes, liveCuesV2JudgeYes(this.deps.env), LIVE_CUES_V2_LIMITS.judgeMaxNotes)
|
|
1309
|
+
}
|
|
1310
|
+
return { candidates, kept, source, writerTitle, arms }
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/** The Tableau candidate for this window, or null. `trace.dataArm` says what happened in one code: offered, no_group, no_funnel,
|
|
1314
|
+
* no_stage, domain, or the file's own reason (missing, unreadable, invalid, stale, future). Never throws. */
|
|
1315
|
+
private dataArm(window: string, ctx: { domain: string | null; orientationFailed: boolean; titles: Array<string | null>; memoryQuilt: boolean }, today: string, trace: Record<string, unknown>): GroundedNote | null {
|
|
1316
|
+
if (!this.deps.dataFacts) return null
|
|
1317
|
+
try {
|
|
1318
|
+
const read = this.deps.dataFacts()
|
|
1319
|
+
if (!read.ok) { trace.dataArm = read.reason; return null }
|
|
1320
|
+
trace.dataAgeH = Math.round((this.deps.now().getTime() - read.facts.asOf) / 360_000) / 10
|
|
1321
|
+
// Cheap exits first: another confirmed domain or an unreadable orientation never reads the speech.
|
|
1322
|
+
const early = dataArmGate({ domain: ctx.domain, orientationFailed: ctx.orientationFailed, brand: true })
|
|
1323
|
+
if (!early.ok) { trace.dataArm = early.why; return null }
|
|
1324
|
+
const found = dataCandidate(read.facts, window, today)
|
|
1325
|
+
if ('none' in found) { trace.dataArm = found.none; return null }
|
|
1326
|
+
const gate = dataArmGate({ domain: ctx.domain, orientationFailed: ctx.orientationFailed, brand: found.brand, memoryQuilt: ctx.memoryQuilt, titleQuilt: quiltTitleSignal(read.facts, ctx.titles) })
|
|
1327
|
+
if (!gate.ok) { trace.dataArm = gate.why; return null }
|
|
1328
|
+
trace.dataArm = 'offered'
|
|
1329
|
+
trace.dataSignal = gate.signal
|
|
1330
|
+
return found.candidate
|
|
1331
|
+
} catch { trace.dataArm = 'threw'; return null }
|
|
1332
|
+
}
|
|
1333
|
+
|
|
958
1334
|
private reap(): void {
|
|
959
1335
|
const nowMs = this.deps.now().getTime()
|
|
960
1336
|
for (const [id, s] of this.sessions) if (!s.inFlight && nowMs - s.lastSeenAt > LIVE_CUES_V2_LIMITS.sessionTtlMs) this.sessions.delete(id)
|
|
961
1337
|
}
|
|
962
1338
|
|
|
963
|
-
shutdown(): void {
|
|
1339
|
+
shutdown(): void {
|
|
1340
|
+
for (const s of this.sessions.values()) s.inFlight?.abort()
|
|
1341
|
+
this.sessions.clear()
|
|
1342
|
+
if (this.general) { this.general.stop(); setGeneralCuesTodayProvider(null) }
|
|
1343
|
+
}
|
|
964
1344
|
|
|
965
1345
|
/** v2 switched off at runtime (6.76.2): abort every evaluation in flight, so no half-written cue is emitted and every
|
|
966
1346
|
* lease is released. Sessions are kept (no new evaluation starts while off), so switching back on resumes a running
|
|
@@ -968,9 +1348,105 @@ export class LiveCuesV2Engine {
|
|
|
968
1348
|
suspend(): number {
|
|
969
1349
|
let aborted = 0
|
|
970
1350
|
for (const s of this.sessions.values()) if (s.inFlight) { s.inFlight.abort(V2_OFF); aborted += 1 }
|
|
1351
|
+
if (this.general?.abortAll(V2_OFF)) aborted += 1
|
|
971
1352
|
return aborted
|
|
972
1353
|
}
|
|
973
1354
|
|
|
1355
|
+
// ── General cues (live cues v3) ──
|
|
1356
|
+
/** Starts the general runner's poll timer and registers its counters for the settings view. Called once, where the engine
|
|
1357
|
+
* singleton is built; an engine made by a test never polls on its own. */
|
|
1358
|
+
startGeneral(): void {
|
|
1359
|
+
if (!this.general) return
|
|
1360
|
+
setGeneralCuesTodayProvider(() => this.general!.generalCuesToday())
|
|
1361
|
+
this.general.start()
|
|
1362
|
+
}
|
|
1363
|
+
/** The general runner, if this engine has one (tests drive its ticks directly). */
|
|
1364
|
+
generalRunner(): GeneralCueRunner | null { return this.general }
|
|
1365
|
+
/** The cue mode was saved: leaving general mode stops an insight in flight at once. */
|
|
1366
|
+
modeChanged(): void { this.general?.modeChanged() }
|
|
1367
|
+
|
|
1368
|
+
private cueMode(): LiveCuesCueMode { return resolveLiveCuesCueMode(this.deps.env, this.deps.settings).value }
|
|
1369
|
+
/** Ask Public topics consent, read the way the general runner reads it: unreadable, absent and off are all off. */
|
|
1370
|
+
private publicTopicsOn(): boolean { try { return this.deps.publicTopics?.() === true } catch { return false } }
|
|
1371
|
+
/** Cue mode `general` turns the focused cues off for a Mac recording, but only while the insights that replace them can
|
|
1372
|
+
* run: with Public topics off the general runner stops at public_topics_off, so switching the focused cues off as well would
|
|
1373
|
+
* leave the meeting with nothing, and Control cannot turn the consent on. Then the focused cues stay on and the status says
|
|
1374
|
+
* needs_public_topics. A G2 meeting keeps them too, whatever kinds it was armed with (6.97.9): it can never carry an insight. */
|
|
1375
|
+
private focusedOff(s: Session): boolean { return macRecording(s) && this.cueMode() === 'general' && this.publicTopicsOn() }
|
|
1376
|
+
private allCues(s: Session): LiveCueV2[] { return s.insights.length ? [...s.cues, ...s.insights].sort((a, b) => a.createdAt - b.createdAt) : [...s.cues] }
|
|
1377
|
+
|
|
1378
|
+
/** Remembers who the ASR path says is speaking, for the general runner's name gate. Bounded; generic labels ("Ext", "Speaker 2")
|
|
1379
|
+
* are not names. Never read by the focused path. */
|
|
1380
|
+
private noteSpeakers(s: Session, speakers: readonly string[] | undefined): void {
|
|
1381
|
+
for (const raw of speakers ?? []) {
|
|
1382
|
+
const name = typeof raw === 'string' ? raw.replace(/\s+/g, ' ').trim() : ''
|
|
1383
|
+
if (!name || name.length > 60 || isGenericSpeakerLabel(name) || s.speakers.size >= SPEAKER_NAMES_MAX) continue
|
|
1384
|
+
s.speakers.add(name)
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/** The meetings the general runner may look at: extended ones only, while v2 is on. The domain is the CONFIRMED Ask orientation
|
|
1389
|
+
* domain or null, never a recording sidecar's default (all saved sidecars say personal). */
|
|
1390
|
+
private generalHost(): GeneralCueHost {
|
|
1391
|
+
return {
|
|
1392
|
+
candidates: () => {
|
|
1393
|
+
if (!this.enabled()) return []
|
|
1394
|
+
const out: GeneralCueCandidate[] = []
|
|
1395
|
+
for (const s of this.sessions.values()) {
|
|
1396
|
+
if (!macRecording(s) || s.kinds !== 'extended') continue // a G2 meeting armed with the five kinds is still never offered to the general runner
|
|
1397
|
+
// A throw is not "unconfirmed": an orientation that cannot be read may belong to a personal meeting, so it is withheld.
|
|
1398
|
+
let domain: string | null = null, calendarTitle: string | null = null, domainUnreadable = false
|
|
1399
|
+
try { const o = this.deps.orientation?.(s.sessionId); domain = o?.domain ?? null; calendarTitle = o?.title ?? null } catch { domainUnreadable = true }
|
|
1400
|
+
// The orientation carries the calendar event's title, not its attendees: its capitalized words stand in for them.
|
|
1401
|
+
const titleNames = (calendarTitle ?? '').match(/\p{Lu}[\p{L}'’-]{2,}/gu) ?? []
|
|
1402
|
+
out.push({ sessionId: s.sessionId, kindSet: s.kinds, armed: !this.deps.store.isEnded(s.sessionId), paused: this.deps.store.isPaused(s.sessionId), domain,
|
|
1403
|
+
...(domainUnreadable ? { domainUnreadable } : {}), title: s.title, transcriptChars: s.charsFed, speakerNames: [...s.speakers, ...titleNames],
|
|
1404
|
+
window: chars => cleanWindow(s.chunks.join('\n')).slice(-chars) })
|
|
1405
|
+
}
|
|
1406
|
+
return out
|
|
1407
|
+
},
|
|
1408
|
+
emit: (sessionId, insight) => this.emitInsight(sessionId, insight),
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
/** The general limits, the mode and the consent as they stand now (read at every tick, so a saved change applies at once). */
|
|
1412
|
+
private generalSettings(): GeneralCueSettingsReader {
|
|
1413
|
+
return {
|
|
1414
|
+
cueMode: () => this.cueMode(),
|
|
1415
|
+
limits: () => {
|
|
1416
|
+
const r = resolveLiveCuesGeneralLimits(this.deps.env, this.deps.settings)
|
|
1417
|
+
return { intervalSeconds: r.intervalSeconds.value, trigger: r.trigger.value, attemptsPerMeeting: r.attemptsPerMeeting.value, maxCuesPerMeeting: r.maxCuesPerMeeting.value,
|
|
1418
|
+
dailyTopicCalls: r.dailyTopicCalls.value, dailyWebSearches: r.dailyWebSearches.value }
|
|
1419
|
+
},
|
|
1420
|
+
publicTopics: () => this.publicTopicsOn(),
|
|
1421
|
+
homeArea: () => { try { return this.deps.homeArea?.() ?? null } catch { return null } },
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
/** One insight: logged, held in the meeting's own list, and emitted as `coaching_nudge_v2` ONLY (no v1 twin, so a v1 reader
|
|
1425
|
+
* never sees it). Extended meetings only. Nothing here touches the focused counters, cooldown, lastCueAt, inFlight, failures
|
|
1426
|
+
* or breaker. False when the meeting is gone, ended or not extended, or the cue would not pass isLiveCueV2. */
|
|
1427
|
+
private emitInsight(sessionId: string, insight: GeneralInsight): boolean {
|
|
1428
|
+
const s = this.sessions.get(sessionId)
|
|
1429
|
+
if (!s || !macRecording(s) || s.kinds !== 'extended' || this.deps.store.isEnded(sessionId)) return false
|
|
1430
|
+
// Every source goes through the same public https check as the cue log's: one that fails drops the whole insight.
|
|
1431
|
+
const urls = insight.sources.map(src => publicHttpsUrl(src.url))
|
|
1432
|
+
if (!insight.sources.length || urls.some(u => u === null)) return false
|
|
1433
|
+
const sources = insight.sources.map((src, i) => ({ ...src, url: urls[i]! }))
|
|
1434
|
+
const cue: LiveCueV2 = { protocol: 2, cueId: randomUUID(), sessionId, headline: insight.headline, detail: insight.detail, kind: 'insight', sources,
|
|
1435
|
+
gate: round(insight.gate), createdAt: this.deps.now().getTime(), model: insight.model ?? liveCuesV2Model(this.deps.env) }
|
|
1436
|
+
if (!isLiveCueV2(cue)) return false // never emit what the companion would drop
|
|
1437
|
+
const log = this.deps.cueLog
|
|
1438
|
+
try {
|
|
1439
|
+
log?.({ v: 1, cueId: cue.cueId, sessionId, meetingTitle: s.title, createdAt: cue.createdAt, kind: cue.kind, headline: cue.headline, detail: cue.detail, gate: cue.gate,
|
|
1440
|
+
sources: cue.sources, model: cue.model, before: cueBefore(s.chunks.join('\n').replace(/^\[\d+\]\s*/gm, '')), general: { category: insight.category, query: insight.query },
|
|
1441
|
+
armedAt: s.armedAt })
|
|
1442
|
+
} catch (error) {
|
|
1443
|
+
console.warn(`[live-cues-v2] insight log append failed: ${errMsg(error)}`)
|
|
1444
|
+
}
|
|
1445
|
+
s.insights.push(cue)
|
|
1446
|
+
this.deps.emit('coaching_nudge_v2', cue as unknown as Record<string, unknown>)
|
|
1447
|
+
return true
|
|
1448
|
+
}
|
|
1449
|
+
|
|
974
1450
|
/** Was this meeting armed under v2 in this process? Its stop and status stay v2 even after v2 is switched off. */
|
|
975
1451
|
knows(sessionId: string): boolean { return this.sessions.has(sessionId) }
|
|
976
1452
|
}
|
|
@@ -983,6 +1459,7 @@ let unsubscribe: (() => void) | null = null
|
|
|
983
1459
|
export function liveCuesV2(): LiveCuesV2Engine {
|
|
984
1460
|
if (!engine) {
|
|
985
1461
|
engine = new LiveCuesV2Engine(realDeps())
|
|
1462
|
+
engine.startGeneral()
|
|
986
1463
|
// A saved meeting ends its cues even when the phone never sent Stop (orphan recovery, Control save).
|
|
987
1464
|
unsubscribe = onDisplay(event => {
|
|
988
1465
|
const sid = event.type === 'recording_stop' ? event.data?.sessionId : null
|
|
@@ -1043,16 +1520,59 @@ export function liveCuesV2SettingsViewV2(env: NodeJS.ProcessEnv = process.env):
|
|
|
1043
1520
|
return { ...liveCuesV2SettingsView(env), v: 2, limits, writerCallsPerMeeting: 2 * limits.maxCuesPerMeeting.value, today: liveCuesV2().today() }
|
|
1044
1521
|
}
|
|
1045
1522
|
|
|
1523
|
+
/** GET/POST /api/live-cues/v2/settings with v=3 (live cues v3): the v2 body (with `v: 3`) plus the cue mode, grounded cues and the
|
|
1524
|
+
* general-cue limits. An older server answers ?v=3 with 400 unsupported_version, which is Control's signal to hide these. */
|
|
1525
|
+
export interface GeneralCuesToday { day: string; topicCalls: number; webSearches: number; breakerOpenUntil: number | null }
|
|
1526
|
+
export interface LiveCuesV2SettingsResponseV3 extends Omit<LiveCuesV2SettingsResponseV2, 'v'> {
|
|
1527
|
+
v: 3
|
|
1528
|
+
cueMode: LiveCuesV2Mode<LiveCuesCueMode>
|
|
1529
|
+
grounding: LiveCuesV2Mode<LiveCuesGrounding>
|
|
1530
|
+
general: { limits: LiveCuesGeneralLimits; today: GeneralCuesToday; publicTopics: boolean; homeArea: string | null }
|
|
1531
|
+
/** Additive (6.97.8): every public web lookup today by feature (cues, meeting assistant, fact check) and the ones held back. `general.today` keeps its meaning (the general cues only). */
|
|
1532
|
+
webToday: WebTodayView
|
|
1533
|
+
}
|
|
1534
|
+
/** Today's general-cue counters and breaker come from the general runner, which registers itself here. Until it does (and
|
|
1535
|
+
* whenever general cues have never run) they read as zero. */
|
|
1536
|
+
let generalTodayProvider: (() => GeneralCuesToday) | null = null
|
|
1537
|
+
export function setGeneralCuesTodayProvider(provider: (() => GeneralCuesToday) | null): void { generalTodayProvider = provider }
|
|
1538
|
+
export function liveCuesV2SettingsViewV3(env: NodeJS.ProcessEnv = process.env): LiveCuesV2SettingsResponseV3 {
|
|
1539
|
+
const v2 = liveCuesV2SettingsViewV2(env)
|
|
1540
|
+
const settings = liveCuesV2Settings()
|
|
1541
|
+
let today: GeneralCuesToday
|
|
1542
|
+
try { today = generalTodayProvider ? generalTodayProvider() : { day: v2.today.day, topicCalls: 0, webSearches: 0, breakerOpenUntil: null } } catch { today = { day: v2.today.day, topicCalls: 0, webSearches: 0, breakerOpenUntil: null } }
|
|
1543
|
+
let publicTopics = false
|
|
1544
|
+
try { publicTopics = new MeetingAskStore().publicTopics() } catch { publicTopics = false }
|
|
1545
|
+
let homeArea: string | null = null
|
|
1546
|
+
try { homeArea = getOwnerHomeArea() } catch { homeArea = null }
|
|
1547
|
+
let webToday: WebTodayView
|
|
1548
|
+
try { webToday = webLedger().today() } catch { webToday = { day: v2.today.day, readable: false, cues: { searches: 0, pagesRead: 0, pagesFailed: 0 }, ask: { searches: 0, pagesRead: 0, pagesFailed: 0 }, factCheck: { searches: 0, pagesRead: 0, pagesFailed: 0 }, withheld: { total: 0, byReason: {}, byFeature: { cues: 0, ask: 0, factCheck: 0 } } } }
|
|
1549
|
+
return { ...v2, v: 3, cueMode: resolveLiveCuesCueMode(env, settings), grounding: resolveLiveCuesGrounding(env, settings),
|
|
1550
|
+
general: { limits: resolveLiveCuesGeneralLimits(env, settings), today, publicTopics, homeArea }, webToday }
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** What a v3 POST may change beyond the v2 patch. */
|
|
1554
|
+
export interface LiveCuesV3Patch { cueMode?: LiveCuesCueMode; grounding?: LiveCuesGrounding; general?: LiveCuesGeneralStoredLimits }
|
|
1555
|
+
|
|
1046
1556
|
/** Save the switch and any limits in ONE write, after every check passed: nothing is saved when any part is refused.
|
|
1047
1557
|
* Turning v2 off stops evaluations in flight; limits apply from each meeting's next evaluation. */
|
|
1048
|
-
export function applyLiveCuesV2Settings(patch: { enabled?: boolean; limits: LiveCuesV2StoredLimits }, env: NodeJS.ProcessEnv = process.env):
|
|
1049
|
-
{ ok: true } | { ok: false; error: 'env_forced' } | { ok: false; error: 'env_locked'; fields:
|
|
1558
|
+
export function applyLiveCuesV2Settings(patch: { enabled?: boolean; limits: LiveCuesV2StoredLimits } & LiveCuesV3Patch, env: NodeJS.ProcessEnv = process.env):
|
|
1559
|
+
{ ok: true } | { ok: false; error: 'env_forced'; fields?: Array<'cueMode' | 'grounding'> } | { ok: false; error: 'env_locked'; fields: string[] } {
|
|
1050
1560
|
if (patch.enabled === false && liveCuesV2EnvForced(env)) return { ok: false, error: 'env_forced' }
|
|
1561
|
+
// Live cues v3: env may force only the SAFE value, so the only refused request is one for the other value.
|
|
1562
|
+
const forced: Array<'cueMode' | 'grounding'> = []
|
|
1563
|
+
if (patch.cueMode !== undefined && patch.cueMode !== 'focused' && resolveLiveCuesCueMode(env, liveCuesV2Settings()).locked) forced.push('cueMode')
|
|
1564
|
+
if (patch.grounding !== undefined && patch.grounding !== 'classic' && resolveLiveCuesGrounding(env, liveCuesV2Settings()).locked) forced.push('grounding')
|
|
1565
|
+
if (forced.length) return { ok: false, error: 'env_forced', fields: forced }
|
|
1051
1566
|
const current = resolveLiveCuesV2Limits(env, liveCuesV2Settings())
|
|
1052
|
-
const
|
|
1567
|
+
const generalNow = resolveLiveCuesGeneralLimits(env, liveCuesV2Settings())
|
|
1568
|
+
const locked: string[] = [
|
|
1569
|
+
...(Object.keys(patch.limits) as LiveCuesV2LimitName[]).filter(name => current[name].locked),
|
|
1570
|
+
...(Object.keys(patch.general ?? {}) as LiveCuesGeneralLimitName[]).filter(name => generalNow[name].locked).map(name => LIVE_CUES_GENERAL_LIMIT_BOUNDS[name].field),
|
|
1571
|
+
]
|
|
1053
1572
|
if (locked.length) return { ok: false, error: 'env_locked', fields: locked }
|
|
1054
1573
|
const wasOn = liveCuesV2Enabled(env)
|
|
1055
1574
|
liveCuesV2Settings().update(patch)
|
|
1575
|
+
if (patch.cueMode !== undefined) engine?.modeChanged() // leaving general mode stops an insight in flight at once
|
|
1056
1576
|
if (wasOn && !liveCuesV2Enabled(env)) {
|
|
1057
1577
|
const aborted = engine?.suspend() ?? 0
|
|
1058
1578
|
console.log(`[live-cues-v2] switched off by setting; ${aborted} evaluation(s) stopped`)
|
|
@@ -1061,6 +1581,9 @@ export function applyLiveCuesV2Settings(patch: { enabled?: boolean; limits: Live
|
|
|
1061
1581
|
}
|
|
1062
1582
|
const changed = Object.entries(patch.limits).map(([k, v]) => `${k}=${v}`).join(' ')
|
|
1063
1583
|
if (changed) console.log(`[live-cues-v2] limits saved: ${changed}`)
|
|
1584
|
+
const v3 = [patch.cueMode ? `cueMode=${patch.cueMode}` : '', patch.grounding ? `grounding=${patch.grounding}` : '',
|
|
1585
|
+
...Object.entries(patch.general ?? {}).map(([k, v]) => `general.${k}=${v}`)].filter(Boolean).join(' ')
|
|
1586
|
+
if (v3) console.log(`[live-cues-v2] settings saved: ${v3}`)
|
|
1064
1587
|
return { ok: true }
|
|
1065
1588
|
}
|
|
1066
1589
|
/** What /api/health, /api/models and the status route advertise: NOTHING while v2 is switched off, so a 6.10.642
|