@gotcos/glasses-server 6.1.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 (51) hide show
  1. package/.cos-profile.example.json +7 -0
  2. package/.env.example +44 -0
  3. package/CHANGELOG.md +25 -0
  4. package/LICENSE +21 -0
  5. package/README.md +78 -0
  6. package/bin/cli.cjs +203 -0
  7. package/package.json +53 -0
  8. package/server/env.ts +26 -0
  9. package/server/index.ts +211 -0
  10. package/server/lib/archive-budget.ts +65 -0
  11. package/server/lib/archive.ts +414 -0
  12. package/server/lib/atomic-fs.ts +50 -0
  13. package/server/lib/audio-enhance.ts +87 -0
  14. package/server/lib/claude-bridge.ts +682 -0
  15. package/server/lib/claude-circuit.ts +52 -0
  16. package/server/lib/claude-run-ledger.ts +279 -0
  17. package/server/lib/codex-bridge.ts +476 -0
  18. package/server/lib/codex-engine-sessions.ts +140 -0
  19. package/server/lib/codex-run-ledger.ts +298 -0
  20. package/server/lib/context-builder.ts +210 -0
  21. package/server/lib/conversation.ts +587 -0
  22. package/server/lib/data-dir.ts +20 -0
  23. package/server/lib/display-bus.ts +21 -0
  24. package/server/lib/display-format.ts +23 -0
  25. package/server/lib/fuzzy-correct.ts +286 -0
  26. package/server/lib/hallucination-filter.ts +469 -0
  27. package/server/lib/local-day.ts +13 -0
  28. package/server/lib/model-router.ts +38 -0
  29. package/server/lib/openai-key.ts +155 -0
  30. package/server/lib/openai-whisper-budget.ts +170 -0
  31. package/server/lib/profile.ts +94 -0
  32. package/server/lib/python-bridge.ts +84 -0
  33. package/server/lib/response-cache.ts +138 -0
  34. package/server/lib/session-cache-writer.ts +266 -0
  35. package/server/lib/session-log.ts +162 -0
  36. package/server/lib/speaker-embeddings.ts +578 -0
  37. package/server/lib/telegram-notify.ts +85 -0
  38. package/server/lib/token-audit.ts +50 -0
  39. package/server/lib/transcribe-audio.ts +187 -0
  40. package/server/lib/utils.ts +5 -0
  41. package/server/lib/vad-silero.ts +179 -0
  42. package/server/lib/whisper-local.ts +697 -0
  43. package/server/routes/diag.ts +115 -0
  44. package/server/routes/display.ts +65 -0
  45. package/server/routes/health.ts +128 -0
  46. package/server/routes/openai-compat.ts +446 -0
  47. package/server/routes/openai-key.ts +121 -0
  48. package/server/routes/query.ts +121 -0
  49. package/server/routes/transcribe-stream.ts +1090 -0
  50. package/server/routes/transcribe.ts +55 -0
  51. package/shared/model-preference.ts +81 -0
