@gotcos/glasses-server 6.21.17 → 6.21.18
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/CHANGELOG.md +76 -0
- package/package.json +1 -1
- package/server/lib/meeting-audio-archive.ts +314 -0
- package/server/lib/meeting-relabel.ts +14 -2
- package/server/lib/meeting-speaker-review.ts +223 -6
- package/server/lib/training-audio-provenance.ts +70 -0
- package/server/routes/health.ts +3 -0
- package/server/routes/meeting.ts +330 -2
- package/server/routes/transcribe-stream.ts +31 -0
- package/server/routes/voice.ts +91 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,79 @@
|
|
|
1
|
+
## 6.21.18
|
|
2
|
+
|
|
3
|
+
Everything a human needs to correct who spoke, and to hear the voice before
|
|
4
|
+
deciding. Four changes that landed together after Miles reviewed the 2026-08-06
|
|
5
|
+
Ditto meeting and found eleven attributed voices, most of them wrong.
|
|
6
|
+
|
|
7
|
+
- A NAME MUST BE EARNED. `identifySpeaker` accepts a match at 0.55, so one
|
|
8
|
+
segment could arrive in the review panel wearing somebody's full name — Richard
|
|
9
|
+
Jenkins on 1 segment at 0.60, Luke Henry on 1 at 0.55. Voices now carry
|
|
10
|
+
`nameAsserted` plus `assertionBlockers`; below the floor a client must render
|
|
11
|
+
"unidentified" and offer the label only as a scored candidate. Floors:
|
|
12
|
+
similarity >= 0.65 AND >= 3 segments AND not thrashing. The owner gets no
|
|
13
|
+
exemption.
|
|
14
|
+
- `timeline` ON THE REVIEW. Consecutive same-speaker spans with start/end, so a
|
|
15
|
+
ribbon can be an actual timeline. The previous one drew a rectangle per voice
|
|
16
|
+
sized by share of segments while labelled "who spoke, in order", so a voice
|
|
17
|
+
that spoke twice appeared once and hover could report nothing true.
|
|
18
|
+
- POST /meeting/:id/deattribute — "this voice was NOT that person". Un-attributes
|
|
19
|
+
it for ONE meeting and retracts the training samples that meeting contributed
|
|
20
|
+
to that person's profile, so a false attribution stops reinforcing itself. It
|
|
21
|
+
reports what it cannot reach: `train-g2` used to stamp a bare `g2-training`,
|
|
22
|
+
discarding which meeting each sample came from, and now stamps
|
|
23
|
+
`g2-training:<sessionId>`. Samples written before this are relabellable but not
|
|
24
|
+
retractable.
|
|
25
|
+
- MEETING AUDIO KEPT 7 DAYS, 8 GB budget (Miles's call: review should survive to
|
|
26
|
+
a weekend). Chunk WAVs previously died with the batch pipeline —
|
|
27
|
+
`session-audio` held 0 files. Hard-linked at the single choke point before the
|
|
28
|
+
rename, so it costs no extra disk and outlives the pipeline's cleanup. Sized on
|
|
29
|
+
measurement: 3.1 h/day mean and 6.9 h peak make a week ~2.5 GB uncompressed, so
|
|
30
|
+
the audio stays usable for re-transcription too. Retention sweeps expiry first,
|
|
31
|
+
then evicts oldest whole sessions; a session whose age cannot be read is kept.
|
|
32
|
+
- Playback: `GET /voice/profiles/:name/sample` (needs no retention change — the
|
|
33
|
+
training audio already existed, and playing a stored profile is what answers
|
|
34
|
+
"is this really them"), `GET /voice/ext-audio/:id/sample`, and
|
|
35
|
+
`GET /meeting/:id/audio/:chunk` which distinguishes "the window passed" from
|
|
36
|
+
"that chunk is missing" and reports which chunks are playable.
|
|
37
|
+
- `/api/health` gains `review_audio`. `voice_provenance` from 6.21.17 already
|
|
38
|
+
reports `noHumanSample`; on the live store that is 67 of 77 profiles.
|
|
39
|
+
|
|
40
|
+
FIXES FOUND BY QA BEFORE PUBLISH (nothing above ever shipped):
|
|
41
|
+
|
|
42
|
+
- PLAYBACK PLAYS THE RIGHT AUDIO. `Phrase` now carries the RAW capture index,
|
|
43
|
+
resolved from the sidecar's `chunkEntries`. The compacted `chunks` array and
|
|
44
|
+
the `chunk_NNNN.wav` numbering are different sequences: on the 2026-08-06 Ditto
|
|
45
|
+
sidecar, 885 compacted chunks against raw indices 0..945 with 36 gaps, so array
|
|
46
|
+
position 884 is raw chunk 940. Playing by position would have played a
|
|
47
|
+
different speaker minutes earlier — on the one screen whose job is confirming
|
|
48
|
+
who spoke. When the counts disagree no index is emitted at all, because a
|
|
49
|
+
shifted index is worse than no button.
|
|
50
|
+
- THE OWNER IS EXEMPT FROM THE NAME FLOOR. The wearer is verified at exactly the
|
|
51
|
+
floor (VERIFY_THRESHOLD 0.65), so they sat permanently on the boundary and any
|
|
52
|
+
thrash pair flipped them: measured across the 2026-08-06 corpus, the owner row
|
|
53
|
+
read "Unidentified voice" in 4 of 9 meetings, once with 285 of their own
|
|
54
|
+
segments. `thrashesWith` still renders, so a mixed row is still visible.
|
|
55
|
+
- DE-ATTRIBUTION NUMBERS ITS LABELS. `Unidentified 1`, `Unidentified 2`, … rather
|
|
56
|
+
than one shared `Ext`. Miles found five wrong attributions in one meeting;
|
|
57
|
+
collapsing them into a single row would have destroyed his ability to tell
|
|
58
|
+
those voices apart, which is exactly what playback is for next.
|
|
59
|
+
- DE-ATTRIBUTION DELETES THE ATTENDEE BULLET instead of renaming it. Renaming
|
|
60
|
+
wrote an unidentified label into the attendee list as though it were a person,
|
|
61
|
+
and every downstream reader takes that bullet at face value while the pipeline
|
|
62
|
+
that BUILDS attendees deliberately excludes such labels.
|
|
63
|
+
- The timeline's LAST SPAN had zero width. `durationMs` frequently equals the
|
|
64
|
+
final chunk's start exactly (both 5,783,732 on the Ditto sidecar), so the
|
|
65
|
+
closing turn rendered as a sliver. The tail now takes one typical chunk of
|
|
66
|
+
width from the meeting's own median gap — measured 7,034 ms where it was 0.
|
|
67
|
+
- De-attribute reports `markdownSkipped`, which relabel already did and it
|
|
68
|
+
silently dropped. 39% of operations sidecars have no `.md` beside them, so the
|
|
69
|
+
response claimed segment changes with no hint the document was untouched.
|
|
70
|
+
- `/api/health` no longer statSyncs every retained chunk on every poll. COS
|
|
71
|
+
Control polls every 12s and a 7-day window is 2,000-3,000 files; stats are now
|
|
72
|
+
cached for 30s and invalidated when retention actually removes something. The
|
|
73
|
+
`/audio` listing reads the retention window as config rather than walking disk.
|
|
74
|
+
|
|
75
|
+
Requires COS Control 0.5.0+ to use the scoped correction and playback surfaces.
|
|
76
|
+
|
|
1
77
|
## 6.21.17
|
|
2
78
|
|
|
3
79
|
- Voice profiles now give up their WEAKEST sample, not their oldest. Eviction is
|
package/package.json
CHANGED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Meeting audio kept long enough for a human to review who was speaking.
|
|
2
|
+
//
|
|
3
|
+
// WHY IT DID NOT EXIST. Chunk WAVs lived in `session-audio` only until a meeting
|
|
4
|
+
// was saved, then moved to `pending-batch` for HQ re-transcription and deleted
|
|
5
|
+
// when that finished. Measured 2026-08-06: `session-audio` held 0 files. So the
|
|
6
|
+
// review panel could show a phrase but never let anyone HEAR it, and its own
|
|
7
|
+
// copy said "naming this needs its audio, which is no longer held."
|
|
8
|
+
//
|
|
9
|
+
// Miles's decision: keep a week, so review can happen on a weekend rather than
|
|
10
|
+
// only within hours of the meeting, and stay under 8 GB.
|
|
11
|
+
//
|
|
12
|
+
// SIZING IS MEASURED, NOT GUESSED. Real recording volume over the 14 days to
|
|
13
|
+
// 2026-08-06 was 3.1 h/day mean, 6.9 h peak. A 7-day window is ~22 hours, which
|
|
14
|
+
// at 16 kHz mono 16-bit (32 KB/s) is ~2.5 GB — comfortably inside 8 GB. So the
|
|
15
|
+
// audio is kept UNCOMPRESSED and stays usable for re-transcription, not just
|
|
16
|
+
// playback. The cap is a runaway backstop: it would take a sustained 10 h/day
|
|
17
|
+
// week to reach it.
|
|
18
|
+
//
|
|
19
|
+
// HARD LINKS, NOT COPIES. Archiving happens at the moment audio moves to
|
|
20
|
+
// `pending-batch`, and links the same inodes rather than duplicating them. A
|
|
21
|
+
// copy would double disk for the whole batch window — 260 MB for a two-hour
|
|
22
|
+
// meeting — and introduce an ordering hazard against the batch purge. With links
|
|
23
|
+
// the pipeline can delete its directory whenever it likes and the bytes survive.
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
copyFileSync, existsSync, linkSync, mkdirSync, readdirSync, rmSync, statSync,
|
|
27
|
+
} from 'node:fs'
|
|
28
|
+
import { join, resolve } from 'node:path'
|
|
29
|
+
import { dataPath } from './data-dir.js'
|
|
30
|
+
|
|
31
|
+
export const MEETING_AUDIO_DIR = 'meeting-audio'
|
|
32
|
+
|
|
33
|
+
/** One week, per Miles: review should survive until a weekend. */
|
|
34
|
+
export function meetingAudioTtlMs(): number {
|
|
35
|
+
const raw = Number(process.env.COS_MEETING_AUDIO_RETENTION_DAYS)
|
|
36
|
+
const days = Number.isFinite(raw) && raw > 0 ? raw : 7
|
|
37
|
+
return days * 24 * 60 * 60 * 1000
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Total budget for retained meeting audio. Miles: stay under 8 GB. */
|
|
41
|
+
export function meetingAudioMaxBytes(): number {
|
|
42
|
+
const raw = Number(process.env.COS_MEETING_AUDIO_MAX_BYTES)
|
|
43
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 8 * 1024 * 1024 * 1024
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Master switch. Default ON — with it off, review has no audio at all. */
|
|
47
|
+
export function meetingAudioEnabled(): boolean {
|
|
48
|
+
return process.env.COS_MEETING_AUDIO !== '0'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sessionDir(sessionId: string): string | null {
|
|
52
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
|
|
53
|
+
const root = dataPath(MEETING_AUDIO_DIR)
|
|
54
|
+
const path = join(root, sessionId.replace(/:/g, '_'))
|
|
55
|
+
return resolve(path).startsWith(resolve(root) + '/') ? path : null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Chunk WAVs as written by the capture path (`chunk_0000.wav`). */
|
|
59
|
+
function isChunkWav(name: string): boolean {
|
|
60
|
+
return /^chunk_\d+\.wav$/.test(name)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ArchiveResult {
|
|
64
|
+
linked: number
|
|
65
|
+
/** Files that had to be copied because the link failed (e.g. cross-device). */
|
|
66
|
+
copied: number
|
|
67
|
+
failed: number
|
|
68
|
+
bytes: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Bring one session's chunk WAVs into the archive.
|
|
73
|
+
*
|
|
74
|
+
* Never throws: this runs on the save path, and losing review audio must never
|
|
75
|
+
* cost a meeting. Individual failures are counted rather than aborting the rest,
|
|
76
|
+
* because a partially-archived meeting is still partially reviewable.
|
|
77
|
+
*/
|
|
78
|
+
export function archiveSessionAudio(sessionId: string, sourceDir: string): ArchiveResult {
|
|
79
|
+
const out: ArchiveResult = { linked: 0, copied: 0, failed: 0, bytes: 0 }
|
|
80
|
+
if (!meetingAudioEnabled()) return out
|
|
81
|
+
const dest = sessionDir(sessionId)
|
|
82
|
+
if (!dest || !existsSync(sourceDir)) return out
|
|
83
|
+
let names: string[]
|
|
84
|
+
try {
|
|
85
|
+
names = readdirSync(sourceDir).filter(isChunkWav)
|
|
86
|
+
} catch {
|
|
87
|
+
return out
|
|
88
|
+
}
|
|
89
|
+
if (names.length === 0) return out
|
|
90
|
+
try {
|
|
91
|
+
mkdirSync(dest, { recursive: true, mode: 0o700 })
|
|
92
|
+
} catch {
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
const from = resolve(sourceDir, name)
|
|
97
|
+
const to = resolve(dest, name)
|
|
98
|
+
if (existsSync(to)) continue
|
|
99
|
+
try {
|
|
100
|
+
linkSync(from, to)
|
|
101
|
+
out.linked++
|
|
102
|
+
} catch {
|
|
103
|
+
// A link can fail across filesystems or if the source vanished mid-sweep.
|
|
104
|
+
// Copy rather than skip: the point is that the audio survives.
|
|
105
|
+
try { copyFileSync(from, to); out.copied++ } catch { out.failed++; continue }
|
|
106
|
+
}
|
|
107
|
+
try { out.bytes += statSync(to).size } catch { /* counted as linked regardless */ }
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 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
|
|
115
|
+
try {
|
|
116
|
+
for (const name of readdirSync(dir).filter(isChunkWav)) {
|
|
117
|
+
try {
|
|
118
|
+
const st = statSync(join(dir, name))
|
|
119
|
+
bytes += st.size
|
|
120
|
+
files++
|
|
121
|
+
mtimeMs = Math.max(mtimeMs, st.mtimeMs)
|
|
122
|
+
} catch { /* skip unreadable */ }
|
|
123
|
+
}
|
|
124
|
+
} catch { /* unreadable dir reports zero */ }
|
|
125
|
+
return { bytes, mtimeMs, files }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface SweepResult {
|
|
129
|
+
removed: string[]
|
|
130
|
+
retained: string[]
|
|
131
|
+
bytesFreed: number
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Drop sessions past the retention window.
|
|
136
|
+
*
|
|
137
|
+
* A session whose age cannot be read is RETAINED — treating an unreadable stat
|
|
138
|
+
* as ancient would delete the audio a pending review depends on.
|
|
139
|
+
*/
|
|
140
|
+
export function sweepMeetingAudio(nowMs: number, ttlMs = meetingAudioTtlMs()): SweepResult {
|
|
141
|
+
const root = dataPath(MEETING_AUDIO_DIR)
|
|
142
|
+
const out: SweepResult = { removed: [], retained: [], bytesFreed: 0 }
|
|
143
|
+
if (!existsSync(root)) return out
|
|
144
|
+
let names: string[]
|
|
145
|
+
try { names = readdirSync(root) } catch { return out }
|
|
146
|
+
for (const name of names) {
|
|
147
|
+
const dir = join(root, name)
|
|
148
|
+
const { bytes, mtimeMs } = sessionSize(dir)
|
|
149
|
+
if (mtimeMs <= 0) { out.retained.push(name); continue }
|
|
150
|
+
if (nowMs - mtimeMs > ttlMs) {
|
|
151
|
+
try { rmSync(dir, { recursive: true, force: true }); out.removed.push(name); out.bytesFreed += bytes }
|
|
152
|
+
catch { out.retained.push(name) }
|
|
153
|
+
} else {
|
|
154
|
+
out.retained.push(name)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface CapResult {
|
|
161
|
+
evicted: string[]
|
|
162
|
+
bytesBefore: number
|
|
163
|
+
bytesAfter: number
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Evict OLDEST SESSIONS FIRST until the archive fits its budget.
|
|
168
|
+
*
|
|
169
|
+
* Whole sessions, not individual chunks: half a meeting's audio is a confusing
|
|
170
|
+
* artefact, and predictable eviction beats squeezing in a few more megabytes.
|
|
171
|
+
*
|
|
172
|
+
* Note on accounting: while `pending-batch` still holds the same inodes, these
|
|
173
|
+
* hard-linked files are counted at full size in BOTH places, so the total reads
|
|
174
|
+
* high during the batch window. That is deliberately conservative — it can sweep
|
|
175
|
+
* slightly early, never late.
|
|
176
|
+
*/
|
|
177
|
+
export function enforceMeetingAudioCap(maxBytes = meetingAudioMaxBytes()): CapResult {
|
|
178
|
+
const root = dataPath(MEETING_AUDIO_DIR)
|
|
179
|
+
const out: CapResult = { evicted: [], bytesBefore: 0, bytesAfter: 0 }
|
|
180
|
+
if (!existsSync(root)) return out
|
|
181
|
+
let names: string[]
|
|
182
|
+
try { names = readdirSync(root) } catch { return out }
|
|
183
|
+
|
|
184
|
+
const entries = names.map(name => ({ name, ...sessionSize(join(root, name)) }))
|
|
185
|
+
out.bytesBefore = entries.reduce((n, e) => n + e.bytes, 0)
|
|
186
|
+
out.bytesAfter = out.bytesBefore
|
|
187
|
+
if (out.bytesAfter <= maxBytes) return out
|
|
188
|
+
|
|
189
|
+
// Oldest first. A session with an unreadable mtime sorts LAST so it is evicted
|
|
190
|
+
// only as a final resort, matching the sweeper's bias toward keeping evidence.
|
|
191
|
+
entries.sort((a, b) => (a.mtimeMs || Number.MAX_SAFE_INTEGER) - (b.mtimeMs || Number.MAX_SAFE_INTEGER))
|
|
192
|
+
for (const e of entries) {
|
|
193
|
+
if (out.bytesAfter <= maxBytes) break
|
|
194
|
+
try {
|
|
195
|
+
rmSync(join(root, e.name), { recursive: true, force: true })
|
|
196
|
+
out.evicted.push(e.name)
|
|
197
|
+
out.bytesAfter -= e.bytes
|
|
198
|
+
} catch { /* leave it counted; the next pass will try again */ }
|
|
199
|
+
}
|
|
200
|
+
return out
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Absolute path to one chunk's WAV, or null when it is not retained. */
|
|
204
|
+
export function meetingAudioChunkPath(sessionId: string, chunkIndex: number): string | null {
|
|
205
|
+
const dir = sessionDir(sessionId)
|
|
206
|
+
if (!dir) return null
|
|
207
|
+
// No integer guard: `chunkIndex` is a number, so the interpolation can never
|
|
208
|
+
// contain a path separator, and a nonsense value simply names a file that does
|
|
209
|
+
// not exist. Mutation confirmed an explicit check here is unreachable behind
|
|
210
|
+
// the existence test below.
|
|
211
|
+
const path = resolve(dir, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`)
|
|
212
|
+
if (!resolve(path).startsWith(resolve(dir) + '/')) return null
|
|
213
|
+
return existsSync(path) ? path : null
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Chunk indices retained for a session, ascending. */
|
|
217
|
+
export function listMeetingAudioChunks(sessionId: string): number[] {
|
|
218
|
+
const dir = sessionDir(sessionId)
|
|
219
|
+
if (!dir || !existsSync(dir)) return []
|
|
220
|
+
try {
|
|
221
|
+
return readdirSync(dir)
|
|
222
|
+
.filter(isChunkWav)
|
|
223
|
+
.map(n => Number(n.slice('chunk_'.length, -'.wav'.length)))
|
|
224
|
+
.filter(n => Number.isInteger(n))
|
|
225
|
+
.sort((a, b) => a - b)
|
|
226
|
+
} catch {
|
|
227
|
+
return []
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Cached stats for /api/health.
|
|
233
|
+
*
|
|
234
|
+
* The uncached walk statSyncs EVERY retained chunk: a 7-day window at the
|
|
235
|
+
* measured rate is roughly 2,000-3,000 files, and COS Control polls health every
|
|
236
|
+
* 12 seconds — thousands of synchronous stats on the same event loop that ingests
|
|
237
|
+
* live audio. The sibling chunkEmbeddingStoreStats is one stat per session for
|
|
238
|
+
* the same reason. A 30s cache keeps the number useful without the cost.
|
|
239
|
+
*/
|
|
240
|
+
let statsCache: { at: number; value: ReturnType<typeof computeMeetingAudioStats> } | null = null
|
|
241
|
+
const STATS_TTL_MS = 30_000
|
|
242
|
+
|
|
243
|
+
export function meetingAudioStats(): ReturnType<typeof computeMeetingAudioStats> {
|
|
244
|
+
const now = Date.now()
|
|
245
|
+
if (statsCache && now - statsCache.at < STATS_TTL_MS) return statsCache.value
|
|
246
|
+
const value = computeMeetingAudioStats()
|
|
247
|
+
statsCache = { at: now, value }
|
|
248
|
+
return value
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Invalidate after a sweep or eviction so health does not report stale usage. */
|
|
252
|
+
export function invalidateMeetingAudioStats(): void {
|
|
253
|
+
statsCache = null
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Counts for /api/health, so retention can be seen rather than assumed. */
|
|
257
|
+
function computeMeetingAudioStats(): {
|
|
258
|
+
enabled: boolean
|
|
259
|
+
sessions: number
|
|
260
|
+
bytes: number
|
|
261
|
+
maxBytes: number
|
|
262
|
+
retentionDays: number
|
|
263
|
+
oldestAgeHours: number | null
|
|
264
|
+
} {
|
|
265
|
+
const root = dataPath(MEETING_AUDIO_DIR)
|
|
266
|
+
const retentionDays = Math.round((meetingAudioTtlMs() / (24 * 60 * 60 * 1000)) * 10) / 10
|
|
267
|
+
const base = {
|
|
268
|
+
enabled: meetingAudioEnabled(),
|
|
269
|
+
maxBytes: meetingAudioMaxBytes(),
|
|
270
|
+
retentionDays,
|
|
271
|
+
}
|
|
272
|
+
if (!existsSync(root)) return { ...base, sessions: 0, bytes: 0, oldestAgeHours: null }
|
|
273
|
+
let sessions = 0, bytes = 0, oldest = Number.POSITIVE_INFINITY
|
|
274
|
+
try {
|
|
275
|
+
for (const name of readdirSync(root)) {
|
|
276
|
+
const { bytes: b, mtimeMs, files } = sessionSize(join(root, name))
|
|
277
|
+
if (files === 0) continue
|
|
278
|
+
sessions++
|
|
279
|
+
bytes += b
|
|
280
|
+
if (mtimeMs > 0) oldest = Math.min(oldest, mtimeMs)
|
|
281
|
+
}
|
|
282
|
+
} catch { /* report what we have */ }
|
|
283
|
+
return {
|
|
284
|
+
...base,
|
|
285
|
+
sessions,
|
|
286
|
+
bytes,
|
|
287
|
+
oldestAgeHours: Number.isFinite(oldest) ? Math.round(((Date.now() - oldest) / 3_600_000) * 10) / 10 : null,
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* One retention pass: expire first, then enforce the budget.
|
|
293
|
+
*
|
|
294
|
+
* ORDER IS LOAD-BEARING and that is why this is a named function rather than two
|
|
295
|
+
* calls inline in an interval. Enforcing the cap first would let it EVICT audio
|
|
296
|
+
* that the sweeper was about to expire anyway — counting those bytes against the
|
|
297
|
+
* budget and so evicting extra sessions that were still inside their window. Run
|
|
298
|
+
* the other way round, the cap only ever sees audio a human could still want.
|
|
299
|
+
*/
|
|
300
|
+
/** Retention window in days — a pure config read, no filesystem. */
|
|
301
|
+
export function meetingAudioRetentionDays(): number {
|
|
302
|
+
return Math.round((meetingAudioTtlMs() / (24 * 60 * 60 * 1000)) * 10) / 10
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function runMeetingAudioRetention(nowMs = Date.now()): {
|
|
306
|
+
swept: SweepResult
|
|
307
|
+
capped: CapResult
|
|
308
|
+
} {
|
|
309
|
+
const swept = sweepMeetingAudio(nowMs)
|
|
310
|
+
const capped = enforceMeetingAudioCap()
|
|
311
|
+
// Usage just changed; do not let health report the pre-sweep figure.
|
|
312
|
+
if (swept.removed.length > 0 || capped.evicted.length > 0) invalidateMeetingAudioStats()
|
|
313
|
+
return { swept, capped }
|
|
314
|
+
}
|
|
@@ -185,7 +185,12 @@ function sectionRange(md: string, heading: string): { start: number; end: number
|
|
|
185
185
|
* by label, not by index, and the markdown's turn segmentation does not match
|
|
186
186
|
* the sidecar's. See the file header.
|
|
187
187
|
*/
|
|
188
|
-
export function relabelMeetingMarkdown(
|
|
188
|
+
export function relabelMeetingMarkdown(
|
|
189
|
+
md: string,
|
|
190
|
+
from: string,
|
|
191
|
+
to: string,
|
|
192
|
+
options: { removeAttendee?: boolean } = {},
|
|
193
|
+
): RelabelOutcome<MarkdownRelabelResult> {
|
|
189
194
|
if (from === to) return { ok: false, error: 'from and to are the same label' }
|
|
190
195
|
for (const [which, label] of [['from', from], ['to', to]] as const) {
|
|
191
196
|
const bad = invalidLabelReason(label)
|
|
@@ -204,7 +209,14 @@ export function relabelMeetingMarkdown(md: string, from: string, to: string): Re
|
|
|
204
209
|
// run past its own line. Without the end anchor, `- Luke H` matches the
|
|
205
210
|
// PREFIX of `- Luke Henry` and deleting it leaves the fragment `enry`.
|
|
206
211
|
const line = (label: string) => `^-[ \t]+${escapeRegExp(label)}[ \t]*$`
|
|
207
|
-
|
|
212
|
+
// A de-attribution must DELETE the attendee bullet, never rename it. Renaming
|
|
213
|
+
// writes `- Unidentified 2` into the attendee list as though it were a
|
|
214
|
+
// person, and the COS pipeline that BUILDS attendees deliberately excludes
|
|
215
|
+
// unidentified labels (sync_meetings.py filters them) while every reader —
|
|
216
|
+
// parseAttendees, extractAttendees, the Python prep generators — takes the
|
|
217
|
+
// bullet at face value. So a rename here injects a phantom attendee that
|
|
218
|
+
// downstream treats as real.
|
|
219
|
+
const alreadyListed = options.removeAttendee || new RegExp(line(to), 'm').test(body)
|
|
208
220
|
let rewritten: string
|
|
209
221
|
if (alreadyListed) {
|
|
210
222
|
// Both names present: drop the old bullet instead of creating a duplicate
|