@gotcos/glasses-server 6.95.0 → 6.98.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +4 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +12 -0
  6. package/server/lib/attached-provider-adapter.ts +35 -1
  7. package/server/lib/claude-bridge.ts +47 -0
  8. package/server/lib/codex-bridge.ts +11 -1
  9. package/server/lib/codex-rollout-watermark.ts +42 -0
  10. package/server/lib/cursor-bridge.ts +2 -1
  11. package/server/lib/decision-provider.ts +4 -2
  12. package/server/lib/dictation-clean-models.ts +2 -0
  13. package/server/lib/dictation-clean.ts +2 -2
  14. package/server/lib/dictation-polish-pipeline.ts +22 -2
  15. package/server/lib/dictation-prewarm.ts +16 -0
  16. package/server/lib/direct-bridge.ts +14 -2
  17. package/server/lib/direct-sse.ts +6 -1
  18. package/server/lib/display-bus.ts +1 -1
  19. package/server/lib/fork-thread.ts +32 -1
  20. package/server/lib/held-naming-batches.ts +44 -7
  21. package/server/lib/held-voice-groups.ts +5 -4
  22. package/server/lib/job-audit-caller.ts +20 -0
  23. package/server/lib/keyed-sessions.ts +21 -1
  24. package/server/lib/lens-gist-engines.ts +17 -5
  25. package/server/lib/live-cos-ask.ts +29 -6
  26. package/server/lib/live-cue-verdicts.ts +43 -8
  27. package/server/lib/live-cues-engine.ts +4 -1
  28. package/server/lib/live-cues-filter.ts +50 -0
  29. package/server/lib/live-cues-general-prompts.ts +137 -0
  30. package/server/lib/live-cues-general-topic.ts +245 -0
  31. package/server/lib/live-cues-general.ts +715 -0
  32. package/server/lib/live-cues-v2-data.ts +763 -0
  33. package/server/lib/live-cues-v2-grounded.ts +262 -0
  34. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  35. package/server/lib/live-cues-v2-names.ts +49 -0
  36. package/server/lib/live-cues-v2-notes.ts +8 -4
  37. package/server/lib/live-cues-v2-open-items.ts +132 -0
  38. package/server/lib/live-cues-v2-pacing.ts +45 -0
  39. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  40. package/server/lib/live-cues-v2-settings.ts +89 -10
  41. package/server/lib/live-cues-v2.ts +572 -47
  42. package/server/lib/mac-recording-asr.ts +6 -3
  43. package/server/lib/mac-speaker-call-human.ts +371 -0
  44. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  45. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  46. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  47. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  48. package/server/lib/mac-speaker-call-producer.ts +539 -0
  49. package/server/lib/mac-speaker-call-store.ts +264 -0
  50. package/server/lib/mac-speaker-call-view.ts +373 -0
  51. package/server/lib/mac-speaker-live-compute.ts +72 -0
  52. package/server/lib/mac-speaker-live-memory.ts +414 -0
  53. package/server/lib/mac-speaker-live-projection.ts +126 -0
  54. package/server/lib/mac-speaker-live-save.ts +90 -0
  55. package/server/lib/mac-speaker-live-store.ts +71 -0
  56. package/server/lib/mac-speaker-live-worker.ts +58 -0
  57. package/server/lib/mac-speaker-live.ts +175 -0
  58. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  59. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  60. package/server/lib/mac-voice-assignment.ts +46 -27
  61. package/server/lib/mac-voice-display.ts +195 -0
  62. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  63. package/server/lib/mac-voice-suggestions.ts +548 -0
  64. package/server/lib/maintenance-lifecycle.ts +2 -0
  65. package/server/lib/media-store.ts +2 -1
  66. package/server/lib/meeting-ask-pasted.ts +135 -0
  67. package/server/lib/meeting-ask-review.ts +8 -1
  68. package/server/lib/meeting-ask-runner.ts +4 -1
  69. package/server/lib/meeting-ask-runtime.ts +189 -17
  70. package/server/lib/meeting-ask-store.ts +2 -0
  71. package/server/lib/meeting-ask-web.ts +284 -13
  72. package/server/lib/meeting-batch-progress.ts +51 -5
  73. package/server/lib/meeting-batch-transcribe.ts +35 -2
  74. package/server/lib/meeting-corrections.ts +2 -1
  75. package/server/lib/meeting-fact-check.ts +3 -0
  76. package/server/lib/meeting-finalization-jobs.ts +69 -2
  77. package/server/lib/meeting-public-topic.ts +6 -1
  78. package/server/lib/meeting-relabel-enrolment.ts +2 -1
  79. package/server/lib/meeting-relabel.ts +17 -10
  80. package/server/lib/meeting-speaker-review.ts +187 -21
  81. package/server/lib/meeting-store.ts +6 -1
  82. package/server/lib/parakeet.ts +51 -5
  83. package/server/lib/pending-batch-retention.ts +58 -0
  84. package/server/lib/private-reference.ts +38 -0
  85. package/server/lib/provider-usage.ts +561 -0
  86. package/server/lib/public-https-url.ts +32 -0
  87. package/server/lib/query-job-runtime.ts +3 -0
  88. package/server/lib/rich-media-safety.ts +23 -2
  89. package/server/lib/session-find-ask.ts +288 -0
  90. package/server/lib/session-find-collect.ts +715 -0
  91. package/server/lib/session-find-redact.ts +254 -0
  92. package/server/lib/session-find.ts +345 -0
  93. package/server/lib/speaker-calibration-log.ts +3 -2
  94. package/server/lib/speaker-embeddings.ts +46 -35
  95. package/server/lib/speaker-name-nfc.ts +37 -0
  96. package/server/lib/speaker-rename-fanout.ts +7 -6
  97. package/server/lib/speaker-shadow-decide.ts +59 -22
  98. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  99. package/server/lib/speaker-shadow-paired.ts +268 -0
  100. package/server/lib/speaker-shadow-report.ts +2 -2
  101. package/server/lib/speaker-shadow-worker.ts +50 -15
  102. package/server/lib/speaker-shadow.ts +80 -19
  103. package/server/lib/speaker-trainer.ts +2 -1
  104. package/server/lib/token-audit.ts +36 -6
  105. package/server/lib/transcript-cleanup.ts +3 -2
  106. package/server/lib/voice-directory.ts +10 -5
  107. package/server/lib/voice-profile-store.ts +24 -14
  108. package/server/lib/web-lookup-ledger.ts +151 -0
  109. package/server/lib/work-review-runtime.ts +2 -0
  110. package/server/lib/work-search.ts +3 -1
  111. package/server/lib/xlsx-text.ts +277 -0
  112. package/server/routes/decision-provider.ts +4 -1
  113. package/server/routes/jev.ts +5 -1
  114. package/server/routes/live-cues.ts +36 -14
  115. package/server/routes/meeting.ts +515 -61
  116. package/server/routes/session-find.ts +73 -0
  117. package/server/routes/transcribe-stream.ts +114 -33
  118. package/server/routes/voice.ts +28 -18
  119. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  120. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  121. package/shared/live-cues-v2.ts +38 -5
  122. package/shared/media-attachment.ts +2 -0
  123. 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, resolveLiveCuesV2,
