@gotcos/glasses-server 6.76.1 → 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.
@@ -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
+ }
@@ -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
+ }