@gotcos/glasses-server 6.73.3 → 6.73.14

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +7 -0
  4. package/server/lib/ask-session-id.ts +5 -0
  5. package/server/lib/decision-provider.ts +163 -0
  6. package/server/lib/decision-settings.ts +72 -0
  7. package/server/lib/glossary-learn.ts +221 -0
  8. package/server/lib/imported-library-rows.ts +62 -4
  9. package/server/lib/jev.ts +3 -3
  10. package/server/lib/lens-gist-engines.ts +7 -2
  11. package/server/lib/live-cos-ask.ts +2 -1
  12. package/server/lib/mac-recording-asr.ts +59 -0
  13. package/server/lib/mac-recording.ts +111 -0
  14. package/server/lib/maintenance-lifecycle.ts +1 -0
  15. package/server/lib/meeting-actions.ts +1 -0
  16. package/server/lib/meeting-ask-follow-up.ts +2 -2
  17. package/server/lib/meeting-ask-runner.ts +26 -5
  18. package/server/lib/meeting-ask-store.ts +2 -1
  19. package/server/lib/meeting-batch-transcribe.ts +19 -4
  20. package/server/lib/meeting-context-keys.ts +7 -4
  21. package/server/lib/meeting-parse.ts +2 -0
  22. package/server/lib/meeting-store.ts +62 -9
  23. package/server/lib/session-recommendation.ts +12 -9
  24. package/server/lib/speaker-embeddings.ts +2 -2
  25. package/server/lib/stranded-session-actions.ts +15 -0
  26. package/server/lib/subq-key.ts +38 -0
  27. package/server/lib/transcribe-audio.ts +9 -1
  28. package/server/lib/work-activity.ts +2 -2
  29. package/server/lib/work-chat.ts +122 -0
  30. package/server/lib/work-completion.ts +11 -8
  31. package/server/lib/work-intake-store.ts +2 -1
  32. package/server/lib/work-search.ts +32 -12
  33. package/server/routes/client-instance.ts +1 -1
  34. package/server/routes/decision-provider.ts +36 -0
  35. package/server/routes/glossary.ts +143 -11
  36. package/server/routes/jev.ts +3 -2
  37. package/server/routes/meeting.ts +44 -12
  38. package/server/routes/meetings.ts +42 -4
  39. package/server/routes/subq-key.ts +27 -0
  40. package/server/routes/transcribe-stream.ts +115 -9
  41. package/server/routes/transcribe.ts +20 -2
  42. package/server/routes/work-board.ts +42 -3
  43. package/server/routes/work-chat.ts +31 -0
  44. package/server/routes/work-search.ts +2 -2
