@gotcos/glasses-server 6.21.30 → 6.21.32
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 +8 -0
- package/CHANGELOG.md +76 -0
- package/README.md +13 -0
- package/package.json +1 -1
- package/server/lib/adaptive-playback-audio.ts +384 -0
- package/server/lib/cos-operations-meetings.ts +25 -13
- package/server/lib/domains.ts +253 -0
- package/server/lib/meeting-audio-archive.ts +41 -8
- package/server/lib/meeting-store.ts +17 -6
- package/server/lib/profile.ts +9 -0
- package/server/routes/health.ts +5 -1
- package/server/routes/meeting.ts +46 -6
package/.env.example
CHANGED
|
@@ -167,6 +167,14 @@ BIND_HOST=0.0.0.0
|
|
|
167
167
|
# deliberately independent from the progressive-HQ compute switch.
|
|
168
168
|
# COS_MEETING_EARLY_SYNC=1
|
|
169
169
|
|
|
170
|
+
# ── RETAINED MEETING-AUDIO PLAYBACK CLEANUP (6.21.32 canary) ────────────
|
|
171
|
+
# Default OFF. When enabled, the first authenticated Play of a retained raw
|
|
172
|
+
# meeting chunk creates a cached, adaptive cleanup copy. Raw WAV evidence stays
|
|
173
|
+
# byte-identical; capture, live preview, canonical transcription, HQ, and sync
|
|
174
|
+
# are untouched. Any FFmpeg/analyzer failure serves raw. Use `?raw=1` on the
|
|
175
|
+
# playback URL for a per-request A/B comparison.
|
|
176
|
+
# COS_MEETING_AUDIO_ADAPTIVE_PLAYBACK=1
|
|
177
|
+
|
|
170
178
|
# ── UNSAVED-CAPTURE QUARANTINE (6.19.0) ─────────────────────────────────
|
|
171
179
|
# Meeting audio whose save never landed is QUARANTINED, never deleted. It
|
|
172
180
|
# surfaces on /api/health (unsaved_captures) and, with COS Control 0.3.1+,
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,79 @@
|
|
|
1
|
+
## 6.21.32
|
|
2
|
+
|
|
3
|
+
- **Adaptive meeting-audio cleanup is a default-off, replay-only canary.** When
|
|
4
|
+
`COS_MEETING_AUDIO_ADAPTIVE_PLAYBACK=1`, the authenticated retained-audio
|
|
5
|
+
playback route profiles each PCM chunk as hot/clipped, hot, quiet,
|
|
6
|
+
wind/noisy, or clean indoor, then generates a bounded cleaned WAV on first
|
|
7
|
+
play. Later plays use the cached copy.
|
|
8
|
+
- **Raw evidence is immutable and remains the fallback.** Derived files live
|
|
9
|
+
beside retained raw chunks under a versioned name, count against the existing
|
|
10
|
+
8 GB archive cap, and cannot extend the seven-day retention clock. Unsupported
|
|
11
|
+
WAVs, missing FFmpeg, timeouts, and invalid output all serve the original raw
|
|
12
|
+
file. Cache-orphan cleanup deletes only directories proven to contain derived
|
|
13
|
+
playback files alone; unknown entries or failed stats retain the directory.
|
|
14
|
+
`?raw=1` provides an authenticated per-request A/B escape hatch.
|
|
15
|
+
- **A live recording always wins.** Play requests made while any meeting is
|
|
16
|
+
actively recording bypass cleanup and serve raw. If recording starts after
|
|
17
|
+
cleanup was admitted, the one global cleanup worker is preempted within
|
|
18
|
+
100 ms; requests for other chunks while it is busy immediately serve raw.
|
|
19
|
+
FFmpeg falls back inside eight seconds, ahead of Control's media deadline.
|
|
20
|
+
- **No live or canonical path changed.** Capture, Turbo preview, Large-v3
|
|
21
|
+
canonical transcription, speaker attribution, meeting save, HQ polish, and
|
|
22
|
+
meeting sync do not import or call the cleanup module. Health reports the
|
|
23
|
+
active policy, generated/cache/fallback counters, and raw-preservation
|
|
24
|
+
contract for COS Control and field diagnostics.
|
|
25
|
+
|
|
26
|
+
## 6.21.31
|
|
27
|
+
|
|
28
|
+
Domains belong to the user. Four places in this codebase hardcoded ONE user's
|
|
29
|
+
business units, a fifth pretended to make them configurable, and two of them
|
|
30
|
+
disagreed with each other. A second person set up their own COS on 2026-08-08 and
|
|
31
|
+
nothing she could name would work.
|
|
32
|
+
|
|
33
|
+
- **New `lib/domains.ts` — one definition of each of these, replacing five.** What
|
|
34
|
+
it replaced: `['quilt','sprocket_rocket','hermit_crabs','personal']` in the
|
|
35
|
+
operations lister (used for listing, sidecar lookup, filtering, AND as the
|
|
36
|
+
path-traversal guard); a hand-written badge table; `domain.slice(0,2)` in the
|
|
37
|
+
meeting store, which rendered `sprocket_rocket` as "SP" while the lister said
|
|
38
|
+
"SR"; and `getDomainKeywords()` in `profile.ts`, exported with **zero call
|
|
39
|
+
sites** — a whole configuration chain built and never connected, which reads as
|
|
40
|
+
"domains are configurable" to anyone who greps for it.
|
|
41
|
+
|
|
42
|
+
- **Domains are the UNION of your configuration and what is on disk.** The union
|
|
43
|
+
is load-bearing, not incidental. Configured with no folder yet: still listed, so
|
|
44
|
+
a new install can be routed before any folder exists. On disk but unconfigured:
|
|
45
|
+
still listed, so a folder made by hand never becomes invisible — and that is
|
|
46
|
+
what makes this change safe for an existing install, whose profile configures
|
|
47
|
+
nothing and whose folders resolve exactly as before. Nothing is written to any
|
|
48
|
+
existing profile. Neither: the defaults.
|
|
49
|
+
|
|
50
|
+
- **Defaults are `personal` and `business`, badged P and B.** Two, not four, and
|
|
51
|
+
only a genuinely fresh COS ever sees them. Set your own in
|
|
52
|
+
`.cos-profile.json` — a bare list works (`"domains": ["personal","work"]`), or
|
|
53
|
+
objects with `keywords` and an `abbr` override.
|
|
54
|
+
|
|
55
|
+
- **A meeting with no domain from the client is now routed by content.** Keyword
|
|
56
|
+
scoring over title and transcript, counting DISTINCT matched keywords so one
|
|
57
|
+
word repeated forty times cannot outvote four different signals, word-boundary
|
|
58
|
+
matched so "car" does not fire inside "carrier". Deliberately not a model call:
|
|
59
|
+
this runs on every save. Nothing scoring falls back to `personal`, the safe
|
|
60
|
+
direction — a work meeting misfiled as personal is a nuisance the user fixes,
|
|
61
|
+
while a personal conversation filed under a business domain can be pasted into
|
|
62
|
+
a work channel.
|
|
63
|
+
|
|
64
|
+
- **A domain name is checked for SAFETY, not style.** The old save-path pattern
|
|
65
|
+
`/^[a-z][a-z0-9_]{0,31}$/` accepted `sprocket_rocket` and rejected `DNP study`,
|
|
66
|
+
so a user could select that folder and then never save a meeting into it. The
|
|
67
|
+
store also lowercased the name, which turned `DNP study` into `dnp study` and
|
|
68
|
+
matched no directory on disk. Spaces and mixed case are now fine; traversal,
|
|
69
|
+
control characters and hidden names are still refused.
|
|
70
|
+
|
|
71
|
+
- **A domain must hold the shape the lister reads.** A `meetings/` folder is no
|
|
72
|
+
longer enough: it must contain at least one `YYYY-MM` month directory. Measured
|
|
73
|
+
on a real install, `operations/archive/meetings/` holds domain names rather than
|
|
74
|
+
months, so a bare directory check listed it as a domain with permanently zero
|
|
75
|
+
meetings. Structural, so no blocklist of names was needed.
|
|
76
|
+
|
|
1
77
|
## 6.21.30
|
|
2
78
|
|
|
3
79
|
- **A name you removed by hand is now stated, and the stale write-up is called
|
package/README.md
CHANGED
|
@@ -279,6 +279,19 @@ available CPUs. `COS_MEETING_EARLY_SYNC=1` separately gives the Operations sync
|
|
|
279
279
|
pipeline a stable meeting identity before HQ completes. Either switch can be
|
|
280
280
|
disabled without changing canonical live transcription or raw meeting audio.
|
|
281
281
|
|
|
282
|
+
Server 6.21.32 adds a separate default-off cleanup canary for retained review
|
|
283
|
+
audio. Set `COS_MEETING_AUDIO_ADAPTIVE_PLAYBACK=1` (or use COS Control 0.5.11+)
|
|
284
|
+
to profile a retained PCM chunk and create a cached playback-only copy when a
|
|
285
|
+
reviewer presses Play. The raw WAV remains byte-identical, still owns the
|
|
286
|
+
seven-day retention clock, and is served on every analyzer/FFmpeg failure.
|
|
287
|
+
Capture, live preview, canonical transcription, speaker attribution, save, HQ,
|
|
288
|
+
and meeting sync are unchanged. Append `?raw=1` to an authenticated playback
|
|
289
|
+
URL for an immediate raw-versus-cleaned A/B check.
|
|
290
|
+
While a meeting is actively recording, playback automatically stays raw so the
|
|
291
|
+
optional cleanup process cannot contend with live transcription. Cleanup uses
|
|
292
|
+
one global worker, serves raw while that worker is busy, and preempts within
|
|
293
|
+
100 ms if a meeting starts after a replay request was admitted.
|
|
294
|
+
|
|
282
295
|
The first server start downloads the real-time turbo model. True HQ additionally
|
|
283
296
|
requires the full `ggml-large-v3.bin` model (about 3.1 GB):
|
|
284
297
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// Adaptive cleanup for RETAINED meeting-audio playback only.
|
|
2
|
+
//
|
|
3
|
+
// This module deliberately does not sit on the live capture, preview, canonical
|
|
4
|
+
// transcription, speaker-attribution, save, HQ, or sync paths. A reviewer asks
|
|
5
|
+
// to hear a retained raw chunk; on first play we derive a bounded cleaned WAV,
|
|
6
|
+
// cache it beside the raw evidence, and serve that copy. Raw is never rewritten.
|
|
7
|
+
// Any unsupported input, missing ffmpeg, timeout, or invalid output falls back
|
|
8
|
+
// to the exact raw path the route would have served before this feature existed.
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import {
|
|
12
|
+
chmodSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
statSync,
|
|
17
|
+
unlinkSync,
|
|
18
|
+
} from 'node:fs'
|
|
19
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
20
|
+
import { randomUUID } from 'node:crypto'
|
|
21
|
+
import { invalidateMeetingAudioStats } from './meeting-audio-archive.js'
|
|
22
|
+
|
|
23
|
+
const FILTER_VERSION = 1
|
|
24
|
+
// Control's bounded media request is 12s. Cleanup must fail back to raw before
|
|
25
|
+
// that client deadline, with margin for the response body to transfer.
|
|
26
|
+
const FFMPEG_TIMEOUT_MS = 8_000
|
|
27
|
+
const LIVE_PREEMPT_POLL_MS = 100
|
|
28
|
+
// Capture chunks are seconds long (~200 KB). Four MiB still tolerates a wildly
|
|
29
|
+
// oversized chunk while bounding synchronous profiling on the server event loop.
|
|
30
|
+
const MAX_INPUT_BYTES = 4 * 1024 * 1024
|
|
31
|
+
const MAX_STDERR_BYTES = 4_096
|
|
32
|
+
|
|
33
|
+
export type AdaptivePlaybackProfile =
|
|
34
|
+
| 'hot_clipped'
|
|
35
|
+
| 'hot'
|
|
36
|
+
| 'quiet'
|
|
37
|
+
| 'wind_noisy'
|
|
38
|
+
| 'clean_indoor'
|
|
39
|
+
|
|
40
|
+
export interface AudioSignalProfile {
|
|
41
|
+
profile: AdaptivePlaybackProfile
|
|
42
|
+
sampleRate: number
|
|
43
|
+
channels: number
|
|
44
|
+
samples: number
|
|
45
|
+
peakDbfs: number
|
|
46
|
+
rmsDbfs: number
|
|
47
|
+
clippedSampleRatio: number
|
|
48
|
+
lowFrequencyEnergyRatio: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AdaptivePlaybackResult {
|
|
52
|
+
path: string
|
|
53
|
+
mode: 'raw' | 'adaptive'
|
|
54
|
+
profile: AdaptivePlaybackProfile | null
|
|
55
|
+
reason?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AdaptivePlaybackOptions {
|
|
59
|
+
/** Fail-safe admission/preemption hook supplied by the meeting route. */
|
|
60
|
+
shouldAbort?: () => boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const FILTERS: Record<AdaptivePlaybackProfile, string> = {
|
|
64
|
+
hot_clipped: 'adeclip,highpass=f=100,afftdn=nt=w,volume=-8dB,alimiter=limit=0.794,loudnorm=I=-17:LRA=9:TP=-2',
|
|
65
|
+
hot: 'highpass=f=80,volume=-6dB,alimiter=limit=0.794,loudnorm=I=-17:LRA=9:TP=-2',
|
|
66
|
+
quiet: 'highpass=f=80,afftdn=nt=w,volume=6dB,alimiter=limit=0.891',
|
|
67
|
+
wind_noisy: 'highpass=f=140,afftdn=nt=w,loudnorm=I=-18:LRA=9:TP=-2',
|
|
68
|
+
clean_indoor: 'highpass=f=80,alimiter=limit=0.891',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type Pcm16 = {
|
|
72
|
+
sampleRate: number
|
|
73
|
+
channels: number
|
|
74
|
+
dataOffset: number
|
|
75
|
+
dataBytes: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function pcm16Wav(buffer: Buffer): Pcm16 | null {
|
|
79
|
+
if (buffer.length < 44 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') return null
|
|
80
|
+
let offset = 12
|
|
81
|
+
let audioFormat = 0
|
|
82
|
+
let channels = 0
|
|
83
|
+
let sampleRate = 0
|
|
84
|
+
let bitsPerSample = 0
|
|
85
|
+
let dataOffset = -1
|
|
86
|
+
let dataBytes = 0
|
|
87
|
+
while (offset + 8 <= buffer.length) {
|
|
88
|
+
const id = buffer.toString('ascii', offset, offset + 4)
|
|
89
|
+
const size = buffer.readUInt32LE(offset + 4)
|
|
90
|
+
const start = offset + 8
|
|
91
|
+
const end = start + size
|
|
92
|
+
if (end > buffer.length) return null
|
|
93
|
+
if (id === 'fmt ' && size >= 16) {
|
|
94
|
+
audioFormat = buffer.readUInt16LE(start)
|
|
95
|
+
channels = buffer.readUInt16LE(start + 2)
|
|
96
|
+
sampleRate = buffer.readUInt32LE(start + 4)
|
|
97
|
+
bitsPerSample = buffer.readUInt16LE(start + 14)
|
|
98
|
+
} else if (id === 'data') {
|
|
99
|
+
dataOffset = start
|
|
100
|
+
dataBytes = size
|
|
101
|
+
}
|
|
102
|
+
offset = end + (size % 2)
|
|
103
|
+
}
|
|
104
|
+
if (audioFormat !== 1 || channels !== 1 || bitsPerSample !== 16 || sampleRate < 8_000 || sampleRate > 96_000) return null
|
|
105
|
+
if (dataOffset < 0 || dataBytes < 2 || dataOffset + dataBytes > buffer.length || dataBytes % 2 !== 0) return null
|
|
106
|
+
return { sampleRate, channels, dataOffset, dataBytes }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function dbfs(value: number): number {
|
|
110
|
+
if (!(value > 0)) return -120
|
|
111
|
+
return Math.round(20 * Math.log10(Math.min(1, value)) * 10) / 10
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Pure, deterministic profiler. It never shells out and never mutates audio. */
|
|
115
|
+
export function analyzePlaybackWav(buffer: Buffer): AudioSignalProfile | null {
|
|
116
|
+
const wav = pcm16Wav(buffer)
|
|
117
|
+
if (!wav) return null
|
|
118
|
+
const count = wav.dataBytes / 2
|
|
119
|
+
let peak = 0
|
|
120
|
+
let energy = 0
|
|
121
|
+
let lowEnergy = 0
|
|
122
|
+
let clipped = 0
|
|
123
|
+
let low = 0
|
|
124
|
+
// One-pole 120 Hz low-pass. This is only a conservative wind/noise proxy;
|
|
125
|
+
// it selects a stronger high-pass profile and never changes canonical text.
|
|
126
|
+
const alpha = 1 - Math.exp((-2 * Math.PI * 120) / wav.sampleRate)
|
|
127
|
+
for (let i = 0; i < count; i++) {
|
|
128
|
+
const raw = wav.dataOffset + i * 2
|
|
129
|
+
const sample = buffer.readInt16LE(raw) / 32768
|
|
130
|
+
const magnitude = Math.abs(sample)
|
|
131
|
+
peak = Math.max(peak, magnitude)
|
|
132
|
+
energy += sample * sample
|
|
133
|
+
low += alpha * (sample - low)
|
|
134
|
+
lowEnergy += low * low
|
|
135
|
+
if (Math.abs(buffer.readInt16LE(raw)) >= 32_760) clipped++
|
|
136
|
+
}
|
|
137
|
+
const rms = Math.sqrt(energy / count)
|
|
138
|
+
const clippedSampleRatio = clipped / count
|
|
139
|
+
const lowFrequencyEnergyRatio = energy > 0 ? Math.min(1, lowEnergy / energy) : 0
|
|
140
|
+
const rmsDbfs = dbfs(rms)
|
|
141
|
+
const peakDbfs = dbfs(peak)
|
|
142
|
+
let profile: AdaptivePlaybackProfile
|
|
143
|
+
if (clippedSampleRatio >= 0.0005 || rmsDbfs >= -12 || peakDbfs >= -0.3) profile = 'hot_clipped'
|
|
144
|
+
else if (lowFrequencyEnergyRatio >= 0.38 && rmsDbfs > -35) profile = 'wind_noisy'
|
|
145
|
+
else if (rmsDbfs >= -18) profile = 'hot'
|
|
146
|
+
else if (rmsDbfs <= -32) profile = 'quiet'
|
|
147
|
+
else profile = 'clean_indoor'
|
|
148
|
+
return {
|
|
149
|
+
profile,
|
|
150
|
+
sampleRate: wav.sampleRate,
|
|
151
|
+
channels: wav.channels,
|
|
152
|
+
samples: count,
|
|
153
|
+
peakDbfs,
|
|
154
|
+
rmsDbfs,
|
|
155
|
+
clippedSampleRatio: Math.round(clippedSampleRatio * 1_000_000) / 1_000_000,
|
|
156
|
+
lowFrequencyEnergyRatio: Math.round(lowFrequencyEnergyRatio * 1_000) / 1_000,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function adaptivePlaybackEnabled(): boolean {
|
|
161
|
+
// Private canary: explicit opt-in, explicit 0 rollback through COS Control.
|
|
162
|
+
return process.env.COS_MEETING_AUDIO_ADAPTIVE_PLAYBACK === '1'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function outputPathFor(rawPath: string): string | null {
|
|
166
|
+
const rawName = basename(rawPath)
|
|
167
|
+
const match = /^chunk_(\d+)\.wav$/.exec(rawName)
|
|
168
|
+
if (!match) return null
|
|
169
|
+
const dir = dirname(rawPath)
|
|
170
|
+
const output = resolve(dir, `playback_v${FILTER_VERSION}_${match[1]}.wav`)
|
|
171
|
+
return output.startsWith(resolve(dir) + '/') ? output : null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function cachedOutput(rawPath: string, outputPath: string): boolean {
|
|
175
|
+
try {
|
|
176
|
+
const raw = statSync(rawPath)
|
|
177
|
+
const output = statSync(outputPath)
|
|
178
|
+
return output.isFile()
|
|
179
|
+
&& output.size > 44
|
|
180
|
+
&& output.mtimeMs >= raw.mtimeMs
|
|
181
|
+
&& analyzePlaybackWav(readFileSync(outputPath)) !== null
|
|
182
|
+
} catch {
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function abortRequested(check?: () => boolean): boolean {
|
|
188
|
+
if (!check) return false
|
|
189
|
+
try { return check() }
|
|
190
|
+
catch { return true }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function runFfmpeg(
|
|
194
|
+
inputPath: string,
|
|
195
|
+
outputPath: string,
|
|
196
|
+
filter: string,
|
|
197
|
+
shouldAbort?: () => boolean,
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
200
|
+
const proc = spawn('ffmpeg', [
|
|
201
|
+
'-nostdin',
|
|
202
|
+
'-hide_banner',
|
|
203
|
+
'-loglevel', 'error',
|
|
204
|
+
'-i', inputPath,
|
|
205
|
+
'-af', filter,
|
|
206
|
+
'-ar', '16000',
|
|
207
|
+
'-ac', '1',
|
|
208
|
+
'-c:a', 'pcm_s16le',
|
|
209
|
+
'-f', 'wav',
|
|
210
|
+
'-y',
|
|
211
|
+
outputPath,
|
|
212
|
+
], { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
213
|
+
let settled = false
|
|
214
|
+
let stderr = ''
|
|
215
|
+
let terminalError: Error | null = null
|
|
216
|
+
let killSettle: NodeJS.Timeout | null = null
|
|
217
|
+
let livePoll: NodeJS.Timeout | null = null
|
|
218
|
+
const finish = (error?: Error) => {
|
|
219
|
+
if (settled) return
|
|
220
|
+
settled = true
|
|
221
|
+
clearTimeout(timeout)
|
|
222
|
+
if (killSettle) clearTimeout(killSettle)
|
|
223
|
+
if (livePoll) clearInterval(livePoll)
|
|
224
|
+
error ? rejectPromise(error) : resolvePromise()
|
|
225
|
+
}
|
|
226
|
+
const stop = (error: Error) => {
|
|
227
|
+
if (settled || terminalError) return
|
|
228
|
+
terminalError = error
|
|
229
|
+
if (!proc.kill('SIGKILL')) { finish(error); return }
|
|
230
|
+
// SIGKILL should close the direct ffmpeg child immediately. Keep a bounded
|
|
231
|
+
// settle fallback so a broken process handle cannot strand the request.
|
|
232
|
+
killSettle = setTimeout(() => finish(error), 1_000)
|
|
233
|
+
killSettle.unref()
|
|
234
|
+
}
|
|
235
|
+
proc.stderr?.on('data', (chunk: Buffer) => {
|
|
236
|
+
stderr = (stderr + chunk.toString()).slice(-MAX_STDERR_BYTES)
|
|
237
|
+
})
|
|
238
|
+
const timeout = setTimeout(() => {
|
|
239
|
+
stop(new Error(`ffmpeg timeout (${FFMPEG_TIMEOUT_MS / 1000}s)`))
|
|
240
|
+
}, FFMPEG_TIMEOUT_MS)
|
|
241
|
+
timeout.unref()
|
|
242
|
+
if (shouldAbort) {
|
|
243
|
+
livePoll = setInterval(() => {
|
|
244
|
+
if (abortRequested(shouldAbort)) stop(new Error('live_recording_started'))
|
|
245
|
+
}, LIVE_PREEMPT_POLL_MS)
|
|
246
|
+
livePoll.unref()
|
|
247
|
+
}
|
|
248
|
+
proc.on('error', error => finish(error))
|
|
249
|
+
proc.on('close', code => {
|
|
250
|
+
if (terminalError) finish(terminalError)
|
|
251
|
+
else if (code !== 0) finish(new Error(`ffmpeg exit ${code}: ${stderr.trim().slice(-300)}`))
|
|
252
|
+
else finish()
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const inFlight = new Map<string, Promise<AdaptivePlaybackResult>>()
|
|
258
|
+
// One cleanup worker for the whole server. Different retained chunks never fan
|
|
259
|
+
// out into competing ffmpeg children; busy requests immediately hear raw.
|
|
260
|
+
let activeGenerationPath: string | null = null
|
|
261
|
+
const counters = {
|
|
262
|
+
generated: 0,
|
|
263
|
+
cacheHits: 0,
|
|
264
|
+
fallbacks: 0,
|
|
265
|
+
busyBypasses: 0,
|
|
266
|
+
liveBypasses: 0,
|
|
267
|
+
profiles: {} as Record<AdaptivePlaybackProfile, number>,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function prepare(rawPath: string, options: AdaptivePlaybackOptions): Promise<AdaptivePlaybackResult> {
|
|
271
|
+
if (!adaptivePlaybackEnabled()) return { path: rawPath, mode: 'raw', profile: null, reason: 'disabled' }
|
|
272
|
+
if (abortRequested(options.shouldAbort)) {
|
|
273
|
+
counters.liveBypasses++
|
|
274
|
+
return { path: rawPath, mode: 'raw', profile: null, reason: 'live_recording' }
|
|
275
|
+
}
|
|
276
|
+
if (activeGenerationPath && activeGenerationPath !== rawPath) {
|
|
277
|
+
counters.busyBypasses++
|
|
278
|
+
return { path: rawPath, mode: 'raw', profile: null, reason: 'cleanup_busy' }
|
|
279
|
+
}
|
|
280
|
+
const outputPath = outputPathFor(rawPath)
|
|
281
|
+
if (!outputPath) {
|
|
282
|
+
counters.fallbacks++
|
|
283
|
+
return { path: rawPath, mode: 'raw', profile: null, reason: 'unsupported_source' }
|
|
284
|
+
}
|
|
285
|
+
let input: Buffer
|
|
286
|
+
try {
|
|
287
|
+
const stat = statSync(rawPath)
|
|
288
|
+
if (!stat.isFile() || stat.size > MAX_INPUT_BYTES) throw new Error('input is not a bounded regular file')
|
|
289
|
+
input = readFileSync(rawPath)
|
|
290
|
+
} catch (error: unknown) {
|
|
291
|
+
counters.fallbacks++
|
|
292
|
+
return { path: rawPath, mode: 'raw', profile: null, reason: error instanceof Error ? error.message : String(error) }
|
|
293
|
+
}
|
|
294
|
+
const signal = analyzePlaybackWav(input)
|
|
295
|
+
if (!signal) {
|
|
296
|
+
counters.fallbacks++
|
|
297
|
+
return { path: rawPath, mode: 'raw', profile: null, reason: 'unsupported_wav' }
|
|
298
|
+
}
|
|
299
|
+
counters.profiles[signal.profile] = (counters.profiles[signal.profile] ?? 0) + 1
|
|
300
|
+
if (cachedOutput(rawPath, outputPath)) {
|
|
301
|
+
counters.cacheHits++
|
|
302
|
+
return { path: outputPath, mode: 'adaptive', profile: signal.profile }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Re-check after bounded synchronous profiling. A recording that started
|
|
306
|
+
// during that work must win before any child process is launched.
|
|
307
|
+
if (abortRequested(options.shouldAbort)) {
|
|
308
|
+
counters.liveBypasses++
|
|
309
|
+
return { path: rawPath, mode: 'raw', profile: signal.profile, reason: 'live_recording' }
|
|
310
|
+
}
|
|
311
|
+
if (activeGenerationPath && activeGenerationPath !== rawPath) {
|
|
312
|
+
counters.busyBypasses++
|
|
313
|
+
return { path: rawPath, mode: 'raw', profile: signal.profile, reason: 'cleanup_busy' }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const tempPath = join(dirname(outputPath), `.${basename(outputPath)}.${randomUUID()}.tmp.wav`)
|
|
317
|
+
activeGenerationPath = rawPath
|
|
318
|
+
try {
|
|
319
|
+
await runFfmpeg(rawPath, tempPath, FILTERS[signal.profile], options.shouldAbort)
|
|
320
|
+
if (abortRequested(options.shouldAbort)) throw new Error('live_recording_started')
|
|
321
|
+
if (!existsSync(tempPath) || !analyzePlaybackWav(readFileSync(tempPath))) {
|
|
322
|
+
throw new Error('ffmpeg produced an invalid PCM WAV')
|
|
323
|
+
}
|
|
324
|
+
chmodSync(tempPath, 0o600)
|
|
325
|
+
renameSync(tempPath, outputPath)
|
|
326
|
+
counters.generated++
|
|
327
|
+
invalidateMeetingAudioStats()
|
|
328
|
+
return { path: outputPath, mode: 'adaptive', profile: signal.profile }
|
|
329
|
+
} catch (error: unknown) {
|
|
330
|
+
counters.fallbacks++
|
|
331
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
332
|
+
const reason = message === 'live_recording_started' ? 'live_recording' : message
|
|
333
|
+
if (reason === 'live_recording') counters.liveBypasses++
|
|
334
|
+
console.warn(`[adaptive-playback] cleanup failed (${signal.profile}); serving raw: ${reason}`)
|
|
335
|
+
return { path: rawPath, mode: 'raw', profile: signal.profile, reason }
|
|
336
|
+
} finally {
|
|
337
|
+
if (activeGenerationPath === rawPath) activeGenerationPath = null
|
|
338
|
+
try { unlinkSync(tempPath) } catch { /* already renamed or never created */ }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Single-flight per raw chunk so simultaneous Play requests run ffmpeg once. */
|
|
343
|
+
export async function adaptivePlaybackAudio(
|
|
344
|
+
rawPath: string,
|
|
345
|
+
options: AdaptivePlaybackOptions = {},
|
|
346
|
+
): Promise<AdaptivePlaybackResult> {
|
|
347
|
+
const current = inFlight.get(rawPath)
|
|
348
|
+
if (current) return current
|
|
349
|
+
const pending = prepare(rawPath, options).finally(() => inFlight.delete(rawPath))
|
|
350
|
+
inFlight.set(rawPath, pending)
|
|
351
|
+
return pending
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function adaptivePlaybackStatus(): {
|
|
355
|
+
supported: true
|
|
356
|
+
enabled: boolean
|
|
357
|
+
mode: 'retained_replay_only'
|
|
358
|
+
rawPreserved: true
|
|
359
|
+
liveRecordingProtected: true
|
|
360
|
+
generatedThisBoot: number
|
|
361
|
+
cacheHitsThisBoot: number
|
|
362
|
+
fallbacksThisBoot: number
|
|
363
|
+
busyBypassesThisBoot: number
|
|
364
|
+
liveBypassesThisBoot: number
|
|
365
|
+
inFlight: number
|
|
366
|
+
globalWorkerBusy: boolean
|
|
367
|
+
profilesThisBoot: Partial<Record<AdaptivePlaybackProfile, number>>
|
|
368
|
+
} {
|
|
369
|
+
return {
|
|
370
|
+
supported: true,
|
|
371
|
+
enabled: adaptivePlaybackEnabled(),
|
|
372
|
+
mode: 'retained_replay_only',
|
|
373
|
+
rawPreserved: true,
|
|
374
|
+
liveRecordingProtected: true,
|
|
375
|
+
generatedThisBoot: counters.generated,
|
|
376
|
+
cacheHitsThisBoot: counters.cacheHits,
|
|
377
|
+
fallbacksThisBoot: counters.fallbacks,
|
|
378
|
+
busyBypassesThisBoot: counters.busyBypasses,
|
|
379
|
+
liveBypassesThisBoot: counters.liveBypasses,
|
|
380
|
+
inFlight: inFlight.size,
|
|
381
|
+
globalWorkerBusy: activeGenerationPath !== null,
|
|
382
|
+
profilesThisBoot: { ...counters.profiles },
|
|
383
|
+
}
|
|
384
|
+
}
|
|
@@ -15,16 +15,26 @@
|
|
|
15
15
|
|
|
16
16
|
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from 'node:fs'
|
|
17
17
|
import { basename, join, resolve } from 'node:path'
|
|
18
|
+
import { discoveredDomains, domainAbbreviation as deriveAbbr, isSafeDomainName as safeName } from './domains.js'
|
|
18
19
|
import type { MeetingDetail, MeetingMeta } from './meeting-store.js'
|
|
19
20
|
import { MEETING_SOURCE_MAX_BYTES } from './meeting-store.js'
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The four domains of ONE user's COS. Retained as the documented example layout
|
|
24
|
+
* and as test material — never as the set this module will accept.
|
|
25
|
+
*/
|
|
26
|
+
export const EXAMPLE_MEETING_DOMAINS = ['quilt', 'sprocket_rocket', 'hermit_crabs', 'personal'] as const
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Domain resolution, safe-name checks and badges have exactly ONE definition
|
|
30
|
+
* each, in `domains.ts`, shared with the meeting store and mirrored by the
|
|
31
|
+
* Control picker. Re-exported because this module's callers import them.
|
|
32
|
+
*/
|
|
33
|
+
export { domainAbbreviation, isSafeDomainName } from './domains.js'
|
|
22
34
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
hermit_crabs: 'HC',
|
|
27
|
-
personal: 'P',
|
|
35
|
+
/** Immediate subdirectories of `operationsDir` holding a `meetings/` tree. */
|
|
36
|
+
export function discoverMeetingDomains(operationsDir: string): string[] {
|
|
37
|
+
return discoveredDomains(operationsDir)
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
const DETAIL_CHUNK_ESTIMATE_CHARS = 1700
|
|
@@ -197,7 +207,7 @@ function parseMeetingMeta(content: string, filename: string, domain: string): Co
|
|
|
197
207
|
date,
|
|
198
208
|
...(time ? { time } : {}),
|
|
199
209
|
domain,
|
|
200
|
-
domainAbbr:
|
|
210
|
+
domainAbbr: deriveAbbr(domain),
|
|
201
211
|
source,
|
|
202
212
|
duration,
|
|
203
213
|
durationMinutes,
|
|
@@ -316,7 +326,7 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
|
316
326
|
const operationsDir = resolveCosOperationsDir()
|
|
317
327
|
if (!operationsDir) return null
|
|
318
328
|
|
|
319
|
-
for (const domain of
|
|
329
|
+
for (const domain of discoverMeetingDomains(operationsDir)) {
|
|
320
330
|
const meetingsBase = join(operationsDir, domain, 'meetings')
|
|
321
331
|
let months: string[]
|
|
322
332
|
try {
|
|
@@ -363,11 +373,10 @@ export function listCosOperationsMeetings(options: {
|
|
|
363
373
|
|
|
364
374
|
const limit = Math.min(Math.max(options.limit ?? 20, 1), 50)
|
|
365
375
|
const domainFilter = options.domain || 'all'
|
|
376
|
+
const discovered = discoverMeetingDomains(operationsDir)
|
|
366
377
|
const domains = domainFilter === 'all'
|
|
367
|
-
?
|
|
368
|
-
:
|
|
369
|
-
? [domainFilter]
|
|
370
|
-
: []
|
|
378
|
+
? discovered
|
|
379
|
+
: discovered.includes(domainFilter) ? [domainFilter] : []
|
|
371
380
|
|
|
372
381
|
const allMeetings: CosOperationsMeetingMeta[] = []
|
|
373
382
|
|
|
@@ -416,7 +425,10 @@ export function getCosOperationsMeetingDetail(
|
|
|
416
425
|
const operationsDir = resolveCosOperationsDir()
|
|
417
426
|
if (!operationsDir) return null
|
|
418
427
|
|
|
419
|
-
|
|
428
|
+
// Two separate jobs, and the old single membership test conflated them:
|
|
429
|
+
// traversal safety, then whether this domain actually exists here.
|
|
430
|
+
if (!safeName(domain)) return null
|
|
431
|
+
if (!discoverMeetingDomains(operationsDir).includes(domain)) return null
|
|
420
432
|
if (!/^\d{4}-\d{2}$/.test(month) || basename(filename) !== filename || !filename.endsWith('.md')) {
|
|
421
433
|
return null
|
|
422
434
|
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Which domains this COS has, and which one a meeting belongs to.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS. Four places independently hardcoded ONE user's business
|
|
4
|
+
// domains, and a fifth pretended to make them configurable:
|
|
5
|
+
//
|
|
6
|
+
// cos-operations-meetings.ts ['quilt','sprocket_rocket','hermit_crabs','personal']
|
|
7
|
+
// cos-control-macos the same four, in the folder-picker validator
|
|
8
|
+
// cos-glasses-app two abbreviation maps (display-pages.ts:843, :1104)
|
|
9
|
+
// meeting-store.ts domain.slice(0,2), which renders sprocket_rocket "SP"
|
|
10
|
+
// profile.ts getDomainKeywords() — exported, ZERO call sites
|
|
11
|
+
//
|
|
12
|
+
// The two abbreviation schemes already disagreed with each other, and
|
|
13
|
+
// getDomainKeywords was a whole configuration chain built and never connected —
|
|
14
|
+
// which reads as "domains are configurable" to anyone who greps for it.
|
|
15
|
+
//
|
|
16
|
+
// A second person set up their own COS on 2026-08-08. Nothing she could name
|
|
17
|
+
// would work: the picker demanded a `quilt/` tree, and a domain like `DNP study`
|
|
18
|
+
// was rejected on save by a pattern permitting only lowercase and underscores.
|
|
19
|
+
|
|
20
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import { loadProfileObject } from './profile.js'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Defaults for a COS that has never been configured and has no folders yet.
|
|
26
|
+
*
|
|
27
|
+
* Two, not four. `personal` and `business` are the split almost everyone has, and
|
|
28
|
+
* the alternative — shipping the author's own business units — is what created
|
|
29
|
+
* this file.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_DOMAINS = ['personal', 'business'] as const
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The domain a meeting falls back to when nothing else decides.
|
|
35
|
+
*
|
|
36
|
+
* `personal` on purpose, and it is the SAFE direction: a work meeting filed under
|
|
37
|
+
* personal is a misfiling the user notices and fixes, while a personal
|
|
38
|
+
* conversation filed under a business domain can end up pasted into a work
|
|
39
|
+
* channel. Matches the standing correction that G2 recordings default to personal
|
|
40
|
+
* and get reclassified by content.
|
|
41
|
+
*/
|
|
42
|
+
export const FALLBACK_DOMAIN = 'personal'
|
|
43
|
+
|
|
44
|
+
/** Keyword seeds for the two defaults, used only when the user has set none. */
|
|
45
|
+
const DEFAULT_KEYWORDS: Record<string, string[]> = {
|
|
46
|
+
business: [
|
|
47
|
+
'client', 'customer', 'revenue', 'pipeline', 'roadmap', 'sprint', 'standup',
|
|
48
|
+
'quarter', 'invoice', 'proposal', 'stakeholder', 'deadline', 'launch',
|
|
49
|
+
'budget', 'campaign', 'hiring', 'onboarding', 'contract', 'vendor', 'demo',
|
|
50
|
+
],
|
|
51
|
+
personal: [
|
|
52
|
+
'family', 'kids', 'wife', 'husband', 'partner', 'doctor', 'dentist',
|
|
53
|
+
'school', 'vacation', 'holiday', 'dinner', 'birthday', 'weekend', 'church',
|
|
54
|
+
'grocery', 'house', 'insurance', 'therapy', 'workout', 'anniversary',
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface DomainConfig {
|
|
59
|
+
name: string
|
|
60
|
+
/** Badge override. Derived when absent. */
|
|
61
|
+
abbr?: string
|
|
62
|
+
/** Words that route a meeting here. Case-insensitive, word-boundary matched. */
|
|
63
|
+
keywords?: string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Is this name safe as a path component AND as a domain label?
|
|
68
|
+
*
|
|
69
|
+
* A safety check, not a naming policy. Permits spaces and mixed case, because
|
|
70
|
+
* `DNP study` is a real domain and the previous pattern (`^[a-z][a-z0-9_]{0,31}$`)
|
|
71
|
+
* silently encoded one author's snake_case habit as a requirement — it accepted
|
|
72
|
+
* `sprocket_rocket` and rejected `DNP study`.
|
|
73
|
+
*/
|
|
74
|
+
export function isSafeDomainName(name: string): boolean {
|
|
75
|
+
if (!name || name.length > 64) return false
|
|
76
|
+
if (name !== name.trim()) return false
|
|
77
|
+
if (name === '.' || name === '..' || name.startsWith('.')) return false
|
|
78
|
+
if (/[/\\\0]/.test(name)) return false
|
|
79
|
+
// Control characters would corrupt a path or a markdown header.
|
|
80
|
+
if (/[\x00-\x1f\x7f]/.test(name)) return false
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The user's configured domains, or [] when unset. Malformed entries dropped. */
|
|
85
|
+
export function configuredDomains(): DomainConfig[] {
|
|
86
|
+
const raw = loadProfileObject().domains
|
|
87
|
+
if (!Array.isArray(raw)) return []
|
|
88
|
+
const out: DomainConfig[] = []
|
|
89
|
+
const seen = new Set<string>()
|
|
90
|
+
for (const entry of raw) {
|
|
91
|
+
// A bare string is accepted: `"domains": ["personal", "work"]` is the
|
|
92
|
+
// shortest thing a user will reach for, and rejecting it would be hostile.
|
|
93
|
+
const name = typeof entry === 'string'
|
|
94
|
+
? entry
|
|
95
|
+
: (entry && typeof entry === 'object' && typeof (entry as DomainConfig).name === 'string')
|
|
96
|
+
? (entry as DomainConfig).name
|
|
97
|
+
: ''
|
|
98
|
+
const trimmed = name.trim()
|
|
99
|
+
if (!isSafeDomainName(trimmed) || seen.has(trimmed.toLowerCase())) continue
|
|
100
|
+
seen.add(trimmed.toLowerCase())
|
|
101
|
+
const obj: Partial<DomainConfig> =
|
|
102
|
+
(entry && typeof entry === 'object') ? entry as Partial<DomainConfig> : {}
|
|
103
|
+
out.push({
|
|
104
|
+
name: trimmed,
|
|
105
|
+
...(typeof obj.abbr === 'string' && obj.abbr.trim() ? { abbr: obj.abbr.trim().slice(0, 4) } : {}),
|
|
106
|
+
...(Array.isArray(obj.keywords)
|
|
107
|
+
? { keywords: obj.keywords.filter((k): k is string => typeof k === 'string' && !!k.trim()) }
|
|
108
|
+
: {}),
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Immediate subdirectories of `operationsDir` holding a `meetings/` tree. */
|
|
115
|
+
export function discoveredDomains(operationsDir: string | null): string[] {
|
|
116
|
+
if (!operationsDir || !existsSync(operationsDir)) return []
|
|
117
|
+
let names: string[]
|
|
118
|
+
try {
|
|
119
|
+
names = readdirSync(operationsDir, { withFileTypes: true })
|
|
120
|
+
.filter(e => e.isDirectory()).map(e => e.name)
|
|
121
|
+
} catch { return [] }
|
|
122
|
+
return names.filter(isSafeDomainName).filter(name => {
|
|
123
|
+
const meetings = join(operationsDir, name, 'meetings')
|
|
124
|
+
try { if (!statSync(meetings).isDirectory()) return false } catch { return false }
|
|
125
|
+
// A `meetings/` folder is not enough: it must hold at least one YYYY-MM month
|
|
126
|
+
// directory, which is the only shape the lister reads. Measured on a real
|
|
127
|
+
// install, `operations/archive/meetings/` contains domain names rather than
|
|
128
|
+
// months, so accepting any `meetings/` folder listed it as a domain with
|
|
129
|
+
// permanently zero meetings. Structural, so it needs no blocklist of names.
|
|
130
|
+
try {
|
|
131
|
+
return readdirSync(meetings, { withFileTypes: true })
|
|
132
|
+
.some(e => e.isDirectory() && /^\d{4}-\d{2}$/.test(e.name))
|
|
133
|
+
} catch { return false }
|
|
134
|
+
}).sort()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Every domain this COS has, as a UNION of configuration and what is on disk.
|
|
139
|
+
*
|
|
140
|
+
* A union rather than a replacement, and the distinction is load-bearing:
|
|
141
|
+
*
|
|
142
|
+
* - Configured but no folder yet: still listed, so a brand-new user can be
|
|
143
|
+
* ROUTED before any folder exists.
|
|
144
|
+
* - On disk but not configured: still listed, so a folder someone made by hand
|
|
145
|
+
* never becomes invisible. This is also what protects an existing install —
|
|
146
|
+
* Miles has no `domains` in his profile, discovery finds his four, and the
|
|
147
|
+
* union is exactly his four. Nothing is written to his profile and nothing
|
|
148
|
+
* about his setup changes.
|
|
149
|
+
* - Neither: the DEFAULTS. Only a genuinely fresh COS ever sees them, which is
|
|
150
|
+
* why defaulting to two is safe.
|
|
151
|
+
*
|
|
152
|
+
* Configured order is preserved and comes first; discovered extras follow,
|
|
153
|
+
* sorted, so the list is stable across calls.
|
|
154
|
+
*/
|
|
155
|
+
export function resolveDomains(operationsDir: string | null): DomainConfig[] {
|
|
156
|
+
const configured = configuredDomains()
|
|
157
|
+
const discovered = discoveredDomains(operationsDir)
|
|
158
|
+
if (configured.length === 0 && discovered.length === 0) {
|
|
159
|
+
return DEFAULT_DOMAINS.map(name => ({ name, keywords: DEFAULT_KEYWORDS[name] }))
|
|
160
|
+
}
|
|
161
|
+
const known = new Set(configured.map(d => d.name.toLowerCase()))
|
|
162
|
+
return [
|
|
163
|
+
...configured,
|
|
164
|
+
...discovered.filter(name => !known.has(name.toLowerCase())).map(name => ({ name })),
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Just the names, in resolved order. */
|
|
169
|
+
export function domainNames(operationsDir: string | null): string[] {
|
|
170
|
+
return resolveDomains(operationsDir).map(d => d.name)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Short badge for a domain.
|
|
175
|
+
*
|
|
176
|
+
* An explicit `abbr` wins; otherwise initials of up to two words. Derivation
|
|
177
|
+
* reproduces the old hand-written table exactly — quilt Q, personal P,
|
|
178
|
+
* hermit_crabs HC, sprocket_rocket SR — which is why that table is gone. The old
|
|
179
|
+
* `meeting-store` version used `slice(0,2)` and rendered sprocket_rocket "SP", so
|
|
180
|
+
* the two schemes in this codebase disagreed with each other.
|
|
181
|
+
*/
|
|
182
|
+
export function domainAbbreviation(domain: string, config?: DomainConfig[]): string {
|
|
183
|
+
const hit = config?.find(d => d.name.toLowerCase() === domain.toLowerCase())
|
|
184
|
+
if (hit?.abbr) return hit.abbr
|
|
185
|
+
const words = domain.split(/[^\p{L}\p{N}]+/u).filter(Boolean)
|
|
186
|
+
if (words.length === 0) return '?'
|
|
187
|
+
// A short single word IS its own badge: the companion uses the short forms `sr`
|
|
188
|
+
// and `hc`, and taking a first initial would render them "S" and "H".
|
|
189
|
+
if (words.length === 1 && words[0].length <= 3) return words[0].toUpperCase()
|
|
190
|
+
return words.slice(0, 2).map(w => w[0].toUpperCase()).join('')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Which domain does this meeting belong to?
|
|
195
|
+
*
|
|
196
|
+
* Keyword scoring over the title and body, highest score wins. Deliberately NOT
|
|
197
|
+
* an LLM call: this runs on every save, and a per-meeting model call would breach
|
|
198
|
+
* the standing rule that every recurring LLM caller must justify its volume.
|
|
199
|
+
*
|
|
200
|
+
* Scoring counts DISTINCT matched keywords rather than total occurrences, so one
|
|
201
|
+
* word repeated forty times cannot outvote four different signals. Word-boundary
|
|
202
|
+
* matched, so "car" does not fire inside "carrier".
|
|
203
|
+
*
|
|
204
|
+
* Returns null when nothing scores, rather than picking. The caller decides, and
|
|
205
|
+
* an unscored meeting must not be asserted into a business domain by accident.
|
|
206
|
+
*/
|
|
207
|
+
export function classifyDomain(
|
|
208
|
+
text: string,
|
|
209
|
+
domains: DomainConfig[],
|
|
210
|
+
): { domain: string; score: number; matched: string[] } | null {
|
|
211
|
+
const haystack = text.toLowerCase()
|
|
212
|
+
if (!haystack.trim()) return null
|
|
213
|
+
let best: { domain: string; score: number; matched: string[] } | null = null
|
|
214
|
+
for (const d of domains) {
|
|
215
|
+
const keywords = d.keywords?.length ? d.keywords : DEFAULT_KEYWORDS[d.name.toLowerCase()] ?? []
|
|
216
|
+
const matched: string[] = []
|
|
217
|
+
for (const kw of keywords) {
|
|
218
|
+
const needle = kw.toLowerCase().trim()
|
|
219
|
+
if (!needle) continue
|
|
220
|
+
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
221
|
+
if (new RegExp(`(^|[^\\p{L}\\p{N}])${escaped}([^\\p{L}\\p{N}]|$)`, 'u').test(haystack)) {
|
|
222
|
+
matched.push(needle)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Strictly greater, so the FIRST domain in resolved order wins a tie. Ties are
|
|
226
|
+
// then deterministic and under the user's control via ordering.
|
|
227
|
+
if (matched.length > 0 && (!best || matched.length > best.score)) {
|
|
228
|
+
best = { domain: d.name, score: matched.length, matched }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return best
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The domain to file a meeting under: the client's choice, else inference, else
|
|
236
|
+
* the safe fallback.
|
|
237
|
+
*
|
|
238
|
+
* The fallback prefers a domain the user actually has — `personal` when present,
|
|
239
|
+
* otherwise the first resolved domain — so a COS with no `personal` domain does
|
|
240
|
+
* not have one invented for it.
|
|
241
|
+
*/
|
|
242
|
+
export function domainForMeeting(
|
|
243
|
+
explicit: string | undefined,
|
|
244
|
+
text: string,
|
|
245
|
+
operationsDir: string | null,
|
|
246
|
+
): string {
|
|
247
|
+
const domains = resolveDomains(operationsDir)
|
|
248
|
+
if (explicit && explicit.trim() && isSafeDomainName(explicit.trim())) return explicit.trim()
|
|
249
|
+
const inferred = classifyDomain(text, domains)
|
|
250
|
+
if (inferred) return inferred.domain
|
|
251
|
+
const personal = domains.find(d => d.name.toLowerCase() === FALLBACK_DOMAIN)
|
|
252
|
+
return personal?.name ?? domains[0]?.name ?? FALLBACK_DOMAIN
|
|
253
|
+
}
|
|
@@ -60,6 +60,11 @@ function isChunkWav(name: string): boolean {
|
|
|
60
60
|
return /^chunk_\d+\.wav$/.test(name)
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** Derived playback copies count against the cap but never define retention age. */
|
|
64
|
+
function isDerivedPlaybackWav(name: string): boolean {
|
|
65
|
+
return /^playback_v\d+_\d+\.wav$/.test(name)
|
|
66
|
+
}
|
|
67
|
+
|
|
63
68
|
export interface ArchiveResult {
|
|
64
69
|
linked: number
|
|
65
70
|
/** Files that had to be copied because the link failed (e.g. cross-device). */
|
|
@@ -110,19 +115,40 @@ export function archiveSessionAudio(sessionId: string, sourceDir: string): Archi
|
|
|
110
115
|
}
|
|
111
116
|
|
|
112
117
|
/** Bytes and age for one archived session. */
|
|
113
|
-
function sessionSize(dir: string): {
|
|
114
|
-
|
|
118
|
+
function sessionSize(dir: string): {
|
|
119
|
+
bytes: number
|
|
120
|
+
mtimeMs: number
|
|
121
|
+
files: number
|
|
122
|
+
derivedFiles: number
|
|
123
|
+
otherEntries: number
|
|
124
|
+
statFailures: number
|
|
125
|
+
readable: boolean
|
|
126
|
+
} {
|
|
127
|
+
let bytes = 0, mtimeMs = 0, files = 0, derivedFiles = 0
|
|
128
|
+
let otherEntries = 0, statFailures = 0, readable = false
|
|
115
129
|
try {
|
|
116
|
-
|
|
130
|
+
const names = readdirSync(dir)
|
|
131
|
+
readable = true
|
|
132
|
+
for (const name of names) {
|
|
133
|
+
if (!isChunkWav(name) && !isDerivedPlaybackWav(name)) {
|
|
134
|
+
otherEntries++
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
117
137
|
try {
|
|
118
138
|
const st = statSync(join(dir, name))
|
|
119
139
|
bytes += st.size
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
140
|
+
// A replay created six days after capture must not buy the raw evidence
|
|
141
|
+
// another seven days. Only immutable raw chunks determine session age.
|
|
142
|
+
if (isChunkWav(name)) {
|
|
143
|
+
files++
|
|
144
|
+
mtimeMs = Math.max(mtimeMs, st.mtimeMs)
|
|
145
|
+
} else {
|
|
146
|
+
derivedFiles++
|
|
147
|
+
}
|
|
148
|
+
} catch { statFailures++ }
|
|
123
149
|
}
|
|
124
150
|
} catch { /* unreadable dir reports zero */ }
|
|
125
|
-
return { bytes, mtimeMs, files }
|
|
151
|
+
return { bytes, mtimeMs, files, derivedFiles, otherEntries, statFailures, readable }
|
|
126
152
|
}
|
|
127
153
|
|
|
128
154
|
export interface SweepResult {
|
|
@@ -145,7 +171,14 @@ export function sweepMeetingAudio(nowMs: number, ttlMs = meetingAudioTtlMs()): S
|
|
|
145
171
|
try { names = readdirSync(root) } catch { return out }
|
|
146
172
|
for (const name of names) {
|
|
147
173
|
const dir = join(root, name)
|
|
148
|
-
const { bytes, mtimeMs } = sessionSize(dir)
|
|
174
|
+
const { bytes, mtimeMs, files, derivedFiles, otherEntries, statFailures, readable } = sessionSize(dir)
|
|
175
|
+
// Delete only a provably cache-only directory. Any unknown entry or failed
|
|
176
|
+
// stat may be retained evidence, so ambiguity fails closed to preservation.
|
|
177
|
+
if (readable && files === 0 && derivedFiles > 0 && otherEntries === 0 && statFailures === 0) {
|
|
178
|
+
try { rmSync(dir, { recursive: true, force: true }); out.removed.push(name); out.bytesFreed += bytes }
|
|
179
|
+
catch { out.retained.push(name) }
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
149
182
|
if (mtimeMs <= 0) { out.retained.push(name); continue }
|
|
150
183
|
if (nowMs - mtimeMs > ttlMs) {
|
|
151
184
|
try { rmSync(dir, { recursive: true, force: true }); out.removed.push(name); out.bytesFreed += bytes }
|
|
@@ -17,6 +17,7 @@ import { createHash } from 'node:crypto'
|
|
|
17
17
|
import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
18
18
|
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
19
19
|
import { dataPath } from './data-dir.js'
|
|
20
|
+
import { FALLBACK_DOMAIN, domainAbbreviation, isSafeDomainName } from './domains.js'
|
|
20
21
|
import type {
|
|
21
22
|
ProviderCandidateRecord,
|
|
22
23
|
IndexedTranscriptChunk,
|
|
@@ -128,9 +129,16 @@ function normalizeSessionId(value: string): string {
|
|
|
128
129
|
return value
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Trim, validate, and PRESERVE CASE.
|
|
134
|
+
*
|
|
135
|
+
* Lowercasing was silently destructive: a domain folder named `DNP study` became
|
|
136
|
+
* `dnp study`, which then matched no directory on disk. Existing all-lowercase
|
|
137
|
+
* domains are unaffected.
|
|
138
|
+
*/
|
|
131
139
|
function normalizeDomain(value?: string): string {
|
|
132
|
-
const domain = (value ||
|
|
133
|
-
if (!
|
|
140
|
+
const domain = (value || FALLBACK_DOMAIN).trim()
|
|
141
|
+
if (!isSafeDomainName(domain)) {
|
|
134
142
|
throw new MeetingStoreError('Invalid domain', 400, 'invalid_domain')
|
|
135
143
|
}
|
|
136
144
|
return domain
|
|
@@ -272,7 +280,7 @@ function parseDurationMinutes(duration: string): number | undefined {
|
|
|
272
280
|
function parseMeeting(content: string, filename: string, month: string): MeetingDetail {
|
|
273
281
|
const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim()
|
|
274
282
|
const date = parseField(content, 'Date') || filename.match(/^(\d{4}-\d{2}-\d{2})/)?.[1] || 'unknown'
|
|
275
|
-
const domain = parseField(content, 'Domain') ||
|
|
283
|
+
const domain = parseField(content, 'Domain') || FALLBACK_DOMAIN
|
|
276
284
|
const duration = parseField(content, 'Duration')
|
|
277
285
|
const transcript = extractSection(content, ['Transcript'], true)
|
|
278
286
|
const storedSummary = extractSection(content, ['Summary'])
|
|
@@ -293,7 +301,10 @@ function parseMeeting(content: string, filename: string, month: string): Meeting
|
|
|
293
301
|
title: heading || basename(filename, '.md').split('_').slice(1).join(' ') || 'Untitled Meeting',
|
|
294
302
|
date,
|
|
295
303
|
domain,
|
|
296
|
-
|
|
304
|
+
// Shared derivation. This was slice(0,2), which rendered sprocket_rocket as
|
|
305
|
+
// "SP" while cos-operations-meetings rendered it "SR" — two schemes in one
|
|
306
|
+
// codebase, disagreeing with each other.
|
|
307
|
+
domainAbbr: domainAbbreviation(domain),
|
|
297
308
|
source: parseField(content, 'Source'),
|
|
298
309
|
duration,
|
|
299
310
|
...(parseDurationMinutes(duration) !== undefined ? { durationMinutes: parseDurationMinutes(duration) } : {}),
|
|
@@ -535,7 +546,7 @@ export class MeetingStore {
|
|
|
535
546
|
list(options: { limit?: number; domain?: string } = {}): MeetingMeta[] {
|
|
536
547
|
const limit = Math.max(1, Math.min(50, Math.trunc(options.limit ?? 20)))
|
|
537
548
|
const domain = options.domain ?? 'all'
|
|
538
|
-
if (domain !== 'all' && !
|
|
549
|
+
if (domain !== 'all' && !isSafeDomainName(domain)) {
|
|
539
550
|
throw new MeetingStoreError('Invalid domain filter', 400, 'invalid_domain')
|
|
540
551
|
}
|
|
541
552
|
const rootReal = this.existingRootRealpath()
|
|
@@ -565,7 +576,7 @@ export class MeetingStore {
|
|
|
565
576
|
}
|
|
566
577
|
|
|
567
578
|
detail(domain: string, month: string, filename: string): MeetingDetail {
|
|
568
|
-
if (!
|
|
579
|
+
if (!isSafeDomainName(domain)) {
|
|
569
580
|
throw new MeetingStoreError('Invalid domain', 400, 'invalid_domain')
|
|
570
581
|
}
|
|
571
582
|
if (!MONTH_PATTERN.test(month)) {
|
package/server/lib/profile.ts
CHANGED
|
@@ -41,6 +41,15 @@ function profilePath(): string {
|
|
|
41
41
|
|
|
42
42
|
let profileCache: Record<string, unknown> | null = null
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The whole profile object, for callers that need a key this module has no
|
|
46
|
+
* dedicated accessor for. Read-only by convention — mutate via
|
|
47
|
+
* updateProfileFields so the atomic write and cache-bust chain still applies.
|
|
48
|
+
*/
|
|
49
|
+
export function loadProfileObject(): Record<string, unknown> {
|
|
50
|
+
return loadProfile()
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
function loadProfile(): Record<string, unknown> {
|
|
45
54
|
if (profileCache) return profileCache
|
|
46
55
|
try {
|
package/server/routes/health.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { profileProvenanceSummary, speakerModelState, speakerReadiness } from '.
|
|
|
10
10
|
import { chunkEmbeddingStoreStats } from '../lib/chunk-embedding-store.js'
|
|
11
11
|
import { correctionStoreStats } from '../lib/meeting-corrections.js'
|
|
12
12
|
import { meetingAudioStats } from '../lib/meeting-audio-archive.js'
|
|
13
|
+
import { adaptivePlaybackStatus } from '../lib/adaptive-playback-audio.js'
|
|
13
14
|
import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
|
|
14
15
|
import {
|
|
15
16
|
isWhisperLocalAvailable,
|
|
@@ -125,7 +126,10 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
125
126
|
// `pending` is the number that matters here: an intent that never closed means
|
|
126
127
|
// some meeting's files may be half-rewritten.
|
|
127
128
|
const speakerCorrections = correctionStoreStats()
|
|
128
|
-
const reviewAudio =
|
|
129
|
+
const reviewAudio = {
|
|
130
|
+
...meetingAudioStats(),
|
|
131
|
+
adaptivePlayback: adaptivePlaybackStatus(),
|
|
132
|
+
}
|
|
129
133
|
// `noHumanSample` is the one to read: a profile with no human-verified sample
|
|
130
134
|
// is trained entirely on labels the system chose for itself.
|
|
131
135
|
const voiceProvenance = speakerId.state === 'active' ? profileProvenanceSummary() : null
|
package/server/routes/meeting.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
|
11
11
|
import { appendCorrection, appliedCorrections, pendingCorrections } from '../lib/meeting-corrections.js'
|
|
12
12
|
import { isSampleFromSession, untraceableSampleCount } from '../lib/training-audio-provenance.js'
|
|
13
13
|
import { sendAudioFile } from '../lib/send-audio.js'
|
|
14
|
+
import { adaptivePlaybackAudio } from '../lib/adaptive-playback-audio.js'
|
|
14
15
|
import { chunkDiagnostics } from '../lib/chunk-embedding-diagnostics.js'
|
|
15
16
|
import { errMsg } from '../lib/utils.js'
|
|
16
17
|
import { confirmedLabels } from '../lib/meeting-corrections.js'
|
|
@@ -100,6 +101,7 @@ import {
|
|
|
100
101
|
getSessionStartTime,
|
|
101
102
|
getSessionTranscript,
|
|
102
103
|
getMeetingSessionStatus,
|
|
104
|
+
getTranscriptionSessionLiveness,
|
|
103
105
|
hasSessionAudio,
|
|
104
106
|
moveSessionAudioToPending,
|
|
105
107
|
type IndexedTranscriptChunk,
|
|
@@ -111,7 +113,9 @@ import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
|
111
113
|
import {
|
|
112
114
|
cosOperationsMeetingsConfigured,
|
|
113
115
|
findCosOperationsMeetingBySessionId,
|
|
116
|
+
resolveCosOperationsDir,
|
|
114
117
|
} from '../lib/cos-operations-meetings.js'
|
|
118
|
+
import { domainForMeeting, resolveDomains } from '../lib/domains.js'
|
|
115
119
|
import { getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
116
120
|
import {
|
|
117
121
|
DEATTRIBUTED_PREFIX,
|
|
@@ -560,10 +564,19 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
560
564
|
|
|
561
565
|
// Initial canonical text + structured metadata are published before any
|
|
562
566
|
// live state is removed or background work is scheduled.
|
|
567
|
+
// The client's choice wins. When it sends none — the normal case for a G2
|
|
568
|
+
// save — infer from the content instead of filing everything under one
|
|
569
|
+
// domain. Keyword scoring, no model call: this runs on every save, and a
|
|
570
|
+
// per-meeting LLM call would breach the recurring-caller rule.
|
|
571
|
+
const filedDomain = domainForMeeting(
|
|
572
|
+
body?.domain as string | undefined,
|
|
573
|
+
`${(body?.title as string | undefined) ?? ''}\n${transcript}`,
|
|
574
|
+
resolveCosOperationsDir(),
|
|
575
|
+
)
|
|
563
576
|
const saved = store.save({
|
|
564
577
|
sessionId,
|
|
565
578
|
title: body?.title as string | undefined,
|
|
566
|
-
domain:
|
|
579
|
+
domain: filedDomain,
|
|
567
580
|
transcript: cleanFinalTranscript(transcript),
|
|
568
581
|
startTime,
|
|
569
582
|
durationMs,
|
|
@@ -1412,7 +1425,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1412
1425
|
// voice. Retention is 7 days (see meeting-audio-archive), so this answers with
|
|
1413
1426
|
// 404 + a reason once the window has passed rather than pretending the audio
|
|
1414
1427
|
// was never there.
|
|
1415
|
-
router.get('/meeting/:sessionId/audio/:chunkIndex', (req, res) => {
|
|
1428
|
+
router.get('/meeting/:sessionId/audio/:chunkIndex', async (req, res) => {
|
|
1416
1429
|
res.set('Cache-Control', 'private, no-store')
|
|
1417
1430
|
const sessionId = String(req.params.sessionId ?? '')
|
|
1418
1431
|
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
@@ -1429,8 +1442,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1429
1442
|
// fallback there is nothing to play on any meeting predating 6.21.18 — while
|
|
1430
1443
|
// 72 hours of unidentified-voice audio is sitting right there, and an
|
|
1431
1444
|
// unidentified voice is exactly what a reviewer needs to hear.
|
|
1432
|
-
const
|
|
1433
|
-
|
|
1445
|
+
const archivedPath = meetingAudioChunkPath(sessionId, chunkIndex)
|
|
1446
|
+
const path = archivedPath ?? extAudioChunkPath(sessionId, chunkIndex)
|
|
1434
1447
|
if (!path) {
|
|
1435
1448
|
const retained = [...new Set([
|
|
1436
1449
|
...listMeetingAudioChunks(sessionId),
|
|
@@ -1449,7 +1462,30 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1449
1462
|
})
|
|
1450
1463
|
return
|
|
1451
1464
|
}
|
|
1452
|
-
|
|
1465
|
+
// Canary scope is intentionally narrow: only the week-retained immutable
|
|
1466
|
+
// archive gets a derived playback copy. Legacy ext-audio remains byte-for-
|
|
1467
|
+
// byte raw. `?raw=1` is the authenticated A/B and emergency per-request
|
|
1468
|
+
// escape hatch; the COS Control toggle is the machine-wide rollback.
|
|
1469
|
+
const liveRecording = getTranscriptionSessionLiveness().live > 0
|
|
1470
|
+
const playback = archivedPath && req.query.raw !== '1' && !liveRecording
|
|
1471
|
+
? await adaptivePlaybackAudio(archivedPath, {
|
|
1472
|
+
// Admission is not enough: if a meeting starts while FFmpeg is
|
|
1473
|
+
// working, the replay job is preempted and this request hears raw.
|
|
1474
|
+
shouldAbort: () => getTranscriptionSessionLiveness().live > 0,
|
|
1475
|
+
})
|
|
1476
|
+
: { path, mode: 'raw' as const, profile: null }
|
|
1477
|
+
res.set('X-COS-Audio-Playback', playback.mode)
|
|
1478
|
+
const bypassReason = 'reason' in playback ? playback.reason : undefined
|
|
1479
|
+
const bypass = liveRecording
|
|
1480
|
+
? 'live_recording'
|
|
1481
|
+
: bypassReason === 'live_recording'
|
|
1482
|
+
? 'live_recording'
|
|
1483
|
+
: bypassReason === 'cleanup_busy'
|
|
1484
|
+
? 'cleanup_busy'
|
|
1485
|
+
: null
|
|
1486
|
+
if (bypass) res.set('X-COS-Audio-Bypass', bypass)
|
|
1487
|
+
if (playback.profile) res.set('X-COS-Audio-Profile', playback.profile)
|
|
1488
|
+
sendAudioFile(res, playback.path)
|
|
1453
1489
|
})
|
|
1454
1490
|
|
|
1455
1491
|
/**
|
|
@@ -1670,7 +1706,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1670
1706
|
const saved = store.save({
|
|
1671
1707
|
sessionId,
|
|
1672
1708
|
title: typeof body.title === 'string' && body.title.trim() ? body.title : undefined,
|
|
1673
|
-
domain:
|
|
1709
|
+
domain: domainForMeeting(
|
|
1710
|
+
typeof body.domain === 'string' && body.domain.trim() ? body.domain : undefined,
|
|
1711
|
+
`${typeof body.title === 'string' ? body.title : ''}\n${transcript}`,
|
|
1712
|
+
resolveCosOperationsDir(),
|
|
1713
|
+
),
|
|
1674
1714
|
transcript,
|
|
1675
1715
|
startTime: capture.startTime,
|
|
1676
1716
|
durationMs: capture.durationMs,
|