@gotcos/glasses-server 6.91.0 → 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,126 @@
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
+
98
+ ## 6.91.1 - The meaning guard reads clock times, so a time format you asked for is not refused
99
+
100
+ 2026-10-25: canary C5 on the pair that ships (Sonnet, v4, instructions and the learned style) found the 6.91.0 guard
101
+ refusing answers that followed the person's own instruction "write times in the form 10:30 am": "at ten" written
102
+ "10:00 am" counted "am" and "00" as added numbers, and the unpolished text went out. 6.90.0 pasted that formatting, so
103
+ this was a step backward for any Sonnet user with a time-format instruction. Fixed before npm latest moves. Zero
104
+ `claude -p` calls.
105
+
106
+ - **Clock times** (`server/lib/dictation-meaning-guard.ts`): every time, spoken or written, in the dictation and in the
107
+ answer, is read as minutes since midnight: "ten", "ten thirty", "half past ten", "quarter to five", "ten o'clock",
108
+ "ten oh five", 10, 10:00, 10:30, am, pm, a.m., p.m., "22:30" and "twenty two thirty". Without am or pm an hour from
109
+ 1 to 12 reads both ways, so "ten thirty pm" written "22:30" and "ten thirty" written "22:30" are kept. am, pm and a
110
+ trailing ":00" are format only and never count as added numbers: "at ten" written "at 10:00 am", "ten thirty
111
+ Central" written "10:30 am Central" and "meet at 9" written "meet at 9:00" are kept. A real change still trips:
112
+ "ten thirty" written "11:30", "7 am" written "7 pm", "3:00 pm" written "3", and a time the speaker never said. A year
113
+ that reads like a time ("nineteen fifty" as "1950") still matches its plain form.
114
+ - **C5 re-checked offline:** the 30 stored C5 answers (synthetic) run through the 6.91.1 guard: 0 of 15 stacked and 0
115
+ of 14 plain refused (6.91.0 refused b02 and b09), so what would go out is 11/11 backtracks on both arms. Every other
116
+ stored synthetic output: still 0 false trips in 356. Route test: on Sonnet v4 with the time-format instruction saved,
117
+ b02 and b09 go out polished, and a changed time is still refused.
118
+ - **Eval:** `STACKED_INSTRUCTIONS` is 985 characters (it was 1,033, over the 1,000 cap, so the reader refused it and
119
+ the 6.91.0 Haiku stacked arm ran without instructions), and the eval now stops if the reader would refuse the set.
120
+ - **Known limit, kept:** the largest possible stack (985-character instructions and all 13 learned-style rules) costs
121
+ Sonnet v4 +4.3% at p50 and **+46.7% at p90** (9,260 vs 6,311 ms, C5 2026-10-25, n=15). No user has that today: the
122
+ learned style starts empty and grows a rule at a time. Prompts are unchanged for it.
123
+
1
124
  ## 6.91.0 - Dictation that learns your voice: prompt v4 on Sonnet, Your style, the meaning guard, an opt-in literal register
2
125
 
3
126
  2026-10-24 22:45: extend AI polish into "a Grammarly but further for voice dictation", remove ungrammatical spoken
@@ -133,8 +256,14 @@ builds the capture, the classifier and Your style.
133
256
  (+3.2%); Sonnet p50 3,024 vs 2,949 (+2.5%), p90 4,460 vs 3,997 (+11.6%). Stacked arm (Haiku, v4 + 1,033-character
134
257
  instructions + the largest learned style, 767 characters): p50 3,147 vs 2,794 (+12.6%), p90 3,646 vs 3,520 (+3.6%),
135
258
  backtracks 11/11. C4 cold, Sonnet: v4 p50 4,346 vs v3 4,713. The absolute Haiku bar is measured after install.
136
- **Pending before npm latest:** the stacked arm on the pair that ships, Sonnet with v4, instructions and the learned
137
- style (about 15 calls). The stacked number above is Haiku, which does not get v4 or the style.
259
+ **Pending before npm latest, run 2026-10-25: DISPROVEN (p90).** The stacked arm on the pair that ships, Sonnet with
260
+ v4, 985-character instructions and the largest learned style (13 rules, 767 characters), against plain Sonnet v4, on
261
+ the 15-case backtrack and new-preserve slice, interleaved, warm (29 of 30 warm; 30 calls, 1 plain call timed out at
262
+ 20 s): p50 4,968 vs 4,762 ms (+4.3%), p90 9,260 vs 6,311 ms (+46.7%, over the +25% bar; 4 of 15 stacked calls took
263
+ 7.9 to 18.0 s, 1 of 14 plain). Backtracks 11/11 in the model's answers on both arms; new preserve 4/4 stacked, 3/3
264
+ plain. What would go out: 9/11 stacked, because the meaning guard refused two answers that followed the instruction
265
+ "write times in the form 10:30 am" ("at ten" as "10:00 am" adds "am" and "00"). The Haiku stacked number above ran
266
+ without its instructions: that set is 1,033 characters, over the 1,000 cap, so the reader refused it.
138
267
  - **C6: PROVEN** (route and golden tests). Every new route is 401 without the token; an old client body works; a skip
139
268
  path sends no `polishOutcome`; a port of the 0.5.343 helper and `choose` logs a guarded answer as
140
269
  `fillers_only:failed` or `skipped:failed`, never applied; all four switches off, and Haiku outside a terminal, give the
package/README.md CHANGED
@@ -868,14 +868,17 @@ 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
875
875
  `polishOutcome: "guarded"`, `guard: "<kind>"` and `polishSkipped: "failed"`.
876
876
  A self-correction may drop what it replaced. "2 million", "25th",
877
877
  "dont", "half past ten" and a numbered list you asked for read as what you
878
- said. The log line carries counts only. `COS_DICTATION_MEANING_GUARD=0` turns
878
+ said. 6.91.1: clock times compare as minutes since midnight, so "at ten"
879
+ written "10:00 am" and "ten thirty pm" written "22:30" are kept (am, pm and
880
+ ":00" are format only), while "ten thirty" written "11:30" trips. The log
881
+ line carries counts only. `COS_DICTATION_MEANING_GUARD=0` turns
879
882
  it off.
880
883
  - **Finalize answers** add, all optional: `polishOutcome` (`polished`,
881
884
  `unchanged` or `guarded`, about the model step; an empty model answer is a
@@ -886,6 +889,37 @@ built-in polish rules, which keep precedence.
886
889
  (`{protocol: 1, maxChars: 1500, maxRules: 20, enabled}`) and
887
890
  `capabilities.dictationCleanup.polishOptions` (`{protocol: 1}`).
888
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
+
889
923
  ### Parakeet (FluidAudio CoreML, offline)
890
924
 
891
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.0",
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
+ }