@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.30",
3
+ "version": "6.21.32",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- export const COS_MEETING_DOMAINS = ['quilt', 'sprocket_rocket', 'hermit_crabs', 'personal'] as const
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
- const DOMAIN_ABBR: Record<string, string> = {
24
- quilt: 'Q',
25
- sprocket_rocket: 'SR',
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: DOMAIN_ABBR[domain] || '?',
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 COS_MEETING_DOMAINS) {
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
- ? [...COS_MEETING_DOMAINS]
368
- : COS_MEETING_DOMAINS.includes(domainFilter as typeof COS_MEETING_DOMAINS[number])
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
- if (!(COS_MEETING_DOMAINS as readonly string[]).includes(domain)) return null
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): { bytes: number; mtimeMs: number; files: number } {
114
- let bytes = 0, mtimeMs = 0, files = 0
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
- for (const name of readdirSync(dir).filter(isChunkWav)) {
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
- files++
121
- mtimeMs = Math.max(mtimeMs, st.mtimeMs)
122
- } catch { /* skip unreadable */ }
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 || 'personal').trim().toLowerCase()
133
- if (!DOMAIN_PATTERN.test(domain)) {
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') || 'personal'
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
- domainAbbr: domain === 'personal' ? 'P' : domain.slice(0, 2).toUpperCase() || '?',
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' && !DOMAIN_PATTERN.test(domain)) {
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 (!DOMAIN_PATTERN.test(domain)) {
579
+ if (!isSafeDomainName(domain)) {
569
580
  throw new MeetingStoreError('Invalid domain', 400, 'invalid_domain')
570
581
  }
571
582
  if (!MONTH_PATTERN.test(month)) {
@@ -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 {
@@ -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 = meetingAudioStats()
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
@@ -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: body?.domain as string | undefined,
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 path = meetingAudioChunkPath(sessionId, chunkIndex)
1433
- ?? extAudioChunkPath(sessionId, chunkIndex)
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
- sendAudioFile(res, path)
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: typeof body.domain === 'string' && body.domain.trim() ? body.domain : undefined,
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,