@gotcos/glasses-server 6.82.0 → 6.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +224 -0
  2. package/docs/work-orchestrator.md +184 -0
  3. package/managed-runtime-contract.json +2 -1
  4. package/package.json +8 -1
  5. package/server/index.ts +12 -0
  6. package/server/lib/claude-bridge.ts +4 -0
  7. package/server/lib/dictation-clean.ts +26 -3
  8. package/server/lib/dictation-format.ts +119 -0
  9. package/server/lib/dictation-polish-need.ts +4 -2
  10. package/server/lib/glossary-learn.ts +88 -10
  11. package/server/lib/hallucination-filter.ts +5 -0
  12. package/server/lib/meeting-batch-transcribe.ts +12 -4
  13. package/server/lib/profile.ts +3 -1
  14. package/server/lib/query-job-coordinator.ts +5 -1
  15. package/server/lib/unsaved-audio-quarantine.ts +36 -5
  16. package/server/lib/whisper-local.ts +31 -5
  17. package/server/lib/work-chat.ts +76 -9
  18. package/server/lib/work-lens-rounds.ts +445 -0
  19. package/server/lib/work-orchestrator/discovery.ts +99 -0
  20. package/server/lib/work-orchestrator/input.ts +109 -0
  21. package/server/lib/work-orchestrator/pickup.ts +85 -0
  22. package/server/lib/work-orchestrator/plan-log.ts +38 -0
  23. package/server/lib/work-orchestrator/redact.ts +41 -0
  24. package/server/lib/work-orchestrator/runtime.ts +74 -0
  25. package/server/lib/work-orchestrator/session-log.ts +114 -0
  26. package/server/lib/work-orchestrator/snapshot.ts +87 -0
  27. package/server/lib/work-orchestrator/tag-index.ts +272 -0
  28. package/server/lib/work-orchestrator/tags.ts +52 -0
  29. package/server/lib/work-orchestrator/usage-events.ts +30 -0
  30. package/server/lib/work-orchestrator/usage.ts +341 -0
  31. package/server/routes/glossary.ts +14 -2
  32. package/server/routes/meeting.ts +76 -1
  33. package/server/routes/prompt-drafts.ts +15 -2
  34. package/server/routes/transcribe-stream.ts +127 -10
  35. package/server/routes/work-chat.ts +54 -11
  36. package/server/routes/work-lens-rounds.ts +179 -0
  37. package/server/routes/work-orchestrator.ts +71 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * 6.83.1: spoken paths and links in dictated messages. Miles, 2026-10-21, dictating funnel
