@gotcos/glasses-server 6.36.28 → 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.
package/.env.example CHANGED
@@ -177,6 +177,23 @@ BIND_HOST=0.0.0.0
177
177
  # deliberately independent from the progressive-HQ compute switch.
178
178
  # COS_MEETING_EARLY_SYNC=1
179
179
 
180
+ # ── Standalone meeting summaries ──────────────────────────────────────────────
181
+ # Generate summary / topics / decisions / action items for meetings recorded
182
+ # WITHOUT the COS operations pipeline (no COS_SCRIPTS_DIR). Spawns one
183
+ # `claude -p --model haiku` per meeting, at the end of finalization.
184
+ #
185
+ # Default OFF. With it off you still get the deterministic tier — speakers,
186
+ # talk-time, word count, opening excerpt — which costs nothing and always runs.
187
+ #
188
+ # Never runs when COS_SCRIPTS_DIR is set: overwriting the summary section would
189
+ # erase the markers sync_meetings.py uses to decide what to process.
190
+ # COS_MEETING_SUMMARY=1
191
+ #
192
+ # Calls per local day before the summariser falls back to the deterministic
193
+ # tier for the rest of the day. Default 40.
194
+ # COS_MEETING_SUMMARY_DAILY_CAP=40
195
+
196
+
180
197
  # ── RETAINED MEETING-AUDIO PLAYBACK CLEANUP (6.21.32 canary) ────────────
181
198
  # Default OFF. When enabled, the first authenticated Play of a retained raw
182
199
  # meeting chunk creates a cached, adaptive cleanup copy. Raw WAV evidence stays
