@gotcos/glasses-server 6.75.0 → 6.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/managed-runtime-contract.json +8 -0
  3. package/package.json +1 -1
  4. package/server/index.ts +6 -1
  5. package/server/lib/cos-bridge-notes.ts +73 -0
  6. package/server/lib/decision-provider.ts +9 -0
  7. package/server/lib/display-bus.ts +2 -0
  8. package/server/lib/fuzzy-correct.ts +77 -49
  9. package/server/lib/live-cos-ask.ts +118 -21
  10. package/server/lib/live-cues-v2-notes.ts +152 -0
  11. package/server/lib/live-cues-v2-settings.ts +63 -0
  12. package/server/lib/live-cues-v2.ts +198 -38
  13. package/server/lib/meeting-ask-owner.ts +144 -0
  14. package/server/lib/meeting-ask-runtime.ts +28 -6
  15. package/server/lib/meeting-stop-requests.ts +123 -0
  16. package/server/lib/transcript-cleanup-context.ts +234 -0
  17. package/server/lib/transcript-cleanup-ledger.ts +304 -0
  18. package/server/lib/transcript-cleanup-model.ts +217 -0
  19. package/server/lib/transcript-cleanup-screen.ts +249 -0
  20. package/server/lib/transcript-cleanup-spans.ts +358 -0
  21. package/server/lib/transcript-cleanup-state.ts +201 -0
  22. package/server/lib/transcript-cleanup-suggestions.ts +126 -0
  23. package/server/lib/transcript-cleanup-text.ts +200 -0
  24. package/server/lib/transcript-cleanup.ts +427 -0
  25. package/server/routes/glossary.ts +38 -19
  26. package/server/routes/health.ts +3 -0
  27. package/server/routes/live-cues.ts +32 -3
  28. package/server/routes/meeting-ask.ts +11 -1
  29. package/server/routes/meeting.ts +177 -0
  30. package/server/routes/transcribe.ts +3 -0
  31. package/server/routes/transcript-cleanup.ts +237 -0
  32. package/server/scripts/live-cues-v2-notes-mutation-gate.mjs +170 -0
  33. package/server/scripts/transcript-cleanup-mutation-gate.mjs +219 -0
  34. package/shared/live-cues-v2.ts +9 -4
  35. package/shared/meeting-ask.ts +23 -1
