@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 +79 -0
- package/README.md +10 -1
- package/managed-runtime-contract.json +5 -1
- package/package.json +1 -1
- package/server/lib/dictation-clean.ts +13 -6
- package/server/lib/dictation-fillers.ts +17 -10
- package/server/lib/dictation-polish-need.ts +48 -0
- package/server/lib/dictation-prewarm.ts +209 -0
- package/server/lib/live-cues-v2-settings.ts +51 -13
- package/server/lib/live-cues-v2.ts +147 -19
- package/server/routes/live-cues.ts +39 -3
- package/server/routes/prompt-drafts.ts +75 -8
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,
|
|
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.
|
|
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
|
-
* -
|
|
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,
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
//
|
|
74
|
-
|
|
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
|
-
/**
|
|
17
|
-
export
|
|
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
|
|
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
|
-
|
|
25
|
-
if (this.cache) return this.cache
|
|
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)
|
|
31
|
-
|
|
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
|
|
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(
|
|
43
|
-
this.cache = { value
|
|
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 {
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
|
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
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
710
|
+
const lim = this.limits()
|
|
624
711
|
if (s.breaker) return 'breaker_open'
|
|
625
|
-
if (counters.cues >=
|
|
626
|
-
|
|
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
|
-
|
|
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 >=
|
|
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 <
|
|
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
|
-
|
|
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().
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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> {
|