@gotcos/glasses-server 6.75.0 → 6.77.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/managed-runtime-contract.json +8 -0
  3. package/package.json +1 -1
  4. package/server/index.ts +6 -1
  5. package/server/lib/cos-bridge-notes.ts +73 -0
  6. package/server/lib/decision-provider.ts +9 -0
  7. package/server/lib/display-bus.ts +2 -0
  8. package/server/lib/fuzzy-correct.ts +77 -49
  9. package/server/lib/live-cos-ask.ts +118 -21
  10. package/server/lib/live-cues-v2-notes.ts +152 -0
  11. package/server/lib/live-cues-v2-settings.ts +63 -0
  12. package/server/lib/live-cues-v2.ts +198 -38
  13. package/server/lib/meeting-ask-owner.ts +144 -0
  14. package/server/lib/meeting-ask-runtime.ts +28 -6
  15. package/server/lib/meeting-stop-requests.ts +123 -0
  16. package/server/lib/transcript-cleanup-context.ts +234 -0
  17. package/server/lib/transcript-cleanup-ledger.ts +304 -0
  18. package/server/lib/transcript-cleanup-model.ts +217 -0
  19. package/server/lib/transcript-cleanup-screen.ts +249 -0
  20. package/server/lib/transcript-cleanup-spans.ts +358 -0
  21. package/server/lib/transcript-cleanup-state.ts +201 -0
  22. package/server/lib/transcript-cleanup-suggestions.ts +126 -0
  23. package/server/lib/transcript-cleanup-text.ts +200 -0
  24. package/server/lib/transcript-cleanup.ts +427 -0
  25. package/server/routes/glossary.ts +38 -19
  26. package/server/routes/health.ts +3 -0
  27. package/server/routes/live-cues.ts +32 -3
  28. package/server/routes/meeting-ask.ts +11 -1
  29. package/server/routes/meeting.ts +177 -0
  30. package/server/routes/transcribe.ts +3 -0
  31. package/server/routes/transcript-cleanup.ts +237 -0
  32. package/server/scripts/live-cues-v2-notes-mutation-gate.mjs +170 -0
  33. package/server/scripts/transcript-cleanup-mutation-gate.mjs +219 -0
  34. package/shared/live-cues-v2.ts +9 -4
  35. package/shared/meeting-ask.ts +23 -1
