@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 CHANGED
@@ -82,11 +82,44 @@ BIND_HOST=0.0.0.0
82
82
  # COS_DICTATION_REGISTERS=1 makes it literal). On by default; 0 stops it applying
83
83
  # everywhere (the capability then says enabled false; the rules are kept).
84
84
  # COS_DICTATION_LEARNED_STYLE=0
85
- # The meaning guard runs where v4 runs on Sonnet (never on Haiku, Luna, other models,
85
+ # The meaning guard runs only on Sonnet with v4 (never on Haiku, Luna, other models,
86
86
  # prompt v3 or meeting lines): it refuses a polish that lost a negation, number,
87
87
  # weekday or month, or name you said (or added a negation or number), and pastes the
88
88
  # unpolished text. 0 turns it off.
89
89
  # COS_DICTATION_MEANING_GUARD=0
90
+ # Daily polish budgets, one per caller (local day): Mac dictation and G2 messages
91
+ # (default 300; the older shared variable is its fallback), G2 meeting lines (200),
92
+ # prompt drafts (200).
93
+ # COS_DICTATION_AUTOCLEAN_MAX_PER_DAY=300
94
+ # COS_DICTATION_AUTOCLEAN_MAX_PER_DAY_MESSAGE=300
95
+ # COS_DICTATION_AUTOCLEAN_MAX_PER_DAY_MEETING=200
96
+ # COS_DICTATION_AUTOCLEAN_MAX_PER_DAY_PROMPT_DRAFT=200
97
+ # The polish prompt for every model (v1 to v4); the alias above takes 3 or v3.
98
+ # COS_DICTATION_POLISH_PROMPT=v3
99
+ # The deterministic um/uh pass before the model (English text only since 6.92.0;
100
+ # never meeting lines). On by default; 0 sends the text to the model as heard.
101
+ # COS_DICTATION_FILLER_PASS=0
102
+ # "forward slash pricing" written "/pricing" before the model. On by default.
103
+ # COS_DICTATION_SPOKEN_PATHS=0
104
+ # Start the polish process at key press (Claude models). On by default.
105
+ # COS_DICTATION_PREWARM=0
106
+ # Skip the model when the text has nothing for it to fix. Off by default.
107
+ # COS_DICTATION_POLISH_SKIP_CLEAN=1
108
+ # 6.92.0: the polish breakers are keyed by model and caller group (Mac dictation, G2
109
+ # messages and prompt drafts; meeting lines on their own). A client that stops waiting
110
+ # never counts as a failure; this many such aborts in a row, each after 4 s or more of
111
+ # model time, pause that model's polish for _MS (default 4 and 300000, 5 minutes).
112
+ # COS_DICTATION_SLOW_BREAKER_ABORTS=4
113
+ # COS_DICTATION_SLOW_BREAKER_MS=300000
114
+ # 6.92.0: G2's prompt edit. On by default; 0 answers 503 "Edit is off". At most
115
+ # _DAILY_CAP edits a local day (default 200, its own counter), then 429 "Edit limit
116
+ # reached"; 3 failures in a row pause it 10 minutes (503 "Edit paused"). The model,
117
+ # effort and timeout default to sonnet, low and 45000 ms.
118
+ # COS_PROMPT_EDIT=0
119
+ # COS_PROMPT_EDIT_DAILY_CAP=200
120
+ # COS_PROMPT_EDIT_MODEL=sonnet
121
+ # COS_PROMPT_EDIT_EFFORT=low
122
+ # COS_PROMPT_EDIT_TIMEOUT_MS=45000
90
123
 
91
124
  # Build 204+ uses server-owned durable query jobs by default. The server fsyncs an
92
125
  # accepted prompt before returning 202, then keeps provider work running if the