31
- type LiveCuesV2LimitName, type LiveCuesV2SettingReader, type LiveCuesV2SettingSource, type LiveCuesV2StoredLimits,
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 { id: string; date: string; title: string; notes: string; key?: string }
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
- export interface ParsedCue { detail: string; kind: CueKind; memoryIds: string[]; numberFlags: string[] }
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
- interface MeetingCounters { cues: number; writerCalls: number; at: number }
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
- s.meetings[sessionId] = { ...m, writerCalls: m.writerCalls + 1, at: this.now().getTime() }
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
- memory(window: string, before: string, signal: AbortSignal): Promise<MemoryResult>
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(row) {
564
- try { mkdirSync(dirname(traceFile), { recursive: true, mode: 0o700 }); appendFileSync(traceFile, JSON.stringify(row) + '\n', { mode: 0o600 }) } catch { /* trace is best effort */ }
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, 4)
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: 4 }))
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
- constructor(private readonly deps: LiveCuesV2Deps) {}
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
- const cues = s ? [...s.cues] : []
734
- return { nudgesGenerated: cues.length, protocol: 2, cues,
735
- // v1 readers know two types: an extended kind is shown as its legacy kind there (the cues keep the real one).
736
- nudges: cues.map(c => ({ nudge: c.headline, type: legacyCueKind(c.kind), priority: 2, timestamp: c.createdAt, cueId: c.cueId })) }
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): LiveCuesV2StatusResponse {
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
- const session: LiveCuesV2SessionStatus = {
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 ? [...s.cues] : [], evaluations: s?.evaluations ?? 0, writerCalls: counters.writerCalls,
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 knownCue = !!this.sessions.get(input.sessionId)?.cues.some(c => c.cueId === input.cueId)
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
- let mem: MemoryResult = await this.deps.memory(window, before, abort.signal).catch((): MemoryResult => ({ notes: [], denseError: 'memory_threw' }))
857
- Object.assign(trace, { memoryMs: Date.now() - t1, ...(mem.denseError ? { memoryError: mem.denseError } : {}), ...(mem.indexAgeH !== undefined ? { indexAgeH: mem.indexAgeH } : {}) })
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 previous = s.cues.slice(-3).map(c => c.detail)
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
- const prompt = pyJson({ meetingTitle: s.title, liveIssue: '', suggestedKind: gate.kind, previousCues: previous, memory: promptMemory, window })
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
- const cue: LiveCueV2 = { protocol: 2, cueId: randomUUID(), sessionId: s.sessionId, headline: cueHeadline(parsed.detail), detail: parsed.detail,
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, s.cues, repeatT)
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,99 @@ 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
+ // A lead source alone (no group named) is everyday speech in a marketing agency's meeting ("the trade show is in 30 days"): a Quilt
1327
+ // memory among the candidates does not vouch for it. Only a Quilt-confirmed meeting or a Quilt term in the title does.
1328
+ const gate = dataArmGate({ domain: ctx.domain, orientationFailed: ctx.orientationFailed, brand: found.brand, memoryQuilt: found.sourceOnly ? false : ctx.memoryQuilt, titleQuilt: quiltTitleSignal(read.facts, ctx.titles) })
1329
+ if (!gate.ok) { trace.dataArm = gate.why; return null }
1330
+ trace.dataArm = 'offered'
1331
+ trace.dataSignal = gate.signal
1332
+ return found.candidate
1333
+ } catch { trace.dataArm = 'threw'; return null }
1334
+ }
1335
+
958
1336
  private reap(): void {
959
1337
  const nowMs = this.deps.now().getTime()
960
1338
  for (const [id, s] of this.sessions) if (!s.inFlight && nowMs - s.lastSeenAt > LIVE_CUES_V2_LIMITS.sessionTtlMs) this.sessions.delete(id)
961
1339
  }
