@gotcos/glasses-server 6.89.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.
@@ -26,9 +26,9 @@ export const CUSTOM_INSTRUCTIONS_PROTOCOL = 1
26
26
  /**
27
27
  * The prompt's own delimiters. A preference that contains one could close its block early and
28
28
  * write text the model would read as COS's, so every one of them is removed, in any case and
29
- * with any spacing or attributes.
29
+ * with any spacing or attributes. 6.91.0: the learned-style block's tag too.
30
30
  */
31
- const RESERVED_TAG = /<\s*\/?\s*(?:user-style-preferences|known-spellings|dictation)\b[^>]*>/gi
31
+ const RESERVED_TAG = /<\s*\/?\s*(?:user-style-preferences|learned-style|known-spellings|dictation)\b[^>]*>/gi
32
32
  /** C0 and C1 controls other than line feed and tab, plus DEL. */
33
33
  const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g
34
34
  /** Invisible format characters: zero-width, bidi embeddings, overrides and isolates, BOM, soft hyphen. */
@@ -0,0 +1,98 @@
1
+ import { readFileSync, statSync, unlinkSync } from 'node:fs'
2
+ import { atomicWriteFileSync } from './atomic-fs.js'
3
+ import { dataPath } from './data-dir.js'
4
+
5
+ /**
6
+ * 6.91.0: "Previous polish", the one polish option COS Control can set (plan r2). The person can
7
+ * go back to the 6.90.0 prompt (v3) from Control's own Settings, with no terminal and no restart,
8
+ * and G2 follows because the option lives on the server.
9
+ *
10
+ * - `promptVersion: "default"` (prompt v4, the default) or `"v3"`.
11
+ * - The environment wins when it names a version (COS_DICTATION_POLISH_PROMPT, or the
12
+ * COS_DICTATION_PROMPT_VERSION alias): dictation-clean.ts dictationPolishPromptVersion.
13
+ * - Stored atomically in the data dir, mode 0600; "default" deletes the file. A missing or
14
+ * malformed file reads as "default".
15
+ */
16
+ export const POLISH_OPTIONS_PROTOCOL = 1
17
+ export const POLISH_OPTIONS_FILE = 'dictation-polish-options.json'
18
+ export const POLISH_PROMPT_OPTIONS = ['default', 'v3'] as const
19
+ export type PolishPromptOption = typeof POLISH_PROMPT_OPTIONS[number]
20
+
21
+ export interface PolishOptions { promptVersion: PolishPromptOption; updatedAt: string | null }
22
+
23
+ const filePath = () => dataPath(POLISH_OPTIONS_FILE)
24
+ let cache: { mtimeMs: number; size: number; value: PolishOptions } | null = null
25
+
26
+ export function readPolishOptions(): PolishOptions {
27
+ const off: PolishOptions = { promptVersion: 'default', updatedAt: null }
28
+ let stat: { mtimeMs: number; size: number }
29
+ try { const s = statSync(filePath()); stat = { mtimeMs: s.mtimeMs, size: s.size } } catch { cache = null; return off }
30
+ if (cache && cache.mtimeMs === stat.mtimeMs && cache.size === stat.size) return { ...cache.value }
31
+ let value = off
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(filePath(), 'utf8'))
34
+ if ((POLISH_PROMPT_OPTIONS as readonly string[]).includes(parsed?.promptVersion)) {
35
+ value = { promptVersion: parsed.promptVersion, updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : null }
36
+ }
37
+ } catch { /* malformed reads as default */ }
38
+ cache = { ...stat, value }
39
+ return { ...value }
40
+ }
41
+
42
+ export function writePolishOptions(promptVersion: PolishPromptOption, now: Date = new Date()): PolishOptions {
43
+ cache = null
44
+ if (promptVersion === 'default') {
45
+ try { unlinkSync(filePath()) } catch (err: any) { if (err?.code !== 'ENOENT') throw err }
46
+ return { promptVersion, updatedAt: null }
47
+ }
48
+ const stored: PolishOptions = { promptVersion, updatedAt: now.toISOString() }
49
+ atomicWriteFileSync(filePath(), JSON.stringify(stored, null, 2) + '\n', { mode: 0o600 })
50
+ return stored
51
+ }
52
+
53
+ export function __resetPolishOptionsCacheForTests(): void { cache = null }
54
+
55
+ export type DictationPolishPromptVersion = 'v1' | 'v2' | 'v3' | 'v4'
56
+ /** The newest prompt, the default on the models in V4_DEFAULT_MODELS. */
57
+ export const DEFAULT_DICTATION_POLISH_PROMPT: DictationPolishPromptVersion = 'v4'
58
+ export const DICTATION_POLISH_PROMPT_VERSIONS: readonly DictationPolishPromptVersion[] = ['v1', 'v2', 'v3', 'v4']
59
+ export type PromptVersionSource = 'env' | 'option' | 'default'
60
+
61
+ /**
62
+ * 6.91.0 (canary C1): prompt v4 passed on Sonnet and failed on Haiku (CHANGELOG), so it is the
63
+ * default on Sonnet only. Haiku, Luna and any other or unnamed model keep v3, byte for byte the
64
+ * 6.90.0 prompt. The environment and the "Previous polish" option still override every model.
65
+ */
66
+ export const V4_DEFAULT_MODELS: readonly string[] = ['sonnet']
67
+ export const POLISH_MODEL_DEFAULTS = ['haiku', 'sonnet', 'luna-5.6-fast'] as const
68
+
69
+ export function defaultPromptFor(model: string | undefined): DictationPolishPromptVersion {
70
+ return model !== undefined && V4_DEFAULT_MODELS.includes(model) ? DEFAULT_DICTATION_POLISH_PROMPT : 'v3'
71
+ }
72
+
73
+ /**
74
+ * Which prompt runs, and why: COS_DICTATION_POLISH_PROMPT (v1 to v4) when it names one, else the
75
+ * alias COS_DICTATION_PROMPT_VERSION (3 or v3), else this file's option ("v3"), else the model's
76
+ * default (v4 on Sonnet, v3 otherwise). With no model named, the newest default (v4) is reported.
77
+ */
78
+ export function dictationPolishPromptChoice(env: NodeJS.ProcessEnv = process.env, model?: string): { version: DictationPolishPromptVersion; source: PromptVersionSource } {
79
+ const raw = (env.COS_DICTATION_POLISH_PROMPT ?? '').trim().toLowerCase()
80
+ if ((DICTATION_POLISH_PROMPT_VERSIONS as readonly string[]).includes(raw)) return { version: raw as DictationPolishPromptVersion, source: 'env' }
81
+ const alias = (env.COS_DICTATION_PROMPT_VERSION ?? '').trim().toLowerCase().replace(/^(\d)$/, 'v$1')
82
+ if ((DICTATION_POLISH_PROMPT_VERSIONS as readonly string[]).includes(alias)) return { version: alias as DictationPolishPromptVersion, source: 'env' }
83
+ if (readPolishOptions().promptVersion === 'v3') return { version: 'v3', source: 'option' }
84
+ return { version: model === undefined ? DEFAULT_DICTATION_POLISH_PROMPT : defaultPromptFor(model), source: 'default' }
85
+ }
86
+
87
+ export function dictationPolishPromptVersion(env: NodeJS.ProcessEnv = process.env, model?: string): DictationPolishPromptVersion {
88
+ return dictationPolishPromptChoice(env, model).version
89
+ }
90
+
91
+ /**
92
+ * 6.91.0: the model and prompt pairs the learned style and the literal register were canaried on
93
+ * (C2 and C3 ran on Sonnet with v4 only). Any other pair gets neither: no learned style, no
94
+ * register block, no literal check, and no `polishRegister` in the answer.
95
+ */
96
+ export function styleAndRegisterApply(model: string | undefined, version: DictationPolishPromptVersion): boolean {
97
+ return model === 'sonnet' && version === 'v4'
98
+ }
@@ -0,0 +1,292 @@
1
+ import type { DictationCleanModel } from './dictation-clean-models.js'
2
+ import { applyNegativeRules } from './hallucination-filter.js'
3
+ import { applyCorrections } from './whisper-local.js'
4
+ import { applyFuzzyCorrections } from './fuzzy-correct.js'
5
+ import { getAllSpeakerNames } from './speaker-embeddings.js'
6
+ import { autoCleanDictation, AUTOCLEAN_MAX_CHARS } from './dictation-clean.js'
7
+ import { dictationPolishPromptVersion, styleAndRegisterApply, type DictationPolishPromptVersion } from './dictation-polish-options.js'
8
+ import { fillerPassEnabled, stripDisfluencies } from './dictation-fillers.js'
9
+ import { polishBudgetExhausted, recordPolishCall, type PolishBucket } from './dictation-polish-budget.js'
10
+ import { polishNeed, skipCleanEnabled } from './dictation-polish-need.js'
11
+ import { discardPolishPrewarm, PrewarmUnavailableError, runWarmPolish, takePolishPrewarm } from './dictation-prewarm.js'
12
+ import { getVocabulary, getWhisperCorrections } from './profile.js'
13
+ import { correctionOnlyForms } from './glossary-learn.js'
14
+ import { polishLinkProblem, renderSpokenPaths, spokenPathsEnabled } from './dictation-format.js'
15
+ import { customInstructionsHash, readCustomInstructions } from './dictation-polish-instructions.js'
16
+ import { learnedStyleEnabled, learnedStyleLinesFor, readLearnedStyle } from './dictation-learned-style.js'
17
+ import { literalAnswer, type DictationRegister } from './dictation-register.js'
18
+ import { meaningGuardCounts, meaningGuardEnabled, meaningGuardProblem, type MeaningGuardKind } from './dictation-meaning-guard.js'
19
+ import { createBreaker } from './claude-circuit.js'
20
+ import { logTokenAudit } from './token-audit.js'
21
+
22
+ /**
23
+ * The outbound dictation polish: the one pipeline behind POST /api/dictation/finalize and the
24
+ * prompt-draft finalize (routes/prompt-drafts.ts). 6.91.0 moved it here, unchanged in order, so
25
+ * the offline evals call this function itself instead of a copy of it:
26
+ *
27
+ * dictionary (exact corrections and negative rules), the fuzzy name pass, the filler pass and
28
+ * spoken paths (not for meeting lines), the gates (disabled, too long, clean, breaker, daily
29
+ * cap), the model (warm when a prewarm token matches, else cold), then the guards: the link
30
+ * guard on every surface; for message and prompt_draft on the canaried pair only (Sonnet with
31
+ * prompt v4), the literal register's word check and the meaning guard.
32
+ *
33
+ * Meeting lines are a faithful record and stay as 6.90.0 had them: prompt v3 (or older), no
34
+ * register, no instructions, no learned style, no meaning guard.
35
+ */
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' }),
41
+ }
42
+ const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
43
+
44
+ /** 6.79.1, additive on finalize answers: why the polish model did not run (or did not finish).
45
+ * 6.91.0: a guard that refuses the model's answer says "failed" too (link_guard keeps its own
46
+ * code), so 0.5.325 to 0.5.343 log fillers_only:failed and never claim polish. */
47
+ export type PolishSkipped = 'disabled' | 'too_long' | 'breaker_open' | 'daily_cap' | 'failed' | 'clean' | 'link_guard'
48
+ /** 6.79.2, additive when finalize was given a prewarmToken: what became of that warm process. */
49
+ export type PrewarmOutcome = 'used' | 'missed' | 'discarded'
50
+ /** 6.91.0, additive, present exactly when the model answered: what became of its answer. */
51
+ export type PolishOutcome = 'polished' | 'unchanged' | 'guarded'
52
+ export type PolishGuard = 'link' | 'literal' | MeaningGuardKind
53
+ /** 6.91.0, additive: the prompt the model ran, as a closed set (v1 and v2 are not reported). */
54
+ export type PolishPrompt = 'v3' | 'v4'
55
+
56
+ export interface AutoCleanRequest { enabled?: boolean; model?: DictationCleanModel }
57
+ export type OutboundOptions = AutoCleanRequest & {
58
+ bucket: PolishBucket
59
+ signal?: AbortSignal
60
+ prewarmToken?: string
61
+ /** 6.91.0: the target app's register (finalize reads it from `context.app`). */
62
+ register?: DictationRegister
63
+ /** Offline evals only: the prompt to use instead of the configured one. */
64
+ promptVersion?: DictationPolishPromptVersion
65
+ }
66
+
67
+ /** 6.85.0 and 6.91.0: additive, present only when the model was given them. */
68
+ export interface PolishInstructionsUse {
69
+ customInstructions?: true
70
+ instructionsHash?: string
71
+ learnedStyle?: true
72
+ learnedStyleHash?: string
73
+ /** 6.91.0: the register the model was given, only on a pair the register was canaried on (Sonnet with v4). */
74
+ polishRegister?: DictationRegister
75
+ /** 6.91.0: the prompt the model ran (v3 or v4), whenever the model was called. */
76
+ polishPrompt?: PolishPrompt
77
+ /** 6.91.0: false whenever the model was called without the learned style. */
78
+ learnedStyleOff?: true
79
+ }
80
+ export type PolishResult = {
81
+ text: string
82
+ polishSkipped?: PolishSkipped
83
+ prewarm?: PrewarmOutcome
84
+ polishOutcome?: PolishOutcome
85
+ guard?: PolishGuard
86
+ /** Never sent by a route: the model's own answer, before any guard (the offline evals score it). */
87
+ modelText?: string
88
+ } & PolishInstructionsUse
89
+
90
+ export async function cleanOutboundDictation(
91
+ text: string,
92
+ opts: OutboundOptions,
93
+ ): Promise<PolishResult> {
94
+ const result = await polishOutboundDictation(text, opts)
95
+ // A warm process the model call did not take must not sit until its TTL.
96
+ if (opts.prewarmToken && !result.prewarm) {
97
+ return { ...result, prewarm: discardPolishPrewarm(opts.prewarmToken) ? 'discarded' : 'missed' }
98
+ }
99
+ return result
100
+ }
101
+
102
+ /**
103
+ * 6.85.0: the user's polish instructions apply to dictation (Mac dictation and G2 messages, which
104
+ * are surface "message", and prompt drafts). Meeting lines are a faithful record, so they stay on
105
+ * the built-in rules alone, the same way they skip the filler pass and spoken paths.
106
+ */
107
+ export function polishInstructionsFor(bucket: PolishBucket): string {
108
+ return bucket === 'meeting' ? '' : readCustomInstructions().customInstructions
109
+ }
110
+
111
+ /**
112
+ * 6.91.0: the learned style for one polish: the lines for its register and the setting's hash.
113
+ * Never for meeting lines or a literal target, and nothing at all when the server switch
114
+ * (COS_DICTATION_LEARNED_STYLE=0) or the person's own switch (enabled: false) is off.
115
+ */
116
+ export function learnedStyleFor(bucket: PolishBucket, register: DictationRegister): { lines: string; hash: string } {
117
+ if (bucket === 'meeting' || !learnedStyleEnabled()) return { lines: '', hash: '' }
118
+ const stored = readLearnedStyle()
119
+ const lines = learnedStyleLinesFor(stored, register)
120
+ return lines ? { lines, hash: stored.hash } : { lines: '', hash: '' }
121
+ }
122
+
123
+ /**
124
+ * 6.91.0: the prompt for one polish. The configured prompt for its model (v4 on Sonnet, v3 on Haiku,
125
+ * Luna or anything else, unless the environment or "Previous polish" says otherwise); meeting lines
126
+ * never take v4.
127
+ */
128
+ export function polishPromptVersionFor(bucket: PolishBucket, wanted?: DictationPolishPromptVersion, model?: string): DictationPolishPromptVersion {
129
+ const version = wanted ?? dictationPolishPromptVersion(process.env, model)
130
+ return bucket === 'meeting' && version === 'v4' ? 'v3' : version
131
+ }
132
+
133
+ /** The deterministic passes, in route order: what the model is given ("cleaned"). */
134
+ export function prepareOutboundDictation(text: string, bucket: PolishBucket): string {
135
+ let cleaned = applyNegativeRules(applyCorrections(text)).replace(/\s+/g, ' ').trim() || text
136
+ // applyCorrections above is an EXACT string map, so it only fixes misspellings someone
137
+ // already hand-authored. A novel miss ("Rileyy" for Riley, "Exampel" for Example) sails
138
+ // through it. The Levenshtein pass is what catches those, and until now it had exactly
139
+ // ONE call site — transcribe-audio.ts:252, the server-transcription route — so phone
140
+ // Moonshine dictation, which arrives here as text, never got it. Same construction and
141
+ // same non-fatal posture as that site, deliberately: targets are speaker names plus
142
+ // vocabulary, and a throw must never cost the user their transcript.
143
+ //
144
+ // Runs BEFORE the autoclean LLM so the model sees corrected proper nouns rather than
145
+ // being asked to guess at them, and it still helps on every path where autoclean is
146
+ // off, over the char cap, or breaker-open.
147
+ try {
148
+ const fuzzyTargets = [...getAllSpeakerNames(), ...getVocabulary()]
149
+ const { text: corrected, replacements } = applyFuzzyCorrections(cleaned, fuzzyTargets)
150
+ if (replacements > 0) {
151
+ console.log(`[prompt-draft] Fuzzy corrected ${replacements} word(s)`)
152
+ cleaned = corrected
153
+ }
154
+ } catch (fuzzyErr: any) {
155
+ console.warn(`[prompt-draft] Fuzzy correction failed (non-fatal): ${fuzzyErr?.message ?? fuzzyErr}`)
156
+ }
157
+ // 6.79.1: um/uh never survive a skipped polish. Meeting lines stay faithful.
158
+ if (bucket !== 'meeting' && fillerPassEnabled()) cleaned = stripDisfluencies(cleaned)
159
+ // 6.83.1: "forward slash pricing," is "/pricing," even when the model is skipped. One word
160
+ // at a phrase end only; slugs of several words are the model's (dictation-format.ts).
161
+ if (bucket !== 'meeting' && spokenPathsEnabled()) cleaned = renderSpokenPaths(cleaned)
162
+ return cleaned
163
+ }
164
+
165
+ /**
166
+ * 6.91.0: the guards after the model, for one answer. Pure apart from reading the dictionary.
167
+ * `text` is what goes out: the answer (repaired for a literal target), or `cleaned` when a guard
168
+ * refused it. The link guard runs on every answer, as in 6.83.1. The literal check and the meaning
169
+ * guard run only on the canaried pair (Sonnet with prompt v4, `canaried`), never on meeting lines:
170
+ * Haiku, Luna, any other model and prompt v3 (the environment or "Previous polish") get 6.90.0's
171
+ * answer, guard for guard.
172
+ */
173
+ export function guardPolishAnswer(
174
+ cleaned: string, polished: string, bucket: PolishBucket, register: DictationRegister, canaried: boolean,
175
+ ): { text: string; outcome: PolishOutcome; guard?: PolishGuard; polishSkipped?: PolishSkipped } {
176
+ // 6.83.1: the model never invents a link and never drops one the dictionary wrote. Its
177
+ // answer is refused (the pre-polish text goes out, polishSkipped "link_guard") rather than
178
+ // pasting a URL nobody said. The call still counted: it ran.
179
+ const linkProblem = polished ? polishLinkProblem(cleaned, polished, correctionOnlyForms(getWhisperCorrections()), getVocabulary()) : null
180
+ if (linkProblem) {
181
+ console.warn(`[prompt-draft] auto-clean answer refused: ${linkProblem}`)
182
+ return { text: cleaned, outcome: 'guarded', guard: 'link', polishSkipped: 'link_guard' }
183
+ }
184
+ let answer = polished || cleaned
185
+ if (canaried && bucket !== 'meeting') {
186
+ // 6.91.0: a terminal gets the speaker's words. Anything but fillers, stutters, punctuation and
187
+ // case changed means the input goes out; otherwise the input's casing is put back and no
188
+ // closing period is added (dictation-register.ts literalAnswer).
189
+ if (register === 'literal') {
190
+ const literal = literalAnswer(cleaned, answer)
191
+ if (literal === null) {
192
+ console.warn('[prompt-draft] auto-clean answer guarded: literal')
193
+ return { text: cleaned, outcome: 'guarded', guard: 'literal', polishSkipped: 'failed' }
194
+ }
195
+ answer = literal || cleaned
196
+ }
197
+ // 6.91.0: nor may it lose (or add) a negation, number, date or name the speaker kept. A
198
+ // self-correction may drop what it replaced. Counts only in the log, never a word.
199
+ if (meaningGuardEnabled()) {
200
+ let problem: ReturnType<typeof meaningGuardProblem>
201
+ try {
202
+ problem = meaningGuardProblem(cleaned, answer, [...getVocabulary(), ...getAllSpeakerNames()])
203
+ } catch {
204
+ // A guard that could not read the answer does not vouch for it.
205
+ console.warn('[prompt-draft] auto-clean answer guarded: unverified')
206
+ return { text: cleaned, outcome: 'guarded', polishSkipped: 'failed' }
207
+ }
208
+ if (problem) {
209
+ console.warn(`[prompt-draft] auto-clean answer guarded: ${meaningGuardCounts(problem)}`)
210
+ return { text: cleaned, outcome: 'guarded', guard: problem.kind, polishSkipped: 'failed' }
211
+ }
212
+ }
213
+ }
214
+ return { text: answer, outcome: answer !== cleaned ? 'polished' : 'unchanged' }
215
+ }
216
+
217
+ export async function polishOutboundDictation(
218
+ text: string,
219
+ opts: OutboundOptions,
220
+ ): Promise<PolishResult> {
221
+ const cleaned = prepareOutboundDictation(text, opts.bucket)
222
+ if (!(opts.enabled ?? autoCleanDefaultEnabled())) return { text: cleaned, polishSkipped: 'disabled' }
223
+ const model = opts.model ?? 'haiku'
224
+ const autoCleanBreaker = autoCleanBreakers[model]
225
+ if (cleaned.length > AUTOCLEAN_MAX_CHARS) return { text: cleaned, polishSkipped: 'too_long' }
226
+ // 6.79.2, off by default: nothing for the model to fix (see dictation-polish-need.ts).
227
+ if (skipCleanEnabled() && polishNeed(cleaned) === null) return { text: cleaned, polishSkipped: 'clean' }
228
+ if (autoCleanBreaker.isOpen()) return { text: cleaned, polishSkipped: 'breaker_open' }
229
+ if (polishBudgetExhausted(opts.bucket)) return { text: cleaned, polishSkipped: 'daily_cap' }
230
+ const startedAt = Date.now()
231
+ recordPolishCall(opts.bucket)
232
+ // Read once per polish: the warm and cold paths see the same prompt.
233
+ const meeting = opts.bucket === 'meeting'
234
+ const promptVersion = polishPromptVersionFor(opts.bucket, opts.promptVersion, model)
235
+ const customInstructions = polishInstructionsFor(opts.bucket)
236
+ const instructionsHash = customInstructionsHash(customInstructions)
237
+ // 6.91.0: the register, the learned style and the meaning guard only where they were canaried
238
+ // (Sonnet with v4); every other pair gets none of them, so its prompt and its answer are 6.90.0's.
239
+ // Meeting lines never reach v4 (polishPromptVersionFor), so they never get any.
240
+ const canaried = styleAndRegisterApply(model, promptVersion)
241
+ const register: DictationRegister = canaried ? opts.register ?? 'default' : 'default'
242
+ const style = canaried ? learnedStyleFor(opts.bucket, register) : { lines: '', hash: '' }
243
+ const used: PolishInstructionsUse = {
244
+ ...(customInstructions ? { customInstructions: true, instructionsHash } : {}),
245
+ ...(style.lines ? { learnedStyle: true, learnedStyleHash: style.hash } : { learnedStyleOff: true }),
246
+ ...(canaried ? { polishRegister: register } : {}),
247
+ ...(promptVersion === 'v3' || promptVersion === 'v4' ? { polishPrompt: promptVersion } : {}),
248
+ }
249
+ const promptExtras = { customInstructions, learnedStyle: style.lines, register, promptVersion }
250
+ // 6.79.2: a warm process started at key press answers without the CLI's start-up cost.
251
+ const warmEntry = opts.prewarmToken ? takePolishPrewarm(opts.prewarmToken, model) : null
252
+ let prewarm: PrewarmOutcome | undefined = opts.prewarmToken ? 'missed' : undefined
253
+ try {
254
+ let polished: string | null = null
255
+ if (warmEntry) {
256
+ try {
257
+ polished = (await runWarmPolish(warmEntry, cleaned, getVocabulary(), { signal: opts.signal, ...promptExtras })).trim()
258
+ prewarm = 'used'
259
+ } catch (err) {
260
+ // Nothing was sent: the cold path below is the same call, not a second one.
261
+ if (!(err instanceof PrewarmUnavailableError)) throw err
262
+ }
263
+ }
264
+ if (polished === null) polished = (await autoCleanDictation(cleaned, getVocabulary(), { model, signal: opts.signal, ...promptExtras })).trim()
265
+ // An empty answer is a failed polish, as 6.90.0 reported it (both model paths already refuse
266
+ // one; this holds if either ever stops): the input goes out, polishSkipped "failed", and the
267
+ // breaker counts it. Never "unchanged".
268
+ if (!polished) throw new Error('Auto-clean returned empty text')
269
+ autoCleanBreaker.recordSuccess()
270
+ const guarded = guardPolishAnswer(cleaned, polished, opts.bucket, register, canaried)
271
+ logTokenAudit({
272
+ source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: polished.length,
273
+ durationMs: Date.now() - startedAt, caller: `dictation_autoclean_${opts.bucket}`,
274
+ polishInstructions: { on: Boolean(customInstructions), hash: instructionsHash },
275
+ // 6.91.0: codes and a hash only, never the app name or a word.
276
+ learnedStyle: { on: Boolean(style.lines), hash: style.hash },
277
+ ...(canaried ? { polishRegister: register } : {}),
278
+ ...(used.polishPrompt ? { polishPrompt: used.polishPrompt } : {}),
279
+ polishOutcome: guarded.outcome, ...(guarded.guard ? { polishGuard: guarded.guard } : {}),
280
+ })
281
+ return {
282
+ text: guarded.text, ...(prewarm ? { prewarm } : {}),
283
+ ...(guarded.polishSkipped ? { polishSkipped: guarded.polishSkipped } : {}),
284
+ polishOutcome: guarded.outcome, ...(guarded.guard ? { guard: guarded.guard } : {}),
285
+ modelText: polished, ...used,
286
+ }
287
+ } catch (err: any) {
288
+ autoCleanBreaker.recordFailure()
289
+ console.warn(`[prompt-draft] auto-clean failed (glossary-only): ${err?.message ?? err}`)
290
+ return { text: cleaned, polishSkipped: 'failed', ...(prewarm ? { prewarm } : {}), ...used }
291
+ }
292
+ }
@@ -6,8 +6,8 @@ import { join } from 'node:path'
6
6
  import { resolveProviderBinary } from './provider-binary.js'
