@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.
- package/.cos-profile.example.json +7 -0
- package/.env.example +44 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/bin/cli.cjs +203 -0
- package/package.json +53 -0
- package/server/env.ts +26 -0
- package/server/index.ts +211 -0
- package/server/lib/archive-budget.ts +65 -0
- package/server/lib/archive.ts +414 -0
- package/server/lib/atomic-fs.ts +50 -0
- package/server/lib/audio-enhance.ts +87 -0
- package/server/lib/claude-bridge.ts +682 -0
- package/server/lib/claude-circuit.ts +52 -0
- package/server/lib/claude-run-ledger.ts +279 -0
- package/server/lib/codex-bridge.ts +476 -0
- package/server/lib/codex-engine-sessions.ts +140 -0
- package/server/lib/codex-run-ledger.ts +298 -0
- package/server/lib/context-builder.ts +210 -0
- package/server/lib/conversation.ts +587 -0
- package/server/lib/data-dir.ts +20 -0
- package/server/lib/display-bus.ts +21 -0
- package/server/lib/display-format.ts +23 -0
- package/server/lib/fuzzy-correct.ts +286 -0
- package/server/lib/hallucination-filter.ts +469 -0
- package/server/lib/local-day.ts +13 -0
- package/server/lib/model-router.ts +38 -0
- package/server/lib/openai-key.ts +155 -0
- package/server/lib/openai-whisper-budget.ts +170 -0
- package/server/lib/profile.ts +94 -0
- package/server/lib/python-bridge.ts +84 -0
- package/server/lib/response-cache.ts +138 -0
- package/server/lib/session-cache-writer.ts +266 -0
- package/server/lib/session-log.ts +162 -0
- package/server/lib/speaker-embeddings.ts +578 -0
- package/server/lib/telegram-notify.ts +85 -0
- package/server/lib/token-audit.ts +50 -0
- package/server/lib/transcribe-audio.ts +187 -0
- package/server/lib/utils.ts +5 -0
- package/server/lib/vad-silero.ts +179 -0
- package/server/lib/whisper-local.ts +697 -0
- package/server/routes/diag.ts +115 -0
- package/server/routes/display.ts +65 -0
- package/server/routes/health.ts +128 -0
- package/server/routes/openai-compat.ts +446 -0
- package/server/routes/openai-key.ts +121 -0
- package/server/routes/query.ts +121 -0
- package/server/routes/transcribe-stream.ts +1090 -0
- package/server/routes/transcribe.ts +55 -0
- 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
|
+
}
|