@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/src/cli.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- // voicetalk-runtime — the voice presence-runtime router (Фаза 2: text caркас).
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 — which in
12
- // Фаза 3 is TTS → the owner's speaker, and in Фаза 2 is a STUB that logs the spoken
13
- // text (and appends it to spoken.log) so the path is exercised without audio.
14
- // • INBOUND (owner → fleet): in Фаза 3 the mic + wake-word + STT turn the owner's
15
- // speech into an utterance; in Фаза 2 that audio source is STUBBED by a local unix
16
- // socket (the `say` subcommand is the mic+STT stand-in). Either way the utterance is
17
- // framed for the spoken modality and delivered to the target peer through the daemon
18
- // (`iapeer send`) FROM the owner's identity (PEER_RUNTIME=voicetalk).
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
- // (a pure HTTP backend this runtime will CONSUME in Фаза 3). The two audio touchpoints
22
- // here (AudioSink + the socket inbound source) are the seams voice-connect plugs into.
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 (narrowed schema, design dialog 21.06). STT/TTS/voice
86
- * config is DELIBERATELY absent — it lives in voice-connect, not the passport. */
87
- type VoicetalkInterface = {
88
- wake_word?: string
89
- device?: string
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-word <w> [--device <d>] # set this peer's voicetalk binding
140
- ${BIN_NAME} run # router loop (Фаза 2: text caркас, audio stubbed)
141
- ${BIN_NAME} say --peer <personality> <text…> # inject an utterance (Фаза-2 mic+STT stand-in)
142
- ${BIN_NAME} doctor [--json] # health: binding + manifest
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 (CDATA-aware) — mirrors the foundation codec / telegram-runtime
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: &amp; LAST so an escaped
418
+ // "&amp;lt;" in the source does not get double-decoded.
374
419
  return value.replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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(`${name}="([^"]*)"`).exec(attrs)
428
+ const m = new RegExp(`(?:^|\\s)${name}="([^"]*)"`).exec(attrs)
379
429
  return m ? unescapeAttr(m[1]) : undefined
380
430
  }
381
431
 
382
- function decodeCdata(inner: string): string {
383
- if (!inner.startsWith('<![CDATA[') || !inner.endsWith(']]>')) return inner
384
- return inner.slice('<![CDATA['.length, -']]>'.length).replaceAll(']]]]><![CDATA[>', ']]>')
385
- }
386
-
387
- /** Find `needle` at/after `from`, ignoring any occurrence INSIDE a <![CDATA[ … ]]>
388
- * section, so a message body that literally contains "</message>"/"</iap>" never
389
- * truncates the envelope. With no CDATA present this behaves exactly like indexOf. */
390
- function indexOfOutsideCdata(s: string, needle: string, from = 0): number {
391
- let i = from
392
- let inCdata = false
393
- while (i < s.length) {
394
- if (inCdata) {
395
- if (s.startsWith(']]>', i)) {
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 (s.startsWith(needle, i)) return i
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 start = xml.indexOf(open)
417
- if (start < 0) return undefined
418
- const innerStart = start + open.length
419
- const end = indexOfOutsideCdata(xml, `</${tag}>`, innerStart)
420
- if (end < 0) return undefined
421
- return decodeCdata(xml.slice(innerStart, end))
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), so multi-line messages and attachment splits are LF-terminated.
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
- const fromPersonality = attrValue(open[1], 'from-personality')
431
- const fromRuntime = attrValue(open[1], 'from-runtime')
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 fromIntelligenceRaw = attrValue(open[1], 'from-intelligence')
436
- const fromIntelligence = isIntelligence(fromIntelligenceRaw) ? fromIntelligenceRaw : undefined
437
- const message = tagContent(xml, 'message')
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
- return { envelopes, rest: rest.slice(Math.max(0, rest.length - 8)) }
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 end = indexOfOutsideCdata(rest, '</iap>')
464
- if (end < 0) return { envelopes, rest }
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
- envelopes.push(rest.slice(0, envelopeEnd))
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
- /** Фаза-2 AudioSink: instead of TTS → speaker, log the spoken text (human stderr line +
626
- * structured evt) and append the envelope to spoken.log so the path is observable and
627
- * the E2E has a deterministic artifact. Фаза 3 replaces this with a voice-connect sink. */
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
- appendFileSync(
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
- // The artifact is best-effort observability; never let it break delivery.
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. Returns a small
711
- * verdict the socket hands back to `say`. */
712
- async function handleInboundUtterance(
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
- raw: string,
852
+ rawTarget: string,
853
+ text: string,
854
+ attachments: string[] = [],
715
855
  ): Promise<{ ok: boolean; target?: string; detail?: string }> {
716
- let req: { target?: unknown; text?: unknown; attachments?: unknown }
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
- wake_word: binding.wake_word ?? null,
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
- note: 'Фаза 2 — text caркас; audio (mic/wake-word/STT/TTS) is stubbed, lands in Фаза 3',
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.wake_word ? ` (wake-word "${binding.wake_word}")` : ' (wake-word unset)'}; ` +
844
- `audio stubbed — Фаза 2 text caркас\n`,
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('interface requires --wake-word <w> and/or --device <d>')
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}"` : ''}${r.device ? ` device="${r.device}"` : ''}\n`,
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', { personality: r.personality, wake_word: r.wakeWord, device: r.device, profile: r.profilePath })
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 && !binding.wake_word) {
1001
- // A warning, not a hard fail: Фаза-2 text routing does not need the wake-word, but a
1002
- // live voice session (Фаза 3) will.
1003
- problems.push('interfaces.voicetalk.wake_word is unset (needed for live voice in Фаза 3)')
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