package/CHANGELOG.md CHANGED
@@ -1,3 +1,109 @@
1
+ ## 6.37.0
2
+
3
+ Meetings recorded without the COS operations pipeline now get a real summary,
4
+ topics, decisions and action items. Until now they got none of that, and the
5
+ server compensated by returning the TRANSCRIPT in the summary slot -- which is
6
+ why this was reported as "no proper summary and transcription."
7
+
8
+ FEATURE DEFAULTS NOW ON (Miles 2026-08-25)
9
+
10
+ Ship the capability active; users opt out of what they do not need. Each gate
11
+ stays strict in the other direction -- only a literal '0' disables, so a stray
12
+ or malformed value cannot silently switch a feature off.
13
+
14
+ COS_THREAD_ATTACH_ENABLED Continue agent threads
15
+ COS_VIDEO_UPLOAD_V2 Reliable video uploads
16
+ COS_MEETING_AUDIO_ADAPTIVE_PLAYBACK Adaptive audio cleanup
17
+
18
+ COS_WHISPER_MEETING_PREVIEW (Meeting Turbo preview) was ALREADY on by default
19
+ server-side; COS Control mis-renders its checkbox as off because it resolves
20
+ the value as == "1" rather than != "0". That is a Control display bug, not a
21
+ server default, and is not fixed here.
22
+
23
+ Idle Metal HQ (COS_BATCH_HQ_METAL) is deliberately unchanged -- it is
24
+ hardware-dependent and stays opt-in.
25
+
26
+ Two consequences worth knowing. An in-flight video upload sets blocksRestart,
27
+ so a drain caught mid-upload waits for it -- that is the contract, not a stuck
28
+ gate, and must never be --forced. Adaptive playback affects review playback
29
+ only; the raw WAV is untouched, and transcript, attribution, save and sync are
30
+ unchanged.
31
+
32
+ VOICE ENROLMENT
33
+
34
+ "enroll my voice" followed by more speech created a profile NAMED with the
35
+ entire spoken utterance -- a ~40-second transcript. Reported by a first-time
36
+ user. Because a junk name never equals owner_speaker_label,
37
+ /api/voice/status reported `enrolled: false` no matter how many times you
38
+ enrolled, and editing voice-profiles.json by hand did not help because the
39
+ server rewrites it from memory.
40
+
41
+ Root cause was client-side: a $-anchored command regex fell through to the
42
+ named-enrolment branch, whose capture group had no length bound. Fixed in
43
+ COS Glasses 6.8.433.
44
+
45
+ POST /api/voice/enroll now validates ?name= as well, so an OLD client cannot
46
+ write a sentence into the profile store: at most 40 characters, at most 4
47
+ words, letters/spaces/hyphens/apostrophes only, no sentence punctuation, and
48
+ no self-referential phrasing. The wearer's own label is always accepted --
49
+ including the default "Me", which is itself self-referential and would
50
+ otherwise have been rejected by its own guard.
51
+
52
+ WHAT CHANGED
53
+
54
+ meeting-store parseMeeting no longer substitutes the transcript into `summary`.
55
+ A standalone placeholder now yields an empty summary, and the transcript is
56
+ carried in its own `transcript` field, which the reader renders as its own
57
+ section (requires COS Glasses 6.8.433). estimatedDetailPages now counts the
58
+ transcript, so a list row no longer advertises ~1p for a meeting that paginates
59
+ to many.
60
+
61
+ Two enrichment tiers, both standalone-only:
62
+
63
+ extractive always on, zero tokens -- duration, word count, speaker roster
64
+ with talk-time split, opening excerpt. Fabricates nothing:
65
+ topics/decisions/actions come back empty rather than guessed.
66
+
67
+ llm COS_MEETING_SUMMARY=1, default OFF. One `claude -p --model haiku`
68
+ per meeting at the ops_pending phase of finalization.
69
+
70
+ It runs at ops_pending specifically because batch HQ transcription replaces the
71
+ whole transcript; summarising earlier would describe text the file no longer
72
+ contains. Every generated section is inserted BEFORE `## Transcript`, because
73
+ replaceMeetingTranscriptAtomic replaces from that marker to end of file.
74
+
75
+ NEVER RUNS FOR OPERATIONS USERS, and refuses twice: the caller gates on
76
+ cosOpsPipelineConfigured(), and the writer independently refuses any file
77
+ carrying the sync_meetings.py markers. Overwriting the summary section would
78
+ erase them and the pipeline would skip the meeting entirely -- losing domain
79
+ reclassification, task extraction and the operations copy.
80
+
81
+ COST CONTROLS
82
+ - COS_MEETING_SUMMARY, read live (not frozen at module load)
83
+ - haiku pinned explicitly, so it can never inherit an Opus session default
84
+ - own daily cap (COS_MEETING_SUMMARY_DAILY_CAP, default 40), never shared
85
+ with the archive counter; committed only after a validated summary, so a
86
+ failure, auth refusal or malformed reply costs nothing
87
+ - circuit breaker with its own failure accounting
88
+ - single-slot queue, so a boot replay cannot spawn one provider per meeting
89
+ - wall budget anchored to the finalization job start, keeping batch decode +
90
+ summariser + handoff under COS Control's 90s waitForRestartProof
91
+ - minimum word floor; head+tail input bound, never head-only
92
+ - surfaced on /api/health as meeting_summary
93
+
94
+ An unauthenticated CLI exits ZERO with a success-shaped payload carrying the
95
+ bearer token. The result is screened with terminalProviderAuthFailure before
96
+ anything is written, so a 401 can never be persisted into a meeting file.
97
+
98
+ Generated prose never attributes statements to a speaker, and diarisation
99
+ labels ("Speaker 2", "Ext", "MU") are stripped from action-item owners -- the
100
+ speaker-review flow deliberately never rewrites these sections, so a label
101
+ captured here would stay wrong permanently.
102
+
103
+ A standalone save with no audio now schedules a finalization pass too; before
104
+ this it was nulled and never reached ops_pending. Orphan recovery, which has
105
+ its own path, enriches as well.
106
+
1
107
  ## 6.36.28
2
108
 
3
109
  THE bug. Three earlier fixes tonight were each real and none was this one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.28",