package/CHANGELOG.md CHANGED
@@ -1,3 +1,100 @@
1
+ ## 6.92.0 - Dictation parity, Wave A (server): breakers that tell an abort from a failure, bounded prompt edit, a filler pass that leaves other languages and paths alone
2
+
3
+ 2026-10-25, plan r3 ("dictation parity", Wave A; pairs with COS Control 0.5.345). Zero `claude -p` calls: every
4
+ change is proven by execution tests, a fake CLI and mutation gates.
5
+
6
+ **What changes by default:**
7
+ - **Polish breakers** (`server/lib/dictation-polish-pipeline.ts`) are keyed by (model, bucket group): `dictation`
8
+ (Mac dictation, G2 messages, prompt drafts) and `meeting`. A G2 meeting that fails no longer turns off message polish.
9
+ - A client abort (Control stopped waiting, the request closed) is **neutral** to the 2-failure breaker: it neither
10
+ counts nor resets. Before, two slow Sonnet polishes past Control's wait could turn Sonnet polish off for 30 minutes.
11
+ - A new **slow breaker** per key: `COS_DICTATION_SLOW_BREAKER_ABORTS` (default 4) aborts in a row, each after at least
12
+ 4 s of model time (stamped when the abort happens, not after the provider process is killed), with no success between, skip polish as `breaker_open` for
13
+ `COS_DICTATION_SLOW_BREAKER_MS` (default 300000, 5 minutes). An abort sooner than 4 s (a disconnect right after a
14
+ sidecar restart) does not count. Any success closes both breakers.
15
+ - Each abort writes a token-audit row with `polish_outcome: "aborted"`, codes only. `aborted` is audit-only: a
16
+ finalize answer's `polishOutcome` keeps its three values.
17
+ - `POST /api/dictation/polish/prewarm` asks the same keyed breaker as the finalize for its surface.
18
+ - **The filler pass** (`server/lib/dictation-fillers.ts`, on since 6.79.1) had removed German and Portuguese words
19
+ ("um drei Uhr" became "drei Uhr", "Er kommt morgen." became "Kommt morgen.") and damaged paths ("src/um.txt" became
20
+ "src/.txt"). Now:
21
+ - **Token exemptions:** a filler is kept when the character right before it is one of `/ . @ _ : # $`, or the
22
+ character right after it is one of `/ @ _ # $`, or a `.` or `:` followed by a letter or digit. A sentence-final
23
+ "um." or "um:" still goes.
24
+ - **Language gate** (`stripFillersIfEnglish`, in the caller; `stripDisfluencies` stays the pure token rules) uses its
25
+ own function, `fillerTextLooksEnglish`. The meaning guard keeps 6.91.1's `looksEnglish` byte for byte, so "merci
26
+ Dana" and "gracias Marco" polished without the name are still refused. The filler gate: not English when
27
+ `looksEnglish` says so; under 12 words, English words (the function words, plus a short-reply list: yes, no, yeah,
28
+ yep, ok, okay, sure, thanks, thank, please, sorry, hi, hey, hello, good, great, fine, cool, done, right, maybe,
29
+ sounds, call, send, tell, ask, am, pm, weekdays, months but "may") must outnumber any foreign function word
30
+ (German, Portuguese, Spanish, French, Dutch; never an all-caps acronym), and an "um" or "er" said inline needs at
31
+ least one English word beside it. So "um yes", "er no", "um thanks Dana", "um Tuesday" and "um 5 pm works" lose
32
+ their filler as in 6.91.1, while "Comprei um carro", "Er kommt morgen." and "Wir treffen uns um 5 Uhr" keep it.
33
+ Known cost: an inline "um" beside only a name ("um Dana") is kept.
34
+ - **Canonical fixture** `server/lib/__fixtures__/dictation-fillers.json`, generated by the server's own functions
35
+ (`server/scripts/generate-dictation-fillers-fixture.ts`) from hand-written expectations: 140 rows, 108 scope `both`
36
+ (the token rules, which COS Control's port must match byte for byte; its 5 short-reply rows hold through both
37
+ repos' language gates too) and 32 scope `server` (the server's language gate). Its sha256,
38
+ `a531d0a99b4661dedcde5d81dbaa304eae73f916eb30e2efd7589e1e0a71de55`, is pinned in
39
+ `dictation-fillers-fixture.test.ts` and in Control's copy.
40
+ - **G2 prompt edit** (`POST /api/prompt-edit`) now answers how it stops. Its limits and settings are unchanged (24,000
41
+ character drafts, 2,000 character instructions, `COS_PROMPT_EDIT_MODEL`, `_EFFORT`, `_TIMEOUT_MS`: sonnet, low,
42
+ 45000), and so is its environment, so a Claude CLI signed in with an API key keeps working.
43
+ - Runs through a new isolated runner, `server/lib/claude-text-runner.ts`: `--tools ''`, no MCP, hooks off in exactly
44
+ one `--settings`, `--permission-mode dontAsk`, no session file, a scratch working directory, a verified absolute
45
+ binary. `spawnClaudeText` stays for meeting summaries (prompt-edit.ts stays TOOL_CAPABLE in the key-lanes census;
46
+ the runner is TOOL_LESS).
47
+ - Switch `COS_PROMPT_EDIT` (read with `readSwitch`, on by default): off answers `503 Edit is off`.
48
+ - Cap `COS_PROMPT_EDIT_DAILY_CAP` (default 200 a local day) in its own counter file (`.prompt_edit_count.json`),
49
+ never the dictation polish counter: `429 Edit limit reached`.
50
+ - Breaker: 3 CLI failures in a row open it for 10 minutes (`503 Edit paused`); a client abort is neutral.
51
+ - A failure answers `500 Edit failed`; the CLI's stdout and stderr tails are gone from the answer and the log.
52
+ - Every refusal is `text/plain` and 24 characters or fewer, so G2 6.10.654 shows `Edit failed.` then, for example,
53
+ `API 429: Edit limit reached` (40 characters at most). A contract test runs 654's own `ApiHTTPError` and
54
+ `applyVoicePromptEdit`, copied verbatim into `server/lib/__fixtures__/g2-6.10.654-prompt-edit.json`, against the
55
+ real refusals: the footer fits and no undo entry is pushed.
56
+ - One audit row per edit that ran (`caller: prompt_edit`, `prompt_edit_outcome` ok, failed or aborted; sizes only).
57
+ - **`GET` and `PUT /api/dictation/polish/options`** add `meaningGuard` (false only with `COS_DICTATION_MEANING_GUARD=0`)
58
+ and `stylePairs` (the model and prompt pairs the guard, Your style and the register run on, as `/rules` computes
59
+ them), so Control 0.5.345's picker line tells the truth. Older servers omit both.
60
+
61
+ **Corrections and housekeeping:**
62
+ - The meaning guard runs **only on Sonnet with v4** (a Haiku forced to v4 gets none). The 6.91.0 wording "where v4
63
+ runs" is corrected in `.env.example` and the README.
64
+ - Eval harness limits: the custom-polish default `--max-calls` is 205 (its default run is 201 calls; it was 200, so a
65
+ default run refused itself) and the format eval's is 50 (it was 40, under the canary's 45). Case counts are pinned
66
+ in `server/scripts/dictation-eval-harness.test.ts`. Nothing was run.
67
+ - Managed runtime: `COS_DICTATION_SLOW_BREAKER_ABORTS`, `_MS`, `COS_PROMPT_EDIT`, `_DAILY_CAP`, `_MODEL`, `_EFFORT` and
68
+ `_TIMEOUT_MS` join `managed-runtime-contract.json` `optionalEnvironment`. `dictation-polish-budget.test.ts` now
69
+ checks all 20 dictation and prompt-edit keys there and in `.env.example`, including the five it missed
70
+ (`SPOKEN_PATHS`, `PROMPT_VERSION`, `LEARNED_STYLE`, `MEANING_GUARD`, `REGISTERS`).
71
+ - Test isolation: the polish goldens (`dictation-polish-prompt-v4.test.ts`), `prompt-drafts`, `dictation-polish-settings`,
72
+ the prompt-edit tests, `dictation-polish-breakers` and `transcript-cleanup` each get a private `COS_PROFILE_PATH` as
73
+ well as a private `COS_DATA_DIR`, restored after the file. In a 4-worker run `transcript-cleanup.test.ts` wrote the
74
+ vocabulary "Jewel360" into the run's shared profile while the goldens read it.
75
+ - The key-lanes off-identity recordings (`server/lib/__fixtures__/key-lanes/off-*.json`, recorded by hand, not run by
76
+ the suite) still show prompt edit's 6.91.1 argv; a re-recording will show the runner's.
77
+
78
+ **Gates:** mutation gates, each from a green baseline:
79
+ - new `server/scripts/dictation-parity-6920-mutation-gate.mjs`: **64 of 64**, each killed by its named test (baseline
80
+ 360 tests in 13 files): abort-neutral, abort then error counts one, the slow threshold, open time and env read, the
81
+ 4 s model-time rule and its boundary, the slow breaker consulted and reset, the aborted audit row, the bucket key
82
+ (meeting apart, prompt drafts with messages), prewarm keyed, prompt edit's switch and default, cap, cap boundary and
83
+ default, call counted, own counter, breaker, threshold, failure and success, abort neutral, text/plain refusal, no
84
+ CLI tails, the 24-character refusal, the runner (no tools, scratch directory, environment kept), the language gate
85
+ (wired in the filler step and the pipeline, its own function with the long-text ratio and the short-reply words,
86
+ foreign words, all-caps, inline um/er, a pause makes a hesitation, and the meaning guard keeping 6.91.1's check), each
87
+ token exemption character before and after, the dot and colon needing a letter or digit, both exemption checks
88
+ wired, the options `meaningGuard` and `stylePairs`, and both harness defaults;
89
+ - dictation polish 92 of 92 (`prewarm-breaker` retargeted to the keyed breaker), format 63 of 63, custom polish 46 of
90
+ 46, voice style 183 of 183 (its anchors unchanged from 6.91.1);
91
+ - dictation STT 21 of 22: its two anchors in `stt-settings.ts` already matched twice at 6.91.1, so the gate stopped
92
+ before its first mutant; a leading newline now pins each. The survivor, `hq-parakeet-warms`
93
+ (`server/lib/stt-runtime.ts`), is in code this release does not touch and was hidden by that stop;
94
+ - full suite, 4 workers, scratch HOME, COS_DATA_DIR and COS_PROFILE_PATH: 9,180 passed, 14 skipped, 0 failed;
95
+ - `check-gate-anchors.mjs --base 96be0b8`: every anchor in a changed file matches once (the one pre-existing
96
+ `manifest-lock-version-drift` miss is unchanged); `tsc --noEmit` clean.
97
+
1
98
  ## 6.91.1 - The meaning guard reads clock times, so a time format you asked for is not refused
2
99
 
3
100
  2026-10-25: canary C5 on the pair that ships (Sonnet, v4, instructions and the learned style) found the 6.91.0 guard
package/README.md CHANGED
@@ -868,7 +868,7 @@ built-in polish rules, which keep precedence.
868
868
  answer says `learnedStyle: true` and `learnedStyleHash` (8 hex), never the
869
869
  rules.
870
870
  - **Meaning guard.** After the model, for messages and prompt drafts, and only
871
- where v4 runs on Sonnet (Haiku, Luna, any other model and prompt v3 get no
871
+ on Sonnet with v4 (Haiku, Luna, any other model and prompt v3 get no
872
872
  guard, so their answers are 6.90.0's): if a negation, number, weekday or
873
873
  month, or name the speaker kept is missing from the answer (or a negation or
874
874
  number appears that was never said), the pre-polish text goes out with
@@ -889,6 +889,37 @@ built-in polish rules, which keep precedence.
889
889
  (`{protocol: 1, maxChars: 1500, maxRules: 20, enabled}`) and
890
890
  `capabilities.dictationCleanup.polishOptions` (`{protocol: 1}`).
891
891
 
892
+ ### Dictation reliability and prompt edit bounds (6.92.0)
893
+
894
+ - **Breakers keyed by model and caller group.** Mac dictation, G2 messages and
895
+ prompt drafts share one group; G2 meeting lines have their own, so a failing
896
+ meeting can no longer turn off message polish. 2 CLI failures in a row open
897
+ a model's breaker for 30 minutes, as before. A client that stops waiting (the
898
+ request closes) is not a failure. `COS_DICTATION_SLOW_BREAKER_ABORTS` (4)
899
+ such aborts in a row, each after 4 s or more of model time, pause that model
900
+ for `COS_DICTATION_SLOW_BREAKER_MS` (300000). Either one open answers
901
+ `polishSkipped: "breaker_open"`, and the prewarm route asks the same keyed
902
+ breaker as its surface. An abort writes an audit row with `polish_outcome`
903
+ `aborted` (codes only); the finalize answer never says `aborted`.
904
+ - **The filler pass** keeps a filler that is part of a path, handle or code
905
+ token (`src/um.txt`, `~/uh`, `@uh`, `um.py`, `um:8080`), and runs only when
906
+ the text is mostly English (`fillerTextLooksEnglish`; short replies such as
907
+ "um yes" count as English), so "um 5 Uhr", "um carro" and "Er kommt" go out
908
+ as said. The meaning guard keeps its 6.91.1 language check. The rules are pinned by `server/lib/__fixtures__/dictation-fillers.json`,
909
+ generated by the server's own functions; COS Control carries a byte-identical
910
+ copy and pins the same sha256.
911
+ - **`GET /api/dictation/polish/options`** adds `meaningGuard` (the
912
+ `COS_DICTATION_MEANING_GUARD` switch) and `stylePairs` (the pairs the guard,
913
+ Your style and the register run on).
914
+ - **G2 prompt edit** (`POST /api/prompt-edit`) runs through an isolated text
915
+ runner (no tools, MCP, hooks or session, a scratch directory) and now stops:
916
+ `COS_PROMPT_EDIT=0` answers `503 Edit is off`; `COS_PROMPT_EDIT_DAILY_CAP`
917
+ (default 200 a local day, its own counter) then `429 Edit limit reached`; 3
918
+ CLI failures in a row pause it 10 minutes (`503 Edit paused`). A failure is
919
+ `500 Edit failed`, never the CLI's output. Every refusal is `text/plain`.
920
+ `COS_PROMPT_EDIT_MODEL`, `_EFFORT` and `_TIMEOUT_MS` are unchanged
921
+ (sonnet, low, 45000).
922
+
892
923
  ### Parakeet (FluidAudio CoreML, offline)
893
924
 
894
925
  `POST /api/stt-settings/parakeet/install` installs FluidAudio's `fluidaudiocli`
@@ -90,7 +90,14 @@
90
90
  "COS_DICTATION_PROMPT_VERSION",
91
91
  "COS_DICTATION_LEARNED_STYLE",
92
92
  "COS_DICTATION_MEANING_GUARD",
93
- "COS_DICTATION_REGISTERS"
93
+ "COS_DICTATION_REGISTERS",
94
+ "COS_DICTATION_SLOW_BREAKER_ABORTS",
95
+ "COS_DICTATION_SLOW_BREAKER_MS",
96
+ "COS_PROMPT_EDIT",
97
+ "COS_PROMPT_EDIT_DAILY_CAP",
98
+ "COS_PROMPT_EDIT_MODEL",
99
+ "COS_PROMPT_EDIT_EFFORT",
100
+ "COS_PROMPT_EDIT_TIMEOUT_MS"
94
101
  ],
95
102
  "maintenance": {
96
103
  "scope": "cross_boot",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.91.1",
3
+ "version": "6.92.0",
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": {
@@ -15,16 +15,18 @@ export interface ClaudeBreaker {
15
15
 
16
16
  export function createBreaker(opts: {
17
17
  label: string
18
- maxFailures?: number
19
- cooldownMs?: number
18
+ /** 6.92.0: a function is read at each check, so an environment value applies after a restart. */
19
+ maxFailures?: number | (() => number)
20
+ cooldownMs?: number | (() => number)
20
21
  }): ClaudeBreaker {
21
- const maxFailures = opts.maxFailures ?? 2
22
- const cooldownMs = opts.cooldownMs ?? 30 * 60 * 1000 // 30 minutes
22
+ const read = (value: number | (() => number) | undefined, fallback: number) => typeof value === 'function' ? value() : value ?? fallback
23
23
  let consecutiveFailures = 0
24
24
  let openedAt = 0
25
25
 
26
26
  return {
27
27
  isOpen(): boolean {
28
+ const maxFailures = read(opts.maxFailures, 2)
29
+ const cooldownMs = read(opts.cooldownMs, 30 * 60 * 1000) // 30 minutes
28
30
  if (consecutiveFailures < maxFailures) return false
29
31
  const elapsed = Date.now() - openedAt
30
32
  if (elapsed >= cooldownMs) {
@@ -35,6 +37,8 @@ export function createBreaker(opts: {
35
37
  return true
36
38
  },
37
39
  recordFailure(): void {
40
+ const maxFailures = read(opts.maxFailures, 2)
41
+ const cooldownMs = read(opts.cooldownMs, 30 * 60 * 1000)
38
42
  consecutiveFailures++
39
43
  if (consecutiveFailures >= maxFailures) {
40
44
  openedAt = Date.now()
@@ -0,0 +1,113 @@
1
+ /**
2
+ * 6.92.0 (plan r3, A-W0b): a small isolated `claude -p` text runner, for callers that only turn text
3
+ * into text (G2's prompt edit today). It replaces spawnClaudeText for those callers; spawnClaudeText
4
+ * stays in prompt-edit.ts for meeting summaries.
5
+ *
6
+ * Isolation, the dictation polish caller's (dictation-clean.ts claudePolishArgs) minus its env strip:
7
+ * - no tools (`--tools ''`), so the key-secrets deny layer has nothing to guard (key-lanes census:
8
+ * TOOL_LESS_CLAUDE); no MCP servers (`--strict-mcp-config` with an empty config); no hooks, in
9
+ * exactly ONE `--settings` layer; `--permission-mode dontAsk`; no session file;
10
+ * - `--model` is always passed, so it can never silently become Opus;
11
+ * - a fresh scratch working directory, never the server's own;
12
+ * - a verified absolute binary (provider-binary.ts), never a bare PATH lookup.
13
+ * The environment is kept as spawnClaudeText kept it (only CLAUDECODE is removed and PATH repaired),
14
+ * so a person who signs the Claude CLI in with an API key keeps working.
15
+ *
16
+ * Errors carry a code and never the CLI's stdout or stderr: those can hold the draft or login data.
17
+ */
18
+ import { spawn } from 'node:child_process'
19
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
20
+ import { tmpdir } from 'node:os'
21
+ import { dirname, join } from 'node:path'
22
+ import { resolveProviderBinary } from './provider-binary.js'
23
+ import { terminateProviderProcess } from './provider-process-lifecycle.js'
24
+
25
+ export type ClaudeTextErrorCode = 'unavailable' | 'aborted' | 'timeout' | 'failed' | 'empty' | 'too_large' | 'input'
26
+
27
+ export class ClaudeTextError extends Error {
28
+ constructor(readonly code: ClaudeTextErrorCode, label: string) {
29
+ super(`${label} ${code}`)
30
+ }
31
+ }
32
+
33
+ export interface ClaudeTextRunOptions {
34
+ model: string
35
+ effort: string
36
+ timeoutMs: number
37
+ systemPrompt: string
38
+ /** Prefix for the error message ("Prompt edit"). */
39
+ label: string
40
+ signal?: AbortSignal
41
+ }
42
+
43
+ /** The argv: one `--settings`, no tools, no MCP, no hooks, no session. */
44
+ export function claudeTextArgs(model: string, effort: string, systemPrompt: string): string[] {
45
+ return [
46
+ '-p', '--model', model, '--effort', effort, '--output-format', 'text',
47
+ '--no-session-persistence', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
48
+ '--tools', '', '--settings', '{"disableAllHooks":true}', '--permission-mode', 'dontAsk',
49
+ '--system-prompt', systemPrompt,
50
+ ]
51
+ }
52
+
53
+ /** spawnClaudeText's environment: everything but CLAUDECODE (anti-recursion), with the binary's dir on PATH. */
54
+ export function claudeTextEnv(binary: string, env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
55
+ const out = { ...env }
56
+ delete out.CLAUDECODE
57
+ out.PATH = [dirname(binary), '/opt/homebrew/bin', out.PATH || ''].join(':')
58
+ out.NO_OPEN_BROWSER = '1'
59
+ return out
60
+ }
61
+
62
+ const MAX_OUTPUT_CHARS = 256_000
63
+
64
+ /** Run the prompt on stdin; resolve the trimmed text answer, or reject with a ClaudeTextError. */
65
+ export async function runClaudeText(prompt: string, opts: ClaudeTextRunOptions): Promise<string> {
66
+ if (opts.signal?.aborted) throw new ClaudeTextError('aborted', opts.label)
67
+ const binary = resolveProviderBinary('claude')
68
+ if (!binary.ok) throw new ClaudeTextError('unavailable', opts.label)
69
+ const timeoutMs = Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 ? opts.timeoutMs : 45_000
70
+ const scratch = mkdtempSync(join(tmpdir(), 'cos-claude-text-'))
71
+ try {
72
+ const workspace = join(scratch, 'workspace')
73
+ mkdirSync(workspace, { mode: 0o700 })
74
+ return await new Promise<string>((resolve, reject) => {
75
+ const proc = spawn(binary.path, claudeTextArgs(opts.model, opts.effort, opts.systemPrompt), {
76
+ stdio: ['pipe', 'pipe', 'pipe'], env: claudeTextEnv(binary.path), cwd: workspace, detached: true,
77
+ })
78
+ let stdout = ''
79
+ let settled = false
80
+ const finish = (error?: ClaudeTextError, output?: string, terminate = false) => {
81
+ if (settled) return
82
+ settled = true
83
+ clearTimeout(timer)
84
+ opts.signal?.removeEventListener('abort', abort)
85
+ const complete = () => error ? reject(error) : resolve(output!)
86
+ if (terminate) void terminateProviderProcess(proc).then(complete, complete)
87
+ else complete()
88
+ }
89
+ const abort = () => finish(new ClaudeTextError('aborted', opts.label), undefined, true)
90
+ const timer = setTimeout(() => finish(new ClaudeTextError('timeout', opts.label), undefined, true), timeoutMs)
91
+ proc.stdout.on('data', (chunk: Buffer) => {
92
+ stdout += chunk.toString()
93
+ if (stdout.length > MAX_OUTPUT_CHARS) finish(new ClaudeTextError('too_large', opts.label), undefined, true)
94
+ })
95
+ // Never read or log stderr: it can carry login or draft data.
96
+ proc.stderr.resume()
97
+ proc.on('error', () => finish(new ClaudeTextError('unavailable', opts.label), undefined, true))
98
+ proc.on('close', (code) => {
99
+ if (settled) return
100
+ if (code !== 0) return finish(new ClaudeTextError('failed', opts.label))
101
+ const output = stdout.trim()
102
+ if (!output) return finish(new ClaudeTextError('empty', opts.label))
103
+ finish(undefined, output)
104
+ })
105
+ proc.stdin.on('error', () => finish(new ClaudeTextError('input', opts.label), undefined, true))
106
+ opts.signal?.addEventListener('abort', abort, { once: true })
107
+ if (opts.signal?.aborted) return abort()
108
+ try { proc.stdin.end(prompt) } catch { finish(new ClaudeTextError('input', opts.label), undefined, true) }
109
+ })
110
+ } finally {
111
+ rmSync(scratch, { recursive: true, force: true })
112
+ }
113
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * 6.92.0 (plan r3, A-W10): the canonical filler fixture's cases, one source of truth for the server's
3
+ * filler pass and COS Control's port. `want` is written by hand, so the generated fixture is checked
4
+ * against an expectation, never only against the function that produced it.
5
+ *
6
+ * scope "both" the token rules alone (stripDisfluencies). Control's port must match 100%. The
7
+ * "language" rows among them (short English replies) also hold through the
8
+ * server's language gate, and must through Control's.
9
+ * scope "server" the server's outbound filler step (stripFillersIfEnglish: the language gate,
10
+ * then the token rules). The gate is the server's own detector, so these rows are
11
+ * not a parity target; Control gates with its own detector.
12
+ *
13
+ * Synthetic, fictional text only. Regenerate the fixture with
14
+ * npx tsx server/scripts/generate-dictation-fillers-fixture.ts
15
+ * and update the sha256 pinned in dictation-fillers-fixture.test.ts (and in COS Control's copy).
16
+ */
17
+ import { stripDisfluencies, stripFillersIfEnglish } from './dictation-fillers.js'
18
+
19
+ export type FillerFixtureScope = 'both' | 'server' | 'client'
20
+ export interface FillerFixtureCase { id: string; scope: FillerFixtureScope; group: 'english' | 'exemption' | 'language'; input: string; want: string }
21
+ export interface FillerFixtureRow { id: string; scope: FillerFixtureScope; group: FillerFixtureCase['group']; input: string; expected: string }
22
+
23
+ /** The 6.79.1 filler-pass tables (dictation-fillers.test.ts), as [input, want]. */
24
+ const ENGLISH: Array<[string, string]> = [
25
+ ['We need uh the deck by Friday', 'We need the deck by Friday'],
26
+ ['Send the um the update', 'Send the the update'],
27
+ ['ship it er tomorrow', 'ship it tomorrow'],
28
+ ['ship it erm tomorrow', 'ship it tomorrow'],
29
+ ['Book the room uhm at noon', 'Book the room at noon'],
30
+ ['So, um, we should ship', 'So, we should ship'],
31
+ ['The plan, uh, is solid', 'The plan, is solid'],
32
+ ['Um, I think we are ready', 'I think we are ready'],
33
+ ['Uh, can you check the build', 'Can you check the build'],
34
+ ['um, so we go', 'so we go'],
35
+ ['Okay. Um, let us start', 'Okay. Let us start'],
36
+ ['Done? Uh, maybe not', 'Done? Maybe not'],
37
+ ['Hmm. Let me think about it.', 'Let me think about it.'],
38
+ ['Okay. Um. Let us go', 'Okay. Let us go'],
39
+ ['Mhm, sure thing', 'Sure thing'],
40
+ ['I think, uh.', 'I think.'],
41
+ ['Is that right, hmm?', 'Is that right?'],
42
+ ['Let us ship it, um', 'Let us ship it'],
43
+ ['Let us ship it um.', 'Let us ship it.'],
44
+ ['Call (uh) Dana', 'Call () Dana'],
45
+ ['It was um... great', 'It was great'],
46
+ ['It was um… great', 'It was great'],
47
+ ['well — um — we could', 'well — we could'],
48
+ ['Um... I forgot', 'I forgot'],
49
+ ['um um the deck', 'the deck'],
50
+ ['Uh, um, so the plan', 'So the plan'],
51
+ ['Ummm, yes', 'Yes'],
52
+ ['the hmmm thing', 'the thing'],
53
+ ['it is ahh fine', 'it is fine'],
54
+ ['Ahh, I see', 'I see'],
55
+ ['Mm, that works', 'That works'],
56
+ ['that uhh works', 'that works'],
57
+ ['He said "um, no"', 'He said "no"'],
58
+ ['First line\num, second line', 'First line\nsecond line'],
59
+ ['First line\nUm, second line', 'First line\nSecond line'],
60
+ ['Ask, um, Ah Kow to join', 'Ask, Ah Kow to join'],
61
+ ['uh uh the deck', 'the deck'],
62
+ ['uh hmm let me think', 'let me think'],
63
+ ['Uh hmm, the plan', 'The plan'],
64
+ ['Hmm. Let me check ... and Ah Kim\'s notes', 'Let me check ... and Ah Kim\'s notes'],
65
+ ['at 5 p.m. uh we meet', 'at 5 p.m. we meet'],
66
+ ['e.g. um the deck', 'e.g. the deck'],
67
+ ['We met Mr. uh Jones', 'We met Mr. Jones'],
68
+ ['I spoke to the U.S. uh team', 'I spoke to the U.S. team'],
69
+ ['Smith Jr. um signed', 'Smith Jr. signed'],
70
+ ['I think, Um, we should', 'I think, we should'],
71
+ ['Wait... Uh, maybe', 'Wait... maybe'],
72
+ ['Wait. Uh, maybe', 'Wait. Maybe'],
73
+ ['um,, so we go', 'so we go'],
74
+ ['Bring an umbrella and keep going ahead', 'Bring an umbrella and keep going ahead'],
75
+ ['To err is human', 'To err is human'],
76
+ ['The hummingbird hummed', 'The hummingbird hummed'],
77
+ ['Erma and Ahmed will join', 'Erma and Ahmed will join'],
78
+ ['Check the umami and the uhaul', 'Check the umami and the uhaul'],
79
+ ['Uh-oh, the build broke', 'Uh-oh, the build broke'],
80
+ ['She said uh-huh and mm-hmm', 'She said uh-huh and mm-hmm'],
81
+ ['UM beat ER in the final, AH was close', 'UM beat ER in the final, AH was close'],
82
+ ['HMM is the model name', 'HMM is the model name'],
83
+ ['Use a 5 mm bolt and a 10mm spacer', 'Use a 5 mm bolt and a 10mm spacer'],
84
+ ['Ask Ah Kow to join the call', 'Ask Ah Kow to join the call'],
85
+ ['Loop in Er Lin and Hm Tran', 'Loop in Er Lin and Hm Tran'],
86
+ ['The note from Mm Patel is attached', 'The note from Mm Patel is attached'],
87
+ ['We met Mr. Ah and Ms. Er yesterday', 'We met Mr. Ah and Ms. Er yesterday'],
88
+ ['Dr. Er said the scan is clear', 'Dr. Er said the scan is clear'],
89
+ ['Dr. Er said', 'Dr. Er said'],
90
+ ['Dr. Er said yes', 'Dr. Er said yes'],
91
+ ['Ask Jr. Ah and Sr. Um', 'Ask Jr. Ah and Sr. Um'],
92
+ ['Ship it to the U.S. Um, team first', 'Ship it to the U.S. Um, team first'],
93
+ ['Dr. Er, the surgeon, called', 'Dr. Er, the surgeon, called'],
94
+ ['Meet with J. Ah tomorrow', 'Meet with J. Ah tomorrow'],
95
+ ['Ask Mrs. Um and Prof. Hm about it', 'Ask Mrs. Um and Prof. Hm about it'],
96
+ ['"um" is a filler word', '"um" is a filler word'],
97
+ ['Say “uh” less often', 'Say “uh” less often'],
98
+ ['He said uh huh and left', 'He said uh huh and left'],
99
+ ['uh oh, the build broke', 'uh oh, the build broke'],
100
+ ['She went mm hmm and nodded', 'She went mm hmm and nodded'],
101
+ ['Plain text with no hesitation.', 'Plain text with no hesitation.'],
102
+ [', and a leading comma stays when nothing was removed', ', and a leading comma stays when nothing was removed'],
103
+ ]
104
+
105
+ /** 6.92.0 token exemptions and quote rules: scope "both". A filler that is part of a path, handle or code token is kept. */
106
+ const EXEMPTION: Array<[string, string]> = [
107
+ ['open src/um.txt now', 'open src/um.txt now'],
108
+ ['cd ~/uh and list it', 'cd ~/uh and list it'],
109
+ ['ping @uh about the deck', 'ping @uh about the deck'],
110
+ ['the tag #um is trending', 'the tag #um is trending'],
111
+ ['echo $um in the shell', 'echo $um in the shell'],
112
+ ['set the my_um field', 'set the my_um field'],
113
+ ['rename it to .um before lunch', 'rename it to .um before lunch'],
114
+ ['connect to host:um today', 'connect to host:um today'],
115
+ ['run um.py again', 'run um.py again'],
116
+ ['open port um:8080 first', 'open port um:8080 first'],
117
+ ['the um/bin folder', 'the um/bin folder'],
118
+ ['mail um@example.com later', 'mail um@example.com later'],
119
+ ['the um_id column', 'the um_id column'],
120
+ ['issue um#12 is open', 'issue um#12 is open'],
121
+ ['the um$ variable', 'the um$ variable'],
122
+ ['pass the -um flag', 'pass the -um flag'],
123
+ // A sentence-final period or colon is not part of a token.
124
+ ['Let us ship it um.', 'Let us ship it.'],
125
+ ['The reason is um: we ran out', 'The reason is: we ran out'],
126
+ ['src/um.txt and um, the notes', 'src/um.txt and the notes'],
127
+ // Meaning, places and quotes.
128
+ ['Uh-oh, the build broke', 'Uh-oh, the build broke'],
129
+ ['We flew to Umbria in May', 'We flew to Umbria in May'],
130
+ ['uh huh, sounds good', 'uh huh, sounds good'],
131
+ ['she said "um" twice', 'she said "um" twice'],
132
+ ['He said "um, no"', 'He said "no"'],
133
+ ['Erm, the uhaul is here', 'The uhaul is here'],
134
+ ]
135
+
136
+ /** 6.92.0 language gate: scope "server". Not English goes out as said; English still loses its fillers. */
137
+ const LANGUAGE: Array<[string, string]> = [
138
+ ['Wir treffen uns um 5 Uhr', 'Wir treffen uns um 5 Uhr'],
139
+ ['um drei Uhr', 'um drei Uhr'],
140
+ ['Er kommt morgen.', 'Er kommt morgen.'],
141
+ ['Er ist heute nicht da', 'Er ist heute nicht da'],
142
+ ['Ich komme um 9 Uhr', 'Ich komme um 9 Uhr'],
143
+ ['Er hat um 8 Uhr angerufen', 'Er hat um 8 Uhr angerufen'],
144
+ ['Comprei um carro novo', 'Comprei um carro novo'],
145
+ ['um carro', 'um carro'],
146
+ ['Ela tem um gato', 'Ela tem um gato'],
147
+ ['Vamos sair um pouco', 'Vamos sair um pouco'],
148
+ ['Isso custa um real', 'Isso custa um real'],
149
+ ['Han er hjemme nu', 'Han er hjemme nu'],
150
+ ['Det er fint', 'Det er fint'],
151
+ ['Er is een probleem', 'Er is een probleem'],
152
+ ['Er komt morgen', 'Er komt morgen'],
153
+ ['ship it er tomorrow', 'ship it tomorrow'],
154
+ ['We need um the deck by Friday', 'We need the deck by Friday'],
155
+ ['Um, sure thing', 'Sure thing'],
156
+ ['er, the plan works', 'the plan works'],
157
+ ['Ask Marco um about the pricing', 'Ask Marco about the pricing'],
158
+ ['um the MIT report', 'the MIT report'],
159
+ ['Send the deck to Dana, um, por favor', 'Send the deck to Dana, por favor'],
160
+ // A time said in English ("pm") is English evidence.
161
+ ['um 5 pm works', '5 pm works'],
162
+ // 6.92.0 QA: short English replies and commands lose their filler, as in 6.91.1.
163
+ ['um okay', 'okay'],
164
+ ['um sure', 'sure'],
165
+ ['um yeah', 'yeah'],
166
+ ['um no', 'no'],
167
+ ['um call Dana', 'call Dana'],
168
+ ['um Tuesday', 'Tuesday'],
169
+ ['ok um send', 'ok send'],
170
+ ['Um, thanks', 'Thanks'],
171
+ // The known cost: an inline um with no English word beside it is kept.
172
+ ['um Dana', 'um Dana'],
173
+ ]
174
+
175
+ /**
176
+ * 6.92.0 QA: short English replies, scope "both". COS Control's port must give the same answer
177
+ * through its own gate and token rules; the server's gate and token rules both give it (pinned).
178
+ */
179
+ const SHORT_REPLIES: Array<[string, string]> = [
180
+ ['um yes', 'yes'],
181
+ ['Um yes.', 'Yes.'],
182
+ ['er no', 'no'],
183
+ ['um thanks Dana', 'thanks Dana'],
184
+ ['um sounds good', 'sounds good'],
185
+ ]
186
+
187
+ const pad = (n: number) => String(n).padStart(3, '0')
188
+ export const FILLER_FIXTURE_CASES: FillerFixtureCase[] = [
189
+ ...ENGLISH.map(([input, want], i) => ({ id: `en${pad(i + 1)}`, scope: 'both' as const, group: 'english' as const, input, want })),
190
+ ...EXEMPTION.map(([input, want], i) => ({ id: `ex${pad(i + 1)}`, scope: 'both' as const, group: 'exemption' as const, input, want })),
191
+ ...LANGUAGE.map(([input, want], i) => ({ id: `la${pad(i + 1)}`, scope: 'server' as const, group: 'language' as const, input, want })),
192
+ ...SHORT_REPLIES.map(([input, want], i) => ({ id: `sr${pad(i + 1)}`, scope: 'both' as const, group: 'language' as const, input, want })),
193
+ ]
194
+
195
+ /** The server function a row's scope names. */
196
+ export function fillerFixtureOutput(scope: FillerFixtureScope, input: string): string {
197
+ return scope === 'server' ? stripFillersIfEnglish(input) : stripDisfluencies(input)
198
+ }
199
+
200
+ /** The fixture file's exact text: generated by the server functions, never by hand. */
201
+ export function renderFillerFixture(): string {
202
+ const rows: FillerFixtureRow[] = FILLER_FIXTURE_CASES.map(c => ({ id: c.id, scope: c.scope, group: c.group, input: c.input, expected: fillerFixtureOutput(c.scope, c.input) }))
203
+ return JSON.stringify({
204
+ schema: 1,
205
+ generator: 'server/scripts/generate-dictation-fillers-fixture.ts',
206
+ tokens: ['um+', 'uhm', 'uh+', 'er', 'erm', 'ah+', 'hm+', 'mm+', 'mhm'],
207
+ scopes: {
208
+ both: 'stripDisfluencies: the token rules alone; a port must match every row',
209
+ server: 'stripFillersIfEnglish: the server language gate, then the token rules',
210
+ client: 'reserved for client-only rows; the server generates none',
211
+ },
212
+ rows,
213
+ }, null, 2) + '\n'
214
+ }
@@ -20,9 +20,22 @@
20
20
  * still loses "uh"), and any quoted mention ('"um"').
21
21
  * - "uh huh", "uh oh" and "mm hmm" written without a hyphen: answers, not hesitations.
22
22
  * - "like", "you know", "I mean": filler only sometimes, so the model decides, not a regex.
23
+ * - 6.92.0: a token that touches a path, handle or code character is part of that token, not a
24
+ * hesitation: "src/um.txt", "~/uh", "@uh", "#um", "$um", "my_um", "um.py", "um:8080", "um/bin".
25
+ * Kept when the character right before it is one of / . @ _ : # $ (a leading - is already
26
+ * outside the token pattern), or the character right after it is one of / @ _ # $, or a . or :
27
+ * followed by a letter or digit. A sentence-final "um." or "um:" (a space or the end after it)
28
+ * still goes.
23
29
  * A filler inside a longer quote ("um, no") is removed like any other.
24
30
  * If nothing but fillers was said, the original text is returned unchanged.
31
+ *
32
+ * 6.92.0: the language gate lives in the caller, not here (`stripFillersIfEnglish`): "um" and "er"
33
+ * are words in German, Portuguese, Danish, Norwegian and Dutch ("um 5 Uhr", "um carro", "Er kommt
34
+ * morgen"). This function stays the pure token rules that the canonical fixture
35
+ * (__fixtures__/dictation-fillers.json, scope "both") pins for COS Control's port.
25
36
  */
37
+ import { fillerTextLooksEnglish } from './dictation-meaning-guard.js'
38
+
26
39
 
27
40
  const FILLER = /(?<![\p{L}\p{N}'’-])(um+|uhm|uh+|erm?|ah+|hm+|mm+|mhm)(?![\p{L}\p{N}'’-])/giu
28
41
  /** After a filler: a pause mark (comma, ellipsis, dash) with its spacing. */
@@ -38,6 +51,10 @@ const QUOTE_OPEN = /["“”'‘’]$/
38
51
  const QUOTE_CLOSE = /^["“”'‘’]/
39
52
  /** A pause mark right after the token: a Titlecase filler set off this way is a hesitation. */
40
53
  const PAUSE_AFTER = /^[ \t]*(?:,|\.{2,}|…)/
54
+ /** 6.92.0: a path, handle or code character right before the token ("~/uh", "@uh", "#um", ".um"). */
55
+ const TOUCH_BEFORE = /[/.@_:#$]$/
56
+ /** 6.92.0: right after it: / @ _ # $ ("um/bin", "um_id"), or . or : with a letter or digit next ("um.py", "um:8080"). */
57
+ const TOUCH_AFTER = /^(?:[/@_#$]|[.:][\p{L}\p{N}])/u
41
58
 
42
59
  function afterAbbreviation(before: string): boolean {
43
60
  const trimmed = before.replace(/[ \t]+$/, '')
@@ -73,6 +90,9 @@ export function stripDisfluencies(text: string): string {
73
90
  const atStart = sentenceStart(out + before)
74
91
  const keep = (token.length > 1 && token === token.toUpperCase())
75
92
  || (/^mm+$/i.test(token) && /\d[ \t]*$/.test(out + before))
93
+ // 6.92.0: part of a path, handle or code token, never a hesitation.
94
+ || TOUCH_BEFORE.test(out + before)
95
+ || TOUCH_AFTER.test(after)
76
96
  // A capital away from a sentence start is a name ("Ask Ah Kow"), unless a pause mark
77
97
  // sets it off as a hesitation ("I think, Um, we should").
78
98
  || (!atStart && /^[A-Z]/.test(token) && !PAUSE_AFTER.test(after))
@@ -114,3 +134,13 @@ export function stripDisfluencies(text: string): string {
114
134
  const result = (out + rest).replace(/^[ \t]*[,;][ \t]*/, '')
115
135
  return result.trim() === '' ? text : result
116
136
  }
137
+
138
+ /**
139
+ * 6.92.0: the server's outbound filler step. The token rules (stripDisfluencies) run only when the
140
+ * text is mostly English (dictation-meaning-guard.ts fillerTextLooksEnglish), so "Wir treffen uns um 5 Uhr",
141
+ * "Comprei um carro" and "Er kommt morgen." go out as said. COS Control gates its own port with
142
+ * its own language detector; that is why the fixture tags these rows scope "server".
143
+ */
144
+ export function stripFillersIfEnglish(text: string): string {
145
+ return fillerTextLooksEnglish(text) ? stripDisfluencies(text) : text
146
+ }