@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.
- package/.env.example +22 -0
- package/CHANGELOG.md +133 -0
- package/package.json +1 -1
- package/server/lib/adaptive-playback-audio.ts +4 -1
- package/server/lib/even-hub-speaker-role.ts +116 -0
- package/server/lib/meeting-store.ts +13 -4
- package/server/lib/meeting-summary-budget.ts +90 -0
- package/server/lib/meeting-summary-persistence.ts +179 -0
- package/server/lib/meeting-summary.ts +429 -0
- package/server/lib/speaker-name.ts +89 -0
- package/server/lib/video-upload-v2.ts +5 -1
- package/server/routes/agent-session-bindings.ts +3 -1
- package/server/routes/health.ts +7 -0
- package/server/routes/meeting.ts +22 -1
- package/server/routes/transcribe-stream.ts +25 -0
- package/server/routes/voice.ts +15 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
package/server/routes/health.ts
CHANGED
|
@@ -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,
|
package/server/routes/meeting.ts
CHANGED
|
@@ -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
|
-
|
|
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
|