@gotcos/glasses-server 6.36.28 → 6.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +22 -0
- package/CHANGELOG.md +133 -0
- package/package.json +1 -1
- package/server/lib/adaptive-playback-audio.ts +4 -1
- package/server/lib/even-hub-speaker-role.ts +116 -0
- package/server/lib/meeting-store.ts +13 -4
- package/server/lib/meeting-summary-budget.ts +90 -0
- package/server/lib/meeting-summary-persistence.ts +179 -0
- package/server/lib/meeting-summary.ts +429 -0
- package/server/lib/speaker-name.ts +89 -0
- package/server/lib/video-upload-v2.ts +5 -1
- package/server/routes/agent-session-bindings.ts +3 -1
- package/server/routes/health.ts +7 -0
- package/server/routes/meeting.ts +22 -1
- package/server/routes/transcribe-stream.ts +25 -0
- package/server/routes/voice.ts +15 -0
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
|
@@ -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
|
-
|
|
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
|
-
//
|
|
325
|
-
//
|
|
326
|
-
// the
|
|
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
|
-
?
|
|
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
|
+
}
|