7
7
  import { terminateProviderProcess } from './provider-process-lifecycle.js'
8
8
  import {
9
- buildDictationPolishPrompt, claudePolishArgs, dictationChildEnv, dictationPolishPromptVersion,
10
- type DictationPolishPromptVersion,
9
+ buildDictationPolishPrompt, claudePolishArgs, DICTATION_POLISH_SYSTEM_PROMPTS, dictationChildEnv, dictationPolishPromptVersion,
10
+ type DictationPolishPromptVersion, type DictationPromptExtras,
11
11
  } from './dictation-clean.js'
12
12
 
13
13
  /**
@@ -67,11 +67,11 @@ function retire(entry: WarmPolish): void {
67
67
  if (!entry.exited) void terminateProviderProcess(entry.proc).catch(() => {})
68
68
  }
69
69
 
70
- export function startPolishPrewarm(model: PrewarmModel): { token: string; expiresMs: number } {
70
+ /** `version` defaults to the configured prompt; only the offline evals pass one (6.91.0). */
71
+ export function startPolishPrewarm(model: PrewarmModel, version: DictationPolishPromptVersion = dictationPolishPromptVersion(process.env, model)): { token: string; expiresMs: number } {
71
72
  const binary = resolveProviderBinary('claude')
72
73
  if (!binary.ok) throw new PrewarmUnavailableError('Dictation cleanup Claude CLI unavailable')
73
74
  while (warm.size >= PREWARM_MAX) retire(warm.values().next().value!)
74
- const version = dictationPolishPromptVersion()
75
75
  const scratch = mkdtempSync(join(tmpdir(), 'cos-dictation-prewarm-'))
76
76
  const workspace = join(scratch, 'workspace')
77
77
  mkdirSync(workspace, { mode: 0o700 })
@@ -134,6 +134,15 @@ export function takePolishPrewarm(token: string | undefined, model: string): War
134
134
  return entry
135
135
  }
