@gotcos/glasses-server 6.76.1 → 6.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.env.example +40 -0
  2. package/CHANGELOG.md +56 -0
  3. package/README.md +12 -1
  4. package/managed-runtime-contract.json +17 -1
  5. package/package.json +10 -1
  6. package/server/index.ts +98 -10
  7. package/server/lib/agent-session-binding-registry.ts +623 -9
  8. package/server/lib/boot-resume.ts +552 -0
  9. package/server/lib/claude-bridge.ts +152 -3
  10. package/server/lib/claude-cli-support.ts +39 -0
  11. package/server/lib/claude-steer.ts +259 -0
  12. package/server/lib/cos-bridge-notes.ts +73 -0
  13. package/server/lib/decision-provider.ts +9 -0
  14. package/server/lib/env-switch.ts +17 -0
  15. package/server/lib/fuzzy-correct.ts +77 -49
  16. package/server/lib/live-cos-ask.ts +41 -7
  17. package/server/lib/live-cues-v2-notes.ts +152 -0
  18. package/server/lib/live-cues-v2-settings.ts +63 -0
  19. package/server/lib/live-cues-v2.ts +198 -38
  20. package/server/lib/managed-restart.ts +8 -0
  21. package/server/lib/provider-actions.ts +18 -0
  22. package/server/lib/query-job-coordinator.ts +300 -0
  23. package/server/lib/query-job-feature.ts +39 -0
  24. package/server/lib/query-job-resume.ts +269 -0
  25. package/server/lib/query-job-runtime.ts +23 -3
  26. package/server/lib/query-job-store.ts +516 -7
  27. package/server/lib/query-job-types.ts +210 -1
  28. package/server/lib/queue-turn-evidence.ts +213 -0
  29. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  30. package/server/lib/thread-turn-queue-store.ts +13 -4
  31. package/server/lib/thread-turn-queue.ts +151 -1
  32. package/server/lib/transcript-cleanup-context.ts +234 -0
  33. package/server/lib/transcript-cleanup-ledger.ts +304 -0
  34. package/server/lib/transcript-cleanup-model.ts +217 -0
  35. package/server/lib/transcript-cleanup-screen.ts +249 -0
  36. package/server/lib/transcript-cleanup-spans.ts +358 -0
  37. package/server/lib/transcript-cleanup-state.ts +201 -0
  38. package/server/lib/transcript-cleanup-suggestions.ts +126 -0
  39. package/server/lib/transcript-cleanup-text.ts +200 -0
  40. package/server/lib/transcript-cleanup.ts +427 -0
  41. package/server/routes/agent-session-bindings.ts +169 -11
  42. package/server/routes/glossary.ts +38 -19
  43. package/server/routes/health.ts +29 -1
  44. package/server/routes/live-cues.ts +32 -3
  45. package/server/routes/maintenance.ts +34 -0
  46. package/server/routes/meeting.ts +92 -9
  47. package/server/routes/query-jobs.ts +107 -13
  48. package/server/routes/recovery.ts +4 -0
  49. package/server/routes/thread-turn-queue.ts +371 -10
  50. package/server/routes/transcribe.ts +3 -0
  51. package/server/routes/transcript-cleanup.ts +237 -0
  52. package/shared/live-cues-v2.ts +9 -4
