@gotcos/glasses-server 6.90.0 → 6.91.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,203 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync, statSync, unlinkSync } from 'node:fs'
3
+ import { atomicWriteFileSync } from './atomic-fs.js'
4
+ import { dataPath } from './data-dir.js'
5
+ import { readSwitch } from './env-switch.js'
6
+ import type { DictationRegister } from './dictation-register.js'
7
+
8
+ /**
9
+ * 6.91.0: "Your style", the learned-style setting (plan r2, contract frozen 2026-10-24).
10
+ *
11
+ * COS Control derives style rules from the corrections the person types into their own dictation
12
+ * (a deterministic diff on the Mac, no LLM) and stores the active ones here, so Mac dictation and
13
+ * G2 messages both get them. The same pattern as the 6.85.0 polish instructions, with its own file,
14
+ * cap and switch, and one difference that matters: the client sends CODES, never text. A rule is
15
+ * a `kind` plus a `param` from a closed set, and the server renders the words the model reads
16
+ * from its own templates, so no client string ever reaches the prompt.
17
+ *
18
+ * - kinds: `drop_phrase` (param from LEARNED_STYLE_ALLOWLIST) and `contractions` (use | avoid).
19
+ * - register: `all` only in the MVP. A literal target (a terminal) never gets learned style.
20
+ * - At most LEARNED_STYLE_MAX_RULES rules, rendered to at most LEARNED_STYLE_MAX_CHARS. Over either
21
+ * is refused (413), never silently dropped or turned off.
22
+ * - The server owns `text`, `hash`, `revision` and `updatedAt`. `revision` is 8 hex of the rendered
23
+ * text AND the person's switch, and `baseRevision` makes a PUT conditional (409), so a stale save
24
+ * can undo neither a newer rule list nor a newer Off. `hash` (the text alone) is what finalize
25
+ * answers and telemetry carry.
26
+ * - `enabled: false` (the person's switch) stops it applying everywhere and keeps the rules. Reset
27
+ * (DELETE) clears the rules and leaves the switch as it was.
28
+ * COS_DICTATION_LEARNED_STYLE=0 (the server's switch) does the same, whatever the file says.
29
+ * - Stored atomically in the data dir, mode 0600, re-validated on every read; a corrupt file reads
30
+ * as empty and is logged as a code only. The rules are never logged; telemetry carries a flag
31
+ * and an 8-hex hash. Never for meeting lines.
32
+ */
33
+ export const LEARNED_STYLE_PROTOCOL = 1
34
+ export const LEARNED_STYLE_MAX_CHARS = 1_500
35
+ export const LEARNED_STYLE_MAX_RULES = 20
36
+ export const LEARNED_STYLE_FILE = 'dictation-learned-style.json'
37
+
38
+ export const LEARNED_STYLE_KINDS = ['drop_phrase', 'contractions'] as const
39
+ export type LearnedStyleKind = typeof LEARNED_STYLE_KINDS[number]
40
+ export const LEARNED_STYLE_REGISTERS = ['all'] as const
41
+ export type LearnedStyleRegister = typeof LEARNED_STYLE_REGISTERS[number]
42
+ export const CONTRACTION_PARAMS = ['use', 'avoid'] as const
43
+
44
+ /**
45
+ * The closed list of phrases a drop_phrase rule may name. Never a negation, a scope word, a number,
46
+ * one of the built-in polish fillers (um, uh, like, you know, I mean, sort of, kind of, basically,
47
+ * so, well), or a word that often carries meaning (like, so, just, right, actually, really).
48
+ */
49
+ export const LEARNED_STYLE_ALLOWLIST = [
50
+ 'honestly', 'literally', 'pretty much', 'i guess', 'to be honest', 'frankly', 'obviously',
51
+ 'kinda', 'sorta', 'you see', 'at the end of the day', 'if that makes sense',
52
+ ] as const
53
+
54
+ export interface LearnedStyleRule {
55
+ id: string
56
+ kind: LearnedStyleKind
57
+ register: LearnedStyleRegister
58
+ param: string
59
+ count: number
60
+ lastSeen: string | null
61
+ }
62
+
63
+ export function learnedStyleEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
64
+ return readSwitch(env.COS_DICTATION_LEARNED_STYLE, true)
65
+ }
66
+
67
+ /** The one place the words come from: a template per kind, filled with a closed-set param. */
68
+ export function ruleText(rule: Pick<LearnedStyleRule, 'kind' | 'param'>): string {
69
+ if (rule.kind === 'drop_phrase') return `Drop "${rule.param}" where the speaker used it as filler.`
70
+ return rule.param === 'use'
71
+ ? 'Use contractions where they fit ("I am" becomes "I\'m", "do not" becomes "don\'t").'
72
+ : 'Write contractions out in full ("I\'m" becomes "I am", "don\'t" becomes "do not").'
73
+ }
74
+
75
+ /** The stored, shown and hashed text: one "- " line per rule, from the templates only. */
76
+ export function renderLearnedStyle(rules: Array<Pick<LearnedStyleRule, 'kind' | 'param'>>): string {
77
+ return rules.map(rule => `- ${ruleText(rule)}`).join('\n')
78
+ }
79
+
80
+ /** 8 hex characters: enough to tell two settings apart in a log, never the rules. '' for none. */
81
+ export function learnedStyleHash(text: string): string {
82
+ return text ? createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 8) : ''
83
+ }
84
+
85
+ /** 8 hex of the rendered text and the switch: what a conditional PUT names as its base. Always 8. */
86
+ export function learnedStyleRevision(text: string, enabled: boolean): string {
87
+ return createHash('sha256').update(`${enabled ? 'on' : 'off'}\n${text}`, 'utf8').digest('hex').slice(0, 8)
88
+ }
89
+
90
+ const RULE_ID = /^[a-z0-9_-]{1,40}$/
91
+ const RULE_KEYS = new Set(['id', 'kind', 'register', 'param', 'count', 'lastSeen'])
92
+
93
+ export type RulesResult =
94
+ | { ok: true; rules: LearnedStyleRule[] }
95
+ | { ok: false; status: 400 | 413; error: string; index?: number; max?: number }
96
+
97
+ /** Validate rules exactly; one bad rule refuses the whole list. Pure. */
98
+ export function validateLearnedStyleRules(raw: unknown): RulesResult {
99
+ if (!Array.isArray(raw)) return { ok: false, status: 400, error: 'rules_not_an_array' }
100
+ if (raw.length > LEARNED_STYLE_MAX_RULES) return { ok: false, status: 413, error: 'too_many_rules', max: LEARNED_STYLE_MAX_RULES }
101
+ const rules: LearnedStyleRule[] = []
102
+ const ids = new Set<string>()
103
+ const meanings = new Map<string, string>()
104
+ for (let index = 0; index < raw.length; index++) {
105
+ const rule = raw[index]
106
+ if (!rule || typeof rule !== 'object' || Array.isArray(rule) || !Object.keys(rule).every(key => RULE_KEYS.has(key))) {
107
+ return { ok: false, status: 400, error: 'invalid_rule', index }
108
+ }
109
+ const { id, kind, register, param, count, lastSeen } = rule as Record<string, unknown>
110
+ if (typeof id !== 'string' || !RULE_ID.test(id) || ids.has(id)) return { ok: false, status: 400, error: 'invalid_rule_id', index }
111
+ if (typeof kind !== 'string' || !(LEARNED_STYLE_KINDS as readonly string[]).includes(kind)) return { ok: false, status: 400, error: 'invalid_rule_kind', index }
112
+ if (typeof register !== 'string' || !(LEARNED_STYLE_REGISTERS as readonly string[]).includes(register)) {
113
+ return { ok: false, status: 400, error: 'invalid_rule_register', index }
114
+ }
115
+ const allowed: readonly string[] = kind === 'drop_phrase' ? LEARNED_STYLE_ALLOWLIST : CONTRACTION_PARAMS
116
+ if (typeof param !== 'string' || !allowed.includes(param)) return { ok: false, status: 400, error: 'invalid_rule_param', index }
117
+ const n = count ?? 0
118
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 0 || n > 1_000_000) return { ok: false, status: 400, error: 'invalid_rule_count', index }
119
+ let seen: string | null = null
120
+ if (lastSeen !== undefined && lastSeen !== null) {
121
+ if (typeof lastSeen !== 'string' || lastSeen.length > 40 || Number.isNaN(Date.parse(lastSeen))) {
122
+ return { ok: false, status: 400, error: 'invalid_rule_last_seen', index }
123
+ }
124
+ seen = new Date(Date.parse(lastSeen)).toISOString()
125
+ }
126
+ // One rule per meaning: two drops of one phrase, or "use" and "avoid" together, is a client bug.
127
+ const meaning = kind === 'drop_phrase' ? `drop:${param}` : 'contractions'
128
+ if (meanings.has(meaning)) return { ok: false, status: 400, error: meanings.get(meaning) === param ? 'duplicate_rule' : 'conflicting_rules', index }
129
+ meanings.set(meaning, param)
130
+ ids.add(id)
131
+ rules.push({ id, kind: kind as LearnedStyleKind, register: register as LearnedStyleRegister, param, count: n, lastSeen: seen })
132
+ }
133
+ if (renderLearnedStyle(rules).length > LEARNED_STYLE_MAX_CHARS) return { ok: false, status: 413, error: 'learned_style_too_long', max: LEARNED_STYLE_MAX_CHARS }
134
+ return { ok: true, rules }
135
+ }
136
+
137
+ /**
138
+ * The lines one polish gets, or '' when nothing applies: the person switched it off, the target
139
+ * is literal (a terminal never restyles), or there are no rules. Every rule is `all` in the MVP.
140
+ */
141
+ export function learnedStyleLinesFor(stored: StoredLearnedStyle, register: DictationRegister): string {
142
+ if (!stored.enabled || register === 'literal' || !stored.rules.length) return ''
143
+ return renderLearnedStyle(stored.rules.filter(rule => rule.register === 'all'))
144
+ }
145
+
146
+ const filePath = () => dataPath(LEARNED_STYLE_FILE)
147
+
148
+ export interface StoredLearnedStyle {
149
+ enabled: boolean; rules: LearnedStyleRule[]; text: string; hash: string; revision: string; updatedAt: string | null
150
+ }
151
+ const empty = (): StoredLearnedStyle => ({ enabled: true, rules: [], text: '', hash: '', revision: learnedStyleRevision('', true), updatedAt: null })
152
+
153
+ let cache: { mtimeMs: number; size: number; value: StoredLearnedStyle } | null = null
154
+ let warned = false
155
+
156
+ function warnOnce(code: string): void {
157
+ if (warned) return
158
+ warned = true
159
+ // A code only. The file's contents are the person's style and are never logged.
160
+ console.warn(`[dictation-polish] learned style unreadable (${code}); polish runs without it until it is saved again`)
161
+ }
162
+
163
+ const copy = (value: StoredLearnedStyle): StoredLearnedStyle => ({ ...value, rules: value.rules.map(rule => ({ ...rule })) })
164
+
165
+ function build(enabled: boolean, rules: LearnedStyleRule[], updatedAt: string | null): StoredLearnedStyle {
166
+ const text = renderLearnedStyle(rules)
167
+ return { enabled, rules, text, hash: learnedStyleHash(text), revision: learnedStyleRevision(text, enabled), updatedAt }
168
+ }
169
+
170
+ /** The stored setting, validated again on every read. Missing or corrupt reads as empty. mtime-cached. */
171
+ export function readLearnedStyle(): StoredLearnedStyle {
172
+ let stat: { mtimeMs: number; size: number }
173
+ try { const s = statSync(filePath()); stat = { mtimeMs: s.mtimeMs, size: s.size } } catch { cache = null; return empty() }
174
+ if (cache && cache.mtimeMs === stat.mtimeMs && cache.size === stat.size) return copy(cache.value)
175
+ let value = empty()
176
+ try {
177
+ const parsed = JSON.parse(readFileSync(filePath(), 'utf8'))
178
+ const clean = validateLearnedStyleRules(parsed?.rules)
179
+ if (clean.ok && (parsed.enabled === undefined || typeof parsed.enabled === 'boolean')) {
180
+ value = build(parsed.enabled !== false, clean.rules, typeof parsed.updatedAt === 'string' ? parsed.updatedAt : null)
181
+ } else {
182
+ warnOnce(clean.ok ? 'enabled' : clean.error)
183
+ }
184
+ } catch {
185
+ warnOnce('parse')
186
+ }
187
+ cache = { ...stat, value }
188
+ return copy(value)
189
+ }
190
+
191
+ /** Save validated rules. No rules and enabled is the empty state, which deletes the file. */
192
+ export function writeLearnedStyle(enabled: boolean, rules: LearnedStyleRule[], now: Date = new Date()): StoredLearnedStyle {
193
+ cache = null
194
+ if (!rules.length && enabled) {
195
+ try { unlinkSync(filePath()) } catch (err: any) { if (err?.code !== 'ENOENT') throw err }
196
+ return empty()
197
+ }
198
+ const stored = build(enabled, rules, now.toISOString())
199
+ atomicWriteFileSync(filePath(), JSON.stringify({ version: LEARNED_STYLE_PROTOCOL, enabled, rules, updatedAt: stored.updatedAt }, null, 2) + '\n', { mode: 0o600 })
200
+ return copy(stored)
201
+ }
202
+
203
+ export function __resetLearnedStyleCacheForTests(): void { cache = null; warned = false }
@@ -0,0 +1,449 @@
1
+ /**
2
+ * 6.91.0: the meaning guard (plan r2). Deterministic, after the polish model, beside the link
3
+ * guard (dictation-format.ts polishLinkProblem), on the `message` and `prompt_draft` buckets, and
4
+ * only where prompt v4 runs on Sonnet (the canaried pair; dictation-polish-pipeline.ts). Haiku,
5
+ * Luna, any other model and prompt v3 get no guard, so their answers are 6.90.0's.
6
+ *
7
+ * Its input is `cleaned`, the text the model was given (after the dictionary, fuzzy and filler
8
+ * passes), never the raw heard text, the same input the link guard uses. It compares SETS of
9
+ * guarded tokens, in four kinds:
10
+ * - negation: not / n't / cannot and the forms speech-to-text writes without an apostrophe ("dont",
11
+ * "cant"), all one NEG token; never, no, none, nothing, nobody, neither, nor, without, unless,
12
+ * except;
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);
17
+ * - date: a weekday or a month;
18
+ * - name: a mid-sentence capital, a dictionary term or a speaker name. A sentence-initial word and
19
+ * "I" are not names unless they are dictionary terms. Skipped for a dictation that is not English.
20
+ *
21
+ * Normalization: case-insensitive; split on - / @ . and every other mark; number words become
22
+ * digits ("twenty twenty six" may come back as "2026"); possessives and plurals fold; a name
23
+ * re-spelled to a dictionary term (or to a near form the model was asked to fix) counts as kept.
24
+ *
25
+ * It trips on a DROPPED token of any kind, and on an ADDED negation or number. With no correction
26
+ * marker in the dictation, negations are COUNTED ("not saying we should not ship" losing one "not"
27
+ * trips; a stutter, the same negation twice in a row, counts once); with one, they are compared as
28
+ * sets. A dropped token is exempt only when a correction marker follows it and a token of the SAME
29
+ * kind replaces it within a few words ("five, actually six"; "Priya ... actually Dana"; "Tuesday,
30
+ * no wait, Wednesday"). "actually" alone is a marker only when punctuation sets it off or the
31
+ * replacement follows within 3 words; "wait" alone only when punctuation sets it off, because it is
32
+ * an ordinary verb ("tell Marco to wait for Dana"). A negation is never exempt; only a marker
33
+ * phrase's own words ("no wait", "wait no", ", no,", "never mind") are not negations. When the
34
+ * speaker asked for a list ("numbered list", "number one", "bullet points"), a list number at the
35
+ * start of a line ("1." or "2)") is never an added number. Known gaps, stated and not claimed:
36
+ * reordering, before/after/only, and a list number standing in for a dropped number of the same
37
+ * value. Pure; it never throws on strings and logs nothing.
38
+ */
39
+ export type MeaningGuardKind = 'negation' | 'number' | 'date' | 'name'
40
+ export const MEANING_GUARD_KINDS: readonly MeaningGuardKind[] = ['negation', 'number', 'date', 'name']
41
+
42
+ export interface MeaningGuardProblem {
43
+ kind: MeaningGuardKind
44
+ dropped: Record<MeaningGuardKind, number>
45
+ added: Record<MeaningGuardKind, number>
46
+ /** True when the name check was skipped because the dictation is not English. */
47
+ namesSkipped: boolean
48
+ }
49
+
50
+ export function meaningGuardEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
51
+ const raw = (env.COS_DICTATION_MEANING_GUARD ?? '').trim().toLowerCase()
52
+ return !(raw === '0' || raw === 'false' || raw === 'off')
53
+ }
54
+
55
+ interface Tok { raw: string; low: string; sentenceStart: boolean; pause: boolean; listMarker: boolean }
56
+ interface Item { kind: MeaningGuardKind; key: string; pos: number; list?: boolean }
57
+
58
+ const ONES = new Map(['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve',
59
+ 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'].map((w, i) => [w, i]))
60
+ const TENS = new Map([['twenty', 20], ['thirty', 30], ['forty', 40], ['fourty', 40], ['fifty', 50], ['sixty', 60], ['seventy', 70], ['eighty', 80], ['ninety', 90]])
61
+ const SCALES = new Map([['hundred', 100], ['thousand', 1_000], ['k', 1_000], ['million', 1_000_000], ['billion', 1_000_000_000]])
62
+ const ORDINALS = new Map([['first', 1], ['second', 2], ['third', 3], ['fourth', 4], ['fifth', 5], ['sixth', 6], ['seventh', 7], ['eighth', 8],
63
+ ['ninth', 9], ['tenth', 10], ['eleventh', 11], ['twelfth', 12], ['thirteenth', 13], ['fourteenth', 14], ['fifteenth', 15],
64
+ ['sixteenth', 16], ['seventeenth', 17], ['eighteenth', 18], ['nineteenth', 19], ['twentieth', 20], ['thirtieth', 30], ['fortieth', 40],
65
+ ['fiftieth', 50], ['sixtieth', 60], ['seventieth', 70], ['eightieth', 80], ['ninetieth', 90]])
66
+ /** Unit and time words that are part of a number item. */
67
+ const NUMBER_WORDS = new Map([['percent', 'percent'], ['percentage', 'percent'], ['dollar', 'dollar'], ['dollars', 'dollar'], ['buck', 'dollar'],
68
+ ['bucks', 'dollar'], ['euro', 'euro'], ['euros', 'euro'], ['pound', 'pound'], ['pounds', 'pound'], ['cent', 'cent'], ['cents', 'cent'],
69
+ ['noon', 'noon'], ['midnight', 'midnight']])
70
+ const SYMBOLS = new Map([['%', 'percent'], ['$', 'dollar'], ['€', 'euro'], ['£', 'pound'], ['¢', 'cent']])
71
+
72
+ const NEG_WORDS = new Set(['never', 'no', 'none', 'nothing', 'nobody', 'neither', 'nor', 'without', 'unless', 'except'])
73
+ /** "n't" forms speech-to-text writes without the apostrophe: the same NEG token as "don't". */
74
+ const BARE_NT = new Set(['dont', 'cant', 'wont', 'isnt', 'arent', 'doesnt', 'didnt', 'wasnt', 'werent', 'shouldnt', 'wouldnt', 'couldnt',
75
+ 'havent', 'hasnt', 'hadnt', 'mustnt', 'neednt', 'aint'])
76
+
77
+ const WEEKDAYS = new Map([['monday', 'monday'], ['tuesday', 'tuesday'], ['wednesday', 'wednesday'], ['thursday', 'thursday'], ['friday', 'friday'],
78
+ ['saturday', 'saturday'], ['sunday', 'sunday'], ['mon', 'monday'], ['tue', 'tuesday'], ['tues', 'tuesday'], ['wed', 'wednesday'],
79
+ ['thu', 'thursday'], ['thur', 'thursday'], ['thurs', 'thursday'], ['fri', 'friday'], ['sat', 'saturday'], ['sun', 'sunday']])
80
+ const MONTHS = new Map([['january', 'january'], ['february', 'february'], ['march', 'march'], ['april', 'april'], ['may', 'may'], ['june', 'june'],
81
+ ['july', 'july'], ['august', 'august'], ['september', 'september'], ['october', 'october'], ['november', 'november'], ['december', 'december'],
82
+ ['jan', 'january'], ['feb', 'february'], ['mar', 'march'], ['apr', 'april'], ['jun', 'june'], ['jul', 'july'], ['aug', 'august'],
83
+ ['sep', 'september'], ['sept', 'september'], ['oct', 'october'], ['nov', 'november'], ['dec', 'december']])
84
+ /** Words that are dates only when capitalized: "may" is a verb, "march" a verb, "sun" and "wed" words. */
85
+ const DATE_NEEDS_CAPITAL = new Set(['may', 'march', 'mar', 'mon', 'tue', 'tues', 'wed', 'thu', 'thur', 'thurs', 'fri', 'sat', 'sun',
86
+ 'jan', 'feb', 'apr', 'jun', 'jul', 'aug', 'sep', 'sept', 'oct', 'nov', 'dec'])
87
+
88
+ const NOT_NAMES = new Set(['i', "i'm", "i'll", "i've", "i'd", 'ok', 'okay', 'oh', 'o', 'a', 'am', 'pm'])
89
+
90
+ /** Correction markers, as token sequences (", no," is "no" between pause marks, checked below). */
91
+ const MARKERS: string[][] = [['actually'], ['i', 'mean'], ['i', 'meant'], ['no', 'wait'], ['wait', 'no'], ['wait'], ['sorry'],
92
+ ['or', 'rather'], ['make', 'that'], ['scratch', 'that'], ['never', 'mind'], ['nevermind'], ['correction']]
93
+ /** Marker phrases whose words are not negations, on either side. */
94
+ const NEG_MARKER_PHRASES: string[][] = [['no', 'wait'], ['wait', 'no'], ['never', 'mind'], ['nevermind']]
95
+ /** One-word markers that are also ordinary words: a marker only when set off (see markers()). */
96
+ const BARE_ACTUALLY = 'actually'
97
+ const BARE_WAIT = 'wait'
98
+ const DROP_TO_MARKER = 8
99
+ const MARKER_TO_REPLACEMENT = 6
100
+ /** A bare "actually" (no punctuation around it) excuses a drop only with a replacement this close. */
101
+ const BARE_MARKER_TO_REPLACEMENT = 3
102
+ /** The speaker asked for a list, so line-start list numbers are formatting, not added numbers. */
103
+ const LIST_ASKED = /\b(?:numbered list|number one|bullet(?:ed)? (?:list|points?)|bullets)\b/i
104
+
105
+ const ENGLISH = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'to', 'of', 'in', 'on', 'for', 'with', 'is', 'are', 'was', 'were', 'be', 'it', 'this',
106
+ 'that', 'i', 'you', 'we', 'they', 'he', 'she', 'my', 'our', 'your', 'their', 'can', 'will', 'do', 'does', 'not', 'at', 'from', 'have', 'has',
107
+ 'me', 'us', 'so', 'if', 'what', 'just', 'about', 'there', 'here', 'all', 'some', 'any', 'how', 'why', 'when', 'need', 'want', 'get', 'make'])
108
+
109
+ const HOUR = '(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|\\d{1,2})'
110
+ const HALF_PAST = new RegExp(`\\bhalf past ${HOUR}\\b`, 'gi')
111
+ const QUARTER_PAST = new RegExp(`\\b(?:a )?quarter (?:past|after) ${HOUR}\\b`, 'gi')
112
+ const QUARTER_TO = new RegExp(`\\b(?:a )?quarter (?:to|of|till|til) ${HOUR}\\b`, 'gi')
113
+
114
+ /** An hour from 1 to 12, as a word or digits, or null. */
115
+ function hourOf(word: string): number | null {
116
+ const value = ONES.get(word.toLowerCase()) ?? (/^\d+$/.test(word) ? Number.parseInt(word, 10) : -1)
117
+ return value >= 1 && value <= 12 ? value : null
118
+ }
119
+
120
+ /** "half past ten" is 10:30, "quarter past ten" 10:15, "quarter to five" 4:45 (and "quarter to one" 12:45). */
121
+ function spokenTimes(text: string): string {
122
+ return text
123
+ .replace(HALF_PAST, (all, h: string) => { const hour = hourOf(h); return hour === null ? all : `${hour}:30` })
124
+ .replace(QUARTER_PAST, (all, h: string) => { const hour = hourOf(h); return hour === null ? all : `${hour}:15` })
125
+ .replace(QUARTER_TO, (all, h: string) => { const hour = hourOf(h); return hour === null ? all : `${hour === 1 ? 12 : hour - 1}:45` })
126
+ }
127
+
128
+ function normalize(text: string): string {
129
+ return spokenTimes(text)
130
+ .replace(/[’‘ʼ]/g, "'")
131
+ .replace(/\b([ap])\.\s?m\b\.?/gi, '$1m')
132
+ .replace(/(\d)\s?(am|pm)\b/gi, '$1 $2')
133
+ // "three o'clock" and "3:00" are one time.
134
+ .replace(/\s?o'?clock\b/gi, ':00')
135
+ // A thousands separator is not a split: "1,000" is 1000.
136
+ .replace(/(\d),(?=\d{3}\b)/g, '$1')
137
+ .replace(/(\d)k\b/gi, '$1 k')
138
+ }
139
+
140
+ function tokenize(text: string): Tok[] {
141
+ const out: Tok[] = []
142
+ const re = /[\p{L}\p{N}]+(?:'[\p{L}]+)*|[%$€£¢]/gu
143
+ let m: RegExpExecArray | null
144
+ let last = 0
145
+ while ((m = re.exec(text))) {
146
+ const gap = text.slice(last, m.index)
147
+ const before = text.slice(0, m.index).replace(/[\s"'“”()[\]{}*_-]+$/u, '')
148
+ const sentenceStart = before.length === 0 || /[.!?:\n•]$/.test(before) || /\n\s*(?:[-*•]|\d+[.)])?\s*$/.test(text.slice(0, m.index))
149
+ // "1." or "2)" at the start of a line: a list number.
150
+ 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 })
152
+ last = m.index + m[0].length
153
+ }
154
+ return out
155
+ }
156
+
157
+ const fold = (w: string) => {
158
+ let x = w.replace(/'s$/, '')
159
+ if (x.length > 3 && /[^s]s$/.test(x)) x = x.slice(0, -1)
160
+ return x
161
+ }
162
+
163
+ function startsWith(tokens: Tok[], at: number, phrase: string[]): boolean {
164
+ return phrase.every((w, i) => tokens[at + i]?.low === w)
165
+ }
166
+
167
+ /** A correction marker: where it starts and ends, how close its replacement must follow, and whether it stood alone. */
168
+ interface Span { start: number; end: number; reach: number; bare: boolean }
169
+
170
+ /** The correction markers, and the positions of the ones whose own words are not negations. */
171
+ function markers(tokens: Tok[]): { negCover: Set<number>; spans: Span[] } {
172
+ const negCover = new Set<number>()
173
+ const spans: Span[] = []
174
+ for (let i = 0; i < tokens.length; i++) {
175
+ const phrase = MARKERS.find(p => startsWith(tokens, i, p))
176
+ if (phrase) {
177
+ // "actually" and "wait" alone are ordinary words unless punctuation sets them off
178
+ // ("Priya, actually Dana"; "Tuesday wait, Wednesday"). A bare "actually" still counts when the
179
+ // replacement follows within BARE_MARKER_TO_REPLACEMENT words ("five actually six"); a bare
180
+ // "wait" never does ("tell Marco to wait for Dana" keeps Marco).
181
+ const bare = phrase.length === 1 && (phrase[0] === BARE_ACTUALLY || phrase[0] === BARE_WAIT)
182
+ && !tokens[i].pause && !tokens[i + 1]?.pause
183
+ if (bare && phrase[0] === BARE_WAIT) continue
184
+ spans.push({ start: i, end: i + phrase.length - 1, reach: bare ? BARE_MARKER_TO_REPLACEMENT : MARKER_TO_REPLACEMENT, bare })
185
+ if (NEG_MARKER_PHRASES.some(p => p.length === phrase.length && startsWith(tokens, i, p))) for (let k = 0; k < phrase.length; k++) negCover.add(i + k)
186
+ i += phrase.length - 1
187
+ continue
188
+ }
189
+ // ", no," : a "no" set off by pause marks on both sides is a correction ("the blue, no, the green").
190
+ if (tokens[i].low === 'no' && i > 0 && tokens[i].pause && tokens[i + 1]?.pause) {
191
+ spans.push({ start: i, end: i, reach: MARKER_TO_REPLACEMENT, bare: false }); negCover.add(i)
192
+ }
193
+ }
194
+ return { negCover, spans }
195
+ }
196
+
197
+ /**
198
+ * Spoken numbers to digits: "twenty five" is 25, "twenty twenty six" is 20 then 26. An ordinal
199
+ * ends a run: "twenty fifth" is 25 (as "25th" is), "one hundred and first" 101.
200
+ */
201
+ function numberRun(tokens: Tok[], i: number): { value: number; end: number } | null {
202
+ const first = tokens[i].low
203
+ if (!ONES.has(first) && !TENS.has(first) && !(SCALES.has(first) && first !== 'k')) return null
204
+ let total = 0, group = 0, last: 'ones' | 'tens' | 'scale' | null = null, teen = false, j = i
205
+ for (; j < tokens.length; j++) {
206
+ const w = tokens[j].low
207
+ const next = tokens[j + 1]?.low ?? ''
208
+ if (w === 'and' && last === 'scale' && (ONES.has(next) || TENS.has(next) || ORDINALS.has(next))) continue
209
+ if (ORDINALS.has(w)) {
210
+ const v = ORDINALS.get(w)!
211
+ if (!((last === 'tens' && v < 10) || last === 'scale')) break
212
+ group += v
213
+ j++
214
+ break
215
+ }
216
+ if (TENS.has(w)) {
217
+ if (last === 'ones' || last === 'tens') break
218
+ group += TENS.get(w)!; last = 'tens'
219
+ } else if (ONES.has(w)) {
220
+ const v = ONES.get(w)!
221
+ if (last === 'ones' || (last === 'tens' && (v >= 10 || v === 0)) || (teen && last !== 'scale')) break
222
+ group += v; last = 'ones'; if (v >= 10) teen = true
223
+ } else if (SCALES.has(w) && !(w === 'k' && last === null)) {
224
+ const s = SCALES.get(w)!
225
+ if (s === 100) group = (group || 1) * 100
226
+ else { total += (group || 1) * s; group = 0 }
227
+ last = 'scale'; teen = false
228
+ } else break
229
+ }
230
+ return { value: total + group, end: j - 1 }
231
+ }
232
+
233
+ interface Extract { items: Item[]; tokens: Tok[] }
234
+
235
+ function extract(text: string, knownPhrases: string[][], withNames: boolean): Extract {
236
+ const tokens = tokenize(normalize(text))
237
+ const { negCover } = markers(tokens)
238
+ const items: Item[] = []
239
+ const midCapital = new Set(tokens.filter(t => !t.sentenceStart && /\p{Lu}/u.test(t.raw)).map(t => fold(t.low)))
240
+ for (let i = 0; i < tokens.length; i++) {
241
+ const t = tokens[i]
242
+ // Negations.
243
+ if (!negCover.has(i)) {
244
+ if (t.low === 'not' || t.low === 'cannot' || /n't$/.test(t.low) || BARE_NT.has(t.low)) items.push({ kind: 'negation', key: 'NEG', pos: i })
245
+ else if (NEG_WORDS.has(t.low)) items.push({ kind: 'negation', key: t.low, pos: i })
246
+ }
247
+ // Numbers.
248
+ if (/^\d+$/.test(t.low)) {
249
+ let value = Number.parseInt(t.low, 10)
250
+ const pos = i
251
+ // A digit and a scale word are one number: "2 million" is two million, as "two million" is.
252
+ while (SCALES.has(tokens[i + 1]?.low ?? '')) { value *= SCALES.get(tokens[i + 1].low)!; i++ }
253
+ items.push({ kind: 'number', key: String(value), pos, ...(t.listMarker ? { list: true } : {}) })
254
+ continue
255
+ }
256
+ const suffixed = /^(\d+)(st|nd|rd|th)$/.exec(t.low)
257
+ if (suffixed) { items.push({ kind: 'number', key: String(Number.parseInt(suffixed[1], 10)), pos: i }); continue }
258
+ // Digits inside a word ("Jewel360", "Q4", "iTerm2") are numbers too, so "jewel three sixty"
259
+ // written "Jewel360" keeps its 3 and 60 (joined), and the word is still checked as a name below.
260
+ if (/\p{L}/u.test(t.low) && /\d/.test(t.low)) {
261
+ for (const digits of t.low.match(/\d+/g) ?? []) items.push({ kind: 'number', key: String(Number.parseInt(digits, 10)), pos: i })
262
+ }
263
+ const run = numberRun(tokens, i)
264
+ if (run) {
265
+ items.push({ kind: 'number', key: String(run.value), pos: i })
266
+ i = run.end
267
+ continue
268
+ }
269
+ if (ORDINALS.has(t.low)) { items.push({ kind: 'number', key: String(ORDINALS.get(t.low)), pos: i }); continue }
270
+ if (NUMBER_WORDS.has(t.low)) { items.push({ kind: 'number', key: NUMBER_WORDS.get(t.low)!, pos: i }); continue }
271
+ if (SYMBOLS.has(t.raw)) { items.push({ kind: 'number', key: SYMBOLS.get(t.raw)!, pos: i }); continue }
272
+ if ((t.low === 'am' || t.low === 'pm') && i > 0 && (/^\d/.test(tokens[i - 1].low) || ONES.has(tokens[i - 1].low) || TENS.has(tokens[i - 1].low))) {
273
+ items.push({ kind: 'number', key: t.low, pos: i }); continue
274
+ }
275
+ // Dates.
276
+ const date = WEEKDAYS.get(t.low) ?? MONTHS.get(t.low)
277
+ if (date && (!DATE_NEEDS_CAPITAL.has(t.low) || /^\p{Lu}/u.test(t.raw))) { items.push({ kind: 'date', key: date, pos: i }); continue }
278
+ // Names: a mid-sentence capital (or a sentence-initial one the text capitalizes elsewhere too).
279
+ if (withNames && /\p{Lu}/u.test(t.raw) && t.raw.length >= 2 && !NOT_NAMES.has(t.low) && !ONES.has(t.low) && !TENS.has(t.low)
280
+ && !SCALES.has(t.low) && !ORDINALS.has(t.low) && !NUMBER_WORDS.has(t.low) && !NEG_WORDS.has(t.low)) {
281
+ if (!t.sentenceStart || midCapital.has(fold(t.low))) items.push({ kind: 'name', key: fold(t.low), pos: i })
282
+ }
283
+ }
284
+ // Dictionary terms and speaker names, in any case and position, as whole phrases.
285
+ if (withNames) {
286
+ for (const phrase of knownPhrases) {
287
+ for (let i = 0; i + phrase.length <= tokens.length; i++) {
288
+ if (phrase.every((w, k) => fold(tokens[i + k].low) === w)) items.push({ kind: 'name', key: phrase.join(' '), pos: i })
289
+ }
290
+ }
291
+ }
292
+ return { items, tokens }
293
+ }
294
+
295
+ function levenshtein(a: string, b: string): number {
296
+ if (a === b) return 0
297
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i)
298
+ for (let i = 1; i <= a.length; i++) {
299
+ let prev = row[0]
300
+ row[0] = i
301
+ for (let j = 1; j <= b.length; j++) {
302
+ const tmp = row[j]
303
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1))
304
+ prev = tmp
305
+ }
306
+ }
307
+ return row[b.length]
308
+ }
309
+
310
+ /** Mostly English? A short text is assumed to be; a long one with few English function words is not. */
311
+ export function looksEnglish(text: string): boolean {
312
+ const letters = text.match(/\p{L}/gu) ?? []
313
+ if (letters.length && letters.filter(ch => !/[A-Za-z]/.test(ch)).length / letters.length > 0.2) return false
314
+ const words = tokenize(text).map(t => t.low)
315
+ // A short command ("ask Marco about the deck") has few function words; only a longer text can tell.
316
+ if (words.length < 12) return true
317
+ return words.filter(w => ENGLISH.has(w)).length / words.length >= 0.12
318
+ }
319
+
320
+ const zero = (): Record<MeaningGuardKind, number> => ({ negation: 0, number: 0, date: 0, name: 0 })
321
+
322
+ /** How many of each negation, a stutter (the same one twice in a row) counted once. */
323
+ function negationCounts(items: Item[]): Map<string, number> {
324
+ const counts = new Map<string, number>()
325
+ let previous: Item | null = null
326
+ for (const item of items.filter(i => i.kind === 'negation').sort((a, b) => a.pos - b.pos)) {
327
+ if (!(previous && previous.key === item.key && item.pos === previous.pos + 1)) counts.set(item.key, (counts.get(item.key) ?? 0) + 1)
328
+ previous = item
329
+ }
330
+ return counts
331
+ }
332
+
333
+ /**
334
+ * The first kind the answer lost or added, with counts, or null when every guarded token the
335
+ * speaker kept is still there. `known` is the dictionary (vocabulary) and the speaker names.
336
+ */
337
+ export function meaningGuardProblem(cleaned: string, answer: string, known: string[] = []): MeaningGuardProblem | null {
338
+ if (typeof cleaned !== 'string' || typeof answer !== 'string') return null
339
+ const knownPhrases = known.filter(k => typeof k === 'string' && k.trim())
340
+ .map(k => tokenize(normalize(k)).map(t => fold(t.low))).filter(p => p.length > 0)
341
+ const withNames = looksEnglish(cleaned)
342
+ const input = extract(cleaned, knownPhrases, withNames)
343
+ const output = extract(answer, knownPhrases, withNames)
344
+ const outKeys = new Map<MeaningGuardKind, Set<string>>(MEANING_GUARD_KINDS.map(k => [k, new Set(output.items.filter(i => i.kind === k).map(i => i.key))]))
345
+ const inKeys = new Map<MeaningGuardKind, Set<string>>(MEANING_GUARD_KINDS.map(k => [k, new Set(input.items.filter(i => i.kind === k).map(i => i.key))]))
346
+ // A bare "actually" is a marker only with a guarded word right after it ("five actually six");
347
+ // "I actually think" is an ordinary adverb, so it neither excuses a drop nor stops the counts.
348
+ const spans = markers(input.tokens).spans
349
+ .filter(span => !span.bare || input.items.some(r => r.kind !== 'negation' && r.pos > span.end && r.pos - span.end <= span.reach))
350
+
351
+ // Spoken digits joined in the answer ("twenty twenty six" as "2026", "nineteen ninety" as "1990").
352
+ const inNumbers = input.items.filter(i => i.kind === 'number' && /^\d+$/.test(i.key)).sort((a, b) => a.pos - b.pos)
353
+ const joined = new Map<string, string[]>()
354
+ for (let i = 0; i < inNumbers.length; i++) {
355
+ for (const len of [2, 3]) {
356
+ const group = inNumbers.slice(i, i + len)
357
+ if (group.length === len && group.every((g, k) => k === 0 || g.pos - group[k - 1].pos <= 2)) joined.set(group.map(g => g.key).join(''), group.map(g => g.key))
358
+ }
359
+ }
360
+ const outNumbers = outKeys.get('number')!
361
+ const keptByJoin = new Set<string>()
362
+ const joinedOut = new Set<string>()
363
+ for (const key of outNumbers) {
364
+ const parts = joined.get(key)
365
+ if (parts && !inKeys.get('number')!.has(key)) { parts.forEach(p => keptByJoin.add(p)); joinedOut.add(key) }
366
+ }
367
+
368
+ // Names the answer re-spelled: to a dictionary term, or to a near form it was asked to fix.
369
+ const outTokens = output.tokens
370
+ const inTokenSet = new Set(input.tokens.map(t => fold(t.low)))
371
+ const outJoined = new Set<string>()
372
+ for (let i = 0; i < input.tokens.length; i++) {
373
+ for (const len of [2, 3]) if (i + len <= input.tokens.length) outJoined.add(input.tokens.slice(i, i + len).map(t => fold(t.low).replace(/'/g, '')).join(''))
374
+ }
375
+ const nameKept = (key: string): boolean => {
376
+ if (outKeys.get('name')!.has(key)) return true
377
+ // Folded on both sides: a plural fold turns "bottlepos" into "bottlepo" on the answer's side too.
378
+ const outWords = [...new Set(outTokens.map(t => fold(t.low).replace(/'/g, '')))]
379
+ const flat = key.replace(/[' ]/g, '')
380
+ if (outWords.includes(fold(flat))) return true
381
+ // Joined into one word ("Hub Spot" as "HubSpot").
382
+ if (outWords.some(w => outJoined.has(w) && w.includes(flat))) return true
383
+ const knownFlat = knownPhrases.map(p => p.join(''))
384
+ const respelled = outTokens.some(t => {
385
+ const w = fold(t.low)
386
+ if (inTokenSet.has(w)) return false
387
+ if (knownFlat.includes(w)) return levenshtein(flat, w) <= Math.max(2, Math.floor(flat.length / 2))
388
+ return /\p{Lu}/u.test(t.raw) && levenshtein(flat, w) <= Math.max(1, Math.floor(flat.length / 4))
389
+ })
390
+ return respelled
391
+ }
392
+
393
+ // A dropped token with a correction marker after it and a same-kind replacement the answer kept.
394
+ const exempt = (item: Item): boolean => {
395
+ if (item.kind === 'negation') return false
396
+ for (const { start, end, reach } of spans) {
397
+ 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)))
400
+ if (replaced) return true
401
+ }
402
+ return false
403
+ }
404
+
405
+ // With no correction marker, negations are counted, so losing one of two "not"s trips. A
406
+ // correction can legitimately drop a repeated negation ("can't, no wait, can't do Friday"), so
407
+ // with a marker they are compared as sets, like every other kind.
408
+ const countNegations = spans.length === 0
409
+
410
+ const dropped = zero()
411
+ const added = zero()
412
+ const seen = new Set<string>()
413
+ for (const item of input.items) {
414
+ const id = `${item.kind}:${item.key}`
415
+ if (seen.has(id)) continue
416
+ 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 }
420
+ // Every occurrence must be exempt for a drop to be excused.
421
+ const occurrences = input.items.filter(i => i.kind === item.kind && i.key === item.key)
422
+ seen.add(id)
423
+ if (occurrences.every(exempt)) continue
424
+ dropped[item.kind]++
425
+ }
426
+ if (countNegations) {
427
+ const inCounts = negationCounts(input.items)
428
+ const outCounts = negationCounts(output.items)
429
+ for (const [key, n] of inCounts) if ((outCounts.get(key) ?? 0) < n) dropped.negation += n - (outCounts.get(key) ?? 0)
430
+ for (const [key, n] of outCounts) if ((inCounts.get(key) ?? 0) < n) added.negation += n - (inCounts.get(key) ?? 0)
431
+ } else {
432
+ for (const key of outKeys.get('negation')!) if (!inKeys.get('negation')!.has(key)) added.negation++
433
+ }
434
+ // A list the speaker asked for: its line-start numbers are formatting, never added numbers.
435
+ const listOnly = new Set<string>()
436
+ if (LIST_ASKED.test(cleaned)) {
437
+ const outNumberItems = output.items.filter(i => i.kind === 'number')
438
+ for (const key of outNumbers) if (outNumberItems.filter(i => i.key === key).every(i => i.list)) listOnly.add(key)
439
+ }
440
+ for (const key of outNumbers) if (!inKeys.get('number')!.has(key) && !joinedOut.has(key) && !listOnly.has(key)) added.number++
441
+
442
+ const kind = MEANING_GUARD_KINDS.find(k => dropped[k] > 0 || added[k] > 0)
443
+ return kind ? { kind, dropped, added, namesSkipped: !withNames } : null
444
+ }
445
+
446
+ /** "negation=0/1 number=..." (dropped/added) for the log: counts only, never a word. */
447
+ export function meaningGuardCounts(problem: MeaningGuardProblem): string {
448
+ return MEANING_GUARD_KINDS.map(k => `${k}=${problem.dropped[k]}/${problem.added[k]}`).join(' ')
449
+ }