@gotcos/glasses-server 6.91.1 → 6.92.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.
@@ -396,6 +396,53 @@ export function looksEnglish(text: string): boolean {
396
396
  return words.filter(w => ENGLISH.has(w)).length / words.length >= 0.12
397
397
  }
398
398
 
399
+ /**
400
+ * 6.92.0: common function words of German, Portuguese, Spanish, French and Dutch that are not
401
+ * English words, not name particles or place words ("de", "la", "von", "Los", "Las") and not words
402
+ * dictation says in English ("dot com", "em dash", "est", "eu"). Evidence that a short text is not
403
+ * English. An all-caps token ("MIT") never counts.
404
+ */
405
+ const FOREIGN = new Set(['der', 'das', 'ein', 'eine', 'einen', 'einem', 'einer', 'und', 'ist', 'sind', 'nicht', 'ich', 'wir', 'ihr',
406
+ 'sie', 'mit', 'uns', 'auf', 'für', 'uhr', 'auch', 'noch', 'oder', 'aber', 'wie', 'kommt', 'heute', 'morgen', 'bitte', 'danke',
407
+ 'haben', 'wird', 'kann', 'zum', 'zur', 'bei', 'nach', 'ja', 'nein',
408
+ 'uma', 'não', 'nao', 'por', 'que', 'você', 'voce', 'ele', 'ela', 'nós', 'isso', 'este', 'esta', 'muito', 'obrigado', 'obrigada',
409
+ 'amanhã', 'hoje',
410
+ 'una', 'unos', 'está', 'pero', 'muy', 'gracias', 'mañana', 'hoy',
411
+ 'une', 'je', 'nous', 'vous', 'avec', 'pour', 'pas', 'mais', 'oui', 'merci',
412
+ 'het', 'een', 'ik', 'niet', 'wij', 'voor'])
413
+ /** 6.92.0: an English filler sound that is also a word in German, Portuguese, Danish, Norwegian or Dutch. */
414
+ const AMBIGUOUS_FILLERS = new Set(['um', 'er'])
415
+ /**
416
+ * 6.92.0 QA: English evidence beyond the function words, for short replies and commands ("um yes",
417
+ * "er no", "um thanks Dana", "um sounds good", "um Tuesday", "um 5 pm works"). None is a word of
418
+ * the languages above.
419
+ */
420
+ const SHORT_REPLY_ENGLISH = new Set(['yes', 'no', 'yeah', 'yep', 'ok', 'okay', 'sure', 'thanks', 'thank', 'please', 'sorry', 'hi', 'hey',
421
+ 'hello', 'good', 'great', 'fine', 'cool', 'done', 'right', 'maybe', 'sounds', 'call', 'send', 'tell', 'ask', 'am', 'pm',
422
+ 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday',
423
+ 'january', 'february', 'march', 'april', 'june', 'july', 'august', 'september', 'october', 'november', 'december'])
424
+
425
+ /**
426
+ * 6.92.0: the filler pass's language gate (dictation-fillers.ts stripFillersIfEnglish), separate
427
+ * from looksEnglish, which the meaning guard keeps exactly as 6.91.1 had it. Not English when
428
+ * looksEnglish says so (a long text with few English function words, or mostly non-Latin letters).
429
+ * Under 12 words: with a foreign function word in it, English words (function words or the short
430
+ * reply list) must outnumber the foreign ones ("Wir treffen uns um 5 Uhr" is not English); with "um"
431
+ * or "er" said inline (no pause after it), at least one English word must be there ("Comprei um
432
+ * carro" and "Er kommt morgen." keep their um and er; "um yes" and "ship it er tomorrow" do not).
433
+ */
434
+ export function fillerTextLooksEnglish(text: string): boolean {
435
+ if (!looksEnglish(text)) return false
436
+ const tokens = tokenize(text)
437
+ if (tokens.length > 11) return true
438
+ const english = tokens.filter(t => ENGLISH.has(t.low) || SHORT_REPLY_ENGLISH.has(t.low)).length
439
+ const foreign = tokens.filter(t => FOREIGN.has(t.low) && !(t.raw.length > 1 && t.raw === t.raw.toUpperCase())).length
440
+ if (foreign > 0) return english > foreign
441
+ const inlineAmbiguous = tokens.some((t, i) => AMBIGUOUS_FILLERS.has(t.low) && i + 1 < tokens.length && !tokens[i + 1].pause)
442
+ if (inlineAmbiguous) return english > 0
443
+ return true
444
+ }
445
+
399
446
  const zero = (): Record<MeaningGuardKind, number> => ({ negation: 0, number: 0, date: 0, name: 0 })
