@gotcos/glasses-server 6.79.1 → 6.79.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,82 @@
1
+ ## 6.79.2 - Live cue limits from COS Control: the bar, cues per meeting, cooldown and a daily writer ceiling, applied without a restart
2
+
3
+ Miles, 2026-10-19: cap live-cue spend from COS Control. On 10/19, live cues ran 126 Sonnet writer calls (about 310K writer tokens) across 7 meetings.
4
+
5
+ - **Client matrix.**
6
+ - COS Control 0.5.328 (or later) reads and sets the limits.
7
+ - Control 0.5.327 and older keep working byte for byte: GET and POST without a version answer exactly the 6.79.1 body.
8
+ - No companion change is required. The lens shows a meeting stopped by the daily ceiling as `budget_exhausted`, a reason it already knows.
9
+ - **Four limits, stored in `live-cues-v2-settings.json`** (atomic write, mode 0600, the file that holds the on/off switch). They apply from each meeting's next evaluation, with no restart.
10
+ - `gate`: the gate's bar, 0.5 to 0.95. Default 0.8.
11
+ - `maxCuesPerMeeting`: 1 to 25, default 25. The writer cap per meeting stays twice this.
12
+ - `cooldownSeconds`: whole seconds, 20 to 900. Default 45.
13
+ - `dailyWriterCalls`: 1 to 500, default 150. **This is a new ceiling**: writer calls per local calendar day across all meetings. The weekly ceiling is unchanged, so this caps today at 150 writer calls a day where before only the weekly 750 applied.
14
+ - A value saved out of bounds (a hand edit) reads as unset, never as a looser limit.
15
+ - A file holding only the switch is still written as schema 1, so a 6.79.1 server reads it as before. Any saved limit makes it schema 2, which 6.79.1 also reads (it looks only at `enabled`).
16
+ - **Env wins and locks.** `COS_LIVE_CUES_V2_GATE` (its acceptance unchanged, 0.3 to 0.99), `COS_LIVE_CUES_V2_MAX_PER_MEETING` (new, whole 1 to 25), `COS_LIVE_CUES_V2_COOLDOWN_S` and `COS_LIVE_CUES_V2_DAILY_WRITER_CALLS` (new, whole 1 to 500).
17
+ - A valid env value is reported `source: "env", locked: true`.
18
+ - A POST naming a locked limit is 409 `env_locked` and saves nothing.
19
+ - An env value outside its range does not lock: the saved setting or the default applies, with one log line per value.
20
+ - Both new variables are in `managed-runtime-contract.json` `optionalEnvironment`.
21
+ - **Engine.**
22
+ - The gate, cue cap, writer cap and cooldown are read from the resolver on every feed and evaluation.
23
+ - A meeting already past a lowered cap stops at its next feed (skip `meeting_cap`, no gate call), and resumes if the cap is raised.
24
+ - The daily ceiling is checked before the gate (skip `daily_cap`, so no Jev spend either).
25
+ - Both the cap and the daily ceiling are checked again just before the writer is charged, so a limit lowered mid-evaluation applies at once (outcome `meeting_cap` or `daily_cap`).
26
+ - On the wire the daily ceiling reads as `budget_exhausted`: the shared stop-reason union, pinned with the companion's copy, has no `daily_cap`.
27
+ - Cue generation, prompts and the judge are unchanged.
28
+ - **Today's usage.** The durable state file gains `day` and `today` `{writerCalls, writerTokensIn, writerTokensOut, cues, jevTokens}` on the local calendar day of the zone it already stores.
29
+ - Writer calls count when charged, cues when shown, and tokens once per evaluation from the numbers it traced. `jevTokens` is the gate plus the judge, from the decision provider that ran them: Jev, or SubQ when SubQ runs cues.
30
+ - A future-dated, malformed or unreadable day reads its writer calls as spent, like the week: it fails toward not spending.
31
+ - A pre-6.79.2 file reads as zero for today.
32
+ - **Routes.** `GET /api/live-cues/v2/settings?v=2` and `POST {"v": 2, ...}` answer the v1 body plus `v`, `limits`, `writerCallsPerMeeting` and `today`.
33
+ - Each limit is reported as `{value, source, locked, min, max, default}`.
34
+ - POST v2 accepts any subset of `enabled`, `gate`, `maxCuesPerMeeting`, `cooldownSeconds` and `dailyWriterCalls`. Every field is checked before one write, so a refused request changes nothing.
35
+ - 400 `invalid_request` with `reason`: `out_of_bounds` (with `field`, `min`, `max`, `integer`), `unknown_field`, `invalid_enabled`, `empty` or `unsupported_version`.
36
+ - 409 `env_locked` (with `fields`), or `env_forced` when turning v2 off while env forces it on.
37
+ - An older server ignores `?v=2` and answers without `v`: that is Control's feature detection.
38
+ - **Dictation filler pass (from `320ecb7`, which shipped after 6.79.1 was published from `a0035a8`).**
39
+ - **Titles and abbreviations.** A Titlecase token right after a title or abbreviation (Mr., Mrs., Ms., Dr., Prof., St., Jr., Sr., vs., etc., e.g., i.e., a.m., p.m., U.S., an initial) is a name and is kept: "We met Mr. Ah and Ms. Er yesterday" and "Dr. Er said yes" stay as said.
40
+ - A lowercase filler there still goes, with no capital hand-off: "We met Mr. uh Jones" becomes "We met Mr. Jones", and "at 5 p.m. uh we meet" becomes "at 5 p.m. we meet". In 6.79.1 every token after a title was kept, so "Mr. uh Jones" kept the "uh", and Jr. and Sr. were not abbreviations.
41
+ - **Sentence ends.** An ellipsis does not end a sentence. The capital passes on only when the removed filler was itself a sentence's first word. The abbreviation check that became unreachable once the rule above runs first is removed.
42
+ - Dictation mutation gate `server/scripts/dictation-polish-mutation-gate.mjs`: 55 of 55 against a 133-test baseline.
43
+ - **Faster dictation polish: start the polish process at key press.**
44
+ - **Why.** Miles, 2026-10-19: polish takes 1.8 to 4.1 s with Haiku (median about 2.4 s). A trivial `claude -p` with the polish args takes 1.6 to 1.8 s, so most of the wait is the CLI starting, not the edit.
45
+ - **Canary on his Mac (Haiku, 9 real calls).** Cold: 1786, 1894, 1886, 1968 ms. A process started 3 to 5 s before the text answered 962 to 1305 ms after the text arrived; one started 1 s before took 1501 ms, because the CLI had not finished starting.
46
+ - **`POST /api/dictation/polish/prewarm`** `{model, surface?}` answers `{token, expiresMs}`. It spawns the exact polish process now (same args: no tools, MCP, hooks or session file, its own empty workspace) with `--input-format stream-json`, waiting for one message.
47
+ - Refusals are 409 `{error: "prewarm_unavailable", reason}`, with reason `disabled`, `model` (Luna cannot be warmed), `breaker_open`, `daily_cap` or `cli`.
48
+ - `DELETE /api/dictation/polish/prewarm/<token>` discards one.
49
+ - **`/api/dictation/finalize` takes an optional `prewarmToken`.**
50
+ - A live token for the same model gets the one message, then its stdin is closed and it exits: no history ever carries over.
51
+ - A process that died before the message falls back to today's cold spawn, the same single call. A failure after the message was sent is a real `failed`, never retried cold.
52
+ - With a token, the answer adds `prewarm`: `used`, `missed` (unknown, expired, evicted, wrong model or dead) or `discarded` (the model was not called, so the process was killed).
53
+ - A malformed token is 400 `invalid_prewarm_token`.
54
+ - **Bounds.**
55
+ - At most 2 warm processes; a third evicts the oldest.
56
+ - Each is killed 150 s after it starts if unused.
57
+ - Every live one is signalled when the server exits, claimed or not.
58
+ - The daily polish budget is spent only when one is used; a warm process makes no model call until its message.
59
+ - `COS_DICTATION_PREWARM=0` turns it off.
60
+ - **Clients.** No companion change. Control needs a change to call it, specified in the COS workspace (`wk43_2026/dictation_polish/PREWARM_CONTROL_SPEC.md`). Control 0.5.328's polish bridge ignores the new answer keys.
61
+ - **Skip the model when the text is clean (off by default).**
62
+ - **The rule.** With `COS_DICTATION_POLISH_SKIP_CLEAN=1`, finalize returns the filler-passed text with `polishSkipped: "clean"` unless the text has any of these:
63
+ - a backtrack marker
64
+ - a repeated word or restarted clause
65
+ - a filler-like ("like", "you know", "kind of", or a leading "So", "Well" or "Okay")
66
+ - a filler sound
67
+ - spoken formatting or punctuation
68
+ - no opening capital or no closing punctuation
69
+ - more than 40 words
70
+
71
+ The rule is `polishNeed` in `server/lib/dictation-polish-need.ts`, with a table test.
72
+ - **Eval on the 45 synthetic cases.**
73
+ - As dictated (lowercase, unpunctuated), all 45 still reach the model, so nothing changes.
74
+ - Capitalized with a closing period, as Mac dictation arrives, 15 would skip. 3 of those lose a word fix that v2 makes on both models: mis-heard names ("tablo" for Tableau and "jewel three sixty" for Jewel360, "preeya" for Priya and "hub spot" for HubSpot, "p o s" for POS). No rule can see those.
75
+ - It therefore stays off.
76
+ - **Managed runtime.** `COS_DICTATION_PREWARM` and `COS_DICTATION_POLISH_SKIP_CLEAN` are in `managed-runtime-contract.json` `optionalEnvironment`.
77
+ - **Dictation mutation gate** (`server/scripts/dictation-polish-mutation-gate.mjs`, not packaged): 92 of 92 against a 199-test baseline in 7 files. 37 of them are new for the prewarm and the skip rule.
78
+ - **Checks.** Full suite 7,896 passed, 3 skipped (510 files); `tsc --noEmit` clean. Mutation gate `server/scripts/live-cues-verdicts-mutation-gate.mjs` (not packaged): 179 of 179 guard mutants killed by named tests against a proven green baseline (149 tests in 8 files), 48 of them new for the limits. The existing pins (prompts, contract hash, calibrated defaults, the v1 settings body and its schema-1 file) are unchanged.
79
+
1
80
  ## 6.79.1 - Dictation polish: one budget per caller, a reason when it does not polish, no more um and uh