@@ -10,6 +10,7 @@ import { 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'
13
+ import { createStopRequestStore, isStopRequestRefusal, STOP_REQUEST_CLAIM_WINDOW_MS, type StopRequest, type StopRequestStore } from '../lib/meeting-stop-requests.js'
13
14
  import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
14
15
  import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
15
16
  import { appendCorrection, appliedCorrections, pendingCorrections } from '../lib/meeting-corrections.js'
@@ -181,6 +182,12 @@ import {
181
182
  } from '../lib/meeting-finalization-jobs.js'
182
183
  import { enrichStandaloneMeeting } from '../lib/meeting-summary-persistence.js'
183
184
  import { recordFinalPassSkip, runNemotronFinalPass, type FinalPassOptions, type FinalPassOutcome } from '../lib/nemotron-final.js'
185
+ import { runTranscriptCleanup, type CleanupOutcome } from '../lib/transcript-cleanup.js'
186
+ import { sweepTranscriptCleanupData } from '../lib/transcript-cleanup-screen.js'
187
+ import type { TranscriptCleanupRouteDeps } from './transcript-cleanup.js'
188
+
189
+ /** 6.77.0: the post-save transcript cleanup. Injectable so tests never reach a model. */
190
+ export type TranscriptCleanupRunner = (input: { sessionId: string; meetingPath: string; sidecarPath: string }) => Promise<CleanupOutcome>
184
191
 
185
192
  /** 6.61.0: the save-time Nemotron pass. Injectable so tests never spawn the CLI. */
186
193
  export type FinalDiarize = (options: Pick<FinalPassOptions, 'meetingPath' | 'sidecarPath' | 'audioDir'>) => Promise<FinalPassOutcome>
@@ -240,6 +247,10 @@ export interface MeetingRouteDependencies {
240
247
  emit?: typeof emitDisplay
241
248
  finalizationJobs?: MeetingFinalizationJobStore
242
249
  finalDiarize?: FinalDiarize
250
+ /** Control's stop requests for live G2 meetings (lib/meeting-stop-requests.ts). */
251
+ stopRequests?: StopRequestStore
252
+ /** 6.77.0: the post-save transcript cleanup (lib/transcript-cleanup.ts). */
253
+ transcriptCleanup?: TranscriptCleanupRunner
243
254
  }
244
255
 
245
256
  const activeFinalizationJobs = new Set<string>()
@@ -251,6 +262,21 @@ interface FinalizationRuntime {
251
262
  scheduleBackground: (task: Promise<void>) => void
252
263
  sessions?: MeetingSessionSource
253
264
  finalDiarize?: FinalDiarize
265
+ transcriptCleanup?: TranscriptCleanupRunner
266
+ }
267
+
268
+ /**
269
+ * The cleanup as finalization runs it: a meeting whose session is somehow live again is
270
+ * skipped, and nothing it does can fail the finalization (it never throws; this catch is
271
+ * the last line of defence should that ever change).
272
+ */
273
+ async function runCleanupStage(runner: TranscriptCleanupRunner | undefined, input: { sessionId: string; meetingPath: string; sidecarPath: string }, sessions?: MeetingSessionSource): Promise<void> {
274
+ const run = runner ?? (cleanupInput => runTranscriptCleanup(cleanupInput, { sessionLive: id => (sessions?.getState ?? defaultSessionState)(id) === 'active' }))
275
+ try {
276
+ await run(input)
277
+ } catch (error) {
278
+ console.warn(`[meeting/save] transcript cleanup threw; the saved transcript is unchanged: ${error instanceof Error ? error.name : 'error'}`)
279
+ }
254
280
  }
255
281
 
256
282
  function validReplayEntries(raw: unknown[]): IndexedTranscriptChunk[] | null {
@@ -369,6 +395,11 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
369
395
  markCanonicalFinalizationState(current.sidecarPath, 'ops_pending', current.claimPending)
370
396
  }
371
397
 
398
+ // 6.77.0: the transcript is final here (HQ text and the Nemotron relabel are written),
399
+ // and nothing downstream has read it yet: COS operations imports, and the standalone
400
+ // summary describes, the cleaned text. Off unless Miles turns it on.
401
+ await runCleanupStage(runtime.transcriptCleanup, { sessionId: current.sessionId, meetingPath: current.meetingPath, sidecarPath: current.sidecarPath }, runtime.sessions)
402
+
372
403
  if (cosOpsPipelineConfigured() && !MAC_CAPTURE_ID.test(current.sessionId)) {
373
404
  await handoffMeetingToOperations(current.meetingPath)
374
405
  } else {
@@ -555,8 +586,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
555
586
  const emit = deps.emit ?? emitDisplay
556
587
  const finalizationJobs = deps.finalizationJobs ?? new MeetingFinalizationJobStore()
557
588
  const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
589
+ const transcriptCleanup = deps.transcriptCleanup
558
590
  const router = Router()
559
591
  const savingSessions = new Set<string>()
592
+ const stopRequests = deps.stopRequests ?? createStopRequestStore()
560
593
  setNamingSessionLiveness(id => sessions.getStartTime(id) !== null || savingSessions.has(id) || activeFinalizationJobs.has(id))
561
594
 
562
595
  let recoveryPendingLogged = false
@@ -624,6 +657,114 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
624
657
  })
625
658
  })
626
659
 
