@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.
- package/.env.example +34 -1
- package/CHANGELOG.md +97 -0
- package/README.md +32 -1
- package/managed-runtime-contract.json +8 -1
- package/package.json +1 -1
- package/server/lib/claude-circuit.ts +8 -4
- package/server/lib/claude-text-runner.ts +113 -0
- package/server/lib/dictation-fillers-fixture-cases.ts +214 -0
- package/server/lib/dictation-fillers.ts +30 -0
- package/server/lib/dictation-meaning-guard.ts +47 -0
- package/server/lib/dictation-polish-pipeline.ts +90 -12
- package/server/lib/prompt-edit.ts +84 -3
- package/server/lib/token-audit.ts +6 -1
- package/server/routes/dictation-polish-settings.ts +8 -1
- package/server/routes/prompt-drafts.ts +3 -2
- package/server/routes/prompt-edit.ts +47 -5
- package/server/scripts/dictation-parity-6920-mutation-gate.mjs +229 -0
- package/server/scripts/generate-dictation-fillers-fixture.ts +18 -0
|
@@ -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,
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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
|
|
183
|
-
model:
|
|
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:
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|