136
136
 
137
+ /**
138
+ * 6.91.0: the version the one message is built with. A caller may ask for another version (meeting
139
+ * lines stay on v3 while dictation runs v4) only when both share the system prompt the process was
140
+ * started with; otherwise the process's own version is used, as before.
141
+ */
142
+ export function messageVersion(started: DictationPolishPromptVersion, wanted: DictationPolishPromptVersion | undefined): DictationPolishPromptVersion {
143
+ return wanted && DICTATION_POLISH_SYSTEM_PROMPTS[wanted] === DICTATION_POLISH_SYSTEM_PROMPTS[started] ? wanted : started
144
+ }
145
+
137
146
  /**
138
147
  * Write the one message and read the one result. PrewarmUnavailableError means nothing was
139
148
  * sent (the process had already gone), so the caller may spawn cold. Any later failure means
@@ -143,7 +152,7 @@ export async function runWarmPolish(
143
152
  entry: WarmPolish,
144
153
  text: string,
145
154
  terms: string[],
146
- opts: { signal?: AbortSignal; customInstructions?: string } = {},
155
+ opts: { signal?: AbortSignal; customInstructions?: string; promptVersion?: DictationPolishPromptVersion } & DictationPromptExtras = {},
147
156
  ): Promise<string> {
148
157
  if (entry.exited || !entry.proc.stdin || entry.proc.stdin.destroyed) {
149
158
  retire(entry)
@@ -180,7 +189,10 @@ export async function runWarmPolish(
180
189
  opts.signal?.addEventListener('abort', abort, { once: true })
181
190
  const message = JSON.stringify({
182
191
  type: 'user',
183
- message: { role: 'user', content: buildDictationPolishPrompt(text, terms, entry.version, opts.customInstructions ?? '') },
192
+ message: {
193
+ role: 'user',
194
+ content: buildDictationPolishPrompt(text, terms, messageVersion(entry.version, opts.promptVersion), opts.customInstructions ?? '', { learnedStyle: opts.learnedStyle, register: opts.register }),
195
+ },
184
196
  })
185
197
  try {
186
198
  entry.proc.stdin!.write(message + '\n')
@@ -0,0 +1,114 @@
1
+ import { readSwitch } from './env-switch.js'
2
+
3
+ /**
4
+ * 6.91.0: the register a dictation is written in, chosen from the app it is going to.
5
+ *
6
+ * COS Control has sent the frontmost app's localized name as `context.app` on every Mac polish
7
+ * since 0.5.325; until 6.91.0 the server ignored it. The MVP has two registers (plan r2):
8
+ *
9
+ * - literal: terminals (iTerm2, Terminal, Ghostty, Warp, WezTerm, Alacritty, kitty). The model
10
+ * adds no sentence capital, no trailing period and no learned style, and keeps the speaker's
11
+ * words (self-corrections and spoken paths included); a deterministic check then requires the
12
+ * answer's words to equal its input's apart from fillers, stutters, punctuation and case, and
13
+ * falls back to the input otherwise (finalize `guard: "literal"`).
14
+ * - default: everything else, Claude, Cursor and Codex included, and an unknown, blank or
15
+ * malformed app name. G2 messages (no app) and meeting lines are default too.
16
+ *
17
+ * ONE table, matched on the exact name (case and surrounding space ignored), published to Control
18
+ * by GET /api/dictation/learned-style (`registers`) so Control never keeps a second copy. The app
19
+ * name is read here, never logged, audited or echoed.
20
+ *
21
+ * The literal register is OPT-IN (COS_DICTATION_REGISTERS=1): terminal targets are mostly AI
22
+ * prompts in prose (Claude Code, Codex), not shell commands, and literal would stop resolving
23
+ * corrections, fixing grammar and capitalizing them. Off (the default), a terminal gets the
24
+ * default register, and the prompt carries no register block at all. The map is published either
25
+ * way; `registersEnabled` says whether it is applied.
26
+ */
27
+ export type DictationRegister = 'literal' | 'default'
28
+ export const DICTATION_REGISTERS: readonly DictationRegister[] = ['literal', 'default']
29
+
30
+ export const LITERAL_APPS: readonly string[] = ['iTerm2', 'Terminal', 'Ghostty', 'Warp', 'WezTerm', 'Alacritty', 'kitty']
31
+
32
+ const LITERAL = new Set(LITERAL_APPS.map(app => app.toLowerCase()))
33
+
34
+ /** Off unless COS_DICTATION_REGISTERS is 1, true or on. */
35
+ export function registersEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
36
+ return readSwitch(env.COS_DICTATION_REGISTERS, false)
37
+ }
38
+
39
+ /**
40
+ * The register for a frontmost-app name: an exact table match (case and surrounding space ignored)
41
+ * or `default`. A long, multi-line or non-string value can never match the table, so it is default
42
+ * without a separate check (the 6.91.0 mutation gate showed one was unreachable).
43
+ */
44
+ export function registerForApp(app: unknown, env: NodeJS.ProcessEnv = process.env): DictationRegister {
45
+ if (!registersEnabled(env) || typeof app !== 'string') return 'default'
46
+ return LITERAL.has(app.trim().toLowerCase()) ? 'literal' : 'default'
47
+ }
48
+
49
+ /** `context.app` from a finalize body, without trusting its shape (a primitive or array has no `app`). */
50
+ export function registerFromContext(context: unknown, env: NodeJS.ProcessEnv = process.env): DictationRegister {
51
+ return registerForApp((context as { app?: unknown } | null | undefined)?.app, env)
52
+ }
53
+
54
+ /** What GET /api/dictation/learned-style publishes: every app that is not default, applied or not. */
55
+ export function publishedRegisters(): Record<string, 'literal'> {
56
+ return Object.fromEntries(LITERAL_APPS.map(app => [app, 'literal' as const]))
57
+ }
58
+
59
+ /** Verbal tics the literal check lets the model drop (one word or two). Never a negation or number. */
60
+ const LITERAL_DROPPABLE = new Set(['um', 'umm', 'uh', 'uhh', 'uhm', 'er', 'erm', 'ah', 'ahh', 'hmm', 'hm', 'mm', 'mhm',
61
+ 'like', 'basically', 'so', 'well'])
62
+ const LITERAL_DROPPABLE_PAIRS = new Set(['you know', 'i mean', 'kind of', 'sort of'])
63
+
64
+ interface Word { lower: string; start: number; end: number }
65
+ function words(text: string): Word[] {
66
+ const out: Word[] = []
67
+ const re = /[\p{L}\p{N}]+(?:['’][\p{L}]+)*/gu
68
+ let m: RegExpExecArray | null
69
+ while ((m = re.exec(text))) out.push({ lower: m[0].toLowerCase().replace(/’/g, "'"), start: m.index, end: m.index + m[0].length })
70
+ return out
71
+ }
72
+
73
+ /** The words that carry the text: fillers out, a stutter ("the the") collapsed. */
74
+ function core(list: Word[]): Word[] {
75
+ const out: Word[] = []
76
+ for (let i = 0; i < list.length; i++) {
77
+ const pair = i + 1 < list.length ? `${list[i].lower} ${list[i + 1].lower}` : ''
78
+ if (pair && LITERAL_DROPPABLE_PAIRS.has(pair)) { i++; continue }
79
+ if (LITERAL_DROPPABLE.has(list[i].lower)) continue
80
+ if (out.length && out[out.length - 1].lower === list[i].lower) continue
81
+ out.push(list[i])
82
+ }
83
+ return out
84
+ }
85
+
86
+ /**
87
+ * The literal register's deterministic check and repair. Pure.
88
+ *
89
+ * `null` when the answer changed a word (beyond fillers, stutters, punctuation and case): the
90
+ * caller sends the input instead. Otherwise the answer with the input's own casing put back on
91
+ * every word, and without a trailing period the input did not have, so a terminal gets no added
92
+ * capital and no added period whatever the model did.
93
+ */
94
+ export function literalAnswer(input: string, answer: string): string | null {
95
+ const a = core(words(answer))
96
+ const b = core(words(input))
97
+ if (a.length !== b.length || a.some((w, i) => w.lower !== b[i].lower)) return null
98
+ const inputWords = words(input)
99
+ const original = new Map<string, string[]>()
100
+ for (const w of inputWords) original.set(w.lower, [...(original.get(w.lower) ?? []), input.slice(w.start, w.end)])
101
+ let out = ''
102
+ let last = 0
103
+ const seen = new Map<string, number>()
104
+ for (const w of words(answer)) {
105
+ const forms = original.get(w.lower)
106
+ const k = seen.get(w.lower) ?? 0
107
+ seen.set(w.lower, k + 1)
108
+ out += answer.slice(last, w.start) + (forms ? forms[Math.min(k, forms.length - 1)] : answer.slice(w.start, w.end))
109
+ last = w.end
110
+ }
111
+ out += answer.slice(last)
112
+ if (/\.\s*$/.test(out) && !/\.\s*$/.test(input)) out = out.replace(/\.(\s*)$/, '$1')
113
+ return out.trim()
114
+ }
@@ -0,0 +1,50 @@
1
+ // 6.90.0: unnamed, meeting-stable voice labels for Mac recordings.
2
+ //
3
+ // A Mac recording hears call audio, video and podcasts through one global tap.
4
+ // Naming a voice from that audio is not safe (no calibration exists, see
5
+ // CHANGELOG 6.73.6 and the 2026-10-14 Mac plan section 5), so every Mac chunk is
6
+ // `Ext`. What IS safe is telling voices apart: one Nemotron pass over the whole
7
+ // saved meeting gives channels that are consistent for the call, and each
8
+ // confident channel becomes `Voice 1`, `Voice 2`, ... in order of first speech.
9
+ //
10
+ // A `Voice N` label asserts NO identity. Everywhere a real name is checked it is
11
+ // a placeholder, exactly like `Ext` and `Unidentified N`: it is never a profile
12
+ // name, never enrolled, never trained, never written into the meeting's
13
+ // `speakers` list. A person may rename it (text only) through the relabel route.
14
+ //
15
+ // Switch: COS_MAC_VOICE_LABELS, on by default; 0, false, off or no turns it off
16
+ // and Mac recordings stay all `Ext` exactly as 6.89.0 saved them.
17
+
18
+ // No imports on purpose: placeholder checks are read from the speaker-review,
19
+ // relabel and store layers, and this must never pull the Mac registry into them.
20
+ const MAC_SOURCE_TYPE = 'mac_system_audio'
21
+
22
+ /** `Voice 1` .. `Voice 99`. No leading zero, single space, nothing after. */
23
+ export const VOICE_LABEL = /^Voice [1-9]\d?$/
24
+
25
+ export function isVoiceLabel(label: unknown): boolean {
26
+ return typeof label === 'string' && VOICE_LABEL.test(label)
27
+ }
28
+
29
+ export function voiceLabel(n: number): string {
30
+ if (!Number.isInteger(n) || n < 1 || n > 99) throw new Error('voice number out of range')
31
+ return `Voice ${n}`
32
+ }
33
+
34
+ /** On unless explicitly switched off. Read at use, so a managed restart is not needed to turn it off. */
35
+ export function macVoiceLabelsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
36
+ const raw = String(env.COS_MAC_VOICE_LABELS ?? '').trim().toLowerCase()
37
+ return !['0', 'false', 'off', 'no'].includes(raw)
38
+ }
39
+
40
+ /**
41
+ * A sidecar written by the Mac recording save path (`sourceType`), or any `mac_`
42
+ * id. Deliberately broader than MAC_CAPTURE_ID: this answers "could this be Mac
43
+ * audio", and a guard that asks it must fail closed on a malformed Mac id too.
44
+ */
45
+ export function isMacCaptureSidecar(sidecar: Record<string, unknown> | null | undefined, sessionId?: string): boolean {
46
+ if (typeof sessionId === 'string' && sessionId.startsWith('mac_')) return true
47
+ if (!sidecar || typeof sidecar !== 'object') return false
48
+ if (sidecar.sourceType === MAC_SOURCE_TYPE) return true
49
+ return typeof sidecar.sessionId === 'string' && sidecar.sessionId.startsWith('mac_')
50
+ }