660
+ // ── Stop a live G2 meeting from the Mac (COS Control) ────────────────────────────
661
+ // The Mac never saves a live G2 session itself (lib/meeting-stop-requests.ts says why).
662
+ // It asks the glasses: POST records the request and puts `meeting_stop_requested` on the
663
+ // display bus; the companion claims it within the claim window and runs its own Stop;
664
+ // Control polls GET until the meeting is saved, or says no glasses answered.
665
+ const stopRequestBody = (request: StopRequest, extra: Record<string, unknown> = {}) => ({
666
+ sessionId: request.sessionId,
667
+ requestId: request.requestId,
668
+ state: request.state,
669
+ requestedAt: request.requestedAt,
670
+ claimedAt: request.claimedAt,
671
+ refusal: request.refusal,
672
+ claimWindowMs: STOP_REQUEST_CLAIM_WINDOW_MS,
673
+ ...extra,
674
+ })
675
+ const stopRequestSessionId = (req: { params: Record<string, unknown> }, res: { status(code: number): { json(body: unknown): void } }): string | null => {
676
+ const sessionId = String(req.params.sessionId ?? '')
677
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
678
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
679
+ return null
680
+ }
681
+ if (MAC_CAPTURE_ID.test(sessionId)) {
682
+ // A Mac recording stops from Control itself; it has no glasses to ask.
683
+ res.status(400).json({ error: 'A Mac recording stops from Record on Mac', reason: 'mac_capture' })
684
+ return null
685
+ }
686
+ return sessionId
687
+ }
688
+
689
+ router.post('/meeting/sessions/:sessionId/stop-request', (req, res) => {
690
+ const sessionId = stopRequestSessionId(req, res)
691
+ if (!sessionId) return
692
+ res.set('Cache-Control', 'private, no-store')
693
+ const saved = store.findBySessionId(sessionId)
694
+ if (saved) {
695
+ res.json({ sessionId, state: 'saved', saveReceipt: publicSaveResponse(saved, true) })
696
+ return
697
+ }
698
+ if (savingSessions.has(sessionId)) {
699
+ res.json({ sessionId, state: 'finalizing' })
700
+ return
701
+ }
702
+ const live = (sessions.getState ?? defaultSessionState)(sessionId)
703
+ if (live !== 'active') {
704
+ res.status(409).json({ error: 'This meeting is not recording', reason: 'not_live', state: live })
705
+ return
706
+ }
707
+ const body = req.body as Record<string, unknown> | undefined
708
+ const source = typeof body?.source === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(body.source) ? body.source : 'control'
709
+ const request = stopRequests.request(sessionId, source)
710
+ try {
711
+ emit({
712
+ type: 'meeting_stop_requested',
713
+ data: { sessionId, requestId: request.requestId, source, claimWindowMs: STOP_REQUEST_CLAIM_WINDOW_MS },
714
+ })
715
+ } catch (error) {
716
+ console.warn('[meeting/stop-request] Display notification failed:', error)
717
+ }
718
+ console.log(`[meeting/stop-request] ${source} asked the glasses to stop ${sessionId} (${request.requestId})`)
719
+ res.status(202).json(stopRequestBody(request))
720
+ })
721
+
722
+ // The companion's answer: `accept` claims the request (then it stops), `refuse` says why not.
723
+ router.post('/meeting/sessions/:sessionId/stop-request/:requestId/claim', (req, res) => {
724
+ const sessionId = stopRequestSessionId(req, res)
725
+ if (!sessionId) return
726
+ res.set('Cache-Control', 'private, no-store')
727
+ const requestId = String(req.params.requestId ?? '')
728
+ const body = req.body as Record<string, unknown> | undefined
729
+ const decision = body?.decision
730
+ if (decision !== 'accept' && decision !== 'refuse') {
731
+ res.status(400).json({ error: 'decision must be accept or refuse', reason: 'invalid_decision' })
732
+ return
733
+ }
734
+ if (decision === 'refuse' && !isStopRequestRefusal(body?.reason)) {
735
+ res.status(400).json({ error: 'Unknown refusal reason', reason: 'invalid_refusal' })
736
+ return
737
+ }
738
+ const result = decision === 'accept'
739
+ ? stopRequests.claim(sessionId, requestId)
740
+ : stopRequests.refuse(sessionId, requestId, body!.reason as Parameters<StopRequestStore['refuse']>[2])
741
+ if (!result.ok) {
742
+ const status = result.reason === 'unknown_request' ? 404 : 409
743
+ res.status(status).json({ error: 'Stop request is not open', reason: result.reason })
744
+ return
745
+ }
746
+ console.log(`[meeting/stop-request] glasses ${decision === 'accept' ? 'claimed' : 'refused'} ${result.request.requestId}${result.request.refusal ? ` (${result.request.refusal})` : ''}`)
747
+ res.json(stopRequestBody(result.request))
748
+ })
749
+
750
+ // Control's poll. A saved meeting answers `saved` with its receipt, whatever the request says.
751
+ router.get('/meeting/sessions/:sessionId/stop-request/:requestId', (req, res) => {
752
+ const sessionId = stopRequestSessionId(req, res)
753
+ if (!sessionId) return
754
+ res.set('Cache-Control', 'private, no-store')
755
+ const request = stopRequests.get(sessionId, String(req.params.requestId ?? ''))
756
+ if (!request) {
757
+ res.status(404).json({ error: 'Unknown stop request', reason: 'unknown_request' })
758
+ return
759
+ }
760
+ const saved = store.findBySessionId(sessionId)
761
+ if (saved) {
762
+ res.json(stopRequestBody(request, { state: 'saved', saveReceipt: publicSaveResponse(saved, true) }))
763
+ return
764
+ }
765
+ res.json(stopRequestBody(request, savingSessions.has(sessionId) ? { finalizing: true } : {}))
766
+ })
767
+
627
768
  router.post('/meeting/save', async (req, res) => {
628
769
  let lockedSessionId: string | null = null
629
770
  let maintenanceLease: MaintenanceWorkLease | undefined
@@ -668,6 +809,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
668
809
  finalizationJobs.reconcileCanonicalSidecars()
669
810
  const pendingJob = finalizationJobs.get(sessionId)
670
811
  if (pendingJob) scheduleFinalizationJob(pendingJob, {
812
+ transcriptCleanup,
671
813
  finalizationJobs,
672
814
  runBatch,
673
815
  scheduleBackground,
@@ -844,6 +986,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
844
986
  scheduleBackground,
845
987
  sessions,
846
988
  finalDiarize,
989
+ transcriptCleanup,
847
990
  })
848
991
  }
849
992
  } catch (error) {
@@ -2326,6 +2469,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2326
2469
  },
