@gotcos/glasses-server 6.36.28 → 6.37.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.
@@ -0,0 +1,429 @@
1
+ // Standalone meeting enrichment — summary / topics / decisions / action items.
2
+ //
3
+ // WHY THIS EXISTS
4
+ // Summary, topics, decisions and actions are normally produced by
5
+ // sync_meetings.py, spawned through g2-ops-handoff. A standalone user has no
6
+ // COS_SCRIPTS_DIR, no Python, and no sync_meetings.py, so that pipeline never
7
+ // runs and MeetingStore.save writes only a placeholder. This module is the
8
+ // standalone-only replacement.
9
+ //
10
+ // TWO TIERS
11
+ // extractiveMeetingSummary() — deterministic, zero tokens, always available.
12
+ // Reports what the transcript literally shows:
13
+ // speakers, turns, talk-time, opening excerpt.
14
+ // It does NOT invent topics or decisions.
15
+ // llmMeetingSummary() — `claude -p` over the transcript. Default OFF.
16
+ // Falls back to the extractive tier on every
17
+ // failure path.
18
+ //
19
+ // NEVER RUNS FOR OPERATIONS USERS. Overwriting the summary section would erase
20
+ // the two markers sync_meetings.py:925-926 gates on
21
+ // ('summary pending pipeline processing' and 'g2-needs-domain-review'), and the
22
+ // meeting would be skipped by the pipeline entirely — permanent loss of domain
23
+ // reclassification, task extraction, and the operations copy. The caller gates
24
+ // on cosOpsPipelineConfigured(); this module refuses independently as defence
25
+ // in depth.
26
+
27
+ import { spawnClaudeText } from './prompt-edit.js'
28
+ import { terminalProviderAuthFailure } from './provider-terminal-error.js'
29
+ import { createBreaker } from './claude-circuit.js'
30
+ import {
31
+ meetingSummaryBudgetAvailable,
32
+ commitMeetingSummaryCall,
33
+ } from './meeting-summary-budget.js'
34
+
35
+ export interface MeetingActionItemDraft {
36
+ task: string
37
+ owner: string
38
+ }
39
+
40
+ export interface MeetingSummaryResult {
41
+ summary: string
42
+ topics: string[]
43
+ decisions: string[]
44
+ actionItems: MeetingActionItemDraft[]
45
+ /** Which tier produced this. Surfaced so no caller can present a mechanical
46
+ * record as though it were an abstractive summary. */
47
+ tier: 'extractive' | 'llm'
48
+ }
49
+
50
+ // ── Bounds ──────────────────────────────────────────────────
51
+
52
+ /** Below this the recording is too short to enrich; spend nothing. */
53
+ export const MIN_SUMMARY_WORDS = 40
54
+
55
+ /** Input bound for the LLM tier. Head+tail, never head-only: a head-only
56
+ * truncation of a long meeting silently discards every decision made in the
57
+ * last two thirds and still reads as confident and complete. */
58
+ export const MAX_SUMMARY_INPUT_CHARS = 24_000
59
+ const HEAD_FRACTION = 0.6
60
+ const ELISION = '\n\n[... middle of transcript omitted for length ...]\n\n'
61
+
62
+ /** Hard wall for one LLM summary call. */
63
+ export const SUMMARY_WALL_MS = 45_000
64
+
65
+ /** Below this much remaining budget the LLM tier is skipped entirely — a call
66
+ * that cannot finish inside the window must not be started. */
67
+ export const MIN_SUMMARY_WALL_MS = 15_000
68
+
69
+ /** Total wall budget for the whole finalization job (batch decode + handoff +
70
+ * this summariser). COS Control's waitForRestartProof defaults to 90s
71
+ * (cos-control-macos/HelperSources/main.swift:2645); a drain that catches
72
+ * finalization in flight past that hard-fails to Repair. 75s leaves 15s of
73
+ * margin for lease release and proof publication. */
74
+ export const FINALIZATION_WALL_BUDGET_MS = 75_000
75
+
76
+ const MAX_TOPICS = 8
77
+ const MAX_DECISIONS = 8
78
+ const MAX_ACTIONS = 10
79
+ const MAX_SUMMARY_CHARS = 1_200
80
+
81
+ const breaker = createBreaker({ label: 'meeting-summary', maxFailures: 2 })
82
+
83
+ /** Exported for tests. */
84
+ export const meetingSummaryBreaker = breaker
85
+
86
+ // ── Flag ────────────────────────────────────────────────────
87
+
88
+ /** Read LIVE, not at module load. COS Control rebuilds the runtime plist
89
+ * environment on update, and tests flip this per-case; a module-scope const
90
+ * would freeze the value at import and make both untestable.
91
+ *
92
+ * DEFAULT ON since 6.37.0 (Miles 2026-08-25). Shipping this off meant the
93
+ * headline fix — a meeting that comes back with a real summary — reached
94
+ * nobody unless they found an undocumented env var.
95
+ *
96
+ * Unlike the display toggles that flipped on in the same release, this one
97
+ * spends tokens on the USER'S OWN provider plan, which is why every control in
98
+ * this module is load-bearing: a per-day cap committed only on a validated
99
+ * result, a breaker with its own accounting, a minimum word floor, a
100
+ * single-slot queue, and a wall budget. Only a literal '0' disables. */
101
+ export function meetingSummaryLLMEnabled(): boolean {
102
+ return process.env.COS_MEETING_SUMMARY?.trim() !== '0'
103
+ }
104
+
105
+ // ── Transcript parsing ──────────────────────────────────────
106
+
107
+ export interface TranscriptTurn {
108
+ speaker: string
109
+ text: string
110
+ }
111
+
112
+ /** Parse `[Speaker]: text` turns. Lines without a speaker prefix are appended
113
+ * to the previous turn so a wrapped line does not become its own turn. */
114
+ export function parseTranscriptTurns(transcript: string): TranscriptTurn[] {
115
+ const turns: TranscriptTurn[] = []
116
+ for (const line of transcript.split('\n')) {
117
+ const match = line.match(/^\s*\[([^\]]{1,60})\]:\s*(.*)$/)
118
+ if (match) {
119
+ turns.push({ speaker: match[1].trim(), text: match[2].trim() })
120
+ continue
121
+ }
122
+ const trimmed = line.trim()
123
+ if (!trimmed) continue
124
+ if (turns.length > 0) {
125
+ turns[turns.length - 1].text = `${turns[turns.length - 1].text} ${trimmed}`.trim()
126
+ } else {
127
+ turns.push({ speaker: '', text: trimmed })
128
+ }
129
+ }
130
+ return turns.filter(turn => turn.text.length > 0)
131
+ }
132
+
133
+ export function countTranscriptWords(transcript: string): number {
134
+ return transcript.split(/\s+/).filter(Boolean).length
135
+ }
136
+
137
+ // ── Tier 1: deterministic ───────────────────────────────────
138
+
139
+ /**
140
+ * A factual record of what the transcript contains. Deliberately makes no
141
+ * claim it cannot support: topics, decisions and action items come back EMPTY
142
+ * because no deterministic method extracts them reliably, and a fabricated
143
+ * list is worse than an absent one.
144
+ */
145
+ export function extractiveMeetingSummary(
146
+ transcript: string,
147
+ opts: { durationMinutes?: number } = {},
148
+ ): MeetingSummaryResult {
149
+ const turns = parseTranscriptTurns(transcript)
150
+ const words = countTranscriptWords(transcript)
151
+
152
+ const wordsBySpeaker = new Map<string, number>()
153
+ for (const turn of turns) {
154
+ if (!turn.speaker) continue
155
+ wordsBySpeaker.set(
156
+ turn.speaker,
157
+ (wordsBySpeaker.get(turn.speaker) ?? 0) + countTranscriptWords(turn.text),
158
+ )
159
+ }
160
+ const speakers = [...wordsBySpeaker.entries()].sort((a, b) => b[1] - a[1])
161
+
162
+ const parts: string[] = []
163
+ if (opts.durationMinutes && opts.durationMinutes > 0) {
164
+ parts.push(`${opts.durationMinutes}-minute recording`)
165
+ } else {
166
+ parts.push('Recording')
167
+ }
168
+ parts.push(`${words.toLocaleString()} words`)
169
+ if (speakers.length > 0) {
170
+ const roster = speakers
171
+ .map(([name, count]) => `${name} (${Math.round((count / Math.max(1, words)) * 100)}%)`)
172
+ .join(', ')
173
+ parts.push(`${speakers.length} speaker${speakers.length === 1 ? '' : 's'}: ${roster}`)
174
+ }
175
+
176
+ const opening = turns
177
+ .slice(0, 3)
178
+ .map(turn => (turn.speaker ? `${turn.speaker}: ${turn.text}` : turn.text))
179
+ .join(' ')
180
+ .slice(0, 300)
181
+ .trim()
182
+
183
+ const summary = opening
184
+ ? `${parts.join('. ')}.\n\nOpening: ${opening}${opening.length >= 300 ? '…' : ''}`
185
+ : `${parts.join('. ')}.`
186
+
187
+ return {
188
+ summary,
189
+ topics: [],
190
+ decisions: [],
191
+ actionItems: [],
192
+ tier: 'extractive',
193
+ }
194
+ }
195
+
196
+ /** Speaker roster, for the Attendees section. Deterministic either tier. */
197
+ export function transcriptSpeakers(transcript: string): string[] {
198
+ const seen: string[] = []
199
+ for (const turn of parseTranscriptTurns(transcript)) {
200
+ if (turn.speaker && !seen.includes(turn.speaker)) seen.push(turn.speaker)
201
+ }
202
+ return seen.slice(0, 20)
203
+ }
204
+
205
+ // ── Tier 2: LLM ─────────────────────────────────────────────
206
+
207
+ /** Head+tail bound that never splits a UTF-16 surrogate pair. */
208
+ export function boundTranscriptForSummary(transcript: string): {
209
+ text: string
210
+ truncated: boolean
211
+ } {
212
+ if (transcript.length <= MAX_SUMMARY_INPUT_CHARS) {
213
+ return { text: transcript, truncated: false }
214
+ }
215
+ const budget = MAX_SUMMARY_INPUT_CHARS - ELISION.length
216
+ let headEnd = Math.floor(budget * HEAD_FRACTION)
217
+ let tailStart = transcript.length - (budget - headEnd)
218
+ // Never cut between a surrogate pair (emoji, some CJK extensions).
219
+ if (isLowSurrogate(transcript.charCodeAt(headEnd))) headEnd -= 1
220
+ if (isLowSurrogate(transcript.charCodeAt(tailStart))) tailStart += 1
221
+ return {
222
+ text: `${transcript.slice(0, headEnd)}${ELISION}${transcript.slice(tailStart)}`,
223
+ truncated: true,
224
+ }
225
+ }
226
+
227
+ function isLowSurrogate(code: number): boolean {
228
+ return code >= 0xdc00 && code <= 0xdfff
229
+ }
230
+
231
+ export function buildSummaryPrompt(transcript: string, truncated: boolean): string {
232
+ return [
233
+ 'You are summarising a meeting transcript for the person who recorded it.',
234
+ '',
235
+ 'Rules:',
236
+ '- Reply with ONLY a JSON object. No prose, no code fence.',
237
+ '- Shape: {"summary": string, "topics": string[], "decisions": string[], "actionItems": [{"task": string, "owner": string}]}',
238
+ '- "summary" is 2-4 sentences of what actually happened.',
239
+ '- Use an empty array when the transcript does not support that field. Never invent a decision or an action item.',
240
+ // Speaker labels in a standalone transcript are diarisation guesses
241
+ // ("Speaker 2", "Ext") and are corrected later by the speaker-review flow,
242
+ // which deliberately never rewrites prose (meeting-corrections.ts:19-26).
243
+ // A generated summary naming a speaker would keep the wrong name forever.
244
+ '- Do NOT attribute statements to a speaker by name or label. Describe what was discussed, not who said it.',
245
+ '- "owner" must be a person NAMED ALOUD in the transcript. Diarisation labels'
246
+ + ' ("Speaker 2", "Ext", "MU", "Me", "Unknown") are placeholders, not names —'
247
+ + ' use "" for owner in that case.',
248
+ '- Write in the same language as the transcript.',
249
+ truncated
250
+ ? '- The middle of this transcript was omitted for length. Say so in the summary rather than implying full coverage.'
251
+ : '',
252
+ '',
253
+ 'Transcript:',
254
+ transcript,
255
+ ]
256
+ .filter(Boolean)
257
+ .join('\n')
258
+ }
259
+
260
+ /** Diarisation placeholders. These are corrected later by the speaker-review
261
+ * flow, which never rewrites the enrichment sections — so a label captured as
262
+ * an action-item owner would stay wrong permanently. Drop it instead. */
263
+ const DIARISATION_LABEL = /^(?:speaker\s*\d+|ext(?:ernal)?|unknown|me|mu)$/i
264
+
265
+ export function isDiarisationLabel(value: string): boolean {
266
+ return DIARISATION_LABEL.test(value.trim())
267
+ }
268
+
269
+ function asStringArray(value: unknown, limit: number): string[] {
270
+ if (!Array.isArray(value)) return []
271
+ return value
272
+ .filter((item): item is string => typeof item === 'string')
273
+ .map(item => item.trim())
274
+ .filter(Boolean)
275
+ .slice(0, limit)
276
+ }
277
+
278
+ /** Strict shape validation. A malformed reply is a failure, not a partial win. */
279
+ export function parseSummaryResponse(raw: string): MeetingSummaryResult | null {
280
+ const start = raw.indexOf('{')
281
+ const end = raw.lastIndexOf('}')
282
+ if (start === -1 || end <= start) return null
283
+ let parsed: unknown
284
+ try {
285
+ parsed = JSON.parse(raw.slice(start, end + 1))
286
+ } catch {
287
+ return null
288
+ }
289
+ if (!parsed || typeof parsed !== 'object') return null
290
+ const record = parsed as Record<string, unknown>
291
+ const summary = typeof record.summary === 'string' ? record.summary.trim() : ''
292
+ if (!summary || summary.length > MAX_SUMMARY_CHARS) return null
293
+
294
+ const actionItems = Array.isArray(record.actionItems)
295
+ ? record.actionItems
296
+ .filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
297
+ .map(item => {
298
+ const owner = typeof item.owner === 'string' ? item.owner.trim() : ''
299
+ return {
300
+ task: typeof item.task === 'string' ? item.task.trim() : '',
301
+ // Belt and braces: the prompt forbids these, and this drops them
302
+ // if a model returns one anyway.
303
+ owner: isDiarisationLabel(owner) ? '' : owner,
304
+ }
305
+ })
306
+ .filter(item => item.task.length > 0)
307
+ .slice(0, MAX_ACTIONS)
308
+ : []
309
+
310
+ return {
311
+ summary,
312
+ topics: asStringArray(record.topics, MAX_TOPICS),
313
+ decisions: asStringArray(record.decisions, MAX_DECISIONS),
314
+ actionItems,
315
+ tier: 'llm',
316
+ }
317
+ }
318
+
319
+ export interface LlmSummaryOptions {
320
+ durationMinutes?: number
321
+ /** Wall time still available before FINALIZATION_WALL_BUDGET_MS is spent. */
322
+ remainingWallMs?: number
323
+ signal?: AbortSignal
324
+ /** Test seam. Defaults to the real spawnClaudeText. */
325
+ spawn?: (prompt: string, opts: { model: string; timeoutMs: number; label: string; signal?: AbortSignal }) => Promise<string>
326
+ }
327
+
328
+ export interface SummaryOutcome extends MeetingSummaryResult {
329
+ /** Why the LLM tier did not run, when it did not. */
330
+ skipReason?:
331
+ | 'flag_off'
332
+ | 'too_short'
333
+ | 'budget_exhausted'
334
+ | 'breaker_open'
335
+ | 'no_wall_time'
336
+ | 'auth_required'
337
+ | 'invalid_response'
338
+ | 'call_failed'
339
+ }
340
+
341
+ /**
342
+ * Produce the best available enrichment. Always returns a usable result: the
343
+ * LLM tier when every gate passes, the deterministic tier otherwise.
344
+ */
345
+ export async function summariseMeeting(
346
+ transcript: string,
347
+ opts: LlmSummaryOptions = {},
348
+ ): Promise<SummaryOutcome> {
349
+ const fallback = extractiveMeetingSummary(transcript, {
350
+ durationMinutes: opts.durationMinutes,
351
+ })
352
+
353
+ if (!meetingSummaryLLMEnabled()) return { ...fallback, skipReason: 'flag_off' }
354
+ if (countTranscriptWords(transcript) < MIN_SUMMARY_WORDS) {
355
+ return { ...fallback, skipReason: 'too_short' }
356
+ }
357
+ if (breaker.isOpen()) return { ...fallback, skipReason: 'breaker_open' }
358
+ if (!meetingSummaryBudgetAvailable()) {
359
+ return { ...fallback, skipReason: 'budget_exhausted' }
360
+ }
361
+
362
+ const remaining = opts.remainingWallMs ?? SUMMARY_WALL_MS
363
+ if (remaining < MIN_SUMMARY_WALL_MS) return { ...fallback, skipReason: 'no_wall_time' }
364
+ const timeoutMs = Math.min(SUMMARY_WALL_MS, remaining)
365
+
366
+ const { text, truncated } = boundTranscriptForSummary(transcript)
367
+ const spawn = opts.spawn ?? ((prompt, spawnOpts) => spawnClaudeText(prompt, spawnOpts))
368
+
369
+ let raw: string
370
+ try {
371
+ raw = await spawn(buildSummaryPrompt(text, truncated), {
372
+ // Cheapest tier, and --model is ALWAYS passed by spawnClaudeText so this
373
+ // can never silently inherit an Opus session default.
374
+ model: 'haiku',
375
+ timeoutMs,
376
+ label: 'Meeting summary',
377
+ signal: opts.signal,
378
+ })
379
+ } catch (err) {
380
+ breaker.recordFailure()
381
+ console.error(`[meeting-summary] call failed: ${err instanceof Error ? err.message : String(err)}`)
382
+ return { ...fallback, skipReason: 'call_failed' }
383
+ }
384
+
385
+ // An unauthenticated CLI exits ZERO with a success-shaped payload carrying
386
+ // the bearer token (claude-bridge-auth-finalization.test.ts:173-191). Without
387
+ // this check that credential would be written into a durable meeting file.
388
+ const authFailure = terminalProviderAuthFailure('claude', raw)
389
+ if (authFailure) {
390
+ breaker.recordFailure()
391
+ console.error(`[meeting-summary] ${authFailure}`)
392
+ return { ...fallback, skipReason: 'auth_required' }
393
+ }
394
+
395
+ const parsed = parseSummaryResponse(raw)
396
+ if (!parsed) {
397
+ breaker.recordFailure()
398
+ console.error('[meeting-summary] response failed shape validation')
399
+ return { ...fallback, skipReason: 'invalid_response' }
400
+ }
401
+
402
+ // Committed only now: a failure, refusal, or malformed reply costs nothing.
403
+ commitMeetingSummaryCall()
404
+ breaker.recordSuccess()
405
+ return parsed
406
+ }
407
+
408
+ // ── Concurrency ─────────────────────────────────────────────
409
+
410
+ let summaryQueueTail: Promise<unknown> = Promise.resolve()
411
+
412
+ /**
413
+ * Serialise summary work to a single slot. resumeMeetingFinalizationJobs()
414
+ * replays EVERY retained job on boot, so without this a crash-then-restart
415
+ * would spawn one provider per pending meeting at once.
416
+ *
417
+ * Load-shedding is automatic and needs no queue limit: each caller computes
418
+ * remainingWallMs from its OWN job start, so a job that waited behind others
419
+ * arrives with too little budget and degrades to the deterministic tier
420
+ * instead of holding its maintenance lease past COS Control's drain timeout.
421
+ */
422
+ export function enqueueSummaryWork<T>(fn: () => Promise<T>): Promise<T> {
423
+ const run = summaryQueueTail.then(fn, fn)
424
+ summaryQueueTail = run.then(
425
+ () => undefined,
426
+ () => undefined,
427
+ )
428
+ return run
429
+ }
@@ -0,0 +1,89 @@
1
+ // What may become a voice-profile name.
2
+ //
3
+ // WHY THIS EXISTS (Chelsie Hodgkiss, first-time user, 2026-08-25)
4
+ // Saying "enroll my voice" and continuing to talk produced a profile whose
5
+ // NAME was the entire ~40-second transcript. Two runs, two junk profiles, one
6
+ // embedding each. Because no junk name ever equals owner_speaker_label,
7
+ // /api/voice/status reported `enrolled: false` forever, and editing
8
+ // voice-profiles.json by hand did not help — the server rewrites it from
9
+ // memory.
10
+ //
11
+ // The client bug was a $-anchored command regex falling through to a
12
+ // named-enrollment branch whose capture group had no length bound. That is
13
+ // fixed in the app (cos-glasses-app src/Main.ts). This module is the SERVER
14
+ // side of the same guard: the app ships in an EHPK on its own release train,
15
+ // so an old client must not be able to write a sentence into the profile store.
16
+ //
17
+ // Counterpart: cos-glasses-app/src/lib/speaker-name.ts — keep the rules and the
18
+ // test vectors in both repos in step.
19
+
20
+ /** Longest plausible human name we will store. */
21
+ export const MAX_SPEAKER_NAME_CHARS = 40
22
+ /** Most words a name may have ("Maria del Carmen Ruiz" is four). */
23
+ export const MAX_SPEAKER_NAME_WORDS = 4
24
+
25
+ /** Words that mean "the wearer", never a third party's name. */
26
+ const SELF_REFERENTIAL = /^(?:my|me|mine|myself|voice|voiceprint|my\s+voice|my\s+voiceprint)$/i
27
+
28
+ /** A name never BEGINS with these. "My Voice Please" is short enough and
29
+ * clean enough to pass every other rule, so the leading word is the only
30
+ * thing that gives it away. */
31
+ const LEADS_WITH_SELF = /^(?:my|me|mine|myself|voice|voiceprint)\b/i
32
+
33
+ export type SpeakerNameRejection =
34
+ | 'empty'
35
+ | 'too_long'
36
+ | 'too_many_words'
37
+ | 'sentence_like'
38
+ | 'invalid_characters'
39
+ | 'self_referential'
40
+
41
+ export interface SpeakerNameCheck {
42
+ ok: boolean
43
+ reason?: SpeakerNameRejection
44
+ /** Human-readable, safe to show on the lens. */
45
+ message?: string
46
+ }
47
+
48
+ const MESSAGES: Record<SpeakerNameRejection, string> = {
49
+ empty: 'A voice profile needs a name.',
50
+ too_long: `That name is too long (limit ${MAX_SPEAKER_NAME_CHARS} characters). It looks like speech, not a name.`,
51
+ too_many_words: `That name has too many words (limit ${MAX_SPEAKER_NAME_WORDS}). It looks like speech, not a name.`,
52
+ sentence_like: 'That looks like a sentence, not a name.',
53
+ invalid_characters: 'A name may only contain letters, spaces, hyphens and apostrophes.',
54
+ self_referential: 'Use "enroll my voice" to enrol yourself.',
55
+ }
56
+
57
+ /**
58
+ * Is this a plausible person's name for a voice profile?
59
+ *
60
+ * Deliberately strict. A false reject costs one clear error message; a false
61
+ * accept writes an unusable profile into a store the user cannot repair by
62
+ * hand.
63
+ */
64
+ export function checkSpeakerName(
65
+ raw: string | undefined | null,
66
+ opts: { ownerLabel?: string } = {},
67
+ ): SpeakerNameCheck {
68
+ const name = (raw ?? '').trim()
69
+ if (!name) return fail('empty')
70
+ // The wearer's own label MUST pass. It defaults to 'Me' (profile.ts:104),
71
+ // which is itself self-referential — without this the guard would reject the
72
+ // very self-enrolment it exists to protect.
73
+ const owner = opts.ownerLabel?.trim()
74
+ const isOwner = !!owner && name.toLowerCase() === owner.toLowerCase()
75
+ if (!isOwner && (SELF_REFERENTIAL.test(name) || LEADS_WITH_SELF.test(name))) {
76
+ return fail('self_referential')
77
+ }
78
+ if (name.length > MAX_SPEAKER_NAME_CHARS) return fail('too_long')
79
+ if (name.split(/\s+/).length > MAX_SPEAKER_NAME_WORDS) return fail('too_many_words')
80
+ // Sentence punctuation is the clearest signal that speech was captured.
81
+ if (/[.!?,;:]/.test(name)) return fail('sentence_like')
82
+ // Unicode letters, marks, spaces, hyphens, apostrophes. No digits, no symbols.
83
+ if (!/^[\p{L}\p{M}][\p{L}\p{M}\s'’-]*$/u.test(name)) return fail('invalid_characters')
84
+ return { ok: true }
85
+ }
86
+
87
+ function fail(reason: SpeakerNameRejection): SpeakerNameCheck {
88
+ return { ok: false, reason, message: MESSAGES[reason] }
89
+ }
@@ -54,7 +54,11 @@ const CLIENT_REQUEST_RE = /^[A-Za-z0-9._:-]{16,160}$/
54
54
  const MEDIA_ID_RE = /^m_[0-9a-f]{24}$/
55
55
 
56
56
  export function videoUploadV2Enabled(): boolean {
57
- return process.env.COS_VIDEO_UPLOAD_V2 === '1'
57
+ // Default ON since 6.37.0 (Miles 2026-08-25). Absent key = on; only a
58
+ // literal '0' disables. NOTE: an in-flight upload sets blocksRestart, so a
59
+ // drain caught mid-upload waits for it — that is the intended contract, not
60
+ // a stuck gate, and must never be --forced.
61
+ return process.env.COS_VIDEO_UPLOAD_V2 !== '0'
58
62
  }
59
63
 
60
64
  export function phoneVideoFramesEnabled(): boolean {
@@ -1302,7 +1302,9 @@ export const TURN_UNKNOWN_COPY = 'COS has no record of that turn. Nothing was se
1302
1302
  export const CLIENT_TURN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/
1303
1303
 
1304
1304
  export function threadAttachEnabled(): boolean {
1305
- return process.env.COS_THREAD_ATTACH_ENABLED === '1'
1305
+ // Default ON since 6.37.0 (Miles 2026-08-25): ship the capability active and
1306
+ // let users opt out. Absent key = on; only a literal '0' disables.
1307
+ return process.env.COS_THREAD_ATTACH_ENABLED !== '0'
1306
1308
  }
1307
1309
 
1308
1310
  export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps): Router {
@@ -20,6 +20,8 @@ import {
20
20
  getHighQualityTranscriptionCapability,
21
21
  } from '../lib/whisper-local.js'
22
22
  import { getOpenAIWhisperBudgetState } from '../lib/openai-whisper-budget.js'
23
+ import { getMeetingSummaryBudgetState } from '../lib/meeting-summary-budget.js'
24
+ import { meetingSummaryLLMEnabled } from '../lib/meeting-summary.js'
23
25
  import { getKeyStatus } from '../lib/openai-key.js'
24
26
  import {
25
27
  getCodexModelCatalog,
@@ -256,6 +258,10 @@ healthRouter.get('/health', async (_req, res) => {
256
258
  speakerId: speakerReadinessState,
257
259
  }
258
260
  const openai_whisper_budget = getOpenAIWhisperBudgetState()
261
+ const meeting_summary = {
262
+ enabled: meetingSummaryLLMEnabled(),
263
+ ...getMeetingSummaryBudgetState(),
264
+ }
259
265
  const codex_models = getCodexModelCatalogSnapshot()
260
266
  // Unauthenticated /api/health publishes Cursor slot capability only; concrete
261
267
  // agent binary paths stay on the authenticated /api/models surface.
@@ -317,6 +323,7 @@ healthRouter.get('/health', async (_req, res) => {
317
323
  readiness,
318
324
  whisper_health,
319
325
  openai_whisper_budget,
326
+ meeting_summary,
320
327
  tts_local,
321
328
  codex_models,
322
329
  cursor_models,
@@ -153,6 +153,7 @@ import {
153
153
  readFinalizationChunkEntries,
154
154
  type MeetingFinalizationJob,
155
155
  } from '../lib/meeting-finalization-jobs.js'
156
+ import { enrichStandaloneMeeting } from '../lib/meeting-summary-persistence.js'
156
157
 
157
158
  function cosOpsPipelineConfigured(): boolean {
158
159
  // Read env live (not the module-load COS_SCRIPTS_DIR const) so unit tests that
@@ -228,6 +229,7 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
228
229
  allowDuringDrain: true,
229
230
  phase: 'queued',
230
231
  })
232
+ const jobStartedAt = Date.now()
231
233
  const task = Promise.resolve().then(async () => {
232
234
  lease.setPhase('active')
233
235
  let current = runtime.finalizationJobs.get(job.sessionId) ?? job
@@ -317,6 +319,12 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
317
319
 
318
320
  if (cosOpsPipelineConfigured()) {
319
321
  await handoffMeetingToOperations(current.meetingPath)
322
+ } else {
323
+ // Standalone: no sync_meetings.py to produce summary/topics/decisions.
324
+ // Runs HERE, after finalizeBatch, because batch HQ replaces the whole
325
+ // transcript (meeting-batch-persistence.ts:7-12) — summarising earlier
326
+ // would describe text the file no longer contains.
327
+ await enrichStandaloneMeeting(current.meetingPath, jobStartedAt)
320
328
  }
321
329
  markCanonicalFinalizationState(current.sidecarPath, 'complete', false)
322
330
  runtime.finalizationJobs.remove(current.sessionId)
@@ -567,7 +575,12 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
567
575
  : Math.max(0, Date.now() - startTime)
568
576
  const integrity = sessions.getIntegrity(sessionId)
569
577
  const needsOperations = cosOpsPipelineConfigured()
570
- const finalizationRequired = sessions.hasAudio(sessionId) || needsOperations
578
+ // Standalone saves need a finalization pass too, for summary enrichment.
579
+ // Before 6.37 a standalone save with no audio nulled the job below and
580
+ // never reached the ops_pending slot, so enrichment could never run.
581
+ const standaloneEnrichmentRequired = !needsOperations
582
+ const finalizationRequired =
583
+ sessions.hasAudio(sessionId) || needsOperations || standaloneEnrichmentRequired
571
584
  const claimPending = needsOperations && earlyMeetingSyncEnabled()
572
585
 
573
586
  // Initial canonical text + structured metadata are published before any
@@ -1948,6 +1961,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1948
1961
  } catch { /* display is best-effort */ }
1949
1962
  if (cosOpsPipelineConfigured()) {
1950
1963
  await handoffMeetingToOperations(saved.filepath)
1964
+ } else {
1965
+ // Orphan recovery is its own path and never reaches
1966
+ // scheduleFinalizationJob, so a recovered standalone meeting would
1967
+ // otherwise stay permanently un-enriched.
1968
+ // Full wall from here, not a shared finalization budget: recovery
1969
+ // already holds the long-running orphan_recovery lease that COS
1970
+ // Control surfaces and warns on before committing a drain.
1971
+ await enrichStandaloneMeeting(saved.filepath, Date.now())
1951
1972
  }
1952
1973
  }).catch(error => {
1953
1974
  // The quarantined audio is untouched on failure — retry stays possible