@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.
package/src/wake.ts ADDED
@@ -0,0 +1,279 @@
1
+ // wake.ts — «кому из флота адресована реплика».
2
+ //
3
+ // HYBRID wake model (design intent agreed with Arthur, 22.07): the passport carries a
4
+ // small CURATED map «wake word → target peer» plus a default target. A wake word that is
5
+ // a peer's name addresses that peer directly («Борис, привет» → boris); a neutral wake
6
+ // word addresses the default agent, who dispatches onward. A word per each of 30+ peers
7
+ // was explicitly REJECTED — it couples the spoken vocabulary to the roster and multiplies
8
+ // false triggers — so nothing here ever sweeps the fleet registry for addressable names.
9
+ // The registry is consulted only for peers the operator ALREADY named in the map, to
10
+ // enrich them with their own human name («наталья» → «наталья вячеславовна») and their
11
+ // voice.
12
+ //
13
+ // Why the address is matched on the TRANSCRIPT rather than by an acoustic wake-word model
14
+ // (openWakeWord et al.): an acoustic detector needs a trained model per phrase, and Фаза 3
15
+ // is proving the pipeline end to end — transcript matching gives the same routing with
16
+ // zero training and no per-name asset to maintain. Privacy is preserved by the property
17
+ // that made an on-device detector attractive in the first place: the recognizer is the
18
+ // LOCAL floor of voice-connect (mlx-whisper on this host), so no audio leaves the machine
19
+ // to decide whether a name was spoken. The cost is running the recognizer on every voiced
20
+ // segment; endpointing keeps that to actual speech, and an acoustic pre-gate (openWakeWord
21
+ // on the curated map — cheap in parallel, one shared feature backbone) is the natural
22
+ // Фаза-4 latency upgrade behind this same interface.
23
+
24
+ import { readFileSync } from 'fs'
25
+ import { homedir } from 'os'
26
+ import { join } from 'path'
27
+
28
+ export type FleetPeer = {
29
+ personality: string
30
+ intelligence?: string
31
+ description?: string
32
+ /** Per-peer TTS voice from `interfaces.voice` — so each agent answers in its own voice. */
33
+ voice?: Record<string, string>
34
+ }
35
+
36
+ export type Address = {
37
+ personality: string
38
+ /** Every spoken form that addresses this peer, normalized. */
39
+ aliases: string[]
40
+ }
41
+
42
+ const FILLERS = new Set(['эй', 'окей', 'ок', 'слушай', 'hey', 'ok', 'okay', 'привет'])
43
+
44
+ /** Normalize a spoken/written form for matching: lowercase, ё→е, punctuation dropped. */
45
+ export function normalizeSpoken(text: string): string {
46
+ return text
47
+ .toLowerCase()
48
+ .replace(/ё/g, 'е')
49
+ .replace(/[^\p{L}\p{N}\s-]/gu, ' ')
50
+ .replace(/\s+/g, ' ')
51
+ .trim()
52
+ }
53
+
54
+ function levenshtein(a: string, b: string): number {
55
+ if (a === b) return 0
56
+ const m = a.length
57
+ const n = b.length
58
+ if (m === 0 || n === 0) return Math.max(m, n)
59
+ let prev = Array.from({ length: n + 1 }, (_, i) => i)
60
+ for (let i = 1; i <= m; i++) {
61
+ const cur = [i]
62
+ for (let j = 1; j <= n; j++) {
63
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
64
+ }
65
+ prev = cur
66
+ }
67
+ return prev[n]
68
+ }
69
+
70
+ /** Speech recognition mangles the tail of a name («Борис» → «борись»); allow one edit on
71
+ * a name long enough that a single-edit collision is implausible. */
72
+ function tokenMatches(spoken: string, alias: string): boolean {
73
+ if (spoken === alias) return true
74
+ if (alias.length >= 5 && Math.abs(spoken.length - alias.length) <= 1) return levenshtein(spoken, alias) <= 1
75
+ return false
76
+ }
77
+
78
+ /** Derive the spoken forms of a peer: its personality plus the human name that opens its
79
+ * description («Борис — PM и архитектор…» → «борис»). This makes the fleet's own
80
+ * registry the single source of truth for names; no separate alias file to drift. */
81
+ export function peerAliases(peer: FleetPeer): string[] {
82
+ const out = new Set<string>()
83
+ const personality = normalizeSpoken(peer.personality)
84
+ if (personality) out.add(personality)
85
+ const head = normalizeSpoken((peer.description ?? '').split(/\s+[—–-]\s+/)[0] ?? '')
86
+ if (head && head.length <= 40) {
87
+ out.add(head)
88
+ const first = head.split(' ')[0]
89
+ // «mr booker» → also «booker»; «наталья вячеславовна» → also «наталья».
90
+ if (first && first.length >= 3 && !['mr', 'mrs', 'ms', 'dr'].includes(first)) out.add(first)
91
+ const words = head.split(' ')
92
+ if (words.length > 1 && ['mr', 'mrs', 'ms', 'dr'].includes(words[0])) out.add(words.slice(1).join(' '))
93
+ }
94
+ return [...out].filter(Boolean)
95
+ }
96
+
97
+ function iapeerRoot(): string {
98
+ const override = process.env.IAPEER_ROOT?.trim()
99
+ return override || join(homedir(), '.iapeer')
100
+ }
101
+
102
+ export function readFleetPeers(path = join(iapeerRoot(), 'peers-profiles.json')): FleetPeer[] {
103
+ try {
104
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as { peers?: unknown }
105
+ if (!Array.isArray(raw.peers)) return []
106
+ return raw.peers
107
+ .filter((p): p is Record<string, unknown> => !!p && typeof p === 'object')
108
+ .map(p => ({
109
+ personality: String(p.personality ?? ''),
110
+ intelligence: typeof p.intelligence === 'string' ? p.intelligence : undefined,
111
+ description: typeof p.description === 'string' ? p.description : undefined,
112
+ voice:
113
+ p.interfaces && typeof p.interfaces === 'object' && (p.interfaces as any).voice
114
+ ? ((p.interfaces as any).voice as Record<string, string>)
115
+ : undefined,
116
+ }))
117
+ .filter(p => p.personality)
118
+ } catch {
119
+ return []
120
+ }
121
+ }
122
+
123
+ /** The voicetalk peer binding — the hybrid wake map. `wake_words` may be written as a
124
+ * map (word → peer) or as a bare list of words, in which case each word is resolved to
125
+ * the peer it names, falling back to `default_target`. `wake_word` is the legacy
126
+ * single-word form and is folded into the same map. */
127
+ export type VoicetalkBinding = {
128
+ wake_word?: string
129
+ wake_words?: Record<string, string> | string[]
130
+ default_target?: string
131
+ device?: string
132
+ }
133
+
134
+ export type AddressBookOptions = {
135
+ /** The speaker — never addressable by their own voice. */
136
+ owner: string
137
+ binding: VoicetalkBinding
138
+ peers?: FleetPeer[]
139
+ }
140
+
141
+ /** Normalize the two accepted spellings of the wake map into word → target|undefined. */
142
+ export function wakeEntries(binding: VoicetalkBinding): Array<[string, string | undefined]> {
143
+ const out: Array<[string, string | undefined]> = []
144
+ const push = (word: unknown, target?: unknown) => {
145
+ const w = normalizeSpoken(String(word ?? ''))
146
+ if (!w) return
147
+ const t = typeof target === 'string' && target.trim() ? target.trim() : undefined
148
+ out.push([w, t])
149
+ }
150
+ const map = binding.wake_words
151
+ if (Array.isArray(map)) for (const word of map) push(word)
152
+ else if (map && typeof map === 'object') for (const [word, target] of Object.entries(map)) push(word, target)
153
+ if (binding.wake_word) push(binding.wake_word)
154
+ return out
155
+ }
156
+
157
+ /** Build the address book from the CURATED wake map. Every entry resolves to a target:
158
+ * the explicit one, else the peer whose name the wake word is, else the default target.
159
+ * A wake word with no resolvable target is dropped (and `doctor` reports it) — routing
160
+ * speech to a guessed peer is worse than not routing it. */
161
+ export function buildAddressBook(opts: AddressBookOptions): Address[] {
162
+ const owner = normalizeSpoken(opts.owner)
163
+ const peers = opts.peers ?? readFleetPeers()
164
+ const byName = (word: string): string | undefined =>
165
+ peers.find(p => normalizeSpoken(p.personality) === word)?.personality ??
166
+ peers.find(p => peerAliases(p).includes(word))?.personality
167
+ const book = new Map<string, Set<string>>()
168
+ const add = (personality: string, aliases: string[]) => {
169
+ const key = personality.trim()
170
+ if (!key || normalizeSpoken(key) === owner) return
171
+ const set = book.get(key) ?? new Set<string>()
172
+ for (const a of aliases) if (a) set.add(a)
173
+ book.set(key, set)
174
+ }
175
+ const entries = wakeEntries(opts.binding)
176
+ if (entries.length === 0 && opts.binding.default_target) {
177
+ // Out-of-the-box default (22.07): the DEFAULT AGENT'S NAME is the activation word —
178
+ // no separate neutral wake word is introduced. A passport carrying only
179
+ // `default_target: "darwin"` is therefore already usable: «Дарвин, …».
180
+ const target = opts.binding.default_target
181
+ const known = peers.find(p => p.personality === target)
182
+ entries.push(...[normalizeSpoken(target), ...(known ? peerAliases(known) : [])].map(w => [w, target] as [string, string]))
183
+ }
184
+ for (const [word, explicit] of entries) {
185
+ const target = explicit ?? byName(word) ?? opts.binding.default_target
186
+ if (!target) continue
187
+ // The target is addressable by: the configured word, its own personality, and — for a
188
+ // peer the registry knows — its human name. Bounded to CONFIGURED targets only.
189
+ const known = peers.find(p => p.personality === target)
190
+ add(target, [word, normalizeSpoken(target), ...(known ? peerAliases(known) : [])])
191
+ }
192
+ return [...book.entries()].map(([personality, aliases]) => ({ personality, aliases: [...aliases] }))
193
+ }
194
+
195
+ /** Wake words the operator configured that resolve to nothing — surfaced by `doctor`. */
196
+ export function unresolvedWakeWords(binding: VoicetalkBinding, peers?: FleetPeer[]): string[] {
197
+ const roster = peers ?? readFleetPeers()
198
+ const out: string[] = []
199
+ for (const [word, explicit] of wakeEntries(binding)) {
200
+ const target =
201
+ explicit ??
202
+ roster.find(p => normalizeSpoken(p.personality) === word)?.personality ??
203
+ roster.find(p => peerAliases(p).includes(word))?.personality ??
204
+ binding.default_target
205
+ if (!target) out.push(word)
206
+ }
207
+ return out
208
+ }
209
+
210
+ export type WakeMatch = {
211
+ target: string
212
+ /** The utterance with the address (and any leading filler) stripped. */
213
+ body: string
214
+ alias: string
215
+ }
216
+
217
+ /** Does this transcript open by addressing a fleet peer? Longest alias wins, so
218
+ * «наталья вячеславовна» is preferred over «наталья». */
219
+ export function matchWake(transcript: string, book: Address[]): WakeMatch | null {
220
+ const normalized = normalizeSpoken(transcript)
221
+ if (!normalized) return null
222
+ let tokens = normalized.split(' ')
223
+ const rawTokens = transcript.trim().split(/\s+/)
224
+ let offset = 0
225
+ // Skip a leading filler («эй, Борис…», «окей, Борис…») — one only.
226
+ if (tokens.length > 1 && FILLERS.has(tokens[0])) {
227
+ tokens = tokens.slice(1)
228
+ offset = 1
229
+ }
230
+ const candidates: Array<{ target: string; alias: string; words: string[] }> = []
231
+ for (const entry of book) {
232
+ for (const alias of entry.aliases) candidates.push({ target: entry.personality, alias, words: alias.split(' ') })
233
+ }
234
+ candidates.sort((a, b) => b.words.length - a.words.length || b.alias.length - a.alias.length)
235
+ for (const cand of candidates) {
236
+ if (tokens.length < cand.words.length) continue
237
+ const ok = cand.words.every((w, i) => tokenMatches(tokens[i], w))
238
+ if (!ok) continue
239
+ // Rebuild the body from the ORIGINAL transcript so punctuation and casing of the
240
+ // message survive — only the address is stripped.
241
+ const body = rawTokens
242
+ .slice(offset + cand.words.length)
243
+ .join(' ')
244
+ .replace(/^[\s,.!:;—–-]+/, '')
245
+ .trim()
246
+ return { target: cand.target, alias: cand.alias, body }
247
+ }
248
+ return null
249
+ }
250
+
251
+ /** Whisper-family recognizers emit stock phrases on near-silence («Продолжение
252
+ * следует…», «Субтитры сделал…»). They are not speech; drop them before matching so a
253
+ * quiet room cannot fabricate an utterance. */
254
+ const HALLUCINATIONS = [
255
+ 'продолжение следует',
256
+ 'субтитры',
257
+ 'редактор субтитров',
258
+ 'корректор',
259
+ 'спасибо за просмотр',
260
+ 'подписывайтесь на канал',
261
+ 'thanks for watching',
262
+ 'subtitles by',
263
+ 'thank you',
264
+ 'you',
265
+ ]
266
+
267
+ export function isLikelyHallucination(transcript: string): boolean {
268
+ const n = normalizeSpoken(transcript)
269
+ if (!n) return true
270
+ if (n.length <= 2) return true
271
+ return HALLUCINATIONS.some(h => n === h || n.startsWith(h))
272
+ }
273
+
274
+ /** Names spoken to the recognizer as a decoder prompt — biases spelling toward the
275
+ * fleet's own vocabulary so the matcher sees «Борис», not «Барис». */
276
+ export function sttPrompt(book: Address[]): string {
277
+ const names = book.flatMap(a => a.aliases).slice(0, 40)
278
+ return names.length ? `Обращение к ассистенту по имени: ${names.join(', ')}.` : ''
279
+ }