2327
2470
  })
2328
2471
  } catch { /* display is best-effort */ }
2472
+ await runCleanupStage(transcriptCleanup, { sessionId, meetingPath: saved.filepath, sidecarPath: saved.sidecarPath }, sessions)
2329
2473
  if (cosOpsPipelineConfigured() && !macCapture) {
2330
2474
  await handoffMeetingToOperations(saved.filepath)
2331
2475
  } else {
@@ -2508,3 +2652,36 @@ async function finalizeBatch(options: {
2508
2652
  }
2509
2653
 
2510
2654
  export const meetingRouter = createMeetingRouter()
2655
+
2656
+ /** 6.77.0: what the transcript cleanup routes need from the meeting layer. */
2657
+ export function transcriptCleanupRouteDeps(store: MeetingStore = getMeetingStore()): TranscriptCleanupRouteDeps {
2658
+ return {
2659
+ sessionState: id => defaultSessionState(id),
2660
+ sessionStartedAt: id => getSessionStartTime(id),
2661
+ findMeeting: id => {
2662
+ const meeting = store.findBySessionId(id)
2663
+ return meeting ? { meetingPath: meeting.filepath, sidecarPath: meeting.sidecarPath } : null
2664
+ },
2665
+ }
2666
+ }
2667
+
2668
+ /**
2669
+ * Boot: delete cleanup data (screen context and ledgers) whose meeting is gone, and screen
2670
+ * text past its retention. Deferred and unref'd, so it never delays startup or a restart.
2671
+ */
2672
+ export function scheduleTranscriptCleanupSweep(delayMs = 90_000): void {
2673
+ const timer = setTimeout(() => {
2674
+ try {
2675
+ const store = getMeetingStore()
2676
+ const jobs = new MeetingFinalizationJobStore()
2677
+ const result = sweepTranscriptCleanupData({
2678
+ now: Date.now(),
2679
+ known: id => defaultSessionState(id) !== 'missing' || Boolean(jobs.get(id)) || store.findBySessionId(id) !== null,
2680
+ })
2681
+ if (result.screenDeleted || result.ledgersDeleted) {
2682
+ console.log(`[transcript-cleanup] swept ${result.screenDeleted} screen and ${result.ledgersDeleted} ledger files`)
2683
+ }
2684
+ } catch { /* the next boot sweeps again */ }
2685
+ }, delayMs)
2686
+ timer.unref()
2687
+ }
@@ -37,6 +37,9 @@ transcribeRouter.get('/transcribe/capabilities', (_req, res) => {
37
37
  localOnly: true,
38
38
  dictionary: 'cos',
39
39
  benchmark: 1,
40
+ // 6.77.0: post-save transcript cleanup. suggestions = /api/transcript-cleanup/suggestions,
41
+ // screenContext = POST /api/meeting/:sessionId/screen-context.
42
+ transcriptCleanup: { protocol: 1, suggestions: 1, screenContext: 1 },
40
43
  })
41
44
  })
