@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.
- package/.env.example +40 -0
- package/CHANGELOG.md +56 -0
- package/README.md +12 -1
- package/managed-runtime-contract.json +17 -1
- package/package.json +10 -1
- package/server/index.ts +98 -10
- package/server/lib/agent-session-binding-registry.ts +623 -9
- package/server/lib/boot-resume.ts +552 -0
- package/server/lib/claude-bridge.ts +152 -3
- package/server/lib/claude-cli-support.ts +39 -0
- package/server/lib/claude-steer.ts +259 -0
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/env-switch.ts +17 -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/managed-restart.ts +8 -0
- package/server/lib/provider-actions.ts +18 -0
- package/server/lib/query-job-coordinator.ts +300 -0
- package/server/lib/query-job-feature.ts +39 -0
- package/server/lib/query-job-resume.ts +269 -0
- package/server/lib/query-job-runtime.ts +23 -3
- package/server/lib/query-job-store.ts +516 -7
- package/server/lib/query-job-types.ts +210 -1
- package/server/lib/queue-turn-evidence.ts +213 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue-store.ts +13 -4
- package/server/lib/thread-turn-queue.ts +151 -1
- 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/agent-session-bindings.ts +169 -11
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +29 -1
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/maintenance.ts +34 -0
- package/server/routes/meeting.ts +92 -9
- package/server/routes/query-jobs.ts +107 -13
- package/server/routes/recovery.ts +4 -0
- package/server/routes/thread-turn-queue.ts +371 -10
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- 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
|
+
}
|