3
+ "version": "6.37.0",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -159,7 +159,10 @@ export function analyzePlaybackWav(buffer: Buffer): AudioSignalProfile | null {
159
159
 
160
160
  export function adaptivePlaybackEnabled(): boolean {
161
161
  // Private canary: explicit opt-in, explicit 0 rollback through COS Control.
162
- return process.env.COS_MEETING_AUDIO_ADAPTIVE_PLAYBACK === '1'
162
+ // Default ON since 6.37.0 (Miles 2026-08-25). Absent key = on; only a
163
+ // literal '0' disables. Raw WAV is untouched either way — this affects
164
+ // review playback only, never the transcript, attribution, save or sync.
165
+ return process.env.COS_MEETING_AUDIO_ADAPTIVE_PLAYBACK !== '0'
163
166
  }
164
167
 
165
168
  function outputPathFor(rawPath: string): string | null {
@@ -0,0 +1,116 @@
1
+ // Even Hub 0.0.14 wearer-vs-other histogram, carried on a meeting chunk.
2
+ // Identity is a suggestion. This module parses and logs. It does not name
3
+ // people and does not change identifyChunkSpeaker.
4
+
5
+ export type EvenSpeakerRole = 'self' | 'other' | 'unknown'
6
+ export type EvenSpeakerRoleMajority = EvenSpeakerRole | 'tie'
7
+
8
+ export interface EvenSpeakerRoleHistogram {
9
+ schema: 1
10
+ frames: number
11
+ self: number
12
+ other: number
13
+ unknown: number
14
+ majority: EvenSpeakerRoleMajority
15
+ directionPresent: number
16
+ directionLast: number | null
17
+ }
18
+
19
+ export type EvenSpeakerRoleMode = 'off' | 'log' | 'apply'
20
+
21
+ export function evenSpeakerRoleMode(): EvenSpeakerRoleMode {
22
+ const raw = (process.env.COS_EVEN_SPEAKER_ROLE ?? 'log').trim().toLowerCase()
23
+ if (raw === 'off' || raw === '0' || raw === 'false') return 'off'
24
+ if (raw === 'apply') return 'apply'
25
+ return 'log'
26
+ }
27
+
28
+ let applyNotImplementedWarned = false
29
+
30
+ /** Gate A is not in this slice. apply must not silently change labels. */
31
+ export function warnEvenSpeakerRoleApplyNotImplemented(): void {
32
+ if (evenSpeakerRoleMode() !== 'apply' || applyNotImplementedWarned) return
33
+ applyNotImplementedWarned = true
34
+ console.warn('[even-role] COS_EVEN_SPEAKER_ROLE=apply is not implemented; logging only')
35
+ }
36
+
37
+ function majorityOf(self: number, other: number, unknown: number, frames: number): EvenSpeakerRoleMajority {
38
+ if (frames <= 0) return 'unknown'
39
+ if (self > other && self > unknown) return 'self'
40
+ if (other > self && other > unknown) return 'other'
41
+ if (unknown > self && unknown > other) return 'unknown'
42
+ return 'tie'
43
+ }
44
+
45
+ function asNonNegInt(raw: unknown): number | null {
46
+ const n = typeof raw === 'number' ? raw : typeof raw === 'string' && raw !== '' ? Number(raw) : NaN
47
+ if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return null
48
+ return n
49
+ }
50
+
51
+ /** Compact query `eh=self,other,unknown,frames,directionPresent,directionLast`. */
52
+ export function parseEvenHubSpeakerRoleQuery(raw: unknown): EvenSpeakerRoleHistogram | undefined {
53
+ if (typeof raw !== 'string' || raw.length === 0) return undefined
54
+ const parts = raw.split(',')
55
+ if (parts.length < 4 || parts.length > 6) return undefined
56
+ const self = asNonNegInt(parts[0])
57
+ const other = asNonNegInt(parts[1])
58
+ const unknown = asNonNegInt(parts[2])
59
+ const frames = asNonNegInt(parts[3])
60
+ if (self == null || other == null || unknown == null || frames == null) return undefined
61
+ if (self + other + unknown !== frames) return undefined
62
+ const directionPresent = parts.length >= 5 ? asNonNegInt(parts[4]) : 0
63
+ if (directionPresent == null) return undefined
64
+ let directionLast: number | null = null
65
+ if (parts.length === 6 && parts[5] !== '') {
66
+ const last = Number(parts[5])
67
+ if (!Number.isFinite(last)) return undefined
68
+ directionLast = last
69
+ }
70
+ return {
71
+ schema: 1,
72
+ frames,
73
+ self,
74
+ other,
75
+ unknown,
76
+ majority: majorityOf(self, other, unknown, frames),
77
+ directionPresent,
78
+ directionLast,
79
+ }
80
+ }
81
+
82
+ export function parseEvenHubSpeakerRoleBody(raw: unknown): EvenSpeakerRoleHistogram | undefined {
83
+ if (!raw || typeof raw !== 'object') return undefined
84
+ const o = raw as Record<string, unknown>
85
+ const self = asNonNegInt(o.self)
86
+ const other = asNonNegInt(o.other)
87
+ const unknown = asNonNegInt(o.unknown)
88
+ const frames = asNonNegInt(o.frames)
89
+ if (self == null || other == null || unknown == null || frames == null) return undefined
90
+ if (self + other + unknown !== frames) return undefined
91
+ const directionPresent = o.directionPresent == null ? 0 : asNonNegInt(o.directionPresent)
92
+ if (directionPresent == null) return undefined
93
+ const directionLast = o.directionLast == null || o.directionLast === ''
94
+ ? null
95
+ : (typeof o.directionLast === 'number' && Number.isFinite(o.directionLast) ? o.directionLast : null)
96
+ return {
97
+ schema: 1,
98
+ frames,
99
+ self,
100
+ other,
101
+ unknown,
102
+ majority: majorityOf(self, other, unknown, frames),
103
+ directionPresent,
104
+ directionLast,
105
+ }
106
+ }
107
+
108
+ export function formatEvenRoleAgreement(opts: {
109
+ chunkIndex: number
110
+ even: EvenSpeakerRoleHistogram
111
+ amp: string
112
+ emb: string
113
+ similarity: number
114
+ }): string {
115
+ return `[even-role] chunk=${opts.chunkIndex} even=${opts.even.majority} amp=${opts.amp} emb=${opts.emb} sim=${opts.similarity.toFixed(2)} frames=${opts.even.frames}`
116
+ }
@@ -321,11 +321,15 @@ function parseMeeting(content: string, filename: string, month: string): Meeting
321
321
  const duration = parseField(content, 'Duration')
322
322
  const transcript = extractSection(content, ['Transcript'], true)
323
323
  const storedSummary = extractSection(content, ['Summary'])
324
- // Standalone recordings have no private enrichment pipeline. Returning the
325
- // canonical transcript as the detail summary lets the build199 reader review
326
- // the saved meeting instead of displaying only a placeholder.
324
+ // A placeholder is not a summary. Until 6.37 this returned the TRANSCRIPT as
325
+ // the summary, which put the transcript in the summary slot on every surface
326
+ // and read as "the summary is broken" the bug this replaces. The transcript
327
+ // is returned in its own `transcript` field and each surface renders it in
328
+ // its own section. An empty summary is the honest state, and every consumer
329
+ // already handles it: display-pages.ts falls back to 'No summary available.',
330
+ // and COS Control's Copy-summary button correctly disables on empty.
327
331
  const summary = !storedSummary || /standalone recording|summary unavailable/i.test(storedSummary)
328
- ? transcript
332
+ ? ''
329
333
  : storedSummary
330
334
  const topics = parseListSection(content, ['Topics Discussed'], 10)
331
335
  const decisions = parseListSection(content, ['Decisions', 'Decisions Made'], 10)
@@ -369,6 +373,10 @@ export function boundedMeetingSource(content: string): { sourceContent: string;
369
373
  }
370
374
 
371
375
  function toMeta(detail: MeetingDetail, sessionId?: string): MeetingMeta {
376
+ // Must mirror what the reader actually renders (display-pages.ts
377
+ // formatMeetingDetailBody), or the list row advertises a page count the
378
+ // reader does not honour. The transcript is part of that body since 6.37;
379
+ // omitting it here reported every standalone meeting as ~1p.
372
380
  const detailCharEstimate = [
373
381
  detail.title,
374
382
  detail.date,
@@ -378,6 +386,7 @@ function toMeta(detail: MeetingDetail, sessionId?: string): MeetingMeta {
378
386
  detail.decisions.join('\n'),
379
387
  detail.actionItems.map(item => `${item.owner ? `[${item.owner}] ` : ''}${item.task}`).join('\n'),
380
388
  detail.attendees.join(', '),
389
+ detail.transcript,
381
390
  ].join('\n\n').trim().length
382
391
  return {
383
392
  filename: detail.filename,
@@ -0,0 +1,90 @@
1
+ // Daily budget for standalone meeting summary generation (`claude -p`).
2
+ //
3
+ // Separate counter from archive-budget.ts ON PURPOSE. That module holds a
4
+ // single global `calls` field; folding meeting summaries into it would let a
5
+ // burst of meetings disarm archive summaries for the rest of the day. Every
6
+ // recurring claude -p caller gets its OWN accounting.
7
+ //
8
+ // Two defects in archive-budget.ts are deliberately NOT copied here:
9
+ // 1. It increments BEFORE the call and never refunds, so a machine offline
10
+ // for one evening burns the whole day's cap on failures. This module
11
+ // commits only a call that produced a validated summary.
12
+ // 2. It uses plain writeFileSync, violating the atomic-writes rule. This
13
+ // module uses atomicWriteFileSync + loadJsonOrQuarantine.
14
+ //
15
+ // Reset is lazy: a read that finds date != localDay() starts fresh. No timers.
16
+
17
+ import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
18
+ import { localDay } from './local-day.js'
19
+ import { dataPath } from './data-dir.js'
20
+
21
+ const BUDGET_FILE = dataPath('meeting-summary-budget.json')
22
+
23
+ /** Exported for tests that need to drive the real on-disk read path. */
24
+ export function meetingSummaryBudgetFile(): string {
25
+ return BUDGET_FILE
26
+ }
27
+
28
+ export const DEFAULT_DAILY_MEETING_SUMMARY_CALLS = 40
29
+
30
+ /** Max claude -p calls per local day for meeting summarisation.
31
+ * Read LIVE for the same reason the feature flag is: COS Control rebuilds the
32
+ * runtime plist environment on update, and a module-load const would freeze
33
+ * the value at import — making it untunable without a restart and untestable
34
+ * in-process. */
35
+ export function maxDailyMeetingSummaryCalls(): number {
36
+ const raw = Number(process.env.COS_MEETING_SUMMARY_DAILY_CAP)
37
+ return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_DAILY_MEETING_SUMMARY_CALLS
38
+ }
39
+
40
+ export interface MeetingSummaryBudgetState {
41
+ date: string
42
+ calls: number
43
+ }
44
+
45
+ function readBudget(): MeetingSummaryBudgetState {
46
+ const today = localDay()
47
+ const result = loadJsonOrQuarantine<MeetingSummaryBudgetState>(BUDGET_FILE)
48
+ if (result.status === 'corrupt') {
49
+ console.error(
50
+ `[meeting-summary] budget file was corrupt, quarantined as ${result.quarantinedAs} — starting today at 0`,
51
+ )
52
+ return { date: today, calls: 0 }
53
+ }
54
+ if (result.status !== 'ok') return { date: today, calls: 0 }
55
+ const data = result.data
56
+ if (typeof data?.calls !== 'number' || data.date !== today) {
57
+ return { date: today, calls: 0 }
58
+ }
59
+ return data
60
+ }
61
+
62
+ /**
63
+ * True when today's budget still has room. Does NOT consume — call
64
+ * `commitMeetingSummaryCall()` only after a call produced a validated summary,
65
+ * so failures, auth refusals, and timeouts cost nothing.
66
+ */
67
+ export function meetingSummaryBudgetAvailable(): boolean {
68
+ return readBudget().calls < maxDailyMeetingSummaryCalls()
69
+ }
70
+
71
+ /** Count one call that produced a validated summary. */
72
+ export function commitMeetingSummaryCall(): void {
73
+ const state = readBudget()
74
+ state.calls += 1
75
+ try {
76
+ atomicWriteFileSync(BUDGET_FILE, JSON.stringify(state))
77
+ } catch {
78
+ /* non-fatal — worst case the next read under-counts by one */
79
+ }
80
+ }
81
+
82
+ /** For /api/health and dashboards. */
83
+ export function getMeetingSummaryBudgetState(): MeetingSummaryBudgetState & {
84
+ max: number
85
+ remaining: number
86
+ } {
87
+ const state = readBudget()
88
+ const max = maxDailyMeetingSummaryCalls()
89
+ return { ...state, max, remaining: Math.max(0, max - state.calls) }
90
+ }
@@ -0,0 +1,179 @@
1
+ // Splice generated enrichment into a saved meeting markdown file.
2
+ //
3
+ // TWO HARD RULES, both learned from validation:
4
+ //
5
+ // 1. EVERY new section goes BEFORE `## Transcript`, never after.
6
+ // replaceMeetingTranscriptAtomic (meeting-batch-persistence.ts:7-12)
7
+ // replaces /## Transcript\n\n[\s\S]*$/ — from the marker to END OF FILE —
8
+ // when HQ batch transcription lands. Anything appended after the transcript
9
+ // is destroyed minutes later, silently.
10
+ //
11
+ // 2. REFUSE when the operations markers are present.
12
+ // sync_meetings.py:925-926 decides whether to process a meeting by looking
13
+ // for 'summary pending pipeline processing' and 'g2-needs-domain-review',
14
+ // written by MeetingStore.save when COS_SCRIPTS_DIR is set. Overwriting the
15
+ // summary section erases both, and the pipeline then skips the meeting
16
+ // entirely — permanent loss of domain reclassification, task extraction and
17
+ // the operations copy. The caller already gates on
18
+ // cosOpsPipelineConfigured(); this is defence in depth against a future
19
+ // caller that forgets.
20
+ //
21
+ // The file is re-read immediately before the write so a speaker relabel that
22
+ // landed during the (up to 45s) LLM call is not clobbered. Atomic write is not
23
+ // atomic read-modify-write; the read has to be late, not early.
24
+
25
+ import { readFileSync } from 'node:fs'
26
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
27
+ import type { MeetingSummaryResult } from './meeting-summary.js'
28
+
29
+ /** Markers that mean the operations pipeline owns this file. */
30
+ export const OPS_PIPELINE_MARKERS = [
31
+ 'summary pending pipeline processing',
32
+ 'g2-needs-domain-review',
33
+ ]
34
+
35
+ export function hasOpsPipelineMarkers(markdown: string): boolean {
36
+ const lower = markdown.toLowerCase()
37
+ return OPS_PIPELINE_MARKERS.some(marker => lower.includes(marker.toLowerCase()))
38
+ }
39
+
40
+ function renderSections(result: MeetingSummaryResult, attendees: string[]): string {
41
+ const blocks: string[] = []
42
+ if (result.topics.length > 0) {
43
+ blocks.push('## Topics Discussed', '', ...result.topics.map(t => `- ${t}`), '')
44
+ }
45
+ if (result.decisions.length > 0) {
46
+ blocks.push('## Decisions', '', ...result.decisions.map(d => `- ${d}`), '')
47
+ }
48
+ if (result.actionItems.length > 0) {
49
+ blocks.push(
50
+ '## Action Items',
51
+ '',
52
+ // parseActions() reads the owner from a trailing (**Name**).
53
+ ...result.actionItems.map(a => (a.owner ? `- ${a.task} (**${a.owner}**)` : `- ${a.task}`)),
54
+ '',
55
+ )
56
+ }
57
+ if (attendees.length > 0) {
58
+ // parseAttendees() reads the name from a leading **Name**.
59
+ blocks.push('## Attendees', '', ...attendees.map(name => `- **${name}**`), '')
60
+ }
61
+ return blocks.join('\n')
62
+ }
63
+
64
+ /**
65
+ * Return the markdown with enrichment spliced in, or null when the file must
66
+ * not be touched. Pure — does no IO — so the splice is unit-testable.
67
+ */
68
+ export function spliceMeetingEnrichment(
69
+ markdown: string,
70
+ result: MeetingSummaryResult,
71
+ attendees: string[],
72
+ ): string | null {
73
+ if (hasOpsPipelineMarkers(markdown)) return null
74
+ if (!/^##\s+Summary\s*$/m.test(markdown)) return null
75
+ if (!/^##\s+Transcript\s*$/m.test(markdown)) return null
76
+
77
+ const sections = renderSections(result, attendees)
78
+
79
+ // Replace the Summary body (up to the next `## ` heading) with the generated
80
+ // summary followed by the new sections. Everything from `## Transcript`
81
+ // onward is left byte-identical.
82
+ // [^\S\n]* not \s*: \s* also matches the newline, swallowing the blank line
83
+ // after the heading into the capture and emitting a double blank.
84
+ return markdown.replace(
85
+ /(^##[^\S\n]+Summary[^\S\n]*$\n)([\s\S]*?)(?=^##\s)/m,
86
+ (_full, heading: string) =>
87
+ `${heading}\n${result.summary}\n\n${sections}${sections ? '\n' : ''}`,
88
+ )
89
+ }
90
+
91
+ /**
92
+ * Read the file fresh, splice, and write atomically. Returns false when the
93
+ * file was not eligible — never throws for an ineligible file.
94
+ */
95
+ export function writeMeetingEnrichment(
96
+ meetingPath: string,
97
+ result: MeetingSummaryResult,
98
+ attendees: string[],
99
+ ): boolean {
100
+ let current: string
101
+ try {
102
+ current = readFileSync(meetingPath, 'utf-8')
103
+ } catch {
104
+ return false
105
+ }
106
+ const updated = spliceMeetingEnrichment(current, result, attendees)
107
+ if (!updated || updated === current) return false
108
+ durableAtomicWriteFileSync(meetingPath, updated)
109
+ return true
110
+ }
111
+
112
+ // ── Orchestrator ────────────────────────────────────────────
113
+
114
+ import {
115
+ summariseMeeting,
116
+ transcriptSpeakers,
117
+ enqueueSummaryWork,
118
+ FINALIZATION_WALL_BUDGET_MS,
119
+ } from './meeting-summary.js'
120
+
121
+ function sectionBody(markdown: string, heading: string, toEnd = false): string {
122
+ const pattern = toEnd
123
+ ? new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*)$`, 'i')
124
+ : new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i')
125
+ return markdown.match(pattern)?.[1]?.trim() ?? ''
126
+ }
127
+
128
+ function durationMinutes(markdown: string): number | undefined {
129
+ const raw = markdown.match(/\*\*Duration\*\*\s*\|\s*(\d+)/)?.[1]
130
+ return raw ? Number(raw) : undefined
131
+ }
132
+
133
+ /**
134
+ * Generate and persist enrichment for a standalone meeting. Never throws — a
135
+ * summariser failure must not fail the finalization job, mark lastError, or
136
+ * trigger a retry, because the meeting itself saved correctly.
137
+ *
138
+ * `jobStartedAt` anchors the wall budget: the summariser only gets whatever
139
+ * remains of FINALIZATION_WALL_BUDGET_MS, so batch decode plus this call plus
140
+ * lease release stays inside COS Control's 90s waitForRestartProof.
141
+ */
142
+ export async function enrichStandaloneMeeting(
143
+ meetingPath: string,
144
+ jobStartedAt: number,
145
+ ): Promise<void> {
146
+ try {
147
+ let markdown: string
148
+ try {
149
+ markdown = readFileSync(meetingPath, 'utf-8')
150
+ } catch {
151
+ return
152
+ }
153
+ if (hasOpsPipelineMarkers(markdown)) return
154
+
155
+ const transcript = sectionBody(markdown, 'Transcript', true)
156
+ if (!transcript) return
157
+
158
+ const remainingWallMs = FINALIZATION_WALL_BUDGET_MS - (Date.now() - jobStartedAt)
159
+ const result = await enqueueSummaryWork(() =>
160
+ summariseMeeting(transcript, {
161
+ durationMinutes: durationMinutes(markdown),
162
+ remainingWallMs,
163
+ }),
164
+ )
165
+
166
+ const wrote = writeMeetingEnrichment(meetingPath, result, transcriptSpeakers(transcript))
167
+ if (wrote) {
168
+ console.log(
169
+ `[meeting-summary] enriched ${meetingPath} (tier=${result.tier}`
170
+ + `${result.skipReason ? `, skipped=${result.skipReason}` : ''})`,
171
+ )
172
+ }
173
+ } catch (error) {
174
+ console.error(
175
+ `[meeting-summary] enrichment failed for ${meetingPath}: `
176
+ + `${error instanceof Error ? error.message : String(error)}`,
177
+ )
178
+ }
179
+ }
@@ -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
+ }
@@ -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
@@ -61,6 +61,14 @@ import {
61
61
  appendChunkEmbedding,
62
62
  sweepExpiredChunkEmbeddings,
63
63
  } from '../lib/chunk-embedding-store.js'
64
+ import {
65
+ evenSpeakerRoleMode,
66
+ formatEvenRoleAgreement,
67
+ parseEvenHubSpeakerRoleBody,
68
+ parseEvenHubSpeakerRoleQuery,
69
+ warnEvenSpeakerRoleApplyNotImplemented,
70
+ type EvenSpeakerRoleHistogram,
71
+ } from '../lib/even-hub-speaker-role.js'
64
72
  import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
65
73
  import {
66
74
  countChunkWavs,
@@ -276,6 +284,7 @@ export interface TranscriptChunk {
276
284
  latencyMs?: number
277
285
  audioSha256?: string
278
286
  canonical?: boolean
287
+ evenHubSpeakerRole?: EvenSpeakerRoleHistogram
279
288
  }
280
289
 
281
290
  export interface ProviderCandidateRecord {
@@ -1945,8 +1954,11 @@ async function processStreamChunk(opts: {
1945
1954
  clientElapsed?: number
1946
1955
  /** Original client recording start, applied only before canonical chunks. */
1947
1956
  startTimeOverride?: number
1957
+ evenHubSpeakerRole?: EvenSpeakerRoleHistogram
1948
1958
  }): Promise<StreamChunkCompletionResponse> {
1949
1959
  const { sessionId, chunkIndex, clientSpeaker, audioBuffer, candidate } = opts
1960
+ const evenHubSpeakerRole = evenSpeakerRoleMode() === 'off' ? undefined : opts.evenHubSpeakerRole
1961
+ if (evenHubSpeakerRole) warnEvenSpeakerRoleApplyNotImplemented()
1950
1962
  const tReq = performance.now()
1951
1963
  validateSessionId(sessionId)
1952
1964
  validateChunkIndex(chunkIndex)
@@ -2066,6 +2078,15 @@ async function processStreamChunk(opts: {
2066
2078
  }
2067
2079
 
2068
2080
  const { speaker, similarity } = await speakerPromise
2081
+ if (evenHubSpeakerRole) {
2082
+ console.log(formatEvenRoleAgreement({
2083
+ chunkIndex,
2084
+ even: evenHubSpeakerRole,
2085
+ amp: clientSpeaker,
2086
+ emb: speaker,
2087
+ similarity,
2088
+ }))
2089
+ }
2069
2090
  // Client time is authoritative for live network jitter and deferred replay.
2070
2091
  const elapsed = Number.isFinite(opts.clientElapsed) && (opts.clientElapsed as number) >= 0
2071
2092
  ? Math.round(opts.clientElapsed as number)
@@ -2110,6 +2131,7 @@ async function processStreamChunk(opts: {
2110
2131
  latencyMs,
2111
2132
  audioSha256,
2112
2133
  canonical: true,
2134
+ evenHubSpeakerRole,
2113
2135
  }
2114
2136
  const finalExisting = session.chunks[chunkIndex]
2115
2137
  if (finalExisting?.text) {
@@ -2263,6 +2285,7 @@ transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
2263
2285
  audioBuffer,
2264
2286
  clientElapsed,
2265
2287
  startTimeOverride,
2288
+ evenHubSpeakerRole: parseEvenHubSpeakerRoleQuery(req.query.eh),
2266
2289
  }))
2267
2290
  } catch (err: unknown) {
2268
2291
  sendStreamError(res, err)
@@ -2327,6 +2350,7 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/chun
2327
2350
  clientSpeaker: String(body.clientSpeaker ?? 'Unknown'),
2328
2351
  audioBuffer,
2329
2352
  clientElapsed: typeof body.elapsed === 'number' ? body.elapsed : Number(body.elapsed ?? 0),
2353
+ evenHubSpeakerRole: parseEvenHubSpeakerRoleBody(body.evenHubSpeakerRole),
2330
2354
  candidate: {
2331
2355
  provider: 'iphone-whisperkit-beta',
2332
2356
  text: normalizeCandidateText(candidate.text),
@@ -2405,6 +2429,7 @@ transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) =>
2405
2429
  clientSpeaker: String(body.clientSpeaker ?? 'Unknown'),
2406
2430
  audioBuffer,
2407
2431
  clientElapsed: typeof body.elapsed === 'number' ? body.elapsed : Number(body.elapsed ?? 0),
2432
+ evenHubSpeakerRole: parseEvenHubSpeakerRoleBody(body.evenHubSpeakerRole),
2408
2433
  candidate: {
2409
2434
  provider: 'iphone-whisperkit-beta',
2410
2435
  text: normalizeCandidateText(candidate.text),
@@ -4,6 +4,7 @@ import { Router } from 'express'
4
4
  import { errMsg } from '../lib/utils.js'
5
5
  import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
6
6
  import { resolve } from 'node:path'
7
+ import { checkSpeakerName } from '../lib/speaker-name.js'
7
8
  import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles, mergeSpeakerProfiles } from '../lib/speaker-embeddings.js'
8
9
  import { statSync } from 'node:fs'
9
10
  import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
@@ -48,6 +49,20 @@ voiceRouter.post('/voice/enroll', async (req, res) => {
48
49
  // under a stranger's name.
49
50
  const name = (req.query.name as string) || getOwnerSpeakerLabel()
50
51
 
52
+ // An old client can still send a whole spoken sentence as the name (the
53
+ // "enroll my voice" fall-through). Refuse it here: a junk profile can
54
+ // never match owner_speaker_label, so /voice/status would report
55
+ // enrolled:false forever, and the store cannot be repaired by editing
56
+ // voice-profiles.json — the server rewrites it from memory.
57
+ const nameCheck = checkSpeakerName(name, { ownerLabel: getOwnerSpeakerLabel() })
58
+ if (!nameCheck.ok) {
59
+ return res.status(400).json({
60
+ success: false,
61
+ error: nameCheck.message,
62
+ reason: nameCheck.reason,
63
+ })
64
+ }
65
+
51
66
  // Collect raw audio body
52
67
  const buffers: Buffer[] = []
53
68
  for await (const chunk of req) {