@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 +17 -0
- package/CHANGELOG.md +106 -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 +420 -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,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
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
package/server/routes/health.ts
CHANGED
|
@@ -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,
|
package/server/routes/meeting.ts
CHANGED
|
@@ -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
|
-
|
|
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),
|
package/server/routes/voice.ts
CHANGED
|
@@ -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) {
|