@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.
- package/CHANGELOG.md +166 -0
- package/README.md +4 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +12 -0
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +44 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +763 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +572 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +46 -27
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-speaker-review.ts +187 -21
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- package/server/lib/session-find-ask.ts +288 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-shadow-decide.ts +59 -22
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +2 -2
- package/server/lib/speaker-shadow-worker.ts +50 -15
- package/server/lib/speaker-shadow.ts +80 -19
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +10 -5
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +515 -61
- package/server/routes/session-find.ts +73 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +28 -18
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- package/shared/meeting-ask.ts +11 -0
|
@@ -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
|
+
}
|