package/CHANGELOG.md CHANGED
@@ -1,3 +1,77 @@
1
+ ## 6.73.14 — Saved Mac recordings appear in Meetings; Work chat keeps a plan with a stray item (local candidate)
2
+
3
+ - A saved Mac recording now lists in Meetings on a Mac whose COS pipeline files G2 recordings (Miles 2026-10-15: the 10:39 PM recording saved but Control said "Saved meeting not found yet" forever). Mac captures never reach the pipeline (routes/meeting.ts, MAC_CAPTURE_ID), and that layout listed only the operations tree. The store's Mac rows now join it (`withMacStoreRows`, standalone identity, an operations copy wins by session), are filtered before the store's 200-row cap, count in the calendar's day dots (`macStoreDayCounts`), and a month holding only Mac recordings reaches the pager. G2 store rows stay the pipeline's.
4
+ - QA before release: the store matches a Mac session from the sidecar's 4 KB head before it opens any markdown, so a list of Mac rows no longer reads the store's G2 recordings (617 files, about 16 MB, on every Control poll, including `?day=` every 3 s during a hand-off); a `day` with no `month` reads only that month's folder. Store rows carry their start `time` ("HH:MM", from the Time line, else the Date line, else the filename), so a Mac recording sorts by its time among the same day's operations rows instead of at 00:00, where the 20-row list could cut it. A Mac recording whose session an operations row or a merged scribe already holds is left out of the day dots as it is of the rows, so an operations copy counts once.
5
+ - Not changed: Mac recordings still skip the operations pipeline (search, vector and graph index, Work intake). That is a product decision, not this fix.
6
+ - Work chat cleans the planner's `contextItemIDs` instead of refusing the plan: unknown ids drop, repeats keep once, anything that is not a list of strings sends every item, and so does a list none of whose ids Control supplied; an empty list sends the fixed three. The count of unknown ids is logged (`[work-chat]`), never the ids. One stray id used to discard a whole high-effort plan (QA 2026-10-15).
7
+ - Pair with Control 0.5.313. Transcription-candidate and cue flags stay disabled. No public publication is implied.
8
+
9
+ ## 6.73.13 — Work chat picks each assignment's context (local candidate)
10
+
11
+ - Work chat input accepts optional `items` ({id, kind, label, estTokens}; ids and estimates only, never contents; at most 40; your request, the task and COS instructions required). The planner returns `contextItemIDs` per assignment, a subset of the supplied ids; the fixed three are always added and Control's order is kept; omitted means every item. GET `/api/work/chat` advertises `contextItems: true` and `maxContextItems: 40`. The planner is asked for the smallest set each assignment needs.
12
+ - Work chat's output budget rises from 4,086 to 4,946 tokens so three full selections fit (derived in lib/work-chat.ts).
13
+ - Pair with Control 0.5.312 (Work Context Meter, Plan and send). Transcription-candidate and cue flags stay disabled. No public publication is implied.
14
+
15
+ ## 6.73.12 — Atomic dictionary edits and Work chat planner effort (local candidate)
16
+
17
+ - Work chat plans at a chosen effort: `low`, `medium` or `high` (default `high`, Miles 2026-10-15). GET `/api/work/chat` advertises `efforts`; the answer reports the effort used. Anything else is a 400 before any model call. Higher effort raises cost per plan; still one call per Plan click.
18
+ - Work chat now thinks at medium and high (6,000 and 16,000 thinking tokens) with an output budget derived from its largest valid plan (4,086 tokens) and a 180-second deadline. Before, every plan ran with thinking off and a 1,400-token cap that could cut a full plan into a 503. Lens gist and Meeting Ask keep exactly their old settings (1,400 / 0 / 45 s, low).
19
+
20
+ - Add `POST /api/transcription-glossary/vocabulary/add` `{term}`, `/vocabulary/remove` `{term}` and `/correction/remove` `{heard}`. Each changes one item inside the same write chain as PUT and learn, so COS Control's Dictionary pane no longer reads the whole dictionary and writes it back (a Fix a word landing in between could be lost). Same limits as learn: 500 terms, 100 UTF-16 units, no URL, email or path in the vocabulary. Hand edits are not logged for undo.
21
+ - Pair with Control 0.5.310. Older servers keep reading and adding corrections from Control; single-word edits, Fix a word and Undo need 6.73.12. Transcription-candidate and cue flags stay disabled. No public publication is implied.
22
+
23
+ ## 6.73.11 — Fix a word: the dictionary learns (local candidate)
24
+
25
+ - Add `POST /api/transcription-glossary/correction` `{heard, written, source}`: learns ONE correction and adds the spelling to the vocabulary in one write, serialized with PUT. A case variant of an existing key is replaced, not duplicated. Same limits as PUT: 500 terms, 100 characters, no URL, email or path spellings (they would join the decoder prompt). `source` is `dictation`, `meeting`, `g2` or `manual`.
26
+ - Add `GET /api/transcription-glossary/recent`: what COS learned, newest first, with source and time. The log lives in the data dir (`transcription-corrections.json`, last 200, atomic, 0600); the dictionary stays in `.cos-profile.json`.
27
+ - Add `POST /api/transcription-glossary/correction/undo` `{id}`: reverts exactly that entry, puts back a replaced case variant, and removes the spelling only if no remaining correction writes it. Refuses with 409 when a newer entry owns the word or it was edited since.
28
+ - Live, meeting and dictation transcription pick a learned word up on the next sentence (decoder and vocab-echo caches are cleared). Pair with Control 0.5.309. 6.73.10 is the Work chat candidate on its own branch. Transcription-candidate and cue flags stay disabled. No public publication is implied.
29
+
30
+ ## 6.73.10 — Choose the Work chat planner (local candidate)
31
+
32
+ - Accept an optional `planner` (`sonnet` or `opus`) on `POST /api/work/chat` and run that Claude model, still tools-off. Anything else is a 400 before any model call. Clients that send none keep Sonnet. `GET /api/work/chat` now advertises `planners: ["sonnet","opus"]` and keeps `planner: "sonnet"` as the default; the answer's `model` names what ran. The choice selects the model only and never reaches the prompt.
33
+ - Cost: still one model call per user action, serialized by the existing busy lock (a second request gets 409). Opus costs more per call than Sonnet; nothing new runs on a schedule.
34
+ - Planning still never dispatches. Ships with Control 0.5.309, which reviews every planned assignment before anything is sent. Transcription-candidate and cue flags stay disabled. No public publication is implied.
35
+
36
+ ## 6.73.9 — Keep stalled Mac captures recoverable (local candidate)
37
+
38
+ - Never auto-save or expire a Mac capture (`mac_<uuid>`). COS Control keeps every chunk on the Mac and delivers or recovers it. The 4 h stranded sweep posted the save without the server pin a Mac save requires, read the 400 as final and closed the session; boot recovery closed any session idle past 4 h the same way. A capture stalled at 51 of 1,200 chunks (2026-10-15) would have lost its server-side recovery at 20:12. Both paths now leave Mac captures open (`restoresAtBoot`, sweep guard).
39
+ - Note for 6.73.6 to 6.73.8: the decision provider switch (Jev or SubQ) for Work search, session suggestions and completion checks, with authenticated `/api/decision-provider` and `/api/subq-key` routes, a private 0600 SubQ key file, and `COS_SUBQ_DAILY_TOKENS` (default 1M) capping each workflow's daily usage separately. SubQ sends the decision prompt to SubQ's API. Meeting intake (`work_intake.py`) stays on Jev.
40
+ - Known limitation: in a saved Mac meeting's full-quality Parakeet pass, a window with no words over detected speech still abandons that pass; the meeting saves with its live transcript.
41
+ - Pair with Control 0.5.307. Transcription-candidate and cue flags stay disabled. No public publication is implied.
42
+
43
+ ## 6.73.8 — Live Ask on Mac recordings (local candidate)
44
+
45
+ - Accept Mac capture ids (`mac_<uuid>`, the id the Mac recording route issues) in live COS Ask, Ask history, files and orientation, and Work intake Ask evidence. Every Ask on a Mac recording failed with `invalid_session_id`; found in a live meeting 2026-10-15. One shared pattern in `lib/ask-session-id.ts`; traversal-shaped and upper-case ids stay refused.
46
+ - Record Parakeet's no-words answer over detected speech on a Mac chunk as an empty chunk with a `[mac-asr]` warning, instead of a retryable 503. Replays of the same audio failed forever and froze every later chunk of a live meeting (2026-10-15, chunk 51). Real Parakeet failures stay retryable with no Whisper fallback; the saved meeting still re-transcribes the full audio with strict Parakeet.
47
+ - Pair with Control 0.5.306. Transcription-candidate and cue flags stay disabled. No public publication is implied.
48
+
49
+ ## 6.73.7 — Strict local desktop dictation (local candidate)
50
+
51
+ - Advertise the desktop dictation capability before microphone audio is transferred.
52
+ - Honor an explicit Parakeet Ultra requirement through the existing COS dictionary and vocabulary pipeline; return a retryable error rather than substituting Whisper or cloud transcription.
53
+ - Bound streamed desktop audio to two minutes of canonical PCM16 WAV data. Preserve legacy transcription behavior for callers without the engine requirement.
54
+ - Pair with Control 0.5.303 for microphone dictation and shared Mac live meeting views. Retain the recording/onboarding/SubQ changes from 6.73.6 and keep transcription-candidate/cue flags disabled. No public publication is implied.
55
+
56
+ ## 6.73.6 — Manual Mac recording transport (local candidate)
57
+
58
+ - Register selected-app Mac system-audio capture with immutable source identity and high-confidence speaker policy before admitting audio.
59
+ - Persist canonical PCM chunk receipts, exact timing and capture provenance through retry, save and meeting context. Reject missing chunks, conflicting retries and source substitution.
60
+ - Keep Mac playback out of automatic G2/Fireflies merges and voice training. Glasses are optional for the matched Control 0.5.301 pilot.
61
+ - Preserve transcription settings and disabled transcription-candidate/cue flags. Signed-host capture and permission acceptance remain local installation gates; this entry does not indicate public availability.
62
+
63
+ ## 6.73.5 — Tasks from saved meetings (local candidate)
64
+
65
+ - Add authenticated, bounded creation of a task and its exact source meeting in one canonical locked write.
66
+ - Preserve source files through the existing meeting context store, reuse matching linked tasks on retry, and explicitly report collisions with unrelated or closed work.
67
+ - Return a renamed task's stable Work identity so matched Control 0.5.295/build 348 opens the saved card.
68
+ - Keep existing transcription choices and experimental cue/transcription flags unchanged.
69
+
70
+ ## 6.73.4 (local candidate)
71
+
72
+ - Add tools-off Sonnet planning and result synthesis for task-level Work chat. Actual delivery uses existing native-session continuation.
73
+ - Preserve independent session status and user ownership of shared task completion.
74
+
1
75
  ## 6.73.3 — Speaker identity policy and exact Codex child sessions