400
447
 
401
448
  /** How many of each negation, a stutter (the same one twice in a row) counted once. */
@@ -5,7 +5,7 @@ import { applyFuzzyCorrections } from './fuzzy-correct.js'
5
5
  import { getAllSpeakerNames } from './speaker-embeddings.js'
6
6
  import { autoCleanDictation, AUTOCLEAN_MAX_CHARS } from './dictation-clean.js'
7
7
  import { dictationPolishPromptVersion, styleAndRegisterApply, type DictationPolishPromptVersion } from './dictation-polish-options.js'
8
- import { fillerPassEnabled, stripDisfluencies } from './dictation-fillers.js'
8
+ import { fillerPassEnabled, stripFillersIfEnglish } from './dictation-fillers.js'
9
9
  import { polishBudgetExhausted, recordPolishCall, type PolishBucket } from './dictation-polish-budget.js'
10
10
  import { polishNeed, skipCleanEnabled } from './dictation-polish-need.js'
11
11
  import { discardPolishPrewarm, PrewarmUnavailableError, runWarmPolish, takePolishPrewarm } from './dictation-prewarm.js'
@@ -16,7 +16,7 @@ import { customInstructionsHash, readCustomInstructions } from './dictation-poli
16
16
  import { learnedStyleEnabled, learnedStyleLinesFor, readLearnedStyle } from './dictation-learned-style.js'
17
17
  import { literalAnswer, type DictationRegister } from './dictation-register.js'
18
18
  import { meaningGuardCounts, meaningGuardEnabled, meaningGuardProblem, type MeaningGuardKind } from './dictation-meaning-guard.js'
19
- import { createBreaker } from './claude-circuit.js'
19
+ import { createBreaker, type ClaudeBreaker } from './claude-circuit.js'
20
20
  import { logTokenAudit } from './token-audit.js'
21
21
 
22
22
  /**
@@ -34,10 +34,60 @@ import { logTokenAudit } from './token-audit.js'
34
34
  * register, no instructions, no learned style, no meaning guard.
35
35
  */
