@gotcos/glasses-server 6.21.14 → 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.
@@ -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
+ }
@@ -0,0 +1,218 @@
1
+ // The record of every speaker correction a human has made, per meeting.
2
+ //
3
+ // WHY A LEDGER AND NOT JUST A REWRITE. A relabel mutates files in place: the
4
+ // chunk sidecar, the attendee list, the transcript turn labels. If that is all
5
+ // that happens, three things become impossible:
6
+ //
7
+ // 1. UNDO. Once "Luke H" has become "Luke Henry" in the sidecar, nothing
8
+ // remembers it was ever anything else. A mistaken correction is permanent.
9
+ // 2. CRASH RECOVERY. A rewrite touches several files. Die between them and the
10
+ // meeting is half-corrected with no trace of what was intended.
11
+ // 3. TRAINING. Piece 3 turns a correction into an enrollment. It needs to know
12
+ // WHICH chunks a human vouched for, and to be able to retract that vouching
13
+ // later if the correction is undone.
14
+ //
15
+ // So the ledger is written FIRST, as intent, and the rewrite follows. A row with
16
+ // an intent and no outcome is a correction that did not finish — visible rather
17
+ // than silent.
18
+ //
19
+ // WHAT IS DELIBERATELY NOT CORRECTED. The meeting markdown's Summary, Topics,
20
+ // Decisions and Action Items are LLM prose that refers to people by BARE FIRST
21
+ // NAME ("Jeremy pushed back", "Chris raised Beamer sentiment"). Verified on a
22
+ // real scribe: 6 of 12 speakers appear that way. This org has two Kyles, two
23
+ // Jacobuses and two Chrises, so a find/replace on a first name in narrative text
24
+ // would silently rewrite a sentence about a different person. Prose is left
25
+ // alone and flagged stale instead — `proseStale` on the applied row.
26
+
27
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
28
+ import { join, resolve } from 'node:path'
29
+ import { dataPath } from './data-dir.js'
30
+
31
+ export const CORRECTIONS_DIR = 'meeting-corrections'
32
+
33
+ /**
34
+ * A correction is recorded in two phases sharing one id.
35
+ * `intent` goes down before any file is touched; `applied` or `failed` closes it.
36
+ * An unclosed intent is an incomplete correction, not a successful one.
37
+ */
38
+ export type CorrectionPhase = 'intent' | 'applied' | 'failed'
39
+
40
+ export interface CorrectionSurfaces {
41
+ /** Chunks whose `speaker` changed in the sidecar. */
42
+ sidecar: number
43
+ /** Lines changed in the markdown attendee list. */
44
+ attendees: number
45
+ /** `[Name]:` turn labels changed in the markdown transcript. */
46
+ transcript: number
47
+ }
48
+
49
+ export interface CorrectionRow {
50
+ id: string
51
+ phase: CorrectionPhase
52
+ /** ISO timestamp, supplied by the caller so this module stays deterministic. */
53
+ at: string
54
+ from: string
55
+ to: string
56
+ /**
57
+ * The exact chunk indices this correction covers. Explicit rather than derived,
58
+ * because piece 3 enrolls precisely these and must be able to retract
59
+ * precisely these. An empty array means "every chunk carrying `from`", which
60
+ * is resolved at apply time and written back onto the applied row.
61
+ */
62
+ chunks: number[]
63
+ /**
64
+ * 'meeting' is the only scope. Corrections are per-meeting BY DESIGN: the
65
+ * identifier mishearing one voice in one room does not mean every past chunk
66
+ * was wrong, and rewriting history on a single correction is how a small
67
+ * mistake becomes an unrecoverable one.
68
+ */
69
+ scope: 'meeting'
70
+ surfaces?: CorrectionSurfaces
71
+ /** True when narrative prose still carries the old label. See the header. */
72
+ proseStale?: boolean
73
+ /** Why an attempt failed, on a `failed` row. */
74
+ error?: string
75
+ }
76
+
77
+ function sessionFile(sessionId: string): string | null {
78
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
79
+ const dir = dataPath(CORRECTIONS_DIR)
80
+ const path = join(dir, `${sessionId.replace(/:/g, '_')}.jsonl`)
81
+ return resolve(path).startsWith(resolve(dir) + '/') ? path : null
82
+ }
83
+
84
+ /**
85
+ * Append one row. Returns false rather than throwing on a bad session id or an
86
+ * unwritable directory — but note the caller's contract: if the INTENT row
87
+ * cannot be written, the rewrite must not proceed. An unrecorded mutation is
88
+ * exactly what this file exists to prevent.
89
+ */
90
+ export function appendCorrection(sessionId: string, row: CorrectionRow): boolean {
91
+ const path = sessionFile(sessionId)
92
+ if (!path) return false
93
+ try {
94
+ mkdirSync(dataPath(CORRECTIONS_DIR), { recursive: true, mode: 0o700 })
95
+ appendFileSync(path, JSON.stringify(row) + '\n', { mode: 0o600 })
96
+ return true
97
+ } catch {
98
+ return false
99
+ }
100
+ }
101
+
102
+ export interface CorrectionReadResult {
103
+ rows: CorrectionRow[]
104
+ /** Lines that could not be parsed. Surfaced, because a correction history read
105
+ * partially is a correction history that lies about what a human decided. */
106
+ unusable: number
107
+ missing: boolean
108
+ }
109
+
110
+ export function readCorrections(sessionId: string): CorrectionReadResult {
111
+ const path = sessionFile(sessionId)
112
+ if (!path || !existsSync(path)) return { rows: [], unusable: 0, missing: true }
113
+ let raw: string
114
+ try {
115
+ raw = readFileSync(path, 'utf-8')
116
+ } catch {
117
+ return { rows: [], unusable: 0, missing: true }
118
+ }
119
+ const rows: CorrectionRow[] = []
120
+ let unusable = 0
121
+ for (const line of raw.split('\n')) {
122
+ if (line.trim() === '') continue
123
+ try {
124
+ const o = JSON.parse(line) as Record<string, unknown>
125
+ if (typeof o.id !== 'string' || typeof o.from !== 'string' || typeof o.to !== 'string') { unusable++; continue }
126
+ if (o.phase !== 'intent' && o.phase !== 'applied' && o.phase !== 'failed') { unusable++; continue }
127
+ rows.push({
128
+ id: o.id,
129
+ phase: o.phase,
130
+ at: typeof o.at === 'string' ? o.at : '',
131
+ from: o.from,
132
+ to: o.to,
133
+ chunks: Array.isArray(o.chunks) ? o.chunks.filter((n): n is number => typeof n === 'number') : [],
134
+ scope: 'meeting',
135
+ surfaces: isSurfaces(o.surfaces) ? o.surfaces : undefined,
136
+ proseStale: typeof o.proseStale === 'boolean' ? o.proseStale : undefined,
137
+ error: typeof o.error === 'string' ? o.error : undefined,
138
+ })
139
+ } catch {
140
+ unusable++
141
+ }
142
+ }
143
+ return { rows, unusable, missing: false }
144
+ }
145
+
146
+ function isSurfaces(v: unknown): v is CorrectionSurfaces {
147
+ if (!v || typeof v !== 'object') return false
148
+ const o = v as Record<string, unknown>
149
+ return typeof o.sidecar === 'number' && typeof o.attendees === 'number' && typeof o.transcript === 'number'
150
+ }
151
+
152
+ /**
153
+ * Corrections that recorded an intent and never closed it.
154
+ *
155
+ * This is the crash signal. A process that died mid-rewrite leaves exactly this,
156
+ * and a meeting with a pending correction should be treated as possibly
157
+ * half-written rather than clean.
158
+ */
159
+ export function pendingCorrections(sessionId: string): CorrectionRow[] {
160
+ const { rows } = readCorrections(sessionId)
161
+ const closed = new Set(rows.filter(r => r.phase !== 'intent').map(r => r.id))
162
+ return rows.filter(r => r.phase === 'intent' && !closed.has(r.id))
163
+ }
164
+
165
+ /** Only the corrections that actually landed — the ones piece 3 may train on. */
166
+ export function appliedCorrections(sessionId: string): CorrectionRow[] {
167
+ return readCorrections(sessionId).rows.filter(r => r.phase === 'applied')
168
+ }
169
+
170
+ /**
171
+ * Follow a chain of applied corrections to the label a voice now carries.
172
+ *
173
+ * Chains happen: a voice labelled 'Ext' is corrected to 'Luke H', then later to
174
+ * 'Luke Henry'. Asking "what is Ext now?" must answer 'Luke Henry', not stop at
175
+ * the first hop. Cycles are possible if a human corrects A→B then B→A, so the
176
+ * walk is bounded by the number of corrections and returns the last label
177
+ * reached rather than looping.
178
+ */
179
+ export function currentLabelFor(sessionId: string, originalLabel: string): string {
180
+ const applied = appliedCorrections(sessionId)
181
+ let label = originalLabel
182
+ const seen = new Set<string>([label])
183
+ for (let hop = 0; hop < applied.length; hop++) {
184
+ // Latest applied correction FROM the current label wins: a later human
185
+ // decision supersedes an earlier one.
186
+ const next = [...applied].reverse().find(r => r.from === label)
187
+ if (!next || seen.has(next.to)) break
188
+ label = next.to
189
+ seen.add(label)
190
+ }
191
+ return label
192
+ }
193
+
194
+ /** Counts for /api/health — pending is the number that matters. */
195
+ export function correctionStoreStats(): {
196
+ sessions: number
197
+ applied: number
198
+ pending: number
199
+ failed: number
200
+ } {
201
+ const dir = dataPath(CORRECTIONS_DIR)
202
+ if (!existsSync(dir)) return { sessions: 0, applied: 0, pending: 0, failed: 0 }
203
+ let sessions = 0, applied = 0, pending = 0, failed = 0
204
+ try {
205
+ for (const name of readdirSync(dir).filter(n => n.endsWith('.jsonl'))) {
206
+ try {
207
+ statSync(join(dir, name))
208
+ sessions++
209
+ const id = name.replace(/\.jsonl$/, '')
210
+ const { rows } = readCorrections(id)
211
+ applied += rows.filter(r => r.phase === 'applied').length
212
+ failed += rows.filter(r => r.phase === 'failed').length
213
+ pending += pendingCorrections(id).length
214
+ } catch { /* skip unreadable */ }
215
+ }
216
+ } catch { /* report what we have */ }
217
+ return { sessions, applied, pending, failed }
218
+ }