@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,286 @@
|
|
|
1
|
+
// Deterministic fuzzy correction for transcribed text.
|
|
2
|
+
// Matches words against a canonical list of proper nouns (enrolled speakers +
|
|
3
|
+
// profile vocabulary) and replaces close misses using length-scaled Levenshtein.
|
|
4
|
+
//
|
|
5
|
+
// Used by:
|
|
6
|
+
// - /api/transcribe (short-form dictation / messaging)
|
|
7
|
+
// - /api/meeting/save (batch post-processing, Phase 3.5 — via meeting.ts)
|
|
8
|
+
//
|
|
9
|
+
// Zero LLM cost, ~5ms on typical dictation length.
|
|
10
|
+
//
|
|
11
|
+
// SAFETY RULES (revised after QA 2026-04-10):
|
|
12
|
+
// 1. Candidates: any 5+ char word (case-insensitive). This supports lowercase
|
|
13
|
+
// dictation output from Whisper (short /transcribe path) AND mixed-case
|
|
14
|
+
// proper nouns like product names and brands.
|
|
15
|
+
// 2. Stop-word list blocks common English (prevents "right"→"Wright",
|
|
16
|
+
// "through"→"Brough"). Must be comprehensive — this is the primary defense.
|
|
17
|
+
// 3. Tight distance thresholds (1 for 5-8 char, 2 for 9-11, 3 for 12+)
|
|
18
|
+
// 4. Length difference ≤ 2 max (prevents long→short collapses)
|
|
19
|
+
// 5. Target must also be a proper noun (starts uppercase in canonical list)
|
|
20
|
+
// 6. Canonical min length 4 (allows short 4-character names as targets)
|
|
21
|
+
|
|
22
|
+
import { getOwnerSpeakerLabel } from './profile.js'
|
|
23
|
+
|
|
24
|
+
/** Compute Levenshtein edit distance between two lowercase strings. */
|
|
25
|
+
export function levenshtein(a: string, b: string): number {
|
|
26
|
+
if (a === b) return 0
|
|
27
|
+
if (a.length === 0) return b.length
|
|
28
|
+
if (b.length === 0) return a.length
|
|
29
|
+
const m = a.length, n = b.length
|
|
30
|
+
let prev = new Array(n + 1).fill(0)
|
|
31
|
+
let curr = new Array(n + 1).fill(0)
|
|
32
|
+
for (let j = 0; j <= n; j++) prev[j] = j
|
|
33
|
+
for (let i = 1; i <= m; i++) {
|
|
34
|
+
curr[0] = i
|
|
35
|
+
for (let j = 1; j <= n; j++) {
|
|
36
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
37
|
+
curr[j] = Math.min(
|
|
38
|
+
prev[j] + 1, // deletion
|
|
39
|
+
curr[j - 1] + 1, // insertion
|
|
40
|
+
prev[j - 1] + cost, // substitution
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
;[prev, curr] = [curr, prev]
|
|
44
|
+
}
|
|
45
|
+
return prev[n]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Common English words that must NEVER be fuzzy-replaced with proper nouns.
|
|
49
|
+
// Deduplicated, alphabetically sorted for maintainability.
|
|
50
|
+
// Covers: conversational fillers, verbs, nouns, function words, and common
|
|
51
|
+
// first names (which the LLM correction pass can disambiguate contextually).
|
|
52
|
+
// Real false positives this prevents:
|
|
53
|
+
// "Alright"→"Wright", "through"→"Brough", "details"→"Retail",
|
|
54
|
+
// "Shelley"→"Kelley", "right"→"Wright", "Christian"→"Cristian"
|
|
55
|
+
const COMMON_ENGLISH_WORDS = new Set<string>([
|
|
56
|
+
// A
|
|
57
|
+
'about', 'above', 'absolutely', 'actually', 'admit', 'adopt', 'adult',
|
|
58
|
+
'after', 'again', 'against', 'agent', 'agree', 'ahead', 'album', 'alert',
|
|
59
|
+
'alike', 'alive', 'allow', 'allright', 'alone', 'along', 'aloud', 'alright',
|
|
60
|
+
'also', 'alter', 'although', 'always', 'among', 'amount', 'anger', 'angle',
|
|
61
|
+
'angry', 'another', 'answer', 'answers', 'anyone', 'anything', 'apart',
|
|
62
|
+
'around', 'array', 'arrive', 'arrow', 'aside', 'asked', 'asking', 'aspect',
|
|
63
|
+
// B
|
|
64
|
+
'basic', 'basically', 'beach', 'because', 'become', 'been', 'before', 'began',
|
|
65
|
+
'begin', 'behind', 'being', 'believe', 'below', 'bench', 'beside', 'better',
|
|
66
|
+
'between', 'beyond', 'birth', 'black', 'blame', 'blank', 'blast', 'blind',
|
|
67
|
+
'block', 'blood', 'board', 'booth', 'boost', 'bound', 'brain', 'brand',
|
|
68
|
+
'bread', 'break', 'breed', 'brief', 'bring', 'broad', 'broke', 'broken',
|
|
69
|
+
'brother', 'brought', 'brown', 'brush', 'built', 'bunch', 'business', 'burst',
|
|
70
|
+
// C
|
|
71
|
+
'calling', 'cannot', 'catch', 'caught', 'change', 'changed', 'chances',
|
|
72
|
+
'cheap', 'check', 'chest', 'chief', 'child', 'children', 'choice', 'choose',
|
|
73
|
+
'chose', 'christian', 'civil', 'claim', 'class', 'clean', 'clear', 'climb',
|
|
74
|
+
'clock', 'close', 'cloud', 'coach', 'coast', 'coming', 'company', 'could',
|
|
75
|
+
'count', 'course', 'court', 'cover', 'craft', 'crash', 'crazy', 'cream',
|
|
76
|
+
'create', 'created', 'crime', 'cross', 'crowd', 'crown', 'crude', 'curve',
|
|
77
|
+
'customer', 'customers', 'cycle',
|
|
78
|
+
// D
|
|
79
|
+
'daily', 'dance', 'dated', 'dealt', 'death', 'debut', 'definitely', 'delay',
|
|
80
|
+
'depth', 'despite', 'detail', 'details', 'doing', 'double', 'doubt', 'dozen',
|
|
81
|
+
'draft', 'drawn', 'dream', 'dress', 'drink', 'drive', 'driven', 'drove',
|
|
82
|
+
'during', 'dying',
|
|
83
|
+
// E
|
|
84
|
+
'early', 'earth', 'eight', 'either', 'email', 'emails', 'empty', 'enemy',
|
|
85
|
+
'enjoy', 'enough', 'entry', 'equal', 'error', 'especially', 'even', 'evening',
|
|
86
|
+
'event', 'every', 'everyone', 'everything', 'exactly', 'example', 'examples',
|
|
87
|
+
'except', 'extra',
|
|
88
|
+
// F
|
|
89
|
+
'faith', 'fancy', 'fault', 'feeling', 'fewer', 'field', 'fifth', 'final',
|
|
90
|
+
'finally', 'finish', 'finished', 'first', 'floor', 'focus', 'follow',
|
|
91
|
+
'followed', 'follows', 'following', 'force', 'forth', 'found', 'frame',
|
|
92
|
+
'fresh', 'friend', 'front',
|
|
93
|
+
// G
|
|
94
|
+
'getting', 'given', 'giving', 'going', 'gonna', 'gotta', 'grade', 'grant',
|
|
95
|
+
'great', 'green', 'group', 'groups', 'guess', 'guest', 'guide',
|
|
96
|
+
// H
|
|
97
|
+
'happened', 'happy', 'hard', 'having', 'head', 'heard', 'heavy', 'helping',
|
|
98
|
+
'helped', 'honestly', 'hope', 'hour', 'house', 'however',
|
|
99
|
+
// I
|
|
100
|
+
'ideal', 'image', 'important', 'inner', 'inside', 'instead', 'interest',
|
|
101
|
+
'issue', 'itself',
|
|
102
|
+
// J
|
|
103
|
+
'joined', 'judge',
|
|
104
|
+
// K
|
|
105
|
+
'keep', 'kept', 'kind', 'knew', 'know', 'known', 'knows',
|
|
106
|
+
// L
|
|
107
|
+
'large', 'later', 'latest', 'laugh', 'lead', 'learn', 'least', 'leave',
|
|
108
|
+
'left', 'less', 'level', 'levels', 'letter', 'light', 'line', 'listen',
|
|
109
|
+
'listened', 'literally', 'little', 'local', 'long', 'longer', 'look',
|
|
110
|
+
'looked', 'looking', 'lower',
|
|
111
|
+
// M
|
|
112
|
+
'making', 'many', 'matter', 'matters', 'maybe', 'mean', 'meaning', 'media',
|
|
113
|
+
'meeting', 'meetings', 'member', 'message', 'messages', 'might', 'mind',
|
|
114
|
+
'minor', 'model', 'money', 'month', 'months', 'moral', 'morning', 'mother',
|
|
115
|
+
'mouse', 'moved', 'moving', 'much', 'music', 'must', 'myself',
|
|
116
|
+
// N
|
|
117
|
+
'name', 'names', 'nature', 'near', 'need', 'needed', 'never', 'news', 'next',
|
|
118
|
+
'night', 'nobody', 'north', 'note', 'noted', 'notes', 'nothing', 'notice',
|
|
119
|
+
'number', 'numbers',
|
|
120
|
+
// O
|
|
121
|
+
'obvious', 'obviously', 'offer', 'office', 'offices', 'often', 'okay',
|
|
122
|
+
'once', 'only', 'onto', 'open', 'order', 'orders', 'other', 'outer',
|
|
123
|
+
'outside', 'over',
|
|
124
|
+
// P
|
|
125
|
+
'paper', 'party', 'people', 'perhaps', 'person', 'phone', 'phones', 'photo',
|
|
126
|
+
'piece', 'place', 'plan', 'plant', 'plate', 'point', 'points', 'poor',
|
|
127
|
+
'possible', 'power', 'press', 'pretty', 'price', 'probably', 'problem',
|
|
128
|
+
'problems', 'product', 'products', 'program', 'project', 'projects', 'proud',
|
|
129
|
+
'public',
|
|
130
|
+
// Q
|
|
131
|
+
'queen', 'question', 'questions', 'quick', 'quiet', 'quite',
|
|
132
|
+
// R
|
|
133
|
+
'rather', 'reach', 'reached', 'ready', 'really', 'reason', 'reasons',
|
|
134
|
+
'recent', 'recently', 'report', 'reports', 'respect', 'result', 'results',
|
|
135
|
+
'retail', 'return', 'right', 'rights', 'rough', 'round', 'rural',
|
|
136
|
+
// S
|
|
137
|
+
'sales', 'same', 'school', 'seeing', 'seemed', 'seems', 'sense', 'sent',
|
|
138
|
+
'seven', 'several', 'shall', 'share', 'sharp', 'shelley', 'short', 'should',
|
|
139
|
+
'showing', 'shown', 'sides', 'since', 'small', 'smile', 'social', 'some',
|
|
140
|
+
'someone', 'something', 'sometimes', 'soon', 'sorry', 'sound', 'south',
|
|
141
|
+
'space', 'speak', 'spoke', 'sports', 'staff', 'stage', 'stand', 'start',
|
|
142
|
+
'started', 'state', 'still', 'stood', 'stop', 'stopping', 'story', 'stuff',
|
|
143
|
+
'style', 'subject', 'success', 'such', 'suggest', 'support', 'sure',
|
|
144
|
+
'system', 'systems',
|
|
145
|
+
// T
|
|
146
|
+
'table', 'taking', 'talking', 'taught', 'teach', 'team', 'teams', 'telling',
|
|
147
|
+
'tells', 'terms', 'thanks', 'thank', 'their', 'them', 'then', 'there',
|
|
148
|
+
'these', 'thing', 'things', 'think', 'third', 'this', 'those', 'though',
|
|
149
|
+
'thought', 'three', 'through', 'throughout', 'today', 'together', 'told',
|
|
150
|
+
'tomorrow', 'tonight', 'took', 'total', 'touch', 'toward', 'towards', 'town',
|
|
151
|
+
'trade', 'train', 'tried', 'trouble', 'truly', 'trust', 'truth', 'trying',
|
|
152
|
+
'turn', 'turned', 'turning', 'twelve', 'type',
|
|
153
|
+
// U
|
|
154
|
+
'under', 'underneath', 'understand', 'unique', 'until', 'upon', 'usually',
|
|
155
|
+
// V
|
|
156
|
+
'value', 'various', 'very', 'visit', 'voice',
|
|
157
|
+
// W
|
|
158
|
+
'wait', 'wanna', 'want', 'wanted', 'watch', 'water', 'ways', 'week', 'weeks',
|
|
159
|
+
'weekend', 'well', 'went', 'were', 'what', 'when', 'where', 'whether',
|
|
160
|
+
'which', 'while', 'white', 'whole', 'whose', 'wife', 'will', 'wind', 'wish',
|
|
161
|
+
'with', 'within', 'without', 'women', 'word', 'words', 'work', 'worked',
|
|
162
|
+
'working', 'world', 'worry', 'worse', 'worst', 'would', 'wrong',
|
|
163
|
+
// Y
|
|
164
|
+
'yeah', 'year', 'years', 'yesterday', 'young', 'your', 'yours', 'yourself',
|
|
165
|
+
|
|
166
|
+
// ── Common first names ──
|
|
167
|
+
// Whisper frequently capitalizes these at any position. Leave them alone —
|
|
168
|
+
// LLM correction pass handles context-aware disambiguation vs enrolled speakers.
|
|
169
|
+
'aaron', 'albert', 'alexander', 'alice', 'amanda', 'amber', 'amy', 'andrea',
|
|
170
|
+
'angela', 'ann', 'anne', 'anthony', 'arthur', 'ashley', 'barbara', 'becky',
|
|
171
|
+
'benjamin', 'betty', 'brandon', 'brenda', 'brian', 'bruce', 'carl', 'carlos',
|
|
172
|
+
'carol', 'carolyn', 'catherine', 'charles', 'cheryl', 'christina', 'christine',
|
|
173
|
+
'christopher', 'cynthia', 'daniel', 'david', 'dawn', 'deborah', 'denise',
|
|
174
|
+
'dennis', 'diana', 'diane', 'donna', 'douglas', 'dylan', 'edward', 'elizabeth',
|
|
175
|
+
'emily', 'emma', 'eric', 'eugene', 'evelyn', 'frank', 'gary', 'george',
|
|
176
|
+
'gloria', 'grace', 'gregory', 'hannah', 'harold', 'heather', 'helen', 'henry',
|
|
177
|
+
'howard', 'jack', 'jackson', 'jacob', 'jacqueline', 'james', 'jane', 'janet',
|
|
178
|
+
'janice', 'jason', 'jeffrey', 'jennifer', 'jerry', 'jessica', 'joan', 'john',
|
|
179
|
+
'johnny', 'jonathan', 'jordan', 'jose', 'joseph', 'joshua', 'joyce', 'judith',
|
|
180
|
+
'julia', 'julie', 'justin', 'karen', 'katherine', 'kathleen', 'kathryn',
|
|
181
|
+
'keith', 'kelly', 'kenneth', 'kevin', 'kimberly', 'larry', 'laura', 'lauren',
|
|
182
|
+
'lawrence', 'linda', 'lisa', 'lori', 'louis', 'margaret', 'maria', 'marie',
|
|
183
|
+
'mark', 'martha', 'martin', 'mary', 'matthew', 'megan', 'melissa', 'michael',
|
|
184
|
+
'michelle', 'monica', 'nancy', 'natalie', 'nicholas', 'nicole', 'olivia',
|
|
185
|
+
'pamela', 'patricia', 'patrick', 'paul', 'peter', 'philip', 'rachel', 'ralph',
|
|
186
|
+
'randy', 'raymond', 'rebecca', 'richard', 'robert', 'roger', 'ronald', 'rose',
|
|
187
|
+
'roy', 'ruby', 'russell', 'ruth', 'ryan', 'samantha', 'samuel', 'sandra',
|
|
188
|
+
'sara', 'sarah', 'scott', 'sean', 'shannon', 'sharon', 'shirley', 'stephanie',
|
|
189
|
+
'stephen', 'steven', 'susan', 'teresa', 'theresa', 'thomas', 'timothy',
|
|
190
|
+
'tina', 'tony', 'tyler', 'victoria', 'virginia', 'walter', 'wayne', 'william',
|
|
191
|
+
'willie', 'zachary',
|
|
192
|
+
])
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Fuzzy-match transcript words against a canonical list of proper nouns.
|
|
196
|
+
*
|
|
197
|
+
* RULES (all must pass):
|
|
198
|
+
* - Candidate word length ≥ 5 chars (any case — supports lowercase dictation
|
|
199
|
+
* AND mixed-case names like GitHub/OpenAI)
|
|
200
|
+
* - Word must NOT be in COMMON_ENGLISH_WORDS stop-list (primary defense)
|
|
201
|
+
* - Target must start with uppercase (canonical proper noun)
|
|
202
|
+
* - Target token length ≥ 4 chars (allows short names like Anna, Ryan, Luke as targets)
|
|
203
|
+
* - Length difference ≤ 2 chars
|
|
204
|
+
* - Distance within length-scaled threshold:
|
|
205
|
+
* 5-8 chars → 1
|
|
206
|
+
* 9-11 chars → 2
|
|
207
|
+
* 12+ chars → 3
|
|
208
|
+
*
|
|
209
|
+
* Preserves the original word's first-letter case: capitalizes the replacement
|
|
210
|
+
* when the source was capitalized, otherwise lowercases it. This avoids
|
|
211
|
+
* creating sentence-middle capitalizations where none existed.
|
|
212
|
+
*/
|
|
213
|
+
export function applyFuzzyCorrections(
|
|
214
|
+
text: string,
|
|
215
|
+
targets: string[],
|
|
216
|
+
): { text: string; replacements: number } {
|
|
217
|
+
if (targets.length === 0 || !text) return { text, replacements: 0 }
|
|
218
|
+
|
|
219
|
+
// Keep only targets that look like proper nouns.
|
|
220
|
+
// Drop speaker tokens and entries that don't start with uppercase.
|
|
221
|
+
const owner = getOwnerSpeakerLabel()
|
|
222
|
+
const canonical = Array.from(new Set(
|
|
223
|
+
targets
|
|
224
|
+
.filter(t => {
|
|
225
|
+
if (!t || t.length < 4) return false
|
|
226
|
+
if (t === 'Ext' || t === 'Unknown' || t === owner) return false
|
|
227
|
+
return /^[A-Z]/.test(t.trim())
|
|
228
|
+
})
|
|
229
|
+
.map(t => t.trim()),
|
|
230
|
+
))
|
|
231
|
+
if (canonical.length === 0) return { text, replacements: 0 }
|
|
232
|
+
|
|
233
|
+
const targetLowerSet = new Set(canonical.map(t => t.toLowerCase()))
|
|
234
|
+
let replacements = 0
|
|
235
|
+
const fuzzyLog: Array<{ from: string; to: string }> = []
|
|
236
|
+
|
|
237
|
+
// Match any 5+ char word (case-insensitive). Stop-list + strict distance
|
|
238
|
+
// thresholds are the primary defenses against false positives.
|
|
239
|
+
const result = text.replace(/\b[A-Za-z][A-Za-z'-]{4,}\b/g, (word) => {
|
|
240
|
+
const lower = word.toLowerCase()
|
|
241
|
+
|
|
242
|
+
// Skip common English words (primary false-positive defense)
|
|
243
|
+
if (COMMON_ENGLISH_WORDS.has(lower)) return word
|
|
244
|
+
|
|
245
|
+
// Skip exact matches (already correct)
|
|
246
|
+
if (targetLowerSet.has(lower)) return word
|
|
247
|
+
|
|
248
|
+
// Length-scaled distance threshold
|
|
249
|
+
const len = word.length
|
|
250
|
+
const maxDist = len <= 8 ? 1 : len <= 11 ? 2 : 3
|
|
251
|
+
|
|
252
|
+
let bestTarget: string | null = null
|
|
253
|
+
let bestDist = maxDist + 1
|
|
254
|
+
|
|
255
|
+
for (const target of canonical) {
|
|
256
|
+
// Split multi-word targets — match on individual tokens
|
|
257
|
+
const tokens = target.split(/\s+/)
|
|
258
|
+
for (const token of tokens) {
|
|
259
|
+
if (token.length < 4) continue
|
|
260
|
+
// Length difference must be small to prevent collapses
|
|
261
|
+
if (Math.abs(token.length - len) > 2) continue
|
|
262
|
+
const dist = levenshtein(lower, token.toLowerCase())
|
|
263
|
+
if (dist > 0 && dist < bestDist && dist <= maxDist) {
|
|
264
|
+
bestDist = dist
|
|
265
|
+
bestTarget = token
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (bestTarget) {
|
|
271
|
+
replacements++
|
|
272
|
+
fuzzyLog.push({ from: word, to: bestTarget })
|
|
273
|
+
// Preserve original capitalization pattern
|
|
274
|
+
const wasCapitalized = /^[A-Z]/.test(word)
|
|
275
|
+
return wasCapitalized ? bestTarget : bestTarget.toLowerCase()
|
|
276
|
+
}
|
|
277
|
+
return word
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
if (fuzzyLog.length > 0) {
|
|
281
|
+
const preview = fuzzyLog.slice(0, 5).map(x => `${x.from}→${x.to}`).join(', ')
|
|
282
|
+
console.log(`[fuzzy-correct] Applied ${replacements} correction(s): ${preview}${fuzzyLog.length > 5 ? '...' : ''}`)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { text: result, replacements }
|
|
286
|
+
}
|