36
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' }),
37
+ /**
38
+ * 6.92.0 (plan r3, A-W0a): the breakers are keyed by (model, bucket group). Meeting lines have their
39
+ * own, so a G2 meeting that fails can no longer turn off Mac dictation and G2 message polish; message
40
+ * and prompt_draft share the `dictation` group. Each key has two breakers:
41
+ *
42
+ * - failure: 2 consecutive CLI failures open it for 30 minutes, as before. A client abort (the
43
+ * request closed, `signal.aborted`) is neutral: it neither counts nor resets.
44
+ * - slow: COS_DICTATION_SLOW_BREAKER_ABORTS (default 4) consecutive aborts, each after at least
45
+ * SLOW_ABORT_MIN_MODEL_MS of model time, with no success between, open it for
46
+ * COS_DICTATION_SLOW_BREAKER_MS (default 300000, 5 minutes). An abort sooner than that (a disconnect
47
+ * right after a sidecar restart) does not count.
48
+ *
49
+ * Either one open skips the polish as `breaker_open`; any success closes both.
50
+ */
51
+ export type PolishBreakerGroup = 'dictation' | 'meeting'
52
+ export const SLOW_ABORT_MIN_MODEL_MS = 4_000
53
+
54
+ export function breakerGroupFor(bucket: PolishBucket): PolishBreakerGroup {
55
+ return bucket === 'meeting' ? 'meeting' : 'dictation'
56
+ }
57
+
58
+ function envPositiveInt(raw: string | undefined, fallback: number): number {
59
+ const trimmed = raw?.trim() ?? ''
60
+ if (!/^\d+$/.test(trimmed)) return fallback
61
+ const value = Number(trimmed)
62
+ return value > 0 ? value : fallback
63
+ }
64
+ export const slowBreakerAborts = (env: NodeJS.ProcessEnv = process.env) => envPositiveInt(env.COS_DICTATION_SLOW_BREAKER_ABORTS, 4)
65
+ export const slowBreakerMs = (env: NodeJS.ProcessEnv = process.env) => envPositiveInt(env.COS_DICTATION_SLOW_BREAKER_MS, 300_000)
66
+
67
+ interface PolishBreakers { failure: ClaudeBreaker; slow: ClaudeBreaker }
68
+ const BREAKER_MODELS = ['sonnet', 'luna-5.6-fast', 'haiku'] as const
69
+ function breakersFor(group: PolishBreakerGroup): Record<DictationCleanModel, PolishBreakers> {
70
+ return Object.fromEntries(BREAKER_MODELS.map(model => {
71
+ const label = `dictation-autoclean-${model === 'luna-5.6-fast' ? 'luna' : model}${group === 'meeting' ? '-meeting' : ''}`
72
+ return [model, {
73
+ failure: createBreaker({ label }),
74
+ slow: createBreaker({ label: `${label}-slow`, maxFailures: () => slowBreakerAborts(), cooldownMs: () => slowBreakerMs() }),
75
+ }]
76
+ })) as Record<DictationCleanModel, PolishBreakers>
77
+ }
78
+ export const autoCleanBreakers: Record<PolishBreakerGroup, Record<DictationCleanModel, PolishBreakers>> = {
79
+ dictation: breakersFor('dictation'),
80
+ meeting: breakersFor('meeting'),
81
+ }
82
+
83
+ export function polishBreakersFor(model: DictationCleanModel, bucket: PolishBucket): PolishBreakers {
84
+ return autoCleanBreakers[breakerGroupFor(bucket)][model]
85
+ }
86
+
87
+ /** The keyed breakers for this model and bucket: open when either is. The prewarm route asks this too. */
88
+ export function polishBreakerOpen(model: DictationCleanModel, bucket: PolishBucket): boolean {
89
+ const breakers = polishBreakersFor(model, bucket)
90
+ return breakers.failure.isOpen() || breakers.slow.isOpen()
41
91
  }
42
92
  const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
43
93
 
@@ -155,7 +205,8 @@ export function prepareOutboundDictation(text: string, bucket: PolishBucket): st
155
205
  console.warn(`[prompt-draft] Fuzzy correction failed (non-fatal): ${fuzzyErr?.message ?? fuzzyErr}`)
156
206
  }
157
207
  // 6.79.1: um/uh never survive a skipped polish. Meeting lines stay faithful.
158
- if (bucket !== 'meeting' && fillerPassEnabled()) cleaned = stripDisfluencies(cleaned)
208
+ // 6.92.0: only when the text is mostly English ("um 5 Uhr" and "um carro" are words).
209
+ if (bucket !== 'meeting' && fillerPassEnabled()) cleaned = stripFillersIfEnglish(cleaned)
159
210
  // 6.83.1: "forward slash pricing," is "/pricing," even when the model is skipped. One word
160
211
  // at a phrase end only; slugs of several words are the model's (dictation-format.ts).
161
212
  if (bucket !== 'meeting' && spokenPathsEnabled()) cleaned = renderSpokenPaths(cleaned)
