@gotcos/glasses-server 6.90.0 → 6.91.1

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 }