962
1340
 
963
- shutdown(): void { for (const s of this.sessions.values()) s.inFlight?.abort(); this.sessions.clear() }
1341
+ shutdown(): void {
1342
+ for (const s of this.sessions.values()) s.inFlight?.abort()
1343
+ this.sessions.clear()
1344
+ if (this.general) { this.general.stop(); setGeneralCuesTodayProvider(null) }
1345
+ }
964
1346
 
965
1347
  /** v2 switched off at runtime (6.76.2): abort every evaluation in flight, so no half-written cue is emitted and every
966
1348
  * lease is released. Sessions are kept (no new evaluation starts while off), so switching back on resumes a running
@@ -968,9 +1350,105 @@ export class LiveCuesV2Engine {
968
1350
  suspend(): number {
969
1351
  let aborted = 0
970
1352
  for (const s of this.sessions.values()) if (s.inFlight) { s.inFlight.abort(V2_OFF); aborted += 1 }
1353
+ if (this.general?.abortAll(V2_OFF)) aborted += 1
971
1354
  return aborted
972
1355
  }
973
1356
 
1357
+ // ── General cues (live cues v3) ──
1358
+ /** Starts the general runner's poll timer and registers its counters for the settings view. Called once, where the engine
1359
+ * singleton is built; an engine made by a test never polls on its own. */
1360
+ startGeneral(): void {
1361
+ if (!this.general) return
1362
+ setGeneralCuesTodayProvider(() => this.general!.generalCuesToday())
1363
+ this.general.start()
1364
+ }
1365
+ /** The general runner, if this engine has one (tests drive its ticks directly). */
1366
+ generalRunner(): GeneralCueRunner | null { return this.general }
1367
+ /** The cue mode was saved: leaving general mode stops an insight in flight at once. */
1368
+ modeChanged(): void { this.general?.modeChanged() }
1369
+
1370
+ private cueMode(): LiveCuesCueMode { return resolveLiveCuesCueMode(this.deps.env, this.deps.settings).value }
1371
+ /** Ask Public topics consent, read the way the general runner reads it: unreadable, absent and off are all off. */
1372
+ private publicTopicsOn(): boolean { try { return this.deps.publicTopics?.() === true } catch { return false } }
1373
+ /** Cue mode `general` turns the focused cues off for a Mac recording, but only while the insights that replace them can
1374
+ * run: with Public topics off the general runner stops at public_topics_off, so switching the focused cues off as well would
1375
+ * leave the meeting with nothing, and Control cannot turn the consent on. Then the focused cues stay on and the status says
1376
+ * needs_public_topics. A G2 meeting keeps them too, whatever kinds it was armed with (6.97.9): it can never carry an insight. */
1377
+ private focusedOff(s: Session): boolean { return macRecording(s) && this.cueMode() === 'general' && this.publicTopicsOn() }
1378
+ private allCues(s: Session): LiveCueV2[] { return s.insights.length ? [...s.cues, ...s.insights].sort((a, b) => a.createdAt - b.createdAt) : [...s.cues] }
1379
+
1380
+ /** Remembers who the ASR path says is speaking, for the general runner's name gate. Bounded; generic labels ("Ext", "Speaker 2")
1381
+ * are not names. Never read by the focused path. */
1382
+ private noteSpeakers(s: Session, speakers: readonly string[] | undefined): void {
1383
+ for (const raw of speakers ?? []) {
1384
+ const name = typeof raw === 'string' ? raw.replace(/\s+/g, ' ').trim() : ''
1385
+ if (!name || name.length > 60 || isGenericSpeakerLabel(name) || s.speakers.size >= SPEAKER_NAMES_MAX) continue
1386
+ s.speakers.add(name)
1387
+ }
1388
+ }
1389
+
1390
+ /** The meetings the general runner may look at: extended ones only, while v2 is on. The domain is the CONFIRMED Ask orientation
1391
+ * domain or null, never a recording sidecar's default (all saved sidecars say personal). */
1392
+ private generalHost(): GeneralCueHost {
1393
+ return {
1394
+ candidates: () => {
1395
+ if (!this.enabled()) return []
1396
+ const out: GeneralCueCandidate[] = []
1397
+ for (const s of this.sessions.values()) {
1398
+ if (!macRecording(s) || s.kinds !== 'extended') continue // a G2 meeting armed with the five kinds is still never offered to the general runner
1399
+ // A throw is not "unconfirmed": an orientation that cannot be read may belong to a personal meeting, so it is withheld.
1400
+ let domain: string | null = null, calendarTitle: string | null = null, domainUnreadable = false
1401
+ try { const o = this.deps.orientation?.(s.sessionId); domain = o?.domain ?? null; calendarTitle = o?.title ?? null } catch { domainUnreadable = true }
1402
+ // The orientation carries the calendar event's title, not its attendees: its capitalized words stand in for them.
1403
+ const titleNames = (calendarTitle ?? '').match(/\p{Lu}[\p{L}'’-]{2,}/gu) ?? []
1404
+ out.push({ sessionId: s.sessionId, kindSet: s.kinds, armed: !this.deps.store.isEnded(s.sessionId), paused: this.deps.store.isPaused(s.sessionId), domain,
1405
+ ...(domainUnreadable ? { domainUnreadable } : {}), title: s.title, transcriptChars: s.charsFed, speakerNames: [...s.speakers, ...titleNames],
1406
+ window: chars => cleanWindow(s.chunks.join('\n')).slice(-chars) })
1407
+ }
1408
+ return out
1409
+ },
1410
+ emit: (sessionId, insight) => this.emitInsight(sessionId, insight),
1411
+ }
1412
+ }
1413
+ /** The general limits, the mode and the consent as they stand now (read at every tick, so a saved change applies at once). */
1414
+ private generalSettings(): GeneralCueSettingsReader {
1415
+ return {
1416
+ cueMode: () => this.cueMode(),
1417
+ limits: () => {
1418
+ const r = resolveLiveCuesGeneralLimits(this.deps.env, this.deps.settings)
1419
+ return { intervalSeconds: r.intervalSeconds.value, trigger: r.trigger.value, attemptsPerMeeting: r.attemptsPerMeeting.value, maxCuesPerMeeting: r.maxCuesPerMeeting.value,
1420
+ dailyTopicCalls: r.dailyTopicCalls.value, dailyWebSearches: r.dailyWebSearches.value }
1421
+ },
1422
+ publicTopics: () => this.publicTopicsOn(),
1423
+ homeArea: () => { try { return this.deps.homeArea?.() ?? null } catch { return null } },
1424
+ }
1425
+ }
1426
+ /** One insight: logged, held in the meeting's own list, and emitted as `coaching_nudge_v2` ONLY (no v1 twin, so a v1 reader
1427
+ * never sees it). Extended meetings only. Nothing here touches the focused counters, cooldown, lastCueAt, inFlight, failures
1428
+ * or breaker. False when the meeting is gone, ended or not extended, or the cue would not pass isLiveCueV2. */
1429
+ private emitInsight(sessionId: string, insight: GeneralInsight): boolean {
1430
+ const s = this.sessions.get(sessionId)
1431
+ if (!s || !macRecording(s) || s.kinds !== 'extended' || this.deps.store.isEnded(sessionId)) return false
1432
+ // Every source goes through the same public https check as the cue log's: one that fails drops the whole insight.
1433
+ const urls = insight.sources.map(src => publicHttpsUrl(src.url))
1434
+ if (!insight.sources.length || urls.some(u => u === null)) return false
1435
+ const sources = insight.sources.map((src, i) => ({ ...src, url: urls[i]! }))
1436
+ const cue: LiveCueV2 = { protocol: 2, cueId: randomUUID(), sessionId, headline: insight.headline, detail: insight.detail, kind: 'insight', sources,
1437
+ gate: round(insight.gate), createdAt: this.deps.now().getTime(), model: insight.model ?? liveCuesV2Model(this.deps.env) }
1438
+ if (!isLiveCueV2(cue)) return false // never emit what the companion would drop
1439
+ const log = this.deps.cueLog
1440
+ try {
1441
+ 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,
1442
+ sources: cue.sources, model: cue.model, before: cueBefore(s.chunks.join('\n').replace(/^\[\d+\]\s*/gm, '')), general: { category: insight.category, query: insight.query },
1443
+ armedAt: s.armedAt })
1444
+ } catch (error) {
1445
+ console.warn(`[live-cues-v2] insight log append failed: ${errMsg(error)}`)
1446
+ }
1447
+ s.insights.push(cue)
1448
+ this.deps.emit('coaching_nudge_v2', cue as unknown as Record<string, unknown>)
1449
+ return true
1450
+ }
1451
+
974
1452
  /** Was this meeting armed under v2 in this process? Its stop and status stay v2 even after v2 is switched off. */
