@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.
- package/CHANGELOG.md +40 -0
- package/managed-runtime-contract.json +8 -0
- package/package.json +1 -1
- package/server/index.ts +6 -1
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +41 -7
- package/server/lib/live-cues-v2-notes.ts +152 -0
- package/server/lib/live-cues-v2-settings.ts +63 -0
- package/server/lib/live-cues-v2.ts +198 -38
- package/server/lib/transcript-cleanup-context.ts +234 -0
- package/server/lib/transcript-cleanup-ledger.ts +304 -0
- package/server/lib/transcript-cleanup-model.ts +217 -0
- package/server/lib/transcript-cleanup-screen.ts +249 -0
- package/server/lib/transcript-cleanup-spans.ts +358 -0
- package/server/lib/transcript-cleanup-state.ts +201 -0
- package/server/lib/transcript-cleanup-suggestions.ts +126 -0
- package/server/lib/transcript-cleanup-text.ts +200 -0
- package/server/lib/transcript-cleanup.ts +427 -0
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +3 -0
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/meeting.ts +65 -0
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- package/server/scripts/live-cues-v2-notes-mutation-gate.mjs +170 -0
- package/server/scripts/transcript-cleanup-mutation-gate.mjs +219 -0
- package/shared/live-cues-v2.ts +9 -4
package/server/routes/meeting.ts
CHANGED
|
@@ -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 }) }
|