3
+ * pages: "demo, forward slash pricing, forward slash build and price". He meant /pricing and
4
+ * /build-and-price, and COS gave him the words.
5
+ *
6
+ * Two pieces, both pure, both used by the outbound dictation pass in routes/prompt-drafts.ts:
7
+ *
8
+ * 1. renderSpokenPaths: the ONE case a rule can do safely. "forward slash" plus a single word
9
+ * that ends a phrase (a comma, a sentence end, a line end, or the end of the text) becomes
10
+ * "/word" in lowercase: "go to forward slash pricing." becomes "go to /pricing.". Anything
11
+ * longer ("forward slash build and price") is left to the polish model, which can tell
12
+ * where a slug ends; a rule cannot. Not rewritten:
13
+ * - "slash" without "forward": "and slash or", "slash prices" are ordinary speech.
14
+ * - the character itself: "the forward slash key", "a forward slash", "forward slash
15
+ * character" (an article before it, or a word that names the key after it).
16
+ * - ordinary speech after it: "with forward slash instead", "type forward slash twice"
17
+ * (an adverb or function word, NOT_A_SLUG).
18
+ * Off with COS_DICTATION_SPOKEN_PATHS=0. Meeting lines never come here (they stay faithful).
19
+ *
20
+ * 2. polishLinkProblem: a guard on the model's answer. The polish must never invent a link
21
+ * and must keep a link the dictionary already wrote. It returns why an answer is refused,
22
+ * or null. A refused answer means the caller sends the pre-polish text instead.
23
+ */
24
+
25
+ import { HANDLE, LINK_TLDS } from './glossary-learn.js'
26
+
27
+ const ARTICLE_BEFORE = /(?:^|[^\p{L}\p{N}])(?:the|a|an|this|that|one)[ \t]+$/iu
28
+ /**
29
+ * Words that follow "forward slash" in ordinary speech about the character, never a page name
30
+ * (6.83.1 QA W1): "with forward slash instead", "type forward slash twice", "two forward slash
31
+ * marks". The names of the key, singular and plural, adverbs, and common function words.
32
+ */
33
+ export const NOT_A_SLUG = new Set([
34
+ 'key', 'keys', 'character', 'characters', 'char', 'chars', 'sign', 'signs', 'symbol', 'symbols', 'mark', 'marks', 'button', 'buttons',
35
+ 'instead', 'too', 'twice', 'once', 'again', 'everywhere', 'here', 'there', 'only', 'please', 'first', 'last', 'also', 'now', 'then',
36
+ 'either', 'neither', 'always', 'never', 'anywhere', 'somewhere', 'before', 'after', 'later', 'yet', 'still', 'though', 'anyway',
37
+ 'the', 'a', 'an', 'and', 'or', 'but', 'nor', 'so', 'to', 'of', 'in', 'on', 'at', 'for', 'with', 'by', 'from', 'as', 'it', 'its',
38
+ 'this', 'that', 'these', 'those', 'is', 'are', 'was', 'were', 'be', 'if', 'not', 'no', 'yes', 'all', 'each', 'every', 'both',
39
+ 'more', 'less', 'one', 'two', 'three', 'thanks', 'ok', 'okay',
40
+ ])
41
+ /** "forward slash" (or "forward-slash"), one word, then a phrase end. */
42
+ const SPOKEN_PATH = /(?<![\p{L}\p{N}])forward[ \t-]+slash[ \t]+([\p{L}\p{N}]+)(?=[ \t]*(?:[,.;:!?)]|\n|$))/giu
43
+
44
+ export function spokenPathsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
45
+ return !['0', 'false', 'off'].includes((env.COS_DICTATION_SPOKEN_PATHS ?? '').trim().toLowerCase())
46
+ }
47
+
48
+ export function renderSpokenPaths(text: string): string {
49
+ return text.replace(SPOKEN_PATH, (match: string, word: string, offset: number) => {
50
+ if (NOT_A_SLUG.has(word.toLowerCase())) return match
51
+ if (ARTICLE_BEFORE.test(text.slice(0, offset))) return match
52
+ return `/${word.toLowerCase()}`
53
+ })
54
+ }
55
+
56
+ /** A written link, email, domain, handle or path, as a polish answer might contain one. */
57
+ const LINK_TOKEN = new RegExp([
58
+ String.raw`https?:\/\/[^\s<>"')\]]+`,
59
+ String.raw`\bwww\.[^\s<>"')\]]+`,
60
+ String.raw`[\p{L}\p{N}._%+-]+@[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+`,
61
+ String.raw`\b[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)*\.(?:${LINK_TLDS.join('|')})\b(?:\/[^\s<>"')\]]*)?`,
62
+ // A handle: "@sam", "@quilt_hq" (an email is matched whole above, first).
63
+ HANDLE.source,
64
+ // A path at the start of a token: "/pricing", "/build-and-price", "/docs/setup". Never
65
+ // "and/or" or "10/21" (a letter or digit right before the slash).
66
+ String.raw`(?<![\p{L}\p{N}\/])\/[\p{L}\p{N}_-]+(?:\/[\p{L}\p{N}_-]+)*`,
67
+ ].join('|'), 'giu')
68
+
69
+ export function linkTokens(text: string): string[] {
70
+ return (text.match(LINK_TOKEN) ?? []).map(token => token.replace(/[.,;:!?]+$/, ''))
71
+ }
72
+
73
+ const compact = (text: string) => text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
74
+ const spoken = (text: string, re: RegExp) => re.test(text)
75
+ /** An explicit request for paths: "as slugs", "as paths", "as a URL". Not any "path" ("the critical path"). */
76
+ const ASKS_FOR_PATHS = /\bas\s+(?:an?\s+)?(?:slugs?|paths?|urls?)\b/u
77
+ /** A dot spoken, or written between two letters ("docs.example"). "v6.83" or "3.5" is not one. */
78
+ const SPOKEN_DOT = /\bdot\b|\p{L}\.\p{L}/u
79
+
80
+ /**
81
+ * Why a polish answer must not be sent, or null.
82
+ * - 'invented_link': the answer holds a link, email, handle or path whose letters the speaker
83
+ * never said (a word from `knownSpellings`, the dictionary's vocabulary, counts as said, so a
84
+ * corrected spelling passes), or whose shape they never spoke: a path without "slash" (unless
85
+ * they asked for slugs, paths or a URL), a domain without "dot", an email or handle without
86
+ * "at". A scheme or "www" nobody said fails on its letters. A path made only of NOT_A_SLUG
87
+ * words ("/twice", "/everywhere") is the character talked about, not a page.
88
+ * - 'lost_dictionary_link': the text going in held a link the dictionary wrote (a
89
+ * correction-only form such as "/pricing") and the answer dropped it, in any case.
90
+ * Not handled: a number said as words and written as digits ("three sixty dot com" as
91
+ * "360.com") reads as invented; the answer is refused and the pre-polish text goes out.
92
+ */
93
+ export function polishLinkProblem(
94
+ input: string,
95
+ output: string,
96
+ dictionaryLinks: readonly string[] = [],
97
+ knownSpellings: readonly string[] = [],
98
+ ): 'invented_link' | 'lost_dictionary_link' | null {
99
+ const inputFold = input.toLowerCase(), outputFold = output.toLowerCase()
100
+ for (const link of dictionaryLinks) {
101
+ const l = link.toLowerCase()
102
+ if (l && inputFold.includes(l) && !outputFold.includes(l)) return 'lost_dictionary_link'
103
+ }
104
+ const said = compact(input)
105
+ const known = new Set(knownSpellings.map(compact).filter(Boolean))
106
+ for (const token of linkTokens(output)) {
107
+ const lower = token.toLowerCase()
108
+ // Every letter run in the link must have been said somewhere (not necessarily in order).
109
+ const pieces = lower.split(/[^\p{L}\p{N}]+/u).filter(Boolean)
110
+ if (pieces.some(piece => !said.includes(piece) && !known.has(piece))) return 'invented_link'
111
+ // And its shape must have been spoken. A link already written in the input has its marks there.
112
+ if (lower.includes('@') && !spoken(inputFold, /@|\bat\b/)) return 'invented_link'
113
+ if (/\.[\p{L}]/u.test(lower.replace(/^https?:\/\//, '').split('@').pop() ?? '') && !spoken(inputFold, SPOKEN_DOT)) return 'invented_link'
114
+ if (lower.startsWith('/') && !spoken(inputFold, /\bslash\b|(?<![\p{L}\p{N}])\/[\p{L}\p{N}]/u) && !spoken(inputFold, ASKS_FOR_PATHS)) return 'invented_link'
115
+ // "/twice", "/everywhere": the speaker talked about the character, not a page (NOT_A_SLUG).
116
+ if (lower.startsWith('/') && !lower.includes('.') && pieces.every(piece => NOT_A_SLUG.has(piece))) return 'invented_link'
117
+ }
118
+ return null
119
+ }
@@ -9,7 +9,9 @@
9
9
  * - a filler-like the deterministic pass leaves to the model ("like", "you know", "kind of"),
10
10
  * or a sentence that opens on a tic ("So", "Well", "Okay")
11
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")
12
+ * - spoken formatting or punctuation ("new line", "bullet point", "comma", "question mark"), and
13
+ * since 6.83.1 spoken links and formatting instructions ("slash", "dot com", "underscore",
14
+ * "at sign", "all caps", "slugs")
13
15
  * - text that does not read as finished: no capital at the start or no closing punctuation
14
16
  * - more than MAX_WORDS words
15
17
  * Each check errs toward calling the model: a false "needed" costs a model call, a false
@@ -27,7 +29,7 @@ const REPEAT = /(?<![\p{L}\p{N}'])([\p{L}\p{N}']+)[\s,]+\1(?![\p{L}\p{N}'])/iu
27
29
  const FALSE_START = /(?<![\p{L}\p{N}'])([\p{L}']+)\s+[\p{L}']+(?:\s+[\p{L}']+)?,\s+\1(?![\p{L}\p{N}'])/iu
28
30
  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
31
  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
32
+ 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|slash|backslash|underscore|hyphen|at sign|dot (com|net|org|io|ai|co|dev|app)|all caps|slugs?)\b/i
31
33
 
32
34
  export function polishNeed(text: string): PolishNeed | null {
33
35
  const trimmed = text.trim()
@@ -1,6 +1,9 @@
1
1
  // One-word dictionary learning (6.73.11). "Fix a word" in COS Control, and any
2
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
3
+ // corrections map and `written` joins the vocabulary, in one write. Since 6.83.1 a
4
+ // written form that is a link, email or path ("forward slash pricing" written
5
+ // "/pricing") is kept as a correction ONLY: it never joins the vocabulary, so it
6
+ // never reaches a decoder prompt. Every learn
4
7
  // is logged (bounded, newest first) so the person can see what COS learned, from
5
8
  // where, and undo exactly that one entry.
6
9
  //
@@ -42,11 +45,19 @@ export type UndoResult =
42
45
  | { ok: false; status: number; error: string }
43
46
 
44
47
  // 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.
48
+ // there induce ".com"/handle hallucinations on quiet audio, so they never join the
49
+ // vocabulary. As a correction's written form they are fine (6.83.1): corrections are
50
+ // applied to text after decoding and are not part of any decoder prompt.
51
+ // 6.83.1: one top-level-domain list for this guard and the polish link check (dictation-format.ts).
52
+ export const LINK_TLDS = ['com', 'net', 'org', 'io', 'ai', 'co', 'gov', 'edu', 'app', 'dev', 'us', 'uk'] as const
53
+ const TLD = new RegExp(`\\.(?:${LINK_TLDS.join('|')})\\b`, 'i')
54
+ /** "@sam" or "@quilt_hq": a handle seeds handle hallucinations in the decoder just as an email does. */
55
+ export const HANDLE = /(?<![\p{L}\p{N}._%+-])@[\p{L}\p{N}_]{2,}/u
46
56
  export function looksLikeUrlEmailPath(s: string): boolean {
47
57
  return /(?:https?:\/\/|www\.)/i.test(s)
48
58
  || /@[\w.-]+\.\w/.test(s)
49
- || /\.(?:com|net|org|io|ai|co|gov|edu|app|dev)\b/i.test(s)
59
+ || TLD.test(s)
60
+ || HANDLE.test(s)
50
61
  || /[/\\]/.test(s)
51
62
  }
52
63
 
@@ -78,26 +89,30 @@ export function learnCorrection(
78
89
  return { ok: false, status: 400, error: 'a correction cannot contain line breaks or control characters' }
79
90
  }
80
91
  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' }
92
+ // 6.83.1: a link, email or path is a correction only (see correctionOnly).
93
+ const correctionOnly = isCorrectionOnly(written)
94
+ if (correctionOnly && !/[\p{L}\p{N}]/u.test(written)) {
95
+ // "/" or "@" alone rewrites every "slash" or "at" in every transcript: no word to anchor it.
96
+ return { ok: false, status: 400, error: 'the written spelling needs a letter or number; a bare symbol would rewrite every sentence that says it' }
83
97
  }
84
98
 
85
99
  const existingKey = Object.keys(words.corrections).find(key => same(key, heard)) ?? null
86
100
  const correctionChanged = existingKey !== heard || words.corrections[heard] !== written
87
101
  const inVocabulary = words.vocabulary.some(term => same(term, written))
88
- if (!correctionChanged && inVocabulary) return { ok: true, unchanged: true }
102
+ if (!correctionChanged && (inVocabulary || correctionOnly)) return { ok: true, unchanged: true }
89
103
 
90
104
  if (existingKey === null && Object.keys(words.corrections).length >= MAX_TERMS) {
91
105
  return { ok: false, status: 409, error: `the dictionary already holds ${MAX_TERMS} corrections; remove one first` }
92
106
  }
93
- if (!inVocabulary && words.vocabulary.length >= MAX_TERMS) {
107
+ const addsVocabulary = !inVocabulary && !correctionOnly
108
+ if (addsVocabulary && words.vocabulary.length >= MAX_TERMS) {
94
109
  return { ok: false, status: 409, error: `the vocabulary already holds ${MAX_TERMS} terms; remove one first` }
95
110
  }
96
111
 
97
112
  const corrections = { ...words.corrections }
98
113
  if (existingKey !== null && existingKey !== heard) delete corrections[existingKey]
99
114
  corrections[heard] = written
100
- const vocabulary = inVocabulary ? [...words.vocabulary] : [...words.vocabulary, written]
115
+ const vocabulary = addsVocabulary ? [...words.vocabulary, written] : [...words.vocabulary]
101
116
  const entry: CorrectionLogEntry = {
102
117
  id: stamp.id,
103
118
  heard,
@@ -106,11 +121,72 @@ export function learnCorrection(
106
121
  at: stamp.at,
107
122
  replacedKey: existingKey,
108
123
  previous: existingKey === null ? null : words.corrections[existingKey],
109
- addedVocabulary: !inVocabulary,
124
+ addedVocabulary: addsVocabulary,
110
125
  }
111
126
  return { ok: true, unchanged: false, words: { vocabulary, corrections }, entry }
112
127
  }
113
128
 
129
+ /**
130
+ * 6.83.1: a written form that is a link, email or path is kept in the corrections map
131
+ * and never added to the vocabulary (the decoder prompt). Same test as the vocabulary
132
+ * guard, so nothing the decoder refuses can reach it through a correction.
133
+ */
134
+ export function isCorrectionOnly(written: string): boolean {
135
+ return looksLikeUrlEmailPath(written)
136
+ }
137
+
138
+ /** The one line a client can show after a correction-only learn. No em dashes, no arrows. */
139
+ export const CORRECTION_ONLY_NOTE = 'Saved as a correction. Links, emails and paths are never added to the words that guide transcription, so COS rewrites what it hears instead.'
140
+
141
+ /**
142
+ * The written forms of correction-only entries ("/pricing"). The polish link check
143
+ * (routes/prompt-drafts.ts) refuses an answer that drops one of them.
144
+ */
145
+ export function correctionOnlyForms(corrections: Record<string, string>): string[] {
146
+ const out: string[] = []
147
+ for (const value of Object.values(corrections)) {
148
+ const v = value.trim()
149
+ if (v && isCorrectionOnly(v) && !out.includes(v)) out.push(v)
150
+ }
151
+ return out
152
+ }
153
+
154
+ /**
155
+ * The rolling transcript context Whisper is primed with already holds corrected text, so
156
+ * a correction-only form ("/pricing") would reach the decoder through it. Put the spoken
157
+ * form back ("forward slash pricing") before priming: the decoder hears words, never the
158
+ * link or path. Any case ("/Pricing" too). Longest forms first so "/build-and-price" is not
159
+ * split by "/build".
160
+ */
161
+ export function decoderSafeContext(context: string, corrections: Record<string, string>): string {
162
+ const pairs = Object.entries(corrections)
163
+ .map(([heard, written]) => [heard.trim(), written.trim()] as const)
164
+ .filter(([heard, written]) => heard && written && isCorrectionOnly(written) && !isCorrectionOnly(heard))
165
+ .sort((a, b) => b[1].length - a[1].length)
166
+ let out = context
167
+ for (const [heard, written] of pairs) {
168
+ out = out.replace(new RegExp(written.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), () => heard)
169
+ }
170
+ return out
171
+ }
172
+
173
+ /**
174
+ * The decoder's priming window (6.83.1 QA W2): swap correction-only forms back to speech
175
+ * BEFORE taking the last `max` characters, so a dictionary link that straddles the cut can
176
+ * never leave its tail in the window. A first token the cut split that still reads as a
177
+ * link ("ample.com", "@example", "e/pricing", from a link the speaker said that is not in
178
+ * the dictionary) is dropped too; a split ordinary word stays, as before.
179
+ */
180
+ export function decoderContextWindow(context: string | undefined, corrections: Record<string, string>, max = 250): string | undefined {
181
+ if (context === undefined) return undefined
182
+ const safe = decoderSafeContext(context, corrections)
183
+ if (safe.length <= max) return safe
184
+ const cut = safe.slice(-max)
185
+ const first = /^\S+/.exec(cut)?.[0] ?? ''
186
+ const split = first !== '' && /\S$/.test(safe.slice(0, safe.length - max))
187
+ return split && looksLikeUrlEmailPath(first) ? cut.slice(first.length).replace(/^\s+/, '') : cut
188
+ }
189
+
114
190
  /** Newest first, bounded. */
115
191
  export function appendLog(log: CorrectionLogEntry[], entry: CorrectionLogEntry): CorrectionLogEntry[] {
116
192
  return [entry, ...log].slice(0, MAX_LOG)
@@ -189,7 +265,9 @@ export function addVocabulary(words: GlossaryWords, raw: unknown): EditResult {
189
265
  const term = validTerm(raw, 'term')
190
266
  if (!term.ok) return term
191
267
  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' }
268
+ // A vocabulary word has no heard side: it only steers the decoder, which is exactly
269
+ // where a link, email or path does harm. Point at the correction that does work.
270
+ return { ok: false, status: 400, error: 'a link, email or path cannot be a vocabulary word because it would steer transcription. Add it as a correction instead: what you say, then how to write it' }
193
271
  }
194
272
  if (words.vocabulary.some(existing => same(existing, term.value))) return { ok: true, unchanged: true }
195
273
  if (words.vocabulary.length >= MAX_TERMS) {
@@ -12,6 +12,7 @@
12
12
  // entirety (silence artifacts, caption training, foreign script, filler-only).
13
13
 
14
14
  import { getNegativeRules, getVocabulary, getOwnerName, getWhisperCorrections } from './profile.js'
15
+ import { isCorrectionOnly } from './glossary-learn.js'
15
16
 
16
17
  // ── Whole-chunk silence hallucinations ─────────────────────────────────────
17
18
  const CAPTION_CREDIT_HALLUCINATION = /^\s*(?:closed\s+)?caption(?:ing|s)?\s+(?:is\s+)?provided\s+by\b/i
@@ -344,6 +345,10 @@ function getVocabEchoMatcher(): RegExp {
344
345
  // Include whisper_corrections key/value variants so the echo matches whatever
345
346
  // spelling whisper emits ("POS Nation" ↔ "POSNation", "Jewel 360" ↔ "Jewel360").
346
347
  for (const [key, value] of Object.entries(getWhisperCorrections())) {
348
+ // 6.83.1: a correction-only pair ("forward slash pricing" written "/pricing") is not in the
349
+ // vocabulary prompt, so it is not one of the seeded terms this detector exists for. Its heard
350
+ // phrase is how the person dictates a link; a chunk of just that phrase is kept as speech.
351
+ if (isCorrectionOnly(value)) continue
347
352
  raw.add(key)
348
353
  raw.add(value)
349
354
  }
@@ -24,7 +24,15 @@ import { MAC_CAPTURE_ID } from './mac-recording.js'
24
24
  import { MacParakeetUnavailableError } from './mac-recording-asr.js'
25
25
  import { transcribeParakeet, type ParakeetResult, type ParakeetRunOptions } from './parakeet.js'
26
26
  import { isDeletionProneEngine, resolveHypotheses, RESOLVER_VERSION, type Hypothesis } from './stt-resolver.js'
27
- import { getVocabulary } from './profile.js'
27
+ import { getVocabulary, getWhisperCorrections } from './profile.js'
28
+ import { decoderContextWindow } from './glossary-learn.js'
29
+
30
+ /** 6.83.1: the decoder's priming window, with correction-only forms put back to speech before the cut. */
31
+ function decoderWindow(previousText?: string): string | undefined {
32
+ let corrections: Record<string, string> = {}
33
+ try { corrections = getWhisperCorrections() } catch { /* a test double without it reads as none */ }
34
+ return decoderContextWindow(previousText, corrections)
35
+ }
28
36
  import { captureSpeakerMode, policyLabel } from './meeting-speaker-policy.js'
29
37
  import { durableAtomicWriteFileSync } from './atomic-fs.js'
30
38
  import { isMetalBatchPreempted } from './whisper-metal-gate.js'
@@ -526,7 +534,7 @@ async function decodeWhisperOnce(
526
534
  audioDir: string,
527
535
  ): Promise<HighQualityTranscriptionResult> {
528
536
  try {
529
- return await deps.whisper(input, previousText?.slice(-250), { priority: 'batch', timestamps })
537
+ return await deps.whisper(input, decoderWindow(previousText), { priority: 'batch', timestamps })
530
538
  } catch (error) {
531
539
  // A live meeting took the GPU mid-segment. The truncated Metal output
532
540
  // was already discarded upstream (BLOCKER contract) — it is never
@@ -539,7 +547,7 @@ async function decodeWhisperOnce(
539
547
  + 'retrying once on CPU',
540
548
  )
541
549
  refreshPendingLease(audioDir)
542
- return deps.whisper(input, previousText?.slice(-250), { priority: 'batch', forceCpu: true, forceCpuReason: 'preempt_retry', timestamps })
550
+ return deps.whisper(input, decoderWindow(previousText), { priority: 'batch', forceCpu: true, forceCpuReason: 'preempt_retry', timestamps })
543
551
  }
544
552
  }
545
553
 
@@ -1043,7 +1051,7 @@ function queueProgressiveWork(sessionId: string, state: ProgressiveSessionState)
1043
1051
  const identity = currentCheckpointIdentity()
1044
1052
  const enhanced = await asrFrontEnd(combined, identity.frontEnd)
1045
1053
  if (controller.signal.aborted || state.closed) return
1046
- const decoded = await transcribeHighQuality(enhanced, pending.previousText?.slice(-250), {
1054
+ const decoded = await transcribeHighQuality(enhanced, decoderWindow(pending.previousText), {
1047
1055
  priority: 'batch',
1048
1056
  forceCpu: true,
1049
1057
  forceCpuReason: 'progressive_checkpoint',
@@ -5,6 +5,7 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs'
5
5
  import { homedir } from 'node:os'
6
6
  import { resolve, dirname } from 'node:path'
7
7
  import { atomicWriteFileSync } from './atomic-fs.js'
8
+ import { looksLikeUrlEmailPath } from './glossary-learn.js'
8
9
 
9
10
  const APP_ROOT = resolve(import.meta.dirname, '../..')
10
11
 
@@ -136,7 +137,8 @@ export function getVocabulary(): string[] {
136
137
  if (typeof value !== 'string') return []
137
138
  const term = value.trim()
138
139
  const key = term.toLowerCase()
139
- if (!term || PLACEHOLDER_VOCABULARY.has(key) || seen.has(key)) return []
140
+ // 6.83.1: a link, email, handle or path never steers the decoder, even hand-written here.
141
+ if (!term || PLACEHOLDER_VOCABULARY.has(key) || seen.has(key) || looksLikeUrlEmailPath(term)) return []
140
142
  seen.add(key)
141
143
  return [term]
142
144
  })
@@ -37,6 +37,7 @@ import type { MaintenanceWorkLease } from './maintenance-lifecycle.js'
37
37
  import { applyTrailToolMode, type JobTrailDraft } from './job-trail.js'
38
38
  import { crashLoopParkedError, type BootResumeGate } from './boot-resume.js'
39
39
  import { restartNoticeEnabled } from './query-job-feature.js'
40
+ import { noteJobProviderLimit } from './work-orchestrator/usage-events.js'
40
41
 
41
42
  const CLIENT_JOB_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
42
43
 
@@ -836,7 +837,10 @@ export class QueryJobCoordinator {
836
837
  this.finishActive(active)
837
838
  return false
838
839
  }
839
- const result = await this.store.fail(active.jobId, normalizeQueryJobError(error))
840
+ const failure = normalizeQueryJobError(error)
841
+ // 6.83.0: a plan-lane provider limit marks that provider exhausted for the Work orchestrator (until its reset).
842
+ noteJobProviderLimit(snapshot.provider, failure.code, error instanceof Error ? error.message : String((error as any)?.message ?? ''), active.request.model, snapshot.lane)
843
+ const result = await this.store.fail(active.jobId, failure)
840
844
  const projected = isTerminalQueryJobStatus(result.job.status)
841
845
  ? await this.projectTerminal(result.job, active.request)
842
846
  : false
@@ -43,8 +43,16 @@ export interface QuarantineManifest {
43
43
  reason: string
44
44
  chunkFiles: number
45
45
  bytes: number
46
+ /** 6.83.2: a shorter clock for this one capture (a Mac capture discarded with no sound). Never longer than the global one. */
47
+ retentionMs?: number
46
48
  }
47
49
 
50
+ /** 6.83.2: how long a discarded no-sound Mac capture stays quarantined: long enough to undo a wrong discard by hand. */
51
+ export const DISCARDED_RETENTION_MS = 24 * 60 * 60 * 1000
52
+
53
+ /** The receipt a discard writes, so the capture is terminal (`no_speech`) and never recovered on its own. */
54
+ export interface QuarantineReceipt { outcome: 'no_speech'; discarded: true; chunkFiles?: number; note: string }
55
+
48
56
  export interface UnsavedCapture {
49
57
  sessionId: string
50
58
  dirName: string
@@ -57,6 +65,12 @@ export interface UnsavedCapture {
57
65
  recovered: boolean
58
66
  }
59
67
 
68
+ /** The clock that applies to one capture: its own when it set a shorter one, else the global retention. */
69
+ export function effectiveRetentionMs(manifest: { retentionMs?: unknown } | null, retentionMs: number): number {
70
+ const own = manifest?.retentionMs
71
+ return typeof own === 'number' && Number.isFinite(own) && own > 0 ? Math.min(retentionMs, own) : retentionMs
72
+ }
73
+
60
74
  export function unsavedAudioRoot(): string {
61
75
  return dataPath('unsaved-audio')
62
76
  }
@@ -99,6 +113,7 @@ export function quarantineSessionAudio(
99
113
  sourceDir: string,
100
114
  reason: string,
101
115
  root: string = unsavedAudioRoot(),
116
+ options: { retentionMs?: number; receipt?: QuarantineReceipt } = {},
102
117
  ): string | null {
103
118
  const sessionId = basename(sourceDir)
104
119
  try {
@@ -114,6 +129,7 @@ export function quarantineSessionAudio(
114
129
  reason,
115
130
  chunkFiles: countChunkWavs(target),
116
131
  bytes: dirBytes(target),
132
+ ...(options.retentionMs ? { retentionMs: options.retentionMs } : {}),
117
133
  }
118
134
  try {
119
135
  writeFileSync(resolve(target, QUARANTINE_MANIFEST), `${JSON.stringify(manifest)}\n`, {
@@ -121,6 +137,19 @@ export function quarantineSessionAudio(
121
137
  mode: 0o600,
122
138
  })
123
139
  } catch { /* manifest is observability; the audio move already succeeded */ }
140
+ if (options.receipt) {
141
+ // 6.83.2: a discarded capture is terminal. The receipt is what keeps the auto-recover sweep and the
142
+ // unsaved-captures count off it (listUnsavedCaptures reads it as recovered, readCaptureDisposition as no_speech).
143
+ try {
144
+ writeFileSync(resolve(target, RECOVERED_RECEIPT), `${JSON.stringify({
145
+ schemaVersion: 1,
146
+ recoveredAt: new Date().toISOString(),
147
+ chunkFiles: manifest.chunkFiles,
148
+ words: 0,
149
+ ...options.receipt,
150
+ })}\n`, { encoding: 'utf8', mode: 0o600 })
151
+ } catch { /* the audio is quarantined either way; without the receipt it is merely listed as recoverable */ }
152
+ }
124
153
  return target
125
154
  } catch {
126
155
  // Rename failed (cross-device, permissions, race). Leave the source alone —
@@ -220,6 +249,7 @@ export function listUnsavedCaptures(
220
249
  } catch { continue }
221
250
  const manifest = readManifest(dirPath)
222
251
  const atMs = quarantinedAtMs(dirPath, manifest)
252
+ const ownRetentionMs = effectiveRetentionMs(manifest, retentionMs)
223
253
  captures.push({
224
254
  sessionId: manifest?.sessionId ?? dir.replace(/\.\d+$/, ''),
225
255
  dirName: dir,
@@ -228,7 +258,7 @@ export function listUnsavedCaptures(
228
258
  chunkFiles: manifest?.chunkFiles ?? countChunkWavs(dirPath),
229
259
  bytes: manifest?.bytes ?? dirBytes(dirPath),
230
260
  reason: manifest?.reason ?? null,
231
- expiresAt: atMs != null ? new Date(atMs + retentionMs).toISOString() : null,
261
+ expiresAt: atMs != null ? new Date(atMs + ownRetentionMs).toISOString() : null,
232
262
  recovered: existsSync(resolve(dirPath, RECOVERED_RECEIPT)),
233
263
  })
234
264
  }
@@ -248,9 +278,10 @@ export function purgeExpiredQuarantine(
248
278
  // sitting exactly at the retention boundary would otherwise lose its
249
279
  // audio mid-run, in the module whose thesis is "never delete evidence".
250
280
  if (isRecoveryActive(capture.dirName)) continue
251
- const atMs = capture.quarantinedAt ? Date.parse(capture.quarantinedAt) : NaN
252
- if (!Number.isFinite(atMs)) continue
253
- if (Date.now() - atMs > retentionMs) {
281
+ // The capture's own expiry: a discarded no-sound capture sets a shorter clock (6.83.2).
282
+ const expiresMs = capture.expiresAt ? Date.parse(capture.expiresAt) : NaN
283
+ if (!Number.isFinite(expiresMs)) continue
284
+ if (Date.now() > expiresMs) {
254
285
  try {
255
286
  rmSync(resolve(root, capture.dirName), { recursive: true, force: true })
256
287
  purged.push(capture.dirName)
@@ -433,7 +464,7 @@ function dispositionOf(sessionId: string, dirPath: string, retentionMs: number):
433
464
  quarantineReason: manifest?.reason ?? null,
434
465
  savedFilename,
435
466
  recovering: isRecoveryActive(basename(dirPath)),
436
- retainedUntil: atMs != null ? new Date(atMs + retentionMs).toISOString() : null,
467
+ retainedUntil: atMs != null ? new Date(atMs + effectiveRetentionMs(manifest, retentionMs)).toISOString() : null,
437
468
  }
438
469
  }
439
470
 
@@ -16,6 +16,7 @@ import crypto from 'node:crypto'
16
16
  import { batchTimestampsEnabled, parseNativeVadTimeline, remapBatchWords } from './whisper-batch-timing.js'
17
17
  import { readSttSettings, type FrontEndId } from './stt-settings.js'
18
18
  import { getVocabulary, getOwnerName, getWhisperCorrections } from './profile.js'
19
+ import { decoderSafeContext } from './glossary-learn.js'
19
20
  import { stripBrandUrls } from './hallucination-filter.js'
20
21
  import {
21
22
  batchHqMetalEnabled,
@@ -1243,8 +1244,9 @@ export async function transcribeHighQuality(
1243
1244
  }
1244
1245
  }
1245
1246
 
1246
- /** Build prompt with optional previous transcript context for continuity */
1247
- function buildPrompt(context?: string, isQuiet?: boolean): string {
1247
+ /** Build prompt with optional previous transcript context for continuity.
1248
+ * Exported (6.83.1) only so tests can assert on the exact decoder prompt. */
1249
+ export function buildPrompt(context?: string, isQuiet?: boolean): string {
1248
1250
  // During quiet/silence audio, skip vocabulary prompt to reduce decoder bias
1249
1251
  if (isQuiet) return ''
1250
1252
  if (!context || context.length < 10) return getWhisperPrompt()
@@ -1259,6 +1261,9 @@ function buildPrompt(context?: string, isQuiet?: boolean): string {
1259
1261
  // prior chunk would otherwise feed back through this context and re-seed the
1260
1262
  // next decode. Strip brand URLs from the priming context (output unaffected).
1261
1263
  if (STRIP_BRAND_URLS) sanitized = stripBrandUrls(sanitized)
1264
+ // 6.83.1: a correction-only form ("/pricing") came from the corrections pass, not the
1265
+ // speaker's audio; the decoder is primed with the spoken form instead (glossary-learn.ts).
1266
+ sanitized = decoderSafeContext(sanitized, getWhisperCorrections())
1262
1267
  // Previous transcript helps Whisper maintain proper noun consistency,
1263
1268
  // continue sentences across chunk boundaries, and reduce hallucinations
1264
1269
  return PROMPT_V2 ? `${getWhisperPrompt()} ${sanitized}` : `${getWhisperPrompt()}. ${sanitized}`
@@ -1379,14 +1384,34 @@ function buildCorrections(): Array<[RegExp, string]> {
1379
1384
 
1380
1385
  // Escape correction keys before interpolation. Names such as "A.C.M.E."
1381
1386
  // are literal vocabulary, never regular-expression programs.
1382
- for (const [pattern, replacement] of Object.entries(getWhisperCorrections())) {
1383
- const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1387
+ // 6.83.1: the words of a multi-word key may be split by a run of spaces or tabs, or by one
1388
+ // hyphen ("forward-slash pricing"); never by a line break or a spaced dash (" - ").
1389
+ for (const [pattern, replacement] of correctionOrder(getWhisperCorrections())) {
1390
+ const escaped = pattern.trim().split(/\s+/).map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('(?:[ \\t]+|-)')
1384
1391
  corrections.push([new RegExp(`\\b${escaped}\\b`, 'gi'), replacement])
1385
1392
  }
1386
1393
 
1387
1394
  return corrections
1388
1395
  }
1389
1396
 
1397
+ /**
1398
+ * The profile's order, except that a key goes ahead of any earlier key whose words it
1399
+ * contains (6.83.1): "forward slash build and price" runs before "forward slash build", or
1400
+ * the shorter key would cut the longer phrase short. Chains the person relies on
1401
+ * ("Myles" then "Miles Ukoma") keep their order, since neither key contains the other.
1402
+ */
1403
+ export function correctionOrder(corrections: Record<string, string>): Array<[string, string]> {
1404
+ const norm = (key: string) => ` ${key.trim().toLowerCase().split(/[\s-]+/).join(' ')} `
1405
+ const out: Array<[string, string]> = []
1406
+ for (const entry of Object.entries(corrections)) {
1407
+ const mine = norm(entry[0])
1408
+ const at = out.findIndex(([key]) => mine !== norm(key) && mine.includes(norm(key)))
1409
+ if (at < 0) out.push(entry)
1410
+ else out.splice(at, 0, entry)
1411
+ }
1412
+ return out
1413
+ }
1414
+
1390
1415
  let _corrections: Array<[RegExp, string]> | null = null
1391
1416
  function getCorrections(): Array<[RegExp, string]> {
1392
1417
  if (!_corrections) _corrections = buildCorrections()
@@ -1399,7 +1424,8 @@ function getCorrections(): Array<[RegExp, string]> {
1399
1424
  export function applyCorrections(text: string): string {
1400
1425
  let result = text
1401
1426
  for (const [pattern, replacement] of getCorrections()) {
1402
- result = result.replace(pattern, replacement)
1427
+ // A function, so a written form holding "$&" or "$1" is inserted literally (6.83.1).
1428
+ result = result.replace(pattern, () => replacement)
1403
1429
  }
1404
1430
  return result
1405
1431
  }