@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
|
@@ -9,6 +9,22 @@
|
|
|
9
9
|
// Clients find it through capabilities.dictationCleanup.customInstructions on /api/health and
|
|
10
10
|
// /api/models; an older server omits it (and answers 404 here), so the clients hide the box.
|
|
11
11
|
// The text is never logged and never returned anywhere but GET/PUT here.
|
|
12
|
+
//
|
|
13
|
+
// 6.91.0 (plan r2, contract frozen 2026-10-24). Token-protected too:
|
|
14
|
+
//
|
|
15
|
+
// GET /api/dictation/learned-style {enabled, rules, text, hash, revision, updatedAt, registers, kinds, allowlist, ...}
|
|
16
|
+
// PUT /api/dictation/learned-style {enabled?, rules?: [{id, kind, register, param?, count, lastSeen}], baseRevision?}
|
|
17
|
+
// Strict keys. text, hash, revision and updatedAt are the server's
|
|
18
|
+
// (400); a stale baseRevision (it covers the rules AND the switch)
|
|
19
|
+
// is 409; over 20 rules or 1,500 characters is 413.
|
|
20
|
+
// DELETE /api/dictation/learned-style Reset: the rules go, the switch stays as it was
|
|
21
|
+
// GET /api/dictation/polish/options {promptVersion, effectiveVersion, source, envOverride, updatedAt}
|
|
22
|
+
// PUT /api/dictation/polish/options {promptVersion: "default" | "v3"}: Control's "Previous polish"
|
|
23
|
+
//
|
|
24
|
+
// Clients find them through capabilities.dictationCleanup.learnedStyle {protocol, maxChars,
|
|
25
|
+
// maxRules, enabled} and capabilities.dictationCleanup.polishOptions {protocol} on /api/health and
|
|
26
|
+
// /api/models. learnedStyle.enabled is false with COS_DICTATION_LEARNED_STYLE=0: the routes still
|
|
27
|
+
// answer, and no prompt carries the rules.
|
|
12
28
|
|
|
13
29
|
import { Router } from 'express'
|
|
14
30
|
import {
|
|
@@ -19,6 +35,17 @@ import {
|
|
|
19
35
|
CUSTOM_INSTRUCTIONS_MAX_CHARS, CUSTOM_INSTRUCTIONS_PROTOCOL, customInstructionsHash, readCustomInstructions,
|
|
20
36
|
sanitizeCustomInstructions, writeCustomInstructions, type StoredInstructions,
|
|
21
37
|
} from '../lib/dictation-polish-instructions.js'
|
|
38
|
+
import {
|
|
39
|
+
CONTRACTION_PARAMS, LEARNED_STYLE_ALLOWLIST, LEARNED_STYLE_KINDS, LEARNED_STYLE_MAX_CHARS, LEARNED_STYLE_MAX_RULES,
|
|
40
|
+
LEARNED_STYLE_PROTOCOL, LEARNED_STYLE_REGISTERS, learnedStyleEnabled, learnedStyleLinesFor, readLearnedStyle,
|
|
41
|
+
validateLearnedStyleRules, writeLearnedStyle, type StoredLearnedStyle,
|
|
42
|
+
} from '../lib/dictation-learned-style.js'
|
|
43
|
+
import {
|
|
44
|
+
dictationPolishPromptChoice, POLISH_MODEL_DEFAULTS, POLISH_OPTIONS_PROTOCOL, POLISH_PROMPT_OPTIONS, readPolishOptions,
|
|
45
|
+
styleAndRegisterApply, writePolishOptions, type PolishPromptOption,
|
|
46
|
+
} from '../lib/dictation-polish-options.js'
|
|
47
|
+
import { LEARNED_STYLE_INTRO, LEARNED_STYLE_LIMITS, learnedStyleBlock, LITERAL_REGISTER_RULES, V4_CLOSING_RULE, V4_LITERAL_CLOSING_RULE } from '../lib/dictation-clean.js'
|
|
48
|
+
import { publishedRegisters, registersEnabled } from '../lib/dictation-register.js'
|
|
22
49
|
|
|
23
50
|
/** Where the instructions apply (prompt-drafts.ts polishInstructionsFor): never meeting lines. */
|
|
24
51
|
export const CUSTOM_INSTRUCTIONS_APPLY_TO = ['message', 'prompt_draft'] as const
|
|
@@ -28,6 +55,21 @@ export function customInstructionsCapability() {
|
|
|
28
55
|
return { protocol: CUSTOM_INSTRUCTIONS_PROTOCOL, maxChars: CUSTOM_INSTRUCTIONS_MAX_CHARS }
|
|
29
56
|
}
|
|
30
57
|
|
|
58
|
+
/**
|
|
59
|
+
* 6.91.0: capabilities.dictationCleanup.learnedStyle on both health surfaces. Present on every
|
|
60
|
+
* 6.91.0 server; `enabled` is the server's switch (COS_DICTATION_LEARNED_STYLE). Codes and numbers only.
|
|
61
|
+
*/
|
|
62
|
+
export function learnedStyleCapability() {
|
|
63
|
+
return { protocol: LEARNED_STYLE_PROTOCOL, maxChars: LEARNED_STYLE_MAX_CHARS, maxRules: LEARNED_STYLE_MAX_RULES, enabled: learnedStyleEnabled() }
|
|
64
|
+
}
|
|
65
|
+
/** 6.91.0: capabilities.dictationCleanup.polishOptions ("Previous polish"). */
|
|
66
|
+
export function polishOptionsCapability() {
|
|
67
|
+
return { protocol: POLISH_OPTIONS_PROTOCOL }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Where the learned style applies (lib/dictation-polish-pipeline.ts learnedStyleFor): never meeting lines. */
|
|
71
|
+
export const LEARNED_STYLE_APPLY_TO = ['message', 'prompt_draft'] as const
|
|
72
|
+
|
|
31
73
|
function view(stored: StoredInstructions) {
|
|
32
74
|
return {
|
|
33
75
|
customInstructions: stored.customInstructions,
|
|
@@ -76,16 +118,164 @@ dictationPolishSettingsRouter.delete('/dictation/polish/instructions', (_req, re
|
|
|
76
118
|
}
|
|
77
119
|
})
|
|
78
120
|
|
|
79
|
-
dictationPolishSettingsRouter.get('/dictation/polish/rules', (
|
|
80
|
-
|
|
121
|
+
dictationPolishSettingsRouter.get('/dictation/polish/rules', (req, res) => {
|
|
122
|
+
// 6.91.0 option C: each model has its own default (v4 on Sonnet, v3 otherwise). `?model=` picks
|
|
123
|
+
// whose view `version` and `rules` describe; without it they describe the newest default.
|
|
124
|
+
const wanted = typeof req.query?.model === 'string' && (POLISH_MODEL_DEFAULTS as readonly string[]).includes(req.query.model) ? req.query.model : undefined
|
|
125
|
+
const choice = dictationPolishPromptChoice(process.env, wanted)
|
|
126
|
+
const version = choice.version
|
|
127
|
+
const promptByModel = Object.fromEntries(POLISH_MODEL_DEFAULTS.map(model => [model, dictationPolishPromptVersion(process.env, model)]))
|
|
128
|
+
// Learned style and the literal register apply only to the pairs they were canaried on.
|
|
129
|
+
const stylePairs = POLISH_MODEL_DEFAULTS.filter(model => styleAndRegisterApply(model, promptByModel[model])).map(model => ({ model, prompt: promptByModel[model] }))
|
|
130
|
+
const styled = wanted === undefined ? stylePairs.length > 0 : styleAndRegisterApply(wanted, version)
|
|
81
131
|
res.json({
|
|
82
132
|
version,
|
|
83
133
|
defaultVersion: DEFAULT_DICTATION_POLISH_PROMPT,
|
|
84
|
-
rolledBack:
|
|
134
|
+
rolledBack: choice.source !== 'default',
|
|
85
135
|
systemPrompt: DICTATION_POLISH_SYSTEM_PROMPTS[version],
|
|
86
136
|
rules: dictationPolishRules(version).join('\n'),
|
|
87
137
|
// The block exactly as the model sees it, with a placeholder for the user's words.
|
|
88
138
|
userBlock: [...USER_PREFERENCES_INTRO, '<user-style-preferences>', INSTRUCTIONS_PLACEHOLDER, '</user-style-preferences>', USER_PREFERENCES_PRECEDENCE].join('\n'),
|
|
89
139
|
maxChars: CUSTOM_INSTRUCTIONS_MAX_CHARS,
|
|
140
|
+
// 6.91.0: which prompt each model gets, the rules of each prompt in use, and where the learned
|
|
141
|
+
// style and the literal register apply (Sonnet with v4 only: the pair canaries C2 and C3 ran on).
|
|
142
|
+
...(wanted ? { model: wanted } : {}),
|
|
143
|
+
promptByModel,
|
|
144
|
+
rulesByPrompt: Object.fromEntries([...new Set(Object.values(promptByModel))].map(v => [v, dictationPolishRules(v).join('\n')])),
|
|
145
|
+
stylePairs,
|
|
146
|
+
registersEnabled: registersEnabled(),
|
|
147
|
+
// The other blocks exactly as the model sees them. The learned style is the saved rules rendered
|
|
148
|
+
// from the server's templates ('' when none apply); the literal block goes to terminals only
|
|
149
|
+
// (none with COS_DICTATION_REGISTERS=0); the closing line is v4's.
|
|
150
|
+
learnedStyleBlock: styled && learnedStyleEnabled() ? learnedStyleBlock(learnedStyleLinesFor(readLearnedStyle(), 'default')).join('\n').trim() : '',
|
|
151
|
+
learnedStyleFrame: [LEARNED_STYLE_INTRO, '<learned-style>', LEARNED_STYLE_PLACEHOLDER, '</learned-style>', LEARNED_STYLE_LIMITS].join('\n'),
|
|
152
|
+
registerBlocks: styled && registersEnabled() && version === 'v4' ? { literal: LITERAL_REGISTER_RULES.join('\n') } : {},
|
|
153
|
+
closingRules: version === 'v4' ? { default: V4_CLOSING_RULE, literal: V4_LITERAL_CLOSING_RULE } : {},
|
|
90
154
|
})
|
|
91
155
|
})
|
|
156
|
+
|
|
157
|
+
/** The placeholder the rules view shows where the learned-style lines go. */
|
|
158
|
+
export const LEARNED_STYLE_PLACEHOLDER = '(your learned style rules)'
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------------------------
|
|
161
|
+
// 6.91.0 learned style and polish options.
|
|
162
|
+
|
|
163
|
+
function styleView(stored: StoredLearnedStyle) {
|
|
164
|
+
return {
|
|
165
|
+
enabled: stored.enabled,
|
|
166
|
+
rules: stored.rules,
|
|
167
|
+
text: stored.text,
|
|
168
|
+
hash: stored.hash,
|
|
169
|
+
revision: stored.revision,
|
|
170
|
+
updatedAt: stored.updatedAt,
|
|
171
|
+
// Control reads the app-to-register map here, so it never keeps a second table. The literal
|
|
172
|
+
// register is opt-in (COS_DICTATION_REGISTERS=1); `registersEnabled` says whether it applies.
|
|
173
|
+
registers: publishedRegisters(),
|
|
174
|
+
registersEnabled: registersEnabled(),
|
|
175
|
+
kinds: LEARNED_STYLE_KINDS,
|
|
176
|
+
registerValues: LEARNED_STYLE_REGISTERS,
|
|
177
|
+
allowlist: LEARNED_STYLE_ALLOWLIST,
|
|
178
|
+
contractionParams: CONTRACTION_PARAMS,
|
|
179
|
+
maxChars: LEARNED_STYLE_MAX_CHARS,
|
|
180
|
+
maxRules: LEARNED_STYLE_MAX_RULES,
|
|
181
|
+
serverEnabled: learnedStyleEnabled(),
|
|
182
|
+
appliesTo: LEARNED_STYLE_APPLY_TO,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const STYLE_PUT_KEYS = new Set(['enabled', 'rules', 'baseRevision'])
|
|
187
|
+
const SERVER_OWNED = new Set(['text', 'hash', 'revision', 'updatedAt'])
|
|
188
|
+
|
|
189
|
+
/** A Node error code ("EACCES") for the log, never a message (a message can carry the path). */
|
|
190
|
+
function errorCode(err: unknown): string {
|
|
191
|
+
const code = (err as { code?: unknown } | null)?.code
|
|
192
|
+
return typeof code === 'string' && /^[A-Z][A-Z0-9_]{1,23}$/.test(code) ? code : 'error'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
dictationPolishSettingsRouter.get('/dictation/learned-style', (_req, res) => {
|
|
196
|
+
res.json(styleView(readLearnedStyle()))
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
dictationPolishSettingsRouter.put('/dictation/learned-style', (req, res) => {
|
|
200
|
+
const body = req.body
|
|
201
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return res.status(400).json({ error: 'invalid_body' })
|
|
202
|
+
const keys = Object.keys(body)
|
|
203
|
+
const owned = keys.find(key => SERVER_OWNED.has(key))
|
|
204
|
+
if (owned) return res.status(400).json({ error: 'server_owned_field', field: owned })
|
|
205
|
+
if (!keys.every(key => STYLE_PUT_KEYS.has(key)) || !('rules' in body || 'enabled' in body)) {
|
|
206
|
+
return res.status(400).json({ error: 'invalid_body', expected: { enabled: 'boolean?', rules: '[{id, kind, register, param, count, lastSeen}]?', baseRevision: 'string?' } })
|
|
207
|
+
}
|
|
208
|
+
if ('enabled' in body && typeof body.enabled !== 'boolean') return res.status(400).json({ error: 'invalid_enabled' })
|
|
209
|
+
if ('baseRevision' in body && (typeof body.baseRevision !== 'string' || !/^[0-9a-f]{8}$/.test(body.baseRevision))) {
|
|
210
|
+
return res.status(400).json({ error: 'invalid_base_revision' })
|
|
211
|
+
}
|
|
212
|
+
const current = readLearnedStyle()
|
|
213
|
+
if (typeof body.baseRevision === 'string' && body.baseRevision !== current.revision) {
|
|
214
|
+
// Someone else saved first (rules or the switch): re-GET and merge.
|
|
215
|
+
return res.status(409).json({ error: 'base_revision_mismatch', revision: current.revision })
|
|
216
|
+
}
|
|
217
|
+
let rules = current.rules
|
|
218
|
+
if ('rules' in body) {
|
|
219
|
+
const clean = validateLearnedStyleRules(body.rules)
|
|
220
|
+
if (!clean.ok) {
|
|
221
|
+
return res.status(clean.status).json({
|
|
222
|
+
error: clean.error,
|
|
223
|
+
...(clean.index !== undefined ? { index: clean.index } : {}),
|
|
224
|
+
...(clean.max !== undefined ? { max: clean.max } : {}),
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
rules = clean.rules
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
res.json(styleView(writeLearnedStyle(typeof body.enabled === 'boolean' ? body.enabled : current.enabled, rules)))
|
|
231
|
+
} catch (err) {
|
|
232
|
+
// The reason stays generic: an fs error can carry the path, never the rules, but say less.
|
|
233
|
+
console.warn(`[dictation-polish] learned_style_not_saved put (${errorCode(err)})`)
|
|
234
|
+
res.status(500).json({ error: 'learned_style_not_saved' })
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
dictationPolishSettingsRouter.delete('/dictation/learned-style', (_req, res) => {
|
|
239
|
+
try {
|
|
240
|
+
// Reset clears the rules and leaves the person's switch as it was: an Off stays off.
|
|
241
|
+
res.json(styleView(writeLearnedStyle(readLearnedStyle().enabled, [])))
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.warn(`[dictation-polish] learned_style_not_saved delete (${errorCode(err)})`)
|
|
244
|
+
res.status(500).json({ error: 'learned_style_not_saved' })
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
function optionsView() {
|
|
249
|
+
const stored = readPolishOptions()
|
|
250
|
+
const choice = dictationPolishPromptChoice()
|
|
251
|
+
return {
|
|
252
|
+
promptVersion: stored.promptVersion,
|
|
253
|
+
effectiveVersion: choice.version,
|
|
254
|
+
// 6.91.0 option C: the prompt each model actually gets.
|
|
255
|
+
effectiveByModel: Object.fromEntries(POLISH_MODEL_DEFAULTS.map(model => [model, dictationPolishPromptChoice(process.env, model).version])),
|
|
256
|
+
source: choice.source,
|
|
257
|
+
// The environment named a version, so this option has no effect until it is removed.
|
|
258
|
+
envOverride: choice.source === 'env',
|
|
259
|
+
options: POLISH_PROMPT_OPTIONS,
|
|
260
|
+
updatedAt: stored.updatedAt,
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
dictationPolishSettingsRouter.get('/dictation/polish/options', (_req, res) => {
|
|
265
|
+
res.json(optionsView())
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
dictationPolishSettingsRouter.put('/dictation/polish/options', (req, res) => {
|
|
269
|
+
const body = req.body
|
|
270
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || !Object.keys(body).every(key => key === 'promptVersion')
|
|
271
|
+
|| !(POLISH_PROMPT_OPTIONS as readonly string[]).includes(body.promptVersion)) {
|
|
272
|
+
return res.status(400).json({ error: 'invalid_body', expected: { promptVersion: POLISH_PROMPT_OPTIONS } })
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
writePolishOptions(body.promptVersion as PolishPromptOption)
|
|
276
|
+
res.json(optionsView())
|
|
277
|
+
} catch (err) {
|
|
278
|
+
console.warn(`[dictation-polish] options_not_saved (${errorCode(err)})`)
|
|
279
|
+
res.status(500).json({ error: 'options_not_saved' })
|
|
280
|
+
}
|
|
281
|
+
})
|
package/server/routes/health.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DICTATION_CLEAN_MODELS } from '../lib/dictation-clean-models.js'
|
|
2
|
-
import { customInstructionsCapability } from './dictation-polish-settings.js'
|
|
2
|
+
import { customInstructionsCapability, learnedStyleCapability, polishOptionsCapability } from './dictation-polish-settings.js'
|
|
3
3
|
import { PAIRING_PROTOCOL_VERSION } from '../lib/glasses-pairing.js'
|
|
4
4
|
import { taskBridgeAvailable } from '../lib/task-bridge.js'
|
|
5
5
|
import { Router } from 'express'
|
|
@@ -491,7 +491,11 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
491
491
|
...(voiceProvenance ? { voice_provenance: voiceProvenance } : {}),
|
|
492
492
|
capabilities: {
|
|
493
493
|
meetingAsk: { ...meetingAskCapabilities(), webRetrieval: meetingWebCapability().available },
|
|
494
|
-
|
|
494
|
+
// 6.91.0: learnedStyle ("Your style", enabled = the server switch) and polishOptions ("Previous polish").
|
|
495
|
+
dictationCleanup: {
|
|
496
|
+
models: DICTATION_CLEAN_MODELS, customInstructions: customInstructionsCapability(),
|
|
497
|
+
learnedStyle: learnedStyleCapability(), polishOptions: polishOptionsCapability(),
|
|
498
|
+
},
|
|
495
499
|
// 6.67.0: glasses pairing (docs/pairing-contract.md). Public on purpose: the phone
|
|
496
500
|
// reads it before it holds a token, and an older server omits it, which is how
|
|
497
501
|
// the phone knows to say "Update COS on your Mac (server 6.67 or newer)".
|
|
@@ -679,7 +683,11 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
679
683
|
...(apiToken ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
|
|
680
684
|
capabilities: {
|
|
681
685
|
meetingAsk: { ...meetingAskCapabilities(), webRetrieval: meetingWebCapability().available },
|
|
682
|
-
|
|
686
|
+
// 6.91.0: learnedStyle ("Your style", enabled = the server switch) and polishOptions ("Previous polish").
|
|
687
|
+
dictationCleanup: {
|
|
688
|
+
models: DICTATION_CLEAN_MODELS, customInstructions: customInstructionsCapability(),
|
|
689
|
+
learnedStyle: learnedStyleCapability(), polishOptions: polishOptionsCapability(),
|
|
690
|
+
},
|
|
683
691
|
durableQueryJobs: {
|
|
684
692
|
enabled: durableJobs.enabled,
|
|
685
693
|
protocolVersion: durableJobs.protocolVersion,
|
|
@@ -33,28 +33,16 @@ import {
|
|
|
33
33
|
isCaptionCreditHallucination,
|
|
34
34
|
isBrandUrlOnly,
|
|
35
35
|
clearSessionHallucinationState,
|
|
36
|
-
applyNegativeRules,
|
|
37
36
|
} from '../lib/hallucination-filter.js'
|
|
38
|
-
import { applyCorrections } from '../lib/whisper-local.js'
|
|
39
|
-
import { applyFuzzyCorrections } from '../lib/fuzzy-correct.js'
|
|
40
|
-
import { getAllSpeakerNames } from '../lib/speaker-embeddings.js'
|
|
41
37
|
import { transcribeWhisperPreview } from '../lib/whisper-preview.js'
|
|
42
38
|
import { currentDictationPlan, runParakeetDictation } from '../lib/dictation-stt.js'
|
|
43
39
|
import { dictationSlotForMode, legacyDictationKey, type DictationPlan } from '../lib/stt-settings.js'
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
49
|
-
|
|
50
|
-
startPolishPrewarm, takePolishPrewarm,
|
|
51
|
-
} from '../lib/dictation-prewarm.js'
|
|
52
|
-
import { getVocabulary, getWhisperCorrections } from '../lib/profile.js'
|
|
53
|
-
import { correctionOnlyForms } from '../lib/glossary-learn.js'
|
|
54
|
-
import { polishLinkProblem, renderSpokenPaths, spokenPathsEnabled } from '../lib/dictation-format.js'
|
|
55
|
-
import { customInstructionsHash, readCustomInstructions } from '../lib/dictation-polish-instructions.js'
|
|
56
|
-
import { createBreaker } from '../lib/claude-circuit.js'
|
|
57
|
-
import { logTokenAudit } from '../lib/token-audit.js'
|
|
40
|
+
import { AUTOCLEAN_MAX_CHARS } from '../lib/dictation-clean.js'
|
|
41
|
+
import { polishBudgetExhausted } from '../lib/dictation-polish-budget.js'
|
|
42
|
+
import { discardPolishPrewarm, isPrewarmModel, prewarmEnabled, startPolishPrewarm } from '../lib/dictation-prewarm.js'
|
|
43
|
+
import { applyCorrections } from '../lib/whisper-local.js'
|
|
44
|
+
import { registerFromContext } from '../lib/dictation-register.js'
|
|
45
|
+
import { autoCleanBreakers, cleanOutboundDictation, type AutoCleanRequest, type PolishResult } from '../lib/dictation-polish-pipeline.js'
|
|
58
46
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
59
47
|
import {
|
|
60
48
|
acquireMaintenanceWork,
|
|
@@ -85,19 +73,8 @@ function speculativeHqWarmEnabled(): boolean {
|
|
|
85
73
|
return !['0', 'false', 'off'].includes((process.env.COS_HQ_SPECULATIVE_WARM ?? '1').toLowerCase())
|
|
86
74
|
}
|
|
87
75
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
'luna-5.6-fast': createBreaker({ label: 'dictation-autoclean-luna' }),
|
|
91
|
-
haiku: createBreaker({ label: 'dictation-autoclean-haiku' }),
|
|
92
|
-
}
|
|
93
|
-
const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
|
|
94
|
-
|
|
95
|
-
/** 6.79.1, additive on finalize answers: why the polish model did not run (or did not finish). */
|
|
96
|
-
export type PolishSkipped = 'disabled' | 'too_long' | 'breaker_open' | 'daily_cap' | 'failed' | 'clean' | 'link_guard'
|
|
97
|
-
/** 6.79.2, additive when finalize was given a prewarmToken: what became of that warm process. */
|
|
98
|
-
export type PrewarmOutcome = 'used' | 'missed' | 'discarded'
|
|
99
|
-
|
|
100
|
-
interface AutoCleanRequest { enabled?: boolean; model?: DictationCleanModel }
|
|
76
|
+
// 6.91.0: the outbound polish pipeline (breakers, gates, model, guards) lives in
|
|
77
|
+
// lib/dictation-polish-pipeline.ts, so the offline evals call it rather than a copy.
|
|
101
78
|
function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
|
|
102
79
|
const rawEnabled = req.body?.autoclean ?? req.query?.autoclean
|
|
103
80
|
const enabled = rawEnabled === undefined ? undefined : ['1', 'true', 'on'].includes(String(rawEnabled).toLowerCase())
|
|
@@ -107,113 +84,6 @@ function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
|
|
|
107
84
|
return { enabled, model: isDictationCleanModel(rawModel) ? rawModel : undefined }
|
|
108
85
|
}
|
|
109
86
|
|
|
110
|
-
async function cleanOutboundDictation(
|
|
111
|
-
text: string,
|
|
112
|
-
opts: AutoCleanRequest & { bucket: PolishBucket; signal?: AbortSignal; prewarmToken?: string },
|
|
113
|
-
): Promise<PolishResult> {
|
|
114
|
-
const result = await polishOutboundDictation(text, opts)
|
|
115
|
-
// A warm process the model call did not take must not sit until its TTL.
|
|
116
|
-
if (opts.prewarmToken && !result.prewarm) {
|
|
117
|
-
return { ...result, prewarm: discardPolishPrewarm(opts.prewarmToken) ? 'discarded' : 'missed' }
|
|
118
|
-
}
|
|
119
|
-
return result
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** 6.85.0, additive and present only when the model was given the user's polish instructions. */
|
|
123
|
-
interface PolishInstructionsUse { customInstructions?: true; instructionsHash?: string }
|
|
124
|
-
type PolishResult = { text: string; polishSkipped?: PolishSkipped; prewarm?: PrewarmOutcome } & PolishInstructionsUse
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* 6.85.0: the user's polish instructions apply to dictation (Mac dictation and G2 messages, which
|
|
128
|
-
* are surface "message", and prompt drafts). Meeting lines are a faithful record, so they stay on
|
|
129
|
-
* the built-in rules alone, the same way they skip the filler pass and spoken paths.
|
|
130
|
-
*/
|
|
131
|
-
export function polishInstructionsFor(bucket: PolishBucket): string {
|
|
132
|
-
return bucket === 'meeting' ? '' : readCustomInstructions().customInstructions
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
async function polishOutboundDictation(
|
|
136
|
-
text: string,
|
|
137
|
-
opts: AutoCleanRequest & { bucket: PolishBucket; signal?: AbortSignal; prewarmToken?: string },
|
|
138
|
-
): Promise<PolishResult> {
|
|
139
|
-
let cleaned = applyNegativeRules(applyCorrections(text)).replace(/\s+/g, ' ').trim() || text
|
|
140
|
-
// applyCorrections above is an EXACT string map, so it only fixes misspellings someone
|
|
141
|
-
// already hand-authored. A novel miss ("Rileyy" for Riley, "Exampel" for Example) sails
|
|
142
|
-
// through it. The Levenshtein pass is what catches those, and until now it had exactly
|
|
143
|
-
// ONE call site — transcribe-audio.ts:252, the server-transcription route — so phone
|
|
144
|
-
// Moonshine dictation, which arrives here as text, never got it. Same construction and
|
|
145
|
-
// same non-fatal posture as that site, deliberately: targets are speaker names plus
|
|
146
|
-
// vocabulary, and a throw must never cost the user their transcript.
|
|
147
|
-
//
|
|
148
|
-
// Runs BEFORE the autoclean LLM so the model sees corrected proper nouns rather than
|
|
149
|
-
// being asked to guess at them, and it still helps on every path where autoclean is
|
|
150
|
-
// off, over the char cap, or breaker-open.
|
|
151
|
-
try {
|
|
152
|
-
const fuzzyTargets = [...getAllSpeakerNames(), ...getVocabulary()]
|
|
153
|
-
const { text: corrected, replacements } = applyFuzzyCorrections(cleaned, fuzzyTargets)
|
|
154
|
-
if (replacements > 0) {
|
|
155
|
-
console.log(`[prompt-draft] Fuzzy corrected ${replacements} word(s)`)
|
|
156
|
-
cleaned = corrected
|
|
157
|
-
}
|
|
158
|
-
} catch (fuzzyErr: any) {
|
|
159
|
-
console.warn(`[prompt-draft] Fuzzy correction failed (non-fatal): ${fuzzyErr?.message ?? fuzzyErr}`)
|
|
160
|
-
}
|
|
161
|
-
// 6.79.1: um/uh never survive a skipped polish. Meeting lines stay faithful.
|
|
162
|
-
if (opts.bucket !== 'meeting' && fillerPassEnabled()) cleaned = stripDisfluencies(cleaned)
|
|
163
|
-
// 6.83.1: "forward slash pricing," is "/pricing," even when the model is skipped. One word
|
|
164
|
-
// at a phrase end only; slugs of several words are the model's (dictation-format.ts).
|
|
165
|
-
if (opts.bucket !== 'meeting' && spokenPathsEnabled()) cleaned = renderSpokenPaths(cleaned)
|
|
166
|
-
if (!(opts.enabled ?? autoCleanDefaultEnabled())) return { text: cleaned, polishSkipped: 'disabled' }
|
|
167
|
-
const model = opts.model ?? 'haiku'
|
|
168
|
-
const autoCleanBreaker = autoCleanBreakers[model]
|
|
169
|
-
if (cleaned.length > AUTOCLEAN_MAX_CHARS) return { text: cleaned, polishSkipped: 'too_long' }
|
|
170
|
-
// 6.79.2, off by default: nothing for the model to fix (see dictation-polish-need.ts).
|
|
171
|
-
if (skipCleanEnabled() && polishNeed(cleaned) === null) return { text: cleaned, polishSkipped: 'clean' }
|
|
172
|
-
if (autoCleanBreaker.isOpen()) return { text: cleaned, polishSkipped: 'breaker_open' }
|
|
173
|
-
if (polishBudgetExhausted(opts.bucket)) return { text: cleaned, polishSkipped: 'daily_cap' }
|
|
174
|
-
const startedAt = Date.now()
|
|
175
|
-
recordPolishCall(opts.bucket)
|
|
176
|
-
// Read once per polish: the warm and cold paths see the same instructions.
|
|
177
|
-
const customInstructions = polishInstructionsFor(opts.bucket)
|
|
178
|
-
const instructionsHash = customInstructionsHash(customInstructions)
|
|
179
|
-
const used: PolishInstructionsUse = customInstructions ? { customInstructions: true, instructionsHash } : {}
|
|
180
|
-
// 6.79.2: a warm process started at key press answers without the CLI's start-up cost.
|
|
181
|
-
const warmEntry = opts.prewarmToken ? takePolishPrewarm(opts.prewarmToken, model) : null
|
|
182
|
-
let prewarm: PrewarmOutcome | undefined = opts.prewarmToken ? 'missed' : undefined
|
|
183
|
-
try {
|
|
184
|
-
let polished: string | null = null
|
|
185
|
-
if (warmEntry) {
|
|
186
|
-
try {
|
|
187
|
-
polished = (await runWarmPolish(warmEntry, cleaned, getVocabulary(), { signal: opts.signal, customInstructions })).trim()
|
|
188
|
-
prewarm = 'used'
|
|
189
|
-
} catch (err) {
|
|
190
|
-
// Nothing was sent: the cold path below is the same call, not a second one.
|
|
191
|
-
if (!(err instanceof PrewarmUnavailableError)) throw err
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
if (polished === null) polished = (await autoCleanDictation(cleaned, getVocabulary(), { model, signal: opts.signal, customInstructions })).trim()
|
|
195
|
-
autoCleanBreaker.recordSuccess()
|
|
196
|
-
logTokenAudit({
|
|
197
|
-
source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: polished.length,
|
|
198
|
-
durationMs: Date.now() - startedAt, caller: `dictation_autoclean_${opts.bucket}`,
|
|
199
|
-
polishInstructions: { on: Boolean(customInstructions), hash: instructionsHash },
|
|
200
|
-
})
|
|
201
|
-
// 6.83.1: the model never invents a link and never drops one the dictionary wrote. Its
|
|
202
|
-
// answer is refused (the pre-polish text goes out, polishSkipped "link_guard") rather than
|
|
203
|
-
// pasting a URL nobody said. The call still counted: it ran.
|
|
204
|
-
const linkProblem = polished ? polishLinkProblem(cleaned, polished, correctionOnlyForms(getWhisperCorrections()), getVocabulary()) : null
|
|
205
|
-
if (linkProblem) {
|
|
206
|
-
console.warn(`[prompt-draft] auto-clean answer refused: ${linkProblem}`)
|
|
207
|
-
return { text: cleaned, ...(prewarm ? { prewarm } : {}), polishSkipped: 'link_guard', ...used }
|
|
208
|
-
}
|
|
209
|
-
return { text: polished || cleaned, ...(prewarm ? { prewarm } : {}), ...used }
|
|
210
|
-
} catch (err: any) {
|
|
211
|
-
autoCleanBreaker.recordFailure()
|
|
212
|
-
console.warn(`[prompt-draft] auto-clean failed (glossary-only): ${err?.message ?? err}`)
|
|
213
|
-
return { text: cleaned, polishSkipped: 'failed', ...(prewarm ? { prewarm } : {}), ...used }
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
87
|
/** Finalize already-transcribed phone text through the same glossary + optional
|
|
218
88
|
* AI cleanup used by server-owned prompt drafts. This route accepts text only:
|
|
219
89
|
* Moonshine audio and rolling preview audio never leave the phone. */
|
|
@@ -238,13 +108,17 @@ promptDraftsRouter.post('/dictation/finalize', async (req, res) => {
|
|
|
238
108
|
|
|
239
109
|
const abort = new AbortController()
|
|
240
110
|
res.on('close', () => { if (!res.writableEnded) abort.abort() })
|
|
241
|
-
const
|
|
111
|
+
const result = await cleanOutboundDictation(text, {
|
|
242
112
|
enabled: true,
|
|
243
113
|
model,
|
|
244
114
|
bucket: surface,
|
|
245
115
|
signal: abort.signal,
|
|
246
116
|
prewarmToken: rawToken,
|
|
117
|
+
// 6.91.0: the target app (Control has sent it since 0.5.325) picks the register. Read here,
|
|
118
|
+
// never logged or echoed; anything that is not a short one-line name is the default register.
|
|
119
|
+
register: registerFromContext(req.body?.context),
|
|
247
120
|
})
|
|
121
|
+
const { text: finalText, polishSkipped, prewarm, customInstructions, instructionsHash } = result
|
|
248
122
|
// 6.79.1: polishSkipped only when the model did not polish, so a polished answer is unchanged.
|
|
249
123
|
// 6.79.2: prewarm only when the request carried a prewarmToken.
|
|
250
124
|
res.json({
|
|
@@ -252,9 +126,27 @@ promptDraftsRouter.post('/dictation/finalize', async (req, res) => {
|
|
|
252
126
|
...(polishSkipped ? { polishSkipped } : {}), ...(prewarm ? { prewarm } : {}),
|
|
253
127
|
// 6.85.0: only when the model was given the user's polish instructions (a hash, never the text).
|
|
254
128
|
...(customInstructions ? { customInstructions, instructionsHash } : {}),
|
|
129
|
+
...polishFields(result),
|
|
255
130
|
})
|
|
256
131
|
})
|
|
257
132
|
|
|
133
|
+
/**
|
|
134
|
+
* 6.91.0, additive on both finalize answers, every field optional (old clients ignore them):
|
|
135
|
+
* polishOutcome (only when the model answered: polished, unchanged or guarded, about the model
|
|
136
|
+
* step), guard (the guard that refused it), and whenever the model was called: polishPrompt (v3
|
|
137
|
+
* or v4), learnedStyle (true with its hash, or false), and polishRegister on the one pair it was
|
|
138
|
+
* canaried on (Sonnet with v4). Never the model's own text.
|
|
139
|
+
*/
|
|
140
|
+
function polishFields(result: PolishResult) {
|
|
141
|
+
return {
|
|
142
|
+
...(result.polishOutcome ? { polishOutcome: result.polishOutcome } : {}),
|
|
143
|
+
...(result.guard ? { guard: result.guard } : {}),
|
|
144
|
+
...(result.polishRegister ? { polishRegister: result.polishRegister } : {}),
|
|
145
|
+
...(result.polishPrompt ? { polishPrompt: result.polishPrompt } : {}),
|
|
146
|
+
...(result.learnedStyle ? { learnedStyle: true, learnedStyleHash: result.learnedStyleHash } : result.learnedStyleOff ? { learnedStyle: false } : {}),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
258
150
|
const PREWARM_TOKEN = /^[0-9a-f-]{36}$/
|
|
259
151
|
|
|
260
152
|
/** 6.79.2: start the polish process at key press. Fire and forget: every refusal is a normal
|
|
@@ -539,7 +431,8 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
539
431
|
await markPromptDraftError(draftId, 'No speech detected')
|
|
540
432
|
throw new NoSpeechDetectedError()
|
|
541
433
|
}
|
|
542
|
-
const
|
|
434
|
+
const polish = await cleanOutboundDictation(text, { ...autoClean, bucket: 'prompt_draft', signal })
|
|
435
|
+
const { text: finalText, polishSkipped, customInstructions, instructionsHash } = polish
|
|
543
436
|
const finalized = await markPromptDraftFinalized(draftId, finalText)
|
|
544
437
|
const qualities = transcriptRecords.map(record => record.actualQuality)
|
|
545
438
|
const actualQuality: 'hq' | 'fast' | 'cloud' = qualities.includes('cloud')
|
|
@@ -571,6 +464,8 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
571
464
|
...(polishSkipped ? { polishSkipped } : {}),
|
|
572
465
|
// 6.85.0, additive.
|
|
573
466
|
...(customInstructions ? { customInstructions, instructionsHash } : {}),
|
|
467
|
+
// 6.91.0, additive.
|
|
468
|
+
...polishFields(polish),
|
|
574
469
|
}
|
|
575
470
|
}
|
|
576
471
|
|