2
76
 
3
77
  - Preserve the original child Codex session when reading completed job details. Control can open the actual result without substituting the parent conversation or starting another model turn.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.73.3",
3
+ "version": "6.73.14",
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": {
@@ -45,6 +45,7 @@
45
45
  "!server/scripts/hq-contention-canary.ts",
46
46
  "!server/scripts/live-cos-ask-canary.ts",
47
47
  "!server/scripts/work-review-candidate.ts",
48
+ "!server/scripts/work-chat-canary.ts",
48
49
  "!server/scripts/work-session-identity-canary.ts",
49
50
  "!server/scripts/work-hardening-fixture.ts",
50
51
  "!server/scripts/work-review-mutation-gate.mjs",
package/server/index.ts CHANGED
@@ -148,7 +148,10 @@ import { createWorkReviewsRouter } from './routes/work-reviews.js'
148
148
  import { createWorkBoardRouter } from './routes/work-board.js'
149
149
  import { createWorkIntakeRouter } from './routes/work-intake.js'
150
150
  import { createWorkHandoffRequestsRouter } from './routes/work-handoff-requests.js'
151
+ import { createSubqKeyRouter } from './routes/subq-key.js'
152
+ import { createDecisionProviderRouter } from './routes/decision-provider.js'
151
153
  import { createJevRouter } from './routes/jev.js'
154
+ import { createWorkChatRouter } from './routes/work-chat.js'
152
155
  import { createWorkSearchRouter } from './routes/work-search.js'
153
156
  import { isReadOnlyApiPost } from './lib/work-search.js'
154
157
  import { dataPath } from './lib/data-dir.js'
