@gotcos/glasses-server 6.76.1 → 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.
@@ -182,6 +182,12 @@ import {
182
182
  } from '../lib/meeting-finalization-jobs.js'
183
183
  import { enrichStandaloneMeeting } from '../lib/meeting-summary-persistence.js'
184
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>
185
191
 
186
192
  /** 6.61.0: the save-time Nemotron pass. Injectable so tests never spawn the CLI. */
187
193
  export type FinalDiarize = (options: Pick<FinalPassOptions, 'meetingPath' | 'sidecarPath' | 'audioDir'>) => Promise<FinalPassOutcome>
@@ -243,6 +249,8 @@ export interface MeetingRouteDependencies {
243
249
  finalDiarize?: FinalDiarize
244
250
  /** Control's stop requests for live G2 meetings (lib/meeting-stop-requests.ts). */
245
251
  stopRequests?: StopRequestStore
252
+ /** 6.77.0: the post-save transcript cleanup (lib/transcript-cleanup.ts). */
253
+ transcriptCleanup?: TranscriptCleanupRunner
246
254
  }
247
255
 
248
256
  const activeFinalizationJobs = new Set<string>()
@@ -254,6 +262,21 @@ interface FinalizationRuntime {
254
262
  scheduleBackground: (task: Promise<void>) => void
255
263
  sessions?: MeetingSessionSource
256
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
+ }
257
280
  }
258
281
 
259
282
  function validReplayEntries(raw: unknown[]): IndexedTranscriptChunk[] | null {
@@ -372,6 +395,11 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
372
395
  markCanonicalFinalizationState(current.sidecarPath, 'ops_pending', current.claimPending)
373
396
  }
374
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
+
375
403
  if (cosOpsPipelineConfigured() && !MAC_CAPTURE_ID.test(current.sessionId)) {
376
404
  await handoffMeetingToOperations(current.meetingPath)
377
405
  } else {
@@ -558,6 +586,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
558
586
  const emit = deps.emit ?? emitDisplay
559
587
  const finalizationJobs = deps.finalizationJobs ?? new MeetingFinalizationJobStore()
560
588
  const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
589
+ const transcriptCleanup = deps.transcriptCleanup
561
590
  const router = Router()
562
591
  const savingSessions = new Set<string>()
563
592
  const stopRequests = deps.stopRequests ?? createStopRequestStore()
@@ -780,6 +809,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
780
809
  finalizationJobs.reconcileCanonicalSidecars()
781
810
  const pendingJob = finalizationJobs.get(sessionId)
782
811
  if (pendingJob) scheduleFinalizationJob(pendingJob, {
812
+ transcriptCleanup,
783
813
  finalizationJobs,
784
814
  runBatch,
785
815
  scheduleBackground,
@@ -956,6 +986,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
956
986
  scheduleBackground,
957
987
  sessions,
958
988
  finalDiarize,
989
+ transcriptCleanup,
959
990
  })
960
991
  }
961
992
  } catch (error) {
@@ -2438,6 +2469,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2438
2469
  },
2439
2470
  })
2440
2471
  } catch { /* display is best-effort */ }
