@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.
- package/CHANGELOG.md +63 -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/display-bus.ts +2 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +118 -21
- 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/meeting-ask-owner.ts +144 -0
- package/server/lib/meeting-ask-runtime.ts +28 -6
- package/server/lib/meeting-stop-requests.ts +123 -0
- 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-ask.ts +11 -1
- package/server/routes/meeting.ts +177 -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/shared/meeting-ask.ts +23 -1
package/server/routes/meeting.ts
CHANGED
|
@@ -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
|
+
|