@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,249 @@
1
+ // Screen context for a meeting (6.77.0): on-screen text lines COS Control reads during a
2
+ // meeting, kept so the post-save transcript cleanup can see what was on screen near a
3
+ // doubtful passage (a name on a slide, a participant tile, a shared document title).
4
+ //
5
+ // TEXT ONLY. Images never reach the server through this store. Every line is checked
6
+ // with the same secret detector the meeting image OCR uses (imageTextHasSecrets): a
7
+ // line that reads like a password, key or token is HELD (counted, never stored), and
8
+ // what is stored is also passed through redactSecrets.
9
+ //
10
+ // BOUNDED. Per request: 100 lines and 32 KB of text. Per line: 400 characters (longer
11
+ // lines are cut and counted). Per meeting: 2,000 lines and 400 KB; past either the rest
12
+ // of a request is dropped and the response says so. A line repeating one stored in the
13
+ // last 30 seconds is a duplicate and is not stored again (OCR re-reads a still screen).
14
+ //
15
+ // THE MEETING'S OWN DATA. One file per meeting, `transcript-cleanup/screen/<id>.jsonl`,
16
+ // beside the meeting's cleanup ledger. It is deleted with the meeting: by
17
+ // DELETE /api/meeting/:id/screen-context, by sweepTranscriptCleanupData once no saved
18
+ // meeting, live session or pending finalization still carries the id, and in any case
19
+ // after the retention window (default 30 days), because screen text is the most
20
+ // sensitive thing this feature keeps.
21
+
22
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
23
+ import { join, resolve } from 'node:path'
24
+ import { dataPath } from './data-dir.js'
25
+ import { redactSecrets } from './lens-gist-engines.js'
26
+ import { imageTextHasSecrets } from './meeting-image-ocr.js'
27
+
28
+ export const SCREEN_LIMITS = {
29
+ linesPerRequest: 100,
30
+ bytesPerRequest: 32 * 1024,
31
+ charsPerLine: 400,
32
+ linesPerMeeting: 2_000,
33
+ bytesPerMeeting: 400 * 1024,
34
+ duplicateWindowMs: 30_000,
35
+ /** A line may be stamped at most this far before the session started or after now. */
36
+ clockSlackMs: 5 * 60_000,
37
+ appChars: 40,
38
+ } as const
39
+
40
+ export const SESSION_ID = /^[A-Za-z0-9:_-]{3,96}$/
41
+
42
+ export interface ScreenLine {
43
+ /** Epoch ms. */
44
+ at: number
45
+ text: string
46
+ app?: string
47
+ }
48
+
49
+ export interface ScreenAppendResult {
50
+ accepted: number
51
+ duplicates: number
52
+ /** Lines the secret detector held back. Never stored. */
53
+ held: number
54
+ truncated: number
55
+ /** Lines refused for a bad shape or clock. */
56
+ invalid: number
57
+ /** Lines past the per-meeting cap. */
58
+ dropped: number
59
+ stored: number
60
+ }
61
+
62
+ export class ScreenContextError extends Error {
63
+ constructor(readonly code: string, readonly status: number) { super(code) }
64
+ }
65
+
66
+ export function cleanupDir(...parts: string[]): string {
67
+ return dataPath('transcript-cleanup', ...parts)
68
+ }
69
+
70
+ /** The file for one meeting, or null for an id that could escape the directory. */
71
+ export function sessionFile(kind: 'screen' | 'ledger', sessionId: string): string | null {
72
+ if (!SESSION_ID.test(sessionId)) return null
73
+ const dir = cleanupDir(kind)
74
+ const path = join(dir, `${sessionId.replace(/:/g, '%3A')}.jsonl`)
75
+ return resolve(path).startsWith(resolve(dir) + '/') ? path : null
76
+ }
77
+
78
+ function parseAt(value: unknown): number | null {
79
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) return Math.round(value)
80
+ if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
81
+ const ms = Date.parse(value)
82
+ return Number.isFinite(ms) ? ms : null
83
+ }
84
+ return null
85
+ }
86
+
87
+ const norm = (text: string) => text.replace(/\s+/g, ' ').trim().toLowerCase()
88
+
89
+ export function readScreenContext(sessionId: string): ScreenLine[] {
90
+ const path = sessionFile('screen', sessionId)
91
+ if (!path || !existsSync(path)) return []
92
+ let raw = ''
93
+ try { raw = readFileSync(path, 'utf8') } catch { return [] }
94
+ const out: ScreenLine[] = []
95
+ for (const line of raw.split('\n')) {
96
+ if (!line.trim()) continue
97
+ try {
98
+ const o = JSON.parse(line) as Record<string, unknown>
99
+ if (typeof o.at !== 'number' || typeof o.text !== 'string') continue
100
+ out.push({ at: o.at, text: o.text, ...(typeof o.app === 'string' ? { app: o.app } : {}) })
101
+ } catch { /* a torn last line is skipped */ }
102
+ }
103
+ return out
104
+ }
105
+
106
+ export function screenContextStats(sessionId: string): { lines: number; bytes: number; firstAt: number | null; lastAt: number | null } {
107
+ const lines = readScreenContext(sessionId)
108
+ const path = sessionFile('screen', sessionId)
109
+ let bytes = 0
110
+ try { if (path && existsSync(path)) bytes = statSync(path).size } catch { bytes = 0 }
111
+ return { lines: lines.length, bytes, firstAt: lines[0]?.at ?? null, lastAt: lines.at(-1)?.at ?? null }
112
+ }
113
+
114
+ /**
115
+ * Validate and append one request's lines. Throws ScreenContextError for a request the
116
+ * route must refuse whole (bad session id or body shape, too many lines or bytes); a
117
+ * single bad line inside a good request is counted, not fatal.
118
+ */
119
+ export function appendScreenContext(
120
+ sessionId: string,
121
+ body: unknown,
122
+ opts: { now: number; sessionStartedAt?: number | null },
123
+ ): ScreenAppendResult {
124
+ const path = sessionFile('screen', sessionId)
125
+ if (!path) throw new ScreenContextError('invalid_session_id', 400)
126
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new ScreenContextError('invalid_request', 400)
127
+ const lines = (body as { lines?: unknown }).lines
128
+ if (!Array.isArray(lines) || lines.length === 0) throw new ScreenContextError('lines_required', 400)
129
+ if (lines.length > SCREEN_LIMITS.linesPerRequest) throw new ScreenContextError('too_many_lines', 413)
130
+ let requestBytes = 0
131
+ for (const line of lines) {
132
+ const text = line && typeof line === 'object' ? (line as { text?: unknown }).text : undefined
133
+ if (typeof text === 'string') requestBytes += Buffer.byteLength(text, 'utf8')
134
+ }
135
+ if (requestBytes > SCREEN_LIMITS.bytesPerRequest) throw new ScreenContextError('request_too_large', 413)
136
+
137
+ const existing = readScreenContext(sessionId)
138
+ let storedBytes = 0
139
+ try { if (existsSync(path)) storedBytes = statSync(path).size } catch { storedBytes = SCREEN_LIMITS.bytesPerMeeting }
140
+ const result: ScreenAppendResult = { accepted: 0, duplicates: 0, held: 0, truncated: 0, invalid: 0, dropped: 0, stored: existing.length }
141
+ const recent = existing.slice(-50)
142
+ const earliest = (opts.sessionStartedAt ?? 0) - SCREEN_LIMITS.clockSlackMs
143
+ const latest = opts.now + SCREEN_LIMITS.clockSlackMs
144
+ const rows: string[] = []
145
+ for (const line of lines) {
146
+ if (!line || typeof line !== 'object' || Array.isArray(line)) { result.invalid++; continue }
147
+ const o = line as { at?: unknown; text?: unknown; app?: unknown }
148
+ const at = parseAt(o.at)
149
+ if (at === null || at < earliest || at > latest || typeof o.text !== 'string') { result.invalid++; continue }
150
+ let text = o.text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim()
151
+ if (!text) { result.invalid++; continue }
152
+ if (imageTextHasSecrets(o.text) || imageTextHasSecrets(text)) { result.held++; continue }
153
+ text = redactSecrets(text)
154
+ if (text.length > SCREEN_LIMITS.charsPerLine) { text = text.slice(0, SCREEN_LIMITS.charsPerLine); result.truncated++ }
155
+ const key = norm(text)
156
+ if (recent.some(r => norm(r.text) === key && Math.abs(r.at - at) <= SCREEN_LIMITS.duplicateWindowMs)) { result.duplicates++; continue }
157
+ const app = typeof o.app === 'string' ? o.app.replace(/[^\p{L}\p{N} ._-]/gu, '').trim().slice(0, SCREEN_LIMITS.appChars) : ''
158
+ const row = JSON.stringify({ at, text, ...(app ? { app } : {}) })
159
+ const rowBytes = Buffer.byteLength(row, 'utf8') + 1
160
+ if (result.stored >= SCREEN_LIMITS.linesPerMeeting || storedBytes + rowBytes > SCREEN_LIMITS.bytesPerMeeting) { result.dropped++; continue }
161
+ rows.push(row)
162
+ recent.push({ at, text })
163
+ storedBytes += rowBytes
164
+ result.stored++
165
+ result.accepted++
166
+ }
167
+ if (rows.length) {
168
+ mkdirSync(cleanupDir('screen'), { recursive: true, mode: 0o700 })
169
+ appendFileSync(path, rows.join('\n') + '\n', { mode: 0o600 })
170
+ }
171
+ return result
172
+ }
173
+
174
+ export function deleteScreenContext(sessionId: string): boolean {
175
+ const path = sessionFile('screen', sessionId)
176
+ if (!path) throw new ScreenContextError('invalid_session_id', 400)
177
+ if (!existsSync(path)) return false
178
+ rmSync(path, { force: true })
179
+ return true
180
+ }
181
+
182
+ /** The screen lines to show next to one doubtful passage: the nearest in time, then any naming one of its targets. */
183
+ export function screenLinesFor(
184
+ lines: readonly ScreenLine[],
185
+ atMs: number | undefined,
186
+ targets: readonly string[],
187
+ opts: { windowMs?: number; max?: number } = {},
188
+ ): ScreenLine[] {
189
+ const windowMs = opts.windowMs ?? 45_000
190
+ const max = opts.max ?? 3
191
+ const picked: ScreenLine[] = []
192
+ if (atMs !== undefined) {
193
+ const near = lines.filter(l => Math.abs(l.at - atMs) <= windowMs).sort((a, b) => Math.abs(a.at - atMs) - Math.abs(b.at - atMs))
194
+ for (const line of near) if (picked.length < max && !picked.includes(line)) picked.push(line)
195
+ }
196
+ if (picked.length < max && targets.length) {
197
+ const wanted = targets.map(t => t.toLowerCase()).filter(t => t.length >= 4)
198
+ for (const line of lines) {
199
+ if (picked.length >= max) break
200
+ const lower = line.text.toLowerCase()
201
+ if (!picked.includes(line) && wanted.some(t => lower.includes(t))) picked.push(line)
202
+ }
203
+ }
204
+ return picked
205
+ }
206
+
207
+ /** Days screen text is kept even while its meeting exists. */
208
+ export function screenRetentionDays(env: NodeJS.ProcessEnv = process.env): number {
209
+ const raw = Number(env.COS_TRANSCRIPT_SCREEN_RETENTION_DAYS)
210
+ return Number.isFinite(raw) && raw >= 1 && raw <= 365 ? Math.floor(raw) : 30
211
+ }
212
+
213
+ /**
214
+ * Delete cleanup data whose meeting is gone, and screen text past its retention.
215
+ * `known(sessionId)` answers whether a saved meeting, a live session or a pending
216
+ * finalization still carries the id. A file younger than `graceMs` is always kept, so a
217
+ * capture still being recovered from quarantine does not lose its screen context.
218
+ */
219
+ export function sweepTranscriptCleanupData(opts: {
220
+ now: number
221
+ known: (sessionId: string) => boolean
222
+ retentionDays?: number
223
+ graceMs?: number
224
+ }): { screenDeleted: number; ledgersDeleted: number } {
225
+ const graceMs = opts.graceMs ?? 72 * 3_600_000
226
+ const retentionMs = (opts.retentionDays ?? screenRetentionDays()) * 86_400_000
227
+ const out = { screenDeleted: 0, ledgersDeleted: 0 }
228
+ for (const kind of ['screen', 'ledger'] as const) {
229
+ const dir = cleanupDir(kind)
230
+ if (!existsSync(dir)) continue
231
+ let names: string[] = []
232
+ try { names = readdirSync(dir).filter(n => n.endsWith('.jsonl')) } catch { continue }
233
+ for (const name of names) {
234
+ const path = join(dir, name)
235
+ let mtime = 0
236
+ try { mtime = statSync(path).mtimeMs } catch { continue }
237
+ const sessionId = name.slice(0, -'.jsonl'.length).replace(/%3A/g, ':')
238
+ const age = opts.now - mtime
239
+ const expired = kind === 'screen' && age > retentionMs
240
+ let gone = false
241
+ if (!expired && age > graceMs) {
242
+ try { gone = !opts.known(sessionId) } catch { gone = false }
243
+ }
244
+ if (!expired && !gone) continue
245
+ try { rmSync(path, { force: true }); kind === 'screen' ? out.screenDeleted++ : out.ledgersDeleted++ } catch { /* next sweep */ }
246
+ }
247
+ }
248
+ return out
249
+ }
@@ -0,0 +1,358 @@
1
+ // Transcript cleanup (6.77.0): which passages are doubtful, decided with no model.
2
+ //
3
+ // Two steps, both deterministic.
4
+ //
5
+ // 1. FIXES. fuzzy-correct's own rule (fuzzyReplacementFor) over CAPITALISED words that are
6
+ // not already a known spelling: this meeting's screen and file terms (a temporary
7
+ // per-meeting vocabulary) at any confidence; the curated dictionary and the owner only
8
+ // for a word the engine was unsure of. Never a lowercase word, never anyone but the owner as
9
+ // a target (that is the model's call), never into an inflection ("Setting" is never a
10
+ // "Settings" button label). Why each rule exists is measured in deterministicFixes. Fixes
11
+ // are free and are applied before any model call, so the obvious cases never cost a token.
12
+ //
13
+ // 2. SPANS. What is left that the engine or the names say is unsure:
14
+ // low_confidence the decoder's own per-word probability below its engine's floor
15
+ // (Parakeet 0.50, the resolver's floor; Whisper 0.40)
16
+ // near_name a word the engine was unsure of (below 0.9) that is a capitalised word
17
+ // one edit past fuzzy-correct's limit from a known name, or that sounds
18
+ // like one (same consonant skeleton and onset); two or three short
19
+ // adjacent pieces joined ("U coma"), none a function word; or the
20
+ // leading-y rule at any confidence ("Yukoma" for Ukaoma)
21
+ // oov_capitalized a capitalised word mid-sentence that is no known name and no common
22
+ // word, within two words of a known name ("Queen Eucoma")
23
+ // repeat a phrase of 2 to 4 words (or one long word) said three or more
24
+ // times in a row: the classic decoder loop
25
+ // Flagged words one word apart merge; a span holds at most 6 words (12 for a repeat)
26
+ // and never crosses a speaker turn or a clause (a comma or a sentence end). Spans are ranked (names first, then repeats, then
27
+ // the least confident) and cut to the per-meeting cap.
28
+
29
+ import { canonicalFuzzyTargets, fuzzyReplacementFor, isCommonEnglishWord, levenshtein } from './fuzzy-correct.js'
30
+ import { closeSpelling, fold, maxFuzzyDistance, soundsAlike, type Token, type TranscriptLine } from './transcript-cleanup-text.js'
31
+ import type { NameTarget } from './transcript-cleanup-context.js'
32
+
33
+ export type SpanReason = 'low_confidence' | 'near_name' | 'oov_capitalized' | 'repeat'
34
+
35
+ export interface Fix {
36
+ line: number
37
+ start: number
38
+ end: number
39
+ original: string
40
+ replacement: string
41
+ /** The target came only from this meeting's screen or files. */
42
+ fromMeeting: boolean
43
+ token: number
44
+ }
45
+
46
+ export interface CleanupSpan {
47
+ id: string
48
+ line: number
49
+ start: number
50
+ end: number
51
+ text: string
52
+ before: string
53
+ after: string
54
+ reasons: SpanReason[]
55
+ /** Token range [from, to) in the flat token list. */
56
+ from: number
57
+ to: number
58
+ minProbability?: number
59
+ atMs?: number
60
+ /** Known names this span is near, for the prompt and the notes search. */
61
+ near: string[]
62
+ }
63
+
64
+ export const SPAN_LIMITS = {
65
+ maxSpanWords: 6,
66
+ maxRepeatWords: 12,
67
+ contextWords: 12,
68
+ defaultMaxSpans: 40,
69
+ hardMaxSpans: 80,
70
+ } as const
71
+
72
+ export const LOW_CONFIDENCE: Record<string, number> = { parakeet: 0.5, whisper: 0.4, default: 0.45 }
73
+ export function lowConfidenceFloor(engine: string | undefined): number {
74
+ if (!engine) return LOW_CONFIDENCE.default
75
+ if (engine.startsWith('parakeet')) return LOW_CONFIDENCE.parakeet
76
+ if (engine.startsWith('large-v3') || engine.includes('whisper') || engine.startsWith('turbo')) return LOW_CONFIDENCE.whisper
77
+ return LOW_CONFIDENCE.default
78
+ }
79
+
80
+ const FILLERS = new Set(['um', 'uh', 'umm', 'uhm', 'erm', 'er', 'ah', 'hmm', 'mm', 'mhm', 'oh', 'huh'])
81
+
82
+ const INFLECTION = /^(?:s|es|ed|d|ing|er|ers|ly)$/
83
+ /** "Setting" and "Settings", "Record" and "Recording": one word's inflection of the other. */
84
+ export function inflectionOf(a: string, b: string): boolean {
85
+ const x = fold(a), y = fold(b)
86
+ if (x === y) return false
87
+ const [short, long] = x.length <= y.length ? [x, y] : [y, x]
88
+ return long.startsWith(short) && INFLECTION.test(long.slice(short.length))
89
+ }
90
+
91
+ const POSSESSIVE = /(['’]s)$/i
92
+
93
+ /**
94
+ * Deterministic fixes over the tokens. Mutates nothing; the caller applies them.
95
+ *
96
+ * Conservative by measurement (2026-10-17, read-only, 68 real meetings, 38.5 hours):
97
+ * - fuzzy-correct's rule over LOWERCASE words, with 94 enrolled names and the vocabulary
98
+ * as targets, rewrote everyday words into names (giant, permit, companies, weight,
99
+ * pitch, range) 29 times in two meetings. Only a capitalised word is rewritten here.
100
+ * - over capitalised words with every enrolled name as a target it still swapped one real
101
+ * name for another (Kelsey to Kelley, Erica to Eric, Sandy to Andy) and a product for a
102
+ * surname (Slack to Black, 14 times). So a word that is itself a known spelling is never
103
+ * rewritten, people's names other than the owner's are NOT targets here (enrolled or attending: with the
104
+ * attendees as targets Slack still became Black twice; they reach the model as context
105
+ * and drive near-name spans instead), and the curated dictionary and the owner
106
+ * ("strong" targets) rewrite only a word the engine was unsure of. With those rules the
107
+ * 68 meetings got 27 fixes, all real (Manuj to Manoj 9 times, MarketPOS to MarktPOS).
108
+ * - a term from THIS meeting's screen or files (the per-meeting vocabulary) may rewrite a
109
+ * capitalised word at any confidence: it is what was on screen while it was said.
110
+ * Never into an inflection of the word, and a word that only opens a sentence only when the
111
+ * engine was unsure of it.
112
+ */
113
+ export function deterministicFixes(
114
+ tokens: readonly Token[],
115
+ strongTargets: string[],
116
+ meetingTargets: readonly NameTarget[],
117
+ known: ReadonlySet<string> = new Set(),
118
+ ): Fix[] {
119
+ const strong = canonicalFuzzyTargets(strongTargets)
120
+ const meeting = canonicalFuzzyTargets(meetingTargets.filter(t => t.fromMeeting).map(t => t.display))
121
+ const allLower = new Set([...strong, ...meeting].map(t => t.toLowerCase()))
122
+ const fixes: Fix[] = []
123
+ for (const token of tokens) {
124
+ const possessive = POSSESSIVE.exec(token.core)?.[1] ?? ''
125
+ const base = possessive ? token.core.slice(0, -possessive.length) : token.core
126
+ if (!/^\p{Lu}/u.test(base) || allLower.has(base.toLowerCase()) || known.has(fold(base))) continue
127
+ const unsure = token.probability === undefined || token.probability < NEAR_NAME_UNSURE
128
+ if (token.sentenceStart && !unsure) continue
129
+ let replacement = unsure ? fuzzyReplacementFor(base, strong, allLower) : null
130
+ let fromMeeting = false
131
+ if (replacement === null && meeting.length) {
132
+ replacement = fuzzyReplacementFor(base, meeting, allLower)
133
+ fromMeeting = replacement !== null
134
+ }
135
+ if (replacement === null || replacement === base || inflectionOf(base, replacement)) continue
136
+ fixes.push({ line: token.line, start: token.start, end: token.end, original: token.core, replacement: replacement + possessive, fromMeeting, token: token.i })
137
+ }
138
+ return fixes
139
+ }
140
+
141
+ interface Flag { reasons: Set<SpanReason>; near: Set<string> }
142
+
143
+ /**
144
+ * Below this the engine was not sure enough of a word for "it is near a name" to count.
145
+ * Measured 2026-10-17 on four real Parakeet meetings (read-only, no model): with no
146
+ * confidence gate the edit-distance rule flagged everyday words ("lives" for Miles,
147
+ * "rates" for Nate) about eight times a minute.
148
+ */
149
+ export const NEAR_NAME_UNSURE = 0.9
150
+
151
+ /** Short, frequent words that are never a fragment of a split name ("like i" is not "Mike"). */
152
+ const FUNCTION_WORDS = new Set(`a an the and or but if so of to in on at by for from with as is am are was were be been it its i me my we us our you
153
+ your he him his she her they them their this that these those there here what who how why when where not no yes yeah yep ok okay oh uh um
154
+ mm hmm ah like just do did does done can could would should will shall may might must have has had get got go going went say said says
155
+ see saw know knew think thought want one two three four five six seven eight nine ten all any some more most very too also then than
156
+ now well still even ever only own same out up down off over into onto about after before again once s t d ll re ve m o y`.split(/\s+/).filter(Boolean))
157
+
158
+ interface NearInput { key: string; capitalized: boolean; probability?: number; join: boolean }
159
+
160
+ /**
161
+ * The known name `key` is plausibly a mishearing of, or null.
162
+ * - the leading-y rule (meeting-ask-owner.ts): "Yukoma" is one edit from "Ukaoma" once its y goes
163
+ * - otherwise only for a word the engine was unsure of (below NEAR_NAME_UNSURE, or unscored):
164
+ * a capitalised single word one edit past fuzzy-correct's own limit, or
165
+ * any word (or short joined pieces) that sounds like the name (soundsAlike)
166
+ * - joined pieces may also be within fuzzy-correct's own limit ("U coma" read as one word)
167
+ */
168
+ function nearName(input: NearInput, targets: readonly NameTarget[], known: ReadonlySet<string>): string | null {
169
+ const { key } = input
170
+ if (key.length < 5) return null
171
+ if (!input.join && (known.has(key) || isCommonEnglishWord(key))) return null
172
+ const unsure = input.probability === undefined || input.probability < NEAR_NAME_UNSURE
173
+ let best: { d: number; display: string } | null = null
174
+ for (const t of targets) {
175
+ if (Math.abs(t.key.length - key.length) > 2) continue
176
+ const d = levenshtein(key, t.key)
177
+ // A single word spelled exactly like a name is the name. Pieces that join into one exactly ("Uka oma") are the
178
+ // strongest sign of a split name there is.
179
+ if (d === 0 && !input.join) return null
180
+ // "setting" next to a "Settings" label is the same word, not a misheard name.
181
+ if (inflectionOf(key, t.key)) continue
182
+ const md = maxFuzzyDistance(Math.max(key.length, t.key.length))
183
+ const yRule = /^y[aeiou]/.test(key) && !/^y/.test(t.key) && levenshtein(key.slice(1), t.key) <= 1
184
+ const hit = yRule || (unsure && (
185
+ input.join ? (d <= md || soundsAlike(key, t.key)) : ((input.capitalized && d <= md + 1) || soundsAlike(key, t.key))))
186
+ if (hit && (!best || d < best.d)) best = { d, display: t.display }
187
+ }
188
+ return best?.display ?? null
189
+ }
190
+
191
+ /** Repeated phrase runs: [from, to) token ranges. */
192
+ export function repeatRuns(tokens: readonly Token[]): Array<[number, number]> {
193
+ const runs: Array<[number, number]> = []
194
+ let i = 0
195
+ while (i < tokens.length) {
196
+ let found: [number, number] | null = null
197
+ for (let unit = 4; unit >= 1 && !found; unit--) {
198
+ const head = tokens.slice(i, i + unit)
199
+ if (head.length < unit || head.some(t => t.line !== tokens[i].line || !t.key)) continue
200
+ if (unit === 1 && head[0].key.length < 4) continue
201
+ let reps = 1
202
+ while (true) {
203
+ const next = tokens.slice(i + reps * unit, i + (reps + 1) * unit)
204
+ if (next.length < unit || next.some((t, k) => t.line !== tokens[i].line || t.key !== head[k].key)) break
205
+ reps++
206
+ }
207
+ if (reps >= (unit === 1 ? 4 : 3)) found = [i, i + reps * unit]
208
+ }
209
+ if (found) { runs.push(found); i = found[1] } else i++
210
+ }
211
+ return runs
212
+ }
213
+
214
+ export interface SelectOptions {
215
+ maxSpans?: number
216
+ /** Tokens already fixed deterministically: never flagged again. */
217
+ fixed?: ReadonlySet<number>
218
+ }
219
+
220
+ export interface SpanSelection {
221
+ spans: CleanupSpan[]
222
+ /** Before the per-meeting cap. */
223
+ found: number
224
+ byReason: Record<SpanReason, number>
225
+ }
226
+
227
+ function snippet(tokens: readonly Token[], from: number, to: number, line: number): string {
228
+ return tokens.slice(Math.max(0, from), Math.max(0, to)).filter(t => t.line === line).map(t => t.raw).join(' ')
229
+ }
230
+
231
+ export function selectSpans(
232
+ lines: readonly TranscriptLine[],
233
+ tokens: readonly Token[],
234
+ targets: readonly NameTarget[],
235
+ known: ReadonlySet<string>,
236
+ opts: SelectOptions = {},
237
+ ): SpanSelection {
238
+ const maxSpans = Math.max(0, Math.min(opts.maxSpans ?? SPAN_LIMITS.defaultMaxSpans, SPAN_LIMITS.hardMaxSpans))
239
+ const fixed = opts.fixed ?? new Set<number>()
240
+ const flags = new Map<number, Flag>()
241
+ const flag = (i: number, reason: SpanReason, near?: string) => {
242
+ const f = flags.get(i) ?? { reasons: new Set<SpanReason>(), near: new Set<string>() }
243
+ f.reasons.add(reason)
244
+ if (near) f.near.add(near)
245
+ flags.set(i, f)
246
+ }
247
+ const nameKeys = new Set(targets.map(t => t.key))
248
+ const nearMemo = new Map<string, string | null>()
249
+
250
+ for (const t of tokens) {
251
+ if (fixed.has(t.i) || !t.key) continue
252
+ if (t.probability !== undefined && t.probability < lowConfidenceFloor(t.engine) && !FILLERS.has(t.key)) flag(t.i, 'low_confidence')
253
+ if (!known.has(t.key)) {
254
+ const memo = `${t.key}|${/^\p{Lu}/u.test(t.core) ? 1 : 0}|${t.probability === undefined || t.probability < NEAR_NAME_UNSURE ? 1 : 0}`
255
+ let near = nearMemo.get(memo)
256
+ if (near === undefined) {
257
+ near = nearName({ key: t.key, capitalized: /^\p{Lu}/u.test(t.core), probability: t.probability, join: false }, targets, known)
258
+ nearMemo.set(memo, near)
259
+ }
260
+ if (near) flag(t.i, 'near_name', near)
261
+ }
262
+ }
263
+ // Two or three adjacent words joined: "U coma", "Yu ko ma". One of them must be short (a split fragment).
264
+ for (let i = 0; i + 1 < tokens.length; i++) {
265
+ for (const width of [2, 3]) {
266
+ const group = tokens.slice(i, i + width)
267
+ if (group.length < width || group.some(g => g.line !== tokens[i].line || !g.key || fixed.has(g.i) || known.has(g.key))) continue
268
+ // A split name is short pieces ("U coma", "Yu ko ma"); a long word plus a short one is just two words,
269
+ // and a function word is never a piece of a name.
270
+ if (!group.some(g => g.key.length <= 3) || group.some(g => g.key.length > 5 || FUNCTION_WORDS.has(g.key))) continue
271
+ const joined = group.map(g => g.key).join('')
272
+ const scores = group.map(g => g.probability).filter((p): p is number => p !== undefined)
273
+ const near = nearName({ key: joined, capitalized: group.some(g => /^\p{Lu}/u.test(g.core)), probability: scores.length === group.length ? Math.min(...scores) : undefined, join: true }, targets, known)
274
+ if (near) for (const g of group) flag(g.i, 'near_name', near)
275
+ }
276
+ }
277
+ for (const t of tokens) {
278
+ if (fixed.has(t.i) || t.sentenceStart || !/^\p{Lu}/u.test(t.core) || t.key.length < 3) continue
279
+ if (known.has(t.key) || isCommonEnglishWord(t.key) || /^\p{Lu}+$/u.test(t.core) || t.core === 'I' || /^I['’]/.test(t.core)) continue
280
+ const neighbours = tokens.slice(Math.max(0, t.i - 2), t.i + 3).filter(n => n.i !== t.i && n.line === t.line)
281
+ const anchor = neighbours.find(n => nameKeys.has(n.key) || (known.has(n.key) && /^\p{Lu}/u.test(n.core)))
282
+ if (anchor) flag(t.i, 'oov_capitalized', targets.find(x => x.key === anchor.key)?.display ?? anchor.core)
283
+ }
284
+
285
+ // Group flagged tokens into spans inside one line.
286
+ type Draft = { from: number; to: number; reasons: Set<SpanReason>; near: Set<string> }
287
+ const drafts: Draft[] = []
288
+ const repeatTokens = new Set<number>()
289
+ for (const [from, to] of repeatRuns(tokens)) {
290
+ if ([...Array(to - from).keys()].some(k => fixed.has(from + k))) continue
291
+ const end = Math.min(to, from + SPAN_LIMITS.maxRepeatWords)
292
+ drafts.push({ from, to: end, reasons: new Set<SpanReason>(['repeat']), near: new Set() })
293
+ for (let k = from; k < end; k++) repeatTokens.add(k)
294
+ }
295
+ const flagged = [...flags.keys()].filter(i => !repeatTokens.has(i)).sort((a, b) => a - b)
296
+ let k = 0
297
+ while (k < flagged.length) {
298
+ const from = flagged[k]
299
+ let to = from + 1
300
+ const reasons = new Set(flags.get(from)!.reasons)
301
+ const near = new Set(flags.get(from)!.near)
302
+ k++
303
+ while (k < flagged.length) {
304
+ const next = flagged[k]
305
+ if (tokens[next].line !== tokens[from].line || next - to > 1 || next + 1 - from > SPAN_LIMITS.maxSpanWords) break
306
+ // Never across a clause: "Yukoma and eucoma, then U coma" is two passages, not one.
307
+ if (tokens.slice(to - 1, next).some(t => /[,.;:!?]["')\]]*$/.test(t.raw))) break
308
+ if ([...Array(next - to).keys()].some(g => repeatTokens.has(to + g))) break
309
+ for (const r of flags.get(next)!.reasons) reasons.add(r)
310
+ for (const n of flags.get(next)!.near) near.add(n)
311
+ to = next + 1
312
+ k++
313
+ }
314
+ drafts.push({ from, to, reasons, near })
315
+ }
316
+
317
+ const byReason: Record<SpanReason, number> = { low_confidence: 0, near_name: 0, oov_capitalized: 0, repeat: 0 }
318
+ for (const d of drafts) for (const r of d.reasons) byReason[r]++
319
+ const minP = (d: Draft) => {
320
+ const ps = tokens.slice(d.from, d.to).map(t => t.probability).filter((p): p is number => p !== undefined)
321
+ return ps.length ? Math.min(...ps) : undefined
322
+ }
323
+ const score = (d: Draft) => (d.reasons.has('near_name') ? 4 : 0) + (d.reasons.has('oov_capitalized') ? 3 : 0)
324
+ + (d.reasons.has('repeat') ? 2 : 0) + (d.reasons.has('low_confidence') ? 1 + (1 - (minP(d) ?? 1)) : 0)
325
+ const kept = [...drafts].sort((a, b) => score(b) - score(a) || a.from - b.from).slice(0, maxSpans).sort((a, b) => a.from - b.from)
326
+
327
+ const spans = kept.map((d, n): CleanupSpan => {
328
+ const first = tokens[d.from], last = tokens[d.to - 1]
329
+ const line = lines.find(l => l.index === first.line)!
330
+ const p = minP(d)
331
+ const at = tokens.slice(d.from, d.to).map(t => t.atMs).find(v => v !== undefined)
332
+ return {
333
+ id: `S${n + 1}`,
334
+ line: first.line,
335
+ start: first.start,
336
+ end: last.end,
337
+ text: line.text.slice(first.start, last.end),
338
+ before: snippet(tokens, d.from - SPAN_LIMITS.contextWords, d.from, first.line),
339
+ after: snippet(tokens, d.to, d.to + SPAN_LIMITS.contextWords, first.line),
340
+ reasons: [...d.reasons].sort(),
341
+ from: d.from,
342
+ to: d.to,
343
+ ...(p !== undefined ? { minProbability: Math.round(p * 1000) / 1000 } : {}),
344
+ ...(at !== undefined ? { atMs: at } : {}),
345
+ near: [...d.near].slice(0, 3),
346
+ }
347
+ })
348
+ return { spans, found: drafts.length, byReason }
349
+ }
350
+
351
+ /** For edit validation: is `word` plausibly one of the span's own words, misheard? */
352
+ export function resemblesSpan(word: string, spanWords: readonly string[]): boolean {
353
+ const w = fold(word)
354
+ if (!w) return true
355
+ const keys = spanWords.map(fold).filter(Boolean)
356
+ const joined = keys.join('')
357
+ return keys.some(k => k === w || closeSpelling(k, w) || soundsAlike(k, w)) || closeSpelling(joined, w) || soundsAlike(joined, w)
358
+ }