@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 CHANGED
@@ -177,6 +177,28 @@ 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 ON since 6.37.0. Set to 0 to opt out; only a literal 0 disables.
186
+ # With it off you still get the deterministic tier — speakers, talk-time, word
187
+ # count, opening excerpt — which costs nothing and always runs.
188
+ #
189
+ # This spends tokens on YOUR provider plan, unlike the display toggles. It is
190
+ # bounded by a per-day cap (below) counted only on a validated summary, a
191
+ # circuit breaker, a minimum word floor, a single-slot queue and a wall budget.
192
+ #
193
+ # Never runs when COS_SCRIPTS_DIR is set: overwriting the summary section would
194
+ # erase the markers sync_meetings.py uses to decide what to process.
195
+ # COS_MEETING_SUMMARY=0
196
+ #
197
+ # Calls per local day before the summariser falls back to the deterministic
198
+ # tier for the rest of the day. Default 40.
199
+ # COS_MEETING_SUMMARY_DAILY_CAP=40
200
+
201
+
180
202
  # ── RETAINED MEETING-AUDIO PLAYBACK CLEANUP (6.21.32 canary) ────────────
181
203
  # Default OFF. When enabled, the first authenticated Play of a retained raw
182
204
  # meeting chunk creates a cached, adaptive cleanup copy. Raw WAV evidence stays
