@agfpd/voicetalk-runtime 0.1.0 → 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/README.md +15 -11
- package/docs/README.md +55 -4
- package/docs/ru/README.md +54 -4
- package/package.json +1 -1
- package/src/audio.ts +428 -0
- package/src/cli.ts +379 -111
- package/src/selfConfig.ts +32 -1
- package/src/voiceConnect.ts +191 -0
- package/src/voiceSession.ts +353 -0
- package/src/wake.ts +279 -0
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// voicetalk-runtime — the voice presence-runtime router (Фаза
|
|
2
|
+
// voicetalk-runtime — the voice presence-runtime router (Фаза 3: live voice pipeline).
|
|
3
3
|
//
|
|
4
4
|
// voicetalk is the third *-runtime after telegram and notifier: it makes the host's
|
|
5
5
|
// microphone + speaker one more runtime through which the OWNER (a human, natural peer)
|
|
@@ -8,18 +8,19 @@
|
|
|
8
8
|
// both directions through its stdio + a local inbound channel:
|
|
9
9
|
//
|
|
10
10
|
// • OUTBOUND (fleet → owner): the supervisor writes <iap>…</iap> envelopes to this
|
|
11
|
-
// process's STDIN; the reader parses each and hands it to the AudioSink —
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
11
|
+
// process's STDIN; the reader parses each and hands it to the AudioSink — TTS through
|
|
12
|
+
// voice-connect onto the owner's speaker (and a spoken.log line, always).
|
|
13
|
+
// • INBOUND (owner → fleet): the microphone runs always-on; endpointing cuts utterances,
|
|
14
|
+
// voice-connect transcribes them, and an utterance that opens by addressing a fleet
|
|
15
|
+
// peer («Борис, привет») is framed for the spoken modality and delivered to that peer
|
|
16
|
+
// through the daemon (`iapeer send`) FROM the owner's identity (PEER_RUNTIME=voicetalk).
|
|
17
|
+
// The `say` subcommand injects an utterance over the local socket without the mic —
|
|
18
|
+
// the Фаза-2 stand-in, kept as the debugging seam and the automated E2E's inbound.
|
|
19
19
|
//
|
|
20
|
-
// Speech ↔ text is NOT in this package — STT/TTS live in the shared voice-connect service
|
|
21
|
-
//
|
|
22
|
-
//
|
|
20
|
+
// Speech ↔ text is NOT in this package — STT/TTS live in the shared voice-connect service,
|
|
21
|
+
// a pure HTTP backend this runtime CONSUMES (src/voiceConnect.ts is the only door to it).
|
|
22
|
+
// Host audio mechanics (capture, endpointing, playback) are in src/audio.ts; turn-taking
|
|
23
|
+
// and the address book in src/voiceSession.ts + src/wake.ts.
|
|
23
24
|
//
|
|
24
25
|
// NO sender-guard (decision: «Sender-guard: NO for the MVP»): the speaker is a single
|
|
25
26
|
// local endpoint of the owner, so any fleet peer may answer by voice — there is no
|
|
@@ -41,8 +42,17 @@ import { randomUUID } from 'crypto'
|
|
|
41
42
|
import { spawn } from 'child_process'
|
|
42
43
|
import { BIN_NAME, IAPEER_DIR, NAME_RE, PACKAGE_NAME, PEER_PROFILE_FILE, RUNTIME, VERSION } from './constants.ts'
|
|
43
44
|
import { selfInstall } from './selfInstall.ts'
|
|
44
|
-
import { runSelfConfig } from './selfConfig.ts'
|
|
45
|
+
import { parseWakeWords, runSelfConfig } from './selfConfig.ts'
|
|
45
46
|
import { writeJsonAtomic } from './fsAtomic.ts'
|
|
47
|
+
import { VoiceSession } from './voiceSession.ts'
|
|
48
|
+
import { voiceHealth } from './voiceConnect.ts'
|
|
49
|
+
import { listInputDevices, resolveInputDevice } from './audio.ts'
|
|
50
|
+
import { buildAddressBook, unresolvedWakeWords, wakeEntries, type VoicetalkBinding } from './wake.ts'
|
|
51
|
+
|
|
52
|
+
// Фаза 3: the audio half is LIVE by default (mic + wake-word + STT inbound, TTS outbound).
|
|
53
|
+
// VOICETALK_AUDIO=0 falls back to the Фаза-2 text carcass — no microphone, no speaker —
|
|
54
|
+
// which is what the automated E2E and any headless host run on.
|
|
55
|
+
const VOICETALK_AUDIO_ENABLED = process.env.VOICETALK_AUDIO !== '0'
|
|
46
56
|
|
|
47
57
|
// Inbound (owner → fleet) delivery is synchronous from the speaker's point of view: if
|
|
48
58
|
// the core router refuses the send (ok=false/err) or our local waiter times out, the
|
|
@@ -73,6 +83,20 @@ function isIntelligence(value: unknown): value is Intelligence {
|
|
|
73
83
|
return value === 'natural' || value === 'artificial' || value === 'absent'
|
|
74
84
|
}
|
|
75
85
|
|
|
86
|
+
// Read-compat map for legacy intelligence values coming off the wire (mirrors the core
|
|
87
|
+
// codec's normalizeIntelligenceValue): human → natural · scripted → absent. A genuinely
|
|
88
|
+
// unknown value is dropped, not invented.
|
|
89
|
+
const LEGACY_INTELLIGENCE: Readonly<Record<string, Intelligence>> = {
|
|
90
|
+
human: 'natural',
|
|
91
|
+
scripted: 'absent',
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeIntelligenceValue(value: unknown): Intelligence | undefined {
|
|
95
|
+
if (typeof value !== 'string') return undefined
|
|
96
|
+
if (isIntelligence(value)) return value
|
|
97
|
+
return LEGACY_INTELLIGENCE[value]
|
|
98
|
+
}
|
|
99
|
+
|
|
76
100
|
const HUMAN_RUNTIMES = new Set(['telegram', 'voicetalk', 'discord', 'matrix', 'email', 'web'])
|
|
77
101
|
const SCRIPTED_RUNTIMES = new Set(['webhook', 'api', 'cron'])
|
|
78
102
|
|
|
@@ -82,12 +106,12 @@ function defaultIntelligenceForRuntime(runtime: string): Intelligence {
|
|
|
82
106
|
return 'artificial'
|
|
83
107
|
}
|
|
84
108
|
|
|
85
|
-
/** The voicetalk peer binding
|
|
86
|
-
*
|
|
87
|
-
|
|
88
|
-
wake_word
|
|
89
|
-
|
|
90
|
-
|
|
109
|
+
/** The voicetalk peer binding. STT/TTS/voice config is DELIBERATELY absent — it lives in
|
|
110
|
+
* voice-connect, not the passport (design dialog 21.06). Фаза 3 evolves the single
|
|
111
|
+
* `wake_word` into the HYBRID wake map (22.07): a curated «word → target peer» set plus
|
|
112
|
+
* a default agent for the neutral word. `wake_word` stays readable as the legacy
|
|
113
|
+
* single-word form, so an already-provisioned peer keeps working untouched. */
|
|
114
|
+
type VoicetalkInterface = VoicetalkBinding
|
|
91
115
|
|
|
92
116
|
type PeerProfile = {
|
|
93
117
|
personality: string
|
|
@@ -105,6 +129,9 @@ type IapEnvelope = {
|
|
|
105
129
|
fromPersonality: string
|
|
106
130
|
fromRuntime: string
|
|
107
131
|
fromIntelligence?: Intelligence
|
|
132
|
+
/** Sender's dispatch instant (`ts` attribute, envelope-compaction F, core 0.4.86) —
|
|
133
|
+
* full local ISO on the wire; absent on legacy envelopes. */
|
|
134
|
+
sentAt?: string
|
|
108
135
|
topic?: string
|
|
109
136
|
attachments: string[]
|
|
110
137
|
message: string
|
|
@@ -136,10 +163,12 @@ function usage(): string {
|
|
|
136
163
|
${BIN_NAME} # self-install (npx contract): bin on PATH + manifest
|
|
137
164
|
${BIN_NAME} self-install # explicit self-install (idempotent)
|
|
138
165
|
${BIN_NAME} self-config # per-peer self-config hook (foundation-invoked)
|
|
139
|
-
${BIN_NAME} interface --wake-
|
|
140
|
-
|
|
141
|
-
${BIN_NAME}
|
|
142
|
-
${BIN_NAME}
|
|
166
|
+
${BIN_NAME} interface --wake-words «дарвин=darwin,борис=boris» [--default-target <peer>] [--device <d>]
|
|
167
|
+
# set this peer's voicetalk binding (wake map)
|
|
168
|
+
${BIN_NAME} run # router loop: live mic ↔ speaker (VOICETALK_AUDIO=0 → text only)
|
|
169
|
+
${BIN_NAME} say --peer <personality> <text…> # inject an utterance without the mic (debug seam)
|
|
170
|
+
${BIN_NAME} devices [--json] # list audio input devices (for --device)
|
|
171
|
+
${BIN_NAME} doctor [--json] # health: binding, manifest, voice-connect, mic
|
|
143
172
|
${BIN_NAME} --version
|
|
144
173
|
${BIN_NAME} --help`
|
|
145
174
|
}
|
|
@@ -230,6 +259,15 @@ function logEvt(evt: string, fields: Record<string, unknown>): void {
|
|
|
230
259
|
process.stderr.write(`${BIN_NAME} ${JSON.stringify({ ts: nowIso(), evt, ...fields })}\n`)
|
|
231
260
|
}
|
|
232
261
|
|
|
262
|
+
/** Is an executable resolvable on PATH? (`doctor`'s host-tooling probe.) */
|
|
263
|
+
function hasBinary(bin: string): Promise<boolean> {
|
|
264
|
+
return new Promise(resolve => {
|
|
265
|
+
const child = spawn('command', ['-v', bin], { stdio: 'ignore', shell: true })
|
|
266
|
+
child.on('error', () => resolve(false))
|
|
267
|
+
child.on('close', code => resolve(code === 0))
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
233
271
|
function oneLine(text: string, max = 200): string {
|
|
234
272
|
const flat = text.replace(/\s+/g, ' ').trim()
|
|
235
273
|
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
|
|
@@ -359,6 +397,8 @@ function setVoicetalkInterface(cwd: string, patch: VoicetalkInterface): Record<s
|
|
|
359
397
|
? { ...((interfaces as { voicetalk: Record<string, unknown> }).voicetalk) }
|
|
360
398
|
: {}
|
|
361
399
|
if (patch.wake_word !== undefined) voicetalk.wake_word = patch.wake_word
|
|
400
|
+
if (patch.wake_words !== undefined) voicetalk.wake_words = patch.wake_words
|
|
401
|
+
if (patch.default_target !== undefined) voicetalk.default_target = patch.default_target
|
|
362
402
|
if (patch.device !== undefined) voicetalk.device = patch.device
|
|
363
403
|
;(interfaces as Record<string, unknown>).voicetalk = voicetalk
|
|
364
404
|
const merged = { ...raw, interfaces }
|
|
@@ -367,80 +407,111 @@ function setVoicetalkInterface(cwd: string, patch: VoicetalkInterface): Record<s
|
|
|
367
407
|
}
|
|
368
408
|
|
|
369
409
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
370
|
-
// IAP envelope parsing
|
|
410
|
+
// IAP envelope parsing — port of the foundation codec (iapeer src/codec/index.ts,
|
|
411
|
+
// envelope-compaction F, 0.4.86): CDATA-aware on BOTH tag sides (В37), В38
|
|
412
|
+
// false-start resync, READ-BOTH attr/tag names (legacy wire + compact
|
|
413
|
+
// presentation) and the additive `ts` attribute.
|
|
371
414
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
372
415
|
|
|
373
416
|
function unescapeAttr(value: string): string {
|
|
417
|
+
// Reverse of the encoder's escapeAttr. Order matters: & LAST so an escaped
|
|
418
|
+
// "&lt;" in the source does not get double-decoded.
|
|
374
419
|
return value.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')
|
|
375
420
|
}
|
|
376
421
|
|
|
422
|
+
/** NAME-ANCHORED attribute lookup. The anchor `(?:^|\s)` is load-bearing: an unanchored
|
|
423
|
+
* regex lets `runtime="` match the TAIL of `from-runtime="…"` (and `intelligence="` the
|
|
424
|
+
* tail of `from-intelligence="…"`) — coincidentally right under today's attr order, a
|
|
425
|
+
* mine under read-both (the short-name lookup must NOT be satisfied by a legacy long
|
|
426
|
+
* name, or the fromRuntime modality cue reads the wrong value). */
|
|
377
427
|
function attrValue(attrs: string, name: string): string | undefined {
|
|
378
|
-
const m = new RegExp(
|
|
428
|
+
const m = new RegExp(`(?:^|\\s)${name}="([^"]*)"`).exec(attrs)
|
|
379
429
|
return m ? unescapeAttr(m[1]) : undefined
|
|
380
430
|
}
|
|
381
431
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
* section
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
inCdata = false
|
|
397
|
-
i += 3
|
|
398
|
-
continue
|
|
399
|
-
}
|
|
400
|
-
i++
|
|
401
|
-
continue
|
|
402
|
-
}
|
|
403
|
-
if (s.startsWith('<![CDATA[', i)) {
|
|
404
|
-
inCdata = true
|
|
405
|
-
i += '<![CDATA['.length
|
|
432
|
+
const CDATA_OPEN = '<![CDATA['
|
|
433
|
+
const CDATA_CLOSE = ']]>'
|
|
434
|
+
|
|
435
|
+
/** Find `needle` at/after `start`, skipping CDATA sections WHOLESALE — `]]>` inside a
|
|
436
|
+
* section is its terminator, never a match — so a message body literally containing
|
|
437
|
+
* "</message>"/"</iap>" never truncates the envelope. Returns -1 when not found OR when
|
|
438
|
+
* an unterminated CDATA section is hit (incomplete buffer → the caller waits). */
|
|
439
|
+
function indexOfOutsideCdata(buffer: string, needle: string, start = 0): number {
|
|
440
|
+
let i = start
|
|
441
|
+
while (i < buffer.length) {
|
|
442
|
+
if (buffer.startsWith(CDATA_OPEN, i)) {
|
|
443
|
+
const term = buffer.indexOf(CDATA_CLOSE, i + CDATA_OPEN.length)
|
|
444
|
+
if (term < 0) return -1 // unterminated CDATA → incomplete buffer
|
|
445
|
+
i = term + CDATA_CLOSE.length
|
|
406
446
|
continue
|
|
407
447
|
}
|
|
408
|
-
if (
|
|
448
|
+
if (buffer.startsWith(needle, i)) return i
|
|
409
449
|
i++
|
|
410
450
|
}
|
|
411
451
|
return -1
|
|
412
452
|
}
|
|
413
453
|
|
|
454
|
+
/** Extract and decode the content of `<tag>…</tag>`, treating the body as a sequence of
|
|
455
|
+
* CDATA sections (and any stray raw text) and CONCATENATING them — which both ignores
|
|
456
|
+
* quoted `</tag>`/`</iap>` inside CDATA and reverses the encoder's
|
|
457
|
+
* `]]>` → `]]]]><![CDATA[>` split. В37: the OPEN tag is located CDATA-aware too (the
|
|
458
|
+
* close side already was) — a message whose CDATA body QUOTES `<attachments>…</attachments>`
|
|
459
|
+
* otherwise mints phantom attachments, and a quoted `<message>` hijacks the real body;
|
|
460
|
+
* for this runtime the corrupted text would go straight to TTS. */
|
|
414
461
|
function tagContent(xml: string, tag: string): string | undefined {
|
|
415
462
|
const open = `<${tag}>`
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
463
|
+
const close = `</${tag}>`
|
|
464
|
+
const openIdx = indexOfOutsideCdata(xml, open, 0)
|
|
465
|
+
if (openIdx < 0) return undefined
|
|
466
|
+
let i = openIdx + open.length
|
|
467
|
+
let out = ''
|
|
468
|
+
while (i < xml.length) {
|
|
469
|
+
if (xml.startsWith(CDATA_OPEN, i)) {
|
|
470
|
+
const term = xml.indexOf(CDATA_CLOSE, i + CDATA_OPEN.length)
|
|
471
|
+
if (term < 0) {
|
|
472
|
+
// Unterminated CDATA — malformed; treat the remainder as raw content.
|
|
473
|
+
return out + xml.slice(i + CDATA_OPEN.length)
|
|
474
|
+
}
|
|
475
|
+
out += xml.slice(i + CDATA_OPEN.length, term)
|
|
476
|
+
i = term + CDATA_CLOSE.length
|
|
477
|
+
continue
|
|
478
|
+
}
|
|
479
|
+
if (xml.startsWith(close, i)) return out
|
|
480
|
+
out += xml[i]
|
|
481
|
+
i++
|
|
482
|
+
}
|
|
483
|
+
return undefined // close tag never reached outside CDATA
|
|
422
484
|
}
|
|
423
485
|
|
|
424
486
|
export function parseIapEnvelope(xml: string): IapEnvelope {
|
|
425
487
|
// Fold \r\n and lone \r → \n once (defensive: a raw-mode pty stdin can surface bare
|
|
426
|
-
// CRs)
|
|
488
|
+
// CRs). DELIBERATE deviation from the core codec, which leaves the fold to the
|
|
489
|
+
// transport: here the parse boundary IS the transport boundary — this function is
|
|
490
|
+
// only ever fed envelopes sliced off this process's stdin.
|
|
427
491
|
xml = xml.replace(/\r\n?/g, '\n')
|
|
428
492
|
const open = /^<iap\s+([^>]*)>/.exec(xml.trim())
|
|
429
493
|
if (!open) throw new VoicetalkRuntimeError('invalid IAP envelope: missing <iap ...>')
|
|
430
|
-
|
|
431
|
-
|
|
494
|
+
// READ-BOTH (envelope-compaction F): the legacy wire names AND the compact
|
|
495
|
+
// presentation names (`from`/`runtime`/`intelligence`, `<msg>`, `ts`) both decode,
|
|
496
|
+
// short names winning when both are present — so a future wire flip to the compact
|
|
497
|
+
// form cannot blank fromRuntime (the spoken-modality cue) on this side.
|
|
498
|
+
const fromPersonality = attrValue(open[1], 'from') ?? attrValue(open[1], 'from-personality')
|
|
499
|
+
const fromRuntime = attrValue(open[1], 'runtime') ?? attrValue(open[1], 'from-runtime')
|
|
432
500
|
if (!fromPersonality || !fromRuntime) {
|
|
433
501
|
throw new VoicetalkRuntimeError('invalid IAP envelope: missing from-personality/from-runtime')
|
|
434
502
|
}
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
503
|
+
const fromIntelligence = normalizeIntelligenceValue(
|
|
504
|
+
attrValue(open[1], 'intelligence') ?? attrValue(open[1], 'from-intelligence'),
|
|
505
|
+
)
|
|
506
|
+
const message = tagContent(xml, 'message') ?? tagContent(xml, 'msg')
|
|
438
507
|
if (message === undefined) throw new VoicetalkRuntimeError('invalid IAP envelope: missing message')
|
|
439
508
|
const attachmentsRaw = tagContent(xml, 'attachments')
|
|
509
|
+
const sentAt = attrValue(open[1], 'ts')
|
|
440
510
|
return {
|
|
441
511
|
fromPersonality,
|
|
442
512
|
fromRuntime,
|
|
443
513
|
...(fromIntelligence ? { fromIntelligence } : {}),
|
|
514
|
+
...(sentAt ? { sentAt } : {}),
|
|
444
515
|
topic: attrValue(open[1], 'topic'),
|
|
445
516
|
attachments: attachmentsRaw
|
|
446
517
|
? attachmentsRaw.split(/\r?\n/).map(item => item.trim()).filter(Boolean)
|
|
@@ -449,21 +520,64 @@ export function parseIapEnvelope(xml: string): IapEnvelope {
|
|
|
449
520
|
}
|
|
450
521
|
}
|
|
451
522
|
|
|
523
|
+
// В38 — a real envelope's open tag is bounded: personality/runtime ≤32 chars each,
|
|
524
|
+
// topic ≤200, intelligence one word, plus attr names and escaping. 1 KiB is far above
|
|
525
|
+
// any legitimate open tag; a longer '>'-less run after `<iap ` is prose, not a tag.
|
|
526
|
+
const MAX_OPEN_TAG_LEN = 1024
|
|
527
|
+
|
|
528
|
+
/** Classify the text at a `<iap ` occurrence: a complete VALID open tag (required attrs
|
|
529
|
+
* present under either naming — legacy wire or compact presentation), a
|
|
530
|
+
* complete-but-INVALID one / overlong tagless run (prose that merely contains the
|
|
531
|
+
* marker), or INCOMPLETE (no '>' yet within bounds — wait for the next chunk). */
|
|
532
|
+
function openTagVerdict(candidate: string): 'valid' | 'invalid' | 'incomplete' {
|
|
533
|
+
const m = /^<iap\s+([^>]*)>/.exec(candidate)
|
|
534
|
+
if (!m) {
|
|
535
|
+
const unclosed = candidate.indexOf('>') < 0
|
|
536
|
+
return unclosed && candidate.length <= MAX_OPEN_TAG_LEN ? 'incomplete' : 'invalid'
|
|
537
|
+
}
|
|
538
|
+
if (m[0].length > MAX_OPEN_TAG_LEN) return 'invalid'
|
|
539
|
+
const hasPersonality = m[1].includes('from-personality="') || /(?:^|\s)from="/.test(m[1])
|
|
540
|
+
const hasRuntime = m[1].includes('from-runtime="') || /(?:^|\s)runtime="/.test(m[1])
|
|
541
|
+
return hasPersonality && hasRuntime ? 'valid' : 'invalid'
|
|
542
|
+
}
|
|
543
|
+
|
|
452
544
|
/** Slice complete <iap>…</iap> envelopes out of a streaming buffer, returning the leftover
|
|
453
|
-
* tail (a partial envelope still being written). CDATA-aware close matching.
|
|
545
|
+
* tail (a partial envelope still being written). CDATA-aware close matching.
|
|
546
|
+
*
|
|
547
|
+
* В38 — a FALSE start (prose that merely contains `<iap `, e.g. a quoted tool
|
|
548
|
+
* description) is never committed to: the open tag is validated first, and a candidate
|
|
549
|
+
* whose decode fails RESYNCS one char forward instead of being kept (which used to
|
|
550
|
+
* either swallow the NEXT real envelope into a mis-attributed blob, or park the buffer
|
|
551
|
+
* forever waiting for a close that never comes). */
|
|
454
552
|
export function extractIapEnvelopes(buffer: string): { envelopes: string[]; rest: string } {
|
|
455
553
|
const envelopes: string[] = []
|
|
456
554
|
let rest = buffer
|
|
457
555
|
while (true) {
|
|
458
556
|
const start = rest.indexOf('<iap ')
|
|
459
557
|
if (start < 0) {
|
|
460
|
-
|
|
558
|
+
// Keep a small tail in case `<iap ` is split across chunk boundaries.
|
|
559
|
+
return { envelopes, rest: rest.slice(Math.max(0, rest.length - '<iap '.length)) }
|
|
461
560
|
}
|
|
462
561
|
if (start > 0) rest = rest.slice(start)
|
|
463
|
-
const
|
|
464
|
-
if (
|
|
562
|
+
const verdict = openTagVerdict(rest)
|
|
563
|
+
if (verdict === 'incomplete') return { envelopes, rest } // open tag split across chunks → wait
|
|
564
|
+
if (verdict === 'invalid') {
|
|
565
|
+
rest = rest.slice(1) // false start: skip past this '<' and rescan for the next '<iap '
|
|
566
|
+
continue
|
|
567
|
+
}
|
|
568
|
+
const end = indexOfOutsideCdata(rest, '</iap>', '<iap '.length)
|
|
569
|
+
if (end < 0) return { envelopes, rest } // incomplete (or mid-CDATA) → wait
|
|
465
570
|
const envelopeEnd = end + '</iap>'.length
|
|
466
|
-
|
|
571
|
+
const candidate = rest.slice(0, envelopeEnd)
|
|
572
|
+
try {
|
|
573
|
+
parseIapEnvelope(candidate)
|
|
574
|
+
} catch {
|
|
575
|
+
// Structurally envelope-shaped but undecodable → a false start after all;
|
|
576
|
+
// resync so a REAL envelope inside/behind the candidate is still found.
|
|
577
|
+
rest = rest.slice(1)
|
|
578
|
+
continue
|
|
579
|
+
}
|
|
580
|
+
envelopes.push(candidate)
|
|
467
581
|
rest = rest.slice(envelopeEnd)
|
|
468
582
|
}
|
|
469
583
|
}
|
|
@@ -622,9 +736,31 @@ export async function runIapSendCommand(args: {
|
|
|
622
736
|
// outbound — the AudioSink (fleet → owner's speaker), Фаза-2 stub
|
|
623
737
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
624
738
|
|
|
625
|
-
/**
|
|
626
|
-
*
|
|
627
|
-
*
|
|
739
|
+
/** Append one spoken envelope to spoken.log — the durable, greppable record of everything
|
|
740
|
+
* the runtime said out loud (and the deterministic assertion target of the text E2E).
|
|
741
|
+
* Written in BOTH modes: the artifact does not depend on whether audio is live. */
|
|
742
|
+
function appendSpokenLog(owner: string, envelope: IapEnvelope): void {
|
|
743
|
+
try {
|
|
744
|
+
appendFileSync(
|
|
745
|
+
spokenLogPath(),
|
|
746
|
+
`${JSON.stringify({
|
|
747
|
+
ts: nowIso(),
|
|
748
|
+
to: owner,
|
|
749
|
+
from: envelope.fromPersonality,
|
|
750
|
+
fromRuntime: envelope.fromRuntime,
|
|
751
|
+
message: envelope.message,
|
|
752
|
+
attachments: envelope.attachments,
|
|
753
|
+
})}\n`,
|
|
754
|
+
{ mode: 0o600 },
|
|
755
|
+
)
|
|
756
|
+
} catch (err) {
|
|
757
|
+
// The artifact is best-effort observability; never let it break delivery.
|
|
758
|
+
logEvt('speak.log-error', { reason: formatError(err) })
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Text-only AudioSink (VOICETALK_AUDIO=0 / no usable audio device): log the spoken text
|
|
763
|
+
* instead of synthesizing it. This is the Фаза-2 carcass, kept as the headless floor. */
|
|
628
764
|
function makeStubAudioSink(owner: string): AudioSink {
|
|
629
765
|
return {
|
|
630
766
|
async speak(envelope: IapEnvelope): Promise<void> {
|
|
@@ -638,22 +774,23 @@ function makeStubAudioSink(owner: string): AudioSink {
|
|
|
638
774
|
chars: envelope.message.length,
|
|
639
775
|
attachments: envelope.attachments.length,
|
|
640
776
|
})
|
|
777
|
+
appendSpokenLog(owner, envelope)
|
|
778
|
+
},
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Фаза-3 AudioSink: the agent's answer becomes speech on the owner's speaker through
|
|
783
|
+
* voice-connect, and the session opens its follow-up window afterwards. A TTS or
|
|
784
|
+
* playback failure degrades to the logged line — the answer is never silently lost. */
|
|
785
|
+
function makeVoiceAudioSink(owner: string, session: VoiceSession): AudioSink {
|
|
786
|
+
return {
|
|
787
|
+
async speak(envelope: IapEnvelope): Promise<void> {
|
|
788
|
+
process.stderr.write(`🔊 ${BIN_NAME} ${envelope.fromPersonality} → ${owner}: ${oneLine(envelope.message)}\n`)
|
|
789
|
+
appendSpokenLog(owner, envelope)
|
|
641
790
|
try {
|
|
642
|
-
|
|
643
|
-
spokenLogPath(),
|
|
644
|
-
`${JSON.stringify({
|
|
645
|
-
ts: nowIso(),
|
|
646
|
-
to: owner,
|
|
647
|
-
from: envelope.fromPersonality,
|
|
648
|
-
fromRuntime: envelope.fromRuntime,
|
|
649
|
-
message: envelope.message,
|
|
650
|
-
attachments: envelope.attachments,
|
|
651
|
-
})}\n`,
|
|
652
|
-
{ mode: 0o600 },
|
|
653
|
-
)
|
|
791
|
+
await session.speak(envelope.fromPersonality, envelope.message)
|
|
654
792
|
} catch (err) {
|
|
655
|
-
|
|
656
|
-
logEvt('speak.log-error', { reason: formatError(err) })
|
|
793
|
+
logEvt('speak.fallback', { from: envelope.fromPersonality, reason: formatError(err) })
|
|
657
794
|
}
|
|
658
795
|
},
|
|
659
796
|
}
|
|
@@ -707,25 +844,18 @@ function installStdinEnvelopeReader(ctx: RuntimeContext): void {
|
|
|
707
844
|
process.stdin.resume()
|
|
708
845
|
}
|
|
709
846
|
|
|
710
|
-
/** Frame + deliver one inbound utterance to the fleet through the daemon.
|
|
711
|
-
*
|
|
712
|
-
|
|
847
|
+
/** Frame + deliver one inbound utterance to the fleet through the daemon. The single
|
|
848
|
+
* delivery path for BOTH inbound sources — the live microphone (Фаза 3) and the `say`
|
|
849
|
+
* socket injector (Фаза 2 stand-in, still the debugging seam). */
|
|
850
|
+
async function deliverUtterance(
|
|
713
851
|
ctx: RuntimeContext,
|
|
714
|
-
|
|
852
|
+
rawTarget: string,
|
|
853
|
+
text: string,
|
|
854
|
+
attachments: string[] = [],
|
|
715
855
|
): Promise<{ ok: boolean; target?: string; detail?: string }> {
|
|
716
|
-
|
|
717
|
-
try {
|
|
718
|
-
req = JSON.parse(raw)
|
|
719
|
-
} catch (err) {
|
|
720
|
-
return { ok: false, detail: `invalid utterance JSON: ${formatError(err)}` }
|
|
721
|
-
}
|
|
722
|
-
const target = normalizeName(String(req.target ?? ''))
|
|
856
|
+
const target = normalizeName(rawTarget)
|
|
723
857
|
if (!NAME_RE.test(target)) return { ok: false, detail: `invalid target "${target}"` }
|
|
724
|
-
const text = typeof req.text === 'string' ? req.text : ''
|
|
725
858
|
if (!text.trim()) return { ok: false, target, detail: 'empty utterance' }
|
|
726
|
-
const attachments = Array.isArray(req.attachments)
|
|
727
|
-
? req.attachments.filter((s): s is string => typeof s === 'string')
|
|
728
|
-
: []
|
|
729
859
|
|
|
730
860
|
logEvt('inbound.utterance', { target, chars: text.length, attachments: attachments.length })
|
|
731
861
|
const result = await runIapSendCommand({
|
|
@@ -745,6 +875,28 @@ async function handleInboundUtterance(
|
|
|
745
875
|
return { ok: false, target, detail }
|
|
746
876
|
}
|
|
747
877
|
|
|
878
|
+
/** The socket wire-form of one utterance: {target, text, attachments?} as a JSON line. */
|
|
879
|
+
async function handleInboundUtterance(
|
|
880
|
+
ctx: RuntimeContext,
|
|
881
|
+
raw: string,
|
|
882
|
+
): Promise<{ ok: boolean; target?: string; detail?: string }> {
|
|
883
|
+
let req: { target?: unknown; text?: unknown; attachments?: unknown }
|
|
884
|
+
try {
|
|
885
|
+
req = JSON.parse(raw)
|
|
886
|
+
} catch (err) {
|
|
887
|
+
return { ok: false, detail: `invalid utterance JSON: ${formatError(err)}` }
|
|
888
|
+
}
|
|
889
|
+
const attachments = Array.isArray(req.attachments)
|
|
890
|
+
? req.attachments.filter((s): s is string => typeof s === 'string')
|
|
891
|
+
: []
|
|
892
|
+
return await deliverUtterance(
|
|
893
|
+
ctx,
|
|
894
|
+
String(req.target ?? ''),
|
|
895
|
+
typeof req.text === 'string' ? req.text : '',
|
|
896
|
+
attachments,
|
|
897
|
+
)
|
|
898
|
+
}
|
|
899
|
+
|
|
748
900
|
/** INBOUND source (Фаза-2 mic+STT stand-in): a unix socket the running router listens on.
|
|
749
901
|
* One connection = one utterance: the client writes a JSON line {target,text,attachments?}
|
|
750
902
|
* then half-closes; the router frames + delivers it and writes back a verdict line. */
|
|
@@ -808,10 +960,37 @@ async function runCommand(extraArgs: string[]): Promise<void> {
|
|
|
808
960
|
sink: makeStubAudioSink(owner.personality),
|
|
809
961
|
}
|
|
810
962
|
|
|
963
|
+
// Фаза 3 — bring the audio half up. A failure here is NOT fatal: the router keeps
|
|
964
|
+
// pumping envelopes in text mode (socket inbound + logged outbound) and says so, which
|
|
965
|
+
// beats a dead presence-runtime because a microphone was unplugged.
|
|
966
|
+
let session: VoiceSession | null = null
|
|
967
|
+
let audio: { device?: number; deviceName?: string; addresses?: number; error?: string } = {}
|
|
968
|
+
if (VOICETALK_AUDIO_ENABLED) {
|
|
969
|
+
const candidate = new VoiceSession({
|
|
970
|
+
owner: owner.personality,
|
|
971
|
+
binding,
|
|
972
|
+
deliver: (target, text) => deliverUtterance(ctx, target, text),
|
|
973
|
+
log: logEvt,
|
|
974
|
+
tmpDir: join(globalVoicetalkRoot(), 'tmp'),
|
|
975
|
+
})
|
|
976
|
+
try {
|
|
977
|
+
const started = await candidate.start()
|
|
978
|
+
session = candidate
|
|
979
|
+
ctx.sink = makeVoiceAudioSink(owner.personality, candidate)
|
|
980
|
+
audio = { device: started.deviceIndex, deviceName: started.deviceName, addresses: started.addresses }
|
|
981
|
+
} catch (err) {
|
|
982
|
+
audio = { error: formatError(err) }
|
|
983
|
+
logEvt('audio.unavailable', { reason: audio.error, note: 'router continues in text mode' })
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
811
987
|
const server = startInboundServer(ctx)
|
|
812
988
|
installStdinEnvelopeReader(ctx)
|
|
813
989
|
|
|
814
990
|
const cleanup = () => {
|
|
991
|
+
try {
|
|
992
|
+
session?.stop()
|
|
993
|
+
} catch {}
|
|
815
994
|
try {
|
|
816
995
|
server.close()
|
|
817
996
|
} catch {}
|
|
@@ -832,16 +1011,21 @@ async function runCommand(extraArgs: string[]): Promise<void> {
|
|
|
832
1011
|
|
|
833
1012
|
logEvt('run', {
|
|
834
1013
|
owner: owner.personality,
|
|
835
|
-
|
|
1014
|
+
wake_words: wakeEntries(binding).map(([w]) => w),
|
|
836
1015
|
device: binding.device ?? null,
|
|
837
1016
|
iapBin: ctx.iapBin,
|
|
838
1017
|
inbound_socket: inboundSocketPath(owner.personality),
|
|
839
|
-
|
|
1018
|
+
audio: session ? { live: true, ...audio } : { live: false, ...audio },
|
|
840
1019
|
})
|
|
1020
|
+
const audioNote = session
|
|
1021
|
+
? `mic [${audio.device}] ${audio.deviceName ?? '?'}, ${audio.addresses} addressable peers`
|
|
1022
|
+
: VOICETALK_AUDIO_ENABLED
|
|
1023
|
+
? `audio UNAVAILABLE (${audio.error ?? 'unknown'}) — text mode`
|
|
1024
|
+
: 'audio disabled (VOICETALK_AUDIO=0) — text mode'
|
|
841
1025
|
process.stderr.write(
|
|
842
1026
|
`${BIN_NAME}: running ${RUNTIME}-${owner.personality}` +
|
|
843
|
-
`${binding.
|
|
844
|
-
|
|
1027
|
+
`${wakeEntries(binding).length ? ` (wake: ${wakeEntries(binding).map(([w]) => w).join('/')})` : ' (no wake words)'}; ` +
|
|
1028
|
+
`${audioNote}\n`,
|
|
845
1029
|
)
|
|
846
1030
|
|
|
847
1031
|
// Run forever — the supervisor (launchd KeepAlive) owns the lifecycle; we exit only on
|
|
@@ -928,13 +1112,19 @@ async function sayCommand(args: string[]): Promise<void> {
|
|
|
928
1112
|
async function interfaceCommand(args: string[]): Promise<void> {
|
|
929
1113
|
const { flags } = parseFlags(args)
|
|
930
1114
|
const wakeWord = stringFlag(flags, 'wake-word')
|
|
1115
|
+
const wakeWords = parseWakeWords(stringFlag(flags, 'wake-words'))
|
|
1116
|
+
const defaultTarget = stringFlag(flags, 'default-target')
|
|
931
1117
|
const device = stringFlag(flags, 'device')
|
|
932
|
-
if (wakeWord === undefined && device === undefined) {
|
|
933
|
-
throw new VoicetalkRuntimeError(
|
|
1118
|
+
if (wakeWord === undefined && wakeWords === undefined && defaultTarget === undefined && device === undefined) {
|
|
1119
|
+
throw new VoicetalkRuntimeError(
|
|
1120
|
+
'interface requires --wake-words <«слово=пир,…»>, --default-target <peer>, --wake-word <w> and/or --device <d>',
|
|
1121
|
+
)
|
|
934
1122
|
}
|
|
935
1123
|
ensureCurrentProfile() // assert the profile exists before patching
|
|
936
1124
|
const merged = setVoicetalkInterface(process.cwd(), {
|
|
937
1125
|
...(wakeWord !== undefined ? { wake_word: wakeWord } : {}),
|
|
1126
|
+
...(wakeWords !== undefined ? { wake_words: wakeWords } : {}),
|
|
1127
|
+
...(defaultTarget !== undefined ? { default_target: defaultTarget } : {}),
|
|
938
1128
|
...(device !== undefined ? { device } : {}),
|
|
939
1129
|
})
|
|
940
1130
|
process.stdout.write(`${peerProfilePath(process.cwd())}\n`)
|
|
@@ -972,9 +1162,19 @@ async function selfConfigCommand(): Promise<void> {
|
|
|
972
1162
|
process.stdout.write(`${r.profilePath}\n`)
|
|
973
1163
|
process.stdout.write(
|
|
974
1164
|
`${BIN_NAME} self-config: configured peer "${r.personality}"` +
|
|
975
|
-
`${r.wakeWord ? ` wake_word="${r.wakeWord}"` : ''}
|
|
1165
|
+
`${r.wakeWord ? ` wake_word="${r.wakeWord}"` : ''}` +
|
|
1166
|
+
`${r.wakeWords ? ` wake_words=${JSON.stringify(r.wakeWords)}` : ''}` +
|
|
1167
|
+
`${r.defaultTarget ? ` default_target="${r.defaultTarget}"` : ''}` +
|
|
1168
|
+
`${r.device ? ` device="${r.device}"` : ''}\n`,
|
|
976
1169
|
)
|
|
977
|
-
logEvt('self-config', {
|
|
1170
|
+
logEvt('self-config', {
|
|
1171
|
+
personality: r.personality,
|
|
1172
|
+
wake_word: r.wakeWord,
|
|
1173
|
+
wake_words: r.wakeWords,
|
|
1174
|
+
default_target: r.defaultTarget,
|
|
1175
|
+
device: r.device,
|
|
1176
|
+
profile: r.profilePath,
|
|
1177
|
+
})
|
|
978
1178
|
}
|
|
979
1179
|
|
|
980
1180
|
/** The contract self-description (runtime id / version / bin / package). Pure + exported
|
|
@@ -997,11 +1197,47 @@ async function doctorCommand(args: string[]): Promise<void> {
|
|
|
997
1197
|
problems.push(`peer intelligence is ${profile.intelligence}, voicetalk requires natural`)
|
|
998
1198
|
}
|
|
999
1199
|
const binding = profile ? voicetalkInterface(profile) : {}
|
|
1000
|
-
if (profile &&
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
problems.push('interfaces.voicetalk
|
|
1200
|
+
if (profile && wakeEntries(binding).length === 0) {
|
|
1201
|
+
// Without a wake word nothing the owner says can be addressed — text routing over the
|
|
1202
|
+
// socket still works, but the microphone half is inert.
|
|
1203
|
+
problems.push('interfaces.voicetalk has no wake words (set wake_words — live voice cannot route without one)')
|
|
1204
|
+
}
|
|
1205
|
+
// The Фаза-3 audio contour: the speech service, the two host binaries and the input
|
|
1206
|
+
// device the passport points at. Reported always; only counted as a problem when audio
|
|
1207
|
+
// is actually enabled — a headless run (VOICETALK_AUDIO=0) legitimately has none.
|
|
1208
|
+
const voice = await voiceHealth()
|
|
1209
|
+
if (VOICETALK_AUDIO_ENABLED && !voice.ok) {
|
|
1210
|
+
problems.push(`voice-connect unreachable at ${voice.endpoint} (${voice.detail ?? 'no detail'}) — STT/TTS will fail`)
|
|
1211
|
+
}
|
|
1212
|
+
const tools: Record<string, boolean> = {}
|
|
1213
|
+
for (const bin of ['ffmpeg', 'ffplay', 'afplay']) {
|
|
1214
|
+
tools[bin] = await hasBinary(bin)
|
|
1215
|
+
if (VOICETALK_AUDIO_ENABLED && !tools[bin]) {
|
|
1216
|
+
problems.push(`${bin} not found on PATH (${bin === 'ffmpeg' ? 'microphone capture' : 'playback'})`)
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
let device: unknown = null
|
|
1220
|
+
if (tools.ffmpeg) {
|
|
1221
|
+
try {
|
|
1222
|
+
const resolved = await resolveInputDevice(binding.device)
|
|
1223
|
+
device = { index: resolved.index, name: resolved.name, matched: resolved.matched }
|
|
1224
|
+
if (resolved.devices.length === 0 && VOICETALK_AUDIO_ENABLED) {
|
|
1225
|
+
problems.push('no avfoundation audio input devices — is the microphone connected / TCC granted?')
|
|
1226
|
+
}
|
|
1227
|
+
} catch (err) {
|
|
1228
|
+
device = { error: formatError(err) }
|
|
1229
|
+
if (VOICETALK_AUDIO_ENABLED) problems.push(formatError(err))
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
const book = profile ? buildAddressBook({ owner: profile.personality, binding }) : []
|
|
1233
|
+
const orphans = profile ? unresolvedWakeWords(binding) : []
|
|
1234
|
+
if (orphans.length) {
|
|
1235
|
+
problems.push(
|
|
1236
|
+
`wake word(s) ${orphans.map(w => `"${w}"`).join(', ')} resolve to no peer — ` +
|
|
1237
|
+
'name the target explicitly in interfaces.voicetalk.wake_words or set default_target',
|
|
1238
|
+
)
|
|
1004
1239
|
}
|
|
1240
|
+
|
|
1005
1241
|
const result = {
|
|
1006
1242
|
...doctorReport(),
|
|
1007
1243
|
ok: problems.length === 0,
|
|
@@ -1010,17 +1246,46 @@ async function doctorCommand(args: string[]): Promise<void> {
|
|
|
1010
1246
|
owner: profile ? { personality: profile.personality, intelligence: profile.intelligence } : null,
|
|
1011
1247
|
binding,
|
|
1012
1248
|
inbound_socket: profile ? inboundSocketPath(profile.personality) : null,
|
|
1249
|
+
audio: { enabled: VOICETALK_AUDIO_ENABLED, tools, device },
|
|
1250
|
+
voice_connect: voice,
|
|
1251
|
+
wake_map: book.map(a => ({ target: a.personality, aliases: a.aliases })),
|
|
1013
1252
|
problems,
|
|
1014
1253
|
}
|
|
1015
1254
|
if (flags.json) printJson(result)
|
|
1016
1255
|
else {
|
|
1017
1256
|
process.stdout.write(`runtime: ${result.runtime}\nversion: ${result.version}\nbin: ${result.bin}\n`)
|
|
1257
|
+
process.stdout.write(
|
|
1258
|
+
`voice: ${voice.ok ? `ok ${voice.endpoint} (${voice.source}${voice.version ? `, v${voice.version}` : ''})` : `DOWN ${voice.endpoint} — ${voice.detail ?? ''}`}\n`,
|
|
1259
|
+
)
|
|
1260
|
+
const dev = device as { index?: number; name?: string; error?: string } | null
|
|
1261
|
+
process.stdout.write(
|
|
1262
|
+
`mic: ${dev?.error ? `ERROR ${dev.error}` : dev ? `[${dev.index}] ${dev.name ?? '?'}` : 'not probed'}\n`,
|
|
1263
|
+
)
|
|
1264
|
+
process.stdout.write(
|
|
1265
|
+
`wake: ${book.length ? book.map(a => `${a.aliases[0]} → ${a.personality}`).join(', ') : 'no wake words configured'}\n`,
|
|
1266
|
+
)
|
|
1018
1267
|
process.stdout.write(`${result.ok ? 'ok' : 'not ok'}\n`)
|
|
1019
1268
|
for (const problem of problems) process.stdout.write(`- ${problem}\n`)
|
|
1020
1269
|
}
|
|
1021
1270
|
if (!result.ok) process.exitCode = 1
|
|
1022
1271
|
}
|
|
1023
1272
|
|
|
1273
|
+
/** `devices` — list the host's audio inputs so an operator can pick one for
|
|
1274
|
+
* `interface --device`. Pure diagnostics. */
|
|
1275
|
+
async function devicesCommand(args: string[]): Promise<void> {
|
|
1276
|
+
const { flags } = parseFlags(args)
|
|
1277
|
+
const devices = await listInputDevices()
|
|
1278
|
+
if (flags.json) {
|
|
1279
|
+
printJson(devices)
|
|
1280
|
+
return
|
|
1281
|
+
}
|
|
1282
|
+
if (devices.length === 0) {
|
|
1283
|
+
process.stdout.write('no audio input devices found (is the microphone connected / TCC granted?)\n')
|
|
1284
|
+
return
|
|
1285
|
+
}
|
|
1286
|
+
for (const d of devices) process.stdout.write(`[${d.index}] ${d.name}\n`)
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1024
1289
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1025
1290
|
// entry
|
|
1026
1291
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1058,6 +1323,9 @@ async function main(): Promise<void> {
|
|
|
1058
1323
|
case 'say':
|
|
1059
1324
|
await sayCommand(rest)
|
|
1060
1325
|
return
|
|
1326
|
+
case 'devices':
|
|
1327
|
+
await devicesCommand(rest)
|
|
1328
|
+
return
|
|
1061
1329
|
case 'doctor':
|
|
1062
1330
|
await doctorCommand(rest)
|
|
1063
1331
|
return
|