2
81
 
3
82
  Miles, 2026-10-19: AI polish for Mac dictation failed on every dictation from 11:39 CDT (`failed:not_polished` in about 60 ms). Every autoclean shared one daily cap (`COS_DICTATION_AUTOCLEAN_MAX_PER_DAY`). The glasses' live meeting lines (surface `meeting`, Sonnet, one call per 10-second chunk while the phone's Moonshine lane is up) spent it by late morning. Nothing in the answer said why.
package/README.md CHANGED
@@ -787,10 +787,19 @@ VOICE) follow the same settings. Contract and measurements:
787
787
  - Messages and prompt drafts drop standalone um, uh, er, ah, hmm, mm and mhm
788
788
  before the model and when it is skipped. Meeting lines are left as heard.
789
789
  It keeps ALL-CAPS tokens, a Titlecase token mid-sentence (a name) unless a
790
- comma follows it, any token after a title (Mr., Dr.) and quoted mentions.
790
+ comma follows it, a Titlecase token after a title or abbreviation (Mr. Ah,
791
+ Dr. Er) and quoted mentions.
791
792
  `COS_DICTATION_FILLER_PASS=0` turns the pass off.
792
793
  - `COS_DICTATION_POLISH_PROMPT=v1` restores the 6.79.0 polish prompt (default
793
794
  `v2`).
795
+ - 6.79.2: `POST /api/dictation/polish/prewarm` `{model}` starts the polish
796
+ process at key press and answers `{token, expiresMs}`. Pass the token as
797
+ `prewarmToken` to `/api/dictation/finalize` and the polish skips the CLI's
798
+ start-up (about 0.9 s on a Mac). At most 2 warm processes, 150 s each, one
799
+ message each. `COS_DICTATION_PREWARM=0` turns it off.
800
+ - 6.79.2: `COS_DICTATION_POLISH_SKIP_CLEAN=1` returns finished, clean text
801
+ without the model (`polishSkipped: "clean"`). Off by default: it cannot see
802
+ a mis-heard name.
794
803
 
795
804
  ### Parakeet (FluidAudio CoreML, offline)
796
805
 
@@ -33,6 +33,8 @@
33
33
  "COS_LIVE_CUES_V2_COOLDOWN_S",
34
34
  "COS_LIVE_CUES_V2_JUDGE_KEEP",
35
35
  "COS_LIVE_CUES_V2_REPEAT_OVERLAP",
36
+ "COS_LIVE_CUES_V2_MAX_PER_MEETING",
37
+ "COS_LIVE_CUES_V2_DAILY_WRITER_CALLS",
36
38
  "COS_LIVE_CUES_JEV_DAILY_TOKENS",
37
39
  "COS_LIVE_CUES_SUBQ_DAILY_TOKENS",
38
40
  "COS_DECISION_PROVIDER_CUES",
@@ -77,7 +79,9 @@
77
79
  "COS_DICTATION_AUTOCLEAN_MAX_PER_DAY_MEETING",
78
80
  "COS_DICTATION_AUTOCLEAN_MAX_PER_DAY_PROMPT_DRAFT",
79
81
  "COS_DICTATION_POLISH_PROMPT",
80
- "COS_DICTATION_FILLER_PASS"
82
+ "COS_DICTATION_FILLER_PASS",
83
+ "COS_DICTATION_PREWARM",
84
+ "COS_DICTATION_POLISH_SKIP_CLEAN"
81
85
  ],
