@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.
Files changed (123) hide show
  1. package/CHANGELOG.md +157 -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 +453 -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 +570 -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
@@ -0,0 +1,715 @@
1
+ // General live cues (default off): a server-wide runner that now and then looks up ONE interesting public fact, news item or
2
+ // local happening for a Mac meeting, outside the focused cue pipeline. Plan: operations/personal/wk44_2026/live-cues-grounding
3
+ // (PLAN_live_cues_v3 Track 2, CONTRACT_v3 sections 1, 3, 4, 5).
4
+ //
5
+ // This is the first automatic path from spoken words to a web search, so it is built to stop, not to try: it runs only for an
6
+ // extended (Mac) meeting that is armed and not paused, in cue mode general or all, with the owner's Public topics consent on and
7
+ // a domain that is not personal. It NEVER runs inside the focused engine's evaluate() and never touches that engine's
8
+ // inFlight, failures, lastCueAt, caps or breaker, so a slow search cannot silence a focused cue (validation 1, 3).
9
+ //
10
+ // How it stops (every recurring model caller must answer this):
11
+ // - ONE attempt in flight server-wide; a tick per meeting at most every intervalSeconds and only after ~90 s of new speech.
12
+ // - Four durable caps, charged synchronously BEFORE the call they pay for and never refunded: topic calls per meeting
13
+ // (attempts) and per day, web searches per day, cues per meeting. The file is local-day keyed and fails CLOSED: an
14
+ // unreadable file, a future day or a failed write means no call.
15
+ // - A server-wide breaker, persisted: three consecutive failures (or one provider quota error) open it for an hour. A clean
16
+ // attempt clears the streak only when it got at least as deep as the stage that failed, so a Jev "no" says nothing about
17
+ // the search provider. Search failures count (searchMeetingWeb swallows them into empty sources, which the typed wrapper
18
+ // below turns back into a failure).
19
+ // - Its own AbortController, a 60 s wall on the search and a wall on the whole attempt. end, pause, suspend and a mode change
20
+ // abort it (abortSession, abortAll, modeChanged); the session and mode are checked again before the search and before emit.
21
+ // - A cue older than 180 s from its window is dropped, never shown late.
22
+ //
23
+ // Privacy order inside an attempt: the deterministic sensitive-window skip, then the Jev trigger, then the Haiku topic, then
24
+ // admitCueTopic (no digit, deny word, name or repeat), then the search. The query text lives in the emitted cue's log row only;
25
+ // a trace row carries a hash. Search cost is not readable (the search process is stopped after result.json), so `usd` is the
26
+ // topic and writer model cost only.
27
+ import { createHash } from 'node:crypto'
28
+ import { mkdirSync, readFileSync } from 'node:fs'
29
+ import { dirname } from 'node:path'
30
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
31
+ import { JevError } from './jev.js'
32
+ import { DecisionProviderError } from './decision-provider.js'
33
+ import { LensGistLimitError, redactSecrets, runLensGistEngine } from './lens-gist-engines.js'
34
+ import { searchMeetingWeb } from './meeting-ask-web.js'
35
+ import { webLedger } from './web-lookup-ledger.js'
36
+ import { publicHttpsUrl } from './public-https-url.js'
37
+ import {
38
+ GENERAL_INSIGHT_CONTEXT_CHARS, GENERAL_INSIGHT_SOURCE_CHARS, GENERAL_INSIGHT_WRITER_SYSTEM, GENERAL_TOPIC_SYSTEM, generalInsightPrompt, generalTopicPrompt,
39
+ parseGeneralInsight, parseGeneralTopic, type GeneralCueCategory, type InsightSourceIn,
40
+ } from './live-cues-general-prompts.js'
41
+ import {
42
+ HASHABLE_REJECTIONS, admitCueTopic, blockedNameWords, ownerTermJoins, ownerTermPhrases, queryHash, realProfileNames, sensitiveWindow, withHomeArea, type AdmitReason,
43
+ type ProfileNameSource,
44
+ } from './live-cues-general-topic.js'
45
+
46
+ // ── Limits and shared types (names follow CONTRACT_v3; settings v3 and the shared types land with the modes branch) ──
47
+ export const LIVE_CUES_GENERAL_LIMITS = {
48
+ /** How often the runner looks at its meetings. A meeting's own cadence is intervalSeconds. */
49
+ pollMs: 15_000,
50
+ /** ~90 s of speech: the engine's own rate is ~2.5 words a second (live-cues-v2.ts staleWords) at ~5.7 characters a word. The
51
+ * host counts characters fed since the meeting was armed (monotonic), so a rolling window cannot hide new speech. */
52
+ minNewTranscriptChars: 1_200,
53
+ /** ~3 minutes of speech at that rate; the topic prompt's ceiling is 6,000. */
54
+ topicWindowChars: 2_700,
55
+ searchWallMs: 60_000,
56
+ /** The whole attempt: Jev 20 s + topic 20 s + search 60 s + writer 20 s, with room. */
57
+ attemptWallMs: 150_000,
58
+ /** A cue is dropped when it is older than this from the moment its window was read. */
59
+ staleMs: 180_000,
60
+ queryCacheMs: 30 * 60_000,
61
+ breakerFailures: 3,
62
+ breakerMs: 60 * 60_000,
63
+ modelTimeoutMs: 20_000,
64
+ topicMaxOutputTokens: 150,
65
+ writerMaxOutputTokens: 400,
66
+ /** Per-meeting counters expire after a day, like the focused engine's. */
67
+ counterTtlMs: 24 * 60 * 60_000,
68
+ skipTraceEveryMs: 5 * 60_000,
69
+ /** At most this many search sources reach the writer. */
70
+ writerSources: 5,
71
+ } as const
72
+ /** The topic model is pinned: a spawn without --model can become Opus (gotchas). Same id live-cues-v2 maps 'haiku' to. */
73
+ export const GENERAL_TOPIC_MODEL = 'claude-haiku-5-5'
74
+
75
+ export type GeneralCueMode = 'focused' | 'general' | 'all'
76
+ export type { GeneralCueCategory } from './live-cues-general-prompts.js'
77
+ export interface GeneralCueLimits { intervalSeconds: number; trigger: number; attemptsPerMeeting: number; maxCuesPerMeeting: number; dailyTopicCalls: number; dailyWebSearches: number }
78
+ export interface GeneralCuesToday { day: string; topicCalls: number; webSearches: number; breakerOpenUntil: number | null }
79
+ export type GeneralCueState = 'off' | 'waiting' | 'capped' | 'withheld' | 'breaker' | 'needs_public_topics'
80
+ export interface GeneralCueStatus { state: GeneralCueState; reason?: string; cues: number; attempts: number }
81
+
82
+ /** A cited web source on an insight: kind web, https only, at most two per cue (isLiveCueV2). Fits CueSourceRef. */
83
+ export interface GeneralInsightSource { id: string; date: string; title: string; kind: 'web'; url: string }
84
+ /** What the host turns into a LiveCueV2 of kind 'insight' (log row `general: {category, query}`). */
85
+ export interface GeneralInsight {
86
+ headline: string
87
+ detail: string
88
+ category: GeneralCueCategory
89
+ /** The admitted query. For the cue log row only; never a trace field. */
90
+ query: string
91
+ sources: GeneralInsightSource[]
92
+ /** P(yes) from the trigger, as the cue's admission probability. */
93
+ gate: number
94
+ /** The model the writer reported, when it said. */
95
+ model?: string
96
+ /** When the window this fact was chosen from was read, and when the fact was handed over (ms). */
97
+ windowAt: number
98
+ createdAt: number
99
+ }
100
+
101
+ /** One meeting as the host (the live-cues engine) shows it to the runner. */
102
+ export interface GeneralCueCandidate {
103
+ sessionId: string
104
+ /** extended = a Mac recording, the only meeting that may carry an insight. */
105
+ kindSet: 'legacy' | 'extended'
106
+ armed: boolean
107
+ paused: boolean
108
+ /** The CONFIRMED Ask orientation domain, or null when unconfirmed. Never the sidecar default (all 974 saved sidecars say personal). */
109
+ domain: string | null
110
+ /** True when the orientation could not be READ (it threw), as opposed to not being confirmed (domain null). The meeting is then
111
+ * withheld: an unreadable orientation may be a personal meeting. */
112
+ domainUnreadable?: boolean
113
+ title: string
114
+ /** Cleaned characters fed to this meeting since it was armed. Monotonic: a rolling window's length is not this. */
115
+ transcriptChars: number
116
+ /** The last `chars` characters of the cleaned transcript, as of now. */
117
+ window(chars: number): string
118
+ /** Names the host knows, to keep out of a query: the speakers the ASR path reported and the capitalized words of the calendar
119
+ * title. (A speaker label that opens a line of the window is read from the window itself.) */
120
+ speakerNames?: readonly string[]
121
+ }
122
+ export interface GeneralCueHost {
123
+ candidates(): GeneralCueCandidate[]
124
+ /** Emits the insight for the meeting; false when the host did not (meeting gone, log or emit refused). */
125
+ emit(sessionId: string, insight: GeneralInsight): boolean
126
+ }
127
+ export interface GeneralCueSettingsReader {
128
+ cueMode(): GeneralCueMode
129
+ limits(): GeneralCueLimits
130
+ /** Ask Public topics consent. */
131
+ publicTopics(): boolean
132
+ /** The owner's city-level home area (profile home_city that passes HOME_AREA_LABEL), or null. */
133
+ homeArea(): string | null
134
+ }
135
+ export interface GeneralModelOut { text: string; costUsd?: number; inputTokens?: number; outputTokens?: number; model?: string }
136
+ export interface GeneralSearchSource { id: string; title: string; text: string; url?: string }
137
+ export interface GeneralCueDeps {
138
+ /** The Jev (or SubQ) trigger: P(yes) for the room being on a public topic. */
139
+ jevTrigger(title: string, window: string, signal: AbortSignal): Promise<{ pGeneral: number; tokens: number }>
140
+ topicModel(system: string, prompt: string, signal: AbortSignal): Promise<GeneralModelOut>
141
+ /** searchMeetingWeb's shape; the runner types the result. */
142
+ search(query: string, signal: AbortSignal): Promise<{ sources: GeneralSearchSource[]; notices?: string[] }>
143
+ writer(system: string, prompt: string, signal: AbortSignal): Promise<GeneralModelOut>
144
+ now(): number
145
+ statePath: string
146
+ trace?(row: GeneralTickRow): void
147
+ /** Names the owner's profile offers (default: the real profile). */
148
+ profile?: ProfileNameSource
149
+ /** Where the internal daily trigger cap is read from (default process.env). */
150
+ env?: NodeJS.ProcessEnv
151
+ }
152
+
153
+ /** An internal ceiling on Jev trigger calls a local day: not a user setting (so not in the settings contract), env only. The
154
+ * trigger runs on every tick of every eligible meeting, so it has a cap of its own beside the topic and search caps. */
155
+ export const GENERAL_TRIGGER_CAP = { default: 300, min: 1, max: 2000, env: 'COS_LIVE_CUES_GENERAL_DAILY_TRIGGERS' } as const
156
+ export function dailyTriggerCap(env: NodeJS.ProcessEnv = process.env): number {
157
+ const raw = env[GENERAL_TRIGGER_CAP.env]
158
+ const n = raw !== undefined && /^\d{1,5}$/.test(raw.trim()) ? Number(raw.trim()) : Number.NaN
159
+ return n >= GENERAL_TRIGGER_CAP.min && n <= GENERAL_TRIGGER_CAP.max ? n : GENERAL_TRIGGER_CAP.default
160
+ }
161
+
162
+ /** CONTRACT_v3 section 5. `reason` is a code, never text from the meeting. */
163
+ export interface GeneralTickRow {
164
+ type: 'general_tick'
165
+ at: string
166
+ sessionId: string
167
+ pGeneral: number | null
168
+ outcome: 'cue' | 'no_cue' | 'cancelled' | 'failure' | 'skip'
169
+ reason: string
170
+ category: GeneralCueCategory | null
171
+ queryHash: string | null
172
+ searchMs: number | null
173
+ sourcesCount: number | null
174
+ writerMs: number | null
175
+ usd: number | null
176
+ /** Input tokens of the Jev trigger request (additive to CONTRACT_v3 section 5). */
177
+ jevTokens: number | null
178
+ }
179
+
180
+ // ── Eligibility ─────────────────────────────────────────────────────────────
181
+ export type GeneralEligibility = { ok: true } | { ok: false; state: 'off' | 'needs_public_topics' | 'withheld'; reason: string }
182
+ const GENERAL_MODES: ReadonlySet<string> = new Set(['general', 'all'])
183
+ export function generalModeOn(mode: string): boolean { return GENERAL_MODES.has(mode) }
184
+ /** The one place that decides whether a meeting may have general cues; used at every tick and again before the search and emit.
185
+ *
186
+ * DOMAIN. `c.domain` must come only from the confirmed Ask orientation (MeetingAskStore.orientation(sessionId).domain, null when
187
+ * unconfirmed). A recording sidecar's domain is NOT a classification: all 974 saved sidecars carry 'personal' as a default,
188
+ * work calls included. So only a confirmed 'personal' skips the meeting; null proceeds, and the content checks (sensitive
189
+ * window, admission) still apply to it. An orientation that cannot be READ (`domainUnreadable`) is not null: it is withheld. */
190
+ export function generalEligibility(c: GeneralCueCandidate, mode: GeneralCueMode, publicTopics: boolean): GeneralEligibility {
191
+ const off = (reason: string): GeneralEligibility => ({ ok: false, state: 'off', reason })
192
+ if (!generalModeOn(mode)) return off('mode_focused')
193
+ if (c.kindSet !== 'extended') return off('not_extended')
194
+ if (!c.armed) return off('not_armed')
195
+ if (c.paused) return off('paused')
196
+ if (!publicTopics) return { ok: false, state: 'needs_public_topics', reason: 'public_topics_off' }
197
+ if (c.domainUnreadable) return { ok: false, state: 'withheld', reason: 'orientation_unreadable' }
198
+ if (c.domain?.trim().toLowerCase() === 'personal') return { ok: false, state: 'withheld', reason: 'personal_domain' }
199
+ return { ok: true }
200
+ }
201
+
202
+ // ── Durable state ───────────────────────────────────────────────────────────
203
+ /** Pipeline stages, deepest last. A clean attempt that reached `depth` clears a failure streak whose deepest stage is <= depth. */
204
+ const STAGES = ['trigger', 'topic', 'search', 'writer'] as const
205
+ type Stage = typeof STAGES[number]
206
+ const stageRank = (s: Stage): number => STAGES.indexOf(s) + 1
207
+ interface MeetingCounters { attempts: number; cues: number; at: number }
208
+ interface StoredBreaker { failures: number; stage: number; openUntil: number | null }
209
+ interface StoreState { v: 1; zone: string; day: string; topicCalls: number; webSearches: number; triggerCalls: number; meetings: Record<string, MeetingCounters>; breaker: StoredBreaker }
210
+ type StoreRead = { ok: true; state: StoreState } | { ok: false }
211
+ export type ChargeResult = 'ok' | 'unreadable' | 'daily_cap' | 'meeting_cap' | 'write_failed'
212
+
213
+ function validZone(zone: unknown): string | null {
214
+ if (typeof zone !== 'string' || !zone) return null
215
+ try { new Intl.DateTimeFormat('en-CA', { timeZone: zone }); return zone } catch { return null }
216
+ }
217
+ /** Local calendar day (YYYY-MM-DD) in `zone`. Intl, never toISOString: a UTC key flips the day at 19:00 Central. (The same rule as
218
+ * live-cues-v2.ts localDay, kept here so this module never imports the engine that imports it.) */
219
+ function localDay(ms: number, zone: string): string {
220
+ return new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(ms))
221
+ }
222
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
223
+ const whole = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0
224
+
225
+ export class GeneralCueStore {
226
+ constructor(private readonly file: string, private readonly now: () => number) {}
227
+ private systemZone(): string { return validZone(Intl.DateTimeFormat().resolvedOptions().timeZone) ?? 'UTC' }
228
+ private fresh(nowMs: number): StoreState {
229
+ const zone = this.systemZone()
230
+ return { v: 1, zone, day: localDay(nowMs, zone), topicCalls: 0, webSearches: 0, triggerCalls: 0, meetings: {}, breaker: { failures: 0, stage: 0, openUntil: null } }
231
+ }
232
+ /** A missing file is a fresh day. Anything else that is not a valid state (unparseable, wrong shape, a future day, a negative or
233
+ * fractional count, an unreadable path) is `ok: false`, and every caller treats that as "do not spend". */
234
+ read(): StoreRead {
235
+ const nowMs = this.now()
236
+ let raw: unknown
237
+ try { raw = JSON.parse(readFileSync(this.file, 'utf8')) } catch (e) {
238
+ if ((e as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: true, state: this.fresh(nowMs) }
239
+ return { ok: false }
240
+ }
241
+ if (!isRecord(raw) || raw.v !== 1) return { ok: false }
242
+ const zone = validZone(raw.zone), today = zone ? localDay(nowMs, zone) : ''
243
+ if (!zone || typeof raw.day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.day) || raw.day > today) return { ok: false }
244
+ if (!whole(raw.topicCalls) || !whole(raw.webSearches) || !(raw.triggerCalls === undefined || whole(raw.triggerCalls)) || !isRecord(raw.meetings) || !isRecord(raw.breaker)) return { ok: false }
245
+ const b = raw.breaker
246
+ if (!whole(b.failures) || !whole(b.stage) || b.stage > STAGES.length || !(b.openUntil === null || (typeof b.openUntil === 'number' && Number.isFinite(b.openUntil)))) return { ok: false }
247
+ const meetings: Record<string, MeetingCounters> = {}
248
+ for (const [id, m] of Object.entries(raw.meetings)) {
249
+ if (!isRecord(m) || !whole(m.attempts) || !whole(m.cues) || typeof m.at !== 'number' || !Number.isFinite(m.at)) return { ok: false }
250
+ if (m.at >= nowMs - LIVE_CUES_GENERAL_LIMITS.counterTtlMs) meetings[id] = { attempts: m.attempts, cues: m.cues, at: m.at }
251
+ }
252
+ const rolled = raw.day < today
253
+ const open = b.openUntil !== null && b.openUntil > nowMs
254
+ return { ok: true, state: { v: 1, zone, day: today, topicCalls: rolled ? 0 : raw.topicCalls, webSearches: rolled ? 0 : raw.webSearches, triggerCalls: rolled ? 0 : (raw.triggerCalls ?? 0), meetings,
255
+ breaker: open ? { failures: 0, stage: 0, openUntil: b.openUntil as number } : { failures: b.failures, stage: b.stage, openUntil: null } } }
256
+ }
257
+ private write(s: StoreState): boolean {
258
+ try {
259
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
260
+ durableAtomicWriteFileSync(this.file, JSON.stringify(s) + '\n', { mode: 0o600 })
261
+ return true
262
+ } catch { return false }
263
+ }
264
+ /** Charge one topic call (and one attempt for this meeting) BEFORE the call. Never refunded. */
265
+ chargeTopic(sessionId: string, caps: { daily: number; perMeeting: number }): ChargeResult {
266
+ const r = this.read()
267
+ if (!r.ok) return 'unreadable'
268
+ const s = r.state, m = s.meetings[sessionId] ?? { attempts: 0, cues: 0, at: 0 }
269
+ if (s.topicCalls >= caps.daily) return 'daily_cap'
270
+ if (m.attempts >= caps.perMeeting) return 'meeting_cap'
271
+ s.topicCalls += 1
272
+ s.meetings[sessionId] = { ...m, attempts: m.attempts + 1, at: this.now() }
273
+ return this.write(s) ? 'ok' : 'write_failed'
274
+ }
275
+ /** Charge one Jev trigger call BEFORE it is sent. Never refunded. */
276
+ chargeTrigger(cap: number): ChargeResult {
277
+ const r = this.read()
278
+ if (!r.ok) return 'unreadable'
279
+ if (r.state.triggerCalls >= cap) return 'daily_cap'
280
+ r.state.triggerCalls += 1
281
+ return this.write(r.state) ? 'ok' : 'write_failed'
282
+ }
283
+ /** Charge one web search BEFORE it starts. Never refunded. */
284
+ chargeSearch(cap: number): ChargeResult {
285
+ const r = this.read()
286
+ if (!r.ok) return 'unreadable'
287
+ if (r.state.webSearches >= cap) return 'daily_cap'
288
+ r.state.webSearches += 1
289
+ return this.write(r.state) ? 'ok' : 'write_failed'
290
+ }
291
+ recordCue(sessionId: string): boolean {
292
+ const r = this.read()
293
+ if (!r.ok) return false
294
+ const m = r.state.meetings[sessionId] ?? { attempts: 0, cues: 0, at: 0 }
295
+ r.state.meetings[sessionId] = { ...m, cues: m.cues + 1, at: this.now() }
296
+ return this.write(r.state)
297
+ }
298
+ /** One failure at `stage`. Three in a row (or `immediate`, a provider quota error) open the breaker for an hour. */
299
+ recordFailure(stage: Stage, immediate = false): boolean {
300
+ const r = this.read()
301
+ if (!r.ok) return false
302
+ const b = r.state.breaker
303
+ b.failures += 1
304
+ b.stage = Math.max(b.stage, stageRank(stage))
305
+ if (immediate || b.failures >= LIVE_CUES_GENERAL_LIMITS.breakerFailures) r.state.breaker = { failures: 0, stage: 0, openUntil: this.now() + LIVE_CUES_GENERAL_LIMITS.breakerMs }
306
+ return this.write(r.state)
307
+ }
308
+ /** A clean attempt that reached `depth` clears the streak only when it got at least as deep as the deepest stage that failed. */
309
+ recordClean(depth: number): void {
310
+ const r = this.read()
311
+ if (!r.ok) return
312
+ const b = r.state.breaker
313
+ if (b.failures > 0 && depth >= b.stage) { r.state.breaker = { failures: 0, stage: 0, openUntil: null }; this.write(r.state) }
314
+ }
315
+ meeting(sessionId: string): { attempts: number; cues: number } | null {
316
+ const r = this.read()
317
+ if (!r.ok) return null
318
+ const m = r.state.meetings[sessionId]
319
+ return { attempts: m?.attempts ?? 0, cues: m?.cues ?? 0 }
320
+ }
321
+ /** Today for the settings view. An unreadable file reads as spent (saturated), like the focused engine's day. */
322
+ today(): GeneralCuesToday {
323
+ const r = this.read()
324
+ if (!r.ok) return { day: localDay(this.now(), this.systemZone()), topicCalls: Number.MAX_SAFE_INTEGER, webSearches: Number.MAX_SAFE_INTEGER, breakerOpenUntil: null }
325
+ return { day: r.state.day, topicCalls: r.state.topicCalls, webSearches: r.state.webSearches, breakerOpenUntil: r.state.breaker.openUntil }
326
+ }
327
+ }
328
+
329
+ // ── Typed search ────────────────────────────────────────────────────────────
330
+ export type GeneralSearchResult =
331
+ | { ok: true; sources: InsightSourceIn[] }
332
+ | { ok: false; code: 'no_sources' | 'timeout' | 'error' | 'aborted' }
333
+ const abortError = (): Error => { const e = new Error('aborted'); e.name = 'AbortError'; return e }
334
+ const isCancel = (error: unknown, signal: AbortSignal): boolean => signal.aborted || (error instanceof Error && error.name === 'AbortError')
335
+ /** Settles when `promise` does, or rejects with an AbortError the moment `signal` aborts: a dependency that ignores its signal
336
+ * can then never hold the server-wide single flight. */
337
+ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
338
+ if (signal.aborted) { promise.catch(() => {}); return Promise.reject(abortError()) }
339
+ return new Promise<T>((resolve, reject) => {
340
+ const onAbort = () => reject(abortError())
341
+ signal.addEventListener('abort', onAbort, { once: true })
342
+ promise.then(v => { signal.removeEventListener('abort', onAbort); resolve(v) }, e => { signal.removeEventListener('abort', onAbort); reject(e) })
343
+ })
344
+ }
345
+ /** searchMeetingWeb swallows a failed search into `{sources: []}` plus a notice, so the focused breaker could never see it. This
346
+ * types the answer: ok needs at least one https source (publicHttpsUrl-checked); no https source, a throw and the 60 s wall are
347
+ * failures; the caller's own abort is `aborted` (a cancel, not a failure). */
348
+ export async function typedGeneralSearch(search: GeneralCueDeps['search'], query: string, parent: AbortSignal, wallMs: number = LIVE_CUES_GENERAL_LIMITS.searchWallMs): Promise<GeneralSearchResult> {
349
+ const own = new AbortController()
350
+ let timedOut = false
351
+ const onAbort = () => own.abort()
352
+ parent.addEventListener('abort', onAbort, { once: true })
353
+ if (parent.aborted) own.abort()
354
+ const timer = setTimeout(() => { timedOut = true; own.abort() }, wallMs)
355
+ timer.unref?.()
356
+ try {
357
+ const out = await raceAbort(search(query, own.signal), own.signal)
358
+ const sources: InsightSourceIn[] = []
359
+ for (const s of out.sources ?? []) {
360
+ const url = publicHttpsUrl(s?.url)
361
+ if (!url || typeof s.id !== 'string' || sources.some(x => x.url === url)) continue
362
+ sources.push({ id: s.id, title: redactSecrets(String(s.title ?? '')).slice(0, 160), text: redactSecrets(String(s.text ?? '')).slice(0, GENERAL_INSIGHT_SOURCE_CHARS), url })
363
+ if (sources.length >= LIVE_CUES_GENERAL_LIMITS.writerSources) break
364
+ }
365
+ return sources.length ? { ok: true, sources } : { ok: false, code: 'no_sources' }
366
+ } catch {
367
+ if (parent.aborted) return { ok: false, code: 'aborted' }
368
+ return { ok: false, code: timedOut ? 'timeout' : 'error' }
369
+ } finally {
370
+ clearTimeout(timer)
371
+ parent.removeEventListener('abort', onAbort)
372
+ }
373
+ }
374
+
375
+ // ── Runner ──────────────────────────────────────────────────────────────────
376
+ /** Decision-provider codes that stop quietly (Jev's own cap, breaker or key): not a failure, so not toward the breaker. Kept in
377
+ * step with QUIET_DECISION_STOPS in live-cues-v2.ts, which this module cannot import (that module imports this one). */
378
+ const QUIET_DECISION_CODES: ReadonlySet<string> = new Set(['jev_cap_reached', 'jev_breaker_open', 'jev_not_configured',
379
+ 'decision_cap_reached', 'decision_breaker_open', 'decision_not_configured', 'decision_invalid_provider'])
380
+ /** The ledger code for an attempt that ended because a PROPOSED lookup was held back: an admission rejection, or no home area for a local
381
+ * query. Otherwise null. The checks that run before the trigger (a private-life window, voices that cannot be read) are not counted:
382
+ * no topic was proposed, they repeat roughly once per interval in any meeting with personal vocabulary, and counting them would
383
+ * make the ledger read as lookups refused when none was ever asked for. */
384
+ export function heldLookupReason(reason: string): string | null {
385
+ if (reason === 'local_no_area') return reason
386
+ const m = /^(rejected|rejected_local):([a-z_]+)$/.exec(reason)
387
+ return m ? `${m[1]}_${m[2]}` : null
388
+ }
389
+ const WALL = 'general_wall'
390
+ const round4 = (n: number): number => Math.round(n * 10_000) / 10_000
391
+ interface SeenSession { firstSeenAt: number; lastTickAt: number | null; lastTickChars: number; last: { state: GeneralCueState; reason: string } | null }
392
+ interface Run { stage: number; usd: number }
393
+ export interface GeneralTickResult { ran: boolean; why?: string; sessionId?: string; outcome?: GeneralTickRow['outcome']; reason?: string }
394
+
395
+ export class GeneralCueRunner {
396
+ private readonly store: GeneralCueStore
397
+ private readonly seen = new Map<string, SeenSession>()
398
+ private inFlight: { sessionId: string; abort: AbortController } | null = null
399
+ /** Normalized queries used by one meeting (its whole life) and by any meeting in the last 30 minutes. In memory only: the
400
+ * query text is never written to the state file. */
401
+ private readonly meetingQueries = new Map<string, Set<string>>()
402
+ private readonly crossQueries = new Map<string, number>()
403
+ private readonly skipTracedAt = new Map<string, number>()
404
+ private timer: ReturnType<typeof setInterval> | null = null
405
+
406
+ constructor(private readonly host: GeneralCueHost, private readonly settings: GeneralCueSettingsReader, private readonly deps: GeneralCueDeps) {
407
+ this.store = new GeneralCueStore(deps.statePath, () => deps.now())
408
+ }
409
+
410
+ // ── Public surface ──
411
+ /** For the settings view (registered with setGeneralCuesTodayProvider by the engine wiring). */
412
+ generalCuesToday(): GeneralCuesToday { return this.store.today() }
413
+
414
+ /** The per-meeting status line for Control (CONTRACT section 4), or null when the host does not know the meeting. */
415
+ status(sessionId: string): GeneralCueStatus | null {
416
+ const c = this.host.candidates().find(x => x.sessionId === sessionId)
417
+ if (!c) return null
418
+ const counts = this.store.meeting(sessionId)
419
+ const base = { cues: counts?.cues ?? 0, attempts: counts?.attempts ?? 0 }
420
+ const el = generalEligibility(c, this.settings.cueMode(), this.settings.publicTopics())
421
+ if (!el.ok) return { state: el.state, reason: el.reason, ...base }
422
+ const block = this.blocked(sessionId, this.settings.limits(), this.deps.now())
423
+ if (block) return { state: block.state, reason: block.reason, ...base }
424
+ const last = this.seen.get(sessionId)?.last
425
+ return last ? { state: last.state, reason: last.reason, ...base } : { state: 'waiting', ...base }
426
+ }
427
+
428
+ /** Aborts the attempt in flight for this meeting (end, pause, suspend). Returns whether one was running. */
429
+ abortSession(sessionId: string, reason = 'session_ended'): boolean {
430
+ if (this.inFlight?.sessionId !== sessionId) return false
431
+ this.inFlight.abort.abort(reason)
432
+ return true
433
+ }
434
+ /** Aborts whatever is in flight (v2 switched off, shutdown). */
435
+ abortAll(reason = 'suspended'): boolean {
436
+ if (!this.inFlight) return false
437
+ this.inFlight.abort.abort(reason)
438
+ return true
439
+ }
440
+ /** The meeting ended: abort its attempt and forget what the runner holds for it. */
441
+ endSession(sessionId: string): void {
442
+ this.abortSession(sessionId, 'session_ended')
443
+ this.seen.delete(sessionId)
444
+ this.meetingQueries.delete(sessionId)
445
+ }
446
+ /** Call after the cue mode is saved: leaving general mode aborts the attempt in flight at once. */
447
+ modeChanged(): void { if (!generalModeOn(this.settings.cueMode())) this.abortAll('mode_changed') }
448
+
449
+ start(): void {
450
+ if (this.timer) return
451
+ this.timer = setInterval(() => { void this.tick().catch(() => {}) }, LIVE_CUES_GENERAL_LIMITS.pollMs)
452
+ this.timer.unref?.()
453
+ }
454
+ stop(): void {
455
+ if (this.timer) clearInterval(this.timer)
456
+ this.timer = null
457
+ this.abortAll('stopped')
458
+ }
459
+
460
+ // ── Tick ──
461
+ /** One pass over the meetings: starts at most ONE attempt, for the meeting that has waited longest. A host that throws ends the pass. */
462
+ async tick(): Promise<GeneralTickResult> {
463
+ const nowMs = this.deps.now()
464
+ const mode = this.settings.cueMode()
465
+ if (!generalModeOn(mode)) { this.abortAll('mode_changed'); this.seen.clear(); return { ran: false, why: 'mode_off' } }
466
+ if (this.inFlight && !this.settings.publicTopics()) this.abortAll('consent_off') // consent withdrawn: stop what is running
467
+ if (this.inFlight) return { ran: false, why: 'busy' }
468
+ const lim = this.settings.limits(), publicTopics = this.settings.publicTopics()
469
+ let candidates: GeneralCueCandidate[]
470
+ try { candidates = this.host.candidates() } catch { return { ran: false, why: 'host_error' } }
471
+ const present = new Set(candidates.map(c => c.sessionId))
472
+ for (const id of [...this.seen.keys()]) if (!present.has(id)) this.seen.delete(id)
473
+ for (const id of [...this.meetingQueries.keys()]) if (!present.has(id)) this.meetingQueries.delete(id)
474
+ const due: GeneralCueCandidate[] = []
475
+ for (const c of candidates) {
476
+ if (!generalEligibility(c, mode, publicTopics).ok) { this.seen.delete(c.sessionId); continue }
477
+ let rec = this.seen.get(c.sessionId)
478
+ if (!rec) { rec = { firstSeenAt: nowMs, lastTickAt: null, lastTickChars: 0, last: null }; this.seen.set(c.sessionId, rec) }
479
+ if (nowMs - (rec.lastTickAt ?? rec.firstSeenAt) < lim.intervalSeconds * 1000) continue
480
+ if (c.transcriptChars - rec.lastTickChars < LIVE_CUES_GENERAL_LIMITS.minNewTranscriptChars) continue
481
+ due.push(c)
482
+ }
483
+ due.sort((a, b) => (this.seen.get(a.sessionId)!.lastTickAt ?? 0) - (this.seen.get(b.sessionId)!.lastTickAt ?? 0))
484
+ for (const c of due) {
485
+ const block = this.blocked(c.sessionId, lim, nowMs)
486
+ if (block) { this.traceSkip(c.sessionId, block.reason, nowMs); continue }
487
+ return this.attempt(c, lim, nowMs)
488
+ }
489
+ return { ran: false, why: due.length ? 'blocked' : 'not_due' }
490
+ }
491
+
492
+ /** Why no attempt may start for this meeting now, or null. Read before the Jev trigger, so a capped meeting costs nothing. */
493
+ private blocked(sessionId: string, lim: GeneralCueLimits, nowMs: number): { state: 'capped' | 'breaker'; reason: string } | null {
494
+ const r = this.store.read()
495
+ if (!r.ok) return { state: 'capped', reason: 'state_unreadable' }
496
+ const s = r.state
497
+ if (s.breaker.openUntil !== null && s.breaker.openUntil > nowMs) return { state: 'breaker', reason: 'breaker_open' }
498
+ if (s.triggerCalls >= dailyTriggerCap(this.deps.env)) return { state: 'capped', reason: 'daily_trigger_cap' }
499
+ if (s.topicCalls >= lim.dailyTopicCalls) return { state: 'capped', reason: 'daily_topic_cap' }
500
+ if (s.webSearches >= lim.dailyWebSearches) return { state: 'capped', reason: 'daily_search_cap' }
501
+ const m = s.meetings[sessionId]
502
+ if (m && m.attempts >= lim.attemptsPerMeeting) return { state: 'capped', reason: 'meeting_attempts_cap' }
503
+ if (m && m.cues >= lim.maxCuesPerMeeting) return { state: 'capped', reason: 'meeting_cues_cap' }
504
+ return null
505
+ }
506
+
507
+ private traceSkip(sessionId: string, reason: string, nowMs: number): void {
508
+ const key = `${sessionId}:${reason}`
509
+ if (nowMs - (this.skipTracedAt.get(key) ?? 0) < LIVE_CUES_GENERAL_LIMITS.skipTraceEveryMs) return
510
+ this.skipTracedAt.set(key, nowMs)
511
+ this.writeTrace({ type: 'general_tick', at: new Date(nowMs).toISOString(), sessionId, pGeneral: null, outcome: 'skip', reason, category: null, queryHash: null,
512
+ searchMs: null, sourcesCount: null, writerMs: null, usd: null, jevTokens: null })
513
+ }
514
+ private writeTrace(row: GeneralTickRow): void { try { this.deps.trace?.(row) } catch { /* the trace is best effort */ } }
515
+
516
+ private recentQueries(sessionId: string, nowMs: number): Set<string> {
517
+ for (const [q, at] of this.crossQueries) if (nowMs - at >= LIVE_CUES_GENERAL_LIMITS.queryCacheMs) this.crossQueries.delete(q)
518
+ return new Set([...this.crossQueries.keys(), ...(this.meetingQueries.get(sessionId) ?? [])])
519
+ }
520
+ private noteQuery(sessionId: string, normalized: string, nowMs: number): void {
521
+ this.crossQueries.set(normalized, nowMs)
522
+ const set = this.meetingQueries.get(sessionId) ?? new Set<string>()
523
+ set.add(normalized)
524
+ this.meetingQueries.set(sessionId, set)
525
+ }
526
+
527
+ /** Public topics consent, right now. Checked on its own right before the search and before emit: if it was withdrawn the attempt
528
+ * is stopped with the reason consent_off, not left to finish. */
529
+ private consentOk(): boolean { return this.settings.publicTopics() === true }
530
+ /** Consent was withdrawn mid-attempt: abort the attempt with that reason (it ends as a cancel, never a failure). */
531
+ private consentWithdrawn(): never {
532
+ this.inFlight?.abort.abort('consent_off')
533
+ throw abortError()
534
+ }
535
+ /** Is the meeting still eligible right now (session still there, mode and consent unchanged)? Fresh from the host. */
536
+ private live(sessionId: string): boolean {
537
+ const c = this.host.candidates().find(x => x.sessionId === sessionId)
538
+ return !!c && generalEligibility(c, this.settings.cueMode(), this.settings.publicTopics()).ok
539
+ }
540
+
541
+ // ── One attempt ──
542
+ private async attempt(c: GeneralCueCandidate, lim: GeneralCueLimits, startedAt: number): Promise<GeneralTickResult> {
543
+ const rec = this.seen.get(c.sessionId)!
544
+ rec.lastTickAt = startedAt
545
+ rec.lastTickChars = c.transcriptChars
546
+ rec.last = null
547
+ const abort = new AbortController()
548
+ this.inFlight = { sessionId: c.sessionId, abort } // synchronously: nothing awaits between the busy check and here
549
+ const run: Run = { stage: 0, usd: 0 }
550
+ const row: GeneralTickRow = { type: 'general_tick', at: new Date(startedAt).toISOString(), sessionId: c.sessionId, pGeneral: null, outcome: 'no_cue', reason: '',
551
+ category: null, queryHash: null, searchMs: null, sourcesCount: null, writerMs: null, usd: null, jevTokens: null }
552
+ const wall = setTimeout(() => abort.abort(WALL), LIVE_CUES_GENERAL_LIMITS.attemptWallMs)
553
+ wall.unref?.()
554
+ try {
555
+ await this.pipeline(c, lim, abort.signal, row, run, rec, startedAt)
556
+ } catch (error) {
557
+ this.failed(error, abort.signal, row, run)
558
+ } finally {
559
+ clearTimeout(wall)
560
+ this.inFlight = null
561
+ if (run.usd > 0) row.usd = round4(run.usd)
562
+ this.writeTrace(row)
563
+ }
564
+ return { ran: true, sessionId: c.sessionId, outcome: row.outcome, reason: row.reason }
565
+ }
566
+
567
+ private done(row: GeneralTickRow, outcome: 'cue' | 'no_cue', reason: string, depth = 0): void {
568
+ row.outcome = outcome
569
+ row.reason = reason
570
+ const held = heldLookupReason(reason)
571
+ if (held) webLedger().withheld('cues', held)
572
+ if (depth > 0) this.store.recordClean(depth)
573
+ }
574
+ private fail(row: GeneralTickRow, stage: Stage, reason: string, immediate = false): void {
575
+ row.outcome = 'failure'
576
+ row.reason = reason
577
+ this.store.recordFailure(stage, immediate)
578
+ }
579
+ private failed(error: unknown, signal: AbortSignal, row: GeneralTickRow, run: Run): void {
580
+ const stage = STAGES[Math.max(1, run.stage) - 1]
581
+ if (signal.aborted && signal.reason === WALL) return this.fail(row, stage, 'wall_timeout')
582
+ if (isCancel(error, signal)) { row.outcome = 'cancelled'; row.reason = typeof signal.reason === 'string' ? signal.reason : 'aborted'; return }
583
+ if ((error instanceof JevError || error instanceof DecisionProviderError) && QUIET_DECISION_CODES.has(error.code)) { row.outcome = 'no_cue'; row.reason = error.code; return }
584
+ if (error instanceof LensGistLimitError) return this.fail(row, stage, 'quota', true)
585
+ const code = error instanceof JevError || error instanceof DecisionProviderError ? error.code : 'error'
586
+ this.fail(row, stage, `${stage}_failed:${code}`)
587
+ }
588
+
589
+ private async pipeline(c: GeneralCueCandidate, lim: GeneralCueLimits, signal: AbortSignal, row: GeneralTickRow, run: Run, rec: SeenSession, startedAt: number): Promise<void> {
590
+ const profile = this.deps.profile ?? realProfileNames
591
+ const window = redactSecrets(c.window(LIVE_CUES_GENERAL_LIMITS.topicWindowChars))
592
+
593
+ // 1. Deterministic skip, before any model call: a window with private-life vocabulary never leaves the machine. (The owner's
594
+ // vocabulary does not skip a window: it guards the query, as whole terms, in admission.)
595
+ const sensitive = sensitiveWindow(window)
596
+ if (sensitive) { rec.last = { state: 'withheld', reason: 'sensitive_window' }; return this.done(row, 'no_cue', `sensitive:${sensitive}`) }
597
+
598
+ // 1b. The names a query must not carry, read now, before any spend: the owner, every enrolled voice, the speakers the ASR path
599
+ // reported, the calendar title's capitalized words and the speaker labels in the window. If the voice names cannot be read
600
+ // the meeting is withheld (fail closed), not searched with fewer names.
601
+ let nameWords: Set<string>
602
+ try { nameWords = await raceAbort(blockedNameWords(window, c.speakerNames ?? [], profile), signal) } catch (error) {
603
+ if (isCancel(error, signal)) throw error
604
+ rec.last = { state: 'withheld', reason: 'names_unavailable' }
605
+ return this.done(row, 'no_cue', 'names_unavailable')
606
+ }
607
+
608
+ // 2. Trigger (Jev), under its own daily cap, charged before the request.
609
+ const triggered = this.store.chargeTrigger(dailyTriggerCap(this.deps.env))
610
+ if (triggered !== 'ok') return this.done(row, 'no_cue', `cap:trigger_${triggered}`)
611
+ run.stage = 1
612
+ const trigger = await raceAbort(this.deps.jevTrigger(c.title, window, signal), signal)
613
+ row.jevTokens = Number.isFinite(trigger.tokens) ? trigger.tokens : null
614
+ row.pGeneral = Number.isFinite(trigger.pGeneral) ? Math.round(trigger.pGeneral * 1000) / 1000 : 0
615
+ if (!(trigger.pGeneral >= lim.trigger)) return this.done(row, 'no_cue', 'trigger_below', 1)
616
+
617
+ // 3. Topic (pinned Haiku). The call is charged first, in the durable file, and a refusal to charge ends the attempt.
618
+ const charged = this.store.chargeTopic(c.sessionId, { daily: lim.dailyTopicCalls, perMeeting: lim.attemptsPerMeeting })
619
+ if (charged !== 'ok') return this.done(row, 'no_cue', `cap:${charged}`)
620
+ run.stage = 2
621
+ const topicOut = await raceAbort(this.deps.topicModel(GENERAL_TOPIC_SYSTEM, generalTopicPrompt(window), signal), signal)
622
+ run.usd += topicOut.costUsd ?? 0
623
+ const topic = parseGeneralTopic(topicOut.text)
624
+ if (topic === 'unparseable') return this.done(row, 'no_cue', 'topic_unparseable', 2)
625
+ if (!topic.topic) return this.done(row, 'no_cue', 'no_topic', 2)
626
+ row.category = topic.category
627
+
628
+ // 4. Admission (deterministic), then the home area for a local query, admitted again as the new string it is.
629
+ const ctx = { nameWords, terms: ownerTermPhrases(profile), joins: ownerTermJoins(profile), recent: this.recentQueries(c.sessionId, startedAt) }
630
+ /** A trace row carries a hash only for a rejection that names nothing private: a repeat or a length. */
631
+ const hashFor = (reason: AdmitReason, q: string): string | null => HASHABLE_REJECTIONS.has(reason) ? queryHash(q) : null
632
+ let admitted = admitCueTopic(topic.query, ctx)
633
+ if (!admitted.ok) { row.queryHash = hashFor(admitted.reason, topic.query); return this.done(row, 'no_cue', `rejected:${admitted.reason}`, 2) }
634
+ if (topic.category === 'local') {
635
+ const withArea = withHomeArea(admitted.query, this.settings.homeArea())
636
+ if (withArea === null) { row.queryHash = queryHash(admitted.query); return this.done(row, 'no_cue', 'local_no_area', 2) }
637
+ const again = admitCueTopic(withArea, ctx)
638
+ if (!again.ok) { row.queryHash = hashFor(again.reason, withArea); return this.done(row, 'no_cue', `rejected_local:${again.reason}`, 2) }
639
+ admitted = again
640
+ }
641
+ row.queryHash = queryHash(admitted.query)
642
+
643
+ // 5. Search. The room, the mode or the consent may have changed during the topic call; the daily search is charged before it starts.
644
+ if (!this.consentOk()) return this.consentWithdrawn()
645
+ if (!this.live(c.sessionId)) return this.done(row, 'no_cue', 'not_eligible')
646
+ const paid = this.store.chargeSearch(lim.dailyWebSearches)
647
+ if (paid !== 'ok') return this.done(row, 'no_cue', `cap:${paid}`)
648
+ this.noteQuery(c.sessionId, admitted.normalized, startedAt)
649
+ run.stage = 3
650
+ const t0 = this.deps.now()
651
+ const found = await typedGeneralSearch(this.deps.search, admitted.query, signal)
652
+ row.searchMs = this.deps.now() - t0
653
+ if (!found.ok) {
654
+ if (found.code === 'aborted') throw abortError()
655
+ return this.fail(row, 'search', `search_${found.code}`)
656
+ }
657
+ row.sourcesCount = found.sources.length
658
+
659
+ // 6. Writer. The context is the tail of the window that passed the sensitive check, not a fresh read of the room: what the
660
+ // writer is sent was checked, only for relevance.
661
+ run.stage = 4
662
+ const t1 = this.deps.now()
663
+ const writerOut = await raceAbort(this.deps.writer(GENERAL_INSIGHT_WRITER_SYSTEM, generalInsightPrompt({ query: admitted.query, category: topic.category, sources: found.sources,
664
+ context: window.slice(-GENERAL_INSIGHT_CONTEXT_CHARS) }), signal), signal)
665
+ row.writerMs = this.deps.now() - t1
666
+ run.usd += writerOut.costUsd ?? 0
667
+ const insight = parseGeneralInsight(writerOut.text, found.sources)
668
+ if (typeof insight === 'string') return this.done(row, 'no_cue', `writer_${insight}`, 4)
669
+
670
+ // 7. Emit, only if it is still fresh and the meeting is still eligible. A late cue is dropped, never shown.
671
+ if (this.deps.now() - startedAt > LIVE_CUES_GENERAL_LIMITS.staleMs) return this.done(row, 'no_cue', 'stale', 4)
672
+ if (!this.consentOk()) return this.consentWithdrawn()
673
+ if (!this.live(c.sessionId)) return this.done(row, 'no_cue', 'not_eligible', 4)
674
+ const day = new Date(this.deps.now()).toLocaleDateString('en-CA')
675
+ const cited = insight.sourceIds.map(id => found.sources.find(s => s.id === id)!)
676
+ // A web source's id is opaque and stable per page, like a past meeting's: the same url gets the same id in every cue.
677
+ const emitted = this.host.emit(c.sessionId, { headline: insight.headline, detail: insight.detail, category: topic.category, query: admitted.query,
678
+ sources: cited.map(s => ({ id: `web_${createHash('sha256').update(s.url).digest('hex').slice(0, 12)}`, date: day, title: s.title, kind: 'web' as const, url: s.url })),
679
+ gate: row.pGeneral ?? 0, ...(writerOut.model ? { model: writerOut.model } : {}), windowAt: startedAt, createdAt: this.deps.now() })
680
+ if (!emitted) return this.done(row, 'no_cue', 'not_emitted', 4)
681
+ this.store.recordCue(c.sessionId)
682
+ this.done(row, 'cue', 'emitted', 4)
683
+ }
684
+ }
685
+
686
+ // ── Real model calls (the engine wiring in phase 2 composes these) ──────────
687
+ function modelOut(run: Awaited<ReturnType<typeof runLensGistEngine>>): GeneralModelOut {
688
+ return { text: run.text, costUsd: run.usage?.costUsd, inputTokens: run.usage?.inputTokens, outputTokens: run.usage?.outputTokens, model: run.model }
689
+ }
690
+ /** The topic call: the existing claude runner, model pinned to Haiku, audit source live-cues-general-topic. */
691
+ export async function runGeneralTopicModel(system: string, prompt: string, signal: AbortSignal): Promise<GeneralModelOut> {
692
+ return modelOut(await runLensGistEngine('claude', GENERAL_TOPIC_MODEL, prompt, { system, timeoutMs: LIVE_CUES_GENERAL_LIMITS.modelTimeoutMs,
693
+ source: 'live-cues-general-topic', maxOutputTokens: LIVE_CUES_GENERAL_LIMITS.topicMaxOutputTokens, signal }))
694
+ }
695
+ /** The writer call: the same runner and writer model as the focused cues (liveCuesV2Model), audit source live-cues-general. */
696
+ export async function runGeneralWriterModel(model: string, system: string, prompt: string, signal: AbortSignal): Promise<GeneralModelOut> {
697
+ return modelOut(await runLensGistEngine('claude', model, prompt, { system, timeoutMs: LIVE_CUES_GENERAL_LIMITS.modelTimeoutMs,
698
+ source: 'live-cues-general', maxOutputTokens: LIVE_CUES_GENERAL_LIMITS.writerMaxOutputTokens, signal }))
699
+ }
700
+ /** The search, ledgered as live_cues_general in the token audit. */
701
+ export function runGeneralSearch(query: string, signal: AbortSignal): ReturnType<GeneralCueDeps['search']> {
702
+ return searchMeetingWeb(query, signal, { auditCaller: 'live_cues_general' })
703
+ }
704
+ /** Real dependencies. The engine passes its Jev/SubQ trigger and its writer model; nothing here imports the engine. */
705
+ export function realGeneralDeps(parts: { jevTrigger: GeneralCueDeps['jevTrigger']; writerModel: () => string; statePath: string; trace?: GeneralCueDeps['trace']; now?: () => number }): GeneralCueDeps {
706
+ return {
707
+ jevTrigger: parts.jevTrigger,
708
+ topicModel: runGeneralTopicModel,
709
+ search: runGeneralSearch,
710
+ writer: (system, prompt, signal) => runGeneralWriterModel(parts.writerModel(), system, prompt, signal),
711
+ now: parts.now ?? (() => Date.now()),
712
+ statePath: parts.statePath,
713
+ ...(parts.trace ? { trace: parts.trace } : {}),
714
+ }
715
+ }