@@ -0,0 +1,427 @@
1
+ // Transcript cleanup (6.77.0): a post-save pass that resolves the words a transcription
2
+ // engine was unsure about, using the meeting's text and screen context.
3
+ //
4
+ // Miles, 2026-10-17: "We need the ability to run a Sonnet or a Haiku to clean up some of
5
+ // those passages where we're unsure. It should have some screen awareness as well as some
6
+ // textual awareness so that the word hallucinations are resolved."
7
+ //
8
+ // WHERE IT RUNS. Once per saved meeting, from the durable finalization job (routes/
9
+ // meeting.ts), after the HQ batch transcript and the Nemotron relabel are written and
10
+ // BEFORE the meeting is handed to COS operations or summarised, so both see cleaned text.
11
+ // NEVER during live transcription: no transcription module imports this file (Miles's rule
12
+ // "Transcription MUST NEVER call claude -p"; pinned by a test), and a meeting whose session
13
+ // is still live is skipped.
14
+ //
15
+ // WHAT IT DOES.
16
+ // 1. Reads the saved `## Transcript` and the sidecar's batch words (confidence, time).
17
+ // 2. Builds the context pack (transcript-cleanup-context.ts).
18
+ // 3. Deterministic fixes: fuzzy-correct with the dictionary and this meeting's own screen
19
+ // and file terms (transcript-cleanup-spans.ts). Free.
20
+ // 4. Picks the doubtful spans with no model (transcript-cleanup-spans.ts).
21
+ // 5. One batched model call for the spans, unless a stop applies (transcript-cleanup-
22
+ // state.ts); every proposed edit is validated (transcript-cleanup-model.ts).
23
+ // 6. Applies fixes and accepted edits through the undoable ledger
24
+ // (transcript-cleanup-ledger.ts) and records dictionary suggestions for written forms
25
+ // the meeting's context supplied (transcript-cleanup-suggestions.ts).
26
+ // 7. One trace row: counts, tokens and milliseconds. No transcript text, no screen text,
27
+ // no names in any log line.
28
+
29
+ import { existsSync, mkdirSync, appendFileSync, readFileSync, renameSync, statSync } from 'node:fs'
30
+ import { dirname } from 'node:path'
31
+ import { LensGistLimitError, runLensGistEngine } from './lens-gist-engines.js'
32
+ import { resolveProviderBinary } from './provider-binary.js'
33
+ import { getVocabulary, getWhisperCorrections } from './profile.js'
34
+ import { ownerCardFromProfile, type OwnerCard } from './meeting-ask-owner.js'
35
+ import { alignWordInfo, fold, joinTranscriptLines, parseTranscriptLines, tokenize, wordInfoFromSidecar } from './transcript-cleanup-text.js'
36
+ import { addNotes, buildContextPack, evidenceSources, inDictionary, knownKeys, nameTargets, type CleanupFile, type CleanupNote, type ContextPack } from './transcript-cleanup-context.js'
37
+ import { deterministicFixes, selectSpans, type SpanReason } from './transcript-cleanup-spans.js'
38
+ import { buildCleanupPrompt, CLEANUP_SYSTEM, MODEL_LIMITS, parseCleanupReply, validateCleanupEdits, type RejectReason } from './transcript-cleanup-model.js'
39
+ import {
40
+ appendLedger, applyPlannedEdits, alreadyCleaned, commitCleanTranscript, CleanupWriteError, newEditId, newRunId,
41
+ readTranscriptSection, sha256, type AppliedEdit, type PlannedEdit,
42
+ } from './transcript-cleanup-ledger.js'
43
+ import { cleanupLimits, resolveCleanupModel, resolveCleanupSwitch, transcriptCleanupBudget, transcriptCleanupSettings, type TranscriptCleanupBudget, type TranscriptCleanupSettings } from './transcript-cleanup-state.js'
44
+ import { suggestionStore, type SuggestionStore } from './transcript-cleanup-suggestions.js'
45
+ import { cleanupDir, readScreenContext, type ScreenLine } from './transcript-cleanup-screen.js'
46
+
47
+ export interface ModelRun { text: string; inputTokens?: number; outputTokens?: number; model?: string }
48
+
49
+ export interface CleanupDeps {
50
+ env: NodeJS.ProcessEnv
51
+ now: () => Date
52
+ settings: TranscriptCleanupSettings
53
+ budget: TranscriptCleanupBudget
54
+ suggestions: SuggestionStore
55
+ sessionLive: (sessionId: string) => boolean
56
+ claudeAvailable: () => boolean
57
+ callModel: (modelId: string, system: string, prompt: string, opts: { timeoutMs: number; maxOutputTokens: number }) => Promise<ModelRun>
58
+ speakers: () => Promise<string[]> | string[]
59
+ vocabulary: () => string[]
60
+ corrections: () => Record<string, string>
61
+ owner: () => OwnerCard | null
62
+ files: (sessionId: string) => Promise<CleanupFile[]> | CleanupFile[]
63
+ screen: (sessionId: string) => ScreenLine[]
64
+ searchNotes: (queries: string[], sessionId: string, signal: AbortSignal) => Promise<CleanupNote[]>
65
+ trace: (row: CleanupTrace) => void
66
+ }
67
+
68
+ export interface CleanupTrace {
69
+ at: string
70
+ sessionId: string
71
+ runId?: string
72
+ status: CleanupOutcome['status']
73
+ reason?: string
74
+ modelSkipped?: string
75
+ model?: string
76
+ words: number
77
+ aligned: number
78
+ fixes: number
79
+ spansFound: number
80
+ spansSent: number
81
+ byReason?: Record<SpanReason, number>
82
+ accepted: number
83
+ kept: number
84
+ rejected: Partial<Record<RejectReason, number>>
85
+ modelError?: string
86
+ suggestions: number
87
+ screenLines: number
88
+ files: number
89
+ notes: number
90
+ promptChars: number
91
+ inputTokens: number
92
+ outputTokens: number
93
+ modelMs: number
94
+ ms: number
95
+ }
96
+
97
+ export interface CleanupOutcome {
98
+ status: 'skipped' | 'done' | 'failed'
99
+ reason?: string
100
+ /** Set when fuzzy fixes ran but the model was not asked (correction_skipped style). */
101
+ modelSkipped?: string
102
+ runId?: string
103
+ applied: number
104
+ }
105
+
106
+ export const NOTES_BUDGET_MS = 3_000
107
+ const inProgress = new Set<string>()
108
+
109
+ /** The local searches the notes lookup uses. Each one may fail or answer nothing; none leaves the Mac. */
110
+ export interface NotesSearchers {
111
+ memories(query: string, signal: AbortSignal): Promise<Array<{ id: string; title?: string; snippet?: string }>>
112
+ meetings(query: string, signal: AbortSignal): Promise<Array<{ recordId: string; sessionId?: string; title: string; snippet?: string }>>
113
+ /** The memory bridge listing (cos-bridge-notes.ts): on the public memory runtime it is where the owner's notes are. */
114
+ bridge(signal: AbortSignal): Promise<Array<{ id: string; content: string; summary?: string }>>
115
+ score(rows: Array<{ id: string; content: string; summary?: string }>, query: string): Array<{ row: { id: string; content: string; summary?: string } }>
116
+ }
117
+
118
+ /**
119
+ * Notes for the names the doubtful spans are near: memories and earlier meetings by keyword (never this meeting), and,
120
+ * when memory search finds nothing for a name, the memory bridge listing scored by keyword, as the Live COS Ask does.
121
+ */
122
+ export function createNotesSearch(searchers: NotesSearchers): CleanupDeps['searchNotes'] {
123
+ return async (queries, sessionId, signal) => {
124
+ const notes: CleanupNote[] = []
125
+ const seen = new Set<string>()
126
+ let listed: Array<{ id: string; content: string; summary?: string }> | null = null
127
+ const push = (note: CleanupNote) => { if (!seen.has(note.ref)) { seen.add(note.ref); notes.push(note) } }
128
+ for (const query of queries) {
129
+ if (signal.aborted) break
130
+ let memoryHits = 0
131
+ try {
132
+ for (const hit of (await searchers.memories(query, signal)).slice(0, 3)) {
133
+ if (!hit.id) continue
134
+ memoryHits++
135
+ push({ id: '', ref: `memory:${hit.id}`, kind: 'memory', title: hit.title || hit.id, snippet: hit.snippet || '', query })
136
+ }
137
+ } catch { /* a source that fails is skipped */ }
138
+ if (!memoryHits && !signal.aborted) {
139
+ try {
140
+ listed ??= await searchers.bridge(signal)
141
+ for (const { row } of searchers.score(listed, query).slice(0, 2)) {
142
+ push({ id: '', ref: `memory:${row.id}`, kind: 'memory', title: (row.summary || row.content.split('\n')[0] || row.id).slice(0, 120), snippet: row.content, query })
143
+ }
144
+ } catch { listed = [] }
145
+ }
146
+ if (signal.aborted) break
147
+ try {
148
+ for (const hit of (await searchers.meetings(query, signal)).slice(0, 3)) {
149
+ if (!hit.recordId || hit.sessionId === sessionId) continue
150
+ push({ id: '', ref: `meeting:${hit.recordId}`, kind: 'meeting', title: hit.title, snippet: hit.snippet || '', query })
151
+ }
152
+ } catch { /* skipped */ }
153
+ }
154
+ return notes
155
+ }
156
+ }
157
+
158
+ const notesSearchDefault: CleanupDeps['searchNotes'] = async (queries, sessionId, signal) => {
159
+ const [{ searchMemories }, { searchMeetingLibrary }, bridgeNotes] = await Promise.all([
160
+ import('./context-library-search.js'), import('./meeting-library-search.js'), import('./cos-bridge-notes.js'),
161
+ ])
162
+ return createNotesSearch({
163
+ memories: async (query, s) => (await searchMemories({ query, limit: 3, semantic: false, signal: s })).hits,
164
+ meetings: async (query, s) => (await searchMeetingLibrary({ query, limit: 3, semantic: false, signal: s })).hits,
165
+ bridge: s => bridgeNotes.listBridgeMemories(s, NOTES_BUDGET_MS),
166
+ score: (rows, query) => bridgeNotes.scoreBridgeMemories(rows, query),
167
+ })(queries, sessionId, signal)
168
+ }
169
+
170
+ const filesDefault: CleanupDeps['files'] = async sessionId => {
171
+ try {
172
+ const { MeetingAskStore } = await import('./meeting-ask-store.js')
173
+ return new MeetingAskStore().files(sessionId).filter(f => f.status === 'ready' && typeof f.text === 'string' && f.text.trim()).map(f => ({ name: f.name, text: f.text }))
174
+ } catch { return [] }
175
+ }
176
+
177
+ const speakersDefault: CleanupDeps['speakers'] = async () => {
178
+ try { return (await import('./speaker-embeddings.js')).getAllSpeakerNames() } catch { return [] }
179
+ }
180
+
181
+ /** One row per meeting, rotated once at 2 MB (about 4,000 meetings) so it can never grow without bound. */
182
+ export const TRACE_MAX_BYTES = 2 * 1024 * 1024
183
+ function traceDefault(row: CleanupTrace): void {
184
+ const file = cleanupDir('trace.jsonl')
185
+ try {
186
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
187
+ try { if (statSync(file).size > TRACE_MAX_BYTES) renameSync(file, `${file}.1`) } catch { /* no file yet */ }
188
+ appendFileSync(file, JSON.stringify(row) + '\n', { mode: 0o600 })
189
+ } catch { /* best effort */ }
190
+ }
191
+
192
+ export function defaultCleanupDeps(): CleanupDeps {
193
+ return {
194
+ env: process.env,
195
+ now: () => new Date(),
196
+ settings: transcriptCleanupSettings(),
197
+ budget: transcriptCleanupBudget(),
198
+ suggestions: suggestionStore(),
199
+ sessionLive: () => false,
200
+ claudeAvailable: () => resolveProviderBinary('claude').ok,
201
+ callModel: async (modelId, system, prompt, opts) => {
202
+ const run = await runLensGistEngine('claude', modelId, prompt, { system, timeoutMs: opts.timeoutMs, source: 'transcript-cleanup', maxOutputTokens: opts.maxOutputTokens })
203
+ return { text: run.text, inputTokens: run.usage?.inputTokens, outputTokens: run.usage?.outputTokens, model: run.model }
204
+ },
205
+ speakers: speakersDefault,
206
+ vocabulary: () => getVocabulary(),
207
+ corrections: () => getWhisperCorrections(),
208
+ owner: () => ownerCardFromProfile(),
209
+ files: filesDefault,
210
+ screen: sessionId => readScreenContext(sessionId),
211
+ searchNotes: notesSearchDefault,
212
+ trace: traceDefault,
213
+ }
214
+ }
215
+
216
+ function titleOf(markdown: string, sidecar: Record<string, unknown>): string {
217
+ if (typeof sidecar.title === 'string' && sidecar.title.trim()) return sidecar.title.trim()
218
+ return /^# (.+)$/m.exec(markdown)?.[1]?.trim() ?? ''
219
+ }
220
+
221
+ /** Run the cleanup for one saved meeting. Never throws: every failure is an outcome. */
222
+ export async function runTranscriptCleanup(
223
+ input: { sessionId: string; meetingPath: string; sidecarPath: string },
224
+ partial: Partial<CleanupDeps> = {},
225
+ ): Promise<CleanupOutcome> {
226
+ const deps: CleanupDeps = { ...defaultCleanupDeps(), ...partial }
227
+ const started = Date.now()
228
+ // Switch first, before ANY read or write: off means nothing happens at all.
229
+ if (!resolveCleanupSwitch(deps.env, deps.settings).enabled) return { status: 'skipped', reason: 'switch_off', applied: 0 }
230
+
231
+ const trace: CleanupTrace = {
232
+ at: deps.now().toISOString(), sessionId: input.sessionId, status: 'skipped', words: 0, aligned: 0, fixes: 0, spansFound: 0, spansSent: 0,
233
+ accepted: 0, kept: 0, rejected: {}, suggestions: 0, screenLines: 0, files: 0, notes: 0, promptChars: 0, inputTokens: 0, outputTokens: 0, modelMs: 0, ms: 0,
234
+ }
235
+ const finish = (outcome: CleanupOutcome): CleanupOutcome => {
236
+ trace.status = outcome.status
237
+ if (outcome.reason) trace.reason = outcome.reason
238
+ if (outcome.modelSkipped) trace.modelSkipped = outcome.modelSkipped
239
+ if (outcome.runId) trace.runId = outcome.runId
240
+ trace.ms = Date.now() - started
241
+ try { deps.trace(trace) } catch { /* trace is best effort */ }
242
+ console.log(`[transcript-cleanup] ${outcome.status}${outcome.reason ? ` (${outcome.reason})` : ''}${outcome.modelSkipped ? ` model skipped: ${outcome.modelSkipped}` : ''}: `
243
+ + `${trace.spansFound} spans found, ${trace.spansSent} sent, ${trace.accepted} accepted, ${Object.values(trace.rejected).reduce((a, b) => a + (b ?? 0), 0)} rejected, `
244
+ + `${trace.fixes} fixes, ${trace.inputTokens + trace.outputTokens} tokens, ${trace.ms} ms`)
245
+ return outcome
246
+ }
247
+
248
+ if (inProgress.has(input.sessionId)) return finish({ status: 'skipped', reason: 'busy', applied: 0 })
249
+ inProgress.add(input.sessionId)
250
+ try {
251
+ let live = false
252
+ try { live = deps.sessionLive(input.sessionId) } catch { live = true }
253
+ if (live) return finish({ status: 'skipped', reason: 'session_live', applied: 0 })
254
+ if (!existsSync(input.meetingPath) || !existsSync(input.sidecarPath)) return finish({ status: 'skipped', reason: 'meeting_missing', applied: 0 })
255
+ const markdown = readFileSync(input.meetingPath, 'utf8')
256
+ const sidecarText = readFileSync(input.sidecarPath, 'utf8')
257
+ let sidecar: Record<string, unknown>
258
+ try { sidecar = JSON.parse(sidecarText) as Record<string, unknown> } catch { return finish({ status: 'failed', reason: 'sidecar_unreadable', applied: 0 }) }
259
+ const section = readTranscriptSection(markdown)
260
+ if (!section || !section.trim()) return finish({ status: 'skipped', reason: 'no_transcript', applied: 0 })
261
+ const inputSha = sha256(section)
262
+ if (alreadyCleaned(input.sessionId, inputSha)) return finish({ status: 'skipped', reason: 'already_done', applied: 0 })
263
+
264
+ const runId = newRunId()
265
+ const at = () => deps.now().toISOString()
266
+ if (!appendLedger(input.sessionId, [{ kind: 'run', runId, phase: 'intent', at: at(), inputSha }])) {
267
+ // An unrecorded edit is exactly what the ledger exists to prevent.
268
+ return finish({ status: 'failed', reason: 'ledger_unwritable', applied: 0 })
269
+ }
270
+
271
+ const lines = parseTranscriptLines(section)
272
+ const tokens = tokenize(lines)
273
+ trace.words = tokens.length
274
+ trace.aligned = alignWordInfo(tokens, wordInfoFromSidecar(sidecar))
275
+ const startMs = typeof sidecar.startTime === 'number' ? sidecar.startTime : null
276
+ const screen = deps.screen(input.sessionId)
277
+ const files = await deps.files(input.sessionId)
278
+ const pack: ContextPack = buildContextPack({
279
+ title: titleOf(markdown, sidecar),
280
+ date: startMs !== null ? new Date(startMs).toLocaleDateString('en-CA') : '',
281
+ attendees: Array.isArray(sidecar.speakers) ? sidecar.speakers.filter((s): s is string => typeof s === 'string') : [],
282
+ speakers: await deps.speakers(),
283
+ owner: deps.owner(),
284
+ vocabulary: deps.vocabulary(),
285
+ corrections: deps.corrections(),
286
+ files,
287
+ screen,
288
+ })
289
+ trace.screenLines = pack.screen.length
290
+ trace.files = pack.files.length
291
+
292
+ // Free deterministic fixes first.
293
+ // The curated dictionary and the owner only: attendee names swapped a product for a surname on real
294
+ // meetings (Slack to Black), so people reach the model as context and near-name spans instead.
295
+ const strongTargets = [...pack.vocabulary, ...pack.corrections.map(c => c.written), ...(pack.owner ? [pack.owner.name] : [])]
296
+ const targets = nameTargets(pack)
297
+ const known = knownKeys(pack)
298
+ const fixes = deterministicFixes(tokens, strongTargets, targets, known)
299
+ trace.fixes = fixes.length
300
+ for (const fix of fixes) { const t = tokens[fix.token]; t.core = fix.replacement; t.key = fold(fix.replacement) }
301
+
302
+ const limits = cleanupLimits(deps.env)
303
+ const selection = selectSpans(lines, tokens, targets, known, { maxSpans: limits.maxSpans, fixed: new Set(fixes.map(f => f.token)) })
304
+ trace.spansFound = selection.found
305
+ trace.byReason = selection.byReason
306
+
307
+ const planned: PlannedEdit[] = fixes.map(f => ({ line: f.line, start: f.start, end: f.end, replacement: f.replacement, source: 'fuzzy' as const, reasons: [f.fromMeeting ? 'meeting_term' : 'dictionary'] }))
308
+ let modelSkipped: string | undefined
309
+ const model = resolveCleanupModel(deps.env, deps.settings)
310
+ if (!selection.spans.length) modelSkipped = 'no_spans'
311
+ else if (deps.env.ANTHROPIC_API_KEY && deps.env.COS_ALLOW_API_FALLBACK !== '1') modelSkipped = 'api_key_in_env'
312
+ else if (!deps.claudeAvailable()) modelSkipped = 'claude_not_found'
313
+ else modelSkipped = deps.budget.gate(limits) ?? undefined
314
+
315
+ if (!modelSkipped) {
316
+ // Local notes for the names the spans are near, bounded in time.
317
+ const queries = [...new Set(selection.spans.flatMap(s => s.near))].slice(0, 4)
318
+ if (queries.length) {
319
+ const abort = new AbortController()
320
+ const timer = setTimeout(() => abort.abort(), NOTES_BUDGET_MS)
321
+ try {
322
+ const found = await Promise.race([
323
+ deps.searchNotes(queries, input.sessionId, abort.signal),
324
+ new Promise<CleanupNote[]>(resolve => abort.signal.addEventListener('abort', () => resolve([]), { once: true })),
325
+ ])
326
+ addNotes(pack, found.map((n, i) => ({ ...n, id: `N${i + 1}` })))
327
+ } catch { /* notes are optional */ } finally { clearTimeout(timer); abort.abort() }
328
+ trace.notes = pack.notes.length
329
+ }
330
+ const built = buildCleanupPrompt(pack, selection.spans, startMs)
331
+ trace.spansSent = built.sent.length
332
+ trace.promptChars = built.chars
333
+ trace.model = model.id
334
+ if (!built.sent.length) modelSkipped = 'prompt_too_large'
335
+ else if (!deps.budget.charge(limits)) modelSkipped = 'daily_calls'
336
+ else {
337
+ const modelStarted = Date.now()
338
+ try {
339
+ const run = await deps.callModel(model.id, CLEANUP_SYSTEM, built.prompt, { timeoutMs: MODEL_LIMITS.timeoutMs, maxOutputTokens: MODEL_LIMITS.maxOutputTokens })
340
+ trace.modelMs = Date.now() - modelStarted
341
+ trace.inputTokens = run.inputTokens ?? Math.ceil(built.chars / 4)
342
+ trace.outputTokens = run.outputTokens ?? Math.ceil(run.text.length / 4)
343
+ deps.budget.recordUsage(trace.inputTokens + trace.outputTokens)
344
+ const raw = parseCleanupReply(run.text)
345
+ deps.budget.recordSuccess()
346
+ const checked = validateCleanupEdits(raw, selection.spans, built.sent, pack)
347
+ trace.accepted = checked.accepted.length
348
+ trace.kept = checked.kept
349
+ trace.rejected = checked.rejected
350
+ for (const { span, text } of checked.accepted) planned.push({ line: span.line, start: span.start, end: span.end, replacement: text, source: 'model', reasons: span.reasons })
351
+ } catch (error) {
352
+ trace.modelMs = trace.modelMs || Date.now() - modelStarted
353
+ const limit = error instanceof LensGistLimitError
354
+ deps.budget.recordFailure(limits, limit)
355
+ trace.modelError = limit ? 'provider_limit' : error instanceof Error && error.message === 'no_json_edits' ? 'bad_reply' : /timed out/.test(String((error as Error)?.message)) ? 'timeout' : 'call_failed'
356
+ modelSkipped = 'model_failed'
357
+ }
358
+ }
359
+ }
360
+
361
+ if (!planned.length) {
362
+ appendLedger(input.sessionId, [{ kind: 'run', runId, phase: 'done', at: at(), inputSha, outputSha: inputSha, model: trace.model, ...(modelSkipped ? { modelSkipped } : {}) }])
363
+ return finish({ status: 'done', runId, applied: 0, ...(modelSkipped ? { modelSkipped } : {}) })
364
+ }
365
+
366
+ const { lines: cleaned, applied } = applyPlannedEdits(lines, planned)
367
+ const transcript = joinTranscriptLines(cleaned)
368
+ const outputSha = sha256(transcript)
369
+ const stamp = at()
370
+ const editRows = applied.map(e => ({ kind: 'edit' as const, id: newEditId(), runId, at: stamp, line: e.line, offset: e.offset, original: e.original, replacement: e.replacement, before: e.before, after: e.after, lineSha: e.lineSha, source: e.source, ...(e.reasons ? { reasons: e.reasons } : {}) }))
371
+ if (!appendLedger(input.sessionId, editRows)) return finish({ status: 'failed', reason: 'ledger_unwritable', runId, applied: 0 })
372
+ try {
373
+ commitCleanTranscript({
374
+ meetingPath: input.meetingPath, sidecarPath: input.sidecarPath, markdown, sidecarText, transcript,
375
+ summary: { version: 1, runId, at: stamp, model: trace.model ?? null, fixes: applied.filter(e => e.source === 'fuzzy').length, edits: applied.filter(e => e.source === 'model').length, liveEdits: applied.length, ...(modelSkipped ? { modelSkipped } : {}) },
376
+ })
377
+ } catch (error) {
378
+ const code = error instanceof CleanupWriteError ? error.code : 'write_failed'
379
+ appendLedger(input.sessionId, [{ kind: 'run', runId, phase: 'failed', at: at(), inputSha, reason: code }])
380
+ return finish({ status: 'failed', reason: code, runId, applied: 0, ...(modelSkipped ? { modelSkipped } : {}) })
381
+ }
382
+ appendLedger(input.sessionId, [{ kind: 'run', runId, phase: 'done', at: at(), inputSha, outputSha, model: trace.model, ...(modelSkipped ? { modelSkipped } : {}) }])
383
+ trace.suggestions = recordSuggestions(applied, pack, input.sessionId, stamp, deps)
384
+ return finish({ status: 'done', runId, applied: applied.length, ...(modelSkipped ? { modelSkipped } : {}) })
385
+ } catch (error) {
386
+ console.warn(`[transcript-cleanup] failed: ${error instanceof Error ? error.name : 'error'}`)
387
+ return finish({ status: 'failed', reason: 'error', applied: 0 })
388
+ } finally {
389
+ inProgress.delete(input.sessionId)
390
+ }
391
+ }
392
+
393
+ const WORDS = /[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu
394
+
395
+ /**
396
+ * A dictionary suggestion for each written form an accepted edit took from the meeting's
397
+ * context and the dictionary lacks. The whole replacement, each word and each adjacent pair
398
+ * are checked against the context terms.
399
+ */
400
+ export function recordSuggestions(applied: readonly AppliedEdit[], pack: ContextPack, sessionId: string, at: string, deps: Pick<CleanupDeps, 'suggestions' | 'vocabulary' | 'corrections'>): number {
401
+ let recorded = 0
402
+ let vocabulary: string[] = [], corrections: string[] = []
403
+ try { vocabulary = deps.vocabulary(); corrections = Object.values(deps.corrections()) } catch { /* treat as empty */ }
404
+ const dictionary = new Set([...vocabulary, ...corrections].map(w => w.trim().toLowerCase()))
405
+ for (const edit of applied) {
406
+ const heardKeys = new Set([...edit.original.matchAll(WORDS)].map(m => fold(m[0])))
407
+ const ws = [...edit.replacement.matchAll(WORDS)].map(m => m[0])
408
+ // Longest first: "Queen Ukaoma" is one suggestion, not that plus "Ukaoma".
409
+ const candidates = [...new Set<string>([edit.replacement, ...ws.slice(0, -1).map((w, i) => `${w} ${ws[i + 1]}`), ...ws])]
410
+ .sort((a, b) => b.split(' ').length - a.split(' ').length)
411
+ const covered = new Set<string>()
412
+ for (const candidate of candidates) {
413
+ const term = pack.terms.get(fold(candidate))
414
+ if (!term || inDictionary(term) || heardKeys.has(term.key)) continue
415
+ const parts = term.display.split(/\s+/).map(fold)
416
+ if (parts.every(p => covered.has(p))) continue
417
+ // Every source a term outside the dictionary can have is evidence (evidenceSources), and record()
418
+ // refuses an empty list as well.
419
+ const evidence = evidenceSources(term)
420
+ for (const p of parts) covered.add(p)
421
+ try {
422
+ if (deps.suggestions.record({ written: term.display, heard: edit.original, evidence, sessionId, at, inDictionary: w => dictionary.has(w.trim().toLowerCase()) })) recorded++
423
+ } catch { /* a suggestion is never worth failing the cleanup */ }
424
+ }
425
+ }
426
+ return recorded
427
+ }