@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.
- package/.env.example +21 -0
- package/CHANGELOG.md +178 -0
- package/README.md +69 -4
- package/managed-runtime-contract.json +5 -1
- package/package.json +2 -1
- package/server/lib/dictation-clean.ts +151 -14
- package/server/lib/dictation-learned-style.ts +203 -0
- package/server/lib/dictation-meaning-guard.ts +552 -0
- package/server/lib/dictation-polish-instructions.ts +2 -2
- package/server/lib/dictation-polish-options.ts +98 -0
- package/server/lib/dictation-polish-pipeline.ts +292 -0
- package/server/lib/dictation-prewarm.ts +18 -6
- package/server/lib/dictation-register.ts +114 -0
- package/server/lib/token-audit.ts +18 -0
- package/server/routes/dictation-polish-settings.ts +193 -3
- package/server/routes/health.ts +11 -3
- package/server/routes/prompt-drafts.ts +35 -140
|
@@ -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 }
|