@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.
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,166 @@
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
+
107
+ ## 6.36.28
108
+
109
+ THE bug. Three earlier fixes tonight were each real and none was this one.
110
+
111
+ Every segment of a reply is minted at /prepare with a 120s idle deadline that
112
+ starts AT MINT. But the client only touches segment k when segment k-1 starts
113
+ playing. Measured on device, 6,781 chars at 1.25x:
114
+
115
+ seg 4 first touched t+101s played
116
+ seg 5 first touched t+147s FAILED
117
+ seg 6 first touched t+215s FAILED
118
+ seg 7 never reached FAILED
119
+
120
+ Segment 5 was deleted 27s before the client first asked for it. Reproduced with
121
+ curl against the running server, replaying the exact timeline: HTTP 404 at
122
+ segment 5, t+216s. The 404 reaches the audio element as NotSupportedError at
123
+ readyState 0 -- identical to every other media failure, which is why a char cap,
124
+ a wider idle window and a render gate all left playback stopping at segment 5.
125
+
126
+ WHAT CHANGED
127
+
128
+ - An unread session gets a grace window DERIVED from how long the whole reply
129
+ takes to speak, plus one segment of margin (the last segment is first touched
130
+ when the second-to-last starts playing).
131
+ - A read may only ever EXTEND a deadline, never shorten one. This is not
132
+ cosmetic: the client warms segment k one whole segment before playing it, so
133
+ collapsing the grace on that first read SPENT it. At 0.5x a 900-char segment
134
+ is 170s of wall time against a 120s window, and the reply lost segment 1 while
135
+ holding a 1,476s grace. Found by QA, after the first version of this fix.
136
+ - SESSION_IDLE_MS and SESSION_MAX_LIFETIME_MS are now COMPUTED from shared
137
+ constants rather than written down. Both were derived from ~19 chars/sec, the
138
+ FAST voice, and this release is the one that measured the slow voice at 10.
139
+ Leaving them meant the file asserted two contradictory worst cases:
140
+ idle 900 / 10 / 0.5 = 180s against a 120s window
141
+ ceiling 40,000 / 10 / 0.5 = 133 min against a 90 min ceiling
142
+ The ceiling had also started silently truncating the grace for any reply over
143
+ ~26,400 chars, relocating the same failure to roughly segment 31 of 46.
144
+ - MAX_LOCAL_TTS_CHARS, LATER_CHUNK_CHARS, the speech rate and the minimum
145
+ playback rate now live in one place and are imported by the tests that used to
146
+ restate them. That duplication is why the contradiction above was invisible.
147
+ - /play now logs the 404 it produces. The server held this fact all evening and
148
+ recorded nothing, so the only reporter was a fire-and-forget client call.
149
+ - CORS exposes Content-Range and Accept-Ranges, without which the client's
150
+ failure probe reads them as null from the null-origin companion WebView.
151
+
152
+ SECURITY, stated plainly rather than buried
153
+
154
+ An unread capability now lives far longer than 120 seconds -- up to the ceiling,
155
+ which is 136 minutes. That is a real widening and it is the price of playing a
156
+ 40,000-character reply, which genuinely takes over two hours at 0.5x. What is
157
+ unchanged: the ceiling is absolute, reading cannot extend a capability past the
158
+ grace it was minted with, and an unread capability still expires on its own.
159
+ Both properties are pinned and both mutate red.
160
+
161
+ 216 files, 3047 tests in scope (218 / 3053 including a parallel session's two
162
+ files, which are not part of this release).
163
+
1
164
  ## 6.36.27
2
165
 
3
166
  The sidecar renders one request at a time. The server finally acts like it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.27",
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": {
package/server/index.ts CHANGED
@@ -172,6 +172,12 @@ app.use(cors({
172
172
  if (isAllowedNetworkOrigin(origin)) return cb(null, true)
173
173
  cb(new Error('CORS blocked'))
174
174
  },
175
+ // Without this, a cross-origin reader sees only the CORS-safelisted response
176
+ // headers. The companion's origin is `null` (a file:// WebView), so it is
177
+ // cross-origin by definition, and the TTS failure probe would log
178
+ // contentRange/acceptRanges as null on every single call -- leading the next
179
+ // reader to conclude the server had stopped sending Range headers.
180
+ exposedHeaders: ['Content-Range', 'Accept-Ranges'],
175
181
  }))
176
182
  // Auth middleware — always active (token is auto-generated if not set).
177
183
  // Mounted before body parsers so rejected uploads cannot consume parse memory.
@@ -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
+ }