2472
+ await runCleanupStage(transcriptCleanup, { sessionId, meetingPath: saved.filepath, sidecarPath: saved.sidecarPath }, sessions)
2441
2473
  if (cosOpsPipelineConfigured() && !macCapture) {
2442
2474
  await handoffMeetingToOperations(saved.filepath)
2443
2475
  } else {
@@ -2620,3 +2652,36 @@ async function finalizeBatch(options: {
2620
2652
  }
2621
2653
 
2622
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
+
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ // Live cues v2 6.76.2 mutation gate: the runtime switch, the notes fallback and the decision provider. Mutations run only
3
+ // in a disposable copy of the checkout; the live checkout is never rewritten.
4
+ //
5
+ // What it proves, in order:
6
+ // 0. The target suites are GREEN unmutated. A gate over a red suite reads every mutant as killed and proves nothing.
7
+ // 1. Each target string appears EXACTLY ONCE in its file, so the mutation is unambiguous.
8
+ // 2. The mutated file differs from the original, so the edit landed.
9
+ // 3. A NAMED test fails ("FAIL server/x.test.ts > name"); a build error or a harness failure is not a kill.
10
+ // 4. The file is restored byte for byte (compared) before the next mutant.
11
+ // Also the Live COS Ask's memory bridge fallback (searchAskHistory) and the helpers it shares with cues (cos-bridge-notes.ts).
12
+ // Usage: node server/scripts/live-cues-v2-notes-mutation-gate.mjs [--list] [name…]
13
+ import { mkdtempSync, cpSync, readFileSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'
14
+ import { tmpdir } from 'node:os'
15
+ import { resolve, join, dirname, basename } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { spawnSync } from 'node:child_process'
18
+
19
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
20
+ const CUES = ['server/lib/live-cues-v2.test.ts', 'server/lib/live-cues-v2-notes.test.ts', 'server/lib/live-cues-v2-notes-bridge.test.ts',
21
+ 'server/lib/live-cues-v2-settings.test.ts', 'server/routes/live-cues-v2-settings-route.test.ts', 'server/routes/live-cues-v2-route.test.ts']
22
+ const HEALTH = [...CUES, 'server/routes/health.test.ts']
23
+ const ASK = ['server/lib/live-cos-ask-memory-fallback.test.ts', 'server/lib/live-cos-ask-queen-runtime.test.ts', 'server/lib/meeting-ask-cos-search.test.ts',
24
+ 'server/lib/live-cos-ask.test.ts', 'server/lib/meeting-ask-owner.test.ts']
25
+ const BOTH = [...CUES, ...ASK]
26
+ const ALL = [...HEALTH, ...ASK]
27
+ const ENGINE = 'server/lib/live-cues-v2.ts', NOTES = 'server/lib/live-cues-v2-notes.ts', SETTINGS = 'server/lib/live-cues-v2-settings.ts'
28
+ const ROUTE = 'server/routes/live-cues.ts', DECISION = 'server/lib/decision-provider.ts'
29
+ const SHARED = 'server/lib/cos-bridge-notes.ts', ASKLIB = 'server/lib/live-cos-ask.ts'
30
+ const m = (name, file, edits, tests = CUES) => ({ name, file, edits: typeof edits[0] === 'string' ? [edits] : edits, tests })
31
+
32
+ const mutations = [
33
+ // ── Runtime switch ──
34
+ m('env-forces-only-exact-1', SETTINGS, ["return env.COS_LIVE_CUES_V2 === '1'", 'return !!env.COS_LIVE_CUES_V2']),
35
+ m('env-forced-wins-over-setting', SETTINGS, ["if (liveCuesV2EnvForced(env)) return { enabled: true, source: 'env' }", 'if (false) return { enabled: true, source: \'env\' }']),
36
+ m('saved-setting-read', SETTINGS, ["if (stored !== null) return { enabled: stored, source: 'setting' }", "if (stored === true) return { enabled: stored, source: 'setting' }"]),
37
+ m('malformed-setting-is-none', SETTINGS, ["typeof (raw as { enabled?: unknown }).enabled === 'boolean'", '(raw as { enabled?: unknown }).enabled !== undefined']),
38
+ m('cache-follows-write', SETTINGS, [' this.cache = { value: enabled }\n', '']),
39
+ m('failed-write-keeps-memory', SETTINGS, [
40
+ [' mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })\n', ' this.cache = { value: enabled }\n mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })\n'],
41
+ ["{ mode: 0o600 })\n this.cache = { value: enabled }\n", '{ mode: 0o600 })\n']]),
42
+ m('settings-file-private', SETTINGS, ["+ '\\n', { mode: 0o600 })", "+ '\\n', { mode: 0o644 })"]),
43
+ m('engine-reads-setting', ENGINE, ['private enabled(): boolean { return liveCuesV2Enabled(this.deps.env, this.deps.settings) }', 'private enabled(): boolean { return liveCuesV2Enabled(this.deps.env, null) }']),
44
+ m('feed-stops-while-off', ENGINE, [' if (!this.enabled()) return\n if (this.deps.store.isEnded(sessionId)) return', ' if (this.deps.store.isEnded(sessionId)) return']),
45
+ m('last-check-before-emit', ENGINE, ['|| this.deps.store.isPaused(s.sessionId) || !this.enabled()) {', '|| this.deps.store.isPaused(s.sessionId)) {']),
46
+ m('suspend-aborts-in-flight', ENGINE, ['if (s.inFlight) { s.inFlight.abort(V2_OFF); aborted += 1 }', 'if (s.inFlight) { aborted += 1 }']),
47
+ m('cancel-reason-traced', ENGINE, [" if (trace.outcome === 'cancelled' && abort.signal.reason === V2_OFF) trace.cancelReason = V2_OFF\n", '']),
48
+ m('env-forced-off-refused', ENGINE, ["if (!enabled && liveCuesV2EnvForced(env)) return { ok: false, error: 'env_forced' }", "if (false) return { ok: false, error: 'env_forced' }"]),
49
+ m('turning-off-suspends', ENGINE, ['const aborted = engine?.suspend() ?? 0', 'const aborted = 0']),
50
+ m('suspend-only-on-transition', ENGINE, ['if (wasOn && !liveCuesV2Enabled(env)) {', 'if (!liveCuesV2Enabled(env)) {']),
51
+ m('saved-meeting-ends-known-session', ENGINE, ['(liveCuesV2Enabled() || engine?.knows(sid))', 'liveCuesV2Enabled()']),
52
+ m('knows-armed-sessions', ENGINE, ['knows(sessionId: string): boolean { return this.sessions.has(sessionId) }', 'knows(sessionId: string): boolean { return false }']),
53
+ m('route-v2-owns-known-meeting', ROUTE, ['return liveCuesV2Enabled() || (peekLiveCuesV2()?.knows(sessionId) ?? false)', 'return liveCuesV2Enabled()']),
54
+ m('route-boolean-enabled-only', ROUTE, ["typeof body.enabled !== 'boolean'", 'body.enabled === undefined']),
55
+ m('route-409-body', ROUTE, ['if (!result.ok) return res.status(409).json({ error: result.error })', 'if (!result.ok) return res.status(200).json(liveCuesV2SettingsView())']),
56
+ m('advertised-follows-setting', ENGINE, ['return liveCuesV2Enabled(env) ? liveCuesV2Capability() : undefined', "return env.COS_LIVE_CUES_V2 === '1' ? liveCuesV2Capability() : undefined"], HEALTH),
57
+ // ── Notes fallback (engine) ──
58
+ m('fallback-runs-on-failure', ENGINE, ['if (cueMemoryFailed(mem) && !abort.signal.aborted) {', 'if (false) {']),
59
+ m('cue-memory-preferred', ENGINE, ['if (cueMemoryFailed(mem) && !abort.signal.aborted) {', 'if (!abort.signal.aborted) {']),
60
+ m('failure-codes-only', ENGINE, ['return !!mem.denseError && CUE_MEMORY_FAILURES.has(mem.denseError)', 'return !!mem.denseError']),
61
+ m('timeout-is-a-failure', ENGINE, ["'no_cue_memory_script', 'memory_timeout', 'spawn_failed'", "'no_cue_memory_script', 'spawn_failed'"]),
62
+ m('notes-candidates-to-judge', ENGINE, [' mem = { notes: found.notes }\n', ' mem = { notes: [] }\n']),
63
+ m('notes-get-session-and-bound', ENGINE, ['this.deps.notes(window, before, abort.signal, s.sessionId)', "this.deps.notes(window, before, abort.signal, '')"]),
64
+ m('source-none-when-empty', ENGINE, ["cueMemorySource: mem.notes.length ? source : 'none'", 'cueMemorySource: source']),
65
+ m('capability-memory-source', ENGINE, [": notesAvailable() ? 'notes' : 'none'", ": 'none'"]),
66
+ // ── Notes fallback (search) ──
67
+ m('notes-date-bound', NOTES, ['!!c && !(c.date && c.date >= before)', '!!c']),
68
+ m('notes-local-day', SHARED, ["return Number.isFinite(ms) ? new Date(ms).toLocaleDateString('en-CA') : ''", "return Number.isFinite(ms) ? v.slice(0, 10) : ''"], BOTH),
69
+ m('notes-drop-live-meeting', NOTES, ['hits.filter(h => !(opts.sessionId && h.sessionId === opts.sessionId))', 'hits']),
70
+ m('notes-budget-race', NOTES, ['new Promise<boolean>(r => { timer = setTimeout(() => r(true), budgetMs) })', 'new Promise<boolean>(() => {})']),
71
+ m('notes-bridge-timeout', NOTES, ['searchers.bridge(local.signal, budgetMs)', 'searchers.bridge(local.signal, 30_000)']),
72
+ m('notes-abort-at-budget', NOTES, [' clearTimeout(timer)\n local.abort()\n', ' clearTimeout(timer)\n']),
73
+ m('notes-aborted-gets-nothing', NOTES, [' if (signal.aborted) return { notes: [] }\n', '']),
74
+ m('notes-no-terms-no-search', NOTES, [" if (!terms.length) return { notes: [], error: 'notes_no_terms' }\n", '']),
75
+ m('notes-queries-short', NOTES, ['terms.slice(0, 4).join', 'terms.join']),
76
+ m('notes-stopwords', NOTES, ['if (term.length < 3 || STOP.has(term) ||', 'if (term.length < 3 ||']),
77
+ m('shared-rank-fusion', SHARED, ['score: (prev?.score ?? 0) + 1 / (RRF_K + rank)', 'score: 1 / (RRF_K + rank)'], BOTH),
78
+ m('shared-fusion-keeps-order-on-ties', SHARED, ['b.score - a.score || a.order - b.order', 'b.score - a.score || b.order - a.order'], BOTH),
79
+ m('notes-top-four', NOTES, ['fused.ranked(top)', 'fused.ranked()']),
80
+ m('shared-bridge-scored', SHARED, [' .filter(x => x.score > 0).sort(', ' .sort('], BOTH),
81
+ m('shared-bridge-needs-content', SHARED, ['if (!id || !content.trim()) continue', 'if (!id) continue'], BOTH),
82
+ m('shared-bridge-lists-memory', SHARED, ["callPython(['memory', '--days'", "callPython(['memory-overview', '--days'"], BOTH),
83
+ m('notes-bridge-searched', NOTES, [' for (const q of queries) add(rankBridgeMemories(rows, q).map(bridgeCandidate))\n', '']),
84
+ m('notes-real-memories-keyword-only', NOTES, ['(await searchMemories({ query, limit: 10, semantic: false, signal })).hits', '[]']),
85
+ m('notes-real-meetings-keyword-only', NOTES, ['(await searchMeetingLibrary({ query, limit: 10, semantic: false, signal })).hits', '[]']),
86
+ // ── Decision provider ──
87
+ m('cue-override-read', ENGINE, [' if (raw !== undefined) {\n', ' if (false) {\n']),
88
+ m('cue-override-validated', ENGINE, [" if (v !== 'jev' && v !== 'subq') throw new DecisionProviderError('decision_invalid_provider')\n", '']),
89
+ m('only-key-subq', ENGINE, [" if (!keys.jev() && keys.subq()) return { provider: 'subq', source: 'only_key' }\n", '']),
90
+ m('only-key-needs-no-jev', ENGINE, ['if (!keys.jev() && keys.subq()) return', 'if (keys.subq()) return']),
91
+ m('global-env-ignored', ENGINE, [' const raw = env.COS_DECISION_PROVIDER_CUES\n', " const raw = env.COS_DECISION_PROVIDER_CUES ?? env.COS_DECISION_PROVIDER\n"]),
92
+ m('global-setting-ignored', ENGINE, [
93
+ ["import type { DecisionProvider } from './decision-settings.js'", "import { readDecisionSettings, type DecisionProvider } from './decision-settings.js'"],
94
+ [' const raw = env.COS_DECISION_PROVIDER_CUES\n', ' const raw = env.COS_DECISION_PROVIDER_CUES ?? readDecisionSettings().provider\n']]),
95
+ m('judge-uses-evaluation-provider', ENGINE, ['abort.signal, provider)\n Object.assign(trace, { judgeMs', 'abort.signal, this.deps.decisionProvider())\n Object.assign(trace, { judgeMs']),
96
+ m('gate-uses-provider', ENGINE, ['await this.deps.gate(s.title, window, abort.signal, provider)', "await this.deps.gate(s.title, window, abort.signal, 'jev')"]),
97
+ m('real-deps-route-subq', ENGINE, ["cueDecisionCalls(provider === 'subq' ? subq : jev)", 'cueDecisionCalls(jev)']),
98
+ m('capability-key-check', ENGINE, [" if (!st.configured) return off(provider === 'jev' && this.deps.decisionState('subq').configured ? 'jev_not_configured' : 'decision_not_configured')\n", '']),
99
+ m('jev-alias-only-with-subq-key', ENGINE, ["provider === 'jev' && this.deps.decisionState('subq').configured ? 'jev_not_configured'", "provider === 'jev' ? 'jev_not_configured'"]),
100
+ m('decision-not-configured-reason', ENGINE, ["this.deps.decisionState('subq').configured ? 'jev_not_configured' : 'decision_not_configured'", "this.deps.decisionState('subq').configured ? 'jev_not_configured' : 'jev_not_configured'"]),
101
+ m('capability-selected-provider-state', ENGINE, [' const st = this.deps.decisionState(provider)\n', " const st = this.deps.decisionState('jev')\n"]),
102
+ m('subq-cap-reason', ENGINE, ["off(provider === 'jev' ? 'jev_cap_reached' : 'decision_cap_reached')", "off('jev_cap_reached')"]),
103
+ m('subq-breaker-reason', ENGINE, ["off(provider === 'jev' ? 'jev_breaker_open' : 'decision_breaker_open')", "off('jev_breaker_open')"]),
104
+ m('capability-names-provider', ENGINE, ['...(provider ? { decisionProvider: provider } : {})', '...({})']),
105
+ m('quiet-decision-stops', ENGINE, ["'decision_cap_reached', 'decision_breaker_open', 'decision_not_configured', 'decision_invalid_provider'])", "'decision_breaker_open', 'decision_not_configured', 'decision_invalid_provider'])"]),
106
+ m('quiet-sees-decision-errors', ENGINE, ['if ((error instanceof JevError || error instanceof DecisionProviderError) && QUIET_DECISION_STOPS.has(error.code)) {', 'if (error instanceof JevError && QUIET_DECISION_STOPS.has(error.code)) {']),
107
+ m('decision-error-code-traced', ENGINE, ["trace.error = error instanceof JevError || error instanceof DecisionProviderError ? error.code :", "trace.error = error instanceof JevError ? error.code :"]),
108
+ m('subq-cue-cap-env', ENGINE, ['Number(process.env.COS_LIVE_CUES_SUBQ_DAILY_TOKENS)', 'Number(process.env.COS_SUBQ_DAILY_TOKENS)']),
109
+ m('subq-cue-ledger', ENGINE, ["dataPath('subq-cues-usage.json')", "dataPath('subq-usage.json')"]),
110
+ m('subq-configured-honest', ENGINE, ['return { configured: st.configured, breakerOpen: st.breakerOpenUntil !== null', 'return { configured: true, breakerOpen: st.breakerOpenUntil !== null']),
111
+ m('subq-status-unreadable-is-spent', DECISION, [' let usedToday = dailyCap\n', ' let usedToday = 0\n']),
112
+ m('subq-tracked-for-new-key', DECISION, ['{ configuredSubqClients.add(client); return client }', '{ return client }']),
113
+ // ── Live COS Ask: memory bridge fallback ──
114
+ m('ask-listing-only-when-memory-did-not-answer', ASKLIB, [' const memoryDidNotAnswer = memoryRaw.length === 0 ||', ' const memoryDidNotAnswer = true ||'], ASK),
115
+ m('ask-listing-when-memory-empty', ASKLIB, [' const memoryDidNotAnswer = memoryRaw.length === 0 ||', ' const memoryDidNotAnswer = false ||'], ASK),
116
+ m('ask-listing-when-meaning-failed', ASKLIB, ["|| (memory.status === 'fulfilled' && semantic && !memory.value.semanticAvailable)\n", '\n'], ASK),
117
+ m('ask-listing-meaning-only', ASKLIB, ["(memory.status === 'fulfilled' && semantic && !memory.value.semanticAvailable)", "(memory.status === 'fulfilled' && !memory.value.semanticAvailable)"], ASK),
118
+ m('ask-listing-threshold', ASKLIB, ['x.score >= ASK_HIT_MIN && (!domain', 'x.score > 0 && (!domain'], ASK),
119
+ m('ask-listing-domain', ASKLIB, ['x.score >= ASK_HIT_MIN && (!domain || x.row.domain === domain)', 'x.score >= ASK_HIT_MIN'], ASK),
120
+ m('ask-memory-cap-three', ASKLIB, ['const memorySources = fused.ranked(3)', 'const memorySources = fused.ranked()'], ASK),
121
+ m('ask-listing-notice', ASKLIB, [" if (memorySources.some(s => s.listed)) notices.push('COS also searched the notes in its memory list by keyword.')\n", ''], ASK),
122
+ m('ask-abort-skips-listing', ASKLIB, ['if (memoryDidNotAnswer && !signal?.aborted) {', 'if (memoryDidNotAnswer) {'], ASK),
123
+ m('ask-listing-failure-tolerated', ASKLIB, ['try { listed = await bridge(signal) } catch { /* no listing: the notices above stand */ }', 'listed = await bridge(signal)'], ASK),
124
+ m('ask-source-id-first', ASKLIB, ["({ id: `H${sources.length + 1}`, ...s })", "({ ...s, id: `H${sources.length + 1}` })"], ASK),
125
+ m('ask-listing-cites-content', ASKLIB, ['text: redactSecrets(row.content).slice(0, 1500), memoryId: row.id', "text: '', memoryId: row.id"], ASK),
126
+ m('ask-default-listing-is-the-bridge', ASKLIB, ['bridge: AskBridgeList = s => listBridgeMemories(s)', 'bridge: AskBridgeList = async () => []'], ASK),
127
+ ]
128
+
129
+ const args = process.argv.slice(2)
130
+ if (args.includes('--list')) { for (const { name } of mutations) console.log(name); process.exit(0) }
131
+ const selected = args.length ? mutations.filter(({ name }) => args.includes(name)) : mutations
132
+ if (args.length && selected.length !== args.length) throw new Error('Unknown mutation name in: ' + args.join(' '))
133
+ if (new Set(mutations.map(x => x.name)).size !== mutations.length) throw new Error('Duplicate mutation name')
134
+
135
+ const scratch = mkdtempSync(join(tmpdir(), 'cues-notes-mutations-'))
136
+ try {
137
+ const skip = new Set(['data', 'models', 'certs', 'node_modules'])
138
+ cpSync(join(root, 'server'), join(scratch, 'server'), { recursive: true, filter: src => !(skip.has(basename(src)) && dirname(src) === join(root, 'server')) })
139
+ for (const dir of ['shared', 'work-task-runtime', 'bin', 'docs', 'memory-runtime']) cpSync(join(root, dir), join(scratch, dir), { recursive: true })
140
+ for (const file of ['vitest.config.ts', 'package.json', 'managed-runtime-contract.json']) cpSync(join(root, file), join(scratch, file))
141
+ symlinkSync(join(root, 'node_modules'), join(scratch, 'node_modules'), 'dir')
142
+ const run = tests => spawnSync(process.execPath, [join(root, 'node_modules/vitest/vitest.mjs'), 'run', '--maxWorkers=1', ...tests], {
143
+ cwd: scratch, env: { PATH: process.env.PATH, HOME: scratch, TMPDIR: process.env.TMPDIR, COS_DATA_DIR: join(scratch, 'data'), COS_PROFILE_PATH: join(scratch, 'data', 'profile.json') },
144
+ encoding: 'utf8', timeout: 300000,
145
+ })
146
+ const baseline = run(ALL)
147
+ if (baseline.status !== 0) throw new Error('Baseline failed; a gate over a red suite proves nothing:\n' + baseline.stdout + '\n' + baseline.stderr)
148
+ const totals = /Tests\s+(\d+) passed/.exec(baseline.stdout)
149
+ console.log(`baseline PASS (${totals ? totals[1] : '?'} tests in ${ALL.length} files)`)
150
+ let killed = 0
151
+ for (const { name, file, edits, tests } of selected) {
152
+ const target = join(scratch, file), original = readFileSync(target, 'utf8')
153
+ let mutated = original
154
+ for (const [find, replacement] of edits) {
155
+ if (mutated.split(find).length !== 2) throw new Error(`Mutation ${name} does not match exactly once in ${file}: ${find.slice(0, 80)}`)
156
+ mutated = mutated.replace(find, () => replacement)
157
+ }
158
+ if (mutated === original) throw new Error(`Mutation ${name} did not change ${file}`)
159
+ writeFileSync(target, mutated)
160
+ let result
161
+ try { result = run(tests) } finally { writeFileSync(target, original) }
162
+ if (readFileSync(target, 'utf8') !== original) throw new Error(`Restore failed for ${file} after ${name}`)
163
+ const output = result.stdout + '\n' + result.stderr
164
+ const killers = [...new Set([...output.matchAll(/FAIL\s+(server\/\S+\.test\.ts > [^\n]+)/g)].map(x => x[1].trim()))]
165
+ if (result.status === 0 || result.error || !killers.length) { console.log(`${name} SURVIVED or harness failed\n${output.slice(-3000)}`); process.exitCode = 1; continue }
166
+ killed += 1
167
+ console.log(`${name} KILLED by ${killers.slice(0, 2).join(' | ')}${killers.length > 2 ? ` (+${killers.length - 2} more)` : ''}`)
168
+ }
169
+ console.log(`${killed} of ${selected.length} live cues and Ask 6.76.2 mutations killed`)
170
+ } finally { rmSync(scratch, { recursive: true, force: true }) }