package/CHANGELOG.md CHANGED
@@ -1,3 +1,136 @@
1
+ ## 6.37.1
2
+
3
+ Standalone meeting summaries default ON.
4
+
5
+ 6.37.0 shipped everything else in this release — the honest summary slot, the
6
+ two enrichment tiers, the voice-profile name validator, and Continue threads /
7
+ video uploads / adaptive audio cleanup all defaulting on — but it went out with
8
+ COS_MEETING_SUMMARY still defaulting OFF. That meant the headline fix, a meeting
9
+ that comes back with a real summary, reached nobody unless they found an
10
+ undocumented environment variable.
11
+
12
+ Only a literal '0' disables, so a stray or malformed value cannot silently
13
+ switch summaries off.
14
+
15
+ This is a different class of default from the display toggles in 6.37.0: it
16
+ spends tokens on the USER'S OWN provider plan. Every control in the module is
17
+ load-bearing rather than decorative — a per-day cap counted only on a validated
18
+ summary, a circuit breaker with its own accounting, a minimum word floor, a
19
+ single-slot queue, and a wall budget that keeps finalization under COS Control's
20
+ 90s restart proof.
21
+
22
+ No other change. Anyone already on 6.37.0 who set COS_MEETING_SUMMARY=1 by hand
23
+ is unaffected.
24
+
25
+ ## 6.37.0
26
+
27
+ Meetings recorded without the COS operations pipeline now get a real summary,
28
+ topics, decisions and action items. Until now they got none of that, and the
29
+ server compensated by returning the TRANSCRIPT in the summary slot -- which is
30
+ why this was reported as "no proper summary and transcription."
31
+
32
+ FEATURE DEFAULTS NOW ON (Miles 2026-08-25)
33
+
34
+ Ship the capability active; users opt out of what they do not need. Each gate
35
+ stays strict in the other direction -- only a literal '0' disables, so a stray
36
+ or malformed value cannot silently switch a feature off.
37
+
38
+ COS_THREAD_ATTACH_ENABLED Continue agent threads
39
+ COS_VIDEO_UPLOAD_V2 Reliable video uploads
40
+ COS_MEETING_AUDIO_ADAPTIVE_PLAYBACK Adaptive audio cleanup
41
+
42
+ COS_WHISPER_MEETING_PREVIEW (Meeting Turbo preview) was ALREADY on by default
43
+ server-side; COS Control mis-renders its checkbox as off because it resolves
44
+ the value as == "1" rather than != "0". That is a Control display bug, not a
45
+ server default, and is not fixed here.
46
+
47
+ Idle Metal HQ (COS_BATCH_HQ_METAL) is deliberately unchanged -- it is
48
+ hardware-dependent and stays opt-in.
49
+
50
+ Two consequences worth knowing. An in-flight video upload sets blocksRestart,
51
+ so a drain caught mid-upload waits for it -- that is the contract, not a stuck
52
+ gate, and must never be --forced. Adaptive playback affects review playback
53
+ only; the raw WAV is untouched, and transcript, attribution, save and sync are
54
+ unchanged.
55
+
56
+ VOICE ENROLMENT
57
+
58
+ "enroll my voice" followed by more speech created a profile NAMED with the
59
+ entire spoken utterance -- a ~40-second transcript. Reported by a first-time
60
+ user. Because a junk name never equals owner_speaker_label,
61
+ /api/voice/status reported `enrolled: false` no matter how many times you
62
+ enrolled, and editing voice-profiles.json by hand did not help because the
63
+ server rewrites it from memory.
64
+
65
+ Root cause was client-side: a $-anchored command regex fell through to the
66
+ named-enrolment branch, whose capture group had no length bound. Fixed in
67
+ COS Glasses 6.8.433.
68
+
69
+ POST /api/voice/enroll now validates ?name= as well, so an OLD client cannot
70
+ write a sentence into the profile store: at most 40 characters, at most 4
71
+ words, letters/spaces/hyphens/apostrophes only, no sentence punctuation, and
72
+ no self-referential phrasing. The wearer's own label is always accepted --
73
+ including the default "Me", which is itself self-referential and would
74
+ otherwise have been rejected by its own guard.
75
+
76
+ WHAT CHANGED
77
+
78
+ meeting-store parseMeeting no longer substitutes the transcript into `summary`.
79
+ A standalone placeholder now yields an empty summary, and the transcript is
80
+ carried in its own `transcript` field, which the reader renders as its own
81
+ section (requires COS Glasses 6.8.433). estimatedDetailPages now counts the
82
+ transcript, so a list row no longer advertises ~1p for a meeting that paginates
83
+ to many.
84
+
85
+ Two enrichment tiers, both standalone-only:
86
+
87
+ extractive always on, zero tokens -- duration, word count, speaker roster
88
+ with talk-time split, opening excerpt. Fabricates nothing:
89
+ topics/decisions/actions come back empty rather than guessed.
90
+
91
+ llm DEFAULT ON. One `claude -p --model haiku` per meeting at the
92
+ ops_pending phase of finalization. Set COS_MEETING_SUMMARY=0 to
93
+ opt out; only a literal 0 disables. This spends tokens on the
94
+ USER'S OWN provider plan, which is why every control below is
95
+ load-bearing rather than decorative.
96
+
97
+ It runs at ops_pending specifically because batch HQ transcription replaces the
98
+ whole transcript; summarising earlier would describe text the file no longer
99
+ contains. Every generated section is inserted BEFORE `## Transcript`, because
100
+ replaceMeetingTranscriptAtomic replaces from that marker to end of file.
101
+
102
+ NEVER RUNS FOR OPERATIONS USERS, and refuses twice: the caller gates on
103
+ cosOpsPipelineConfigured(), and the writer independently refuses any file
104
+ carrying the sync_meetings.py markers. Overwriting the summary section would
105
+ erase them and the pipeline would skip the meeting entirely -- losing domain
106
+ reclassification, task extraction and the operations copy.
107
+
108
+ COST CONTROLS
109
+ - COS_MEETING_SUMMARY, read live (not frozen at module load), default ON
110
+ - haiku pinned explicitly, so it can never inherit an Opus session default
111
+ - own daily cap (COS_MEETING_SUMMARY_DAILY_CAP, default 40), never shared
112
+ with the archive counter; committed only after a validated summary, so a
113
+ failure, auth refusal or malformed reply costs nothing
114
+ - circuit breaker with its own failure accounting
115
+ - single-slot queue, so a boot replay cannot spawn one provider per meeting
116
+ - wall budget anchored to the finalization job start, keeping batch decode +
117
+ summariser + handoff under COS Control's 90s waitForRestartProof
118
+ - minimum word floor; head+tail input bound, never head-only
119
+ - surfaced on /api/health as meeting_summary
120
+
121
+ An unauthenticated CLI exits ZERO with a success-shaped payload carrying the
122
+ bearer token. The result is screened with terminalProviderAuthFailure before
123
+ anything is written, so a 401 can never be persisted into a meeting file.
124
+
125
+ Generated prose never attributes statements to a speaker, and diarisation
126
+ labels ("Speaker 2", "Ext", "MU") are stripped from action-item owners -- the
127
+ speaker-review flow deliberately never rewrites these sections, so a label
128
+ captured here would stay wrong permanently.
129
+
130
+ A standalone save with no audio now schedules a finalization pass too; before
131
+ this it was nulled and never reached ops_pending. Orphan recovery, which has
132
+ its own path, enriches as well.
133
+
1
134
  ## 6.36.28
2
135
 
3
136
  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.1",
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
+ }