@@ -383,6 +386,7 @@ app.use('/api', (req, res, next) => {
383
386
  || req.path === '/diagnostics/provider-proof'
384
387
  || req.path === '/transcribe'
385
388
  || req.path.startsWith('/transcribe-stream')
389
+ || req.path.startsWith('/mac-recording/')
386
390
  || req.path === '/meeting/save'
387
391
  // The import run answers 202 and keeps working. It takes its own
388
392
  // maintenance lease per page and checks admissions itself, so the global
@@ -797,7 +801,10 @@ app.use('/api', createWorkIntakeRouter({ store: workIntakeStore }))
797
801
  // (6.57.1), and the Work completion check (6.58.0).
798
802
  app.use('/api', createJevRouter(workReviewRuntime ? { review: async id => workReviewRuntime.peek(id) } : {}))
799
803
  // Work search by meaning (6.65.0, Control 0.5.259): one Jev Choice over the board's cards, with its own cap, switch and breaker.
804
+ app.use('/api', createDecisionProviderRouter())
805
+ app.use('/api', createSubqKeyRouter())
800
806
  app.use('/api', createWorkSearchRouter())
807
+ app.use('/api', createWorkChatRouter())
801
808
  app.use('/api', queryRouter)
802
809
  // The scheduled start-of-day brief: settings, status, run-now. Same auth as
803
810
  // every other settings route; the brief itself is an ordinary durable job.
@@ -0,0 +1,5 @@
1
+ // Which recordings may open live Ask, its history and Work evidence. 6.73.8: a Mac capture
2
+ // (mac_<uuid>, lib/mac-recording.ts MAC_CAPTURE_ID) uses the same Ask as a G2 meeting; before this
3
+ // every Mac request and saved-question read failed with invalid_session_id.
4
+ export const MAC_ASK_SESSION = /^mac_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
5
+ export const ASK_SESSION = /^(?:meeting_[A-Za-z0-9_-]{1,89}|mac_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/
@@ -0,0 +1,163 @@
1
+ /** Explicit classifier routing. No retry or cross-provider fallback; generative engines do not use this seam. */
2
+ import { lstatSync, mkdirSync, readFileSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+ import { JevClient, JevError, type JevAnswers } from './jev.js'
5
+ import { dataPath } from './data-dir.js'
6
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
7
+ import { redactSecretText } from './activity-preview.js'
8
+ import { secureExistingPrivateFile } from './secure-user-config.js'
9
+
10
+ import { resolveSubqKey, SUBQ_MODEL } from './subq-key.js'
11
+ export { SUBQ_MODEL } from './subq-key.js'
12
+ import { resolveDecisionSelection, type DecisionProvider, type DecisionUseCase } from './decision-settings.js'
13
+ export type { DecisionProvider, DecisionUseCase } from './decision-settings.js'
14
+ export interface DecisionImage { mediaType: 'image/png' | 'image/jpeg' | 'image/webp'; base64: string }
15
+ export interface DecisionOptions { signal?: AbortSignal; images?: DecisionImage[] }
16
+ export interface DecisionClient { readonly provider?: DecisionProvider; snapshot?: () => DecisionClient; ask(state: unknown, questions: Record<string, unknown>, estimate: number, options?: DecisionOptions): Promise<JevAnswers> }
17
+ export class DecisionProviderError extends Error {
18
+ constructor(readonly code: 'decision_invalid_provider' | 'decision_not_configured' | 'decision_cap_reached' | 'decision_breaker_open' |
19
+ 'decision_key_rejected' | 'decision_credits_required' | 'decision_rate_limited' | 'decision_request_rejected' | 'decision_unavailable' |
20
+ 'decision_bad_answer' | 'decision_cancelled' | 'decision_images_unsupported' | 'decision_usage_unavailable') { super(code) }
21
+ }
22
+ export const SUBQ_ENDPOINT = 'https://api.subq.ai/v1/systemone'
23
+ export const SUBQ_LIMITS = { timeoutMs: 20_000, dailyTokens: 1_000_000, breakerFailures: 3, breakerMs: 3_600_000, maxBodyBytes: 13 * 1024 * 1024 } as const
24
+
25
+ /** No key probing, and no unknown value silently treated as Jev. Per-use-case settings override the global switch. */
26
+ export function selectedDecisionProvider(useCase: DecisionUseCase, env: NodeJS.ProcessEnv = process.env): DecisionProvider {
27
+ try { return resolveDecisionSelection(useCase, env).provider } catch { throw new DecisionProviderError('decision_invalid_provider') }
28
+ }
29
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
30
+ const probability = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1
31
+ function clean(value: unknown): unknown {
32
+ if (typeof value === 'string') return redactSecretText(value).replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]')
33
+ .replace(/\b\d{3}[-. ]\d{3}[-. ]\d{4}\b/g, '[phone]')
34
+ if (Array.isArray(value)) return value.map(clean)
35
+ if (isRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, v]) => [String(clean(key)), clean(v)]))
36
+ return value
37
+ }
38
+ /** Validate locally. Image bytes are never logged, persisted, fetched by URL, or inferred from a user's screen. */
39
+ function requestBody(state: unknown, questions: Record<string, unknown>, images: DecisionImage[] = []): string {
40
+ const entries = Object.entries(questions)
41
+ if (!entries.length || entries.length > 16 || entries.some(([k, q]) => !k || k.length > 100 || !isRecord(q) || typeof q.instructions !== 'string' ||
42
+ !['noul', 'choice', 'score'].includes(String(q.type)) || (q.type === 'choice' && (!isRecord(q.criteria) || Object.keys(q.criteria).length < 2 || Object.keys(q.criteria).length > 255)) ||
43
+ (q.type === 'score' && (!Array.isArray(q.criteria) || q.criteria.length < 2 || q.criteria.length > 10)))) throw new DecisionProviderError('decision_request_rejected')
44
+ let total = 0
45
+ if (images.length > 4) throw new DecisionProviderError('decision_request_rejected')
46
+ for (const image of images) {
47
+ if (!['image/png', 'image/jpeg', 'image/webp'].includes(image.mediaType) || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(image.base64)) throw new DecisionProviderError('decision_request_rejected')
48
+ const bytes = Buffer.from(image.base64, 'base64'); total += bytes.length
49
+ const valid = image.mediaType === 'image/png' ? bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')) :
50
+ image.mediaType === 'image/jpeg' ? bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 : bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP'
51
+ if (!valid || bytes.length > 4 * 1024 * 1024 || total > 8 * 1024 * 1024) throw new DecisionProviderError('decision_request_rejected')
52
+ }
53
+ const body = JSON.stringify({ model: SUBQ_MODEL, state: clean(state), questions: clean(questions), ...(images.length ? { images } : {}) })
54
+ if (Buffer.byteLength(body) > SUBQ_LIMITS.maxBodyBytes) throw new DecisionProviderError('decision_request_rejected')
55
+ return body
56
+ }
57
+
58
+ /** Strict schema at the provider boundary, before any workflow threshold can turn it into advice. */
59
+ export function validateDecisionAnswers(raw: unknown, questions: Record<string, unknown>): JevAnswers['answers'] {
60
+ if (!isRecord(raw) || Object.keys(raw).length !== Object.keys(questions).length) throw new DecisionProviderError('decision_bad_answer')
61
+ for (const [name, spec] of Object.entries(questions)) {
62
+ const q = spec as Record<string, unknown>, a = raw[name]
63
+ if (!isRecord(a) || a.type !== q.type) throw new DecisionProviderError('decision_bad_answer')
64
+ if (q.type === 'noul') { if (!probability(a.noul)) throw new DecisionProviderError('decision_bad_answer'); continue }
65
+ const criteria = q.criteria as Record<string, unknown> | unknown[]
66
+ const labels = Object.keys(criteria), probs = a.probabilities
67
+ if (!isRecord(probs) || Object.keys(probs).length !== labels.length || labels.some(k => !probability(probs[k])) ||
68
+ Math.abs(Object.values(probs).reduce<number>((sum, p) => sum + Number(p), 0) - 1) > 0.02 || !probability(a.confidence)) throw new DecisionProviderError('decision_bad_answer')
69
+ if (q.type === 'choice' && (typeof a.choice !== 'string' || !labels.includes(a.choice))) throw new DecisionProviderError('decision_bad_answer')
70
+ if (q.type === 'score' && (typeof a.score !== 'number' || !Number.isFinite(a.score) || a.score < 0 || a.score > labels.length - 1 || !isRecord(a.legend) || labels.some(k => !(k in (a.legend as Record<string, unknown>))))) throw new DecisionProviderError('decision_bad_answer')
71
+ }
72
+ return raw as JevAnswers['answers']
73
+ }
74
+
75
+ export class SubqDecisionClient implements DecisionClient {
76
+ readonly provider = 'subq' as const
77
+ private failures = 0
78
+ private breakerUntil = 0
79
+ private reserved = 0
80
+ constructor(private readonly fetchImpl: typeof fetch = fetch, private readonly now: () => Date = () => new Date(),
81
+ private readonly usageFile = dataPath('subq-decisions-usage.json'), private readonly key = () => resolveSubqKey()?.key,
82
+ private readonly cap = () => { const n = Number(process.env.COS_SUBQ_DAILY_TOKENS); return Number.isFinite(n) && n > 0 ? Math.floor(n) : SUBQ_LIMITS.dailyTokens },
83
+ private readonly timeoutMs: number = SUBQ_LIMITS.timeoutMs) {}
84
+ resetForNewKey(): void { this.failures = 0; this.breakerUntil = 0 }
85
+ private ledger(): Record<string, number> {
86
+ try {
87
+ const stat = lstatSync(this.usageFile)
88
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('unsafe')
89
+ } catch (e) {
90
+ if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT') return {}
91
+ throw new DecisionProviderError('decision_usage_unavailable')
92
+ }
93
+ try {
94
+ secureExistingPrivateFile(this.usageFile)
95
+ const value: unknown = JSON.parse(readFileSync(this.usageFile, 'utf8'))
96
+ if (!isRecord(value) || Object.entries(value).some(([day, count]) => !/^\d{4}-\d{2}-\d{2}$/.test(day) || typeof count !== 'number' || !Number.isFinite(count) || count < 0 || count > Number.MAX_SAFE_INTEGER)) throw new Error('invalid')
97
+ return value as Record<string, number>
98
+ } catch { throw new DecisionProviderError('decision_usage_unavailable') }
99
+ }
100
+ private day(): string { return this.now().toISOString().slice(0, 10) }
101
+ private record(tokens: number): void {
102
+ try {
103
+ const ledger = this.ledger(); ledger[this.day()] = (ledger[this.day()] ?? 0) + tokens
104
+ mkdirSync(dirname(this.usageFile), { recursive: true, mode: 0o700 })
105
+ durableAtomicWriteFileSync(this.usageFile, JSON.stringify(ledger) + '\n', { mode: 0o600 })
106
+ } catch { throw new DecisionProviderError('decision_usage_unavailable') }
107
+ }
108
+ private fail(code: DecisionProviderError['code'], breaker = false): never {
109
+ if (breaker && ++this.failures >= SUBQ_LIMITS.breakerFailures) { this.breakerUntil = this.now().getTime() + SUBQ_LIMITS.breakerMs; this.failures = 0 }
110
+ throw new DecisionProviderError(code)
111
+ }
112
+ async ask(state: unknown, questions: Record<string, unknown>, _estimate: number, options: DecisionOptions = {}): Promise<JevAnswers> {
113
+ if (options.signal?.aborted) return this.fail('decision_cancelled')
114
+ const key = this.key(); if (!key) return this.fail('decision_not_configured')
115
+ if (this.breakerUntil > this.now().getTime()) return this.fail('decision_breaker_open')
116
+ const body = requestBody(state, questions, options.images)
117
+ // Reserve a conservative byte-based estimate, including encoded images, before concurrent requests can enter.
118
+ const estimate = Buffer.byteLength(body) + 1
119
+ if ((Number(this.ledger()[this.day()]) || 0) + this.reserved + estimate > this.cap()) return this.fail('decision_cap_reached')
120
+ this.reserved += estimate
121
+ try {
122
+ let res: Response
123
+ try { res = await this.fetchImpl(SUBQ_ENDPOINT, { method: 'POST', redirect: 'error', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
124
+ body, signal: AbortSignal.any([AbortSignal.timeout(this.timeoutMs), ...(options.signal ? [options.signal] : [])]) }) }
125
+ catch { this.record(estimate); return this.fail(options.signal?.aborted ? 'decision_cancelled' : 'decision_unavailable', !options.signal?.aborted) }
126
+ if (res.status === 401 || res.status === 403) return this.fail('decision_key_rejected')
127
+ if (res.status === 402) return this.fail('decision_credits_required')
128
+ if (res.status === 429) return this.fail('decision_rate_limited')
129
+ if ([400, 413, 422].includes(res.status)) return this.fail('decision_request_rejected')
130
+ if (!res.ok) return this.fail('decision_unavailable', true)
131
+ // Count conservative usage even when a successful response is unreadable. No raw provider error is echoed.
132
+ let raw: unknown
133
+ try { raw = await res.json() } catch { this.record(estimate); return this.fail('decision_bad_answer', true) }
134
+ const used = isRecord(raw) && isRecord(raw.usage) && typeof raw.usage.input_tokens === 'number' && Number.isFinite(raw.usage.input_tokens) && raw.usage.input_tokens > 0 ? raw.usage.input_tokens : estimate
135
+ this.record(used)
136
+ if (!isRecord(raw) || raw.model !== SUBQ_MODEL) return this.fail('decision_bad_answer', true)
137
+ let answers: JevAnswers['answers']
138
+ try { answers = validateDecisionAnswers(raw.answers, questions) } catch { return this.fail('decision_bad_answer', true) }
139
+ this.failures = 0
140
+ return { provider: 'subq', answers, model: SUBQ_MODEL, inputTokens: used }
141
+ } finally { this.reserved -= estimate }
142
+ }
143
+ }
144
+
145
+ const configuredSubqClients = new Set<SubqDecisionClient>()
146
+ export function resetSubqDecisionClients(): void { configuredSubqClients.forEach(client => client.resetForNewKey()) }
147
+ /** Snapshot selection per workflow request. Separate clients and cache identities prevent stale cross-provider answers. */
148
+ export function createDecisionClient(useCase: DecisionUseCase, jev: Pick<JevClient, 'ask'> = new JevClient(), env: NodeJS.ProcessEnv = process.env): DecisionClient {
149
+ const clients: Partial<Record<DecisionProvider, DecisionClient>> = {}
150
+ const snapshot = (): DecisionClient => {
151
+ const selected = selectedDecisionProvider(useCase, env)
152
+ if (!clients[selected]) clients[selected] = selected === 'subq' ? new SubqDecisionClient(fetch, () => new Date(), dataPath(`subq-${useCase}-usage.json`)) : {
153
+ provider: 'jev', async ask(state, questions, estimate, options) {
154
+ if (options?.images?.length) throw new DecisionProviderError('decision_images_unsupported')
155
+ return { ...await jev.ask(state, questions, estimate, options), provider: 'jev' }
156
+ },
157
+ }
158
+ if (selected === 'subq') configuredSubqClients.add(clients[selected] as SubqDecisionClient)
159
+ return clients[selected]!
160
+ }
161
+ return { snapshot, ask: (state, questions, estimate, options) => snapshot().ask(state, questions, estimate, options) }
162
+ }
163
+ export function isDecisionFailure(error: unknown): error is JevError | DecisionProviderError { return error instanceof JevError || error instanceof DecisionProviderError }
@@ -0,0 +1,72 @@
1
+ /** Private, durable classifier selection. No key storage or account/network changes. */
2
+ import { createHash } from 'node:crypto'
3
+ import { lstatSync, readFileSync } from 'node:fs'
4
+ import { dirname } from 'node:path'
5
+ import { dataPath } from './data-dir.js'
6
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
7
+ import { secureExistingPrivateFile, securePrivateDirectory } from './secure-user-config.js'
8
+
9
+ export type DecisionProvider = 'jev' | 'subq'
10
+ export const DECISION_WORKFLOWS = ['session', 'completion', 'search'] as const
11
+ export type DecisionUseCase = typeof DECISION_WORKFLOWS[number]
12
+ export interface DecisionSettings { schema: 1; provider?: DecisionProvider; overrides: Partial<Record<DecisionUseCase, DecisionProvider>> }
13
+ export class DecisionSettingsError extends Error {
14
+ constructor(readonly code: 'invalid_decision_settings' | 'decision_settings_unavailable' | 'decision_invalid_provider' | 'decision_settings_conflict') { super(code) }
15
+ }
16
+ const record = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
17
+ const provider = (v: unknown): v is DecisionProvider => v === 'jev' || v === 'subq'
18
+ const settingsFile = () => dataPath('decision-provider.json')
19
+ export function readDecisionSettings(file = settingsFile()): DecisionSettings {
20
+ // Only an absent directory entry means defaults. A dangling symlink or a file
21
+ // disappearing after this check must not silently change the selected provider.
22
+ try {
23
+ const stat = lstatSync(file)
24
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('unsafe')
25
+ } catch (e) {
26
+ if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT') return { schema: 1, overrides: {} }
27
+ throw new DecisionSettingsError('decision_settings_unavailable')
28
+ }
29
+ try {
30
+ secureExistingPrivateFile(file)
31
+ const value = JSON.parse(readFileSync(file, 'utf8'))
32
+ if (!record(value) || value.schema !== 1 || !record(value.overrides) || Object.keys(value).some(k => !['schema','provider','overrides'].includes(k)) ||
33
+ (value.provider !== undefined && !provider(value.provider)) || Object.entries(value.overrides).some(([key,v]) => !DECISION_WORKFLOWS.includes(key as DecisionUseCase) || !provider(v))) throw new Error('invalid')
34
+ return value as unknown as DecisionSettings
35
+ } catch {
36
+ throw new DecisionSettingsError('decision_settings_unavailable')
37
+ }
38
+ }
39
+ export function decisionSettingsRevision(settings: DecisionSettings): string {
40
+ return createHash('sha256').update(JSON.stringify([settings.provider ?? null, ...DECISION_WORKFLOWS.map(w => settings.overrides[w] ?? null)])).digest('hex')
41
+ }
42
+ export function patchDecisionSettings(current: DecisionSettings, raw: unknown): DecisionSettings {
43
+ if (!record(raw) || !Object.keys(raw).length || Object.keys(raw).some(k => !['provider','overrides','expectedRevision'].includes(k)) || typeof raw.expectedRevision !== 'string' || !/^[a-f0-9]{64}$/.test(raw.expectedRevision) || (!('provider' in raw) && !('overrides' in raw)) ||
44
+ ('provider' in raw && raw.provider !== null && !provider(raw.provider)) ||
45
+ ('overrides' in raw && (!record(raw.overrides) || !Object.keys(raw.overrides).length || Object.entries(raw.overrides).some(([k,v]) => !DECISION_WORKFLOWS.includes(k as DecisionUseCase) || (v !== null && !provider(v)))))) {
46
+ throw new DecisionSettingsError('invalid_decision_settings')
47
+ }
48
+ if (raw.expectedRevision !== decisionSettingsRevision(current)) throw new DecisionSettingsError('decision_settings_conflict')
49
+ const next: DecisionSettings = { ...current, overrides: { ...current.overrides } }
50
+ if ('provider' in raw) { if (raw.provider === null) delete next.provider; else next.provider = raw.provider as DecisionProvider }
51
+ if (record(raw.overrides)) for (const [key,value] of Object.entries(raw.overrides)) {
52
+ if (value === null) delete next.overrides[key as DecisionUseCase]; else next.overrides[key as DecisionUseCase] = value as DecisionProvider
53
+ }
54
+ return next
55
+ }
56
+ export function writeDecisionSettings(next: DecisionSettings, file = settingsFile()): void {
57
+ securePrivateDirectory(dirname(file)); secureExistingPrivateFile(file)
58
+ durableAtomicWriteFileSync(file, JSON.stringify(next) + '\n', { mode: 0o600 })
59
+ }
60
+ export function resolveDecisionSelection(useCase: DecisionUseCase, env: NodeJS.ProcessEnv = process.env, settings = readDecisionSettings()): { provider: DecisionProvider; source: 'config' | 'env' | 'default' } {
61
+ const workflowEnv = env[`COS_DECISION_PROVIDER_${useCase.toUpperCase()}`]
62
+ const choice = settings.overrides[useCase] ?? workflowEnv ?? settings.provider ?? env.COS_DECISION_PROVIDER ?? 'jev'
63
+ const selected = choice.trim().toLowerCase()
64
+ if (!provider(selected)) throw new DecisionSettingsError('decision_invalid_provider')
65
+ return { provider: selected, source: settings.overrides[useCase] !== undefined ? 'config' : workflowEnv !== undefined ? 'env' : settings.provider !== undefined ? 'config' : env.COS_DECISION_PROVIDER !== undefined ? 'env' : 'default' }
66
+ }
67
+ export function decisionSettingsView(settings: DecisionSettings, configured: Record<DecisionProvider, boolean>, env: NodeJS.ProcessEnv = process.env) {
68
+ const global = resolveDecisionSelection('session', { COS_DECISION_PROVIDER: env.COS_DECISION_PROVIDER }, { schema: 1, provider: settings.provider, overrides: {} })
69
+ const selections = Object.fromEntries(DECISION_WORKFLOWS.map(w => [w, resolveDecisionSelection(w, env, settings)])) as Record<DecisionUseCase, ReturnType<typeof resolveDecisionSelection>>
70
+ return { schema: 1 as const, revision: decisionSettingsRevision(settings), provider: global.provider, overrides: settings.overrides, effective: Object.fromEntries(DECISION_WORKFLOWS.map(w => [w,selections[w].provider])),
71
+ sources: Object.fromEntries(DECISION_WORKFLOWS.map(w => [w,selections[w].source])), configured, saved: { provider: settings.provider ?? null, overrides: settings.overrides } }
72
+ }
@@ -0,0 +1,221 @@
1
+ // One-word dictionary learning (6.73.11). "Fix a word" in COS Control, and any
2
+ // other surface, teaches ONE correction at a time: heard → written joins the
3
+ // corrections map and `written` joins the vocabulary, in one write. Every learn
4
+ // is logged (bounded, newest first) so the person can see what COS learned, from
5
+ // where, and undo exactly that one entry.
6
+ //
7
+ // Pure: no IO. The route in routes/glossary.ts reads the profile and the log,
8
+ // calls these, and writes both back inside the glossary write chain.
9
+
10
+ export const MAX_TERMS = 500
11
+ export const MAX_TERM_LEN = 100
12
+ export const MAX_LOG = 200
13
+ export const CORRECTION_SOURCES = ['dictation', 'meeting', 'g2', 'manual'] as const
14
+ export type CorrectionSource = typeof CORRECTION_SOURCES[number]
15
+
16
+ export interface GlossaryWords {
17
+ vocabulary: string[]
18
+ corrections: Record<string, string>
19
+ }
20
+
21
+ export interface CorrectionLogEntry {
22
+ id: string
23
+ heard: string
24
+ written: string
25
+ source: CorrectionSource
26
+ at: string
27
+ /** The existing key this entry replaced (a case variant of `heard`, or `heard` itself), or null. */
28
+ replacedKey: string | null
29
+ /** What `replacedKey` mapped to before this entry, or null. */
30
+ previous: string | null
31
+ /** True when this entry put `written` into the vocabulary. */
32
+ addedVocabulary: boolean
33
+ }
34
+
35
+ export type LearnResult =
36
+ | { ok: true; unchanged: true }
37
+ | { ok: true; unchanged: false; words: GlossaryWords; entry: CorrectionLogEntry }
38
+ | { ok: false; status: number; error: string }
39
+
40
+ export type UndoResult =
41
+ | { ok: true; words: GlossaryWords; log: CorrectionLogEntry[]; entry: CorrectionLogEntry }
42
+ | { ok: false; status: number; error: string }
43
+
44
+ // Positive vocab is injected into the Whisper decoder prompt; URLs/emails/paths
45
+ // there induce ".com"/handle hallucinations on quiet audio, so they are refused.
46
+ export function looksLikeUrlEmailPath(s: string): boolean {
47
+ return /(?:https?:\/\/|www\.)/i.test(s)
48
+ || /@[\w.-]+\.\w/.test(s)
49
+ || /\.(?:com|net|org|io|ai|co|gov|edu|app|dev)\b/i.test(s)
50
+ || /[/\\]/.test(s)
51
+ }
52
+
53
+ const CONTROL = /[\u0000-\u001f\u007f]/
54
+ const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
55
+
56
+ export function isCorrectionSource(value: unknown): value is CorrectionSource {
57
+ return typeof value === 'string' && (CORRECTION_SOURCES as readonly string[]).includes(value)
58
+ }
59
+
60
+ export function learnCorrection(
61
+ words: GlossaryWords,
62
+ input: { heard: unknown; written: unknown; source: unknown },
63
+ stamp: { id: string; at: string },
64
+ ): LearnResult {
65
+ if (typeof input.heard !== 'string' || typeof input.written !== 'string') {
66
+ return { ok: false, status: 400, error: 'heard and written must be strings' }
67
+ }
68
+ if (!isCorrectionSource(input.source)) {
69
+ return { ok: false, status: 400, error: `source must be one of ${CORRECTION_SOURCES.join(', ')}` }
70
+ }
71
+ const heard = input.heard.trim()
72
+ const written = input.written.trim()
73
+ if (!heard || !written) return { ok: false, status: 400, error: 'heard and written must not be empty' }
74
+ if (heard.length > MAX_TERM_LEN || written.length > MAX_TERM_LEN) {
75
+ return { ok: false, status: 400, error: `a correction is at most ${MAX_TERM_LEN} characters` }
76
+ }
77
+ if (CONTROL.test(heard) || CONTROL.test(written)) {
78
+ return { ok: false, status: 400, error: 'a correction cannot contain line breaks or control characters' }
79
+ }
80
+ if (heard === written) return { ok: false, status: 400, error: 'the written spelling is the same as what was heard' }
81
+ if (looksLikeUrlEmailPath(written)) {
82
+ return { ok: false, status: 400, error: 'the written spelling cannot be a URL, email or path; it would join the decoder prompt' }
83
+ }
84
+
85
+ const existingKey = Object.keys(words.corrections).find(key => same(key, heard)) ?? null
86
+ const correctionChanged = existingKey !== heard || words.corrections[heard] !== written
87
+ const inVocabulary = words.vocabulary.some(term => same(term, written))
88
+ if (!correctionChanged && inVocabulary) return { ok: true, unchanged: true }
89
+
90
+ if (existingKey === null && Object.keys(words.corrections).length >= MAX_TERMS) {
91
+ return { ok: false, status: 409, error: `the dictionary already holds ${MAX_TERMS} corrections; remove one first` }
92
+ }
93
+ if (!inVocabulary && words.vocabulary.length >= MAX_TERMS) {
94
+ return { ok: false, status: 409, error: `the vocabulary already holds ${MAX_TERMS} terms; remove one first` }
95
+ }
96
+
97
+ const corrections = { ...words.corrections }
98
+ if (existingKey !== null && existingKey !== heard) delete corrections[existingKey]
99
+ corrections[heard] = written
100
+ const vocabulary = inVocabulary ? [...words.vocabulary] : [...words.vocabulary, written]
101
+ const entry: CorrectionLogEntry = {
102
+ id: stamp.id,
103
+ heard,
104
+ written,
105
+ source: input.source,
106
+ at: stamp.at,
107
+ replacedKey: existingKey,
108
+ previous: existingKey === null ? null : words.corrections[existingKey],
109
+ addedVocabulary: !inVocabulary,
110
+ }
111
+ return { ok: true, unchanged: false, words: { vocabulary, corrections }, entry }
112
+ }
113
+
114
+ /** Newest first, bounded. */
115
+ export function appendLog(log: CorrectionLogEntry[], entry: CorrectionLogEntry): CorrectionLogEntry[] {
116
+ return [entry, ...log].slice(0, MAX_LOG)
117
+ }
118
+
119
+ /**
120
+ * Revert exactly one logged entry, and only while the dictionary still says
121
+ * what that entry wrote. A later entry for the same word, or a hand edit since,
122
+ * makes the undo refuse rather than clobber the newer choice.
123
+ */
124
+ export function undoCorrection(words: GlossaryWords, log: CorrectionLogEntry[], id: unknown): UndoResult {
125
+ if (typeof id !== 'string' || !id) return { ok: false, status: 400, error: 'id is required' }
126
+ const index = log.findIndex(entry => entry.id === id)
127
+ if (index < 0) return { ok: false, status: 404, error: 'that correction is no longer in the recent list' }
128
+ const entry = log[index]
129
+ if (log.slice(0, index).some(newer => same(newer.heard, entry.heard))) {
130
+ return { ok: false, status: 409, error: `a newer correction changed "${entry.heard}"; undo that one first` }
131
+ }
132
+ if (words.corrections[entry.heard] !== entry.written) {
133
+ return { ok: false, status: 409, error: `"${entry.heard}" was changed since; edit it in the dictionary instead` }
134
+ }
135
+ const corrections = { ...words.corrections }
136
+ delete corrections[entry.heard]
137
+ if (entry.replacedKey !== null && entry.previous !== null) {
138
+ const collision = Object.keys(corrections).some(key => same(key, entry.replacedKey!))
139
+ if (collision) return { ok: false, status: 409, error: `"${entry.replacedKey}" was changed since; edit it in the dictionary instead` }
140
+ corrections[entry.replacedKey] = entry.previous
141
+ }
142
+ let vocabulary = [...words.vocabulary]
143
+ // The spelling stays while any remaining correction still writes it.
144
+ if (entry.addedVocabulary && !Object.values(corrections).some(value => same(value, entry.written))) {
145
+ vocabulary = vocabulary.filter(term => !same(term, entry.written))
146
+ }
147
+ return { ok: true, words: { vocabulary, corrections }, log: log.filter((_, i) => i !== index), entry }
148
+ }
149
+
150
+ /** Tolerant reader: a missing or corrupt log is an empty one; malformed rows are dropped. */
151
+ export function parseLog(raw: unknown): CorrectionLogEntry[] {
152
+ const entries = raw && typeof raw === 'object' && Array.isArray((raw as { entries?: unknown }).entries)
153
+ ? (raw as { entries: unknown[] }).entries : []
154
+ const out: CorrectionLogEntry[] = []
155
+ for (const value of entries) {
156
+ if (!value || typeof value !== 'object') continue
157
+ const e = value as Record<string, unknown>
158
+ if (typeof e.id !== 'string' || typeof e.heard !== 'string' || typeof e.written !== 'string'
159
+ || !isCorrectionSource(e.source) || typeof e.at !== 'string' || typeof e.addedVocabulary !== 'boolean') continue
160
+ const replacedKey = typeof e.replacedKey === 'string' ? e.replacedKey : null
161
+ const previous = typeof e.previous === 'string' ? e.previous : null
162
+ out.push({ id: e.id, heard: e.heard, written: e.written, source: e.source, at: e.at, replacedKey, previous, addedVocabulary: e.addedVocabulary })
163
+ if (out.length >= MAX_LOG) break
164
+ }
165
+ return out
166
+ }
167
+
168
+ // ── Single-item edits (6.73.12) ──────────────────────────────────────────────
169
+ // COS Control's Dictionary pane adds or removes ONE word or correction. Each is a
170
+ // pure step over the stored words, run inside the glossary write chain, so a
171
+ // concurrent learn can never be lost the way a client read-then-PUT could lose it.
172
+ // These are hand edits; they are not logged for undo.
173
+
174
+ export type EditResult =
175
+ | { ok: true; unchanged: true }
176
+ | { ok: true; unchanged: false; words: GlossaryWords }
177
+ | { ok: false; status: number; error: string }
178
+
179
+ function validTerm(raw: unknown, what: string): { ok: true; value: string } | { ok: false; status: number; error: string } {
180
+ if (typeof raw !== 'string') return { ok: false, status: 400, error: `${what} must be a string` }
181
+ const value = raw.trim()
182
+ if (!value) return { ok: false, status: 400, error: `${what} must not be empty` }
183
+ if (value.length > MAX_TERM_LEN) return { ok: false, status: 400, error: `${what} is at most ${MAX_TERM_LEN} characters` }
184
+ if (CONTROL.test(value)) return { ok: false, status: 400, error: `${what} cannot contain line breaks or control characters` }
185
+ return { ok: true, value }
186
+ }
187
+
188
+ export function addVocabulary(words: GlossaryWords, raw: unknown): EditResult {
189
+ const term = validTerm(raw, 'term')
190
+ if (!term.ok) return term
191
+ if (looksLikeUrlEmailPath(term.value)) {
192
+ return { ok: false, status: 400, error: 'a vocabulary word cannot be a URL, email or path; it would join the decoder prompt' }
193
+ }
194
+ if (words.vocabulary.some(existing => same(existing, term.value))) return { ok: true, unchanged: true }
195
+ if (words.vocabulary.length >= MAX_TERMS) {
196
+ return { ok: false, status: 409, error: `the vocabulary already holds ${MAX_TERMS} terms; remove one first` }
197
+ }
198
+ return { ok: true, unchanged: false, words: { vocabulary: [...words.vocabulary, term.value], corrections: { ...words.corrections } } }
199
+ }
200
+
201
+ /** Removes the exact word; a word that is not there is not an error. */
202
+ export function removeVocabulary(words: GlossaryWords, raw: unknown): EditResult {
203
+ const term = validTerm(raw, 'term')
204
+ if (!term.ok) return term
205
+ const vocabulary = words.vocabulary.filter(existing => existing.trim() !== term.value)
206
+ if (vocabulary.length === words.vocabulary.length) return { ok: true, unchanged: true }
207
+ return { ok: true, unchanged: false, words: { vocabulary, corrections: { ...words.corrections } } }
208
+ }
209
+
210
+ /** Removes the correction for `heard` (its exact key, else its one case variant). */
211
+ export function removeCorrection(words: GlossaryWords, raw: unknown): EditResult {
212
+ const heard = validTerm(raw, 'heard')
213
+ if (!heard.ok) return heard
214
+ const key = heard.value in words.corrections
215
+ ? heard.value
216
+ : Object.keys(words.corrections).find(existing => same(existing, heard.value))
217
+ if (key === undefined) return { ok: true, unchanged: true }
218
+ const corrections = { ...words.corrections }
219
+ delete corrections[key]
220
+ return { ok: true, unchanged: false, words: { vocabulary: [...words.vocabulary], corrections } }
221
+ }