975
1453
  knows(sessionId: string): boolean { return this.sessions.has(sessionId) }
976
1454
  }
@@ -983,6 +1461,7 @@ let unsubscribe: (() => void) | null = null
983
1461
  export function liveCuesV2(): LiveCuesV2Engine {
984
1462
  if (!engine) {
985
1463
  engine = new LiveCuesV2Engine(realDeps())
1464
+ engine.startGeneral()
986
1465
  // A saved meeting ends its cues even when the phone never sent Stop (orphan recovery, Control save).
987
1466
  unsubscribe = onDisplay(event => {
988
1467
  const sid = event.type === 'recording_stop' ? event.data?.sessionId : null
@@ -1043,16 +1522,59 @@ export function liveCuesV2SettingsViewV2(env: NodeJS.ProcessEnv = process.env):
1043
1522
  return { ...liveCuesV2SettingsView(env), v: 2, limits, writerCallsPerMeeting: 2 * limits.maxCuesPerMeeting.value, today: liveCuesV2().today() }
1044
1523
  }
1045
1524
 
1525
+ /** 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
1526
+ * general-cue limits. An older server answers ?v=3 with 400 unsupported_version, which is Control's signal to hide these. */
1527
+ export interface GeneralCuesToday { day: string; topicCalls: number; webSearches: number; breakerOpenUntil: number | null }
1528
+ export interface LiveCuesV2SettingsResponseV3 extends Omit<LiveCuesV2SettingsResponseV2, 'v'> {
1529
+ v: 3
1530
+ cueMode: LiveCuesV2Mode<LiveCuesCueMode>
1531
+ grounding: LiveCuesV2Mode<LiveCuesGrounding>
1532
+ general: { limits: LiveCuesGeneralLimits; today: GeneralCuesToday; publicTopics: boolean; homeArea: string | null }
1533
+ /** 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). */
1534
+ webToday: WebTodayView
1535
+ }
1536
+ /** Today's general-cue counters and breaker come from the general runner, which registers itself here. Until it does (and
1537
+ * whenever general cues have never run) they read as zero. */
1538
+ let generalTodayProvider: (() => GeneralCuesToday) | null = null
1539
+ export function setGeneralCuesTodayProvider(provider: (() => GeneralCuesToday) | null): void { generalTodayProvider = provider }
1540
+ export function liveCuesV2SettingsViewV3(env: NodeJS.ProcessEnv = process.env): LiveCuesV2SettingsResponseV3 {
1541
+ const v2 = liveCuesV2SettingsViewV2(env)
1542
+ const settings = liveCuesV2Settings()
1543
+ let today: GeneralCuesToday
1544
+ 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 } }
1545
+ let publicTopics = false
1546
+ try { publicTopics = new MeetingAskStore().publicTopics() } catch { publicTopics = false }
1547
+ let homeArea: string | null = null
1548
+ try { homeArea = getOwnerHomeArea() } catch { homeArea = null }
1549
+ let webToday: WebTodayView
1550
+ 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 } } } }
1551
+ return { ...v2, v: 3, cueMode: resolveLiveCuesCueMode(env, settings), grounding: resolveLiveCuesGrounding(env, settings),
1552
+ general: { limits: resolveLiveCuesGeneralLimits(env, settings), today, publicTopics, homeArea }, webToday }
1553
+ }
1554
+
1555
+ /** What a v3 POST may change beyond the v2 patch. */
1556
+ export interface LiveCuesV3Patch { cueMode?: LiveCuesCueMode; grounding?: LiveCuesGrounding; general?: LiveCuesGeneralStoredLimits }
1557
+
1046
1558
  /** Save the switch and any limits in ONE write, after every check passed: nothing is saved when any part is refused.
1047
1559
  * 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: LiveCuesV2LimitName[] } {
1560
+ export function applyLiveCuesV2Settings(patch: { enabled?: boolean; limits: LiveCuesV2StoredLimits } & LiveCuesV3Patch, env: NodeJS.ProcessEnv = process.env):
1561
+ { ok: true } | { ok: false; error: 'env_forced'; fields?: Array<'cueMode' | 'grounding'> } | { ok: false; error: 'env_locked'; fields: string[] } {
1050
1562
  if (patch.enabled === false && liveCuesV2EnvForced(env)) return { ok: false, error: 'env_forced' }
1563
+ // Live cues v3: env may force only the SAFE value, so the only refused request is one for the other value.
1564
+ const forced: Array<'cueMode' | 'grounding'> = []
1565
+ if (patch.cueMode !== undefined && patch.cueMode !== 'focused' && resolveLiveCuesCueMode(env, liveCuesV2Settings()).locked) forced.push('cueMode')
1566
+ if (patch.grounding !== undefined && patch.grounding !== 'classic' && resolveLiveCuesGrounding(env, liveCuesV2Settings()).locked) forced.push('grounding')
1567
+ if (forced.length) return { ok: false, error: 'env_forced', fields: forced }
1051
1568
  const current = resolveLiveCuesV2Limits(env, liveCuesV2Settings())
1052
- const locked = (Object.keys(patch.limits) as LiveCuesV2LimitName[]).filter(name => current[name].locked)
1569
+ const generalNow = resolveLiveCuesGeneralLimits(env, liveCuesV2Settings())
1570
+ const locked: string[] = [
1571
+ ...(Object.keys(patch.limits) as LiveCuesV2LimitName[]).filter(name => current[name].locked),
1572
+ ...(Object.keys(patch.general ?? {}) as LiveCuesGeneralLimitName[]).filter(name => generalNow[name].locked).map(name => LIVE_CUES_GENERAL_LIMIT_BOUNDS[name].field),
1573
+ ]
1053
1574
  if (locked.length) return { ok: false, error: 'env_locked', fields: locked }
1054
1575
  const wasOn = liveCuesV2Enabled(env)
1055
1576
  liveCuesV2Settings().update(patch)
1577
+ if (patch.cueMode !== undefined) engine?.modeChanged() // leaving general mode stops an insight in flight at once
1056
1578
  if (wasOn && !liveCuesV2Enabled(env)) {
1057
1579
  const aborted = engine?.suspend() ?? 0
1058
1580
  console.log(`[live-cues-v2] switched off by setting; ${aborted} evaluation(s) stopped`)
@@ -1061,6 +1583,9 @@ export function applyLiveCuesV2Settings(patch: { enabled?: boolean; limits: Live
1061
1583
  }
1062
1584
  const changed = Object.entries(patch.limits).map(([k, v]) => `${k}=${v}`).join(' ')
1063
1585
  if (changed) console.log(`[live-cues-v2] limits saved: ${changed}`)
1586
+ const v3 = [patch.cueMode ? `cueMode=${patch.cueMode}` : '', patch.grounding ? `grounding=${patch.grounding}` : '',
1587
+ ...Object.entries(patch.general ?? {}).map(([k, v]) => `general.${k}=${v}`)].filter(Boolean).join(' ')
1588
+ if (v3) console.log(`[live-cues-v2] settings saved: ${v3}`)
1064
1589
  return { ok: true }
1065
1590
  }
1066
1591
  /** What /api/health, /api/models and the status route advertise: NOTHING while v2 is switched off, so a 6.10.642