42
45
 
@@ -0,0 +1,237 @@
1
+ // Transcript cleanup routes (6.77.0). Token-authenticated by the global /api X-Cos-Token
2
+ // middleware (index.ts) like every other /api route; nothing here is public.
3
+ //
4
+ // Settings (COS Control's toggle, later):
5
+ // GET /transcript-cleanup/settings { enabled, source, model, modelSource, limits, today }
6
+ // POST /transcript-cleanup/settings { enabled?, model? } same shape; 409 env_forced when env pins the value
7
+ // Screen context (COS Control supplies on-screen text during a meeting, later):
8
+ // POST /meeting/:sessionId/screen-context { lines: [{ at, text, app? }] } counts of what was stored
9
+ // GET /meeting/:sessionId/screen-context counts only, never the text
10
+ // DELETE /meeting/:sessionId/screen-context { deleted }
11
+ // The cleanup ledger of one meeting:
12
+ // GET /meeting/:sessionId/transcript-cleanup { runs, edits } (edits carry original and replacement words)
13
+ // POST /meeting/:sessionId/transcript-cleanup/undo { editId } | { runId }
14
+ // Dictionary suggestions (COS Control's Dictionary tab, later):
15
+ // GET /transcript-cleanup/suggestions?status=pending|all
16
+ // POST /transcript-cleanup/suggestions/:id/accept { correction? }
17
+ // POST /transcript-cleanup/suggestions/:id/dismiss
18
+
19
+ import { Router } from 'express'
20
+ import { appendScreenContext, deleteScreenContext, ScreenContextError, SESSION_ID, screenContextStats } from '../lib/transcript-cleanup-screen.js'
21
+ import { alreadyCleaned, ledgerView, readLedger, readTranscriptSection, sha256, undoCleanupEdits } from '../lib/transcript-cleanup-ledger.js'
22
+ import { cleanupLimits, resolveCleanupModel, resolveCleanupSwitch, transcriptCleanupBudget, transcriptCleanupSettings, type TranscriptCleanupBudget, type TranscriptCleanupSettings } from '../lib/transcript-cleanup-state.js'
23
+ import { SUGGESTION_ID, suggestionStore, type DictionarySuggestion, type SuggestionStore } from '../lib/transcript-cleanup-suggestions.js'
24
+ import { getVocabulary } from '../lib/profile.js'
25
+ import { readFileSync } from 'node:fs'
26
+
27
+ export const TRANSCRIPT_CLEANUP_PROTOCOL = 1
28
+
29
+ type GlossaryOutcome = { status: number; body: unknown }
30
+
31
+ export interface TranscriptCleanupRouteDeps {
32
+ env?: NodeJS.ProcessEnv
33
+ now?: () => Date
34
+ settings?: TranscriptCleanupSettings
35
+ budget?: TranscriptCleanupBudget
36
+ suggestions?: SuggestionStore
37
+ /** 'active' while the meeting is being recorded. */
38
+ sessionState?: (sessionId: string) => 'active' | 'closed' | 'missing'
39
+ sessionStartedAt?: (sessionId: string) => number | null
40
+ /** The saved meeting's files, or null. */
41
+ findMeeting?: (sessionId: string) => { meetingPath: string; sidecarPath: string } | null
42
+ addVocabulary?: (term: string) => Promise<GlossaryOutcome>
43
+ learnCorrection?: (body: { heard: string; written: string; source: 'meeting' }) => Promise<GlossaryOutcome>
44
+ vocabulary?: () => string[]
45
+ }
46
+
47
+ /** Health and capability shape. Codes only: /api/health is public. */
48
+ export function transcriptCleanupAdvertised(env: NodeJS.ProcessEnv = process.env, settings: TranscriptCleanupSettings = transcriptCleanupSettings()): { protocol: number; enabled: boolean } {
49
+ let enabled = false
50
+ try { enabled = resolveCleanupSwitch(env, settings).enabled } catch { enabled = false }
51
+ return { protocol: TRANSCRIPT_CLEANUP_PROTOCOL, enabled }
52
+ }
53
+
54
+ function suggestionView(s: DictionarySuggestion, vocabulary: string[]): DictionarySuggestion & { inVocabulary: boolean } {
55
+ const lower = s.written.trim().toLowerCase()
56
+ return { ...s, inVocabulary: vocabulary.some(v => v.trim().toLowerCase() === lower) }
57
+ }
58
+
59
+ export function createTranscriptCleanupRouter(deps: TranscriptCleanupRouteDeps = {}): Router {
60
+ const env = deps.env ?? process.env
61
+ const now = deps.now ?? (() => new Date())
62
+ const settings = deps.settings ?? transcriptCleanupSettings()
63
+ const budget = deps.budget ?? transcriptCleanupBudget()
64
+ const suggestions = deps.suggestions ?? suggestionStore()
65
+ const sessionState = deps.sessionState ?? (() => 'missing' as const)
66
+ const sessionStartedAt = deps.sessionStartedAt ?? (() => null)
67
+ const findMeeting = deps.findMeeting ?? (() => null)
68
+ const vocabulary = deps.vocabulary ?? (() => { try { return getVocabulary() } catch { return [] } })
69
+ const addVocabulary = deps.addVocabulary ?? (async term => (await import('./glossary.js')).glossaryAddVocabulary(term))
70
+ const learnCorrection = deps.learnCorrection ?? (async body => (await import('./glossary.js')).glossaryLearnCorrection(body))
71
+ const router = Router()
72
+
73
+ const settingsView = () => {
74
+ const sw = resolveCleanupSwitch(env, settings)
75
+ const model = resolveCleanupModel(env, settings)
76
+ const limits = cleanupLimits(env)
77
+ const state = budget.read()
78
+ return {
79
+ protocol: TRANSCRIPT_CLEANUP_PROTOCOL,
80
+ enabled: sw.enabled,
81
+ source: sw.source,
82
+ model: model.choice,
83
+ modelId: model.id,
84
+ modelSource: model.source,
85
+ limits: { dailyCalls: limits.dailyCalls, dailyTokens: limits.dailyTokens, maxSpansPerMeeting: limits.maxSpans },
86
+ today: {
87
+ calls: Math.min(state.calls, Number.MAX_SAFE_INTEGER),
88
+ tokens: Math.min(state.tokens, Number.MAX_SAFE_INTEGER),
89
+ breakerOpenUntil: state.breakerOpenUntil !== null && state.breakerOpenUntil > now().getTime() ? new Date(state.breakerOpenUntil).toISOString() : null,
90
+ },
91
+ }
92
+ }
93
+
94
+ router.get('/transcript-cleanup/settings', (_req, res) => {
95
+ res.set('Cache-Control', 'private, no-store')
96
+ try { return res.json(settingsView()) } catch { return res.status(503).json({ error: 'settings_unavailable' }) }
97
+ })
98
+
99
+ router.post('/transcript-cleanup/settings', (req, res) => {
100
+ res.set('Cache-Control', 'private, no-store')
101
+ const body = req.body as { enabled?: unknown; model?: unknown } | undefined
102
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return res.status(400).json({ error: 'invalid_request' })
103
+ const hasEnabled = body.enabled !== undefined, hasModel = body.model !== undefined
104
+ if ((!hasEnabled && !hasModel) || (hasEnabled && typeof body.enabled !== 'boolean') || (hasModel && body.model !== 'haiku' && body.model !== 'sonnet')) {
105
+ return res.status(400).json({ error: 'invalid_request' })
106
+ }
107
+ if (hasEnabled && (env.COS_TRANSCRIPT_CLEANUP === '1' || env.COS_TRANSCRIPT_CLEANUP === '0') && body.enabled !== (env.COS_TRANSCRIPT_CLEANUP === '1')) {
108
+ return res.status(409).json({ error: 'env_forced' })
109
+ }
110
+ if (hasModel && (env.COS_TRANSCRIPT_CLEANUP_MODEL === 'haiku' || env.COS_TRANSCRIPT_CLEANUP_MODEL === 'sonnet') && body.model !== env.COS_TRANSCRIPT_CLEANUP_MODEL) {
111
+ return res.status(409).json({ error: 'env_forced' })
112
+ }
113
+ try {
114
+ settings.save({ ...(hasEnabled ? { enabled: body.enabled as boolean } : {}), ...(hasModel ? { model: body.model as 'haiku' | 'sonnet' } : {}) }, now())
115
+ return res.json(settingsView())
116
+ } catch {
117
+ return res.status(500).json({ error: 'settings_write_failed' })
118
+ }
119
+ })
120
+
121
+ const sessionParam = (raw: string | undefined): string | null => (raw && SESSION_ID.test(raw) ? raw : null)
122
+
123
+ /** A late flush right after stop is welcome until the cleanup has run on the saved meeting. */
124
+ function screenAdmission(sessionId: string): { ok: true } | { ok: false; status: number; error: string } {
125
+ if (sessionState(sessionId) === 'active') return { ok: true }
126
+ const meeting = findMeeting(sessionId)
127
+ if (!meeting) return { ok: false, status: 404, error: 'unknown_session' }
128
+ const done = readLedger(sessionId).rows.some(r => r.kind === 'run' && (r.phase === 'done' || r.phase === 'skipped'))
129
+ return done ? { ok: false, status: 409, error: 'cleanup_done' } : { ok: true }
130
+ }
131
+
132
+ router.post('/meeting/:sessionId/screen-context', (req, res) => {
133
+ res.set('Cache-Control', 'private, no-store')
134
+ const sessionId = sessionParam(req.params.sessionId)
135
+ if (!sessionId) return res.status(400).json({ error: 'invalid_session_id' })
136
+ const admission = screenAdmission(sessionId)
137
+ if (!admission.ok) return res.status(admission.status).json({ error: admission.error })
138
+ try {
139
+ const result = appendScreenContext(sessionId, req.body, { now: now().getTime(), sessionStartedAt: sessionStartedAt(sessionId) })
140
+ return res.json({ ok: true, protocol: TRANSCRIPT_CLEANUP_PROTOCOL, ...result })
141
+ } catch (error) {
142
+ if (error instanceof ScreenContextError) return res.status(error.status).json({ error: error.code })
143
+ return res.status(503).json({ error: 'screen_context_unavailable' })
144
+ }
145
+ })
146
+
147
+ router.get('/meeting/:sessionId/screen-context', (req, res) => {
148
+ res.set('Cache-Control', 'private, no-store')
149
+ const sessionId = sessionParam(req.params.sessionId)
150
+ if (!sessionId) return res.status(400).json({ error: 'invalid_session_id' })
151
+ return res.json({ protocol: TRANSCRIPT_CLEANUP_PROTOCOL, ...screenContextStats(sessionId) })
152
+ })
153
+
154
+ router.delete('/meeting/:sessionId/screen-context', (req, res) => {
155
+ res.set('Cache-Control', 'private, no-store')
156
+ const sessionId = sessionParam(req.params.sessionId)
157
+ if (!sessionId) return res.status(400).json({ error: 'invalid_session_id' })
158
+ try { return res.json({ deleted: deleteScreenContext(sessionId) }) } catch { return res.status(500).json({ error: 'delete_failed' }) }
159
+ })
160
+
161
+ router.get('/meeting/:sessionId/transcript-cleanup', (req, res) => {
162
+ res.set('Cache-Control', 'private, no-store')
163
+ const sessionId = sessionParam(req.params.sessionId)
164
+ if (!sessionId) return res.status(400).json({ error: 'invalid_session_id' })
165
+ const view = ledgerView(sessionId)
166
+ let current: string | null = null
167
+ const meeting = findMeeting(sessionId)
168
+ if (meeting) {
169
+ try { const section = readTranscriptSection(readFileSync(meeting.meetingPath, 'utf8')); current = section === null ? null : sha256(section) } catch { current = null }
170
+ }
171
+ return res.json({ protocol: TRANSCRIPT_CLEANUP_PROTOCOL, ...view, cleanedCurrent: current !== null && alreadyCleaned(sessionId, current) })
172
+ })
173
+
174
+ router.post('/meeting/:sessionId/transcript-cleanup/undo', (req, res) => {
175
+ res.set('Cache-Control', 'private, no-store')
176
+ const sessionId = sessionParam(req.params.sessionId)
177
+ if (!sessionId) return res.status(400).json({ error: 'invalid_session_id' })
178
+ const body = (req.body ?? {}) as { editId?: unknown; runId?: unknown }
179
+ const editId = typeof body.editId === 'string' && /^te_[0-9a-f]{16}$/.test(body.editId) ? body.editId : undefined
180
+ const runId = typeof body.runId === 'string' && /^tc_[0-9a-f]{16}$/.test(body.runId) ? body.runId : undefined
181
+ if (!editId === !runId) return res.status(400).json({ error: 'editId_or_runId_required' })
182
+ const meeting = findMeeting(sessionId)
183
+ if (!meeting) return res.status(404).json({ error: 'meeting_missing' })
184
+ const result = undoCleanupEdits({ sessionId, meetingPath: meeting.meetingPath, sidecarPath: meeting.sidecarPath, editId, runId, now })
185
+ if (!result.ok) return res.status(result.status).json({ error: result.error })
186
+ return res.json({ reverted: result.reverted })
187
+ })
188
+
189
+ router.get('/transcript-cleanup/suggestions', (req, res) => {
190
+ res.set('Cache-Control', 'private, no-store')
191
+ const status = typeof req.query.status === 'string' ? req.query.status : 'pending'
192
+ if (status !== 'pending' && status !== 'all') return res.status(400).json({ error: 'invalid_status' })
193
+ const vocab = vocabulary()
194
+ const list = suggestions.list().filter(s => status === 'all' || s.status === 'pending')
195
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).map(s => suggestionView(s, vocab))
196
+ return res.json({ protocol: TRANSCRIPT_CLEANUP_PROTOCOL, suggestions: list })
197
+ })
198
+
199
+ router.post('/transcript-cleanup/suggestions/:id/accept', async (req, res) => {
200
+ res.set('Cache-Control', 'private, no-store')
201
+ const id = req.params.id
202
+ if (!SUGGESTION_ID.test(id)) return res.status(400).json({ error: 'invalid_suggestion_id' })
203
+ const body = (req.body ?? {}) as { correction?: unknown }
204
+ if (body.correction !== undefined && typeof body.correction !== 'boolean') return res.status(400).json({ error: 'invalid_request' })
205
+ const suggestion = suggestions.get(id)
206
+ if (!suggestion) return res.status(404).json({ error: 'suggestion_not_found' })
207
+ if (suggestion.status === 'dismissed') return res.status(409).json({ error: 'suggestion_dismissed' })
208
+ try {
209
+ const heard = suggestion.heard.find(h => h.trim().toLowerCase() !== suggestion.written.trim().toLowerCase())
210
+ const outcome = body.correction === true && heard
211
+ ? await learnCorrection({ heard, written: suggestion.written, source: 'meeting' })
212
+ : await addVocabulary(suggestion.written)
213
+ if (outcome.status !== 200) return res.status(outcome.status).json(outcome.body)
214
+ const accepted = suggestions.setStatus(id, 'accepted', now().toISOString()) ?? suggestion
215
+ const out = outcome.body as { unchanged?: boolean; glossary?: unknown; entry?: unknown }
216
+ return res.json({ suggestion: suggestionView(accepted, vocabulary()), unchanged: out.unchanged === true, glossary: out.glossary ?? null, entry: out.entry ?? null })
217
+ } catch {
218
+ return res.status(500).json({ error: 'accept_failed' })
219
+ }
220
+ })
221
+
222
+ router.post('/transcript-cleanup/suggestions/:id/dismiss', (req, res) => {
223
+ res.set('Cache-Control', 'private, no-store')
224
+ const id = req.params.id
225
+ if (!SUGGESTION_ID.test(id)) return res.status(400).json({ error: 'invalid_suggestion_id' })
226
+ try {
227
+ const dismissed = suggestions.setStatus(id, 'dismissed', now().toISOString())
228
+ if (!dismissed) return res.status(404).json({ error: 'suggestion_not_found' })
229
+ return res.json({ suggestion: suggestionView(dismissed, vocabulary()) })
230
+ } catch {
231
+ return res.status(500).json({ error: 'dismiss_failed' })
232
+ }
233
+ })
234
+
235
+ return router
236
+ }
237
+