82
86
  "maintenance": {
83
87
  "scope": "cross_boot",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.79.1",
3
+ "version": "6.79.2",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -83,6 +83,18 @@ export function dictationChildEnv(binary: string): NodeJS.ProcessEnv {
83
83
  return env
84
84
  }
85
85
 
86
+ /** The Claude polish process: no tools, MCP, hooks or session file. `stream` keeps stdin open
87
+ * for exactly one stream-json user message (the 6.79.2 prewarm); `text` reads the prompt from stdin. */
88
+ export function claudePolishArgs(model: string, version: DictationPolishPromptVersion, io: 'text' | 'stream'): string[] {
89
+ return [
90
+ '-p', '--model', model, '--effort', 'low',
91
+ ...(io === 'text' ? ['--output-format', 'text'] : ['--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose']),
92
+ '--no-session-persistence', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
93
+ '--tools', '', '--settings', '{"disableAllHooks":true}', '--permission-mode', 'dontAsk',
94
+ '--system-prompt', DICTATION_POLISH_SYSTEM_PROMPTS[version],
95
+ ]
96
+ }
97
+
86
98
  /** A fresh workspace and no MCP. Claude has no tools or hooks; Cursor's ask
87
99
  * mode retains read tools. Neither runner is given the COS working directory.
88
100
  * Failure rejects so callers preserve the deterministic transcript. */
@@ -128,12 +140,7 @@ export async function autoCleanDictation(
128
140
  const args = cursor ? [
129
141
  '-p', '--mode', 'ask', '--model', LUNA_DICTATION_CLI_MODEL,
130
142
  '--output-format', 'json', '--trust', '--workspace', workspace,
131
- ] : [
132
- '-p', '--model', model, '--effort', 'low', '--output-format', 'text',
133
- '--no-session-persistence', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
134
- '--tools', '', '--settings', '{"disableAllHooks":true}', '--permission-mode', 'dontAsk',
135
- '--system-prompt', DICTATION_POLISH_SYSTEM_PROMPTS[version],
136
- ]
143
+ ] : claudePolishArgs(model, version, 'text')
137
144
  return await new Promise<string>((resolve, reject) => {
138
145
  const proc = spawn(binary.path, args, { stdio: ['pipe', 'pipe', 'pipe'], env, cwd: workspace, detached: true })
139
146
  let stdout = ''
@@ -16,7 +16,8 @@
16
16
  * ellipsis follows it ("I think, Um, we"). An abbreviation's period ("Mr.", "e.g.", "5 p.m.",
17
17
  * "U.S.", an initial) and an ellipsis are not sentence ends, so they neither start a sentence
18
18
  * nor trigger the capital hand-off.
19
- * - any token right after a title ("Mr. Ah", "Dr. Er") and any quoted mention ('"um"').
19
+ * - a Titlecase token right after a title or abbreviation ("Mr. Ah", "Dr. Er"; "Mr. uh Jones"
20
+ * still loses "uh"), and any quoted mention ('"um"').
20
21
  * - "uh huh", "uh oh" and "mm hmm" written without a hyphen: answers, not hesitations.
21
22
  * - "like", "you know", "I mean": filler only sometimes, so the model decides, not a regex.
22
23
  * A filler inside a longer quote ("um, no") is removed like any other.
@@ -30,23 +31,28 @@ const TRAILING_PAUSE = /^[ \t]*(?:,|\.{2,}|…|--|—|–)?[ \t]*/
30
31
  const OWN_SENTENCE_END = /^[ \t]*[.?!]+[ \t]*/
31
32
  const CLOSES_CLAUSE = /^(?:[.?!;:)\]}"”’]|$|\n)/
32
33
 
33
- /** A period that ends an abbreviation, not a sentence: "Mr.", "e.g.", "5 p.m.", "U.S.", "J.". */
34
- const ABBREVIATION = /(?:^|[\s(“"‘'])(?:mrs?|ms|dr|prof|st|vs|etc|e\.g|i\.e|a\.m|p\.m|u\.s)\.["”’)]*$/i
34
+ /** A period that ends an abbreviation, not a sentence: "Mr.", "Jr.", "e.g.", "5 p.m.", "U.S.", "J.". */
35
+ const ABBREVIATION = /(?:^|[\s(“"‘'])(?:mrs?|ms|dr|prof|st|jr|sr|vs|etc|e\.g|i\.e|a\.m|p\.m|u\.s)\.["”’)]*$/i
35
36
  const INITIAL = /(?:^|[\s(“"‘'])[A-Z]\.["”’)]*$/
36
- /** A title right before a token makes the token a name ("Mr. Ah", "Dr. Er"). */
37
- const TITLE = /(?:^|[\s(“"‘'])(?:mrs?|ms|dr|prof|st)\.[ \t]*$/i
38
37
  const QUOTE_OPEN = /["“”'‘’]$/
39
38
  const QUOTE_CLOSE = /^["“”'‘’]/
40
39
  /** A pause mark right after the token: a Titlecase filler set off this way is a hesitation. */
41
40
  const PAUSE_AFTER = /^[ \t]*(?:,|\.{2,}|…)/
42
41
 
42
+ function afterAbbreviation(before: string): boolean {
43
+ const trimmed = before.replace(/[ \t]+$/, '')
44
+ return ABBREVIATION.test(trimmed) || INITIAL.test(trimmed)
45
+ }
46
+
43
47
  function sentenceStart(before: string): boolean {
44
48
  const trimmed = before.replace(/[ \t]+$/, '')
45
49
  if (trimmed === '' || trimmed.endsWith('\n')) return true
46
50
  if (!/[.?!]["”’)]*$/.test(trimmed)) return false
47
- // An ellipsis is a pause, and an abbreviation's period is not a sentence end.
48
- if (/(?:\.{2,}|…)["”’)]*$/.test(trimmed)) return false
49
- return !(ABBREVIATION.test(trimmed) || INITIAL.test(trimmed))
51
+ // An ellipsis is a pause, not a sentence end. An abbreviation's period needs no case here:
52
+ // the only thing a sentence start changes is the capital hand-off from a Titlecase filler,
53
+ // and a Titlecase token after an abbreviation is kept as a name before this is consulted
54
+ // (the 6.79.1 mutation gate showed a check here was unreachable).
55
+ return !/(?:\.{2,}|…)["”’)]*$/.test(trimmed)
50
56
  }
51
57
 
52
58
  export function fillerPassEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
@@ -70,8 +76,9 @@ export function stripDisfluencies(text: string): string {
70
76
  // A capital away from a sentence start is a name ("Ask Ah Kow"), unless a pause mark
71
77
  // sets it off as a hesitation ("I think, Um, we should").
72
78
  || (!atStart && /^[A-Z]/.test(token) && !PAUSE_AFTER.test(after))
73
- // Right after a title it is a name ("Mr. Ah", "Dr. Er").
74
- || TITLE.test(out + before)
79
+ // Titlecase right after a title or abbreviation is a name ("Mr. Ah", "Dr. Er, the surgeon").
80
+ // A lowercase filler there still goes ("Mr. uh Jones").
81
+ || (/^[A-Z]/.test(token) && afterAbbreviation(out + before))
75
82
  // A quoted mention is the word itself ('"um" is a filler word').
76
83
  || (QUOTE_OPEN.test(out + before) && QUOTE_CLOSE.test(after))
77
84
  // Two-word answers said without a hyphen: "uh huh", "uh oh", "mm hmm".
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Skip the polish model when the text has nothing for it to fix.
3
+ *
4
+ * Runs after the deterministic filler pass. The model is called only when one of these
5
+ * is present; anything else goes out as the filler-passed text with polishSkipped "clean":
6
+ * - a backtrack or self-correction marker ("actually", "no wait", "I mean", "scratch that", "sorry")
7
+ * - a repeated word or stutter ("the the", "I I"), or a clause restarted on its first word
8
+ * ("we should, we need to")
9
+ * - a filler-like the deterministic pass leaves to the model ("like", "you know", "kind of"),
10
+ * or a sentence that opens on a tic ("So", "Well", "Okay")
11
+ * - a filler sound still present (a meeting line, or one the pass kept)
12
+ * - spoken formatting or punctuation ("new line", "bullet point", "comma", "question mark")
13
+ * - text that does not read as finished: no capital at the start or no closing punctuation
14
+ * - more than MAX_WORDS words
15
+ * Each check errs toward calling the model: a false "needed" costs a model call, a false
16
+ * "clean" costs a fix. What it cannot see is a mis-heard name ("ask preeya" for Priya): the
17
+ * 6.79.2 eval found those are the fixes skipping loses, so the switch defaults OFF.
18
+ */
19
+ export const MAX_WORDS = 40
20
+
21
+ export type PolishNeed =
22
+ | 'backtrack' | 'repeat' | 'false_start' | 'filler_like' | 'filler' | 'formatting' | 'unfinished' | 'long'
23
+
24
+ const BACKTRACK = /\b(actually|no,? wait|wait,? no|i mean|scratch that|sorry|or rather|correction|let me rephrase|i meant|no no)\b|,\s*no\s*,/i
25
+ const REPEAT = /(?<![\p{L}\p{N}'])([\p{L}\p{N}']+)[\s,]+\1(?![\p{L}\p{N}'])/iu
26
+ /** A clause restarted on the same word: "we should, we need to". */
27
+ const FALSE_START = /(?<![\p{L}\p{N}'])([\p{L}']+)\s+[\p{L}']+(?:\s+[\p{L}']+)?,\s+\1(?![\p{L}\p{N}'])/iu
28
+ const FILLER_LIKE = /\b(like|you know|kind of|kinda|sort of|sorta|basically|literally|i guess)\b|^["“(]?(so|well|okay|ok|alright|anyway)\b/i
29
+ const FILLER = /(?<![\p{L}\p{N}'’-])(um+|uhm|uh+|erm?|ah+|hm+|mm+|mhm)(?![\p{L}\p{N}'’-])/iu
30
+ const FORMATTING = /\b(new ?line|new paragraph|next line|bullet( point)?|numbered|number (one|two|three|1|2|3)|comma|period|full stop|question mark|exclamation (point|mark)|colon|semicolon|open (quote|paren)|close (quote|paren)|dash)\b/i
31
+
32
+ export function polishNeed(text: string): PolishNeed | null {
33
+ const trimmed = text.trim()
34
+ if (BACKTRACK.test(trimmed)) return 'backtrack'
35
+ if (REPEAT.test(trimmed)) return 'repeat'
36
+ if (FALSE_START.test(trimmed)) return 'false_start'
37
+ if (FILLER_LIKE.test(trimmed)) return 'filler_like'
38
+ if (FILLER.test(trimmed)) return 'filler'
39
+ if (FORMATTING.test(trimmed)) return 'formatting'
40
+ if (!/^["“'‘(]?[\p{Lu}\p{N}]/u.test(trimmed) || !/[.?!]["”’)]*$/.test(trimmed)) return 'unfinished'
41
+ if (trimmed.split(/\s+/).length > MAX_WORDS) return 'long'
42
+ return null
43
+ }
44
+
45
+ /** Off unless COS_DICTATION_POLISH_SKIP_CLEAN is 1, true or on. */
46
+ export function skipCleanEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
47
+ return ['1', 'true', 'on'].includes((env.COS_DICTATION_POLISH_SKIP_CLEAN ?? '').trim().toLowerCase())
48
+ }
@@ -0,0 +1,209 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { resolveProviderBinary } from './provider-binary.js'
7
+ import { terminateProviderProcess } from './provider-process-lifecycle.js'
8
+ import {
9
+ buildDictationPolishPrompt, claudePolishArgs, dictationChildEnv, dictationPolishPromptVersion,
10
+ type DictationPolishPromptVersion,
11
+ } from './dictation-clean.js'
12
+
13
+ /**
14
+ * 6.79.2: start the polish Claude process when dictation starts, not when it ends.
15
+ *
16
+ * About 70% of a polish is the CLI's own start (measured on Miles's Mac: cold polish 1.8 to
17
+ * 1.9 s; the same process started 3 s earlier answered 1.0 to 1.3 s after the text arrived).
18
+ * A warm process is the exact polish process (same args, no tools, MCP, hooks or session
19
+ * file, its own empty workspace) in stream-json input mode, waiting for ONE user message.
20
+ * It gets that message, answers, and its stdin is closed at once, so it exits: no history
21
+ * ever carries into another dictation.
22
+ *
23
+ * Bounded: at most PREWARM_MAX live (a new one evicts the oldest), each killed after
24
+ * PREWARM_TTL_MS unused, killed when finalize decides not to call the model, and killed
25
+ * with the server. A warm process costs no model call until a message is written, so the
26
+ * daily polish budget is spent only when it is used.
27
+ */
28
+ export const PREWARM_TTL_MS = 150_000
29
+ export const PREWARM_MAX = 2
30
+ export const PREWARM_MODELS = ['haiku', 'sonnet'] as const
31
+ export type PrewarmModel = typeof PREWARM_MODELS[number]
32
+
33
+ export class PrewarmUnavailableError extends Error {}
34
+
35
+ interface WarmPolish {
36
+ token: string
37
+ model: PrewarmModel
38
+ version: DictationPolishPromptVersion
39
+ proc: ChildProcess
40
+ scratch: string
41
+ exited: boolean
42
+ timer: NodeJS.Timeout
43
+ onResult: ((event: any) => void) | null
44
+ onExit: (() => void) | null
45
+ }
46
+
47
+ /** Unclaimed warm processes, by token. */
48
+ const warm = new Map<string, WarmPolish>()
49
+ /** Every process not yet exited, claimed or not: what the server kills on its way out. */
50
+ const alive = new Set<WarmPolish>()
51
+
52
+ export function prewarmEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
53
+ return !['0', 'false', 'off'].includes((env.COS_DICTATION_PREWARM ?? '').trim().toLowerCase())
54
+ }
55
+
56
+ export function isPrewarmModel(model: unknown): model is PrewarmModel {
57
+ return typeof model === 'string' && (PREWARM_MODELS as readonly string[]).includes(model)
58
+ }
59
+
60
+ export function livePrewarmCount(): number {
61
+ return warm.size
62
+ }
63
+
64
+ function retire(entry: WarmPolish): void {
65
+ warm.delete(entry.token)
66
+ clearTimeout(entry.timer)
67
+ if (!entry.exited) void terminateProviderProcess(entry.proc).catch(() => {})
68
+ }
69
+
70
+ export function startPolishPrewarm(model: PrewarmModel): { token: string; expiresMs: number } {
71
+ const binary = resolveProviderBinary('claude')
72
+ if (!binary.ok) throw new PrewarmUnavailableError('Dictation cleanup Claude CLI unavailable')
73
+ while (warm.size >= PREWARM_MAX) retire(warm.values().next().value!)
74
+ const version = dictationPolishPromptVersion()
75
+ const scratch = mkdtempSync(join(tmpdir(), 'cos-dictation-prewarm-'))
76
+ const workspace = join(scratch, 'workspace')
77
+ mkdirSync(workspace, { mode: 0o700 })
78
+ const proc = spawn(binary.path, claudePolishArgs(model, version, 'stream'), {
79
+ stdio: ['pipe', 'pipe', 'pipe'], env: dictationChildEnv(binary.path), cwd: workspace, detached: true,
80
+ })
81
+ const token = randomUUID()
82
+ const entry: WarmPolish = {
83
+ token, model, version, proc, scratch, exited: false, onResult: null, onExit: null,
84
+ timer: setTimeout(() => retire(entry), PREWARM_TTL_MS),
85
+ }
86
+ entry.timer.unref?.()
87
+ let buffer = ''
88
+ proc.stdout?.on('data', (chunk: Buffer) => {
89
+ buffer += chunk.toString()
90
+ if (buffer.length > 256_000) { retire(entry); return }
91
+ let newline: number
92
+ while ((newline = buffer.indexOf('\n')) >= 0) {
93
+ const line = buffer.slice(0, newline).trim()
94
+ buffer = buffer.slice(newline + 1)
95
+ if (!line) continue
96
+ let event: any
97
+ try { event = JSON.parse(line) } catch { continue }
98
+ if (event?.type === 'result') entry.onResult?.(event)
99
+ }
100
+ })
101
+ // Do not log provider stderr: it can contain login or dictation data.
102
+ proc.stderr?.resume()
103
+ proc.stdin?.on('error', () => {})
104
+ const exited = () => {
105
+ if (entry.exited) return
106
+ entry.exited = true
107
+ warm.delete(token)
108
+ alive.delete(entry)
109
+ clearTimeout(entry.timer)
110
+ rmSync(scratch, { recursive: true, force: true })
111
+ entry.onExit?.()
112
+ }
113
+ proc.on('error', exited)
114
+ proc.on('close', exited)
115
+ warm.set(token, entry)
116
+ alive.add(entry)
117
+ return { token, expiresMs: PREWARM_TTL_MS }
118
+ }
119
+
120
+ /** Kill a warm process nobody will use (finalize skipped the model, or the press was cancelled). */
121
+ export function discardPolishPrewarm(token: string | undefined): boolean {
122
+ const entry = token ? warm.get(token) : undefined
123
+ if (!entry) return false
124
+ retire(entry)
125
+ return true
126
+ }
127
+
128
+ /** One use: the entry leaves the pool whether or not it matches. A model mismatch kills it. */
129
+ export function takePolishPrewarm(token: string | undefined, model: string): WarmPolish | null {
130
+ const entry = token ? warm.get(token) : undefined
131
+ if (!entry) return null
132
+ warm.delete(entry.token)
133
+ if (entry.model !== model || entry.exited) { retire(entry); return null }
134
+ return entry
135
+ }
136
+
137
+ /**
138
+ * Write the one message and read the one result. PrewarmUnavailableError means nothing was
139
+ * sent (the process had already gone), so the caller may spawn cold. Any later failure means
140
+ * the model may have run, so it is a real polish failure.
141
+ */
142
+ export async function runWarmPolish(
143
+ entry: WarmPolish,
144
+ text: string,
145
+ terms: string[],
146
+ opts: { signal?: AbortSignal } = {},
147
+ ): Promise<string> {
148
+ if (entry.exited || !entry.proc.stdin || entry.proc.stdin.destroyed) {
149
+ retire(entry)
150
+ throw new PrewarmUnavailableError('Warm polish process already exited')
151
+ }
152
+ if (opts.signal?.aborted) { retire(entry); throw new Error('Auto-clean aborted') }
153
+ clearTimeout(entry.timer)
154
+ const configuredTimeout = Number.parseInt(process.env.COS_DICTATION_AUTOCLEAN_TIMEOUT_MS || '20000', 10)
155
+ const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : 20_000
156
+ return await new Promise<string>((resolve, reject) => {
157
+ let settled = false
158
+ const finish = (error?: Error, output?: string) => {
159
+ if (settled) return
160
+ settled = true
161
+ clearTimeout(timer)
162
+ opts.signal?.removeEventListener('abort', abort)
163
+ entry.onResult = null
164
+ entry.onExit = null
165
+ // Exactly one message: once it is answered (or abandoned) the process must go.
166
+ retire(entry)
167
+ if (error) reject(error); else resolve(output!)
168
+ }
169
+ const abort = () => finish(new Error('Auto-clean aborted'))
170
+ const timer = setTimeout(() => finish(new Error(`Auto-clean timed out (${timeoutMs}ms)`)), timeoutMs)
171
+ entry.onResult = (event) => {
172
+ if (event.subtype !== 'success' || event.is_error || typeof event.result !== 'string') {
173
+ return finish(new Error('Warm polish returned an unsuccessful result'))
174
+ }
175
+ const output = event.result.trim()
176
+ if (!output) return finish(new Error('Auto-clean returned empty text'))
177
+ finish(undefined, output)
178
+ }
179
+ entry.onExit = () => finish(new Error('Warm polish process exited without a result'))
180
+ opts.signal?.addEventListener('abort', abort, { once: true })
181
+ const message = JSON.stringify({
182
+ type: 'user',
183
+ message: { role: 'user', content: buildDictationPolishPrompt(text, terms, entry.version) },
184
+ })
185
+ try {
186
+ entry.proc.stdin!.write(message + '\n')
187
+ entry.proc.stdin!.end()
188
+ } catch {
189
+ finish(new Error('Auto-clean input failed'))
190
+ }
191
+ })
192
+ }
193
+
194
+ /** The server is going away: a detached warm process would outlive it. */
195
+ export function killAllPrewarmsSync(): void {
196
+ for (const entry of alive) {
197
+ try { if (entry.proc.pid) process.kill(-entry.proc.pid, 'SIGTERM') } catch {}
198
+ // No close event will run before the server is gone, so its workspace goes now.
199
+ try { rmSync(entry.scratch, { recursive: true, force: true }) } catch {}
200
+ }
201
+ warm.clear()
202
+ alive.clear()
203
+ }
204
+ process.once('exit', killAllPrewarmsSync)
205
+
206
+ export function _resetPrewarmsForTest(): void {
207
+ for (const entry of [...alive]) retire(entry)
208
+ warm.clear()
209
+ }
@@ -5,6 +5,12 @@
5
5
  // 3. off (source 'default').
6
6
  // Only the exact value '1' forces. Any other env value (unset, '0', 'true') defers to the setting, as it always read as off.
7
7
  // An unreadable or malformed settings file reads as no setting, so it fails toward off, never toward spending.
8
+ //
9
+ // 6.79.2: the same file holds COS Control's spend limits (gate, cues per meeting, cooldown, writer calls per local day).
10
+ // Each saved value is validated on read; one out of bounds reads as unset (the default), never as a looser limit. An env
11
+ // var for a limit wins over the saved value and locks it. A file holding only `enabled` is still written as schema 1, so
12
+ // a 6.79.1 server reads it exactly as before; any limit makes it schema 2, which 6.79.1 also reads (it looks only at
13
+ // `enabled`).
8
14
 
9
15
  import { existsSync, mkdirSync, readFileSync } from 'node:fs'
10
16
  import { dirname } from 'node:path'
@@ -13,34 +19,66 @@ import { dataPath } from './data-dir.js'
13
19
 
14
20
  export type LiveCuesV2SettingSource = 'env' | 'setting' | 'default'
15
21
 
16
- /** What the engine and the routes need from the store: the saved choice, or null when there is none. */
17
- export interface LiveCuesV2SettingReader { stored(): boolean | null }
22
+ /** COS Control's live-cue spend limits (6.79.2). Bounds are inclusive; integer fields take whole numbers only. */
23
+ export const LIVE_CUES_V2_LIMIT_BOUNDS = {
24
+ gate: { min: 0.5, max: 0.95, default: 0.8, integer: false, env: 'COS_LIVE_CUES_V2_GATE' },
25
+ maxCuesPerMeeting: { min: 1, max: 25, default: 25, integer: true, env: 'COS_LIVE_CUES_V2_MAX_PER_MEETING' },
26
+ cooldownSeconds: { min: 20, max: 900, default: 45, integer: true, env: 'COS_LIVE_CUES_V2_COOLDOWN_S' },
27
+ dailyWriterCalls: { min: 1, max: 500, default: 150, integer: true, env: 'COS_LIVE_CUES_V2_DAILY_WRITER_CALLS' },
28
+ } as const
29
+ export type LiveCuesV2LimitName = keyof typeof LIVE_CUES_V2_LIMIT_BOUNDS
30
+ export const LIVE_CUES_V2_LIMIT_NAMES = Object.keys(LIVE_CUES_V2_LIMIT_BOUNDS) as LiveCuesV2LimitName[]
31
+ export type LiveCuesV2StoredLimits = Partial<Record<LiveCuesV2LimitName, number>>
32
+
33
+ /** Inside the bounds, and whole where the field is whole. */
34
+ export function limitInBounds(name: LiveCuesV2LimitName, value: unknown): value is number {
35
+ const b = LIVE_CUES_V2_LIMIT_BOUNDS[name]
36
+ return typeof value === 'number' && Number.isFinite(value) && value >= b.min && value <= b.max && (!b.integer || Number.isInteger(value))
37
+ }
38
+
39
+ /** What the engine and the routes need from the store: the saved choice, or null when there is none, and (6.79.2) the
40
+ * saved limits. `limits` is optional so a reader without it means "no saved limits". */
41
+ export interface LiveCuesV2SettingReader { stored(): boolean | null; limits?(): LiveCuesV2StoredLimits }
18
42
 
19
43
  export class LiveCuesV2Settings implements LiveCuesV2SettingReader {
20
- /** Loaded once; only this server writes the file, and every write goes through save(). */
21
- private cache: { value: boolean | null } | null = null
44
+ /** Loaded once; only this server writes the file, and every write goes through update(). */
45
+ private cache: { value: boolean | null; limits: LiveCuesV2StoredLimits } | null = null
22
46
  constructor(readonly file: string) {}
23
47
 
24
- stored(): boolean | null {
25
- if (this.cache) return this.cache.value
48
+ private load(): { value: boolean | null; limits: LiveCuesV2StoredLimits } {
49
+ if (this.cache) return this.cache
26
50
  let value: boolean | null = null
51
+ const limits: LiveCuesV2StoredLimits = {}
27
52
  try {
28
53
  if (existsSync(this.file)) {
29
54
  const raw = JSON.parse(readFileSync(this.file, 'utf8')) as unknown
30
- if (raw && typeof raw === 'object' && !Array.isArray(raw) && typeof (raw as { enabled?: unknown }).enabled === 'boolean') {
31
- value = (raw as { enabled: boolean }).enabled
55
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
56
+ const r = raw as Record<string, unknown>
57
+ if (typeof r.enabled === 'boolean') value = r.enabled
58
+ for (const name of LIVE_CUES_V2_LIMIT_NAMES) if (limitInBounds(name, r[name])) limits[name] = r[name] as number
32
59
  }
33
60
  }
34
61
  } catch { value = null }
35
- this.cache = { value }
36
- return value
62
+ this.cache = { value, limits }
63
+ return this.cache
37
64
  }
38
65
 
66
+ stored(): boolean | null { return this.load().value }
67
+ limits(): LiveCuesV2StoredLimits { return { ...this.load().limits } }
68
+
39
69
  /** Atomic and durable. The in-memory value changes only after the file is written, so a failed write changes nothing. */
40
- save(enabled: boolean, now: Date = new Date()): void {
70
+ save(enabled: boolean, now: Date = new Date()): void { this.update({ enabled }, now) }
71
+
72
+ /** One write for any mix of the switch and the limits; unnamed fields keep their saved values. */
73
+ update(patch: { enabled?: boolean; limits?: LiveCuesV2StoredLimits }, now: Date = new Date()): void {
74
+ const current = this.load()
75
+ const value = patch.enabled ?? current.value
76
+ const limits = { ...current.limits, ...patch.limits }
77
+ const hasLimits = Object.keys(limits).length > 0
78
+ const body = { schema: hasLimits ? 2 : 1, ...(value === null ? {} : { enabled: value }), ...limits, updatedAt: now.toISOString() }
41
79
  mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
42
- durableAtomicWriteFileSync(this.file, JSON.stringify({ schema: 1, enabled, updatedAt: now.toISOString() }) + '\n', { mode: 0o600 })
43
- this.cache = { value: enabled }
80
+ durableAtomicWriteFileSync(this.file, JSON.stringify(body) + '\n', { mode: 0o600 })
81
+ this.cache = { value, limits }
44
82
  }
45
83
  }
46
84
 
@@ -26,7 +26,10 @@ import { JevClient, JevError, estimateJevTokens, resolveJevKey, type JevAnswers
26
26
  import { DecisionProviderError, SubqDecisionClient, trackSubqDecisionClient, type DecisionClient } from './decision-provider.js'
27
27
  import type { DecisionProvider } from './decision-settings.js'
28
28
  import { resolveSubqKey } from './subq-key.js'
29
- import { liveCuesV2EnvForced, liveCuesV2Settings, resolveLiveCuesV2, type LiveCuesV2SettingReader, type LiveCuesV2SettingSource } from './live-cues-v2-settings.js'
29
+ import {
30
+ LIVE_CUES_V2_LIMIT_BOUNDS, LIVE_CUES_V2_LIMIT_NAMES, liveCuesV2EnvForced, liveCuesV2Settings, resolveLiveCuesV2,
31
+ type LiveCuesV2LimitName, type LiveCuesV2SettingReader, type LiveCuesV2SettingSource, type LiveCuesV2StoredLimits,
32
+ } from './live-cues-v2-settings.js'
30
33
  import { notesMemory } from './live-cues-v2-notes.js'
31
34
  import { meetingLibraryConfigured } from './cos-operations-meetings.js'
32
35
  import { getMeetingStore } from './meeting-store.js'
@@ -87,9 +90,13 @@ export const LIVE_CUES_V2_LIMITS = {
87
90
  export function liveCuesV2Enabled(env: NodeJS.ProcessEnv = process.env, settings: LiveCuesV2SettingReader | null = liveCuesV2Settings()): boolean {
88
91
  return resolveLiveCuesV2(env, settings).enabled
89
92
  }
90
- export function liveCuesV2Gate(env: NodeJS.ProcessEnv = process.env): number {
93
+ /** The gate from env alone, or null when env does not set a usable one (unchanged acceptance: 0.3 to 0.99). */
94
+ function envGate(env: NodeJS.ProcessEnv): number | null {
91
95
  const raw = Number(env.COS_LIVE_CUES_V2_GATE)
92
- return Number.isFinite(raw) && raw >= 0.3 && raw <= 0.99 ? raw : LIVE_CUES_V2_LIMITS.defaultGate
96
+ return env.COS_LIVE_CUES_V2_GATE !== undefined && env.COS_LIVE_CUES_V2_GATE.trim() !== '' && Number.isFinite(raw) && raw >= 0.3 && raw <= 0.99 ? raw : null
97
+ }
98
+ export function liveCuesV2Gate(env: NodeJS.ProcessEnv = process.env): number {
99
+ return envGate(env) ?? LIVE_CUES_V2_LIMITS.defaultGate
93
100
  }
94
101
  /** 6.79.0 tuning knobs for Friday's rated-cue review. Unset (or empty, which a managed env may pass for an unset optional
95
102
  * variable) is the calibrated default with no log. Anything else outside the accepted form falls back to the default with
@@ -102,12 +109,49 @@ function knobFallback<T>(name: string, raw: string, fallback: T, accepted: strin
102
109
  }
103
110
  return fallback
104
111
  }
105
- export function liveCuesV2CooldownMs(env: NodeJS.ProcessEnv = process.env): number {
112
+ /** A whole-number env knob (6.79.2 limits): null when unset or empty, the value when inside [min, max], else null with
113
+ * one log line per value (the saved setting or the default then applies). */
114
+ function envWhole(env: NodeJS.ProcessEnv, name: string, min: number, max: number): number | null {
115
+ const raw = env[name]
116
+ if (raw === undefined || raw.trim() === '') return null
117
+ const n = /^\d{1,4}$/.test(raw.trim()) ? Number(raw.trim()) : Number.NaN
118
+ return n >= min && n <= max ? n : knobFallback<number | null>(name, raw, null, `a whole number from ${min} to ${max}`)
119
+ }
120
+ /** The cooldown from env alone (whole seconds 20..900), or null when unset, empty or invalid (one log line per value). */
121
+ function envCooldownSeconds(env: NodeJS.ProcessEnv): number | null {
106
122
  const raw = env.COS_LIVE_CUES_V2_COOLDOWN_S
107
- if (raw === undefined || raw.trim() === '') return LIVE_CUES_V2_LIMITS.cooldownAfterCueMs
123
+ if (raw === undefined || raw.trim() === '') return null
108
124
  const seconds = /^\d{1,3}$/.test(raw.trim()) ? Number(raw.trim()) : Number.NaN // whole seconds only
109
- return seconds >= 20 && seconds <= 900 ? seconds * 1000
110
- : knobFallback('COS_LIVE_CUES_V2_COOLDOWN_S', raw, LIVE_CUES_V2_LIMITS.cooldownAfterCueMs, 'whole seconds from 20 to 900')
125
+ return seconds >= 20 && seconds <= 900 ? seconds : knobFallback<number | null>('COS_LIVE_CUES_V2_COOLDOWN_S', raw, null, 'whole seconds from 20 to 900')
126
+ }
127
+ export function liveCuesV2CooldownMs(env: NodeJS.ProcessEnv = process.env): number {
128
+ const seconds = envCooldownSeconds(env)
129
+ return seconds === null ? LIVE_CUES_V2_LIMITS.cooldownAfterCueMs : seconds * 1000
130
+ }
131
+
132
+ /** One resolved limit: where its value came from, and whether COS Control may change it (an env var locks it). */
133
+ export interface LiveCuesV2Limit { value: number; source: LiveCuesV2SettingSource; locked: boolean; min: number; max: number; default: number }
134
+ export type LiveCuesV2Limits = Record<LiveCuesV2LimitName, LiveCuesV2Limit>
135
+ /** 6.79.2: env wins and locks; else the saved setting; else the default. Read on every evaluation, so a saved change
136
+ * applies from the next one with no restart. */
137
+ export function resolveLiveCuesV2Limits(env: NodeJS.ProcessEnv = process.env, settings: LiveCuesV2SettingReader | null = liveCuesV2Settings()): LiveCuesV2Limits {
138
+ let saved: LiveCuesV2StoredLimits = {}
139
+ try { saved = settings?.limits?.() ?? {} } catch { saved = {} }
140
+ const fromEnv: Record<LiveCuesV2LimitName, number | null> = {
141
+ gate: envGate(env),
142
+ maxCuesPerMeeting: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.maxCuesPerMeeting.env, 1, 25),
143
+ cooldownSeconds: envCooldownSeconds(env),
144
+ dailyWriterCalls: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.dailyWriterCalls.env, 1, 500),
145
+ }
146
+ const out = {} as LiveCuesV2Limits
147
+ for (const name of LIVE_CUES_V2_LIMIT_NAMES) {
148
+ const b = LIVE_CUES_V2_LIMIT_BOUNDS[name]
149
+ const e = fromEnv[name], v = saved[name]
150
+ out[name] = e !== null ? { value: e, source: 'env', locked: true, min: b.min, max: b.max, default: b.default }
151
+ : v !== undefined ? { value: v, source: 'setting', locked: false, min: b.min, max: b.max, default: b.default }
152
+ : { value: b.default, source: 'default', locked: false, min: b.min, max: b.max, default: b.default }
153
+ }
154
+ return out
111
155
  }
112
156
  export function liveCuesV2JudgeKeep(env: NodeJS.ProcessEnv = process.env): number {
113
157
  const raw = env.COS_LIVE_CUES_V2_JUDGE_KEEP
@@ -244,11 +288,22 @@ export function localWeekStart(now: Date, zone: string): string {
244
288
 
245
289
  // ── Durable state: weekly ceiling, tombstones, pauses, per-meeting counters ─────
246
290
  interface MeetingCounters { cues: number; writerCalls: number; at: number }
291
+ /** 6.79.2: usage on the local calendar day (in the stored zone), for the daily writer ceiling and COS Control's readout. */
292
+ export interface LiveCuesV2DayUsage { writerCalls: number; writerTokensIn: number; writerTokensOut: number; cues: number; jevTokens: number }
293
+ const DAY_FIELDS = ['writerCalls', 'writerTokensIn', 'writerTokensOut', 'cues', 'jevTokens'] as const
247
294
  interface DurableState {
248
295
  zone: string; weekStart: string; writerCalls: number
249
296
  ended: Record<string, number>; paused: Record<string, boolean>; pauseSeq: Record<string, number>
250
297
  meetings: Record<string, MeetingCounters>
298
+ day: string; today: LiveCuesV2DayUsage
251
299
  }
300
+ /** Local calendar day (YYYY-MM-DD) in `zone`. Intl, never toISOString: a UTC key flips the day at 19:00 Central. */
301
+ export function localDay(now: Date, zone: string): string {
302
+ return new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(now)
303
+ }
304
+ const zeroDay = (): LiveCuesV2DayUsage => ({ writerCalls: 0, writerTokensIn: 0, writerTokensOut: 0, cues: 0, jevTokens: 0 })
305
+ /** Saturated writer calls: an unreadable, future-dated or malformed day fails toward NOT spending, like the week. */
306
+ const spentDay = (): LiveCuesV2DayUsage => ({ ...zeroDay(), writerCalls: Number.MAX_SAFE_INTEGER })
252
307
  function validZone(zone: unknown): string | null {
253
308
  if (typeof zone !== 'string' || !zone) return null
254
309
  try { new Intl.DateTimeFormat('en-CA', { timeZone: zone }); return zone } catch { return null }
@@ -278,11 +333,20 @@ export class LiveCuesV2Store {
278
333
  const calls = Number(s.writerCalls ?? 0)
279
334
  // A future week key or a negative/non-numeric count fails toward NOT spending: the week is saturated.
280
335
  const saturated = futureWeek || (sameWeek && !(calls >= 0))
281
- return { zone, weekStart: week, writerCalls: saturated ? Number.MAX_SAFE_INTEGER : sameWeek ? calls : 0, ended, paused, pauseSeq, meetings }
336
+ const day = localDay(this.now(), zone)
337
+ const t = (s.today ?? {}) as Partial<Record<string, unknown>>
338
+ let today: LiveCuesV2DayUsage
339
+ if (s.day === undefined || (typeof s.day === 'string' && s.day < day)) today = zeroDay()
340
+ else if (s.day !== day) today = spentDay() // a future or malformed day
341
+ else today = DAY_FIELDS.every(f => t[f] === undefined || Number(t[f]) >= 0)
342
+ ? Object.fromEntries(DAY_FIELDS.map(f => [f, Number(t[f] ?? 0)])) as unknown as LiveCuesV2DayUsage
343
+ : spentDay()
344
+ return { zone, weekStart: week, writerCalls: saturated ? Number.MAX_SAFE_INTEGER : sameWeek ? calls : 0, ended, paused, pauseSeq, meetings, day, today }
282
345
  } catch {
283
- // An unreadable file fails closed for this week (no spending) but keeps cues off nothing else.
346
+ // An unreadable file fails closed for this week and this day (no spending) but keeps cues off nothing else.
284
347
  const zone = validZone(Intl.DateTimeFormat().resolvedOptions().timeZone) ?? 'UTC'
285
- return { zone, weekStart: localWeekStart(this.now(), zone), writerCalls: Number.MAX_SAFE_INTEGER, ended: {}, paused: {}, pauseSeq: {}, meetings: {} }
348
+ return { zone, weekStart: localWeekStart(this.now(), zone), writerCalls: Number.MAX_SAFE_INTEGER, ended: {}, paused: {}, pauseSeq: {}, meetings: {},
349
+ day: localDay(this.now(), zone), today: spentDay() }
286
350
  }
287
351
  }
288
352
  private write(s: DurableState): void {
@@ -294,6 +358,7 @@ export class LiveCuesV2Store {
294
358
  const s = this.read()
295
359
  if (s.writerCalls >= cap) return false
296
360
  s.writerCalls += 1
361
+ s.today.writerCalls += 1
297
362
  const m = s.meetings[sessionId] ?? { cues: 0, writerCalls: 0, at: 0 }
298
363
  s.meetings[sessionId] = { ...m, writerCalls: m.writerCalls + 1, at: this.now().getTime() }
299
364
  this.write(s)
@@ -303,8 +368,21 @@ export class LiveCuesV2Store {
303
368
  const s = this.read()
304
369
  const m = s.meetings[sessionId] ?? { cues: 0, writerCalls: 0, at: 0 }
305
370
  s.meetings[sessionId] = { ...m, cues: m.cues + 1, at: this.now().getTime() }
371
+ s.today.cues += 1
372
+ this.write(s)
373
+ }
374
+ /** Tokens one evaluation spent (writer in and out, the decision provider's gate and judge). One write; nothing when zero. */
375
+ recordUsage(u: { writerTokensIn?: number; writerTokensOut?: number; jevTokens?: number }): void {
376
+ const add = (n: number | undefined) => (typeof n === 'number' && Number.isFinite(n) && n > 0 ? Math.round(n) : 0)
377
+ const inc = { writerTokensIn: add(u.writerTokensIn), writerTokensOut: add(u.writerTokensOut), jevTokens: add(u.jevTokens) }
378
+ if (!inc.writerTokensIn && !inc.writerTokensOut && !inc.jevTokens) return
379
+ const s = this.read()
380
+ s.today.writerTokensIn += inc.writerTokensIn; s.today.writerTokensOut += inc.writerTokensOut; s.today.jevTokens += inc.jevTokens
306
381
  this.write(s)
307
382
  }
383
+ /** Today's usage (local day in the stored zone). A saturated (fail-closed) day reads its writer calls as the sentinel. */
384
+ today(): { day: string } & LiveCuesV2DayUsage { const s = this.read(); return { day: s.day, ...s.today } }
385
+ dailyWriterCalls(): number { return this.read().today.writerCalls }
308
386
  counters(sessionId: string): { cues: number; writerCalls: number } {
309
387
  const m = this.read().meetings[sessionId]
310
388
  return { cues: m?.cues ?? 0, writerCalls: m?.writerCalls ?? 0 }
@@ -544,6 +622,15 @@ export class LiveCuesV2Engine {
544
622
  constructor(private readonly deps: LiveCuesV2Deps) {}
545
623
 
546
624
  private enabled(): boolean { return liveCuesV2Enabled(this.deps.env, this.deps.settings) }
625
+ /** 6.79.2: the spend limits as they stand now (env, else COS Control's saved values, else the defaults). The writer
626
+ * cap per meeting stays twice the cue cap. */
627
+ private limits(): { gate: number; maxCues: number; writerCap: number; cooldownMs: number; dailyWriterCalls: number } {
628
+ const r = resolveLiveCuesV2Limits(this.deps.env, this.deps.settings)
629
+ return { gate: r.gate.value, maxCues: r.maxCuesPerMeeting.value, writerCap: 2 * r.maxCuesPerMeeting.value,
630
+ cooldownMs: r.cooldownSeconds.value * 1000, dailyWriterCalls: r.dailyWriterCalls.value }
631
+ }
632
+ /** Today's usage for COS Control (local day in the stored zone). */
633
+ today(): { day: string } & LiveCuesV2DayUsage { return this.deps.store.today() }
547
634
 
548
635
  capability(): LiveCuesV2Capability {
549
636
  const model = liveCuesV2Model(this.deps.env)
@@ -620,10 +707,12 @@ export class LiveCuesV2Engine {
620
707
  }
621
708
 
622
709
  private stopReasonOf(s: Session, counters: { cues: number; writerCalls: number }): LiveCuesV2StopReason | null {
623
- const L = LIVE_CUES_V2_LIMITS
710
+ const lim = this.limits()
624
711
  if (s.breaker) return 'breaker_open'
625
- if (counters.cues >= L.maxCuesPerMeeting || counters.writerCalls >= L.maxWriterCallsPerMeeting) return 'meeting_cap'
626
- if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env)) return 'budget_exhausted'
712
+ if (counters.cues >= lim.maxCues || counters.writerCalls >= lim.writerCap) return 'meeting_cap'
713
+ // The daily ceiling reads as budget_exhausted on the wire: the shared stop-reason union (pinned with the companion's
714
+ // copy) has no daily_cap. The trace says daily_cap.
715
+ if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env) || this.deps.store.dailyWriterCalls() >= lim.dailyWriterCalls) return 'budget_exhausted'
627
716
  return s.stopReason
628
717
  }
629
718
 
@@ -665,10 +754,12 @@ export class LiveCuesV2Engine {
665
754
  if (s.breaker) return this.traceSkip(s, 'breaker_open', nowMs)
666
755
  if (this.deps.store.isPaused(sessionId)) return this.traceSkip(s, 'paused', nowMs)
667
756
  if (s.wordsSinceEval < L.minNewWords) return
668
- if (nowMs - s.lastEvalAt < L.minIntervalMs || nowMs - s.lastCueAt < liveCuesV2CooldownMs(this.deps.env)) return
757
+ const lim = this.limits()
758
+ if (nowMs - s.lastEvalAt < L.minIntervalMs || nowMs - s.lastCueAt < lim.cooldownMs) return
669
759
  const counters = this.deps.store.counters(sessionId)
670
- if (counters.cues >= L.maxCuesPerMeeting || counters.writerCalls >= L.maxWriterCallsPerMeeting) return this.traceSkip(s, 'meeting_cap', nowMs)
760
+ if (counters.cues >= lim.maxCues || counters.writerCalls >= lim.writerCap) return this.traceSkip(s, 'meeting_cap', nowMs)
671
761
  if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env)) return this.traceSkip(s, 'budget_exhausted', nowMs) // before the gate
762
+ if (this.deps.store.dailyWriterCalls() >= lim.dailyWriterCalls) return this.traceSkip(s, 'daily_cap', nowMs) // before the gate
672
763
  if (this.deps.busy()) return this.traceSkip(s, 'busy', nowMs)
673
764
  await this.evaluate(s)
674
765
  }
@@ -704,7 +795,7 @@ export class LiveCuesV2Engine {
704
795
  const gate = await this.deps.gate(s.title, window, abort.signal, provider)
705
796
  const pCue = Number.isFinite(gate.pCue) ? gate.pCue : 0
706
797
  Object.assign(trace, { gateMs: Date.now() - t0, pCue: round(pCue), cueKind: gate.kind, lookup: gate.lookup, gateTokens: gate.tokens })
707
- if (pCue < liveCuesV2Gate(this.deps.env)) { trace.outcome = 'gate_silent'; s.stopReason = null; return }
798
+ if (pCue < this.limits().gate) { trace.outcome = 'gate_silent'; s.stopReason = null; return }
708
799
  if (abort.signal.aborted) { trace.outcome = 'cancelled'; return }
709
800
 
710
801
  const t1 = Date.now()
@@ -731,6 +822,10 @@ export class LiveCuesV2Engine {
731
822
  trace.kept = kept.length
732
823
  if (abort.signal.aborted) { trace.outcome = 'cancelled'; return }
733
824
 
825
+ // 6.79.2: limits lowered while this evaluation ran apply before the writer is charged.
826
+ const lim = this.limits(), counters = this.deps.store.counters(s.sessionId)
827
+ if (counters.cues >= lim.maxCues || counters.writerCalls >= lim.writerCap) { trace.outcome = 'meeting_cap'; return }
828
+ if (this.deps.store.dailyWriterCalls() >= lim.dailyWriterCalls) { trace.outcome = 'daily_cap'; s.stopReason = 'budget_exhausted'; return }
734
829
  if (!this.deps.store.charge(liveCuesV2WeeklyCap(this.deps.env), s.sessionId)) { trace.outcome = 'budget_exhausted'; s.stopReason = 'budget_exhausted'; return }
735
830
  const { version, system } = liveCuesV2WriterSystem(this.deps.env)
736
831
  const model = liveCuesV2Model(this.deps.env)
@@ -791,6 +886,11 @@ export class LiveCuesV2Engine {
791
886
  if (error instanceof LensGistLimitError || s.failures >= L.breakerFailures) s.breaker = true
792
887
  } finally {
793
888
  clearTimeout(wall)
889
+ // 6.79.2: today's spend for COS Control, from the numbers this evaluation traced. One write; best effort.
890
+ try {
891
+ this.deps.store.recordUsage({ writerTokensIn: trace.writerIn as number | undefined, writerTokensOut: trace.writerOut as number | undefined,
892
+ jevTokens: (Number(trace.gateTokens) || 0) + (Number(trace.judgeTokens) || 0) })
893
+ } catch { /* the readout is best effort; the trace row still has the numbers */ }
794
894
  if (wallHit && trace.outcome === 'cancelled') { trace.outcome = 'failure'; trace.error = 'wall_timeout'; s.failures += 1; if (s.failures >= L.breakerFailures) s.breaker = true }
795
895
  if (trace.outcome === 'cancelled' && abort.signal.reason === V2_OFF) trace.cancelReason = V2_OFF
796
896
  s.inFlight = null
@@ -850,16 +950,44 @@ export function liveCuesV2SettingsView(env: NodeJS.ProcessEnv = process.env): Li
850
950
  /** Save the switch. Off while env forces it on is refused and changes nothing. Turning it off ends cue evaluation for running
851
951
  * meetings at once; turning it on applies from each meeting's next evaluation. */
852
952
  export function setLiveCuesV2Enabled(enabled: boolean, env: NodeJS.ProcessEnv = process.env): { ok: true; view: LiveCuesV2SettingsResponse } | { ok: false; error: 'env_forced' } {
853
- if (!enabled && liveCuesV2EnvForced(env)) return { ok: false, error: 'env_forced' }
953
+ const r = applyLiveCuesV2Settings({ enabled, limits: {} }, env)
954
+ if (!r.ok) return { ok: false, error: 'env_forced' }
955
+ return { ok: true, view: liveCuesV2SettingsView(env) }
956
+ }
957
+
958
+ /** GET /api/live-cues/v2/settings?v=2 and POST {v: 2, ...} (6.79.2): the v1 body plus the spend limits and today's usage.
959
+ * Without v=2 the routes answer exactly the v1 body, so an older COS Control is unaffected. */
960
+ export interface LiveCuesV2SettingsResponseV2 extends LiveCuesV2SettingsResponse {
961
+ v: 2
962
+ limits: LiveCuesV2Limits
963
+ /** Always twice the cue cap. */
964
+ writerCallsPerMeeting: number
965
+ today: { day: string } & LiveCuesV2DayUsage
966
+ }
967
+ export function liveCuesV2SettingsViewV2(env: NodeJS.ProcessEnv = process.env): LiveCuesV2SettingsResponseV2 {
968
+ const limits = resolveLiveCuesV2Limits(env, liveCuesV2Settings())
969
+ return { ...liveCuesV2SettingsView(env), v: 2, limits, writerCallsPerMeeting: 2 * limits.maxCuesPerMeeting.value, today: liveCuesV2().today() }
970
+ }
971
+
972
+ /** Save the switch and any limits in ONE write, after every check passed: nothing is saved when any part is refused.
973
+ * Turning v2 off stops evaluations in flight; limits apply from each meeting's next evaluation. */
974
+ export function applyLiveCuesV2Settings(patch: { enabled?: boolean; limits: LiveCuesV2StoredLimits }, env: NodeJS.ProcessEnv = process.env):
975
+ { ok: true } | { ok: false; error: 'env_forced' } | { ok: false; error: 'env_locked'; fields: LiveCuesV2LimitName[] } {
976
+ if (patch.enabled === false && liveCuesV2EnvForced(env)) return { ok: false, error: 'env_forced' }
977
+ const current = resolveLiveCuesV2Limits(env, liveCuesV2Settings())
978
+ const locked = (Object.keys(patch.limits) as LiveCuesV2LimitName[]).filter(name => current[name].locked)
979
+ if (locked.length) return { ok: false, error: 'env_locked', fields: locked }
854
980
  const wasOn = liveCuesV2Enabled(env)
855
- liveCuesV2Settings().save(enabled)
981
+ liveCuesV2Settings().update(patch)
856
982
  if (wasOn && !liveCuesV2Enabled(env)) {
857
983
  const aborted = engine?.suspend() ?? 0
858
984
  console.log(`[live-cues-v2] switched off by setting; ${aborted} evaluation(s) stopped`)
859
985
  } else if (!wasOn && liveCuesV2Enabled(env)) {
860
986
  console.log('[live-cues-v2] switched on by setting')
861
987
  }
862
- return { ok: true, view: liveCuesV2SettingsView(env) }
988
+ const changed = Object.entries(patch.limits).map(([k, v]) => `${k}=${v}`).join(' ')
989
+ if (changed) console.log(`[live-cues-v2] limits saved: ${changed}`)
990
+ return { ok: true }
863
991
  }
864
992
  /** What /api/health, /api/models and the status route advertise: NOTHING while v2 is switched off, so a 6.10.642
865
993
  * companion treats the server exactly like a v1 server (v1 cues keep their own capability and indicator). */
@@ -18,6 +18,8 @@ import {
18
18
  LiveCuesArmError,
19
19
  } from '../lib/live-cues-engine.js'
20
20
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
21
+ import { applyLiveCuesV2Settings, liveCuesV2SettingsViewV2 } from '../lib/live-cues-v2.js'
22
+ import { LIVE_CUES_V2_LIMIT_BOUNDS, LIVE_CUES_V2_LIMIT_NAMES, limitInBounds, type LiveCuesV2StoredLimits } from '../lib/live-cues-v2-settings.js'
21
23
  import { LIVE_CUES_V2_LIMITS, LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2MeetingCounters, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
22
24
  import { LIVE_CUE_VERDICTS_PROTOCOL, LIVE_CUE_VERDICT_LIMITS, VerdictsUnreadableError, isCueVerdict, liveCueVerdicts } from '../lib/live-cue-verdicts.js'
23
25
 
@@ -186,14 +188,23 @@ liveCuesRouter.get('/live-cues/history', (req, res) => {
186
188
  // COS Control's live cues v2 toggle (6.76.2). Token-authenticated like every /api route. The setting lives in this server's
187
189
  // data dir (live-cues-v2-settings.json, atomic write) and applies without a restart: /api/health and /api/models advertise
188
190
  // liveCuesV2 only while it is on. capability.memory and capability.decisionProvider say where notes and the gate come from.
189
- liveCuesRouter.get('/live-cues/v2/settings', (_req, res) => {
191
+ //
192
+ // 6.79.2: COS Control's spend limits ride the same routes, opt-in by version so an older Control is answered byte for byte as
193
+ // before. GET ?v=2 and POST {v: 2, ...any of enabled, gate, maxCuesPerMeeting, cooldownSeconds, dailyWriterCalls} answer the
194
+ // v1 body plus {v: 2, limits, writerCallsPerMeeting, today}. A limit an env var sets is locked (409 env_locked). An older
195
+ // server ignores ?v=2 and answers without `v`, which is Control's signal to hide the limits.
196
+ liveCuesRouter.get('/live-cues/v2/settings', (req, res) => {
190
197
  res.set('Cache-Control', 'private, no-store')
191
- try { return res.json(liveCuesV2SettingsView()) } catch { return res.status(503).json({ error: 'settings_unavailable' }) }
198
+ const v = req.query.v
199
+ if (v !== undefined && v !== '2') return res.status(400).json({ error: 'invalid_request', reason: 'unsupported_version' })
200
+ try { return res.json(v === '2' ? liveCuesV2SettingsViewV2() : liveCuesV2SettingsView()) } catch { return res.status(503).json({ error: 'settings_unavailable' }) }
192
201
  })
193
202
 
203
+ const V2_SETTINGS_KEYS = new Set<string>(['v', 'enabled', ...LIVE_CUES_V2_LIMIT_NAMES])
194
204
  liveCuesRouter.post('/live-cues/v2/settings', (req, res) => {
195
205
  res.set('Cache-Control', 'private, no-store')
196
- const body = req.body as { enabled?: unknown } | undefined
206
+ const body = req.body as { enabled?: unknown; v?: unknown } | undefined
207
+ if (body && typeof body === 'object' && !Array.isArray(body) && body.v !== undefined) return postSettingsV2(body as Record<string, unknown>, res)
197
208
  if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.enabled !== 'boolean') {
198
209
  return res.status(400).json({ error: 'invalid_request' })
199
210
  }
@@ -205,3 +216,28 @@ liveCuesRouter.post('/live-cues/v2/settings', (req, res) => {
205
216
  return res.status(500).json({ error: 'settings_write_failed' })
206
217
  }
207
218
  })
219
+
220
+ /** POST {v: 2, ...}: every field is checked before anything is saved, so a refused request changes nothing. */
221
+ function postSettingsV2(body: Record<string, unknown>, res: import('express').Response) {
222
+ if (body.v !== 2) return res.status(400).json({ error: 'invalid_request', reason: 'unsupported_version' })
223
+ const unknown = Object.keys(body).find(k => !V2_SETTINGS_KEYS.has(k))
224
+ if (unknown) return res.status(400).json({ error: 'invalid_request', reason: 'unknown_field', field: unknown })
225
+ if (body.enabled !== undefined && typeof body.enabled !== 'boolean') return res.status(400).json({ error: 'invalid_request', reason: 'invalid_enabled', field: 'enabled' })
226
+ const limits: LiveCuesV2StoredLimits = {}
227
+ for (const name of LIVE_CUES_V2_LIMIT_NAMES) {
228
+ if (body[name] === undefined) continue
229
+ if (!limitInBounds(name, body[name])) {
230
+ const b = LIVE_CUES_V2_LIMIT_BOUNDS[name]
231
+ return res.status(400).json({ error: 'invalid_request', reason: 'out_of_bounds', field: name, min: b.min, max: b.max, integer: b.integer })
232
+ }
233
+ limits[name] = body[name] as number
234
+ }
235
+ if (body.enabled === undefined && Object.keys(limits).length === 0) return res.status(400).json({ error: 'invalid_request', reason: 'empty' })
236
+ try {
237
+ const result = applyLiveCuesV2Settings({ ...(typeof body.enabled === 'boolean' ? { enabled: body.enabled } : {}), limits })
238
+ if (!result.ok) return res.status(409).json(result.error === 'env_locked' ? { error: 'env_locked', fields: result.fields } : { error: result.error })
239
+ return res.json(liveCuesV2SettingsViewV2())
240
+ } catch {
241
+ return res.status(500).json({ error: 'settings_write_failed' })
242
+ }
243
+ }
@@ -44,6 +44,11 @@ import { dictationSlotForMode, legacyDictationKey, type DictationPlan } from '..
44
44
  import { autoCleanDictation, AUTOCLEAN_MAX_CHARS } from '../lib/dictation-clean.js'
45
45
  import { fillerPassEnabled, stripDisfluencies } from '../lib/dictation-fillers.js'
46
46
  import { polishBudgetExhausted, recordPolishCall, type PolishBucket } from '../lib/dictation-polish-budget.js'
47
+ import { polishNeed, skipCleanEnabled } from '../lib/dictation-polish-need.js'
48
+ import {
49
+ discardPolishPrewarm, isPrewarmModel, prewarmEnabled, PrewarmUnavailableError, runWarmPolish,
50
+ startPolishPrewarm, takePolishPrewarm,
51
+ } from '../lib/dictation-prewarm.js'
47
52
  import { getVocabulary } from '../lib/profile.js'
48
53
  import { createBreaker } from '../lib/claude-circuit.js'
49
54
  import { logTokenAudit } from '../lib/token-audit.js'
@@ -85,7 +90,9 @@ const autoCleanBreakers = {
85
90
  const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
86
91
 
87
92
  /** 6.79.1, additive on finalize answers: why the polish model did not run (or did not finish). */
88
- export type PolishSkipped = 'disabled' | 'too_long' | 'breaker_open' | 'daily_cap' | 'failed'
93
+ export type PolishSkipped = 'disabled' | 'too_long' | 'breaker_open' | 'daily_cap' | 'failed' | 'clean'
94
+ /** 6.79.2, additive when finalize was given a prewarmToken: what became of that warm process. */
95
+ export type PrewarmOutcome = 'used' | 'missed' | 'discarded'
89
96
 
90
97
  interface AutoCleanRequest { enabled?: boolean; model?: DictationCleanModel }
91
98
  function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
@@ -99,8 +106,20 @@ function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
99
106
 
100
107
  async function cleanOutboundDictation(
101
108
  text: string,
102
- opts: AutoCleanRequest & { bucket: PolishBucket; signal?: AbortSignal },
103
- ): Promise<{ text: string; polishSkipped?: PolishSkipped }> {
109
+ opts: AutoCleanRequest & { bucket: PolishBucket; signal?: AbortSignal; prewarmToken?: string },
110
+ ): Promise<{ text: string; polishSkipped?: PolishSkipped; prewarm?: PrewarmOutcome }> {
111
+ const result = await polishOutboundDictation(text, opts)
112
+ // A warm process the model call did not take must not sit until its TTL.
113
+ if (opts.prewarmToken && !result.prewarm) {
114
+ return { ...result, prewarm: discardPolishPrewarm(opts.prewarmToken) ? 'discarded' : 'missed' }
115
+ }
116
+ return result
117
+ }
118
+
119
+ async function polishOutboundDictation(
120
+ text: string,
121
+ opts: AutoCleanRequest & { bucket: PolishBucket; signal?: AbortSignal; prewarmToken?: string },
122
+ ): Promise<{ text: string; polishSkipped?: PolishSkipped; prewarm?: PrewarmOutcome }> {
104
123
  let cleaned = applyNegativeRules(applyCorrections(text)).replace(/\s+/g, ' ').trim() || text
105
124
  // applyCorrections above is an EXACT string map, so it only fixes misspellings someone
106
125
  // already hand-authored. A novel miss ("Rileyy" for Riley, "Exampel" for Example) sails
@@ -129,22 +148,37 @@ async function cleanOutboundDictation(
129
148
  const model = opts.model ?? 'haiku'
130
149
  const autoCleanBreaker = autoCleanBreakers[model]
131
150
  if (cleaned.length > AUTOCLEAN_MAX_CHARS) return { text: cleaned, polishSkipped: 'too_long' }
151
+ // 6.79.2, off by default: nothing for the model to fix (see dictation-polish-need.ts).
152
+ if (skipCleanEnabled() && polishNeed(cleaned) === null) return { text: cleaned, polishSkipped: 'clean' }
132
153
  if (autoCleanBreaker.isOpen()) return { text: cleaned, polishSkipped: 'breaker_open' }
133
154
  if (polishBudgetExhausted(opts.bucket)) return { text: cleaned, polishSkipped: 'daily_cap' }
134
155
  const startedAt = Date.now()
135
156
  recordPolishCall(opts.bucket)
157
+ // 6.79.2: a warm process started at key press answers without the CLI's start-up cost.
158
+ const warmEntry = opts.prewarmToken ? takePolishPrewarm(opts.prewarmToken, model) : null
159
+ let prewarm: PrewarmOutcome | undefined = opts.prewarmToken ? 'missed' : undefined
136
160
  try {
137
- const polished = (await autoCleanDictation(cleaned, getVocabulary(), { model, signal: opts.signal })).trim()
161
+ let polished: string | null = null
162
+ if (warmEntry) {
163
+ try {
164
+ polished = (await runWarmPolish(warmEntry, cleaned, getVocabulary(), { signal: opts.signal })).trim()
165
+ prewarm = 'used'
166
+ } catch (err) {
167
+ // Nothing was sent: the cold path below is the same call, not a second one.
168
+ if (!(err instanceof PrewarmUnavailableError)) throw err
169
+ }
170
+ }
171
+ if (polished === null) polished = (await autoCleanDictation(cleaned, getVocabulary(), { model, signal: opts.signal })).trim()
138
172
  autoCleanBreaker.recordSuccess()
139
173
  logTokenAudit({
140
174
  source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: polished.length,
141
175
  durationMs: Date.now() - startedAt, caller: `dictation_autoclean_${opts.bucket}`,
142
176
  })
143
- return { text: polished || cleaned }
177
+ return { text: polished || cleaned, ...(prewarm ? { prewarm } : {}) }
144
178
  } catch (err: any) {
145
179
  autoCleanBreaker.recordFailure()
146
180
  console.warn(`[prompt-draft] auto-clean failed (glossary-only): ${err?.message ?? err}`)
147
- return { text: cleaned, polishSkipped: 'failed' }
181
+ return { text: cleaned, polishSkipped: 'failed', ...(prewarm ? { prewarm } : {}) }
148
182
  }
149
183
  }
150
184
 
@@ -165,17 +199,50 @@ promptDraftsRouter.post('/dictation/finalize', async (req, res) => {
165
199
  if (model !== undefined && !isDictationCleanModel(model)) {
166
200
  return res.status(400).json({ error: 'invalid_autoclean_model' })
167
201
  }
202
+ const rawToken = req.body?.prewarmToken
203
+ if (rawToken !== undefined && (typeof rawToken !== 'string' || !PREWARM_TOKEN.test(rawToken))) {
204
+ return res.status(400).json({ error: 'invalid_prewarm_token' })
205
+ }
168
206
 
169
207
  const abort = new AbortController()
170
208
  res.on('close', () => { if (!res.writableEnded) abort.abort() })
171
- const { text: finalText, polishSkipped } = await cleanOutboundDictation(text, {
209
+ const { text: finalText, polishSkipped, prewarm } = await cleanOutboundDictation(text, {
172
210
  enabled: true,
173
211
  model,
174
212
  bucket: surface,
175
213
  signal: abort.signal,
214
+ prewarmToken: rawToken,
176
215
  })
177
216
  // 6.79.1: polishSkipped only when the model did not polish, so a polished answer is unchanged.
178
- res.json({ text: finalText, surface, polished: finalText !== text, ...(polishSkipped ? { polishSkipped } : {}) })
217
+ // 6.79.2: prewarm only when the request carried a prewarmToken.
218
+ res.json({
219
+ text: finalText, surface, polished: finalText !== text,
220
+ ...(polishSkipped ? { polishSkipped } : {}), ...(prewarm ? { prewarm } : {}),
221
+ })
222
+ })
223
+
224
+ const PREWARM_TOKEN = /^[0-9a-f-]{36}$/
225
+
226
+ /** 6.79.2: start the polish process at key press. Fire and forget: every refusal is a normal
227
+ * answer, and finalize without a token (or with a dead one) spawns as before. */
228
+ promptDraftsRouter.post('/dictation/polish/prewarm', (req, res) => {
229
+ const model = req.body?.model ?? 'haiku'
230
+ const surface = req.body?.surface ?? 'message'
231
+ if (!isDictationCleanModel(model)) return res.status(400).json({ error: 'invalid_autoclean_model' })
232
+ if (surface !== 'message' && surface !== 'meeting') return res.status(400).json({ error: 'invalid_surface' })
233
+ if (!prewarmEnabled()) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'disabled' })
234
+ if (!isPrewarmModel(model)) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'model' })
235
+ if (autoCleanBreakers[model].isOpen()) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'breaker_open' })
236
+ if (polishBudgetExhausted(surface)) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'daily_cap' })
237
+ try {
238
+ res.json(startPolishPrewarm(model))
239
+ } catch (err: any) {
240
+ res.status(409).json({ error: 'prewarm_unavailable', reason: 'cli' })
241
+ }
242
+ })
243
+
244
+ promptDraftsRouter.delete('/dictation/polish/prewarm/:token', (req, res) => {
245
+ res.json({ discarded: discardPolishPrewarm(req.params.token) })
179
246
  })
180
247
 
181
248
  async function readRawBody(req: AsyncIterable<Buffer | Uint8Array | string>): Promise<Buffer> {