@gotcos/glasses-server 6.83.0 → 6.85.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.
@@ -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,10 @@ 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'
55
+ import { customInstructionsHash, readCustomInstructions } from '../lib/dictation-polish-instructions.js'
53
56
  import { createBreaker } from '../lib/claude-circuit.js'
54
57
  import { logTokenAudit } from '../lib/token-audit.js'
55
58
  import { emitDisplay } from '../lib/display-bus.js'
@@ -90,7 +93,7 @@ const autoCleanBreakers = {
90
93
  const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
91
94
 
92
95
  /** 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'
96
+ export type PolishSkipped = 'disabled' | 'too_long' | 'breaker_open' | 'daily_cap' | 'failed' | 'clean' | 'link_guard'
94
97
  /** 6.79.2, additive when finalize was given a prewarmToken: what became of that warm process. */
95
98
  export type PrewarmOutcome = 'used' | 'missed' | 'discarded'
96
99
 
@@ -107,7 +110,7 @@ function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
107
110
  async function cleanOutboundDictation(
108
111
  text: string,
109
112
  opts: AutoCleanRequest & { bucket: PolishBucket; signal?: AbortSignal; prewarmToken?: string },
110
- ): Promise<{ text: string; polishSkipped?: PolishSkipped; prewarm?: PrewarmOutcome }> {
113
+ ): Promise<PolishResult> {
111
114
  const result = await polishOutboundDictation(text, opts)
112
115
  // A warm process the model call did not take must not sit until its TTL.
113
116
  if (opts.prewarmToken && !result.prewarm) {
@@ -116,10 +119,23 @@ async function cleanOutboundDictation(
116
119
  return result
117
120
  }
118
121
 
122
+ /** 6.85.0, additive and present only when the model was given the user's polish instructions. */
123
+ interface PolishInstructionsUse { customInstructions?: true; instructionsHash?: string }
124
+ type PolishResult = { text: string; polishSkipped?: PolishSkipped; prewarm?: PrewarmOutcome } & PolishInstructionsUse
125
+
126
+ /**
127
+ * 6.85.0: the user's polish instructions apply to dictation (Mac dictation and G2 messages, which
128
+ * are surface "message", and prompt drafts). Meeting lines are a faithful record, so they stay on
129
+ * the built-in rules alone, the same way they skip the filler pass and spoken paths.
130
+ */
131
+ export function polishInstructionsFor(bucket: PolishBucket): string {
132
+ return bucket === 'meeting' ? '' : readCustomInstructions().customInstructions
133
+ }
134
+
119
135
  async function polishOutboundDictation(
120
136
  text: string,
121
137
  opts: AutoCleanRequest & { bucket: PolishBucket; signal?: AbortSignal; prewarmToken?: string },
122
- ): Promise<{ text: string; polishSkipped?: PolishSkipped; prewarm?: PrewarmOutcome }> {
138
+ ): Promise<PolishResult> {
123
139
  let cleaned = applyNegativeRules(applyCorrections(text)).replace(/\s+/g, ' ').trim() || text
124
140
  // applyCorrections above is an EXACT string map, so it only fixes misspellings someone
125
141
  // already hand-authored. A novel miss ("Rileyy" for Riley, "Exampel" for Example) sails
@@ -144,6 +160,9 @@ async function polishOutboundDictation(
144
160
  }
145
161
  // 6.79.1: um/uh never survive a skipped polish. Meeting lines stay faithful.
146
162
  if (opts.bucket !== 'meeting' && fillerPassEnabled()) cleaned = stripDisfluencies(cleaned)
163
+ // 6.83.1: "forward slash pricing," is "/pricing," even when the model is skipped. One word
164
+ // at a phrase end only; slugs of several words are the model's (dictation-format.ts).
165
+ if (opts.bucket !== 'meeting' && spokenPathsEnabled()) cleaned = renderSpokenPaths(cleaned)
147
166
  if (!(opts.enabled ?? autoCleanDefaultEnabled())) return { text: cleaned, polishSkipped: 'disabled' }
148
167
  const model = opts.model ?? 'haiku'
149
168
  const autoCleanBreaker = autoCleanBreakers[model]
@@ -154,6 +173,10 @@ async function polishOutboundDictation(
154
173
  if (polishBudgetExhausted(opts.bucket)) return { text: cleaned, polishSkipped: 'daily_cap' }
155
174
  const startedAt = Date.now()
156
175
  recordPolishCall(opts.bucket)
176
+ // Read once per polish: the warm and cold paths see the same instructions.
177
+ const customInstructions = polishInstructionsFor(opts.bucket)
178
+ const instructionsHash = customInstructionsHash(customInstructions)
179
+ const used: PolishInstructionsUse = customInstructions ? { customInstructions: true, instructionsHash } : {}
157
180
  // 6.79.2: a warm process started at key press answers without the CLI's start-up cost.
158
181
  const warmEntry = opts.prewarmToken ? takePolishPrewarm(opts.prewarmToken, model) : null
159
182
  let prewarm: PrewarmOutcome | undefined = opts.prewarmToken ? 'missed' : undefined
@@ -161,24 +184,33 @@ async function polishOutboundDictation(
161
184
  let polished: string | null = null
162
185
  if (warmEntry) {
163
186
  try {
164
- polished = (await runWarmPolish(warmEntry, cleaned, getVocabulary(), { signal: opts.signal })).trim()
187
+ polished = (await runWarmPolish(warmEntry, cleaned, getVocabulary(), { signal: opts.signal, customInstructions })).trim()
165
188
  prewarm = 'used'
166
189
  } catch (err) {
167
190
  // Nothing was sent: the cold path below is the same call, not a second one.
168
191
  if (!(err instanceof PrewarmUnavailableError)) throw err
169
192
  }
170
193
  }
171
- if (polished === null) polished = (await autoCleanDictation(cleaned, getVocabulary(), { model, signal: opts.signal })).trim()
194
+ if (polished === null) polished = (await autoCleanDictation(cleaned, getVocabulary(), { model, signal: opts.signal, customInstructions })).trim()
172
195
  autoCleanBreaker.recordSuccess()
173
196
  logTokenAudit({
174
197
  source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: polished.length,
175
198
  durationMs: Date.now() - startedAt, caller: `dictation_autoclean_${opts.bucket}`,
199
+ polishInstructions: { on: Boolean(customInstructions), hash: instructionsHash },
176
200
  })
177
- return { text: polished || cleaned, ...(prewarm ? { prewarm } : {}) }
201
+ // 6.83.1: the model never invents a link and never drops one the dictionary wrote. Its
202
+ // answer is refused (the pre-polish text goes out, polishSkipped "link_guard") rather than
203
+ // pasting a URL nobody said. The call still counted: it ran.
204
+ const linkProblem = polished ? polishLinkProblem(cleaned, polished, correctionOnlyForms(getWhisperCorrections()), getVocabulary()) : null
205
+ if (linkProblem) {
206
+ console.warn(`[prompt-draft] auto-clean answer refused: ${linkProblem}`)
207
+ return { text: cleaned, ...(prewarm ? { prewarm } : {}), polishSkipped: 'link_guard', ...used }
208
+ }
209
+ return { text: polished || cleaned, ...(prewarm ? { prewarm } : {}), ...used }
178
210
  } catch (err: any) {
179
211
  autoCleanBreaker.recordFailure()
180
212
  console.warn(`[prompt-draft] auto-clean failed (glossary-only): ${err?.message ?? err}`)
181
- return { text: cleaned, polishSkipped: 'failed', ...(prewarm ? { prewarm } : {}) }
213
+ return { text: cleaned, polishSkipped: 'failed', ...(prewarm ? { prewarm } : {}), ...used }
182
214
  }
183
215
  }
184
216
 
@@ -206,7 +238,7 @@ promptDraftsRouter.post('/dictation/finalize', async (req, res) => {
206
238
 
207
239
  const abort = new AbortController()
208
240
  res.on('close', () => { if (!res.writableEnded) abort.abort() })
209
- const { text: finalText, polishSkipped, prewarm } = await cleanOutboundDictation(text, {
241
+ const { text: finalText, polishSkipped, prewarm, customInstructions, instructionsHash } = await cleanOutboundDictation(text, {
210
242
  enabled: true,
211
243
  model,
212
244
  bucket: surface,
@@ -218,6 +250,8 @@ promptDraftsRouter.post('/dictation/finalize', async (req, res) => {
218
250
  res.json({
219
251
  text: finalText, surface, polished: finalText !== text,
220
252
  ...(polishSkipped ? { polishSkipped } : {}), ...(prewarm ? { prewarm } : {}),
253
+ // 6.85.0: only when the model was given the user's polish instructions (a hash, never the text).
254
+ ...(customInstructions ? { customInstructions, instructionsHash } : {}),
221
255
  })
222
256
  })
223
257
 
@@ -505,7 +539,7 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
505
539
  await markPromptDraftError(draftId, 'No speech detected')
506
540
  throw new NoSpeechDetectedError()
507
541
  }
508
- const { text: finalText, polishSkipped } = await cleanOutboundDictation(text, { ...autoClean, bucket: 'prompt_draft', signal })
542
+ const { text: finalText, polishSkipped, customInstructions, instructionsHash } = await cleanOutboundDictation(text, { ...autoClean, bucket: 'prompt_draft', signal })
509
543
  const finalized = await markPromptDraftFinalized(draftId, finalText)
510
544
  const qualities = transcriptRecords.map(record => record.actualQuality)
511
545
  const actualQuality: 'hq' | 'fast' | 'cloud' = qualities.includes('cloud')
@@ -535,6 +569,8 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
535
569
  ...(engineReason ? { engineReason } : {}),
536
570
  // 6.79.1, additive.
537
571
  ...(polishSkipped ? { polishSkipped } : {}),
572
+ // 6.85.0, additive.
573
+ ...(customInstructions ? { customInstructions, instructionsHash } : {}),
538
574
  }
539
575
  }
540
576
 
@@ -1,5 +1,6 @@
1
1
  import { Router, type Request, type Response } from 'express'
2
2
  import { durableQueryJobsEnabled, messagesSteerEnabled } from '../lib/query-job-feature.js'
3
+ import { ACTIVE_QUERY_JOBS_LIST_MAX, ACTIVE_QUERY_JOBS_PROTOCOL } from '../lib/query-job-active.js'
3
4
  import {
4
5
  QueryJobCoordinator,
5
6
  QueryJobCoordinatorError,
@@ -197,6 +198,27 @@ export function createQueryJobsRouter(
197
198
  }
198
199
  })
199
200
 
201
+ // 6.84.1: the in-flight jobs for COS Control's Running panel (docs/active-query-jobs.md).
202
+ // Read-only and in memory; registered ahead of `/query-jobs/:jobId`, which would answer
203
+ // 400 invalid_job_id for "active". A title is the prompt's first line, cut to 80
204
+ // characters; no prompt body, answer, activity or trail is ever in this answer.
205
+ router.get('/query-jobs/active', (_req, res) => {
206
+ try {
207
+ const now = Date.now()
208
+ const { items, count } = coordinator.activeJobs(now)
209
+ return res.json({
210
+ protocolVersion: ACTIVE_QUERY_JOBS_PROTOCOL,
211
+ enabled: enabled(),
212
+ now: new Date(now).toISOString(),
213
+ count,
214
+ items: items.slice(0, ACTIVE_QUERY_JOBS_LIST_MAX),
215
+ })
216
+ } catch (error) {
217
+ const wire = wireError(error)
218
+ return res.status(wire.status).json(wire.body)
219
+ }
220
+ })
221
+
200
222
  // Lost-202 recovery is keyed by the immutable client identity. Keep this
201
223
  // route explicit and ahead of the dynamic job lookup for future router
202
224
  // changes that may broaden the latter's matcher.
@@ -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 {
@@ -2493,7 +2608,9 @@ async function processStreamChunk(opts: {
2493
2608
  // The ASR await above lets a late chunk land after Stop or save; a closed session never feeds cues.
2494
2609
  if (!isSessionDeleted(sessionId)) void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
2495
2610
 
2496
- console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} gate=${quiet.gate}${quiet.speechSec === null ? '' : ` speech=${quiet.speechSec.toFixed(2)}s`} fe=${stt.frontEnd} | ${asrProvider} | "${trimmedText.slice(0, 50)}"`)
2611
+ // 6.85.0: timing and the character count only. This line runs for every chunk, so the meeting
2612
+ // text itself never goes to the server log (transcribe-stream-log-privacy.test.ts).
2613
+ console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} gate=${quiet.gate}${quiet.speechSec === null ? '' : ` speech=${quiet.speechSec.toFixed(2)}s`} fe=${stt.frontEnd} | ${asrProvider} | chars=${trimmedText.length}`)
2497
2614
  return canonicalChunkResponse(chunk, sessionId, chunkIndex)
2498
2615
  }
2499
2616
 
@@ -2610,7 +2727,9 @@ export function assertMacCaptureComplete(sessionId: string, expectedChunkCount:
2610
2727
 
2611
2728
  // Manual Mac pilot: independent from iPhone candidate and cue rollout gates.
2612
2729
  transcribeStreamRouter.get('/mac-recording/capabilities', (_req, res) => {
2613
- res.set('Cache-Control', 'private, no-store').json({ available: true, ...MAC_AUDIO, serverInstanceId: getServerInstanceId() })
2730
+ // 6.83.2: `discard` advertises POST /api/meeting/sessions/:id/discard for a stranded no-sound capture. Older
2731
+ // servers omit it, and COS Control then discards only its own local copy and says so.
2732
+ res.set('Cache-Control', 'private, no-store').json({ available: true, ...MAC_AUDIO, serverInstanceId: getServerInstanceId(), discard: true })
2614
2733
  })
2615
2734
  function requireMacPin(req: { get(name: string): string | undefined }): string {
2616
2735
  const pin = req.get('X-COS-Server-Instance')?.trim()
@@ -3,7 +3,7 @@
3
3
  // only proxies Open-Meteo / reverse-geocode — it is not "server location".
4
4
 
5
5
  import { Router } from 'express'
6
- import { callPython } from '../lib/python-bridge.js'
6
+ import { callPython, pythonBridgeAvailable } from '../lib/python-bridge.js'
7
7
 
8
8
  export const welcomeContextRouter = Router()
9
9
 
@@ -24,6 +24,8 @@ let lastKnownCity = ''
24
24
 
25
25
  let cachedNextEvent: NextEventPayload | null = null
26
26
  let calendarFetchedAt = 0
27
+ /** 6.84.1: whether the read that filled `cachedNextEvent` really reached the calendar. */
28
+ let cachedCalendarRead = false
27
29
 
28
30
  // 6.63.1: after a server restart the first forecast lookup can fail and, with nothing cached, the
29
31
  // glasses Home got no weather until the app was relaunched. The last good weather and
@@ -282,9 +284,21 @@ function isGenericNoiseEvent(title: string): boolean {
282
284
  return /\booo\b|out of office|vacation|\bpto\b/i.test(t)
283
285
  }
284
286
 
285
- async function fetchNextEvent(): Promise<NextEventPayload | null> {
287
+ /**
288
+ * 6.84.1: did the bridge answer with a calendar? False for no bridge (COS_SCRIPTS_DIR unset, or
289
+ * set without the venv and bridge script: callPython then answers an empty no-op), for a
290
+ * missing answer, and for the bridge's own error answer (`calendar_unavailable` on its
291
+ * deadline, or not configured). An empty calendar is a real read.
292
+ */
293
+ export function calendarAnswerWasRead(bridgeReady: boolean, answer: unknown): boolean {
294
+ if (!bridgeReady) return false
295
+ if (!answer || typeof answer !== 'object' || Array.isArray(answer)) return false
296
+ return !('error' in answer)
297
+ }
298
+
299
+ async function fetchNextEvent(): Promise<{ event: NextEventPayload | null; read: boolean }> {
286
300
  if (cachedNextEvent && Date.now() - calendarFetchedAt < CALENDAR_TTL_MS) {
287
- return cachedNextEvent
301
+ return { event: cachedNextEvent, read: cachedCalendarRead }
288
302
  }
289
303
 
290
304
  try {
@@ -299,6 +313,7 @@ async function fetchNextEvent(): Promise<NextEventPayload | null> {
299
313
  minutes_until_next?: number
300
314
  } | null
301
315
 
316
+ const read = calendarAnswerWasRead(pythonBridgeAvailable(), cal)
302
317
  const remaining = cal?.today_remaining ?? []
303
318
  const meaningful = remaining.find((evt) => {
304
319
  if (evt.is_all_day) return false
@@ -327,9 +342,11 @@ async function fetchNextEvent(): Promise<NextEventPayload | null> {
327
342
  }
328
343
 
329
344
  calendarFetchedAt = Date.now()
330
- return cachedNextEvent
345
+ cachedCalendarRead = read
346
+ return { event: cachedNextEvent, read }
331
347
  } catch {
332
- return cachedNextEvent
348
+ // Threw or timed out: whatever is cached was not read just now.
349
+ return { event: cachedNextEvent, read: false }
333
350
  }
334
351
  }
335
352
 
@@ -342,6 +359,7 @@ export function _resetWelcomeContextCachesForTests(): void {
342
359
  lastKnownCity = ''
343
360
  cachedNextEvent = null
344
361
  calendarFetchedAt = 0
362
+ cachedCalendarRead = false
345
363
  weatherPersistence = null
346
364
  weatherRestored = false
347
365
  lastFailureLogAt.clear()
@@ -354,13 +372,18 @@ welcomeContextRouter.get('/welcome-context', async (req, res) => {
354
372
  const pairLat = lat != null && lon != null ? lat : undefined
355
373
  const pairLon = lat != null && lon != null ? lon : undefined
356
374
 
357
- const [weather, nextEvent] = await Promise.all([
375
+ const [weather, calendar] = await Promise.all([
358
376
  fetchWeather(pairLat, pairLon),
359
377
  fetchNextEvent(),
360
378
  ])
379
+ const nextEvent = calendar.event
361
380
 
362
381
  res.json({
363
382
  ...(weather ? { weather } : {}),
364
383
  ...(nextEvent ? { nextEvent } : {}),
384
+ // 6.84.1: true only when this answer's calendar came from a successful read (an empty
385
+ // calendar included); false when the read threw, timed out or there is no COS pipeline.
386
+ // Lets a client tell "no more events today" from "could not read the calendar".
387
+ calendarRead: calendar.read,
365
388
  })
366
389
  })