@@ -221,11 +272,11 @@ export async function polishOutboundDictation(
221
272
  const cleaned = prepareOutboundDictation(text, opts.bucket)
222
273
  if (!(opts.enabled ?? autoCleanDefaultEnabled())) return { text: cleaned, polishSkipped: 'disabled' }
223
274
  const model = opts.model ?? 'haiku'
224
- const autoCleanBreaker = autoCleanBreakers[model]
275
+ const breakers = polishBreakersFor(model, opts.bucket)
225
276
  if (cleaned.length > AUTOCLEAN_MAX_CHARS) return { text: cleaned, polishSkipped: 'too_long' }
226
277
  // 6.79.2, off by default: nothing for the model to fix (see dictation-polish-need.ts).
227
278
  if (skipCleanEnabled() && polishNeed(cleaned) === null) return { text: cleaned, polishSkipped: 'clean' }
228
- if (autoCleanBreaker.isOpen()) return { text: cleaned, polishSkipped: 'breaker_open' }
279
+ if (polishBreakerOpen(model, opts.bucket)) return { text: cleaned, polishSkipped: 'breaker_open' }
229
280
  if (polishBudgetExhausted(opts.bucket)) return { text: cleaned, polishSkipped: 'daily_cap' }
230
281
  const startedAt = Date.now()
231
282
  recordPolishCall(opts.bucket)
@@ -250,6 +301,11 @@ export async function polishOutboundDictation(
250
301
  // 6.79.2: a warm process started at key press answers without the CLI's start-up cost.
251
302
  const warmEntry = opts.prewarmToken ? takePolishPrewarm(opts.prewarmToken, model) : null
252
303
  let prewarm: PrewarmOutcome | undefined = opts.prewarmToken ? 'missed' : undefined
304
+ // 6.92.0 QA S1: the model time of an abort is stamped when the abort happens, not in the catch,
305
+ // which runs only after the provider process is terminated (up to 4 s on the cold path).
306
+ let abortedAt: number | null = null
307
+ const onAbort = () => { abortedAt = Date.now() }
308
+ opts.signal?.addEventListener('abort', onAbort, { once: true })
253
309
  try {
254
310
  let polished: string | null = null
255
311
  if (warmEntry) {
@@ -266,7 +322,8 @@ export async function polishOutboundDictation(
266
322
  // one; this holds if either ever stops): the input goes out, polishSkipped "failed", and the
267
323
  // breaker counts it. Never "unchanged".
268
324
  if (!polished) throw new Error('Auto-clean returned empty text')
269
- autoCleanBreaker.recordSuccess()
325
+ breakers.failure.recordSuccess()
326
+ breakers.slow.recordSuccess()
270
327
  const guarded = guardPolishAnswer(cleaned, polished, opts.bucket, register, canaried)
271
328
  logTokenAudit({
272
329
  source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: polished.length,
@@ -285,8 +342,29 @@ export async function polishOutboundDictation(
285
342
  modelText: polished, ...used,
286
343
  }
287
344
  } catch (err: any) {
288
- autoCleanBreaker.recordFailure()
289
- console.warn(`[prompt-draft] auto-clean failed (glossary-only): ${err?.message ?? err}`)
345
+ if (opts.signal?.aborted) {
346
+ // 6.92.0: the client went away (Control stopped waiting, the phone closed the request). Not a
347
+ // CLI failure: only the slow breaker hears it, and only after real model time. The audit row
348
+ // says `aborted`, codes only; the answer (nobody reads it) keeps PolishOutcome's three values.
349
+ const durationMs = (abortedAt ?? Date.now()) - startedAt
350
+ if (durationMs >= SLOW_ABORT_MIN_MODEL_MS) breakers.slow.recordFailure()
351
+ logTokenAudit({
352
+ source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: 0,
353
+ durationMs, caller: `dictation_autoclean_${opts.bucket}`,
354
+ // Key order differs from the answered row's on purpose: each gate anchor matches one row.
355
+ polishInstructions: { hash: instructionsHash, on: Boolean(customInstructions) },
356
+ learnedStyle: { hash: style.hash, on: Boolean(style.lines) },
357
+ ...(canaried ? { polishRegister: register } : {}),
358
+ ...(used.polishPrompt ? { polishPrompt: used.polishPrompt } : {}),
359
+ polishOutcome: 'aborted',
360
+ })
361
+ console.warn('[prompt-draft] auto-clean aborted by the client (glossary-only)')
362
+ } else {
363
+ breakers.failure.recordFailure()
364
+ console.warn(`[prompt-draft] auto-clean failed (glossary-only): ${err?.message ?? err}`)
365
+ }
290
366
  return { text: cleaned, polishSkipped: 'failed', ...(prewarm ? { prewarm } : {}), ...used }
367
+ } finally {
368
+ opts.signal?.removeEventListener('abort', onAbort)
291
369
  }
292
370
  }
@@ -1,5 +1,13 @@
1
1
  import { spawn } from 'node:child_process'
2
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
2
4
  import { claudeKeySecretsGuardArgs } from './key-secrets-guard.js'
5
+ import { runClaudeText } from './claude-text-runner.js'
6
+ import { createBreaker } from './claude-circuit.js'
7
+ import { readSwitch } from './env-switch.js'
8
+ import { atomicWriteFileSync } from './atomic-fs.js'
9
+ import { dataPath } from './data-dir.js'
10
+ import { localDay } from './dictation-polish-budget.js'
3
11
 
4
12
  export const PROMPT_EDIT_DRAFT_MAX_CHARS = 24_000
5
13
  export const PROMPT_EDIT_INSTRUCTION_MAX_CHARS = 2_000
@@ -178,17 +186,90 @@ export function spawnClaudeText(prompt: string, opts: SpawnClaudeTextOptions = {
178
186
  })
179
187
  }
180
188
 
189
+ export const PROMPT_EDIT_SYSTEM_PROMPT = 'You revise dictated prompt drafts. Output only the revised prompt text.'
190
+ export const promptEditModel = (env: NodeJS.ProcessEnv = process.env) => env.COS_PROMPT_EDIT_MODEL || 'sonnet'
191
+
192
+ /**
193
+ * G2's prompt edit (POST /api/prompt-edit). 6.92.0: through the isolated text runner
194
+ * (claude-text-runner.ts: no tools, MCP, hooks or session, a scratch directory), with the same
195
+ * model, effort and 45 s timeout settings as before.
196
+ */
181
197
  export async function applyPromptEdit(input: PromptEditInput, signal?: AbortSignal): Promise<string> {
182
- return spawnClaudeText(buildPromptEditPrompt(input), {
183
- model: process.env.COS_PROMPT_EDIT_MODEL || 'sonnet',
198
+ return runClaudeText(buildPromptEditPrompt(input), {
199
+ model: promptEditModel(),
184
200
  effort: process.env.COS_PROMPT_EDIT_EFFORT || 'low',
185
201
  timeoutMs: Number.parseInt(process.env.COS_PROMPT_EDIT_TIMEOUT_MS || '45000', 10),
186
- systemPrompt: 'You revise dictated prompt drafts. Output only the revised prompt text.',
202
+ systemPrompt: PROMPT_EDIT_SYSTEM_PROMPT,
187
203
  label: 'Prompt edit',
188
204
  signal,
189
205
  })
190
206
  }
191
207
 
208
+ // ── 6.92.0 (plan r3, A-W0b): how prompt edit stops ───────────────────
209
+ //
210
+ // Every `claude -p` caller answers how it stops. Prompt edit had no answer through 6.91.1:
211
+ // - master switch: COS_PROMPT_EDIT (readSwitch; on by default, 0/false/off answers 503);
212
+ // - daily cap: COS_PROMPT_EDIT_DAILY_CAP (default 200 a local day) in its OWN counter file, never
213
+ // the dictation polish counter, so editing a prompt never spends Mac dictation's budget;
214
+ // - breaker: 3 consecutive CLI failures open it for 10 minutes; a client abort is neutral;
215
+ // - an audit row per call that ran (caller prompt_edit, codes and sizes only).
216
+ // Refusals are text/plain bodies of 24 characters or fewer, so G2 6.10.654 renders
217
+ // "Edit failed.\nAPI <status>: <body>" in 40 characters or fewer.
218
+
219
+ export const PROMPT_EDIT_REFUSALS = {
220
+ cap: { status: 429, body: 'Edit limit reached' },
221
+ off: { status: 503, body: 'Edit is off' },
222
+ paused: { status: 503, body: 'Edit paused' },
223
+ failed: { status: 500, body: 'Edit failed' },
224
+ } as const
225
+ export type PromptEditRefusal = keyof typeof PROMPT_EDIT_REFUSALS
226
+
227
+ export const PROMPT_EDIT_DEFAULT_DAILY_CAP = 200
228
+ export const PROMPT_EDIT_BREAKER_FAILURES = 3
229
+ export const PROMPT_EDIT_BREAKER_MS = 10 * 60 * 1000
230
+ export const PROMPT_EDIT_COUNT_FILE = '.prompt_edit_count.json'
231
+
232
+ export function promptEditEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
233
+ return readSwitch(env.COS_PROMPT_EDIT, true)
234
+ }
235
+
236
+ export function promptEditDailyCap(env: NodeJS.ProcessEnv = process.env): number {
237
+ const trimmed = env.COS_PROMPT_EDIT_DAILY_CAP?.trim() ?? ''
238
+ if (!/^\d+$/.test(trimmed)) return PROMPT_EDIT_DEFAULT_DAILY_CAP
239
+ const value = Number(trimmed)
240
+ return value > 0 ? value : PROMPT_EDIT_DEFAULT_DAILY_CAP
241
+ }
242
+
243
+ export const promptEditBreaker = createBreaker({
244
+ label: 'prompt-edit', maxFailures: PROMPT_EDIT_BREAKER_FAILURES, cooldownMs: PROMPT_EDIT_BREAKER_MS,
245
+ })
246
+
247
+ const countFile = () => dataPath(PROMPT_EDIT_COUNT_FILE)
248
+
249
+ export function promptEditCountToday(): number {
250
+ try {
251
+ const raw = JSON.parse(readFileSync(countFile(), 'utf8'))
252
+ if (raw?.date !== localDay() || !Number.isInteger(raw.count) || raw.count < 0) return 0
253
+ return raw.count
254
+ } catch { return 0 }
255
+ }
256
+
257
+ export function recordPromptEditCall(): void {
258
+ try {
259
+ const file = countFile()
260
+ if (!existsSync(dirname(file))) mkdirSync(dirname(file), { recursive: true })
261
+ atomicWriteFileSync(file, JSON.stringify({ date: localDay(), count: promptEditCountToday() + 1 }), { mode: 0o600 })
262
+ } catch {}
263
+ }
264
+
265
+ /** The refusal for this request, before any call, or null when the edit may run. Switch, breaker, cap. */
266
+ export function promptEditRefusal(env: NodeJS.ProcessEnv = process.env): PromptEditRefusal | null {
267
+ if (!promptEditEnabled(env)) return 'off'
268
+ if (promptEditBreaker.isOpen()) return 'paused'
269
+ if (promptEditCountToday() >= promptEditDailyCap(env)) return 'cap'
270
+ return null
271
+ }
272
+
192
273
  // ── Outbound dictation auto-clean ────────────────────────────────────
193
274
  export const AUTOCLEAN_MAX_CHARS = 8_000
194
275
 
@@ -42,9 +42,12 @@ export interface TokenAuditEntry extends TokenAuditKeyFields {
42
42
  polishRegister?: string
43
43
  /** 6.91.0, dictation polish only: the prompt that ran (v3 or v4). */
44
44
  polishPrompt?: string
45
- /** 6.91.0, dictation polish only: what became of the model's answer (polished, unchanged, guarded) and the guard kind. */
45
+ /** 6.91.0, dictation polish only: what became of the model's answer (polished, unchanged, guarded) and the guard kind.
46
+ * 6.92.0: `aborted` (audit only, never in a finalize answer) when the client went away mid-call. */
46
47
  polishOutcome?: string
47
48
  polishGuard?: string
49
+ /** 6.92.0, prompt edit only: ok, failed or aborted. */
50
+ promptEditOutcome?: 'ok' | 'failed' | 'aborted'
48
51
  }
49
52
 
50
53
  /**
@@ -86,6 +89,8 @@ export function buildTokenAuditRecord(entry: TokenAuditEntry, now: Date = new Da
86
89
  ...(entry.polishPrompt ? { polish_prompt: entry.polishPrompt } : {}),
87
90
  ...(entry.polishOutcome ? { polish_outcome: entry.polishOutcome } : {}),
88
91
  ...(entry.polishGuard ? { polish_guard: entry.polishGuard } : {}),
92
+ // 6.92.0: set only by the prompt-edit caller.
93
+ ...(entry.promptEditOutcome ? { prompt_edit_outcome: entry.promptEditOutcome } : {}),
89
94
  }
90
95
  }
91
96
 
@@ -46,6 +46,7 @@ import {
46
46
  } from '../lib/dictation-polish-options.js'
47
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
48
  import { publishedRegisters, registersEnabled } from '../lib/dictation-register.js'
49
+ import { meaningGuardEnabled } from '../lib/dictation-meaning-guard.js'
49
50
 
50
51
  /** Where the instructions apply (prompt-drafts.ts polishInstructionsFor): never meeting lines. */
51
52
  export const CUSTOM_INSTRUCTIONS_APPLY_TO = ['message', 'prompt_draft'] as const
@@ -248,11 +249,17 @@ dictationPolishSettingsRouter.delete('/dictation/learned-style', (_req, res) =>
248
249
  function optionsView() {
249
250
  const stored = readPolishOptions()
250
251
  const choice = dictationPolishPromptChoice()
252
+ const effectiveByModel = Object.fromEntries(POLISH_MODEL_DEFAULTS.map(model => [model, dictationPolishPromptChoice(process.env, model).version]))
251
253
  return {
252
254
  promptVersion: stored.promptVersion,
253
255
  effectiveVersion: choice.version,
254
256
  // 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])),
257
+ effectiveByModel,
258
+ // 6.92.0 (plan r3, A-W2): what Control's picker line needs, additive. The pairs the meaning guard,
259
+ // Your style and the register apply to (the same list GET /rules computes), and whether the
260
+ // meaning guard is on (COS_DICTATION_MEANING_GUARD). Older servers omit both.
261
+ stylePairs: POLISH_MODEL_DEFAULTS.filter(model => styleAndRegisterApply(model, effectiveByModel[model])).map(model => ({ model, prompt: effectiveByModel[model] })),
262
+ meaningGuard: meaningGuardEnabled(),
256
263
  source: choice.source,
257
264
  // The environment named a version, so this option has no effect until it is removed.
258
265
  envOverride: choice.source === 'env',
@@ -42,7 +42,7 @@ import { polishBudgetExhausted } from '../lib/dictation-polish-budget.js'
42
42
  import { discardPolishPrewarm, isPrewarmModel, prewarmEnabled, startPolishPrewarm } from '../lib/dictation-prewarm.js'
43
43
  import { applyCorrections } from '../lib/whisper-local.js'
44
44
  import { registerFromContext } from '../lib/dictation-register.js'
45
- import { autoCleanBreakers, cleanOutboundDictation, type AutoCleanRequest, type PolishResult } from '../lib/dictation-polish-pipeline.js'
45
+ import { cleanOutboundDictation, polishBreakerOpen, type AutoCleanRequest, type PolishResult } from '../lib/dictation-polish-pipeline.js'
46
46
  import { emitDisplay } from '../lib/display-bus.js'
47
47
  import {
48
48
  acquireMaintenanceWork,
@@ -158,7 +158,8 @@ promptDraftsRouter.post('/dictation/polish/prewarm', (req, res) => {
158
158
  if (surface !== 'message' && surface !== 'meeting') return res.status(400).json({ error: 'invalid_surface' })
159
159
  if (!prewarmEnabled()) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'disabled' })
160
160
  if (!isPrewarmModel(model)) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'model' })
161
- if (autoCleanBreakers[model].isOpen()) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'breaker_open' })
161
+ // 6.92.0: the same keyed breaker (model, bucket group) the finalize for this surface would ask.
162
+ if (polishBreakerOpen(model, surface)) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'breaker_open' })
162
163
  if (polishBudgetExhausted(surface)) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'daily_cap' })
163
164
  try {
164
165
  res.json(startPolishPrewarm(model))
@@ -1,13 +1,28 @@
1
1
  import { Router } from 'express'
2
+ import type { Response } from 'express'
2
3
  import {
4
+ PROMPT_EDIT_REFUSALS,
3
5
  PromptEditValidationError,
4
6
  applyPromptEdit,
7
+ buildPromptEditPrompt,
5
8
  normalizePromptEditInput,
9
+ promptEditBreaker,
10
+ promptEditModel,
11
+ promptEditRefusal,
12
+ recordPromptEditCall,
13
+ type PromptEditRefusal,
6
14
  } from '../lib/prompt-edit.js'
7
- import { errMsg } from '../lib/utils.js'
15
+ import { ClaudeTextError } from '../lib/claude-text-runner.js'
16
+ import { logTokenAudit } from '../lib/token-audit.js'
8
17
 
9
18
  export const promptEditRouter = Router()
10
19
 
20
+ /** 6.92.0: a short text/plain body (24 characters or fewer), which G2 6.10.654 shows after "Edit failed.". */
21
+ function refuse(res: Response, refusal: PromptEditRefusal): void {
22
+ const { status, body } = PROMPT_EDIT_REFUSALS[refusal]
23
+ res.status(status).type('text/plain').send(body)
24
+ }
25
+
11
26
  promptEditRouter.post('/prompt-edit', async (req, res) => {
12
27
  const abort = new AbortController()
13
28
  // Abort the model spawn ONLY if the client disconnects before we respond.
@@ -19,15 +34,42 @@ promptEditRouter.post('/prompt-edit', async (req, res) => {
19
34
  res.on('close', () => {
20
35
  if (!res.writableEnded) abort.abort()
21
36
  })
37
+ // 6.92.0: the switch first, so "off" answers the same whatever the body.
38
+ if (promptEditRefusal() === 'off') return refuse(res, 'off')
39
+ let input
22
40
  try {
23
- const input = normalizePromptEditInput(req.body)
24
- const revisedText = await applyPromptEdit(input, abort.signal)
25
- res.json({ revisedText })
41
+ input = normalizePromptEditInput(req.body)
26
42
  } catch (err) {
27
43
  if (err instanceof PromptEditValidationError) {
28
44
  res.status(err.status).json({ error: err.message })
29
45
  return
30
46
  }
31
- res.status(500).json({ error: errMsg(err) })
47
+ return refuse(res, 'failed')
48
+ }
49
+ const refusal = promptEditRefusal()
50
+ if (refusal) return refuse(res, refusal)
51
+ // Counted before the call, as the dictation polish counts: a call that started was spent.
52
+ recordPromptEditCall()
53
+ const startedAt = Date.now()
54
+ const audit = (outcome: 'ok' | 'failed' | 'aborted', outputChars: number) => logTokenAudit({
55
+ source: 'g2-prompt-edit', model: promptEditModel(), inputChars: buildPromptEditPrompt(input).length, outputChars,
56
+ durationMs: Date.now() - startedAt, caller: 'prompt_edit', promptEditOutcome: outcome,
57
+ })
58
+ try {
59
+ const revisedText = await applyPromptEdit(input, abort.signal)
60
+ promptEditBreaker.recordSuccess()
61
+ audit('ok', revisedText.length)
62
+ res.json({ revisedText })
63
+ } catch (err) {
64
+ if (abort.signal.aborted) {
65
+ // The phone went away: not a CLI failure, so the breaker neither counts nor resets.
66
+ audit('aborted', 0)
67
+ return
68
+ }
69
+ promptEditBreaker.recordFailure()
70
+ audit('failed', 0)
71
+ // A code only: the CLI's stdout and stderr can carry the draft.
72
+ console.warn(`[prompt-edit] failed (${err instanceof ClaudeTextError ? err.code : 'error'})`)
73
+ refuse(res, 'failed')
32
74
  }
33
75
  })