@@ -0,0 +1,201 @@
1
+ // Transcript cleanup (6.77.0): how it is switched on, which model it uses, and how it stops.
2
+ //
3
+ // SWITCH. Its own flag, nothing shared:
4
+ // 1. env COS_TRANSCRIPT_CLEANUP=1 forces it on, =0 forces it off (source 'env'); a
5
+ // request to change it then answers 409 env_forced.
6
+ // 2. the saved setting (transcript-cleanup/settings.json, atomic), which COS Control's
7
+ // toggle writes through POST /api/transcript-cleanup/settings (source 'setting').
8
+ // 3. off (source 'default'). Miles turns it on.
9
+ // An unreadable settings file reads as no setting: it fails toward off, never toward spending.
10
+ //
11
+ // MODEL. Haiku unless the setting or env COS_TRANSCRIPT_CLEANUP_MODEL says sonnet. Always a
12
+ // pinned model id, never the CLI default (a bare `claude -p` can resolve to Opus).
13
+ //
14
+ // STOPS (each one a recorded skip, correction_skipped style):
15
+ // switch_off nothing runs at all: no fuzzy pass, no file, no trace
16
+ // api_key_in_env ANTHROPIC_API_KEY is set and COS_ALLOW_API_FALLBACK is not 1: the CLI
17
+ // would bill the API key, not the plan
18
+ // claude_not_found no Claude CLI
19
+ // daily_calls COS_TRANSCRIPT_CLEANUP_DAILY calls today (default 30), charged BEFORE
20
+ // the call and never refunded
21
+ // daily_tokens COS_TRANSCRIPT_CLEANUP_DAILY_TOKENS today (default 400k in plus out)
22
+ // breaker_open 3 failures in a row, or one provider limit, open it for 30 minutes
23
+ // (60 after a limit); the first meeting after that is one trial call
24
+ // Per meeting: one call, at most COS_TRANSCRIPT_CLEANUP_MAX_SPANS spans (default 40, hard
25
+ // 80), a 24k-character prompt and a 1,500-token answer. The day is the local calendar day.
26
+ // An unreadable or impossible state file reads as a spent day, so a corrupt ledger can
27
+ // stop spending but never start it.
28
+
29
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
30
+ import { dirname } from 'node:path'
31
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
32
+ import { cleanupDir } from './transcript-cleanup-screen.js'
33
+ import { CLEANUP_MODEL_IDS, type CleanupModelChoice } from './transcript-cleanup-model.js'
34
+ import { SPAN_LIMITS } from './transcript-cleanup-spans.js'
35
+
36
+ export type CleanupSettingSource = 'env' | 'setting' | 'default'
37
+ export interface StoredCleanupSettings { enabled: boolean | null; model: CleanupModelChoice | null }
38
+
39
+ export class TranscriptCleanupSettings {
40
+ private cache: StoredCleanupSettings | null = null
41
+ constructor(readonly file: string = cleanupDir('settings.json')) {}
42
+
43
+ stored(): StoredCleanupSettings {
44
+ if (this.cache) return this.cache
45
+ let value: StoredCleanupSettings = { enabled: null, model: null }
46
+ try {
47
+ if (existsSync(this.file)) {
48
+ const raw = JSON.parse(readFileSync(this.file, 'utf8')) as Record<string, unknown>
49
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
50
+ value = {
51
+ enabled: typeof raw.enabled === 'boolean' ? raw.enabled : null,
52
+ model: raw.model === 'haiku' || raw.model === 'sonnet' ? raw.model : null,
53
+ }
54
+ }
55
+ }
56
+ } catch { value = { enabled: null, model: null } }
57
+ this.cache = value
58
+ return value
59
+ }
60
+
61
+ /** Atomic and durable; the cached value changes only after the write. */
62
+ save(patch: Partial<{ enabled: boolean; model: CleanupModelChoice }>, now: Date = new Date()): StoredCleanupSettings {
63
+ const current = this.stored()
64
+ const next: StoredCleanupSettings = {
65
+ enabled: patch.enabled ?? current.enabled,
66
+ model: patch.model ?? current.model,
67
+ }
68
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
69
+ durableAtomicWriteFileSync(this.file, JSON.stringify({ schema: 1, ...next, updatedAt: now.toISOString() }) + '\n', { mode: 0o600 })
70
+ this.cache = next
71
+ return next
72
+ }
73
+ }
74
+
75
+ export function resolveCleanupSwitch(env: NodeJS.ProcessEnv, settings: TranscriptCleanupSettings | null): { enabled: boolean; source: CleanupSettingSource } {
76
+ if (env.COS_TRANSCRIPT_CLEANUP === '1') return { enabled: true, source: 'env' }
77
+ if (env.COS_TRANSCRIPT_CLEANUP === '0') return { enabled: false, source: 'env' }
78
+ let stored: boolean | null = null
79
+ try { stored = settings ? settings.stored().enabled : null } catch { stored = null }
80
+ if (stored !== null) return { enabled: stored, source: 'setting' }
81
+ return { enabled: false, source: 'default' }
82
+ }
83
+
84
+ export function resolveCleanupModel(env: NodeJS.ProcessEnv, settings: TranscriptCleanupSettings | null): { choice: CleanupModelChoice; id: string; source: CleanupSettingSource } {
85
+ const raw = (env.COS_TRANSCRIPT_CLEANUP_MODEL ?? '').trim().toLowerCase()
86
+ if (raw === 'haiku' || raw === 'sonnet') return { choice: raw, id: CLEANUP_MODEL_IDS[raw], source: 'env' }
87
+ let stored: CleanupModelChoice | null = null
88
+ try { stored = settings ? settings.stored().model : null } catch { stored = null }
89
+ if (stored) return { choice: stored, id: CLEANUP_MODEL_IDS[stored], source: 'setting' }
90
+ return { choice: 'haiku', id: CLEANUP_MODEL_IDS.haiku, source: 'default' }
91
+ }
92
+
93
+ function intEnv(env: NodeJS.ProcessEnv, name: string, fallback: number, max: number): number {
94
+ const raw = Number(env[name])
95
+ return Number.isFinite(raw) && raw >= 0 ? Math.min(Math.floor(raw), max) : fallback
96
+ }
97
+
98
+ export interface CleanupLimits { dailyCalls: number; dailyTokens: number; maxSpans: number; breakerFailures: number; breakerCooldownMs: number; limitCooldownMs: number }
99
+ export function cleanupLimits(env: NodeJS.ProcessEnv = process.env): CleanupLimits {
100
+ return {
101
+ dailyCalls: intEnv(env, 'COS_TRANSCRIPT_CLEANUP_DAILY', 30, 500),
102
+ dailyTokens: intEnv(env, 'COS_TRANSCRIPT_CLEANUP_DAILY_TOKENS', 400_000, 10_000_000),
103
+ maxSpans: Math.max(1, intEnv(env, 'COS_TRANSCRIPT_CLEANUP_MAX_SPANS', SPAN_LIMITS.defaultMaxSpans, SPAN_LIMITS.hardMaxSpans)),
104
+ breakerFailures: 3,
105
+ breakerCooldownMs: 30 * 60_000,
106
+ limitCooldownMs: 60 * 60_000,
107
+ }
108
+ }
109
+
110
+ export interface BudgetState {
111
+ day: string
112
+ calls: number
113
+ tokens: number
114
+ consecutiveFailures: number
115
+ breakerOpenUntil: number | null
116
+ }
117
+
118
+ export function localDay(now: Date): string {
119
+ return now.toLocaleDateString('en-CA')
120
+ }
121
+
122
+ /** The day's spend and the breaker. One small JSON file, rewritten atomically on every change. */
123
+ export class TranscriptCleanupBudget {
124
+ constructor(readonly file: string = cleanupDir('state.json'), private readonly now: () => Date = () => new Date()) {}
125
+
126
+ read(): BudgetState {
127
+ const day = localDay(this.now())
128
+ const spent: BudgetState = { day, calls: Number.MAX_SAFE_INTEGER, tokens: Number.MAX_SAFE_INTEGER, consecutiveFailures: 0, breakerOpenUntil: null }
129
+ try {
130
+ if (!existsSync(this.file)) return { day, calls: 0, tokens: 0, consecutiveFailures: 0, breakerOpenUntil: null }
131
+ const raw = JSON.parse(readFileSync(this.file, 'utf8')) as Partial<BudgetState>
132
+ if (!raw || typeof raw !== 'object' || typeof raw.day !== 'string') return spent
133
+ const failures = Number(raw.consecutiveFailures ?? 0)
134
+ const openUntil = raw.breakerOpenUntil === null || raw.breakerOpenUntil === undefined ? null : Number(raw.breakerOpenUntil)
135
+ const breaker = { consecutiveFailures: Number.isFinite(failures) && failures >= 0 ? failures : 0, breakerOpenUntil: openUntil !== null && Number.isFinite(openUntil) ? openUntil : null }
136
+ // A future day key or a negative count fails toward NOT spending.
137
+ if (raw.day > day) return { ...spent, ...breaker }
138
+ if (raw.day < day) return { day, calls: 0, tokens: 0, ...breaker }
139
+ const calls = Number(raw.calls), tokens = Number(raw.tokens)
140
+ if (!(calls >= 0) || !(tokens >= 0)) return { ...spent, ...breaker }
141
+ return { day, calls, tokens, ...breaker }
142
+ } catch {
143
+ return spent
144
+ }
145
+ }
146
+
147
+ private write(state: BudgetState): void {
148
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
149
+ durableAtomicWriteFileSync(this.file, JSON.stringify(state) + '\n', { mode: 0o600 })
150
+ }
151
+
152
+ /** Why a call may not run now, or null. Read-only. */
153
+ gate(limits: CleanupLimits): 'daily_calls' | 'daily_tokens' | 'breaker_open' | null {
154
+ const s = this.read()
155
+ if (s.breakerOpenUntil !== null && this.now().getTime() < s.breakerOpenUntil) return 'breaker_open'
156
+ if (s.calls >= limits.dailyCalls) return 'daily_calls'
157
+ if (s.tokens >= limits.dailyTokens) return 'daily_tokens'
158
+ return null
159
+ }
160
+
161
+ /** Charge one call BEFORE it runs. False when the day is spent. */
162
+ charge(limits: CleanupLimits): boolean {
163
+ const s = this.read()
164
+ if (s.calls >= limits.dailyCalls || s.tokens >= limits.dailyTokens) return false
165
+ this.write({ ...s, calls: s.calls + 1 })
166
+ return true
167
+ }
168
+
169
+ recordUsage(tokens: number): void {
170
+ const s = this.read()
171
+ if (s.tokens === Number.MAX_SAFE_INTEGER) return
172
+ this.write({ ...s, tokens: s.tokens + Math.max(0, Math.round(tokens)) })
173
+ }
174
+
175
+ recordSuccess(): void {
176
+ const s = this.read()
177
+ if (s.consecutiveFailures === 0 && s.breakerOpenUntil === null) return
178
+ this.write({ ...s, consecutiveFailures: 0, breakerOpenUntil: null })
179
+ }
180
+
181
+ /** Returns true when this failure opened the breaker. */
182
+ recordFailure(limits: CleanupLimits, providerLimit: boolean): boolean {
183
+ const s = this.read()
184
+ const failures = s.consecutiveFailures + 1
185
+ const open = providerLimit || failures >= limits.breakerFailures
186
+ const until = open ? this.now().getTime() + (providerLimit ? limits.limitCooldownMs : limits.breakerCooldownMs) : s.breakerOpenUntil
187
+ this.write({ ...s, consecutiveFailures: failures, breakerOpenUntil: until })
188
+ return open
189
+ }
190
+ }
191
+
192
+ let settingsSingleton: TranscriptCleanupSettings | null = null
193
+ let budgetSingleton: TranscriptCleanupBudget | null = null
194
+ export function transcriptCleanupSettings(): TranscriptCleanupSettings {
195
+ if (!settingsSingleton) settingsSingleton = new TranscriptCleanupSettings()
196
+ return settingsSingleton
197
+ }
198
+ export function transcriptCleanupBudget(): TranscriptCleanupBudget {
199
+ if (!budgetSingleton) budgetSingleton = new TranscriptCleanupBudget()
200
+ return budgetSingleton
201
+ }
@@ -0,0 +1,126 @@
1
+ // Dictionary suggestions from transcript cleanup (6.77.0).
2
+ //
3
+ // When a cleanup edit is accepted and what it wrote is a term this meeting's CONTEXT
4
+ // supplied (a screen line, a dropped file, the owner card, COS people, a notes hit) and
5
+ // the dictionary does not already hold it, COS records a SUGGESTION: the written form,
6
+ // the heard forms, and where the evidence came from. Never an automatic addition. A
7
+ // person accepts it (the word joins the profile vocabulary through the glossary's own
8
+ // writer, optionally with its heard form as a logged, undoable correction) or dismisses
9
+ // it (that written form is never suggested again).
10
+ //
11
+ // One file, `transcript-cleanup/suggestions.json`, rewritten atomically. Deduped by the
12
+ // written form, case-insensitive. Bounded: 200 suggestions, 5 heard forms and 5 evidence
13
+ // rows each; accepted and dismissed ones are evicted before pending ones. Evidence rows
14
+ // carry ids, a file or app name and a time, never transcript or screen text.
15
+
16
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
17
+ import { dirname } from 'node:path'
18
+ import { randomBytes } from 'node:crypto'
19
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
20
+ import { cleanupDir } from './transcript-cleanup-screen.js'
21
+ import type { EvidenceSource } from './transcript-cleanup-context.js'
22
+
23
+ export const SUGGESTION_LIMITS = { suggestions: 200, heard: 5, evidence: 5 } as const
24
+ export const SUGGESTION_ID = /^sug_[0-9a-f]{16}$/
25
+
26
+ export interface SuggestionEvidence {
27
+ source: EvidenceSource
28
+ sessionId: string
29
+ heard: string
30
+ at: string
31
+ detail: string | null
32
+ }
33
+
34
+ export interface DictionarySuggestion {
35
+ id: string
36
+ written: string
37
+ heard: string[]
38
+ status: 'pending' | 'accepted' | 'dismissed'
39
+ count: number
40
+ createdAt: string
41
+ updatedAt: string
42
+ evidence: SuggestionEvidence[]
43
+ }
44
+
45
+ const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
46
+
47
+ export class SuggestionStore {
48
+ constructor(readonly file: string = cleanupDir('suggestions.json')) {}
49
+
50
+ list(): DictionarySuggestion[] {
51
+ try {
52
+ if (!existsSync(this.file)) return []
53
+ const raw = JSON.parse(readFileSync(this.file, 'utf8')) as { suggestions?: unknown }
54
+ if (!raw || !Array.isArray(raw.suggestions)) return []
55
+ return raw.suggestions.filter((s): s is DictionarySuggestion => !!s && typeof s === 'object'
56
+ && typeof (s as DictionarySuggestion).id === 'string' && typeof (s as DictionarySuggestion).written === 'string'
57
+ && ['pending', 'accepted', 'dismissed'].includes((s as DictionarySuggestion).status)
58
+ && Array.isArray((s as DictionarySuggestion).heard) && Array.isArray((s as DictionarySuggestion).evidence))
59
+ } catch {
60
+ return []
61
+ }
62
+ }
63
+
64
+ private write(list: DictionarySuggestion[]): void {
65
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
66
+ durableAtomicWriteFileSync(this.file, JSON.stringify({ schema: 1, suggestions: list }, null, 2) + '\n', { mode: 0o600 })
67
+ }
68
+
69
+ get(id: string): DictionarySuggestion | null {
70
+ return this.list().find(s => s.id === id) ?? null
71
+ }
72
+
73
+ /**
74
+ * Record one piece of evidence for a written form. Returns the suggestion, or null when
75
+ * nothing was recorded: no evidence, the form is already in the dictionary, or it was
76
+ * dismissed before.
77
+ */
78
+ record(input: { written: string; heard: string; evidence: Array<{ source: EvidenceSource; detail: string | null }>; sessionId: string; at: string; inDictionary: (written: string) => boolean }): DictionarySuggestion | null {
79
+ const written = input.written.replace(/\s+/g, ' ').trim()
80
+ if (!written || written.length > 100 || !input.evidence.length) return null
81
+ if (input.inDictionary(written)) return null
82
+ const list = this.list()
83
+ const existing = list.find(s => same(s.written, written))
84
+ if (existing && existing.status !== 'pending') return null
85
+ const rows: SuggestionEvidence[] = input.evidence.map(e => ({ source: e.source, sessionId: input.sessionId, heard: input.heard.slice(0, 100), at: input.at, detail: e.detail }))
86
+ let out: DictionarySuggestion
87
+ if (existing) {
88
+ if (!existing.heard.some(h => same(h, input.heard)) && existing.heard.length < SUGGESTION_LIMITS.heard) existing.heard.push(input.heard.slice(0, 100))
89
+ for (const row of rows) {
90
+ const dup = existing.evidence.some(e => e.source === row.source && e.sessionId === row.sessionId && same(e.heard, row.heard))
91
+ if (!dup && existing.evidence.length < SUGGESTION_LIMITS.evidence) existing.evidence.push(row)
92
+ }
93
+ existing.count += 1
94
+ existing.updatedAt = input.at
95
+ out = existing
96
+ } else {
97
+ out = { id: `sug_${randomBytes(8).toString('hex')}`, written, heard: [input.heard.slice(0, 100)], status: 'pending', count: 1, createdAt: input.at, updatedAt: input.at, evidence: rows.slice(0, SUGGESTION_LIMITS.evidence) }
98
+ list.push(out)
99
+ }
100
+ this.write(evict(list))
101
+ return out
102
+ }
103
+
104
+ setStatus(id: string, status: 'accepted' | 'dismissed', at: string): DictionarySuggestion | null {
105
+ const list = this.list()
106
+ const s = list.find(x => x.id === id)
107
+ if (!s) return null
108
+ s.status = status
109
+ s.updatedAt = at
110
+ this.write(list)
111
+ return s
112
+ }
113
+ }
114
+
115
+ function evict(list: DictionarySuggestion[]): DictionarySuggestion[] {
116
+ if (list.length <= SUGGESTION_LIMITS.suggestions) return list
117
+ const ordered = [...list].sort((a, b) => (a.status === 'pending' ? 1 : 0) - (b.status === 'pending' ? 1 : 0) || a.updatedAt.localeCompare(b.updatedAt))
118
+ const drop = new Set(ordered.slice(0, list.length - SUGGESTION_LIMITS.suggestions).map(s => s.id))
119
+ return list.filter(s => !drop.has(s.id))
120
+ }
121
+
122
+ let singleton: SuggestionStore | null = null
123
+ export function suggestionStore(): SuggestionStore {
124
+ if (!singleton) singleton = new SuggestionStore()
125
+ return singleton
126
+ }
@@ -0,0 +1,200 @@
1
+ // Transcript cleanup (6.77.0): the text model every other cleanup module shares.
2
+ //
3
+ // A saved meeting's `## Transcript` section is lines of `[Label]: words`. Cleanup only
4
+ // ever edits the WORDS of a line, never its label, never a line it cannot parse, and
5
+ // never across a line break: a speaker turn is the largest unit an edit can touch.
6
+ //
7
+ // Tokens are whitespace runs inside a line's text. Each carries its `core`: the token
8
+ // without leading and trailing punctuation, which is what is compared, flagged and
9
+ // replaced, so "Ukoma," becomes "Ukaoma," and the comma stays exactly where it was.
10
+ //
11
+ // No IO and no model here.
12
+
13
+ import { levenshtein } from './fuzzy-correct.js'
14
+
15
+ export interface TranscriptLine {
16
+ index: number
17
+ /** `[Label]: ` including the trailing space, or '' for a line without a label. */
18
+ prefix: string
19
+ label: string | null
20
+ text: string
21
+ /** False for blank lines and anything that is not a speaker turn. Never edited. */
22
+ editable: boolean
23
+ }
24
+
25
+ export interface Token {
26
+ /** Position in the flat token list. */
27
+ i: number
28
+ line: number
29
+ /** Offsets of the core inside `line.text`. */
30
+ start: number
31
+ end: number
32
+ raw: string
33
+ core: string
34
+ key: string
35
+ /** First token of its line, or after a sentence end. */
36
+ sentenceStart: boolean
37
+ probability?: number
38
+ /** Epoch ms of the word, when the batch words could be aligned. */
39
+ atMs?: number
40
+ engine?: string
41
+ }
42
+
43
+ const LINE = /^\[([^\]\n]{1,80})\]: ?(.*)$/
44
+
45
+ export function parseTranscriptLines(section: string): TranscriptLine[] {
46
+ return section.split('\n').map((raw, index) => {
47
+ const m = LINE.exec(raw)
48
+ if (!m) return { index, prefix: '', label: null, text: raw, editable: false }
49
+ const prefix = raw.slice(0, raw.length - m[2].length)
50
+ return { index, prefix, label: m[1], text: m[2], editable: m[2].trim().length > 0 }
51
+ })
52
+ }
53
+
54
+ export function joinTranscriptLines(lines: readonly TranscriptLine[]): string {
55
+ return lines.map(line => line.prefix + line.text).join('\n')
56
+ }
57
+
58
+ /** Letters and digits only, accents folded, lowercase. The comparison form of a word. */
59
+ export function fold(word: string): string {
60
+ return word.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, '')
61
+ }
62
+
63
+ const EDGE = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu
64
+
65
+ export function tokenize(lines: readonly TranscriptLine[]): Token[] {
66
+ const out: Token[] = []
67
+ for (const line of lines) {
68
+ if (!line.editable) continue
69
+ let sentenceStart = true
70
+ for (const m of line.text.matchAll(/\S+/g)) {
71
+ const raw = m[0]
72
+ const at = m.index ?? 0
73
+ const lead = /^[^\p{L}\p{N}]*/u.exec(raw)![0].length
74
+ const core = raw.replace(EDGE, '')
75
+ const start = at + lead
76
+ out.push({ i: out.length, line: line.index, start, end: start + core.length, raw, core, key: fold(core), sentenceStart })
77
+ sentenceStart = /[.!?]["')\]]*$/.test(raw)
78
+ }
79
+ }
80
+ return out
81
+ }
82
+
83
+ /** One spoken word from the batch decode, placed on the meeting clock. */
84
+ export interface WordInfo {
85
+ word: string
86
+ probability?: number
87
+ atMs?: number
88
+ engine?: string
89
+ }
90
+
91
+ /**
92
+ * The batch words of a saved meeting, in transcript order, from its `.g2-chunks.json`
93
+ * sidecar. A segment's word times are seconds from that window's start; the window
94
+ * starts at `startElapsed` on the meeting clock (the same convention
95
+ * mapWordsToSpeakers uses), and the meeting started at `startTime` (epoch ms).
96
+ */
97
+ export function wordInfoFromSidecar(sidecar: Record<string, unknown>): WordInfo[] {
98
+ const segments = Array.isArray(sidecar.batchSegments) ? sidecar.batchSegments : []
99
+ const startTime = typeof sidecar.startTime === 'number' && Number.isFinite(sidecar.startTime) ? sidecar.startTime : null
100
+ const out: WordInfo[] = []
101
+ for (const raw of segments) {
102
+ if (!raw || typeof raw !== 'object') continue
103
+ const segment = raw as { words?: unknown; startElapsed?: unknown; stt?: { engine?: unknown } }
104
+ const words = Array.isArray(segment.words) ? segment.words : []
105
+ const startElapsed = typeof segment.startElapsed === 'number' && Number.isFinite(segment.startElapsed) ? segment.startElapsed : null
106
+ const engine = typeof segment.stt?.engine === 'string' ? segment.stt.engine : undefined
107
+ for (const w of words) {
108
+ if (!w || typeof w !== 'object') continue
109
+ const word = typeof (w as { word?: unknown }).word === 'string' ? (w as { word: string }).word.trim() : ''
110
+ if (!word) continue
111
+ const p = (w as { probability?: unknown }).probability
112
+ const s = (w as { start?: unknown }).start
113
+ const atMs = startTime !== null && startElapsed !== null && typeof s === 'number' && Number.isFinite(s)
114
+ ? Math.round(startTime + startElapsed + s * 1000) : undefined
115
+ out.push({
116
+ word,
117
+ ...(typeof p === 'number' && Number.isFinite(p) ? { probability: p } : {}),
118
+ ...(atMs !== undefined ? { atMs } : {}),
119
+ ...(engine ? { engine } : {}),
120
+ })
121
+ }
122
+ }
123
+ return out
124
+ }
125
+
126
+ /** How far ahead the aligner looks for a transcript token among the batch words. */
127
+ export const ALIGN_LOOKAHEAD = 40
128
+
129
+ /**
130
+ * Attach probability, time and engine to the tokens the batch words produced.
131
+ *
132
+ * The saved transcript IS those words (selectBatchTranscriptForPersistence joins
133
+ * them; the hallucination line filter may drop a line; Nemotron only relabels), so
134
+ * a greedy forward match finds them in order. A token with no match within the
135
+ * lookahead stays unaligned: it simply carries no confidence, which can only make
136
+ * cleanup look at it less, never more.
137
+ */
138
+ export function alignWordInfo(tokens: Token[], words: readonly WordInfo[]): number {
139
+ let j = 0
140
+ let aligned = 0
141
+ for (const token of tokens) {
142
+ const limit = Math.min(words.length, j + ALIGN_LOOKAHEAD)
143
+ let hit = -1
144
+ for (let k = j; k < limit; k++) {
145
+ if (words[k].word === token.raw || fold(words[k].word) === token.key) { hit = k; break }
146
+ }
147
+ if (hit < 0) continue
148
+ const w = words[hit]
149
+ if (w.probability !== undefined) token.probability = w.probability
150
+ if (w.atMs !== undefined) token.atMs = w.atMs
151
+ if (w.engine) token.engine = w.engine
152
+ aligned++
153
+ j = hit + 1
154
+ }
155
+ return aligned
156
+ }
157
+
158
+ /**
159
+ * A consonant skeleton: how a word sounds with its vowels unsure. Ukaoma, Yukoma,
160
+ * eucoma and "U coma" all read "km". Only ever used together with an onset check and
161
+ * a length bound, because a two-letter skeleton alone matches far too much.
162
+ */
163
+ export function skeleton(word: string): string {
164
+ let s = fold(word).replace(/[0-9]/g, '')
165
+ s = s.replace(/ph/g, 'f').replace(/ck/g, 'k').replace(/c(?=[eiy])/g, 's').replace(/c/g, 'k').replace(/q/g, 'k')
166
+ .replace(/x/g, 'ks').replace(/z/g, 's').replace(/dg/g, 'j')
167
+ s = s.replace(/[aeiouy]/g, '')
168
+ return s.replace(/(.)\1+/g, '$1')
169
+ }
170
+
171
+ /** Vowel onset, counting a leading y before a vowel ("Yukoma") as a vowel. */
172
+ export function vowelOnset(word: string): boolean {
173
+ const f = fold(word)
174
+ return /^[aeiou]/.test(f) || /^y[aeiou]/.test(f)
175
+ }
176
+
177
+ export function maxFuzzyDistance(length: number): number {
178
+ return length <= 8 ? 1 : length <= 11 ? 2 : 3
179
+ }
180
+
181
+ /** True when two folded words sound alike under the skeleton rule. */
182
+ export function soundsAlike(a: string, b: string): boolean {
183
+ const fa = fold(a), fb = fold(b)
184
+ if (!fa || !fb || Math.abs(fa.length - fb.length) > 2) return false
185
+ const sa = skeleton(fa), sb = skeleton(fb)
186
+ if (sa.length < 2 || sa !== sb) return false
187
+ if (vowelOnset(fa) !== vowelOnset(fb)) return false
188
+ const d = levenshtein(fa, fb)
189
+ return d <= Math.max(maxFuzzyDistance(Math.max(fa.length, fb.length)) + 2, Math.ceil(Math.max(fa.length, fb.length) * 0.5))
190
+ }
191
+
192
+ /** Edit distance small enough that two words are plausibly one word misheard. */
193
+ export function closeSpelling(a: string, b: string): boolean {
194
+ const fa = fold(a), fb = fold(b)
195
+ if (!fa || !fb) return false
196
+ if (fa === fb) return true
197
+ const longest = Math.max(fa.length, fb.length)
198
+ return levenshtein(fa, fb) <= Math.max(1, Math.floor(longest / 3))
199
+ }
200
+