@@ -0,0 +1,469 @@
1
+ // Whisper hallucination detection + stripping.
2
+ // Extracted from transcribe-stream.ts so /api/transcribe (one-shot message query)
3
+ // can apply the same filters the streaming meeting path uses.
4
+ //
5
+ // Two surfaces:
6
+ // stripInlineHallucinations(text, sessionId) — streaming path; maintains per-session
7
+ // frequency map and promotes "Name:" patterns to a blocklist after N chunks.
8
+ // stripInlineHallucinationsOneShot(text) — one-shot path; applies sound-descriptor
9
+ // + known-name stripping WITHOUT per-session state (no learning benefit on a
10
+ // single-chunk request).
11
+ // isFullHallucination(text) — returns true if the text IS a hallucination in its
12
+ // entirety (silence artifacts, caption training, foreign script, filler-only).
13
+
14
+ import { getNegativeRules } from './profile.js'
15
+
16
+ // ── Whole-chunk silence hallucinations ─────────────────────────────────────
17
+ const KNOWN_HALLUCINATIONS = [
18
+ /^subtitles?\s+by\b/i,
19
+ /\blike\s+and\s+subscribe\b/i,
20
+ /\bthanks?\s+for\s+watching\b/i,
21
+ /\bplease\s+subscribe\b/i,
22
+ /\bdon'?t\s+forget\s+to\s+subscribe\b/i,
23
+ /\bsee\s+you\s+(next|in\s+the)\b/i,
24
+ /\bthe\s+end\.?\s*$/i,
25
+ /^\s*\*[^*\n]{1,40}\*\s*$/,
26
+ /^\s*\[[^\]\n]{1,40}\]\s*$/,
27
+ /^\s*♪+\s*[^♪\n]{0,40}\s*♪+\s*$/,
28
+ ]
29
+
30
+ // ── Inline "Name:" detector (session-aware in streaming, static in one-shot) ──
31
+ const INLINE_HALLUCINATION_THRESHOLD = 3
32
+ // Seed list of known Whisper "Name:" training artifacts. Empty by default — the
33
+ // streaming path auto-learns repeated artifacts after N chunks, and users can add
34
+ // their own via the negative-rules glossary (see applyNegativeRules below).
35
+ const KNOWN_INLINE_HALLUCINATIONS = new Set<string>()
36
+
37
+ const inlineNameFrequency = new Map<string, Map<string, number>>()
38
+ const inlineBlocklist = new Map<string, Set<string>>()
39
+
40
+ /**
41
+ * Release per-session hallucination state. Call when a session ends or during
42
+ * periodic cleanup sweeps so long-running processes don't leak memory.
43
+ */
44
+ export function clearSessionHallucinationState(sessionId: string): void {
45
+ inlineNameFrequency.delete(sessionId)
46
+ inlineBlocklist.delete(sessionId)
47
+ }
48
+
49
+ const INLINE_NAME_PATTERN = /\b([A-Z][a-z]+\s+[A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s*:/g
50
+
51
+ // ── Sound descriptor hallucinations (caption training leakage) ────────────
52
+ const SOUND_DESCRIPTORS = [
53
+ 'music', 'sad music', 'upbeat music', 'dramatic music', 'tense music',
54
+ 'soft music', 'somber music', 'gentle music', 'soothing music', 'slow music',
55
+ 'music playing', 'music fades', 'music continues', 'music stops', 'music ends',
56
+ 'applause', 'laughter', 'laughs', 'laughing', 'cheering', 'clapping',
57
+ 'sighs', 'sighing', 'coughs', 'coughing', 'breathing', 'sneezes',
58
+ 'silence', 'inaudible', 'indistinct', 'crosstalk', 'no speech',
59
+ 'blank audio', 'blank_audio', 'no_speech',
60
+ 'background noise', 'crowd noise', 'crowd cheering', 'crowd chatter',
61
+ 'indistinct chatter', 'door closes', 'door opens', 'phone rings',
62
+ 'typing', 'keyboard typing', 'footsteps',
63
+ ]
64
+ const SOUND_DESCRIPTOR_ALT = SOUND_DESCRIPTORS.map(s => s.replace(/\s+/g, '\\s+')).join('|')
65
+ const SOUND_DESCRIPTOR_PATTERN = new RegExp(
66
+ `[*\\[(♪]\\s*(?:${SOUND_DESCRIPTOR_ALT})\\s*[*\\])♪]`,
67
+ 'gi'
68
+ )
69
+ const ASTERISK_CAPTION = /\*\s*[a-z][a-z'\s]{0,35}[a-z]\s*\*/g
70
+
71
+ function stripSoundDescriptors(text: string): string {
72
+ let cleaned = text.replace(SOUND_DESCRIPTOR_PATTERN, '')
73
+ cleaned = cleaned.replace(ASTERISK_CAPTION, '')
74
+ return cleaned
75
+ }
76
+
77
+ // ── Prompt dictation artifacts ─────────────────────────────────────────────
78
+ // Whisper sometimes fills short/silent dictation chunks with caption-training
79
+ // residue. Configure brand/vocab domains you want treated as silence artifacts
80
+ // here (e.g. company domains Whisper hallucinates during silence); empty by
81
+ // default. Generic one-shot dictation always preserves user-spoken URLs.
82
+ const URL_ARTIFACT_DOMAINS: string[] = []
83
+ const URL_ARTIFACT_PATTERN = URL_ARTIFACT_DOMAINS.length > 0
84
+ ? new RegExp(
85
+ `\\b(?:https?:\\/\\/)?(?:www\\.)?(?:${URL_ARTIFACT_DOMAINS
86
+ .map(domain => domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
87
+ .join('|')})(?:\\/\\S*)?\\b`,
88
+ 'gi',
89
+ )
90
+ : /(?!)/gi // no domains configured → never matches (standalone default)
91
+
92
+ // Generic URL detector — ANY domain, not just brand vocab. Used ONLY to decide if a
93
+ // whole chunk/line is nothing but a URL (the silence-hallucination shape: patreon.com,
94
+ // plastics-car.com, youtube.com, etc.). Never used to strip mid-sentence. Requires an
95
+ // explicit https://|www. prefix OR a common TLD so "U.S.", "e.g.", "v5.9.72", "a.m."
96
+ // are NOT matched as URLs.
97
+ const GENERIC_URL_PATTERN = new RegExp(
98
+ '\\b(?:https?:\\/\\/|www\\.)[^\\s]+' +
99
+ '|\\b[a-z0-9][a-z0-9-]*(?:\\.[a-z0-9-]+)*\\.(?:com|org|net|io|co|gov|edu|us|uk|tv|me|app|dev|ai|info|biz|store|shop|online|ing)\\b(?:\\/\\S*)?',
100
+ 'gi',
101
+ )
102
+ const PROMPT_DICTATION_ARTIFACTS: RegExp[] = [
103
+ /\bTranscript\s+by\s+Rev\.com\b(?:\s+Page\s+(?:of|\d+))*\.?/gi,
104
+ /\bThanks?\s+for\s+watching!?/gi,
105
+ /\bThank\s+you\s+for\s+watching!?/gi,
106
+ ]
107
+ const PROMPT_DICTATION_ARTIFACT_CONTEXT = /\b(?:Transcript\s+by\s+Rev\.com|Thanks?\s+for\s+watching|Thank\s+you\s+for\s+watching)\b/i
108
+
109
+ function knownDomainMatches(text: string): string[] {
110
+ return Array.from(text.matchAll(URL_ARTIFACT_PATTERN))
111
+ .map(match => match[0]
112
+ .toLowerCase()
113
+ .replace(/^https?:\/\//, '')
114
+ .replace(/^www\./, '')
115
+ .replace(/\/.*$/, ''))
116
+ }
117
+
118
+ function shouldStripKnownDomainArtifacts(text: string): boolean {
119
+ const domains = knownDomainMatches(text)
120
+ if (domains.length === 0) return false
121
+ if (PROMPT_DICTATION_ARTIFACT_CONTEXT.test(text)) return true
122
+
123
+ const counts = new Map<string, number>()
124
+ for (const domain of domains) counts.set(domain, (counts.get(domain) ?? 0) + 1)
125
+ if ([...counts.values()].some(count => count >= 2)) return true
126
+
127
+ const nonUrlWords = text
128
+ .replace(URL_ARTIFACT_PATTERN, ' ')
129
+ .toLowerCase()
130
+ .replace(/[.!?,;:'"()\-\n]/g, ' ')
131
+ .split(/\s+/)
132
+ .filter(Boolean)
133
+ return domains.length >= 3 && nonUrlWords.length <= 10
134
+ }
135
+
136
+ export function stripPromptDictationArtifacts(text: string): string {
137
+ let cleaned = text
138
+ for (const re of PROMPT_DICTATION_ARTIFACTS) cleaned = cleaned.replace(re, ' ')
139
+ if (shouldStripKnownDomainArtifacts(text)) cleaned = cleaned.replace(URL_ARTIFACT_PATTERN, ' ')
140
+ cleaned = cleaned
141
+ .replace(/\s+([,.!?;:])/g, '$1')
142
+ .replace(/(?:^|\s+)[,.!?;:](?=\s+|$)/g, ' ')
143
+ .replace(/\s{2,}/g, ' ')
144
+ .trim()
145
+ return cleaned
146
+ }
147
+
148
+ /**
149
+ * Streaming variant. Maintains per-session "Name:" frequency/blocklist.
150
+ */
151
+ export function stripInlineHallucinations(text: string, sessionId: string): string {
152
+ if (!text) return text
153
+
154
+ let cleaned = stripSoundDescriptors(text)
155
+
156
+ if (!inlineNameFrequency.has(sessionId)) inlineNameFrequency.set(sessionId, new Map())
157
+ if (!inlineBlocklist.has(sessionId)) inlineBlocklist.set(sessionId, new Set(KNOWN_INLINE_HALLUCINATIONS))
158
+ const freq = inlineNameFrequency.get(sessionId)!
159
+ const blocklist = inlineBlocklist.get(sessionId)!
160
+
161
+ const namesInChunk = new Set<string>()
162
+ let match: RegExpExecArray | null
163
+ const patternCopy = new RegExp(INLINE_NAME_PATTERN.source, INLINE_NAME_PATTERN.flags)
164
+ while ((match = patternCopy.exec(cleaned)) !== null) {
165
+ const norm = match[1].toLowerCase()
166
+ if (!namesInChunk.has(norm)) {
167
+ namesInChunk.add(norm)
168
+ const count = (freq.get(norm) ?? 0) + 1
169
+ freq.set(norm, count)
170
+ if (count >= INLINE_HALLUCINATION_THRESHOLD && !blocklist.has(norm)) {
171
+ blocklist.add(norm)
172
+ console.log(`[hallucination] Inline name auto-blocked after ${count} chunks (${match[1].length} chars)`)
173
+ }
174
+ }
175
+ }
176
+
177
+ for (const blocked of blocklist) {
178
+ const escaped = blocked.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
179
+ const stripRe = new RegExp(`\\b${escaped}\\s*:\\s*`, 'gi')
180
+ cleaned = cleaned.replace(stripRe, '')
181
+ }
182
+
183
+ cleaned = cleaned.replace(/\s{2,}/g, ' ').trim()
184
+
185
+ if (cleaned !== text.trim()) {
186
+ console.log(`[hallucination] Inline strip: ${text.trim().length} chars → ${cleaned.length} chars`)
187
+ }
188
+
189
+ return cleaned
190
+ }
191
+
192
+ /**
193
+ * One-shot variant (e.g., /api/transcribe for ASK voice).
194
+ * Strips sound descriptors + well-known inline names. No per-session learning —
195
+ * a single request has no history to build a frequency map from.
196
+ */
197
+ export function stripInlineHallucinationsOneShot(text: string): string {
198
+ if (!text) return text
199
+ let cleaned = stripSoundDescriptors(text)
200
+ for (const blocked of KNOWN_INLINE_HALLUCINATIONS) {
201
+ const escaped = blocked.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
202
+ const stripRe = new RegExp(`\\b${escaped}\\s*:\\s*`, 'gi')
203
+ cleaned = cleaned.replace(stripRe, '')
204
+ }
205
+ cleaned = cleaned.replace(/\s{2,}/g, ' ').trim()
206
+ if (cleaned !== text.trim()) {
207
+ console.log(`[hallucination] One-shot strip: ${text.trim().length} chars → ${cleaned.length} chars`)
208
+ }
209
+ return cleaned
210
+ }
211
+
212
+ // ── Full-chunk hallucination detector ──────────────────────────────────────
213
+ const FILLER_WORDS = new Set([
214
+ 'you', 'so', 'and', 'but', 'well', 'okay', 'oh', 'uh', 'um', 'right',
215
+ 'yeah', 'yes', 'no', 'the', 'a', 'i', 'it', 'is', 'was', 'we', 'they',
216
+ 'he', 'she', 'that', 'this', 'to', 'of', 'in', 'for', 'on', 'do',
217
+ ])
218
+
219
+ /**
220
+ * Returns true if the text as a whole is a known hallucination class
221
+ * (silence artifacts, caption training, foreign script, filler-only, low entropy).
222
+ * Callers typically return 204 / drop the chunk when this is true.
223
+ */
224
+ export function isFullHallucination(text: string): boolean {
225
+ if (!text || !text.trim()) return true
226
+
227
+ for (const re of KNOWN_HALLUCINATIONS) {
228
+ if (re.test(text)) return true
229
+ }
230
+ // Foreign script in English-only context (CJK, Arabic, Devanagari)
231
+ if (/[ -鿿؀-ۿऀ-ॿ]/.test(text)) return true
232
+
233
+ const clean = text.toLowerCase().replace(/[.!?,;:'"()\-\n]/g, '').replace(/\s+/g, ' ').trim()
234
+ const words = clean.split(' ').filter(w => w)
235
+ if (words.length === 0) return true
236
+
237
+ const thankMatches = clean.match(/thank(?:s|\s*you)/g)
238
+ if (thankMatches && thankMatches.length >= 3) return true
239
+
240
+ // Filler-only chunk threshold raised 2 → 6 on 2026-04-25 after audit.
241
+ // At length 2, this killed legitimate sentence-starts: "Yeah, this is" →
242
+ // ['yeah','this','is'] all in FILLER_WORDS → dropped. At 6+ words of pure
243
+ // fillers, the chunk is almost certainly a hallucination loop ("yeah yeah
244
+ // yeah yeah yeah yeah") because real conversational sentences of 6+ short
245
+ // words almost always include at least one content word.
246
+ if (words.length >= 6 && words.every(w => FILLER_WORDS.has(w))) return true
247
+
248
+ if (words.length >= 15) {
249
+ const unique = new Set(words)
250
+ if (unique.size / words.length < 0.3) return true
251
+ }
252
+
253
+ return false
254
+ }
255
+
256
+ // ── Brand-URL silence helpers ──────────────────────────────────────────────
257
+ // Reuse the URL_ARTIFACT_PATTERN / URL_ARTIFACT_DOMAINS above (the configured
258
+ // brand/vocab domains, empty by default). These power the streaming + final-transcript silence cleanup.
259
+ //
260
+ // IMPORTANT contract distinction (3 coexisting URL surfaces — keep coherent):
261
+ // stripPromptDictationArtifacts() — prompt-draft path, context-GATED strip.
262
+ // stripBrandUrls() — UNCONDITIONAL brand-URL removal. Used ONLY
263
+ // for prompt-CONTEXT hygiene (decoder priming), never to mutate stored output.
264
+ // isBrandUrlOnly() — true iff a chunk/line is NOTHING but brand
265
+ // URLs. Used to DROP whole chunks/lines (output), so real speech that merely
266
+ // mentions a brand URL ("go to example.com later") is never altered.
267
+
268
+ /** Remove brand-vocab URL tokens unconditionally. Output-safe ONLY for decoder
269
+ * priming context — do not use to rewrite a stored transcript line (use the
270
+ * drop path via isBrandUrlOnly instead, which preserves mixed real speech). */
271
+ export function stripBrandUrls(text: string): string {
272
+ if (!text) return text
273
+ let cleaned = text.replace(URL_ARTIFACT_PATTERN, ' ')
274
+ cleaned = cleaned
275
+ .replace(/\s+([,.!?;:])/g, '$1')
276
+ .replace(/(?:^|\s+)[,.!?;:](?=\s+|$)/g, ' ')
277
+ .replace(/\s{2,}/g, ' ')
278
+ .trim()
279
+ return cleaned
280
+ }
281
+
282
+ /** True iff the text is non-empty and contains nothing but brand URLs (plus
283
+ * punctuation/whitespace). The classic silence hallucination shape
284
+ * ("www.example.com www.acme.com"). Mixed speech returns false. */
285
+ export function isBrandUrlOnly(text: string): boolean {
286
+ if (!text || !text.trim()) return false
287
+ return !/[a-z0-9]/i.test(stripBrandUrls(text))
288
+ }
289
+
290
+ /** True iff the text is non-empty and contains nothing but URL(s) of ANY domain
291
+ * (plus punctuation/whitespace) — e.g. "https://www.patreon.com", "plastics-car.com".
292
+ * Superset of isBrandUrlOnly. Mixed speech ("go to patreon.com later") returns false. */
293
+ export function isUrlOnly(text: string): boolean {
294
+ if (!text || !text.trim()) return false
295
+ return !/[a-z0-9]/i.test(text.replace(GENERIC_URL_PATTERN, ' '))
296
+ }
297
+
298
+ /** True iff the text is ONLY repeated thanks (the "Thank you! Thank you!" silence
299
+ * artifact). Tighter than isFullHallucination's >=3 rule so it can catch the 2x
300
+ * case — callers MUST AND-gate this with a silence signal (isQuiet) so a genuine
301
+ * soft closing is never dropped. Guards: >=2 thanks, zero content words, <=4 words.
302
+ * "No, thank you. Thank you." (5 words) and "thanks, appreciate it" (content word)
303
+ * both return false. */
304
+ export function isRepeatedThankYouOnly(text: string): boolean {
305
+ if (!text) return false
306
+ const clean = text.toLowerCase().replace(/[.!?,;:'"()\-\n]/g, '').replace(/\s+/g, ' ').trim()
307
+ const words = clean.split(' ').filter(Boolean)
308
+ if (words.length === 0 || words.length > 4) return false
309
+ const thankMatches = clean.match(/thank(?:s|\s*you)/g)
310
+ if (!thankMatches || thankMatches.length < 2) return false
311
+ const content = words.filter(w => w !== 'thank' && w !== 'thanks' && w !== 'you' && !FILLER_WORDS.has(w))
312
+ return content.length === 0
313
+ }
314
+
315
+ /** Streaming-chunk silence-drop decision. Pure + exported so the gate is testable
316
+ * (sanitizeStreamTranscript is private). Returns a fallbackReason or null. Contract:
317
+ * brand-URL-only -> 'brand_url' DROP ALWAYS — brand URLs are vocab-seeded,
318
+ * never a real standalone meeting utterance.
319
+ * any-URL-only -> 'url_silence' DROP only when isQuiet — a clearly-dictated
320
+ * third-party URL during speech is preserved.
321
+ * thank-you-only -> 'thankyou_silence' DROP only when isQuiet — soft real closings
322
+ * stay (see isRepeatedThankYouOnly).
323
+ * Real speech (any chunk with content words) always returns null. */
324
+ export function streamSilenceDropReason(text: string, isQuiet: boolean): 'brand_url' | 'url_silence' | 'thankyou_silence' | null {
325
+ if (!text || !text.trim()) return null
326
+ if (isBrandUrlOnly(text)) return 'brand_url'
327
+ if (isQuiet && isUrlOnly(text)) return 'url_silence'
328
+ if (isQuiet && isRepeatedThankYouOnly(text)) return 'thankyou_silence'
329
+ return null
330
+ }
331
+
332
+ // ── Editable negative / cleanup rules (glossary-authored) ──────────────────
333
+ // One rule per line, authored via the glossary PUT:
334
+ // whole:<text> drop any LINE containing <text>
335
+ // <text> (bare) same as whole:
336
+ // strip:<text> remove <text> from the line, keep the rest
337
+ // replace:<bad>=><good> substitute <bad> with <good>
338
+ // flag:<text> no-op on text (reserved marker for review surfaces)
339
+ // #... comment / ignored
340
+ // Patterns match LITERALLY (escaped) with word boundaries, case-insensitive —
341
+ // user input never reaches the regex engine as metacharacters, so there is no
342
+ // ReDoS surface. Applied ONLY on non-live surfaces (final meeting save +
343
+ // outbound dictation finalize), NEVER on the live per-chunk decode path.
344
+ interface NegRule {
345
+ kind: 'whole' | 'strip' | 'replace' | 'flag'
346
+ test?: RegExp // non-global — whole/flag line matching
347
+ search?: RegExp // global — strip/replace substitution
348
+ replacement: string
349
+ }
350
+
351
+ function escapeRegexLiteral(s: string): string {
352
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
353
+ }
354
+
355
+ // Literal matcher with conditional word boundaries: a \b is added only on an
356
+ // edge that is a word character, so word-ish patterns ("um") don't match inside
357
+ // other words ("summary") while punctuation patterns ("(a+)+", ".com") still
358
+ // match. The body is escaped, so user input never injects regex metacharacters.
359
+ function literalMatcher(body: string, global: boolean): RegExp {
360
+ const lead = /^\w/.test(body) ? '\\b' : ''
361
+ const trail = /\w$/.test(body) ? '\\b' : ''
362
+ return new RegExp(`${lead}${escapeRegexLiteral(body)}${trail}`, global ? 'gi' : 'i')
363
+ }
364
+
365
+ export function parseNegativeRules(raw: string[]): NegRule[] {
366
+ const out: NegRule[] = []
367
+ for (const lineRaw of raw) {
368
+ const line = (lineRaw || '').trim()
369
+ if (!line || line.startsWith('#')) continue
370
+ let kind: NegRule['kind'] = 'whole'
371
+ let body = line
372
+ const colon = line.indexOf(':')
373
+ if (colon > 0) {
374
+ const prefix = line.slice(0, colon).toLowerCase()
375
+ if (prefix === 'whole' || prefix === 'strip' || prefix === 'replace' || prefix === 'flag') {
376
+ kind = prefix
377
+ body = line.slice(colon + 1).trim()
378
+ }
379
+ }
380
+ if (!body) continue
381
+ try {
382
+ if (kind === 'replace') {
383
+ const arrow = body.indexOf('=>')
384
+ if (arrow < 0) continue
385
+ const bad = body.slice(0, arrow).trim()
386
+ const good = body.slice(arrow + 2).trim()
387
+ if (!bad) continue
388
+ out.push({ kind, search: literalMatcher(bad, true), replacement: good })
389
+ } else if (kind === 'strip') {
390
+ out.push({ kind, search: literalMatcher(body, true), replacement: '' })
391
+ } else {
392
+ out.push({ kind, test: literalMatcher(body, false), replacement: '' })
393
+ }
394
+ } catch { /* escaped input shouldn't throw; skip defensively */ }
395
+ }
396
+ return out
397
+ }
398
+
399
+ /** Apply editable negative rules to text. Drops whole lines (whole/bare),
400
+ * strips/replaces inline, ignores flag. Per-rule try/catch so one bad rule
401
+ * can't break the pass. Never empties a non-empty input (mirrors
402
+ * cleanTranscriptLines). `rawRules` defaults to the profile's negative_rules. */
403
+ export function applyNegativeRules(text: string, rawRules?: string[]): string {
404
+ if (!text) return text
405
+ const rules = parseNegativeRules(rawRules ?? getNegativeRules())
406
+ if (rules.length === 0) return text
407
+ const kept: string[] = []
408
+ for (const line of text.split('\n')) {
409
+ let working = line
410
+ let dropped = false
411
+ for (const r of rules) {
412
+ try {
413
+ if (r.kind === 'whole') {
414
+ if (r.test!.test(working)) { dropped = true; break }
415
+ } else if (r.kind === 'strip' || r.kind === 'replace') {
416
+ working = working.replace(r.search!, r.replacement)
417
+ }
418
+ // 'flag' — no text mutation (reserved for review surfaces)
419
+ } catch { /* one bad rule never breaks the whole pass */ }
420
+ }
421
+ if (dropped) continue
422
+ if (working !== line) working = working.replace(/[ \t]{2,}/g, ' ').trim()
423
+ kept.push(working)
424
+ }
425
+ const cleaned = kept.join('\n')
426
+ return cleaned.trim() ? cleaned : text // never empty a non-empty input
427
+ }
428
+
429
+ /** Validate one user-authored rule line for the glossary PUT. Patterns are
430
+ * literal, so the only failures are structural (oversized / malformed replace /
431
+ * empty body). Returns ok or an error string (route rejects with 400). */
432
+ export function validateNegativeRule(lineRaw: string): { ok: true } | { ok: false; error: string } {
433
+ const line = (lineRaw || '').trim()
434
+ if (!line || line.startsWith('#')) return { ok: true }
435
+ if (line.length > 200) return { ok: false, error: 'rule too long (max 200 chars)' }
436
+ const colon = line.indexOf(':')
437
+ const prefix = colon > 0 ? line.slice(0, colon).toLowerCase() : ''
438
+ if (prefix === 'whole' || prefix === 'strip' || prefix === 'replace' || prefix === 'flag') {
439
+ const body = line.slice(colon + 1).trim()
440
+ if (!body) return { ok: false, error: `"${prefix}:" rule has an empty body` }
441
+ if (prefix === 'replace' && body.indexOf('=>') < 0) return { ok: false, error: 'replace rule must be "replace:bad=>good"' }
442
+ }
443
+ return { ok: true }
444
+ }
445
+
446
+ /** Final-transcript cleanup for the saved meeting markdown. First applies the
447
+ * editable negative rules, then drops a LINE only when its content (with any
448
+ * "[Speaker]:" label stripped) is NOTHING but URL(s) of any domain — the
449
+ * saved-notes silence artifact (brand URLs AND patreon/youtube/etc.). Real
450
+ * sentences are never touched, even if they mention a URL ("go to example.com
451
+ * later" is kept), and blank lines / speaker labels are preserved. Deliberately
452
+ * does NOT run isFullHallucination here: that filter's caption regexes ("see you
453
+ * next", "the end", "thanks for watching") match legitimate meeting closings, and
454
+ * the final batch/correction text — unlike streaming chunks — has no audio-volume
455
+ * context to gate them safely. Returns the original transcript unchanged if
456
+ * cleaning would empty a non-empty input (degenerate guard — never write blank). */
457
+ export function cleanTranscriptLines(transcript: string): string {
458
+ if (!transcript || !transcript.trim()) return transcript
459
+ const afterRules = applyNegativeRules(transcript) // whole/strip/replace first
460
+ const kept: string[] = []
461
+ for (const line of afterRules.split('\n')) {
462
+ const m = line.match(/^(\[[^\]]+\]:\s*)?([\s\S]*)$/)
463
+ const content = (m?.[2] ?? line).trim()
464
+ if (content && isUrlOnly(content)) continue // drop URL-only line (label included)
465
+ kept.push(line)
466
+ }
467
+ const cleaned = kept.join('\n').trim()
468
+ return cleaned ? cleaned : transcript
469
+ }
@@ -0,0 +1,13 @@
1
+ // Local-timezone YYYY-MM-DD helper.
2
+ // Replaces `new Date(...).toISOString().slice(0,10)` which is UTC — that
3
+ // makes CDT/PST users see their own late-evening sessions archived under
4
+ // "tomorrow" from their POV. We key archives and "today" filters off the
5
+ // server's local timezone so the user's sense of "today" matches what they see.
6
+
7
+ export function localDay(ts: number = Date.now()): string {
8
+ const d = new Date(ts)
9
+ const y = d.getFullYear()
10
+ const m = String(d.getMonth() + 1).padStart(2, '0')
11
+ const day = String(d.getDate()).padStart(2, '0')
12
+ return `${y}-${m}-${day}`
13
+ }
@@ -0,0 +1,38 @@
1
+ import { callClaudeStreaming, type CallOptions, type StreamCallbacks } from './claude-bridge.js'
2
+ import { callCodexStreaming } from './codex-bridge.js'
3
+ import {
4
+ getOrCreateSession,
5
+ getSessionModel,
6
+ setSessionModel,
7
+ type ModelPreference,
8
+ type PromptReference,
9
+ } from './conversation.js'
10
+ import { DEFAULT_MODEL, isCodexModel, isClaudeModel, normalizeModelPreference } from '../../shared/model-preference.js'
11
+
12
+ // Chat routes to the user's local Claude Code CLI (opus/sonnet/haiku) or the
13
+ // Codex CLI (codex-high). Any unknown preference falls back to the Claude default
14
+ // so chat always works on a stock install.
15
+ export async function callModelStreaming(
16
+ query: string,
17
+ sessionId: string | undefined,
18
+ callbacks: StreamCallbacks,
19
+ model?: ModelPreference,
20
+ images?: string[],
21
+ reference?: PromptReference,
22
+ globalMsgNum?: number,
23
+ options?: CallOptions,
24
+ ): Promise<string> {
25
+ const sid = getOrCreateSession(sessionId)
26
+ const sessionModel = getSessionModel(sid)
27
+ const resolvedModel = normalizeModelPreference(model) ?? sessionModel ?? DEFAULT_MODEL
28
+
29
+ setSessionModel(sid, resolvedModel)
30
+
31
+ if (isCodexModel(resolvedModel)) {
32
+ return callCodexStreaming(query, sid, callbacks, resolvedModel, images, reference, globalMsgNum, options)
33
+ }
34
+ if (isClaudeModel(resolvedModel)) {
35
+ return callClaudeStreaming(query, sid, callbacks, resolvedModel, images, reference, globalMsgNum, options)
36
+ }
37
+ return callClaudeStreaming(query, sid, callbacks, DEFAULT_MODEL, images, reference, globalMsgNum, options)
38
+ }
@@ -0,0 +1,155 @@
1
+ // Shared OpenAI API key resolver — used by Whisper (transcribe + transcribe-stream)
2
+ // and TTS (voice output). Single source of truth so we don't drift across callers.
3
+ //
4
+ // Resolution order (v5.9.5 — phone-set key support added between env and .env):
5
+ // 1. process.env.OPENAI_API_KEY — admin override / .env / shell. Wins.
6
+ // 2. server/data/openai-key.json — written by POST /api/openai-key/set,
7
+ // shape: { key, savedAt, validatedAt }.
8
+ // New in v5.9.5 — lets the phone Settings
9
+ // panel configure the key without ever
10
+ // editing a .env file.
11
+ // 3. COS_SCRIPTS_DIR/.env regex — legacy fallback for COS pipeline mode.
12
+ //
13
+ // Cached after first successful resolution. clearCachedKey() is exposed so the
14
+ // new POST/DELETE handlers can force a re-read after the user updates the key.
15
+ //
16
+ // Throws if no key is reachable so callers can choose to surface a clean
17
+ // 401/503 instead of a network attempt.
18
+
19
+ import { existsSync, readFileSync } from 'node:fs'
20
+ import { dirname, resolve } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+ import { COS_SCRIPTS_DIR } from './python-bridge.js'
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url))
25
+
26
+ /** Path to the phone-set key file. Lives next to other server-managed JSON
27
+ * state in server/data/. The directory is gitignored so the key never lands
28
+ * in version control. */
29
+ import { dataPath } from './data-dir.js'
30
+ export const KEY_FILE_PATH = dataPath('openai-key.json')
31
+
32
+ /** Source of the currently-resolved key. Useful for diagnostics + the
33
+ * Settings status display ("Active (env)" vs "Active (saved Apr 28)"). */
34
+ export type KeySource = 'env' | 'config' | 'scripts-env' | 'none'
35
+
36
+ interface KeyConfigFile {
37
+ key: string
38
+ savedAt: string
39
+ validatedAt?: string
40
+ }
41
+
42
+ interface ResolvedKey {
43
+ key: string
44
+ source: Exclude<KeySource, 'none'>
45
+ /** ISO timestamp the file was first written (only set when source==='config'). */
46
+ savedAt?: string
47
+ /** ISO timestamp of the last successful /v1/models validation
48
+ * (only set when source==='config'). */
49
+ validatedAt?: string
50
+ }
51
+
52
+ let cachedResolution: ResolvedKey | null = null
53
+
54
+ /** Clear the in-process cache so the next getOpenAIKey() / tryGetOpenAIKey()
55
+ * call re-resolves from disk + env. Call after writing or deleting
56
+ * server/data/openai-key.json. */
57
+ export function clearCachedKey(): void {
58
+ cachedResolution = null
59
+ }
60
+
61
+ function readConfigFile(): KeyConfigFile | null {
62
+ if (!existsSync(KEY_FILE_PATH)) return null
63
+ try {
64
+ const raw = readFileSync(KEY_FILE_PATH, 'utf-8')
65
+ const parsed = JSON.parse(raw) as Partial<KeyConfigFile>
66
+ if (!parsed || typeof parsed.key !== 'string' || !parsed.key.trim()) return null
67
+ return {
68
+ key: parsed.key.trim(),
69
+ savedAt: typeof parsed.savedAt === 'string' ? parsed.savedAt : new Date().toISOString(),
70
+ validatedAt: typeof parsed.validatedAt === 'string' ? parsed.validatedAt : undefined,
71
+ }
72
+ } catch {
73
+ return null
74
+ }
75
+ }
76
+
77
+ function resolveFromScratch(): ResolvedKey | null {
78
+ // 1. process.env wins — supports ad-hoc overrides + the .env file loaded by env.ts
79
+ if (process.env.OPENAI_API_KEY) {
80
+ return { key: process.env.OPENAI_API_KEY, source: 'env' }
81
+ }
82
+
83
+ // 2. Phone-set key file (v5.9.5)
84
+ const cfg = readConfigFile()
85
+ if (cfg) {
86
+ return {
87
+ key: cfg.key,
88
+ source: 'config',
89
+ savedAt: cfg.savedAt,
90
+ validatedAt: cfg.validatedAt,
91
+ }
92
+ }
93
+
94
+ // 3. COS scripts .env regex fallback
95
+ if (COS_SCRIPTS_DIR) {
96
+ const envPaths = [
97
+ resolve(COS_SCRIPTS_DIR, '.env'),
98
+ resolve(COS_SCRIPTS_DIR, '../../.env'),
99
+ ]
100
+ for (const envPath of envPaths) {
101
+ try {
102
+ const content = readFileSync(envPath, 'utf-8')
103
+ const match = content.match(/^OPENAI_API_KEY=(.+)$/m)
104
+ if (match) {
105
+ return { key: match[1].trim(), source: 'scripts-env' }
106
+ }
107
+ } catch { /* try next */ }
108
+ }
109
+ }
110
+
111
+ return null
112
+ }
113
+
114
+ /** Resolve and cache the key, throwing if no source is reachable. */
115
+ export function getOpenAIKey(): string {
116
+ if (cachedResolution) return cachedResolution.key
117
+ const resolved = resolveFromScratch()
118
+ if (!resolved) {
119
+ throw new Error('OPENAI_API_KEY not found — set in process.env, save via Settings, or add to COS .env files')
120
+ }
121
+ cachedResolution = resolved
122
+ return resolved.key
123
+ }
124
+
125
+ /** Returns the key if resolvable, else null — for endpoints that need to degrade gracefully. */
126
+ export function tryGetOpenAIKey(): string | null {
127
+ try {
128
+ return getOpenAIKey()
129
+ } catch {
130
+ return null
131
+ }
132
+ }
133
+
134
+ /** Diagnostic snapshot for /api/openai-key/status and /api/health.
135
+ * NEVER returns the actual key string — callers only get the source +
136
+ * metadata so the Settings panel can render "Active (saved Apr 28)" without
137
+ * exposing the secret. */
138
+ export function getKeyStatus(): {
139
+ hasKey: boolean
140
+ source: KeySource
141
+ savedAt?: string
142
+ validatedAt?: string
143
+ } {
144
+ // Force a fresh resolution so we reflect the file/env state at call time.
145
+ // Callers that need the raw key still go through getOpenAIKey() which
146
+ // separately caches.
147
+ const resolved = resolveFromScratch()
148
+ if (!resolved) return { hasKey: false, source: 'none' }
149
+ return {
150
+ hasKey: true,
151
+ source: resolved.source,
152
+ savedAt: resolved.savedAt,
153
+ validatedAt: resolved.validatedAt,
154
+ }
155
+ }