@agfpd/voicetalk-runtime 0.1.1 → 0.2.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.
@@ -0,0 +1,191 @@
1
+ // voice-connect client — the ONLY place this package talks to speech.
2
+ //
3
+ // Speech ↔ text is not ours (decision: «Голосовой слой voice-connect — единый сервис,
4
+ // core плюс два фасада»). voice-connect runs as an always-on local HTTP service and
5
+ // publishes a discovery slot at <IAPEER_ROOT>/voice-provider.json; we are a pure
6
+ // CONSUMER of its OpenAI-compatible routes:
7
+ //
8
+ // POST /v1/audio/transcriptions multipart(file, language?, engine?) → { text }
9
+ // POST /v1/audio/speech json(input, voice?, lang?, engine?) → ogg/opus bytes
10
+ // GET /health → { status }
11
+ //
12
+ // Engine selection, provider cascade and fallbacks live INSIDE that service — never
13
+ // re-implement any of it here.
14
+
15
+ import { readFileSync, writeFileSync } from 'fs'
16
+ import { homedir } from 'os'
17
+ import { join } from 'path'
18
+
19
+ export type VoiceEndpoint = {
20
+ base: string
21
+ tts: string
22
+ stt: string
23
+ health: string
24
+ /** Where the endpoint came from — surfaced by `doctor` so a misrouted host is obvious. */
25
+ source: 'env' | 'slot' | 'default'
26
+ version?: string
27
+ }
28
+
29
+ const DEFAULT_BASE = 'http://127.0.0.1:8127'
30
+
31
+ function iapeerRoot(): string {
32
+ const override = process.env.IAPEER_ROOT?.trim()
33
+ return override || join(homedir(), '.iapeer')
34
+ }
35
+
36
+ function providerSlotPath(): string {
37
+ return join(iapeerRoot(), 'voice-provider.json')
38
+ }
39
+
40
+ function trimSlash(url: string): string {
41
+ return url.replace(/\/+$/, '')
42
+ }
43
+
44
+ /** Resolve the voice service endpoint: explicit env override → the discovery slot
45
+ * voice-connect writes on install → the documented local default. Never throws;
46
+ * a missing/corrupt slot degrades to the default (and `doctor`/health surfaces it). */
47
+ export function resolveVoiceEndpoint(env: NodeJS.ProcessEnv = process.env): VoiceEndpoint {
48
+ const routes = { tts: '/v1/audio/speech', stt: '/v1/audio/transcriptions', health: '/health' }
49
+ const override = env.VOICETALK_VOICE_ENDPOINT?.trim()
50
+ if (override) {
51
+ const base = trimSlash(override)
52
+ return { base, tts: base + routes.tts, stt: base + routes.stt, health: base + routes.health, source: 'env' }
53
+ }
54
+ try {
55
+ const slot = JSON.parse(readFileSync(providerSlotPath(), 'utf8')) as {
56
+ endpoint?: string
57
+ host?: string
58
+ port?: number
59
+ version?: string
60
+ routes?: Partial<typeof routes>
61
+ }
62
+ const base = trimSlash(
63
+ slot.endpoint?.trim() || (slot.host && slot.port ? `http://${slot.host}:${slot.port}` : ''),
64
+ )
65
+ if (base) {
66
+ return {
67
+ base,
68
+ tts: base + (slot.routes?.tts ?? routes.tts),
69
+ stt: base + (slot.routes?.stt ?? routes.stt),
70
+ health: base + (slot.routes?.health ?? routes.health),
71
+ source: 'slot',
72
+ version: slot.version,
73
+ }
74
+ }
75
+ } catch {
76
+ // no slot / unreadable → fall through to the default
77
+ }
78
+ return {
79
+ base: DEFAULT_BASE,
80
+ tts: DEFAULT_BASE + routes.tts,
81
+ stt: DEFAULT_BASE + routes.stt,
82
+ health: DEFAULT_BASE + routes.health,
83
+ source: 'default',
84
+ }
85
+ }
86
+
87
+ export type VoiceHealth = { ok: boolean; endpoint: string; source: string; version?: string; detail?: string }
88
+
89
+ export async function voiceHealth(timeoutMs = 4_000): Promise<VoiceHealth> {
90
+ const ep = resolveVoiceEndpoint()
91
+ try {
92
+ const res = await fetch(ep.health, { signal: AbortSignal.timeout(timeoutMs) })
93
+ if (!res.ok) return { ok: false, endpoint: ep.base, source: ep.source, detail: `HTTP ${res.status}` }
94
+ const body = (await res.json()) as { status?: string; version?: string }
95
+ return {
96
+ ok: body?.status === 'ok',
97
+ endpoint: ep.base,
98
+ source: ep.source,
99
+ version: body?.version ?? ep.version,
100
+ detail: body?.status === 'ok' ? undefined : `status=${body?.status ?? 'unknown'}`,
101
+ }
102
+ } catch (err) {
103
+ return {
104
+ ok: false,
105
+ endpoint: ep.base,
106
+ source: ep.source,
107
+ detail: err instanceof Error ? err.message : String(err),
108
+ }
109
+ }
110
+ }
111
+
112
+ export type TranscribeOptions = {
113
+ /** ISO-639-1 hint. Omitted → the service auto-detects. */
114
+ lang?: string
115
+ /** Force an STT engine (`mlx-whisper` keeps it fully local); default = service cascade. */
116
+ engine?: string
117
+ /** Decoder-priming prompt — biases spelling of fleet names so the wake matcher sees
118
+ * «Борис», not «Барис». */
119
+ prompt?: string
120
+ timeoutMs?: number
121
+ }
122
+
123
+ /** Transcribe a local audio file. Returns the raw transcript (may be empty). */
124
+ export async function transcribe(audioPath: string, opts: TranscribeOptions = {}): Promise<string> {
125
+ const ep = resolveVoiceEndpoint()
126
+ const form = new FormData()
127
+ const bytes = readFileSync(audioPath)
128
+ form.append('file', new Blob([new Uint8Array(bytes)], { type: 'audio/wav' }), audioPath.split('/').pop() ?? 'audio.wav')
129
+ if (opts.lang) form.append('language', opts.lang)
130
+ if (opts.engine) form.append('engine', opts.engine)
131
+ if (opts.prompt) form.append('prompt', opts.prompt)
132
+ form.append('response_format', 'json')
133
+ const res = await fetch(ep.stt, {
134
+ method: 'POST',
135
+ body: form,
136
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 90_000),
137
+ })
138
+ if (!res.ok) {
139
+ throw new Error(`voice-connect stt ${res.status}: ${(await res.text()).slice(0, 300)}`)
140
+ }
141
+ const body = (await res.json()) as { text?: string }
142
+ return (body?.text ?? '').trim()
143
+ }
144
+
145
+ export type SynthesizeOptions = {
146
+ /** Per-peer voice (the sender's `interfaces.voice` entry) — so agents sound distinct. */
147
+ voice?: string
148
+ lang?: string
149
+ style?: string
150
+ engine?: string
151
+ timeoutMs?: number
152
+ }
153
+
154
+ export type SynthesizeResult = {
155
+ path: string
156
+ engine?: string
157
+ voice?: string
158
+ fallbackFrom?: string
159
+ bytes: number
160
+ }
161
+
162
+ /** Synthesize text to a local .ogg file. The HTTP facade is always synchronous — it
163
+ * returns the audio bytes, delivery is our job (the speaker). */
164
+ export async function synthesize(text: string, outPath: string, opts: SynthesizeOptions = {}): Promise<SynthesizeResult> {
165
+ const ep = resolveVoiceEndpoint()
166
+ const res = await fetch(ep.tts, {
167
+ method: 'POST',
168
+ headers: { 'content-type': 'application/json' },
169
+ body: JSON.stringify({
170
+ input: text,
171
+ ...(opts.voice ? { voice: opts.voice } : {}),
172
+ ...(opts.lang ? { lang: opts.lang } : {}),
173
+ ...(opts.style ? { style: opts.style } : {}),
174
+ ...(opts.engine ? { engine: opts.engine } : {}),
175
+ }),
176
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 120_000),
177
+ })
178
+ if (!res.ok) {
179
+ throw new Error(`voice-connect tts ${res.status}: ${(await res.text()).slice(0, 300)}`)
180
+ }
181
+ const buf = Buffer.from(await res.arrayBuffer())
182
+ if (buf.length === 0) throw new Error('voice-connect tts returned 0 bytes')
183
+ writeFileSync(outPath, buf, { mode: 0o600 })
184
+ return {
185
+ path: outPath,
186
+ engine: res.headers.get('x-voice-engine') ?? undefined,
187
+ voice: res.headers.get('x-voice-voice') ?? undefined,
188
+ fallbackFrom: res.headers.get('x-voice-fallback-from') ?? undefined,
189
+ bytes: buf.length,
190
+ }
191
+ }
@@ -0,0 +1,353 @@
1
+ // voiceSession.ts — the live voice session (Фаза 3): the runtime's ears, its turn-taking
2
+ // and its mouth, wired between audio.ts (host mechanics) and voiceConnect.ts (speech).
3
+ //
4
+ // INBOUND mic → endpointing → STT → address match → deliver to the fleet
5
+ // OUTBOUND agent envelope → TTS → speaker, then an open-mic follow-up window
6
+ //
7
+ // Turn-taking follows the established voice-assistant pattern recorded in Описание
8
+ // voicetalk: the address (wake word) only INITIATES a conversation; after an agent has
9
+ // answered, a short open-mic window lets the owner continue without naming the peer
10
+ // again, and the window closes on silence. The microphone is gated while the speaker is
11
+ // talking, so the runtime never transcribes its own voice.
12
+ //
13
+ // The numeric defaults below are NOT invented here — they come from the turn-taking
14
+ // research prepared for this phase (~/Peers/perplex/research-tree/voice-ux-turntaking/
15
+ // 2026-06-21_turn-taking-defaults.md), which converges Alexa / Google / Siri / Алиса and
16
+ // the open-source stacks: open-mic window 8–10 s, endpointing pause 500 ms (500–800 ms
17
+ // for free dialogue), rising earcon on mic-open / short descending on utterance-end /
18
+ // double descending on error, and NO earcon while processing.
19
+ //
20
+ // Barge-in (interrupting the spoken answer) is deliberately OUT of Фаза 3: the same
21
+ // research shows it requires acoustic echo cancellation plus a neural VAD, neither of
22
+ // which exists here yet. Mic gating during playback is the honest substitute — the
23
+ // runtime simply does not listen while it talks.
24
+
25
+ import { mkdirSync, rmSync } from 'fs'
26
+ import { join } from 'path'
27
+ import { randomUUID } from 'crypto'
28
+ import {
29
+ Microphone,
30
+ Segmenter,
31
+ earconWav,
32
+ playFile,
33
+ resolveInputDevice,
34
+ writeWav,
35
+ type Segment,
36
+ } from './audio.ts'
37
+ import { synthesize, transcribe } from './voiceConnect.ts'
38
+ import {
39
+ buildAddressBook,
40
+ isLikelyHallucination,
41
+ matchWake,
42
+ readFleetPeers,
43
+ sttPrompt,
44
+ type Address,
45
+ type FleetPeer,
46
+ type VoicetalkBinding,
47
+ } from './wake.ts'
48
+ import { writeFileSync } from 'fs'
49
+
50
+ const num = (v: string | undefined, d: number) => (v && Number(v) > 0 ? Number(v) : d)
51
+
52
+ /** How long after a turn the mic stays open for a follow-up without re-addressing.
53
+ * Research default: 8–10 s (Google Continued Conversation ≈10 s; Alexa's 5 s reads as
54
+ * too short). */
55
+ const FOLLOWUP_MS = num(process.env.VOICETALK_FOLLOWUP_MS, 9_000)
56
+ /** Silence that ends an utterance (endpointing pause). Research default 500 ms, band
57
+ * 500–800 ms for free dialogue; the upper half of the band suits a heavier recognizer —
58
+ * it fragments one thought into two transcriptions far less often. */
59
+ const HANGOVER_MS = num(process.env.VOICETALK_HANGOVER_MS, 700)
60
+ /** Gate the mic for a moment after the speaker stops — room echo tail. */
61
+ const ECHO_TAIL_MS = num(process.env.VOICETALK_ECHO_TAIL_MS, 400)
62
+ /** Long answers are truncated for speech: a spoken monologue is worse than a summary. */
63
+ const TTS_MAX_CHARS = num(process.env.VOICETALK_TTS_MAX_CHARS, 1_200)
64
+ /** Language hint for STT; the owner's language. Empty = let the service auto-detect. */
65
+ const STT_LANG = process.env.VOICETALK_STT_LANG ?? 'ru'
66
+ /** Force a local recognizer (mlx-whisper) to keep always-on listening on-device. */
67
+ const STT_ENGINE = process.env.VOICETALK_STT_ENGINE?.trim() || undefined
68
+
69
+ export type DeliverFn = (target: string, text: string) => Promise<{ ok: boolean; detail?: string }>
70
+ export type LogFn = (evt: string, fields: Record<string, unknown>) => void
71
+
72
+ export type VoiceSessionOptions = {
73
+ owner: string
74
+ binding: VoicetalkBinding
75
+ deliver: DeliverFn
76
+ log: LogFn
77
+ /** Scratch dir for captured WAVs and synthesized OGGs. */
78
+ tmpDir: string
79
+ peers?: FleetPeer[]
80
+ }
81
+
82
+ /** State sounds, per the earcon convention in the research: `open` = the microphone is
83
+ * yours (rising, ≤300 ms), `end` = utterance captured, processing (short descending,
84
+ * ≤200 ms), `error` = nothing routed (double descending). Processing itself is silent. */
85
+ type Earcons = { open: string; end: string; error: string }
86
+
87
+ export class VoiceSession {
88
+ private mic: Microphone | null = null
89
+ private readonly segmenter = new Segmenter({
90
+ hangoverMs: HANGOVER_MS,
91
+ ratio: num(process.env.VOICETALK_VAD_RATIO, 3.5),
92
+ minRms: num(process.env.VOICETALK_VAD_MIN_RMS, 700),
93
+ })
94
+ private book: Address[] = []
95
+ private prompt = ''
96
+ private earcons: Earcons | null = null
97
+ private busy = false
98
+ private muteUntil = 0
99
+ private followTarget: string | null = null
100
+ private followUntil = 0
101
+ private stopped = false
102
+
103
+ constructor(private readonly opts: VoiceSessionOptions) {}
104
+
105
+ /** Resolve the input device, prime the address book, open the mic. Throws if the mic
106
+ * cannot be resolved — the caller decides whether that is fatal (it is not: the
107
+ * router keeps running text-only). */
108
+ async start(): Promise<{ deviceIndex: number; deviceName?: string; addresses: number }> {
109
+ mkdirSync(this.opts.tmpDir, { recursive: true, mode: 0o700 })
110
+ this.refreshAddressBook()
111
+ this.earcons = this.writeEarcons()
112
+ const device = await resolveInputDevice(this.opts.binding.device)
113
+ this.mic = new Microphone({
114
+ deviceIndex: device.index,
115
+ onData: chunk => this.onAudio(chunk),
116
+ onStart: attempt => this.opts.log('mic.start', { device: device.index, name: device.name, attempt }),
117
+ onError: detail => this.opts.log('mic.error', { detail }),
118
+ })
119
+ this.mic.start()
120
+ return { deviceIndex: device.index, deviceName: device.name, addresses: this.book.length }
121
+ }
122
+
123
+ stop(): void {
124
+ this.stopped = true
125
+ this.mic?.stop()
126
+ this.mic = null
127
+ }
128
+
129
+ private refreshAddressBook(): void {
130
+ this.book = buildAddressBook({
131
+ owner: this.opts.owner,
132
+ binding: this.opts.binding,
133
+ peers: this.opts.peers ?? readFleetPeers(),
134
+ })
135
+ this.prompt = sttPrompt(this.book)
136
+ }
137
+
138
+ private writeEarcons(): Earcons {
139
+ const files: Earcons = {
140
+ open: join(this.opts.tmpDir, 'earcon-open.wav'),
141
+ end: join(this.opts.tmpDir, 'earcon-end.wav'),
142
+ error: join(this.opts.tmpDir, 'earcon-error.wav'),
143
+ }
144
+ // A5→C6→E6 rising, 240 ms — the microphone is open.
145
+ writeFileSync(files.open, earconWav([{ hz: 880, ms: 80 }, { hz: 1047, ms: 80 }, { hz: 1319, ms: 80 }]), { mode: 0o600 })
146
+ // Short descending, 160 ms — heard you, working on it.
147
+ writeFileSync(files.end, earconWav([{ hz: 880, ms: 80 }, { hz: 660, ms: 80 }]), { mode: 0o600 })
148
+ // Double descending, 260 ms — tonally "closed": nothing was routed.
149
+ writeFileSync(files.error, earconWav([{ hz: 520, ms: 120 }, { hz: 330, ms: 140 }]), { mode: 0o600 })
150
+ return files
151
+ }
152
+
153
+ private async earcon(kind: keyof Earcons): Promise<void> {
154
+ if (!this.earcons || process.env.VOICETALK_EARCONS === '0') return
155
+ try {
156
+ this.muteUntil = Math.max(this.muteUntil, Date.now() + 600)
157
+ await playFile(this.earcons[kind], { timeoutMs: 5_000 })
158
+ } catch (err) {
159
+ this.opts.log('earcon.error', { kind, detail: err instanceof Error ? err.message : String(err) })
160
+ }
161
+ }
162
+
163
+ private muted(): boolean {
164
+ return Date.now() < this.muteUntil
165
+ }
166
+
167
+ private onAudio(chunk: Buffer): void {
168
+ if (this.stopped) return
169
+ if (this.muted()) {
170
+ // Our own speech (or an earcon) is in the room — throw the audio away rather than
171
+ // letting the endpointer build an utterance out of it.
172
+ this.segmenter.reset()
173
+ return
174
+ }
175
+ for (const segment of this.segmenter.push(chunk)) {
176
+ if (this.busy) {
177
+ // One utterance in flight at a time: STT is the expensive step and a pile-up
178
+ // would only add latency to the turn that matters.
179
+ this.opts.log('segment.drop', { reason: 'busy', ms: segment.ms })
180
+ continue
181
+ }
182
+ this.busy = true
183
+ void this.handleSegment(segment).finally(() => {
184
+ this.busy = false
185
+ })
186
+ }
187
+ }
188
+
189
+ private async handleSegment(segment: Segment): Promise<void> {
190
+ const wav = join(this.opts.tmpDir, `utt-${randomUUID()}.wav`)
191
+ const started = Date.now()
192
+ try {
193
+ writeWav(segment.pcm, wav)
194
+ let transcript = ''
195
+ try {
196
+ transcript = await transcribe(wav, {
197
+ lang: STT_LANG || undefined,
198
+ engine: STT_ENGINE,
199
+ prompt: this.prompt || undefined,
200
+ })
201
+ } catch (err) {
202
+ this.opts.log('stt.error', { ms: segment.ms, detail: err instanceof Error ? err.message : String(err) })
203
+ return
204
+ }
205
+ const sttMs = Date.now() - started
206
+ if (isLikelyHallucination(transcript)) {
207
+ this.opts.log('stt.ignored', {
208
+ ms: segment.ms,
209
+ sttMs,
210
+ peak: Math.round(segment.peakRms),
211
+ floor: Math.round(segment.noiseFloor),
212
+ text: transcript,
213
+ })
214
+ return
215
+ }
216
+ this.opts.log('stt.text', {
217
+ ms: segment.ms,
218
+ sttMs,
219
+ peak: Math.round(segment.peakRms),
220
+ floor: Math.round(segment.noiseFloor),
221
+ threshold: segment.threshold,
222
+ text: transcript,
223
+ })
224
+
225
+ const match = matchWake(transcript, this.book)
226
+ const followOpen = this.followTarget && Date.now() < this.followUntil
227
+ let target: string | null = null
228
+ let body = ''
229
+ if (match) {
230
+ target = match.target
231
+ body = match.body
232
+ if (!body) {
233
+ // Addressed with nothing to say yet («Борис?») — acknowledge and hold the mic
234
+ // open for the actual request, exactly as an assistant beeps and waits.
235
+ this.followTarget = target
236
+ this.followUntil = Date.now() + FOLLOWUP_MS
237
+ this.opts.log('wake.await-body', { target, alias: match.alias })
238
+ await this.earcon('open')
239
+ return
240
+ }
241
+ } else if (followOpen) {
242
+ target = this.followTarget
243
+ body = transcript.trim()
244
+ this.opts.log('followup.route', { target })
245
+ } else {
246
+ this.opts.log('wake.no-match', { text: transcript })
247
+ return
248
+ }
249
+
250
+ // The window is extended for the whole turn: the agent's answer will extend it
251
+ // again when it is spoken.
252
+ this.followTarget = target
253
+ this.followUntil = Date.now() + FOLLOWUP_MS
254
+ // Utterance accepted → the "end of listening, now processing" sound. Delivery
255
+ // itself stays SILENT (processing earns no earcon) — the agent's own voice is the
256
+ // next thing the owner hears.
257
+ await this.earcon('end')
258
+ const verdict = await this.opts.deliver(target!, body)
259
+ if (verdict.ok) {
260
+ this.opts.log('voice.delivered', { target, chars: body.length, totalMs: Date.now() - started })
261
+ } else {
262
+ this.opts.log('voice.undelivered', { target, detail: verdict.detail })
263
+ await this.earcon('error')
264
+ await this.say(`Не доставлено: ${verdict.detail ?? 'неизвестная ошибка'}`)
265
+ }
266
+ } finally {
267
+ rmSync(wav, { force: true })
268
+ }
269
+ }
270
+
271
+ /** Voice of the SENDER — each agent keeps the voice configured in its own passport
272
+ * (`interfaces.voice`), so the owner hears who is talking. */
273
+ private voiceFor(personality: string): string | undefined {
274
+ const peers = this.opts.peers ?? readFleetPeers()
275
+ const voice = peers.find(p => p.personality === personality)?.voice
276
+ if (!voice) return undefined
277
+ return voice['gemini-3.1-flash-tts-preview'] ?? Object.values(voice)[0]
278
+ }
279
+
280
+ /** Speak an agent's answer on the owner's speaker, then hold the follow-up window
281
+ * open — the conversational turn passes back to the human. */
282
+ async speak(from: string, message: string): Promise<void> {
283
+ const text = speakableText(message)
284
+ if (!text) {
285
+ this.opts.log('tts.skip', { from, reason: 'nothing speakable' })
286
+ return
287
+ }
288
+ await this.say(text, { from, voice: this.voiceFor(from) })
289
+ this.followTarget = from
290
+ this.followUntil = Date.now() + FOLLOWUP_MS
291
+ // The turn passes back to the human: the mic is open for a follow-up without a
292
+ // wake word, and the rising earcon says so.
293
+ await this.earcon('open')
294
+ }
295
+
296
+ private async say(text: string, opts: { from?: string; voice?: string } = {}): Promise<void> {
297
+ const out = join(this.opts.tmpDir, `say-${randomUUID()}.ogg`)
298
+ const started = Date.now()
299
+ try {
300
+ const result = await synthesize(text, out, { voice: opts.voice })
301
+ const ttsMs = Date.now() - started
302
+ // Gate the mic for the whole playback: the runtime must not hear itself.
303
+ this.muteUntil = Date.now() + 3_600_000
304
+ this.segmenter.reset()
305
+ try {
306
+ await playFile(result.path)
307
+ } finally {
308
+ this.muteUntil = Date.now() + ECHO_TAIL_MS
309
+ this.segmenter.reset()
310
+ }
311
+ this.opts.log('tts.spoken', {
312
+ from: opts.from,
313
+ chars: text.length,
314
+ engine: result.engine,
315
+ voice: result.voice,
316
+ fallbackFrom: result.fallbackFrom,
317
+ ttsMs,
318
+ totalMs: Date.now() - started,
319
+ })
320
+ } catch (err) {
321
+ this.muteUntil = 0
322
+ this.opts.log('tts.error', { from: opts.from, detail: err instanceof Error ? err.message : String(err) })
323
+ throw err
324
+ } finally {
325
+ rmSync(out, { force: true })
326
+ }
327
+ }
328
+ }
329
+
330
+ /** Prepare an agent's text for the speaker: strip what only makes sense on a screen and
331
+ * cap the length — a spoken wall of text is unusable. The modality frame already asks
332
+ * agents for short plain answers; this is the floor for when one forgets. */
333
+ export function speakableText(message: string, maxChars = TTS_MAX_CHARS): string {
334
+ let text = message
335
+ .replace(/```[\s\S]*?```/g, ' (фрагмент кода) ')
336
+ .replace(/`([^`]*)`/g, '$1')
337
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
338
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
339
+ .replace(/https?:\/\/\S+/g, ' ссылка ')
340
+ .replace(/^\s{0,3}#{1,6}\s+/gm, '')
341
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
342
+ .replace(/(^|\s)\*([^*]+)\*/g, '$1$2')
343
+ .replace(/^\s*[-*•]\s+/gm, '')
344
+ .replace(/[ \t]+/g, ' ')
345
+ .replace(/\n{2,}/g, '\n')
346
+ .trim()
347
+ if (text.length > maxChars) {
348
+ const cut = text.slice(0, maxChars)
349
+ const lastStop = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '))
350
+ text = `${(lastStop > maxChars * 0.5 ? cut.slice(0, lastStop + 1) : cut).trim()} … Дальше — в тексте.`
351
+ }
352
+ return text
353
+ }