@agfpd/voicetalk-runtime 0.1.1 → 0.2.1

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