@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/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
@@ -96,12 +106,12 @@ function defaultIntelligenceForRuntime(runtime: string): Intelligence {
96
106
  return 'artificial'
97
107
  }
98
108
 
99
- /** The voicetalk peer binding (narrowed schema, design dialog 21.06). STT/TTS/voice
100
- * config is DELIBERATELY absent — it lives in voice-connect, not the passport. */
101
- type VoicetalkInterface = {
102
- wake_word?: string
103
- device?: string
104
- }
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
105
115
 
106
116
  type PeerProfile = {
107
117
  personality: string
@@ -153,10 +163,12 @@ function usage(): string {
153
163
  ${BIN_NAME} # self-install (npx contract): bin on PATH + manifest
154
164
  ${BIN_NAME} self-install # explicit self-install (idempotent)
155
165
  ${BIN_NAME} self-config # per-peer self-config hook (foundation-invoked)
156
- ${BIN_NAME} interface --wake-word <w> [--device <d>] # set this peer's voicetalk binding
157
- ${BIN_NAME} run # router loop (Фаза 2: text caркас, audio stubbed)
158
- ${BIN_NAME} say --peer <personality> <text…> # inject an utterance (Фаза-2 mic+STT stand-in)
159
- ${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
160
172
  ${BIN_NAME} --version
161
173
  ${BIN_NAME} --help`
162
174
  }
@@ -247,6 +259,15 @@ function logEvt(evt: string, fields: Record<string, unknown>): void {
247
259
  process.stderr.write(`${BIN_NAME} ${JSON.stringify({ ts: nowIso(), evt, ...fields })}\n`)
248
260
  }
249
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
+
250
271
  function oneLine(text: string, max = 200): string {
251
272
  const flat = text.replace(/\s+/g, ' ').trim()
252
273
  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
@@ -376,6 +397,8 @@ function setVoicetalkInterface(cwd: string, patch: VoicetalkInterface): Record<s
376
397
  ? { ...((interfaces as { voicetalk: Record<string, unknown> }).voicetalk) }
377
398
  : {}
378
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
379
402
  if (patch.device !== undefined) voicetalk.device = patch.device
380
403
  ;(interfaces as Record<string, unknown>).voicetalk = voicetalk
381
404
  const merged = { ...raw, interfaces }
@@ -713,9 +736,31 @@ export async function runIapSendCommand(args: {
713
736
  // outbound — the AudioSink (fleet → owner's speaker), Фаза-2 stub
714
737
  // ─────────────────────────────────────────────────────────────────────────────
715
738
 
716
- /** Фаза-2 AudioSink: instead of TTS → speaker, log the spoken text (human stderr line +
717
- * structured evt) and append the envelope to spoken.log so the path is observable and
718
- * 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. */
719
764
  function makeStubAudioSink(owner: string): AudioSink {
720
765
  return {
721
766
  async speak(envelope: IapEnvelope): Promise<void> {
@@ -729,22 +774,23 @@ function makeStubAudioSink(owner: string): AudioSink {
729
774
  chars: envelope.message.length,
730
775
  attachments: envelope.attachments.length,
731
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)
732
790
  try {
733
- appendFileSync(
734
- spokenLogPath(),
735
- `${JSON.stringify({
736
- ts: nowIso(),
737
- to: owner,
738
- from: envelope.fromPersonality,
739
- fromRuntime: envelope.fromRuntime,
740
- message: envelope.message,
741
- attachments: envelope.attachments,
742
- })}\n`,
743
- { mode: 0o600 },
744
- )
791
+ await session.speak(envelope.fromPersonality, envelope.message)
745
792
  } catch (err) {
746
- // The artifact is best-effort observability; never let it break delivery.
747
- logEvt('speak.log-error', { reason: formatError(err) })
793
+ logEvt('speak.fallback', { from: envelope.fromPersonality, reason: formatError(err) })
748
794
  }
749
795
  },
750
796
  }
@@ -798,25 +844,18 @@ function installStdinEnvelopeReader(ctx: RuntimeContext): void {
798
844
  process.stdin.resume()
799
845
  }
800
846
 
801
- /** Frame + deliver one inbound utterance to the fleet through the daemon. Returns a small
802
- * verdict the socket hands back to `say`. */
803
- 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(
804
851
  ctx: RuntimeContext,
805
- raw: string,
852
+ rawTarget: string,
853
+ text: string,
854
+ attachments: string[] = [],
806
855
  ): Promise<{ ok: boolean; target?: string; detail?: string }> {
807
- let req: { target?: unknown; text?: unknown; attachments?: unknown }
808
- try {
809
- req = JSON.parse(raw)
810
- } catch (err) {
811
- return { ok: false, detail: `invalid utterance JSON: ${formatError(err)}` }
812
- }
813
- const target = normalizeName(String(req.target ?? ''))
856
+ const target = normalizeName(rawTarget)
814
857
  if (!NAME_RE.test(target)) return { ok: false, detail: `invalid target "${target}"` }
815
- const text = typeof req.text === 'string' ? req.text : ''
816
858
  if (!text.trim()) return { ok: false, target, detail: 'empty utterance' }
817
- const attachments = Array.isArray(req.attachments)
818
- ? req.attachments.filter((s): s is string => typeof s === 'string')
819
- : []
820
859
 
821
860
  logEvt('inbound.utterance', { target, chars: text.length, attachments: attachments.length })
822
861
  const result = await runIapSendCommand({
@@ -836,6 +875,28 @@ async function handleInboundUtterance(
836
875
  return { ok: false, target, detail }
837
876
  }
838
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
+
839
900
  /** INBOUND source (Фаза-2 mic+STT stand-in): a unix socket the running router listens on.
840
901
  * One connection = one utterance: the client writes a JSON line {target,text,attachments?}
841
902
  * then half-closes; the router frames + delivers it and writes back a verdict line. */
@@ -899,10 +960,37 @@ async function runCommand(extraArgs: string[]): Promise<void> {
899
960
  sink: makeStubAudioSink(owner.personality),
900
961
  }
901
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
+
902
987
  const server = startInboundServer(ctx)
903
988
  installStdinEnvelopeReader(ctx)
904
989
 
905
990
  const cleanup = () => {
991
+ try {
992
+ session?.stop()
993
+ } catch {}
906
994
  try {
907
995
  server.close()
908
996
  } catch {}
@@ -923,16 +1011,21 @@ async function runCommand(extraArgs: string[]): Promise<void> {
923
1011
 
924
1012
  logEvt('run', {
925
1013
  owner: owner.personality,
926
- wake_word: binding.wake_word ?? null,
1014
+ wake_words: wakeEntries(binding).map(([w]) => w),
927
1015
  device: binding.device ?? null,
928
1016
  iapBin: ctx.iapBin,
929
1017
  inbound_socket: inboundSocketPath(owner.personality),
930
- note: 'Фаза 2 — text caркас; audio (mic/wake-word/STT/TTS) is stubbed, lands in Фаза 3',
1018
+ audio: session ? { live: true, ...audio } : { live: false, ...audio },
931
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'
932
1025
  process.stderr.write(
933
1026
  `${BIN_NAME}: running ${RUNTIME}-${owner.personality}` +
934
- `${binding.wake_word ? ` (wake-word "${binding.wake_word}")` : ' (wake-word unset)'}; ` +
935
- `audio stubbed — Фаза 2 text caркас\n`,
1027
+ `${wakeEntries(binding).length ? ` (wake: ${wakeEntries(binding).map(([w]) => w).join('/')})` : ' (no wake words)'}; ` +
1028
+ `${audioNote}\n`,
936
1029
  )
937
1030
 
938
1031
  // Run forever — the supervisor (launchd KeepAlive) owns the lifecycle; we exit only on
@@ -1019,13 +1112,19 @@ async function sayCommand(args: string[]): Promise<void> {
1019
1112
  async function interfaceCommand(args: string[]): Promise<void> {
1020
1113
  const { flags } = parseFlags(args)
1021
1114
  const wakeWord = stringFlag(flags, 'wake-word')
1115
+ const wakeWords = parseWakeWords(stringFlag(flags, 'wake-words'))
1116
+ const defaultTarget = stringFlag(flags, 'default-target')
1022
1117
  const device = stringFlag(flags, 'device')
1023
- if (wakeWord === undefined && device === undefined) {
1024
- 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
+ )
1025
1122
  }
1026
1123
  ensureCurrentProfile() // assert the profile exists before patching
1027
1124
  const merged = setVoicetalkInterface(process.cwd(), {
1028
1125
  ...(wakeWord !== undefined ? { wake_word: wakeWord } : {}),
1126
+ ...(wakeWords !== undefined ? { wake_words: wakeWords } : {}),
1127
+ ...(defaultTarget !== undefined ? { default_target: defaultTarget } : {}),
1029
1128
  ...(device !== undefined ? { device } : {}),
1030
1129
  })
1031
1130
  process.stdout.write(`${peerProfilePath(process.cwd())}\n`)
@@ -1063,9 +1162,19 @@ async function selfConfigCommand(): Promise<void> {
1063
1162
  process.stdout.write(`${r.profilePath}\n`)
1064
1163
  process.stdout.write(
1065
1164
  `${BIN_NAME} self-config: configured peer "${r.personality}"` +
1066
- `${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`,
1067
1169
  )
1068
- 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
+ })
1069
1178
  }
1070
1179
 
1071
1180
  /** The contract self-description (runtime id / version / bin / package). Pure + exported
@@ -1088,11 +1197,47 @@ async function doctorCommand(args: string[]): Promise<void> {
1088
1197
  problems.push(`peer intelligence is ${profile.intelligence}, voicetalk requires natural`)
1089
1198
  }
1090
1199
  const binding = profile ? voicetalkInterface(profile) : {}
1091
- if (profile && !binding.wake_word) {
1092
- // A warning, not a hard fail: Фаза-2 text routing does not need the wake-word, but a
1093
- // live voice session (Фаза 3) will.
1094
- 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)')
1095
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
+ )
1239
+ }
1240
+
1096
1241
  const result = {
1097
1242
  ...doctorReport(),
1098
1243
  ok: problems.length === 0,
@@ -1101,17 +1246,46 @@ async function doctorCommand(args: string[]): Promise<void> {
1101
1246
  owner: profile ? { personality: profile.personality, intelligence: profile.intelligence } : null,
1102
1247
  binding,
1103
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 })),
1104
1252
  problems,
1105
1253
  }
1106
1254
  if (flags.json) printJson(result)
1107
1255
  else {
1108
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
+ )
1109
1267
  process.stdout.write(`${result.ok ? 'ok' : 'not ok'}\n`)
1110
1268
  for (const problem of problems) process.stdout.write(`- ${problem}\n`)
1111
1269
  }
1112
1270
  if (!result.ok) process.exitCode = 1
1113
1271
  }
1114
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
+
1115
1289
  // ─────────────────────────────────────────────────────────────────────────────
1116
1290
  // entry
1117
1291
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1149,6 +1323,9 @@ async function main(): Promise<void> {
1149
1323
  case 'say':
1150
1324
  await sayCommand(rest)
1151
1325
  return
1326
+ case 'devices':
1327
+ await devicesCommand(rest)
1328
+ return
1152
1329
  case 'doctor':
1153
1330
  await doctorCommand(rest)
1154
1331
  return
package/src/selfConfig.ts CHANGED
@@ -46,9 +46,34 @@ export interface SelfConfigOutcome {
46
46
  profilePath: string
47
47
  /** What was written into interfaces.voicetalk this run (for the CLI summary/log). */
48
48
  wakeWord?: string
49
+ wakeWords?: Record<string, string> | string[]
50
+ defaultTarget?: string
49
51
  device?: string
50
52
  }
51
53
 
54
+ /** Parse the operator's wake map from one env string: "дарвин=darwin,борис=boris".
55
+ * A bare word ("борис") inherits its target from the peer it names / default_target,
56
+ * and is written as a bare list entry. Mixed forms collapse into the map form. */
57
+ export function parseWakeWords(spec: string | undefined): Record<string, string> | string[] | undefined {
58
+ const raw = spec?.trim()
59
+ if (!raw) return undefined
60
+ const pairs: Array<[string, string | undefined]> = []
61
+ for (const part of raw.split(/[,;\n]/)) {
62
+ const item = part.trim()
63
+ if (!item) continue
64
+ const eq = item.indexOf('=')
65
+ if (eq < 0) pairs.push([item, undefined])
66
+ else {
67
+ const word = item.slice(0, eq).trim()
68
+ const target = item.slice(eq + 1).trim()
69
+ if (word) pairs.push([word, target || undefined])
70
+ }
71
+ }
72
+ if (pairs.length === 0) return undefined
73
+ if (pairs.every(([, target]) => target === undefined)) return pairs.map(([word]) => word)
74
+ return Object.fromEntries(pairs.map(([word, target]) => [word, target ?? word]))
75
+ }
76
+
52
77
  function trimmed(value: string | undefined): string | undefined {
53
78
  const v = value?.trim()
54
79
  return v && v.length > 0 ? v : undefined
@@ -102,6 +127,10 @@ export function runSelfConfig(opts: SelfConfigOptions = {}): SelfConfigOutcome {
102
127
  // Operator-supplied voice binding (env, forwarded by the foundation).
103
128
  const wakeWord = trimmed(env.VOICETALK_WAKE_WORD)
104
129
  const device = trimmed(env.VOICETALK_DEVICE)
130
+ // Hybrid wake map (Фаза 3): "борис=boris,линус=linus" — a bare word inherits the
131
+ // default target. Written only when the operator supplies it.
132
+ const wakeWords = parseWakeWords(trimmed(env.VOICETALK_WAKE_WORDS))
133
+ const defaultTarget = trimmed(env.VOICETALK_DEFAULT_TARGET)
105
134
 
106
135
  // Merge interfaces.voicetalk (preserve any existing voicetalk fields). Only set
107
136
  // wake_word/device when provided — never blank out a value the operator set earlier.
@@ -115,6 +144,8 @@ export function runSelfConfig(opts: SelfConfigOptions = {}): SelfConfigOutcome {
115
144
  ? { ...((interfaces as { voicetalk: Record<string, unknown> }).voicetalk) }
116
145
  : {}
117
146
  if (wakeWord) voicetalk.wake_word = wakeWord
147
+ if (wakeWords) voicetalk.wake_words = wakeWords
148
+ if (defaultTarget) voicetalk.default_target = defaultTarget
118
149
  if (device) voicetalk.device = device
119
150
  ;(interfaces as Record<string, unknown>).voicetalk = voicetalk
120
151
 
@@ -125,5 +156,5 @@ export function runSelfConfig(opts: SelfConfigOptions = {}): SelfConfigOutcome {
125
156
 
126
157
  writeJsonAtomic(profilePath, merged)
127
158
 
128
- return { personality, profilePath, wakeWord, device }
159
+ return { personality, profilePath, wakeWord, wakeWords, defaultTarget, device }
129
160
  }