@gotcos/glasses-server 6.91.0 → 6.92.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.
@@ -20,9 +20,22 @@
20
20
  * still loses "uh"), and any quoted mention ('"um"').
21
21
  * - "uh huh", "uh oh" and "mm hmm" written without a hyphen: answers, not hesitations.
22
22
  * - "like", "you know", "I mean": filler only sometimes, so the model decides, not a regex.
23
+ * - 6.92.0: a token that touches a path, handle or code character is part of that token, not a
24
+ * hesitation: "src/um.txt", "~/uh", "@uh", "#um", "$um", "my_um", "um.py", "um:8080", "um/bin".
25
+ * Kept when the character right before it is one of / . @ _ : # $ (a leading - is already
26
+ * outside the token pattern), or the character right after it is one of / @ _ # $, or a . or :
27
+ * followed by a letter or digit. A sentence-final "um." or "um:" (a space or the end after it)
28
+ * still goes.
23
29
  * A filler inside a longer quote ("um, no") is removed like any other.
24
30
  * If nothing but fillers was said, the original text is returned unchanged.
31
+ *
32
+ * 6.92.0: the language gate lives in the caller, not here (`stripFillersIfEnglish`): "um" and "er"
33
+ * are words in German, Portuguese, Danish, Norwegian and Dutch ("um 5 Uhr", "um carro", "Er kommt
34
+ * morgen"). This function stays the pure token rules that the canonical fixture
35
+ * (__fixtures__/dictation-fillers.json, scope "both") pins for COS Control's port.
25
36
  */
37
+ import { fillerTextLooksEnglish } from './dictation-meaning-guard.js'
38
+
26
39
 
27
40
  const FILLER = /(?<![\p{L}\p{N}'’-])(um+|uhm|uh+|erm?|ah+|hm+|mm+|mhm)(?![\p{L}\p{N}'’-])/giu
28
41
  /** After a filler: a pause mark (comma, ellipsis, dash) with its spacing. */
@@ -38,6 +51,10 @@ const QUOTE_OPEN = /["“”'‘’]$/
38
51
  const QUOTE_CLOSE = /^["“”'‘’]/
39
52
  /** A pause mark right after the token: a Titlecase filler set off this way is a hesitation. */
40
53
  const PAUSE_AFTER = /^[ \t]*(?:,|\.{2,}|…)/
54
+ /** 6.92.0: a path, handle or code character right before the token ("~/uh", "@uh", "#um", ".um"). */
55
+ const TOUCH_BEFORE = /[/.@_:#$]$/
56
+ /** 6.92.0: right after it: / @ _ # $ ("um/bin", "um_id"), or . or : with a letter or digit next ("um.py", "um:8080"). */
57
+ const TOUCH_AFTER = /^(?:[/@_#$]|[.:][\p{L}\p{N}])/u
41
58
 
42
59
  function afterAbbreviation(before: string): boolean {
43
60
  const trimmed = before.replace(/[ \t]+$/, '')
@@ -73,6 +90,9 @@ export function stripDisfluencies(text: string): string {
73
90
  const atStart = sentenceStart(out + before)
74
91
  const keep = (token.length > 1 && token === token.toUpperCase())
75
92
  || (/^mm+$/i.test(token) && /\d[ \t]*$/.test(out + before))
93
+ // 6.92.0: part of a path, handle or code token, never a hesitation.
94
+ || TOUCH_BEFORE.test(out + before)
95
+ || TOUCH_AFTER.test(after)
76
96
  // A capital away from a sentence start is a name ("Ask Ah Kow"), unless a pause mark
77
97
  // sets it off as a hesitation ("I think, Um, we should").
78
98
  || (!atStart && /^[A-Z]/.test(token) && !PAUSE_AFTER.test(after))
@@ -114,3 +134,13 @@ export function stripDisfluencies(text: string): string {
114
134
  const result = (out + rest).replace(/^[ \t]*[,;][ \t]*/, '')
115
135
  return result.trim() === '' ? text : result
116
136
  }
137
+
138
+ /**
139
+ * 6.92.0: the server's outbound filler step. The token rules (stripDisfluencies) run only when the
140
+ * text is mostly English (dictation-meaning-guard.ts fillerTextLooksEnglish), so "Wir treffen uns um 5 Uhr",
141
+ * "Comprei um carro" and "Er kommt morgen." go out as said. COS Control gates its own port with
142
+ * its own language detector; that is why the fixture tags these rows scope "server".
143
+ */
144
+ export function stripFillersIfEnglish(text: string): string {
145
+ return fillerTextLooksEnglish(text) ? stripDisfluencies(text) : text
146
+ }
@@ -11,9 +11,16 @@
11
11
  * "cant"), all one NEG token; never, no, none, nothing, nobody, neither, nor, without, unless,
12
12
  * except;
13
13
  * - number: digits, number words, ordinals ("twenty fifth" is 25, like "25th"), %, $, a digit with a
14
- * scale word ("2 million" is two million, like "two million"), clock times ("half past ten" is
15
- * 10:30, "quarter to five" 4:45) and am/pm. A decimal point splits a number on both sides ("one
16
- * point five" and "1.5" are both 1 then 5);
14
+ * scale word ("2 million" is two million, like "two million"), and clock times. A decimal point
15
+ * splits a number on both sides ("one point five" and "1.5" are both 1 then 5);
16
+ * - times (6.91.1): every clock time, spoken or written, in the dictation and in the answer, is read
17
+ * as minutes since midnight: "ten thirty", "half past ten", "quarter to five", "ten o'clock",
18
+ * "10:30", "10:30 am", "ten thirty pm", "22:30", "twenty two thirty". Without am or pm an hour
19
+ * from 1 to 12 reads both ways, so "ten thirty pm" written "22:30" is kept and "ten thirty" written
20
+ * "11:30" is a changed number. am, pm and a trailing ":00" are format only: "at ten" written
21
+ * "at 10:00 am" is kept. A time is kept when the answer has the same time, when it is on the hour
22
+ * and the answer says just that hour (and the dictation said no am or pm), or when every part of
23
+ * it survives as a plain number ("nineteen fifty" written "1950");
17
24
  * - date: a weekday or a month;
18
25
  * - name: a mid-sentence capital, a dictionary term or a speaker name. A sentence-initial word and
19
26
  * "I" are not names unless they are dictionary terms. Skipped for a dictation that is not English.
@@ -52,8 +59,10 @@ export function meaningGuardEnabled(env: NodeJS.ProcessEnv = process.env): boole
52
59
  return !(raw === '0' || raw === 'false' || raw === 'off')
53
60
  }
54
61
 
55
- interface Tok { raw: string; low: string; sentenceStart: boolean; pause: boolean; listMarker: boolean }
56
- interface Item { kind: MeaningGuardKind; key: string; pos: number; list?: boolean }
62
+ interface Tok { raw: string; low: string; sentenceStart: boolean; pause: boolean; listMarker: boolean; colonNext: boolean }
63
+ interface Item { kind: MeaningGuardKind; key: string; pos: number; list?: boolean; inTime?: boolean; time?: TimeSpan }
64
+ /** A clock time: the tokens it covers, its minutes since midnight (two readings without am/pm), its parts. */
65
+ interface TimeSpan { start: number; end: number; times: number[]; explicit: boolean; parts: string[] }
57
66
 
58
67
  const ONES = new Map(['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve',
59
68
  'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'].map((w, i) => [w, i]))
@@ -148,7 +157,8 @@ function tokenize(text: string): Tok[] {
148
157
  const sentenceStart = before.length === 0 || /[.!?:\n•]$/.test(before) || /\n\s*(?:[-*•]|\d+[.)])?\s*$/.test(text.slice(0, m.index))
149
158
  // "1." or "2)" at the start of a line: a list number.
150
159
  const listMarker = /^\d+$/.test(m[0]) && /(?:^|\n)[ \t]*$/.test(text.slice(0, m.index)) && /^[.)]/.test(text.slice(m.index + m[0].length))
151
- out.push({ raw: m[0], low: m[0].toLowerCase(), sentenceStart, pause: /[,;.!?—–()]/.test(gap), listMarker })
160
+ const colonNext = /^:\d/.test(text.slice(m.index + m[0].length))
161
+ out.push({ raw: m[0], low: m[0].toLowerCase(), sentenceStart, pause: /[,;.!?—–()]/.test(gap), listMarker, colonNext })
152
162
  last = m.index + m[0].length
153
163
  }
154
164
  return out
@@ -230,6 +240,74 @@ function numberRun(tokens: Tok[], i: number): { value: number; end: number } | n
230
240
  return { value: total + group, end: j - 1 }
231
241
  }
232
242
 
243
+ const MERIDIEM = new Set(['am', 'pm'])
244
+
245
+ /** Minutes since midnight: one reading with am/pm or a 24-hour hour, both readings for 1 to 12 without. */
246
+ function clockMinutes(hour: number, minute: number, meridiem: string | undefined): { times: number[]; explicit: boolean } {
247
+ if (meridiem && hour >= 1 && hour <= 12) return { times: [((hour % 12) + (meridiem === 'pm' ? 12 : 0)) * 60 + minute], explicit: true }
248
+ if (hour >= 1 && hour <= 12) return { times: [(hour % 12) * 60 + minute, ((hour % 12) + 12) * 60 + minute], explicit: false }
249
+ return { times: [hour * 60 + minute], explicit: true }
250
+ }
251
+
252
+ /** The readings of a bare hour ("ten" or "10") as a time on the hour. */
253
+ function hourReadings(n: number): number[] {
254
+ if (n >= 1 && n <= 12) return [(n % 12) * 60, ((n % 12) + 12) * 60]
255
+ return n === 0 || (n >= 13 && n <= 23) ? [n * 60] : []
256
+ }
257
+
258
+ /** A spoken hour: one to nineteen, twenty, twenty one to twenty three. */
259
+ function spokenHour(tokens: Tok[], i: number): { value: number; end: number } | null {
260
+ const w = tokens[i]?.low ?? ''
261
+ const v = ONES.get(w)
262
+ if (v !== undefined) return v >= 1 ? { value: v, end: i } : null
263
+ if (w !== 'twenty') return null
264
+ const ones = ONES.get(tokens[i + 1]?.low ?? '')
265
+ return ones !== undefined && ones >= 1 && ones <= 3 ? { value: 20 + ones, end: i + 1 } : { value: 20, end: i }
266
+ }
267
+
268
+ /** Spoken minutes after an hour: "oh five", ten to nineteen, twenty to fifty nine. */
269
+ function spokenMinutes(tokens: Tok[], i: number): { value: number; end: number } | null {
270
+ const w = tokens[i]?.low ?? ''
271
+ const next = ONES.get(tokens[i + 1]?.low ?? '')
272
+ if (w === 'oh' || w === 'o') return next !== undefined && next >= 1 && next <= 9 ? { value: next, end: i + 1 } : null
273
+ const v = ONES.get(w)
274
+ if (v !== undefined) return v >= 10 ? { value: v, end: i } : null
275
+ const tens = TENS.get(w)
276
+ if (tens === undefined || tens > 50) return null
277
+ return next !== undefined && next >= 1 && next <= 9 ? { value: tens + next, end: i + 1 } : { value: tens, end: i }
278
+ }
279
+
280
+ /** One clock time starting at token i, or null. An hour alone is not a time; it needs minutes or am/pm. */
281
+ function timeAt(tokens: Tok[], i: number): Omit<TimeSpan, 'parts'> | null {
282
+ const t = tokens[i]
283
+ let hour: number, end: number
284
+ if (/^\d{1,2}$/.test(t.low)) { hour = Number.parseInt(t.low, 10); end = i }
285
+ else { const h = spokenHour(tokens, i); if (!h) return null; hour = h.value; end = h.end }
286
+ let minute = 0, hasMinutes = false
287
+ const next = tokens[end + 1]?.low ?? ''
288
+ if (tokens[end].colonNext && /^\d{2}$/.test(next)) { minute = Number.parseInt(next, 10); end++; hasMinutes = true }
289
+ else if (!/^\d/.test(t.low)) { const m = spokenMinutes(tokens, end + 1); if (m) { minute = m.value; end = m.end; hasMinutes = true } }
290
+ const meridiem = MERIDIEM.has(tokens[end + 1]?.low ?? '') ? tokens[end + 1].low : undefined
291
+ if (!hasMinutes && !meridiem) return null
292
+ if (meridiem) end++
293
+ if (hour > 23 || minute > 59) return null
294
+ const { times, explicit } = clockMinutes(hour, minute, meridiem)
295
+ return { start: i, end, times, explicit }
296
+ }
297
+
298
+ /** Every clock time in the text, as time items; the number items inside one become its parts. */
299
+ function addTimes(tokens: Tok[], items: Item[]): void {
300
+ for (let i = 0; i < tokens.length; i++) {
301
+ const found = timeAt(tokens, i)
302
+ if (!found) continue
303
+ const parts = items.filter(it => it.kind === 'number' && it.pos >= found.start && it.pos <= found.end)
304
+ parts.forEach(it => { it.inTime = true })
305
+ const time: TimeSpan = { ...found, parts: parts.map(it => it.key) }
306
+ items.push({ kind: 'number', key: `time:${time.times.join('/')}`, pos: found.start, time })
307
+ i = found.end
308
+ }
309
+ }
310
+
233
311
  interface Extract { items: Item[]; tokens: Tok[] }
234
312
 
235
313
  function extract(text: string, knownPhrases: string[][], withNames: boolean): Extract {
@@ -281,6 +359,7 @@ function extract(text: string, knownPhrases: string[][], withNames: boolean): Ex
281
359
  if (!t.sentenceStart || midCapital.has(fold(t.low))) items.push({ kind: 'name', key: fold(t.low), pos: i })
282
360
  }
283
361
  }
362
+ addTimes(tokens, items)
284
363
  // Dictionary terms and speaker names, in any case and position, as whole phrases.
285
364
  if (withNames) {
286
365
  for (const phrase of knownPhrases) {
@@ -317,6 +396,53 @@ export function looksEnglish(text: string): boolean {
317
396
  return words.filter(w => ENGLISH.has(w)).length / words.length >= 0.12
318
397
  }
319
398
 
399
+ /**
400
+ * 6.92.0: common function words of German, Portuguese, Spanish, French and Dutch that are not
401
+ * English words, not name particles or place words ("de", "la", "von", "Los", "Las") and not words
402
+ * dictation says in English ("dot com", "em dash", "est", "eu"). Evidence that a short text is not
403
+ * English. An all-caps token ("MIT") never counts.
404
+ */
405
+ const FOREIGN = new Set(['der', 'das', 'ein', 'eine', 'einen', 'einem', 'einer', 'und', 'ist', 'sind', 'nicht', 'ich', 'wir', 'ihr',
406
+ 'sie', 'mit', 'uns', 'auf', 'für', 'uhr', 'auch', 'noch', 'oder', 'aber', 'wie', 'kommt', 'heute', 'morgen', 'bitte', 'danke',
407
+ 'haben', 'wird', 'kann', 'zum', 'zur', 'bei', 'nach', 'ja', 'nein',
408
+ 'uma', 'não', 'nao', 'por', 'que', 'você', 'voce', 'ele', 'ela', 'nós', 'isso', 'este', 'esta', 'muito', 'obrigado', 'obrigada',
409
+ 'amanhã', 'hoje',
410
+ 'una', 'unos', 'está', 'pero', 'muy', 'gracias', 'mañana', 'hoy',
411
+ 'une', 'je', 'nous', 'vous', 'avec', 'pour', 'pas', 'mais', 'oui', 'merci',
412
+ 'het', 'een', 'ik', 'niet', 'wij', 'voor'])
413
+ /** 6.92.0: an English filler sound that is also a word in German, Portuguese, Danish, Norwegian or Dutch. */
414
+ const AMBIGUOUS_FILLERS = new Set(['um', 'er'])
415
+ /**
416
+ * 6.92.0 QA: English evidence beyond the function words, for short replies and commands ("um yes",
417
+ * "er no", "um thanks Dana", "um sounds good", "um Tuesday", "um 5 pm works"). None is a word of
418
+ * the languages above.
419
+ */
420
+ const SHORT_REPLY_ENGLISH = new Set(['yes', 'no', 'yeah', 'yep', 'ok', 'okay', 'sure', 'thanks', 'thank', 'please', 'sorry', 'hi', 'hey',
421
+ 'hello', 'good', 'great', 'fine', 'cool', 'done', 'right', 'maybe', 'sounds', 'call', 'send', 'tell', 'ask', 'am', 'pm',
422
+ 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday',
423
+ 'january', 'february', 'march', 'april', 'june', 'july', 'august', 'september', 'october', 'november', 'december'])
424
+
425
+ /**
426
+ * 6.92.0: the filler pass's language gate (dictation-fillers.ts stripFillersIfEnglish), separate
427
+ * from looksEnglish, which the meaning guard keeps exactly as 6.91.1 had it. Not English when
428
+ * looksEnglish says so (a long text with few English function words, or mostly non-Latin letters).
429
+ * Under 12 words: with a foreign function word in it, English words (function words or the short
430
+ * reply list) must outnumber the foreign ones ("Wir treffen uns um 5 Uhr" is not English); with "um"
431
+ * or "er" said inline (no pause after it), at least one English word must be there ("Comprei um
432
+ * carro" and "Er kommt morgen." keep their um and er; "um yes" and "ship it er tomorrow" do not).
433
+ */
434
+ export function fillerTextLooksEnglish(text: string): boolean {
435
+ if (!looksEnglish(text)) return false
436
+ const tokens = tokenize(text)
437
+ if (tokens.length > 11) return true
438
+ const english = tokens.filter(t => ENGLISH.has(t.low) || SHORT_REPLY_ENGLISH.has(t.low)).length
439
+ const foreign = tokens.filter(t => FOREIGN.has(t.low) && !(t.raw.length > 1 && t.raw === t.raw.toUpperCase())).length
440
+ if (foreign > 0) return english > foreign
441
+ const inlineAmbiguous = tokens.some((t, i) => AMBIGUOUS_FILLERS.has(t.low) && i + 1 < tokens.length && !tokens[i + 1].pause)
442
+ if (inlineAmbiguous) return english > 0
443
+ return true
444
+ }
445
+
320
446
  const zero = (): Record<MeaningGuardKind, number> => ({ negation: 0, number: 0, date: 0, name: 0 })
321
447
 
322
448
  /** How many of each negation, a stutter (the same one twice in a row) counted once. */
@@ -390,13 +516,35 @@ export function meaningGuardProblem(cleaned: string, answer: string, known: stri
390
516
  return respelled
391
517
  }
392
518
 
519
+ // 6.91.1: clock times, compared as minutes since midnight (see the header).
520
+ const inTimes = input.items.filter(i => i.time).map(i => i.time!)
521
+ const outTimes = output.items.filter(i => i.time).map(i => i.time!)
522
+ const bareHours = (items: Item[]) => items.filter(i => i.kind === 'number' && !i.time && !i.inTime && /^\d+$/.test(i.key)).map(i => Number(i.key))
523
+ const inBare = bareHours(input.items)
524
+ const outBare = bareHours(output.items)
525
+ const sameTime = (a: TimeSpan, b: TimeSpan) => a.times.some(t => b.times.includes(t))
526
+ // A bare hour matches a time only on that hour: its readings are all on the hour.
527
+ const onTheHour = (time: TimeSpan, n: number) => hourReadings(n).some(t => time.times.includes(t))
528
+ const numberKept = (key: string) => outKeys.get('number')!.has(key) || keptByJoin.has(key)
529
+ const timeKept = (time: TimeSpan) => outTimes.some(o => sameTime(time, o))
530
+ || (!time.explicit && outBare.some(n => onTheHour(time, n)))
531
+ || time.parts.every(numberKept)
532
+ // A time in the answer is not new when the dictation had it, had its hour ("at ten" as "10:00 am"),
533
+ // or had its parts as numbers. am, pm and ":00" are format only.
534
+ const timeAccounted = (time: TimeSpan) => inTimes.some(i => sameTime(i, time))
535
+ || inBare.some(n => onTheHour(time, n))
536
+ || time.parts.filter(p => !MERIDIEM.has(p) && p !== '0').every(p => inKeys.get('number')!.has(p))
537
+ const kept = (item: Item): boolean => item.kind === 'name' ? nameKept(item.key)
538
+ : item.time ? timeKept(item.time)
539
+ : item.kind === 'number' ? numberKept(item.key) || (/^\d+$/.test(item.key) && outTimes.some(o => onTheHour(o, Number(item.key))))
540
+ : outKeys.get(item.kind)!.has(item.key)
541
+
393
542
  // A dropped token with a correction marker after it and a same-kind replacement the answer kept.
394
543
  const exempt = (item: Item): boolean => {
395
544
  if (item.kind === 'negation') return false
396
545
  for (const { start, end, reach } of spans) {
397
546
  if (start <= item.pos || start - item.pos > DROP_TO_MARKER) continue
398
- const replaced = input.items.some(r => r.kind === item.kind && r.key !== item.key && r.pos > end && r.pos - end <= reach
399
- && (r.kind === 'name' ? nameKept(r.key) : outKeys.get(r.kind)!.has(r.key)))
547
+ const replaced = input.items.some(r => r.kind === item.kind && r.key !== item.key && r.pos > end && r.pos - end <= reach && kept(r))
400
548
  if (replaced) return true
401
549
  }
402
550
  return false
@@ -414,9 +562,9 @@ export function meaningGuardProblem(cleaned: string, answer: string, known: stri
414
562
  const id = `${item.kind}:${item.key}`
415
563
  if (seen.has(id)) continue
416
564
  if (item.kind === 'negation' && countNegations) continue
417
- const kept = item.kind === 'name' ? nameKept(item.key)
418
- : outKeys.get(item.kind)!.has(item.key) || (item.kind === 'number' && keptByJoin.has(item.key))
419
- if (kept) { seen.add(id); continue }
565
+ // A number inside a clock time is judged with its time.
566
+ if (item.inTime) continue
567
+ if (kept(item)) { seen.add(id); continue }
420
568
  // Every occurrence must be exempt for a drop to be excused.
421
569
  const occurrences = input.items.filter(i => i.kind === item.kind && i.key === item.key)
422
570
  seen.add(id)
@@ -437,7 +585,9 @@ export function meaningGuardProblem(cleaned: string, answer: string, known: stri
437
585
  const outNumberItems = output.items.filter(i => i.kind === 'number')
438
586
  for (const key of outNumbers) if (outNumberItems.filter(i => i.key === key).every(i => i.list)) listOnly.add(key)
439
587
  }
440
- for (const key of outNumbers) if (!inKeys.get('number')!.has(key) && !joinedOut.has(key) && !listOnly.has(key)) added.number++
588
+ const plainOut = new Set(output.items.filter(i => i.kind === 'number' && !i.time && !i.inTime).map(i => i.key))
589
+ for (const key of plainOut) if (!inKeys.get('number')!.has(key) && !joinedOut.has(key) && !listOnly.has(key)) added.number++
590
+ for (const time of outTimes) if (!timeAccounted(time)) added.number++
441
591
 
442
592
  const kind = MEANING_GUARD_KINDS.find(k => dropped[k] > 0 || added[k] > 0)
443
593
  return kind ? { kind, dropped, added, namesSkipped: !withNames } : null
@@ -5,7 +5,7 @@ import { applyFuzzyCorrections } from './fuzzy-correct.js'
5
5
  import { getAllSpeakerNames } from './speaker-embeddings.js'
6
6
  import { autoCleanDictation, AUTOCLEAN_MAX_CHARS } from './dictation-clean.js'
7
7
  import { dictationPolishPromptVersion, styleAndRegisterApply, type DictationPolishPromptVersion } from './dictation-polish-options.js'
8
- import { fillerPassEnabled, stripDisfluencies } from './dictation-fillers.js'
8
+ import { fillerPassEnabled, stripFillersIfEnglish } from './dictation-fillers.js'
9
9
  import { polishBudgetExhausted, recordPolishCall, type PolishBucket } from './dictation-polish-budget.js'
10
10
  import { polishNeed, skipCleanEnabled } from './dictation-polish-need.js'
11
11
  import { discardPolishPrewarm, PrewarmUnavailableError, runWarmPolish, takePolishPrewarm } from './dictation-prewarm.js'
@@ -16,7 +16,7 @@ import { customInstructionsHash, readCustomInstructions } from './dictation-poli
16
16
  import { learnedStyleEnabled, learnedStyleLinesFor, readLearnedStyle } from './dictation-learned-style.js'
17
17
  import { literalAnswer, type DictationRegister } from './dictation-register.js'
18
18
  import { meaningGuardCounts, meaningGuardEnabled, meaningGuardProblem, type MeaningGuardKind } from './dictation-meaning-guard.js'
19
- import { createBreaker } from './claude-circuit.js'
19
+ import { createBreaker, type ClaudeBreaker } from './claude-circuit.js'
20
20
  import { logTokenAudit } from './token-audit.js'
21
21
 
22
22
  /**
@@ -34,10 +34,60 @@ import { logTokenAudit } from './token-audit.js'
34
34
  * register, no instructions, no learned style, no meaning guard.
35
35
  */
36
36
 
37
- export const autoCleanBreakers = {
38
- sonnet: createBreaker({ label: 'dictation-autoclean-sonnet' }),
39
- 'luna-5.6-fast': createBreaker({ label: 'dictation-autoclean-luna' }),
40
- haiku: createBreaker({ label: 'dictation-autoclean-haiku' }),
37
+ /**
38
+ * 6.92.0 (plan r3, A-W0a): the breakers are keyed by (model, bucket group). Meeting lines have their
39
+ * own, so a G2 meeting that fails can no longer turn off Mac dictation and G2 message polish; message
40
+ * and prompt_draft share the `dictation` group. Each key has two breakers:
41
+ *
42
+ * - failure: 2 consecutive CLI failures open it for 30 minutes, as before. A client abort (the
43
+ * request closed, `signal.aborted`) is neutral: it neither counts nor resets.
44
+ * - slow: COS_DICTATION_SLOW_BREAKER_ABORTS (default 4) consecutive aborts, each after at least
45
+ * SLOW_ABORT_MIN_MODEL_MS of model time, with no success between, open it for
46
+ * COS_DICTATION_SLOW_BREAKER_MS (default 300000, 5 minutes). An abort sooner than that (a disconnect
47
+ * right after a sidecar restart) does not count.
48
+ *
49
+ * Either one open skips the polish as `breaker_open`; any success closes both.
50
+ */
51
+ export type PolishBreakerGroup = 'dictation' | 'meeting'
52
+ export const SLOW_ABORT_MIN_MODEL_MS = 4_000
53
+
54
+ export function breakerGroupFor(bucket: PolishBucket): PolishBreakerGroup {
55
+ return bucket === 'meeting' ? 'meeting' : 'dictation'
56
+ }
57
+
58
+ function envPositiveInt(raw: string | undefined, fallback: number): number {
59
+ const trimmed = raw?.trim() ?? ''
60
+ if (!/^\d+$/.test(trimmed)) return fallback
61
+ const value = Number(trimmed)
62
+ return value > 0 ? value : fallback
63
+ }
64
+ export const slowBreakerAborts = (env: NodeJS.ProcessEnv = process.env) => envPositiveInt(env.COS_DICTATION_SLOW_BREAKER_ABORTS, 4)
65
+ export const slowBreakerMs = (env: NodeJS.ProcessEnv = process.env) => envPositiveInt(env.COS_DICTATION_SLOW_BREAKER_MS, 300_000)
66
+
67
+ interface PolishBreakers { failure: ClaudeBreaker; slow: ClaudeBreaker }
68
+ const BREAKER_MODELS = ['sonnet', 'luna-5.6-fast', 'haiku'] as const
69
+ function breakersFor(group: PolishBreakerGroup): Record<DictationCleanModel, PolishBreakers> {
70
+ return Object.fromEntries(BREAKER_MODELS.map(model => {
71
+ const label = `dictation-autoclean-${model === 'luna-5.6-fast' ? 'luna' : model}${group === 'meeting' ? '-meeting' : ''}`
72
+ return [model, {
73
+ failure: createBreaker({ label }),
74
+ slow: createBreaker({ label: `${label}-slow`, maxFailures: () => slowBreakerAborts(), cooldownMs: () => slowBreakerMs() }),
75
+ }]
76
+ })) as Record<DictationCleanModel, PolishBreakers>
77
+ }
78
+ export const autoCleanBreakers: Record<PolishBreakerGroup, Record<DictationCleanModel, PolishBreakers>> = {
79
+ dictation: breakersFor('dictation'),
80
+ meeting: breakersFor('meeting'),
81
+ }
82
+
83
+ export function polishBreakersFor(model: DictationCleanModel, bucket: PolishBucket): PolishBreakers {
84
+ return autoCleanBreakers[breakerGroupFor(bucket)][model]
85
+ }
86
+
87
+ /** The keyed breakers for this model and bucket: open when either is. The prewarm route asks this too. */
88
+ export function polishBreakerOpen(model: DictationCleanModel, bucket: PolishBucket): boolean {
89
+ const breakers = polishBreakersFor(model, bucket)
90
+ return breakers.failure.isOpen() || breakers.slow.isOpen()
41
91
  }
42
92
  const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
43
93
 
@@ -155,7 +205,8 @@ export function prepareOutboundDictation(text: string, bucket: PolishBucket): st
155
205
  console.warn(`[prompt-draft] Fuzzy correction failed (non-fatal): ${fuzzyErr?.message ?? fuzzyErr}`)
156
206
  }
157
207
  // 6.79.1: um/uh never survive a skipped polish. Meeting lines stay faithful.
158
- if (bucket !== 'meeting' && fillerPassEnabled()) cleaned = stripDisfluencies(cleaned)
208
+ // 6.92.0: only when the text is mostly English ("um 5 Uhr" and "um carro" are words).
209
+ if (bucket !== 'meeting' && fillerPassEnabled()) cleaned = stripFillersIfEnglish(cleaned)
159
210
  // 6.83.1: "forward slash pricing," is "/pricing," even when the model is skipped. One word
160
211
  // at a phrase end only; slugs of several words are the model's (dictation-format.ts).
161
212
  if (bucket !== 'meeting' && spokenPathsEnabled()) cleaned = renderSpokenPaths(cleaned)
@@ -221,11 +272,11 @@ export async function polishOutboundDictation(
221
272
  const cleaned = prepareOutboundDictation(text, opts.bucket)
222
273
  if (!(opts.enabled ?? autoCleanDefaultEnabled())) return { text: cleaned, polishSkipped: 'disabled' }
223
274
  const model = opts.model ?? 'haiku'
224
- const autoCleanBreaker = autoCleanBreakers[model]
275
+ const breakers = polishBreakersFor(model, opts.bucket)
225
276
  if (cleaned.length > AUTOCLEAN_MAX_CHARS) return { text: cleaned, polishSkipped: 'too_long' }
226
277
  // 6.79.2, off by default: nothing for the model to fix (see dictation-polish-need.ts).
227
278
  if (skipCleanEnabled() && polishNeed(cleaned) === null) return { text: cleaned, polishSkipped: 'clean' }
228
- if (autoCleanBreaker.isOpen()) return { text: cleaned, polishSkipped: 'breaker_open' }
279
+ if (polishBreakerOpen(model, opts.bucket)) return { text: cleaned, polishSkipped: 'breaker_open' }
229
280
  if (polishBudgetExhausted(opts.bucket)) return { text: cleaned, polishSkipped: 'daily_cap' }
230
281
  const startedAt = Date.now()
231
282
  recordPolishCall(opts.bucket)
@@ -250,6 +301,11 @@ export async function polishOutboundDictation(
250
301
  // 6.79.2: a warm process started at key press answers without the CLI's start-up cost.
251
302
  const warmEntry = opts.prewarmToken ? takePolishPrewarm(opts.prewarmToken, model) : null
252
303
  let prewarm: PrewarmOutcome | undefined = opts.prewarmToken ? 'missed' : undefined
304
+ // 6.92.0 QA S1: the model time of an abort is stamped when the abort happens, not in the catch,
305
+ // which runs only after the provider process is terminated (up to 4 s on the cold path).
306
+ let abortedAt: number | null = null
307
+ const onAbort = () => { abortedAt = Date.now() }
308
+ opts.signal?.addEventListener('abort', onAbort, { once: true })
253
309
  try {
254
310
  let polished: string | null = null
255
311
  if (warmEntry) {
@@ -266,7 +322,8 @@ export async function polishOutboundDictation(
266
322
  // one; this holds if either ever stops): the input goes out, polishSkipped "failed", and the
267
323
  // breaker counts it. Never "unchanged".
268
324
  if (!polished) throw new Error('Auto-clean returned empty text')
269
- autoCleanBreaker.recordSuccess()
325
+ breakers.failure.recordSuccess()
326
+ breakers.slow.recordSuccess()
270
327
  const guarded = guardPolishAnswer(cleaned, polished, opts.bucket, register, canaried)
271
328
  logTokenAudit({
272
329
  source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: polished.length,
@@ -285,8 +342,29 @@ export async function polishOutboundDictation(
285
342
  modelText: polished, ...used,
286
343
  }
287
344
  } catch (err: any) {
288
- autoCleanBreaker.recordFailure()
289
- console.warn(`[prompt-draft] auto-clean failed (glossary-only): ${err?.message ?? err}`)
345
+ if (opts.signal?.aborted) {
346
+ // 6.92.0: the client went away (Control stopped waiting, the phone closed the request). Not a
347
+ // CLI failure: only the slow breaker hears it, and only after real model time. The audit row
348
+ // says `aborted`, codes only; the answer (nobody reads it) keeps PolishOutcome's three values.
349
+ const durationMs = (abortedAt ?? Date.now()) - startedAt
350
+ if (durationMs >= SLOW_ABORT_MIN_MODEL_MS) breakers.slow.recordFailure()
351
+ logTokenAudit({
352
+ source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: 0,
353
+ durationMs, caller: `dictation_autoclean_${opts.bucket}`,
354
+ // Key order differs from the answered row's on purpose: each gate anchor matches one row.
355
+ polishInstructions: { hash: instructionsHash, on: Boolean(customInstructions) },
356
+ learnedStyle: { hash: style.hash, on: Boolean(style.lines) },
357
+ ...(canaried ? { polishRegister: register } : {}),
358
+ ...(used.polishPrompt ? { polishPrompt: used.polishPrompt } : {}),
359
+ polishOutcome: 'aborted',
360
+ })
361
+ console.warn('[prompt-draft] auto-clean aborted by the client (glossary-only)')
362
+ } else {
363
+ breakers.failure.recordFailure()
364
+ console.warn(`[prompt-draft] auto-clean failed (glossary-only): ${err?.message ?? err}`)
365
+ }
290
366
  return { text: cleaned, polishSkipped: 'failed', ...(prewarm ? { prewarm } : {}), ...used }
367
+ } finally {
368
+ opts.signal?.removeEventListener('abort', onAbort)
291
369
  }
292
370
  }
@@ -1,5 +1,13 @@
1
1
  import { spawn } from 'node:child_process'
2
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
2
4
  import { claudeKeySecretsGuardArgs } from './key-secrets-guard.js'
5
+ import { runClaudeText } from './claude-text-runner.js'
6
+ import { createBreaker } from './claude-circuit.js'
7
+ import { readSwitch } from './env-switch.js'
8
+ import { atomicWriteFileSync } from './atomic-fs.js'
9
+ import { dataPath } from './data-dir.js'
10
+ import { localDay } from './dictation-polish-budget.js'
3
11
 
4
12
  export const PROMPT_EDIT_DRAFT_MAX_CHARS = 24_000
5
13
  export const PROMPT_EDIT_INSTRUCTION_MAX_CHARS = 2_000
@@ -178,17 +186,90 @@ export function spawnClaudeText(prompt: string, opts: SpawnClaudeTextOptions = {
178
186
  })
179
187
  }
180
188
 
189
+ export const PROMPT_EDIT_SYSTEM_PROMPT = 'You revise dictated prompt drafts. Output only the revised prompt text.'
190
+ export const promptEditModel = (env: NodeJS.ProcessEnv = process.env) => env.COS_PROMPT_EDIT_MODEL || 'sonnet'
191
+
192
+ /**
193
+ * G2's prompt edit (POST /api/prompt-edit). 6.92.0: through the isolated text runner
194
+ * (claude-text-runner.ts: no tools, MCP, hooks or session, a scratch directory), with the same
195
+ * model, effort and 45 s timeout settings as before.
196
+ */
181
197
  export async function applyPromptEdit(input: PromptEditInput, signal?: AbortSignal): Promise<string> {
182
- return spawnClaudeText(buildPromptEditPrompt(input), {
183
- model: process.env.COS_PROMPT_EDIT_MODEL || 'sonnet',
198
+ return runClaudeText(buildPromptEditPrompt(input), {
199
+ model: promptEditModel(),
184
200
  effort: process.env.COS_PROMPT_EDIT_EFFORT || 'low',
185
201
  timeoutMs: Number.parseInt(process.env.COS_PROMPT_EDIT_TIMEOUT_MS || '45000', 10),
186
- systemPrompt: 'You revise dictated prompt drafts. Output only the revised prompt text.',
202
+ systemPrompt: PROMPT_EDIT_SYSTEM_PROMPT,
187
203
  label: 'Prompt edit',
188
204
  signal,
189
205
  })
190
206
  }
191
207
 
208
+ // ── 6.92.0 (plan r3, A-W0b): how prompt edit stops ───────────────────
209
+ //
210
+ // Every `claude -p` caller answers how it stops. Prompt edit had no answer through 6.91.1:
211
+ // - master switch: COS_PROMPT_EDIT (readSwitch; on by default, 0/false/off answers 503);
212
+ // - daily cap: COS_PROMPT_EDIT_DAILY_CAP (default 200 a local day) in its OWN counter file, never
213
+ // the dictation polish counter, so editing a prompt never spends Mac dictation's budget;
214
+ // - breaker: 3 consecutive CLI failures open it for 10 minutes; a client abort is neutral;
215
+ // - an audit row per call that ran (caller prompt_edit, codes and sizes only).
216
+ // Refusals are text/plain bodies of 24 characters or fewer, so G2 6.10.654 renders
217
+ // "Edit failed.\nAPI <status>: <body>" in 40 characters or fewer.
218
+
219
+ export const PROMPT_EDIT_REFUSALS = {
220
+ cap: { status: 429, body: 'Edit limit reached' },
221
+ off: { status: 503, body: 'Edit is off' },
222
+ paused: { status: 503, body: 'Edit paused' },
223
+ failed: { status: 500, body: 'Edit failed' },
224
+ } as const
225
+ export type PromptEditRefusal = keyof typeof PROMPT_EDIT_REFUSALS
226
+
227
+ export const PROMPT_EDIT_DEFAULT_DAILY_CAP = 200
228
+ export const PROMPT_EDIT_BREAKER_FAILURES = 3
229
+ export const PROMPT_EDIT_BREAKER_MS = 10 * 60 * 1000
230
+ export const PROMPT_EDIT_COUNT_FILE = '.prompt_edit_count.json'
231
+
232
+ export function promptEditEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
233
+ return readSwitch(env.COS_PROMPT_EDIT, true)
234
+ }
235
+
236
+ export function promptEditDailyCap(env: NodeJS.ProcessEnv = process.env): number {
237
+ const trimmed = env.COS_PROMPT_EDIT_DAILY_CAP?.trim() ?? ''
238
+ if (!/^\d+$/.test(trimmed)) return PROMPT_EDIT_DEFAULT_DAILY_CAP
239
+ const value = Number(trimmed)
240
+ return value > 0 ? value : PROMPT_EDIT_DEFAULT_DAILY_CAP
241
+ }
242
+
243
+ export const promptEditBreaker = createBreaker({
244
+ label: 'prompt-edit', maxFailures: PROMPT_EDIT_BREAKER_FAILURES, cooldownMs: PROMPT_EDIT_BREAKER_MS,
245
+ })
246
+
247
+ const countFile = () => dataPath(PROMPT_EDIT_COUNT_FILE)
248
+
249
+ export function promptEditCountToday(): number {
250
+ try {
251
+ const raw = JSON.parse(readFileSync(countFile(), 'utf8'))
252
+ if (raw?.date !== localDay() || !Number.isInteger(raw.count) || raw.count < 0) return 0
253
+ return raw.count
254
+ } catch { return 0 }
255
+ }
256
+
257
+ export function recordPromptEditCall(): void {
258
+ try {
259
+ const file = countFile()
260
+ if (!existsSync(dirname(file))) mkdirSync(dirname(file), { recursive: true })
261
+ atomicWriteFileSync(file, JSON.stringify({ date: localDay(), count: promptEditCountToday() + 1 }), { mode: 0o600 })
262
+ } catch {}
263
+ }
264
+
265
+ /** The refusal for this request, before any call, or null when the edit may run. Switch, breaker, cap. */
266
+ export function promptEditRefusal(env: NodeJS.ProcessEnv = process.env): PromptEditRefusal | null {
267
+ if (!promptEditEnabled(env)) return 'off'
268
+ if (promptEditBreaker.isOpen()) return 'paused'
269
+ if (promptEditCountToday() >= promptEditDailyCap(env)) return 'cap'
270
+ return null
271
+ }
272
+
192
273
  // ── Outbound dictation auto-clean ────────────────────────────────────
193
274
  export const AUTOCLEAN_MAX_CHARS = 8_000
194
275
 
@@ -42,9 +42,12 @@ export interface TokenAuditEntry extends TokenAuditKeyFields {
42
42
  polishRegister?: string
43
43
  /** 6.91.0, dictation polish only: the prompt that ran (v3 or v4). */
44
44
  polishPrompt?: string
45
- /** 6.91.0, dictation polish only: what became of the model's answer (polished, unchanged, guarded) and the guard kind. */
45
+ /** 6.91.0, dictation polish only: what became of the model's answer (polished, unchanged, guarded) and the guard kind.
46
+ * 6.92.0: `aborted` (audit only, never in a finalize answer) when the client went away mid-call. */
46
47
  polishOutcome?: string
47
48
  polishGuard?: string
49
+ /** 6.92.0, prompt edit only: ok, failed or aborted. */
50
+ promptEditOutcome?: 'ok' | 'failed' | 'aborted'
48
51
  }
49
52
 
50
53
  /**
@@ -86,6 +89,8 @@ export function buildTokenAuditRecord(entry: TokenAuditEntry, now: Date = new Da
86
89
  ...(entry.polishPrompt ? { polish_prompt: entry.polishPrompt } : {}),
87
90
  ...(entry.polishOutcome ? { polish_outcome: entry.polishOutcome } : {}),
88
91
  ...(entry.polishGuard ? { polish_guard: entry.polishGuard } : {}),
92
+ // 6.92.0: set only by the prompt-edit caller.
93
+ ...(entry.promptEditOutcome ? { prompt_edit_outcome: entry.promptEditOutcome } : {}),
89
94
  }
90
95
  }
91
96
 
@@ -46,6 +46,7 @@ import {
46
46
  } from '../lib/dictation-polish-options.js'
47
47
  import { LEARNED_STYLE_INTRO, LEARNED_STYLE_LIMITS, learnedStyleBlock, LITERAL_REGISTER_RULES, V4_CLOSING_RULE, V4_LITERAL_CLOSING_RULE } from '../lib/dictation-clean.js'
48
48
  import { publishedRegisters, registersEnabled } from '../lib/dictation-register.js'
49
+ import { meaningGuardEnabled } from '../lib/dictation-meaning-guard.js'
49
50
 
50
51
  /** Where the instructions apply (prompt-drafts.ts polishInstructionsFor): never meeting lines. */
51
52
  export const CUSTOM_INSTRUCTIONS_APPLY_TO = ['message', 'prompt_draft'] as const
@@ -248,11 +249,17 @@ dictationPolishSettingsRouter.delete('/dictation/learned-style', (_req, res) =>
248
249
  function optionsView() {
249
250
  const stored = readPolishOptions()
250
251
  const choice = dictationPolishPromptChoice()
252
+ const effectiveByModel = Object.fromEntries(POLISH_MODEL_DEFAULTS.map(model => [model, dictationPolishPromptChoice(process.env, model).version]))
251
253
  return {
252
254
  promptVersion: stored.promptVersion,
253
255
  effectiveVersion: choice.version,
254
256
  // 6.91.0 option C: the prompt each model actually gets.
255
- effectiveByModel: Object.fromEntries(POLISH_MODEL_DEFAULTS.map(model => [model, dictationPolishPromptChoice(process.env, model).version])),
257
+ effectiveByModel,
258
+ // 6.92.0 (plan r3, A-W2): what Control's picker line needs, additive. The pairs the meaning guard,
259
+ // Your style and the register apply to (the same list GET /rules computes), and whether the
260
+ // meaning guard is on (COS_DICTATION_MEANING_GUARD). Older servers omit both.
261
+ stylePairs: POLISH_MODEL_DEFAULTS.filter(model => styleAndRegisterApply(model, effectiveByModel[model])).map(model => ({ model, prompt: effectiveByModel[model] })),
262
+ meaningGuard: meaningGuardEnabled(),
256
263
  source: choice.source,
257
264
  // The environment named a version, so this option has no effect until it is removed.
258
265
  envOverride: choice.source === 'env',
@@ -42,7 +42,7 @@ import { polishBudgetExhausted } from '../lib/dictation-polish-budget.js'
42
42
  import { discardPolishPrewarm, isPrewarmModel, prewarmEnabled, startPolishPrewarm } from '../lib/dictation-prewarm.js'
43
43
  import { applyCorrections } from '../lib/whisper-local.js'
44
44
  import { registerFromContext } from '../lib/dictation-register.js'
45
- import { autoCleanBreakers, cleanOutboundDictation, type AutoCleanRequest, type PolishResult } from '../lib/dictation-polish-pipeline.js'
45
+ import { cleanOutboundDictation, polishBreakerOpen, type AutoCleanRequest, type PolishResult } from '../lib/dictation-polish-pipeline.js'
46
46
  import { emitDisplay } from '../lib/display-bus.js'
47
47
  import {
48
48
  acquireMaintenanceWork,
@@ -158,7 +158,8 @@ promptDraftsRouter.post('/dictation/polish/prewarm', (req, res) => {
158
158
  if (surface !== 'message' && surface !== 'meeting') return res.status(400).json({ error: 'invalid_surface' })
159
159
  if (!prewarmEnabled()) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'disabled' })
160
160
  if (!isPrewarmModel(model)) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'model' })
161
- if (autoCleanBreakers[model].isOpen()) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'breaker_open' })
161
+ // 6.92.0: the same keyed breaker (model, bucket group) the finalize for this surface would ask.
162
+ if (polishBreakerOpen(model, surface)) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'breaker_open' })
162
163
  if (polishBudgetExhausted(surface)) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'daily_cap' })
163
164
  try {
164
165
  res.json(startPolishPrewarm(model))