@gotcos/glasses-server 6.83.0 → 6.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,8 +43,16 @@ export interface QuarantineManifest {
43
43
  reason: string
44
44
  chunkFiles: number
45
45
  bytes: number
46
+ /** 6.83.2: a shorter clock for this one capture (a Mac capture discarded with no sound). Never longer than the global one. */
47
+ retentionMs?: number
46
48
  }
47
49
 
50
+ /** 6.83.2: how long a discarded no-sound Mac capture stays quarantined: long enough to undo a wrong discard by hand. */
51
+ export const DISCARDED_RETENTION_MS = 24 * 60 * 60 * 1000
52
+
53
+ /** The receipt a discard writes, so the capture is terminal (`no_speech`) and never recovered on its own. */
54
+ export interface QuarantineReceipt { outcome: 'no_speech'; discarded: true; chunkFiles?: number; note: string }
55
+
48
56
  export interface UnsavedCapture {
49
57
  sessionId: string
50
58
  dirName: string
@@ -57,6 +65,12 @@ export interface UnsavedCapture {
57
65
  recovered: boolean
58
66
  }
59
67
 
68
+ /** The clock that applies to one capture: its own when it set a shorter one, else the global retention. */
69
+ export function effectiveRetentionMs(manifest: { retentionMs?: unknown } | null, retentionMs: number): number {
70
+ const own = manifest?.retentionMs
71
+ return typeof own === 'number' && Number.isFinite(own) && own > 0 ? Math.min(retentionMs, own) : retentionMs
72
+ }
73
+
60
74
  export function unsavedAudioRoot(): string {
61
75
  return dataPath('unsaved-audio')
62
76
  }
@@ -99,6 +113,7 @@ export function quarantineSessionAudio(
99
113
  sourceDir: string,
100
114
  reason: string,
101
115
  root: string = unsavedAudioRoot(),
116
+ options: { retentionMs?: number; receipt?: QuarantineReceipt } = {},
102
117
  ): string | null {
103
118
  const sessionId = basename(sourceDir)
104
119
  try {
@@ -114,6 +129,7 @@ export function quarantineSessionAudio(
114
129
  reason,
115
130
  chunkFiles: countChunkWavs(target),
116
131
  bytes: dirBytes(target),
132
+ ...(options.retentionMs ? { retentionMs: options.retentionMs } : {}),
117
133
  }
118
134
  try {
119
135
  writeFileSync(resolve(target, QUARANTINE_MANIFEST), `${JSON.stringify(manifest)}\n`, {
@@ -121,6 +137,19 @@ export function quarantineSessionAudio(
121
137
  mode: 0o600,
122
138
  })
123
139
  } catch { /* manifest is observability; the audio move already succeeded */ }
140
+ if (options.receipt) {
141
+ // 6.83.2: a discarded capture is terminal. The receipt is what keeps the auto-recover sweep and the
142
+ // unsaved-captures count off it (listUnsavedCaptures reads it as recovered, readCaptureDisposition as no_speech).
143
+ try {
144
+ writeFileSync(resolve(target, RECOVERED_RECEIPT), `${JSON.stringify({
145
+ schemaVersion: 1,
146
+ recoveredAt: new Date().toISOString(),
147
+ chunkFiles: manifest.chunkFiles,
148
+ words: 0,
149
+ ...options.receipt,
150
+ })}\n`, { encoding: 'utf8', mode: 0o600 })
151
+ } catch { /* the audio is quarantined either way; without the receipt it is merely listed as recoverable */ }
152
+ }
124
153
  return target
125
154
  } catch {
126
155
  // Rename failed (cross-device, permissions, race). Leave the source alone —
@@ -220,6 +249,7 @@ export function listUnsavedCaptures(
220
249
  } catch { continue }
221
250
  const manifest = readManifest(dirPath)
222
251
  const atMs = quarantinedAtMs(dirPath, manifest)
252
+ const ownRetentionMs = effectiveRetentionMs(manifest, retentionMs)
223
253
  captures.push({
224
254
  sessionId: manifest?.sessionId ?? dir.replace(/\.\d+$/, ''),
225
255
  dirName: dir,
@@ -228,7 +258,7 @@ export function listUnsavedCaptures(
228
258
  chunkFiles: manifest?.chunkFiles ?? countChunkWavs(dirPath),
229
259
  bytes: manifest?.bytes ?? dirBytes(dirPath),
230
260
  reason: manifest?.reason ?? null,
231
- expiresAt: atMs != null ? new Date(atMs + retentionMs).toISOString() : null,
261
+ expiresAt: atMs != null ? new Date(atMs + ownRetentionMs).toISOString() : null,
232
262
  recovered: existsSync(resolve(dirPath, RECOVERED_RECEIPT)),
233
263
  })
234
264
  }
@@ -248,9 +278,10 @@ export function purgeExpiredQuarantine(
248
278
  // sitting exactly at the retention boundary would otherwise lose its
249
279
  // audio mid-run, in the module whose thesis is "never delete evidence".
250
280
  if (isRecoveryActive(capture.dirName)) continue
251
- const atMs = capture.quarantinedAt ? Date.parse(capture.quarantinedAt) : NaN
252
- if (!Number.isFinite(atMs)) continue
253
- if (Date.now() - atMs > retentionMs) {
281
+ // The capture's own expiry: a discarded no-sound capture sets a shorter clock (6.83.2).
282
+ const expiresMs = capture.expiresAt ? Date.parse(capture.expiresAt) : NaN
283
+ if (!Number.isFinite(expiresMs)) continue
284
+ if (Date.now() > expiresMs) {
254
285
  try {
255
286
  rmSync(resolve(root, capture.dirName), { recursive: true, force: true })
256
287
  purged.push(capture.dirName)
@@ -433,7 +464,7 @@ function dispositionOf(sessionId: string, dirPath: string, retentionMs: number):
433
464
  quarantineReason: manifest?.reason ?? null,
434
465
  savedFilename,
435
466
  recovering: isRecoveryActive(basename(dirPath)),
436
- retainedUntil: atMs != null ? new Date(atMs + retentionMs).toISOString() : null,
467
+ retainedUntil: atMs != null ? new Date(atMs + effectiveRetentionMs(manifest, retentionMs)).toISOString() : null,
437
468
  }
438
469
  }
439
470
 
@@ -16,6 +16,7 @@ import crypto from 'node:crypto'
16
16
  import { batchTimestampsEnabled, parseNativeVadTimeline, remapBatchWords } from './whisper-batch-timing.js'
17
17
  import { readSttSettings, type FrontEndId } from './stt-settings.js'
18
18
  import { getVocabulary, getOwnerName, getWhisperCorrections } from './profile.js'
19
+ import { decoderSafeContext } from './glossary-learn.js'
19
20
  import { stripBrandUrls } from './hallucination-filter.js'
20
21
  import {
21
22
  batchHqMetalEnabled,
@@ -1243,8 +1244,9 @@ export async function transcribeHighQuality(
1243
1244
  }
1244
1245
  }
1245
1246
 
1246
- /** Build prompt with optional previous transcript context for continuity */
1247
- function buildPrompt(context?: string, isQuiet?: boolean): string {
1247
+ /** Build prompt with optional previous transcript context for continuity.
1248
+ * Exported (6.83.1) only so tests can assert on the exact decoder prompt. */
1249
+ export function buildPrompt(context?: string, isQuiet?: boolean): string {
1248
1250
  // During quiet/silence audio, skip vocabulary prompt to reduce decoder bias
1249
1251
  if (isQuiet) return ''
1250
1252
  if (!context || context.length < 10) return getWhisperPrompt()
@@ -1259,6 +1261,9 @@ function buildPrompt(context?: string, isQuiet?: boolean): string {
1259
1261
  // prior chunk would otherwise feed back through this context and re-seed the
1260
1262
  // next decode. Strip brand URLs from the priming context (output unaffected).
1261
1263
  if (STRIP_BRAND_URLS) sanitized = stripBrandUrls(sanitized)
1264
+ // 6.83.1: a correction-only form ("/pricing") came from the corrections pass, not the
1265
+ // speaker's audio; the decoder is primed with the spoken form instead (glossary-learn.ts).
1266
+ sanitized = decoderSafeContext(sanitized, getWhisperCorrections())
1262
1267
  // Previous transcript helps Whisper maintain proper noun consistency,
1263
1268
  // continue sentences across chunk boundaries, and reduce hallucinations
1264
1269
  return PROMPT_V2 ? `${getWhisperPrompt()} ${sanitized}` : `${getWhisperPrompt()}. ${sanitized}`
@@ -1379,14 +1384,34 @@ function buildCorrections(): Array<[RegExp, string]> {
1379
1384
 
1380
1385
  // Escape correction keys before interpolation. Names such as "A.C.M.E."
1381
1386
  // are literal vocabulary, never regular-expression programs.
1382
- for (const [pattern, replacement] of Object.entries(getWhisperCorrections())) {
1383
- const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1387
+ // 6.83.1: the words of a multi-word key may be split by a run of spaces or tabs, or by one
1388
+ // hyphen ("forward-slash pricing"); never by a line break or a spaced dash (" - ").
1389
+ for (const [pattern, replacement] of correctionOrder(getWhisperCorrections())) {
1390
+ const escaped = pattern.trim().split(/\s+/).map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('(?:[ \\t]+|-)')
1384
1391
  corrections.push([new RegExp(`\\b${escaped}\\b`, 'gi'), replacement])
1385
1392
  }
1386
1393
 
1387
1394
  return corrections
1388
1395
  }
1389
1396
 
1397
+ /**
1398
+ * The profile's order, except that a key goes ahead of any earlier key whose words it
1399
+ * contains (6.83.1): "forward slash build and price" runs before "forward slash build", or
1400
+ * the shorter key would cut the longer phrase short. Chains the person relies on
1401
+ * ("Myles" then "Miles Ukoma") keep their order, since neither key contains the other.
1402
+ */
1403
+ export function correctionOrder(corrections: Record<string, string>): Array<[string, string]> {
1404
+ const norm = (key: string) => ` ${key.trim().toLowerCase().split(/[\s-]+/).join(' ')} `
1405
+ const out: Array<[string, string]> = []
1406
+ for (const entry of Object.entries(corrections)) {
1407
+ const mine = norm(entry[0])
1408
+ const at = out.findIndex(([key]) => mine !== norm(key) && mine.includes(norm(key)))
1409
+ if (at < 0) out.push(entry)
1410
+ else out.splice(at, 0, entry)
1411
+ }
1412
+ return out
1413
+ }
1414
+
1390
1415
  let _corrections: Array<[RegExp, string]> | null = null
1391
1416
  function getCorrections(): Array<[RegExp, string]> {
1392
1417
  if (!_corrections) _corrections = buildCorrections()
@@ -1399,7 +1424,8 @@ function getCorrections(): Array<[RegExp, string]> {
1399
1424
  export function applyCorrections(text: string): string {
1400
1425
  let result = text
1401
1426
  for (const [pattern, replacement] of getCorrections()) {
1402
- result = result.replace(pattern, replacement)
1427
+ // A function, so a written form holding "$&" or "$1" is inserted literally (6.83.1).
1428
+ result = result.replace(pattern, () => replacement)
1403
1429
  }
1404
1430
  return result
1405
1431
  }
@@ -8,7 +8,9 @@
8
8
  // left unchanged). whisper_corrections is stored as a JSON STRING in the
9
9
  // profile (legacy decoder contract), so the route encodes/decodes it here.
10
10
  // POST /api/transcription-glossary/correction {heard, written, source} → learn
11
- // ONE correction and its spelling (6.73.11, Fix a word), logged for undo.
11
+ // ONE correction and its spelling (6.73.11, Fix a word), logged for undo. A
12
+ // written link, email or path is a correction only (6.83.1): the answer adds
13
+ // correctionOnly: true and a note, and the vocabulary is untouched.
12
14
  // GET /api/transcription-glossary/recent → { entries } newest first.
13
15
  // POST /api/transcription-glossary/correction/undo {id} → revert that entry.
14
16
  // POST /api/transcription-glossary/vocabulary/add {term}, /vocabulary/remove {term},
@@ -32,6 +34,8 @@ import {
32
34
  MAX_TERM_LEN,
33
35
  addVocabulary,
34
36
  appendLog,
37
+ CORRECTION_ONLY_NOTE,
38
+ isCorrectionOnly,
35
39
  learnCorrection,
36
40
  looksLikeUrlEmailPath,
37
41
  parseLog,
@@ -219,7 +223,15 @@ export function glossaryLearnCorrection(body: unknown): Promise<Outcome> {
219
223
  if (!learned.ok) return { status: learned.status, body: { error: learned.error } }
220
224
  if (learned.unchanged) return { status: 200, body: { unchanged: true, entry: null, glossary: currentGlossary() } }
221
225
  commitWords(learned.words, appendLog(previousLog, learned.entry), previousLog)
222
- return { status: 200, body: { unchanged: false, entry: learned.entry, glossary: currentGlossary() } }
226
+ // 6.83.1: a link, email or path is kept as a correction only; say so in words a client can show.
227
+ const correctionOnly = isCorrectionOnly(learned.entry.written)
228
+ return {
229
+ status: 200,
230
+ body: {
231
+ unchanged: false, entry: learned.entry, glossary: currentGlossary(),
232
+ ...(correctionOnly ? { correctionOnly: true, note: CORRECTION_ONLY_NOTE } : {}),
233
+ },
234
+ }
223
235
  })
224
236
  }
225
237
 
@@ -6,7 +6,7 @@ import { readSpeakerPolicy, writeSpeakerPolicy, isSpeakerMode } from '../lib/mee
6
6
 
7
7
  import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'
8
8
  import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
9
- import { resolve } from 'node:path'
9
+ import { basename, resolve } from 'node:path'
10
10
  import { createHash } from 'node:crypto'
11
11
  import { Router } from 'express'
12
12
  import { emitDisplay } from '../lib/display-bus.js'
@@ -129,6 +129,7 @@ import {
129
129
  analyzeTranscriptGaps,
130
130
  deleteSession,
131
131
  assertMacCaptureComplete,
132
+ discardSilentMacCapture,
132
133
  drainSessionAudioWrites,
133
134
  durableSessionRecoveryHasRun,
134
135
  getSessionChunkEntries,
@@ -805,6 +806,80 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
805
806
  })
806
807
  })
807
808
 
809
+ // ── 6.83.2: discard a stranded Mac capture that held no sound ──────────────────────
810
+ // POST /meeting/sessions/:id/discard {confirm: true, serverInstanceId}. COS Control offers it only for a capture the
811
+ // server called no_speech or whose audio was all zeros; the server re-checks everything itself
812
+ // (transcribe-stream.ts discardSilentMacCapture) and never touches a capture with speech. Without `confirm: true`
813
+ // it answers 400 confirmation_required with what it WOULD do, and changes nothing.
814
+ router.post('/meeting/sessions/:sessionId/discard', async (req, res) => {
815
+ const sessionId = String(req.params.sessionId ?? '')
816
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
817
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
818
+ return
819
+ }
820
+ res.set('Cache-Control', 'private, no-store')
821
+ if (!MAC_CAPTURE_ID.test(sessionId)) {
822
+ res.status(400).json({ error: 'Only a Mac recording can be discarded here', reason: 'not_mac_capture' })
823
+ return
824
+ }
825
+ const body = (req.body ?? {}) as Record<string, unknown>
826
+ const serverInstanceId = getServerInstanceId()
827
+ if (!serverInstanceId) {
828
+ res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
829
+ return
830
+ }
831
+ const pin = suppliedServerPin(req, body.serverInstanceId)
832
+ if (!pin) {
833
+ res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
834
+ return
835
+ }
836
+ if (pin !== serverInstanceId) {
837
+ res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
838
+ return
839
+ }
840
+ if (store.findBySessionId(sessionId)) {
841
+ res.status(409).json({ error: 'This recording is saved as a meeting', reason: 'already_saved' })
842
+ return
843
+ }
844
+ if (savingSessions.has(sessionId)) {
845
+ res.status(409).json({ error: 'This recording is being saved', reason: 'saving' })
846
+ return
847
+ }
848
+ const confirmed = body.confirm === true
849
+ const result = await discardSilentMacCapture(sessionId, { dryRun: !confirmed })
850
+ if (!result.ok && result.reason === 'session_not_found') {
851
+ // Already discarded (a retry after a lost answer): the quarantine says so, and the answer is the same.
852
+ const disposition = readCaptureDisposition(sessionId)
853
+ if (disposition?.quarantineReason === 'discarded_no_sound') {
854
+ res.json({ sessionId, discarded: true, replayed: true, quarantined: disposition.dirName, retainedUntil: disposition.retainedUntil })
855
+ return
856
+ }
857
+ res.status(404).json({ error: 'The server holds no live session for this recording', reason: 'session_not_found' })
858
+ return
859
+ }
860
+ if (!result.ok) {
861
+ const message: Record<string, string> = {
862
+ has_speech: 'This recording has speech; save it instead',
863
+ asr_in_progress: 'Audio is still being transcribed; try again in a moment',
864
+ chunk_in_progress: 'An audio chunk is still arriving; try again in a moment',
865
+ not_idle: 'This recording is still receiving audio',
866
+ not_mac_capture: 'Only a Mac recording can be discarded here',
867
+ }
868
+ res.status(409).json({ error: message[result.reason ?? ''] ?? 'Not discarded', reason: result.reason, receivedCount: result.receivedCount })
869
+ return
870
+ }
871
+ if (!confirmed) {
872
+ res.status(400).json({
873
+ error: 'Confirmation required', reason: 'confirmation_required',
874
+ preview: { sessionId, chunkFiles: result.chunkFiles, receivedCount: result.receivedCount, retentionHours: 24 },
875
+ })
876
+ return
877
+ }
878
+ console.warn(`[meeting/discard] Discarded no-sound Mac capture ${sessionId}: ${result.receivedCount} chunk(s) received, audio quarantined for 24 h`)
879
+ res.json({ sessionId, discarded: true, replayed: false, quarantined: result.quarantined ? basename(result.quarantined) : null,
880
+ chunkFiles: result.chunkFiles, receivedCount: result.receivedCount, retainedUntil: result.retainedUntil })
881
+ })
882
+
808
883
  // ── Stop a live G2 meeting from the Mac (COS Control) ────────────────────────────
809
884
  // The Mac never saves a live G2 session itself (lib/meeting-stop-requests.ts says why).
810
885
  // It asks the glasses: POST records the request and puts `meeting_stop_requested` on the
@@ -49,7 +49,9 @@ import {
49
49
  discardPolishPrewarm, isPrewarmModel, prewarmEnabled, PrewarmUnavailableError, runWarmPolish,
50
50
  startPolishPrewarm, takePolishPrewarm,
51
51
  } from '../lib/dictation-prewarm.js'
52
- import { getVocabulary } from '../lib/profile.js'
52
+ import { getVocabulary, getWhisperCorrections } from '../lib/profile.js'
53
+ import { correctionOnlyForms } from '../lib/glossary-learn.js'
54
+ import { polishLinkProblem, renderSpokenPaths, spokenPathsEnabled } from '../lib/dictation-format.js'
53
55
  import { createBreaker } from '../lib/claude-circuit.js'
54
56
  import { logTokenAudit } from '../lib/token-audit.js'
55
57
  import { emitDisplay } from '../lib/display-bus.js'
@@ -90,7 +92,7 @@ const autoCleanBreakers = {
90
92
  const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
91
93
 
92
94
  /** 6.79.1, additive on finalize answers: why the polish model did not run (or did not finish). */
93
- export type PolishSkipped = 'disabled' | 'too_long' | 'breaker_open' | 'daily_cap' | 'failed' | 'clean'
95
+ export type PolishSkipped = 'disabled' | 'too_long' | 'breaker_open' | 'daily_cap' | 'failed' | 'clean' | 'link_guard'
94
96
  /** 6.79.2, additive when finalize was given a prewarmToken: what became of that warm process. */
95
97
  export type PrewarmOutcome = 'used' | 'missed' | 'discarded'
96
98
 
@@ -144,6 +146,9 @@ async function polishOutboundDictation(
144
146
  }
145
147
  // 6.79.1: um/uh never survive a skipped polish. Meeting lines stay faithful.
146
148
  if (opts.bucket !== 'meeting' && fillerPassEnabled()) cleaned = stripDisfluencies(cleaned)
149
+ // 6.83.1: "forward slash pricing," is "/pricing," even when the model is skipped. One word
150
+ // at a phrase end only; slugs of several words are the model's (dictation-format.ts).
151
+ if (opts.bucket !== 'meeting' && spokenPathsEnabled()) cleaned = renderSpokenPaths(cleaned)
147
152
  if (!(opts.enabled ?? autoCleanDefaultEnabled())) return { text: cleaned, polishSkipped: 'disabled' }
148
153
  const model = opts.model ?? 'haiku'
149
154
  const autoCleanBreaker = autoCleanBreakers[model]
@@ -174,6 +179,14 @@ async function polishOutboundDictation(
174
179
  source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: polished.length,
175
180
  durationMs: Date.now() - startedAt, caller: `dictation_autoclean_${opts.bucket}`,
176
181
  })
182
+ // 6.83.1: the model never invents a link and never drops one the dictionary wrote. Its
183
+ // answer is refused (the pre-polish text goes out, polishSkipped "link_guard") rather than
184
+ // pasting a URL nobody said. The call still counted: it ran.
185
+ const linkProblem = polished ? polishLinkProblem(cleaned, polished, correctionOnlyForms(getWhisperCorrections()), getVocabulary()) : null
186
+ if (linkProblem) {
187
+ console.warn(`[prompt-draft] auto-clean answer refused: ${linkProblem}`)
188
+ return { text: cleaned, ...(prewarm ? { prewarm } : {}), polishSkipped: 'link_guard' }
189
+ }
177
190
  return { text: polished || cleaned, ...(prewarm ? { prewarm } : {}) }
178
191
  } catch (err: any) {
179
192
  autoCleanBreaker.recordFailure()
@@ -1,7 +1,7 @@
1
1
  import { MacParakeetUnavailableError, transcribeMacParakeet } from '../lib/mac-recording-asr.js'
2
2
  import { getMeetingStore } from '../lib/meeting-store.js'
3
3
  import { appendAutonomousSaveLog, getStopIntentStore, savedChunkReplay, type StopIntent } from '../lib/meeting-stop-intents.js'
4
- import { MAC_AUDIO, MAC_CAPTURE_ID, persistMacChunkReceipt, macError, registerMacRecording, requireMacRecording, readMacRecording, validateMacWav } from '../lib/mac-recording.js'
4
+ import { MAC_AUDIO, MAC_CAPTURE_ID, closeMacRecording, persistMacChunkReceipt, macError, registerMacRecording, requireMacRecording, readMacRecording, validateMacWav } from '../lib/mac-recording.js'
5
5
  import { AUTO_ENROLL_THRESHOLD } from '../lib/voiceprint-floor.js'
6
6
  import { readSpeakerPolicy, policyLabel, type SpeakerMode } from '../lib/meeting-speaker-policy.js'
7
7
  // POST /api/transcribe-stream — Streaming transcription for continuous meeting capture
@@ -15,7 +15,8 @@ import { chmodSync, readFileSync, writeFileSync, existsSync, lstatSync, mkdirSyn
15
15
  import { writeFile } from 'node:fs/promises'
16
16
  import { resolve } from 'node:path'
17
17
  import { fileURLToPath } from 'node:url'
18
- import { getVocabulary, getOwnerName, getOwnerSpeakerLabel } from '../lib/profile.js'
18
+ import { getVocabulary, getOwnerName, getOwnerSpeakerLabel, getWhisperCorrections } from '../lib/profile.js'
19
+ import { decoderContextWindow } from '../lib/glossary-learn.js'
19
20
  import { getOpenAIKey } from '../lib/openai-key.js'
20
21
  import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from '../lib/transcription-policy.js'
21
22
  import { STRANDED_STALE_MS } from '../lib/stranded-sessions.js'
@@ -81,12 +82,14 @@ import {
81
82
  } from '../lib/even-hub-speaker-role.js'
82
83
  import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
83
84
  import {
85
+ DISCARDED_RETENTION_MS,
84
86
  countChunkWavs,
85
87
  listUnsavedCaptures,
86
88
  purgeExpiredQuarantine,
87
89
  quarantineSessionAudio,
88
90
  readCaptureDisposition,
89
91
  sweepOrphanedSessionAudio,
92
+ type QuarantineReceipt,
90
93
  } from '../lib/unsaved-audio-quarantine.js'
91
94
  import {
92
95
  STALE_ACTIVE_SESSION_DIR_NAME,
@@ -1728,11 +1731,14 @@ export function getMeetingSessionStatus(sessionId: string, now = Date.now()): Me
1728
1731
  }
1729
1732
  }
1730
1733
 
1734
+ /** 6.83.2: a close that quarantines under its own reason, clock and terminal receipt (the Mac discard). */
1735
+ type CloseQuarantine = { reason: string; retentionMs?: number; receipt?: QuarantineReceipt }
1736
+
1731
1737
  function closeTranscriptSession(
1732
1738
  sessionId: string,
1733
1739
  reason: ClosedTranscriptSession['reason'],
1734
- options: { preserveAudio?: boolean } = {},
1735
- ): void {
1740
+ options: { preserveAudio?: boolean; quarantine?: CloseQuarantine } = {},
1741
+ ): string | null {
1736
1742
  // Progressive HQ is disposable cache state. Every terminal session path —
1737
1743
  // save, expiry, quarantine, or abandonment — must release the single owner.
1738
1744
  void stopProgressiveHqSession(sessionId)
@@ -1751,7 +1757,7 @@ function closeTranscriptSession(
1751
1757
  maxChunkIndex,
1752
1758
  reason,
1753
1759
  })
1754
- finishClosingTranscriptSession(sessionId, { ...options, closeReason: reason })
1760
+ return finishClosingTranscriptSession(sessionId, { ...options, closeReason: reason })
1755
1761
  }
1756
1762
 
1757
1763
  /** Delete session after save */
@@ -1759,10 +1765,11 @@ export function deleteSession(sessionId: string, options: { preserveAudio?: bool
1759
1765
  closeTranscriptSession(sessionId, 'saved', options)
1760
1766
  }
1761
1767
 
1768
+ /** Where a close put the session's chunk audio (the quarantine dir), or null when it moved none. */
1762
1769
  function finishClosingTranscriptSession(
1763
1770
  sessionId: string,
1764
- options: { preserveAudio?: boolean; closeReason?: ClosedTranscriptSession['reason'] },
1765
- ): void {
1771
+ options: { preserveAudio?: boolean; closeReason?: ClosedTranscriptSession['reason']; quarantine?: CloseQuarantine },
1772
+ ): string | null {
1766
1773
  sessions.delete(sessionId)
1767
1774
  sessionAudioBytes.delete(sessionId)
1768
1775
  sessionAudioWrites.delete(sessionId)
@@ -1794,6 +1801,7 @@ function finishClosingTranscriptSession(
1794
1801
  if (countChunkWavs(audioDir) > 0) {
1795
1802
  const target = quarantineSessionAudio(audioDir, 'close_saved_residual_chunks')
1796
1803
  if (target) console.warn(`[transcribe-stream] Saved-close left chunk audio behind; quarantined: ${sessionId} → ${target}`)
1804
+ return target
1797
1805
  } else {
1798
1806
  try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
1799
1807
  }
@@ -1801,13 +1809,114 @@ function finishClosingTranscriptSession(
1801
1809
  // Any non-saved close (expired, error, …) with chunk audio still on disk
1802
1810
  // is an unsaved capture. Quarantine it — never delete evidence (6.19.0).
1803
1811
  if (countChunkWavs(audioDir) > 0) {
1804
- const target = quarantineSessionAudio(audioDir, `close_${options.closeReason ?? 'unknown'}`)
1812
+ const quarantine = options.quarantine
1813
+ const target = quarantineSessionAudio(audioDir, quarantine?.reason ?? `close_${options.closeReason ?? 'unknown'}`, undefined,
1814
+ quarantine ? { retentionMs: quarantine.retentionMs, receipt: quarantine.receipt } : {})
1805
1815
  if (target) console.warn(`[transcribe-stream] Quarantined unsaved session-audio on close: ${sessionId} → ${target}`)
1806
1816
  else console.error(`[transcribe-stream] Quarantine move failed on close, source retained: ${sessionId}`)
1817
+ return target
1807
1818
  } else {
1808
1819
  try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
1809
1820
  }
1810
1821
  }
1822
+ return null
1823
+ }
1824
+
1825
+ // ── 6.83.2: discard a stranded Mac capture that holds no sound ─────────────────────
1826
+ // Miles, 2026-10-21: Record on Mac captured a 21-minute Zoom call as 211 chunks of exact zeros (macOS returns
1827
+ // zeros, not an error, when system audio recording is not allowed). The save refused it (no_speech), and the
1828
+ // capture then sat in the stranded list with a Save that can never work. COS Control asks the server to let it go.
1829
+ //
1830
+ // What it may touch: a Mac capture (Control is its only uploader, so nothing else will send it a chunk) that is in
1831
+ // memory, holds NO canonical chunk (no transcribed text, so no speech), has finished ASR on every chunk it received
1832
+ // (an in-flight chunk could still become speech), is idle past EMPTY_SESSION_STALE_MS, and has no chunk upload in
1833
+ // progress. Anything else is refused and left exactly as it was. The audio is never deleted here: it moves into the
1834
+ // existing quarantine with a terminal no_speech receipt and a 24-hour clock (DISCARDED_RETENTION_MS).
1835
+ export type MacDiscardRefusal =
1836
+ | 'session_not_found' | 'not_mac_capture' | 'has_speech' | 'asr_in_progress' | 'not_idle' | 'chunk_in_progress'
1837
+
1838
+ export interface MacDiscardResult {
1839
+ ok: boolean
1840
+ reason?: MacDiscardRefusal
1841
+ sessionId: string
1842
+ /** The quarantine dir the audio moved into, or null when the capture held no chunk audio. */
1843
+ quarantined: string | null
1844
+ chunkFiles: number
1845
+ receivedCount: number
1846
+ retainedUntil: string | null
1847
+ }
1848
+
1849
+ export const MAC_DISCARD_IDLE_MS = EMPTY_SESSION_STALE_MS
1850
+
1851
+ /** Why a session may not be discarded, or null when it may. Pure over the session's ledger. */
1852
+ export function macDiscardRefusal(input: {
1853
+ sessionId: string
1854
+ present: boolean
1855
+ canonicalCount: number
1856
+ receivedIndices: number[]
1857
+ asrCompletedIndices: number[]
1858
+ silentForMs: number
1859
+ chunkInProgress: boolean
1860
+ }): MacDiscardRefusal | null {
1861
+ if (!MAC_CAPTURE_ID.test(input.sessionId)) return 'not_mac_capture'
1862
+ if (!input.present) return 'session_not_found'
1863
+ if (input.canonicalCount > 0) return 'has_speech'
1864
+ const completed = new Set(input.asrCompletedIndices)
1865
+ if (input.receivedIndices.some(index => !completed.has(index))) return 'asr_in_progress'
1866
+ if (input.chunkInProgress) return 'chunk_in_progress'
1867
+ if (input.silentForMs < MAC_DISCARD_IDLE_MS) return 'not_idle'
1868
+ return null
1869
+ }
1870
+
1871
+ export async function discardSilentMacCapture(sessionId: string, options: { now?: number; dryRun?: boolean } = {}): Promise<MacDiscardResult> {
1872
+ const now = options.now ?? Date.now()
1873
+ const refusalFor = () => {
1874
+ const session = sessions.get(sessionId)
1875
+ return {
1876
+ session,
1877
+ refusal: macDiscardRefusal({
1878
+ sessionId,
1879
+ present: !!session,
1880
+ canonicalCount: session ? canonicalIndices(session).length : 0,
1881
+ receivedIndices: session?.receivedIndices ?? [],
1882
+ asrCompletedIndices: session?.asrCompletedIndices ?? [],
1883
+ silentForMs: session ? now - session.lastActivityAt : 0,
1884
+ chunkInProgress: macChunkLocks.has(sessionId),
1885
+ }),
1886
+ }
1887
+ }
1888
+ const empty = (reason: MacDiscardRefusal, receivedCount = 0): MacDiscardResult =>
1889
+ ({ ok: false, reason, sessionId, quarantined: null, chunkFiles: 0, receivedCount, retainedUntil: null })
1890
+ const first = refusalFor()
1891
+ if (first.refusal) return empty(first.refusal, first.session?.receivedIndices?.length ?? 0)
1892
+ if (options.dryRun) {
1893
+ const audioDir = getSessionAudioDirectory(sessionId)
1894
+ return { ok: true, sessionId, quarantined: null, chunkFiles: audioDir ? countChunkWavs(audioDir) : 0,
1895
+ receivedCount: first.session?.receivedIndices?.length ?? 0, retainedUntil: null }
1896
+ }
1897
+ // Every chunk write this session started reaches disk before its directory moves.
1898
+ await drainSessionAudioWrites(sessionId)
1899
+ // Ask again after the await: a chunk may have arrived meanwhile.
1900
+ const second = refusalFor()
1901
+ if (second.refusal) return empty(second.refusal, second.session?.receivedIndices?.length ?? 0)
1902
+ const receivedCount = second.session?.receivedIndices?.length ?? 0
1903
+ const quarantined = closeTranscriptSession(sessionId, 'closed', {
1904
+ quarantine: {
1905
+ reason: 'discarded_no_sound',
1906
+ retentionMs: DISCARDED_RETENTION_MS,
1907
+ receipt: { outcome: 'no_speech', discarded: true, note: 'Discarded from COS Control: no sound was captured. Audio retained until its 24-hour clock clears it.' },
1908
+ },
1909
+ })
1910
+ // A late chunk or a replayed start for this capture answers 410 from here on.
1911
+ closeMacRecording(sessionId)
1912
+ return {
1913
+ ok: true,
1914
+ sessionId,
1915
+ quarantined,
1916
+ chunkFiles: quarantined ? countChunkWavs(quarantined) : 0,
1917
+ receivedCount,
1918
+ retainedUntil: quarantined ? new Date(Date.now() + DISCARDED_RETENTION_MS).toISOString() : null,
1919
+ }
1811
1920
  }
1812
1921
 
1813
1922
  function isIosAsrCandidateEnabled(): boolean {
@@ -1946,7 +2055,13 @@ function recentWhisperContext(sessionId: string, session: TranscriptSession): st
1946
2055
  .map(c => c.text)
1947
2056
  .join(' ')
1948
2057
  const cleanedContext = stripInlineHallucinations(recentText, sessionId)
1949
- return cleanedContext.length > 250 ? cleanedContext.slice(-250) : cleanedContext
2058
+ // 6.83.1: correction-only forms ("/pricing") go back to speech BEFORE the 250-character cut.
2059
+ return decoderContextWindow(cleanedContext, decoderCorrections()) ?? ''
2060
+ }
2061
+
2062
+ /** The corrections map for the decoder window; a test double without it reads as none. */
2063
+ function decoderCorrections(): Record<string, string> {
2064
+ try { return getWhisperCorrections() } catch { return {} }
1950
2065
  }
1951
2066
 
1952
2067
  function isCrossChunkRepeat(session: TranscriptSession, text: string): boolean {
@@ -2610,7 +2725,9 @@ export function assertMacCaptureComplete(sessionId: string, expectedChunkCount:
2610
2725
 
2611
2726
  // Manual Mac pilot: independent from iPhone candidate and cue rollout gates.
2612
2727
  transcribeStreamRouter.get('/mac-recording/capabilities', (_req, res) => {
2613
- res.set('Cache-Control', 'private, no-store').json({ available: true, ...MAC_AUDIO, serverInstanceId: getServerInstanceId() })
2728
+ // 6.83.2: `discard` advertises POST /api/meeting/sessions/:id/discard for a stranded no-sound capture. Older
2729
+ // servers omit it, and COS Control then discards only its own local copy and says so.
2730
+ res.set('Cache-Control', 'private, no-store').json({ available: true, ...MAC_AUDIO, serverInstanceId: getServerInstanceId(), discard: true })
2614
2731
  })
2615
2732
  function requireMacPin(req: { get(name: string): string | undefined }): string {
2616
2733
  const pin = req.get('X-COS-Server-Instance')?.trim()