@gotcos/glasses-server 6.36.27 → 6.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,420 @@
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
+ export function meetingSummaryLLMEnabled(): boolean {
92
+ const raw = process.env.COS_MEETING_SUMMARY?.trim()
93
+ return raw === '1' || raw?.toLowerCase() === 'true'
94
+ }
95
+
96
+ // ── Transcript parsing ──────────────────────────────────────
97
+
98
+ export interface TranscriptTurn {
99
+ speaker: string
100
+ text: string
101
+ }
102
+
103
+ /** Parse `[Speaker]: text` turns. Lines without a speaker prefix are appended
104
+ * to the previous turn so a wrapped line does not become its own turn. */
105
+ export function parseTranscriptTurns(transcript: string): TranscriptTurn[] {
106
+ const turns: TranscriptTurn[] = []
107
+ for (const line of transcript.split('\n')) {
108
+ const match = line.match(/^\s*\[([^\]]{1,60})\]:\s*(.*)$/)
109
+ if (match) {
110
+ turns.push({ speaker: match[1].trim(), text: match[2].trim() })
111
+ continue
112
+ }
113
+ const trimmed = line.trim()
114
+ if (!trimmed) continue
115
+ if (turns.length > 0) {
116
+ turns[turns.length - 1].text = `${turns[turns.length - 1].text} ${trimmed}`.trim()
117
+ } else {
118
+ turns.push({ speaker: '', text: trimmed })
119
+ }
120
+ }
121
+ return turns.filter(turn => turn.text.length > 0)
122
+ }
123
+
124
+ export function countTranscriptWords(transcript: string): number {
125
+ return transcript.split(/\s+/).filter(Boolean).length
126
+ }
127
+
128
+ // ── Tier 1: deterministic ───────────────────────────────────
129
+
130
+ /**
131
+ * A factual record of what the transcript contains. Deliberately makes no
132
+ * claim it cannot support: topics, decisions and action items come back EMPTY
133
+ * because no deterministic method extracts them reliably, and a fabricated
134
+ * list is worse than an absent one.
135
+ */
136
+ export function extractiveMeetingSummary(
137
+ transcript: string,
138
+ opts: { durationMinutes?: number } = {},
139
+ ): MeetingSummaryResult {
140
+ const turns = parseTranscriptTurns(transcript)
141
+ const words = countTranscriptWords(transcript)
142
+
143
+ const wordsBySpeaker = new Map<string, number>()
144
+ for (const turn of turns) {
145
+ if (!turn.speaker) continue
146
+ wordsBySpeaker.set(
147
+ turn.speaker,
148
+ (wordsBySpeaker.get(turn.speaker) ?? 0) + countTranscriptWords(turn.text),
149
+ )
150
+ }
151
+ const speakers = [...wordsBySpeaker.entries()].sort((a, b) => b[1] - a[1])
152
+
153
+ const parts: string[] = []
154
+ if (opts.durationMinutes && opts.durationMinutes > 0) {
155
+ parts.push(`${opts.durationMinutes}-minute recording`)
156
+ } else {
157
+ parts.push('Recording')
158
+ }
159
+ parts.push(`${words.toLocaleString()} words`)
160
+ if (speakers.length > 0) {
161
+ const roster = speakers
162
+ .map(([name, count]) => `${name} (${Math.round((count / Math.max(1, words)) * 100)}%)`)
163
+ .join(', ')
164
+ parts.push(`${speakers.length} speaker${speakers.length === 1 ? '' : 's'}: ${roster}`)
165
+ }
166
+
167
+ const opening = turns
168
+ .slice(0, 3)
169
+ .map(turn => (turn.speaker ? `${turn.speaker}: ${turn.text}` : turn.text))
170
+ .join(' ')
171
+ .slice(0, 300)
172
+ .trim()
173
+
174
+ const summary = opening
175
+ ? `${parts.join('. ')}.\n\nOpening: ${opening}${opening.length >= 300 ? '…' : ''}`
176
+ : `${parts.join('. ')}.`
177
+
178
+ return {
179
+ summary,
180
+ topics: [],
181
+ decisions: [],
182
+ actionItems: [],
183
+ tier: 'extractive',
184
+ }
185
+ }
186
+
187
+ /** Speaker roster, for the Attendees section. Deterministic either tier. */
188
+ export function transcriptSpeakers(transcript: string): string[] {
189
+ const seen: string[] = []
190
+ for (const turn of parseTranscriptTurns(transcript)) {
191
+ if (turn.speaker && !seen.includes(turn.speaker)) seen.push(turn.speaker)
192
+ }
193
+ return seen.slice(0, 20)
194
+ }
195
+
196
+ // ── Tier 2: LLM ─────────────────────────────────────────────
197
+
198
+ /** Head+tail bound that never splits a UTF-16 surrogate pair. */
199
+ export function boundTranscriptForSummary(transcript: string): {
200
+ text: string
201
+ truncated: boolean
202
+ } {
203
+ if (transcript.length <= MAX_SUMMARY_INPUT_CHARS) {
204
+ return { text: transcript, truncated: false }
205
+ }
206
+ const budget = MAX_SUMMARY_INPUT_CHARS - ELISION.length
207
+ let headEnd = Math.floor(budget * HEAD_FRACTION)
208
+ let tailStart = transcript.length - (budget - headEnd)
209
+ // Never cut between a surrogate pair (emoji, some CJK extensions).
210
+ if (isLowSurrogate(transcript.charCodeAt(headEnd))) headEnd -= 1
211
+ if (isLowSurrogate(transcript.charCodeAt(tailStart))) tailStart += 1
212
+ return {
213
+ text: `${transcript.slice(0, headEnd)}${ELISION}${transcript.slice(tailStart)}`,
214
+ truncated: true,
215
+ }
216
+ }
217
+
218
+ function isLowSurrogate(code: number): boolean {
219
+ return code >= 0xdc00 && code <= 0xdfff
220
+ }
221
+
222
+ export function buildSummaryPrompt(transcript: string, truncated: boolean): string {
223
+ return [
224
+ 'You are summarising a meeting transcript for the person who recorded it.',
225
+ '',
226
+ 'Rules:',
227
+ '- Reply with ONLY a JSON object. No prose, no code fence.',
228
+ '- Shape: {"summary": string, "topics": string[], "decisions": string[], "actionItems": [{"task": string, "owner": string}]}',
229
+ '- "summary" is 2-4 sentences of what actually happened.',
230
+ '- Use an empty array when the transcript does not support that field. Never invent a decision or an action item.',
231
+ // Speaker labels in a standalone transcript are diarisation guesses
232
+ // ("Speaker 2", "Ext") and are corrected later by the speaker-review flow,
233
+ // which deliberately never rewrites prose (meeting-corrections.ts:19-26).
234
+ // A generated summary naming a speaker would keep the wrong name forever.
235
+ '- Do NOT attribute statements to a speaker by name or label. Describe what was discussed, not who said it.',
236
+ '- "owner" must be a person NAMED ALOUD in the transcript. Diarisation labels'
237
+ + ' ("Speaker 2", "Ext", "MU", "Me", "Unknown") are placeholders, not names —'
238
+ + ' use "" for owner in that case.',
239
+ '- Write in the same language as the transcript.',
240
+ truncated
241
+ ? '- The middle of this transcript was omitted for length. Say so in the summary rather than implying full coverage.'
242
+ : '',
243
+ '',
244
+ 'Transcript:',
245
+ transcript,
246
+ ]
247
+ .filter(Boolean)
248
+ .join('\n')
249
+ }
250
+
251
+ /** Diarisation placeholders. These are corrected later by the speaker-review
252
+ * flow, which never rewrites the enrichment sections — so a label captured as
253
+ * an action-item owner would stay wrong permanently. Drop it instead. */
254
+ const DIARISATION_LABEL = /^(?:speaker\s*\d+|ext(?:ernal)?|unknown|me|mu)$/i
255
+
256
+ export function isDiarisationLabel(value: string): boolean {
257
+ return DIARISATION_LABEL.test(value.trim())
258
+ }
259
+
260
+ function asStringArray(value: unknown, limit: number): string[] {
261
+ if (!Array.isArray(value)) return []
262
+ return value
263
+ .filter((item): item is string => typeof item === 'string')
264
+ .map(item => item.trim())
265
+ .filter(Boolean)
266
+ .slice(0, limit)
267
+ }
268
+
269
+ /** Strict shape validation. A malformed reply is a failure, not a partial win. */
270
+ export function parseSummaryResponse(raw: string): MeetingSummaryResult | null {
271
+ const start = raw.indexOf('{')
272
+ const end = raw.lastIndexOf('}')
273
+ if (start === -1 || end <= start) return null
274
+ let parsed: unknown
275
+ try {
276
+ parsed = JSON.parse(raw.slice(start, end + 1))
277
+ } catch {
278
+ return null
279
+ }
280
+ if (!parsed || typeof parsed !== 'object') return null
281
+ const record = parsed as Record<string, unknown>
282
+ const summary = typeof record.summary === 'string' ? record.summary.trim() : ''
283
+ if (!summary || summary.length > MAX_SUMMARY_CHARS) return null
284
+
285
+ const actionItems = Array.isArray(record.actionItems)
286
+ ? record.actionItems
287
+ .filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
288
+ .map(item => {
289
+ const owner = typeof item.owner === 'string' ? item.owner.trim() : ''
290
+ return {
291
+ task: typeof item.task === 'string' ? item.task.trim() : '',
292
+ // Belt and braces: the prompt forbids these, and this drops them
293
+ // if a model returns one anyway.
294
+ owner: isDiarisationLabel(owner) ? '' : owner,
295
+ }
296
+ })
297
+ .filter(item => item.task.length > 0)
298
+ .slice(0, MAX_ACTIONS)
299
+ : []
300
+
301
+ return {
302
+ summary,
303
+ topics: asStringArray(record.topics, MAX_TOPICS),
304
+ decisions: asStringArray(record.decisions, MAX_DECISIONS),
305
+ actionItems,
306
+ tier: 'llm',
307
+ }
308
+ }
309
+
310
+ export interface LlmSummaryOptions {
311
+ durationMinutes?: number
312
+ /** Wall time still available before FINALIZATION_WALL_BUDGET_MS is spent. */
313
+ remainingWallMs?: number
314
+ signal?: AbortSignal
315
+ /** Test seam. Defaults to the real spawnClaudeText. */
316
+ spawn?: (prompt: string, opts: { model: string; timeoutMs: number; label: string; signal?: AbortSignal }) => Promise<string>
317
+ }
318
+
319
+ export interface SummaryOutcome extends MeetingSummaryResult {
320
+ /** Why the LLM tier did not run, when it did not. */
321
+ skipReason?:
322
+ | 'flag_off'
323
+ | 'too_short'
324
+ | 'budget_exhausted'
325
+ | 'breaker_open'
326
+ | 'no_wall_time'
327
+ | 'auth_required'
328
+ | 'invalid_response'
329
+ | 'call_failed'
330
+ }
331
+
332
+ /**
333
+ * Produce the best available enrichment. Always returns a usable result: the
334
+ * LLM tier when every gate passes, the deterministic tier otherwise.
335
+ */
336
+ export async function summariseMeeting(
337
+ transcript: string,
338
+ opts: LlmSummaryOptions = {},
339
+ ): Promise<SummaryOutcome> {
340
+ const fallback = extractiveMeetingSummary(transcript, {
341
+ durationMinutes: opts.durationMinutes,
342
+ })
343
+
344
+ if (!meetingSummaryLLMEnabled()) return { ...fallback, skipReason: 'flag_off' }
345
+ if (countTranscriptWords(transcript) < MIN_SUMMARY_WORDS) {
346
+ return { ...fallback, skipReason: 'too_short' }
347
+ }
348
+ if (breaker.isOpen()) return { ...fallback, skipReason: 'breaker_open' }
349
+ if (!meetingSummaryBudgetAvailable()) {
350
+ return { ...fallback, skipReason: 'budget_exhausted' }
351
+ }
352
+
353
+ const remaining = opts.remainingWallMs ?? SUMMARY_WALL_MS
354
+ if (remaining < MIN_SUMMARY_WALL_MS) return { ...fallback, skipReason: 'no_wall_time' }
355
+ const timeoutMs = Math.min(SUMMARY_WALL_MS, remaining)
356
+
357
+ const { text, truncated } = boundTranscriptForSummary(transcript)
358
+ const spawn = opts.spawn ?? ((prompt, spawnOpts) => spawnClaudeText(prompt, spawnOpts))
359
+
360
+ let raw: string
361
+ try {
362
+ raw = await spawn(buildSummaryPrompt(text, truncated), {
363
+ // Cheapest tier, and --model is ALWAYS passed by spawnClaudeText so this
364
+ // can never silently inherit an Opus session default.
365
+ model: 'haiku',
366
+ timeoutMs,
367
+ label: 'Meeting summary',
368
+ signal: opts.signal,
369
+ })
370
+ } catch (err) {
371
+ breaker.recordFailure()
372
+ console.error(`[meeting-summary] call failed: ${err instanceof Error ? err.message : String(err)}`)
373
+ return { ...fallback, skipReason: 'call_failed' }
374
+ }
375
+
376
+ // An unauthenticated CLI exits ZERO with a success-shaped payload carrying
377
+ // the bearer token (claude-bridge-auth-finalization.test.ts:173-191). Without
378
+ // this check that credential would be written into a durable meeting file.
379
+ const authFailure = terminalProviderAuthFailure('claude', raw)
380
+ if (authFailure) {
381
+ breaker.recordFailure()
382
+ console.error(`[meeting-summary] ${authFailure}`)
383
+ return { ...fallback, skipReason: 'auth_required' }
384
+ }
385
+
386
+ const parsed = parseSummaryResponse(raw)
387
+ if (!parsed) {
388
+ breaker.recordFailure()
389
+ console.error('[meeting-summary] response failed shape validation')
390
+ return { ...fallback, skipReason: 'invalid_response' }
391
+ }
392
+
393
+ // Committed only now: a failure, refusal, or malformed reply costs nothing.
394
+ commitMeetingSummaryCall()
395
+ breaker.recordSuccess()
396
+ return parsed
397
+ }
398
+
399
+ // ── Concurrency ─────────────────────────────────────────────
400
+
401
+ let summaryQueueTail: Promise<unknown> = Promise.resolve()
402
+
403
+ /**
404
+ * Serialise summary work to a single slot. resumeMeetingFinalizationJobs()
405
+ * replays EVERY retained job on boot, so without this a crash-then-restart
406
+ * would spawn one provider per pending meeting at once.
407
+ *
408
+ * Load-shedding is automatic and needs no queue limit: each caller computes
409
+ * remainingWallMs from its OWN job start, so a job that waited behind others
410
+ * arrives with too little budget and degrades to the deterministic tier
411
+ * instead of holding its maintenance lease past COS Control's drain timeout.
412
+ */
413
+ export function enqueueSummaryWork<T>(fn: () => Promise<T>): Promise<T> {
414
+ const run = summaryQueueTail.then(fn, fn)
415
+ summaryQueueTail = run.then(
416
+ () => undefined,
417
+ () => undefined,
418
+ )
419
+ return run
420
+ }
@@ -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
+ }