@agfpd/voicetalk-runtime 0.3.1 → 0.4.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.
@@ -19,13 +19,20 @@
19
19
  // (the package now owns this path); a sandbox proof sets IAPEER_BIN_DIR so a test never
20
20
  // touches the real ~/.local/bin.
21
21
 
22
- import { spawnSync } from 'child_process'
23
- import { chmodSync, copyFileSync, existsSync, mkdirSync, renameSync, statSync } from 'fs'
22
+ import { chmodSync, copyFileSync, mkdirSync, renameSync, rmSync, statSync } from 'fs'
23
+ import { publishInstallPair } from './installPair.ts'
24
+ import { randomUUID } from 'crypto'
24
25
  import { homedir } from 'os'
25
26
  import { dirname, join } from 'path'
26
27
  import { BIN_NAME, PACKAGE_NAME } from './constants.ts'
27
28
  import { scaffoldHostDocs, type HostDocsResult } from './hostDocs.ts'
28
- import { buildManifest, resolveIapeerRoot, writeManifestAtomic } from './manifest.ts'
29
+ import { buildManifest, resolveIapeerRoot } from './manifest.ts'
30
+ import {
31
+ ensureExecutableSignature,
32
+ runInstallCommand,
33
+ type ExecutableSignatureState,
34
+ type InstallCommandRunner,
35
+ } from './installSignature.ts'
29
36
 
30
37
  /** The launcher binary name (`voicetalk-runtime`). Re-exported so callers/tests have a
31
38
  * single import surface for the self-install contract. */
@@ -44,6 +51,11 @@ export interface SelfInstallOptions {
44
51
  * (`<pkg>/src/cli.ts` → `<pkg>/docs`). Missing (e.g. running from a compiled bin with
45
52
  * no source tree) → soft-skip, never fails the install. */
46
53
  docsSource?: string
54
+ /** Test seams: no host-global mocks or environment-based signature bypass. */
55
+ runner?: InstallCommandRunner
56
+ platform?: NodeJS.Platform
57
+ /** Synchronous publication fault seam for tests; no environment bypass. */
58
+ publishRename?: typeof renameSync
47
59
  }
48
60
 
49
61
  export type SelfInstallBinMode = 'compiled' | 'copied-self'
@@ -54,6 +66,7 @@ export interface SelfInstallResult {
54
66
  binMode: SelfInstallBinMode
55
67
  root: string
56
68
  binDir: string
69
+ signature: ExecutableSignatureState
57
70
  /** On-host docs scaffolding verdict (best-effort; never blocks the install). */
58
71
  docs: HostDocsResult
59
72
  }
@@ -88,20 +101,12 @@ function isBunInterpreter(execPath: string): boolean {
88
101
 
89
102
  function cleanup(path: string): void {
90
103
  try {
91
- if (existsSync(path)) renameSync(path, `${path}.dead`)
104
+ rmSync(path, { force: true })
92
105
  } catch {
93
106
  // best-effort
94
107
  }
95
108
  }
96
109
 
97
- /** Tiny deterministic string hash for a unique-ish tmp suffix (Math.random is avoided
98
- * to keep the tmp name reproducible within a run). */
99
- function hashStr(s: string): number {
100
- let h = 0
101
- for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
102
- return h
103
- }
104
-
105
110
  /**
106
111
  * Self-install: place the launcher on PATH + write the manifest. IDEMPOTENT — a
107
112
  * repeat run produces the same on-disk bin and a byte-identical manifest (atomic
@@ -116,42 +121,51 @@ export function selfInstall(opts: SelfInstallOptions = {}): SelfInstallResult {
116
121
  const binDir = resolveBinDir(env)
117
122
  const binPath = join(binDir, BIN_NAME)
118
123
 
124
+ // Fail closed BEFORE any write/process: a sandbox must isolate BOTH surfaces, so
125
+ // both must be EXPLICIT overrides. Comparing against a "host home" cannot work —
126
+ // Bun's os.userInfo().homedir follows $HOME, which a sandbox points at itself.
127
+ if ((env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1')
128
+ && (!env.IAPEER_BIN_DIR?.trim() || !env.IAPEER_ROOT?.trim())) {
129
+ throw new Error('self-install: refusing real host bin/root under IAPEER_TEST_SANDBOX=1 — set IAPEER_BIN_DIR and IAPEER_ROOT')
130
+ }
119
131
  mkdirSync(binDir, { recursive: true, mode: 0o755 })
120
132
 
121
133
  // Build to a sibling tmp in the SAME dir (rename is atomic only within one FS), then
122
- // chmod +x and rename over the target. A reader of `voicetalk-runtime` never sees a
123
- // half-written file, and a legacy symlink at binPath is replaced atomically.
124
- const tmp = `${binPath}.tmp.${process.pid}.${Math.abs(hashStr(binPath + execPath))}`
134
+ // chmod +x, verify/repair its macOS signature, THEN rename over the target. A
135
+ // reader never sees a half-written file; a legacy symlink is replaced atomically.
136
+ const tmp = `${binPath}.tmp.${process.pid}.${randomUUID()}`
137
+ const run = opts.runner ?? runInstallCommand
125
138
  let binMode: SelfInstallBinMode
139
+ let signature: ExecutableSignatureState
140
+ let manifestPath: string
126
141
 
127
- if (isCompilableSource(sourceEntry) && isBunInterpreter(execPath)) {
128
- // PRIMARY path: running under bun from the package source (npx → bin → bun cli.ts).
129
- // Compile a self-contained snapshot so the installed bin does not depend on the npm
130
- // cache (GC'd) or the source tree.
131
- const r = spawnSync(execPath, ['build', '--compile', '--outfile', tmp, sourceEntry], {
132
- encoding: 'utf8',
133
- env: env as Record<string, string>,
134
- })
135
- if (r.error || (r.status ?? 1) !== 0) {
136
- cleanup(tmp)
137
- throw new Error(
138
- `self-install: bun build --compile failed: ${(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim()}`,
139
- )
142
+ try {
143
+ if (isCompilableSource(sourceEntry) && isBunInterpreter(execPath)) {
144
+ // PRIMARY: compile a snapshot decoupled from the npm cache/source tree.
145
+ const r = run(execPath, ['build', '--compile', '--outfile', tmp, sourceEntry], env)
146
+ if (r.error || (r.status ?? 1) !== 0) {
147
+ throw new Error(
148
+ `self-install: bun build --compile failed: ${(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim()}`,
149
+ )
150
+ }
151
+ binMode = 'compiled'
152
+ } else {
153
+ // FALLBACK: running from a compiled bin; re-assert by copying the executable.
154
+ copyFileSync(execPath, tmp)
155
+ binMode = 'copied-self'
140
156
  }
141
- binMode = 'compiled'
142
- } else {
143
- // FALLBACK: running from an already-compiled bin (no .ts source / execPath is not
144
- // bun). Re-assert the install by copying the running executable into binDir.
145
- copyFileSync(execPath, tmp)
146
- binMode = 'copied-self'
147
- }
148
-
149
- chmodSync(tmp, 0o755)
150
- renameSync(tmp, binPath)
151
157
 
152
- // Manifest pins the ABSOLUTE installed bin into the self-config descriptor.
153
- const manifest = buildManifest(binPath)
154
- const manifestPath = writeManifestAtomic(manifest, env)
158
+ chmodSync(tmp, 0o755)
159
+ signature = ensureExecutableSignature(tmp, { env, runner: run, platform: opts.platform })
160
+ // Pair publication: the manifest (pinning the ABSOLUTE installed bin into the
161
+ // self-config descriptor) is staged BEFORE the binary is published; a manifest
162
+ // rename failure restores the previous binary inode (or removes a first install).
163
+ manifestPath = publishInstallPair(tmp, binPath, buildManifest(binPath), env, opts.publishRename)
164
+ } finally {
165
+ // Includes partial compiler/copy output and chmod/sign/rename failures. Do not
166
+ // leave .dead binaries; a failed signature gate never reaches the manifest.
167
+ cleanup(tmp)
168
+ }
155
169
 
156
170
  // On-host docs — stage this version's public docs to <root>/docs/voicetalk-runtime/.
157
171
  // Best-effort: scaffoldHostDocs already soft-skips missing source / copy errors; the
@@ -168,5 +182,5 @@ export function selfInstall(opts: SelfInstallOptions = {}): SelfInstallResult {
168
182
  }
169
183
  }
170
184
 
171
- return { binPath, manifestPath, binMode, root: resolveIapeerRoot(env), binDir, docs }
185
+ return { binPath, manifestPath, binMode, signature, root: resolveIapeerRoot(env), binDir, docs }
172
186
  }
@@ -43,6 +43,7 @@ import {
43
43
  type Segment,
44
44
  } from './audio.ts'
45
45
  import { synthesize, transcribe } from './voiceConnect.ts'
46
+ import { nativeVoiceTargets, type NativeVoiceControl } from './nativeVoice.ts'
46
47
  import {
47
48
  buildAddressBook,
48
49
  hallucinationReason,
@@ -104,6 +105,11 @@ const PLAYBACK_ENABLED = process.env.VOICETALK_PLAYBACK !== '0'
104
105
  * are mode 0600 under the runtime scratch dir and the oldest pair is evicted. */
105
106
  const WAKE_CAPTURE_ENABLED = process.env.VOICETALK_WAKE_CAPTURE !== '0'
106
107
  const WAKE_CAPTURE_MAX = Math.min(100, num(process.env.VOICETALK_WAKE_CAPTURE_MAX, 16))
108
+ /** Native voice hand-off: how often the listener asks whether the peer's voice is closed,
109
+ * and the hard ceiling after which it asks iapeer to close it. Idle-close itself belongs
110
+ * to iapeer; the ceiling only bounds a hand-off whose closing was never confirmed. */
111
+ const NATIVE_POLL_MS = num(process.env.VOICETALK_NATIVE_POLL_MS, 1_000)
112
+ const NATIVE_MAX_MS = num(process.env.VOICETALK_NATIVE_MAX_MS, 30 * 60_000)
107
113
 
108
114
  export type DeliverFn = (target: string, text: string) => Promise<{ ok: boolean; detail?: string }>
109
115
  export type LogFn = (evt: string, fields: Record<string, unknown>) => void
@@ -116,6 +122,21 @@ export type VoiceSessionOptions = {
116
122
  /** Scratch dir for captured WAVs and synthesized OGGs. */
117
123
  tmpDir: string
118
124
  peers?: FleetPeer[]
125
+ /** The door to a peer's native voice mode; absent = every target goes through STT. */
126
+ nativeVoice?: NativeVoiceControl
127
+ /** Override the hand-off poll / hold ceiling (tests); defaults come from the env. */
128
+ nativePollMs?: number
129
+ nativeMaxMs?: number
130
+ }
131
+
132
+ /** The microphone is lent to a peer's native voice. `releasing` and `reclaiming` are the
133
+ * edges where nobody listens (earcons are allowed there); during `opening` and `open`
134
+ * the room belongs to the peer and voicetalk stays silent. */
135
+ type NativeHandOff = {
136
+ target: string
137
+ phase: 'releasing' | 'opening' | 'open' | 'reclaiming'
138
+ since: number
139
+ session?: string | null
119
140
  }
120
141
 
121
142
  type WakeRoutingDecision = {
@@ -153,8 +174,18 @@ export class VoiceSession {
153
174
  /** When the current turn was handed to the fleet — the clock for «ответ за N с». */
154
175
  private deliveredAt = new Map<string, number>()
155
176
  private stopped = false
177
+ private nativeTargets: Set<string>
178
+ private native: NativeHandOff | null = null
179
+ private nativeWaiters: Array<() => void> = []
180
+
181
+ constructor(private readonly opts: VoiceSessionOptions) {
182
+ this.nativeTargets = opts.nativeVoice ? nativeVoiceTargets(opts.binding) : new Set()
183
+ }
156
184
 
157
- constructor(private readonly opts: VoiceSessionOptions) {}
185
+ /** Who currently holds the microphone instead of the wake listener (null = we do). */
186
+ get nativeHolder(): { target: string; phase: string } | null {
187
+ return this.native ? { target: this.native.target, phase: this.native.phase } : null
188
+ }
158
189
 
159
190
  /** Resolve the input device, prime the address book, open the mic. Throws if the mic
160
191
  * cannot be resolved — the caller decides whether that is fatal (it is not: the
@@ -173,7 +204,10 @@ export class VoiceSession {
173
204
  onStart: attempt => this.opts.log('mic.start', { device: device.index, name: device.name, attempt }),
174
205
  onError: detail => this.opts.log('mic.error', { detail }),
175
206
  })
176
- this.mic.start()
207
+ // A restart must not grab a microphone a peer is still talking on: adopt an open
208
+ // native voice and wait for its close before listening.
209
+ const adopted = await this.adoptOpenNativeVoice()
210
+ if (!adopted) this.mic.start()
177
211
  return { deviceIndex: device.index, deviceName: device.name, addresses: this.book.length }
178
212
  }
179
213
 
@@ -216,6 +250,9 @@ export class VoiceSession {
216
250
 
217
251
  private async earcon(kind: keyof Earcons): Promise<void> {
218
252
  if (!this.earcons || process.env.VOICETALK_EARCONS === '0' || !PLAYBACK_ENABLED) return
253
+ // The peer's own voice session is listening to the room — a beep from us would be
254
+ // heard (and possibly answered) by it.
255
+ if (this.native && (this.native.phase === 'opening' || this.native.phase === 'open')) return
219
256
  try {
220
257
  this.muteUntil = Math.max(this.muteUntil, Date.now() + 600)
221
258
  await playFile(this.earcons[kind], { timeoutMs: 5_000 })
@@ -229,7 +266,7 @@ export class VoiceSession {
229
266
  }
230
267
 
231
268
  private onAudio(chunk: Buffer): void {
232
- if (this.stopped) return
269
+ if (this.stopped || this.native) return
233
270
  if (this.muted()) {
234
271
  // Our own speech (or an earcon) is in the room — throw the audio away rather than
235
272
  // letting the endpointer build an utterance out of it.
@@ -439,10 +476,18 @@ export class VoiceSession {
439
476
  // goes out before the new one starts.
440
477
  this.opts.log('wake.switch', { from: active, to: match!.target })
441
478
  this.flush('switch')
442
- this.append(match!.target, match!.body)
479
+ if (this.nativeTargets.has(match!.target)) this.handOff(match!)
480
+ else this.append(match!.target, match!.body)
443
481
  return { candidate: match!, accepted: true }
444
482
  }
445
483
 
484
+ if (match && this.nativeTargets.has(match.target)) {
485
+ // An accepted, spoken, exact address to a native-voice peer: the conversation moves
486
+ // to the peer's own realtime voice. Nothing is transcribed onward or sent as text.
487
+ this.handOff(match)
488
+ return { candidate: match, accepted: true }
489
+ }
490
+
446
491
  if (match) {
447
492
  if (!match.body) {
448
493
  // Addressed with nothing to say yet («Борис?») — acknowledge and hold the mic
@@ -569,6 +614,151 @@ export class VoiceSession {
569
614
  this.followTimer = null
570
615
  }
571
616
 
617
+ // ── native voice hand-off ─────────────────────────────────────────────────────
618
+
619
+ /** One accepted wake → one hand-off. Synchronously marks the microphone as lent (so no
620
+ * second wake can be formed from audio already captured), then runs the hand-off. */
621
+ private handOff(match: WakeMatch): void {
622
+ if (this.native) {
623
+ this.opts.log('native.ignored', { target: match.target, holder: this.native.target, phase: this.native.phase })
624
+ return
625
+ }
626
+ this.native = { target: match.target, phase: 'releasing', since: Date.now() }
627
+ // Audio captured before the wake was handled belongs to no one now: the owner is
628
+ // about to talk to the peer directly. A thought in flight to someone ELSE still goes.
629
+ const dropped = this.queue.length
630
+ this.queue = []
631
+ if (this.pending) this.flush('native-voice')
632
+ this.clearWindow()
633
+ this.followTarget = null
634
+ this.opts.log('native.wake', { target: match.target, alias: match.alias, body: match.body || null, droppedSegments: dropped })
635
+ void this.runHandOff(match.target)
636
+ }
637
+
638
+ private async runHandOff(target: string): Promise<void> {
639
+ const control = this.opts.nativeVoice!
640
+ const released = this.mic ? await this.mic.pause() : { released: true }
641
+ this.segmenter.reset()
642
+ this.opts.log('mic.released', { to: target, confirmed: released.released })
643
+ if (!released.released) {
644
+ // Our capture has not provably let go of the device; opening the peer's voice now
645
+ // could put two listeners on one microphone. Abort this activation instead.
646
+ this.native!.phase = 'reclaiming'
647
+ await this.earcon('error')
648
+ this.reclaim(target, 'release-unconfirmed')
649
+ return
650
+ }
651
+ // "Heard you" — played while NOBODY is listening: our capture is gone and the peer's
652
+ // voice is not open yet.
653
+ await this.earcon('end')
654
+ if (this.stopped) return
655
+ this.native!.phase = 'opening'
656
+ this.opts.log('native.open-requested', { target })
657
+ const started = Date.now()
658
+ const receipt = await control.open(target)
659
+ if (!receipt.ok || receipt.state !== 'open') {
660
+ // A failed open is NOT evidence that the peer is off the microphone: the realtime
661
+ // session may still start late, and a timeout or an unreadable answer says nothing
662
+ // at all. The microphone comes back only on a confirmed `closed` (contract with
663
+ // iapeer, 26.10) — until then we hold exactly as if the voice were open.
664
+ this.opts.log('native.open-failed', { target, state: receipt.state, error: receipt.error, openMs: Date.now() - started })
665
+ this.native!.phase = 'open'
666
+ await this.holdUntilClosed(target, { failedOpen: true })
667
+ return
668
+ }
669
+ this.native!.phase = 'open'
670
+ this.native!.session = receipt.session ?? null
671
+ this.opts.log('native.opened', { target, session: receipt.session ?? null, already: !!receipt.already, openMs: Date.now() - started })
672
+ await this.holdUntilClosed(target)
673
+ }
674
+
675
+ /** Poll the peer's voice state until iapeer confirms it is closed, then give the
676
+ * microphone back to the wake listener. */
677
+ private async holdUntilClosed(target: string, opts: { failedOpen?: boolean } = {}): Promise<void> {
678
+ const control = this.opts.nativeVoice!
679
+ const openedAt = Date.now()
680
+ let closeRequested = false
681
+ let lastAlarm = Date.now()
682
+ let lastState = ''
683
+ while (!this.stopped) {
684
+ await new Promise(r => setTimeout(r, this.opts.nativePollMs ?? NATIVE_POLL_MS))
685
+ if (this.stopped) return
686
+ const st = await control.status(target)
687
+ if (st.state !== lastState) {
688
+ this.opts.log('native.status', { target, state: st.state, reason: st.reason ?? null, error: st.error ?? null })
689
+ lastState = st.state
690
+ }
691
+ if (st.state === 'closed') {
692
+ this.opts.log('native.closed', { target, session: st.session ?? this.native?.session ?? null, reason: st.reason ?? null, heldMs: Date.now() - openedAt })
693
+ break
694
+ }
695
+ if (!closeRequested && Date.now() - openedAt >= (this.opts.nativeMaxMs ?? NATIVE_MAX_MS)) {
696
+ closeRequested = true
697
+ this.opts.log('native.close-requested', { target, reason: 'hold ceiling', heldMs: Date.now() - openedAt })
698
+ const closed = await control.close(target)
699
+ this.opts.log('native.close-receipt', { target, ok: closed.ok, state: closed.state, error: closed.error ?? null })
700
+ }
701
+ if (closeRequested && Date.now() - lastAlarm >= 60_000) {
702
+ // Past the ceiling and still no confirmed close. Taking the microphone back
703
+ // unconfirmed would put two listeners on one device; staying deaf is the
704
+ // recoverable failure, so it is reported loudly instead of resolved by guess.
705
+ lastAlarm = Date.now()
706
+ this.opts.log('native.stuck', { target, lastState, heldMs: Date.now() - openedAt })
707
+ const closed = await control.close(target)
708
+ this.opts.log('native.close-receipt', { target, ok: closed.ok, state: closed.state, error: closed.error ?? null })
709
+ }
710
+ }
711
+ if (this.stopped) return
712
+ this.native!.phase = 'reclaiming'
713
+ if (opts.failedOpen) await this.earcon('error')
714
+ this.reclaim(target, opts.failedOpen ? 'open-failed' : 'closed')
715
+ if (!opts.failedOpen) void this.earcon('close')
716
+ }
717
+
718
+ private reclaim(target: string, reason: string): void {
719
+ this.segmenter.reset()
720
+ this.native = null
721
+ // Back to wake mode proper: after a native conversation the next turn needs a name.
722
+ this.clearWindow()
723
+ this.followTarget = null
724
+ this.mic?.resume()
725
+ this.opts.log('wake.listening', { after: target, reason })
726
+ const waiters = this.nativeWaiters
727
+ this.nativeWaiters = []
728
+ for (const w of waiters) w()
729
+ }
730
+
731
+ /** On start: if a native voice is already open (the runtime restarted mid-conversation),
732
+ * stay off the microphone until it closes. */
733
+ private async adoptOpenNativeVoice(): Promise<boolean> {
734
+ const control = this.opts.nativeVoice
735
+ if (!control) return false
736
+ for (const target of [...this.nativeTargets]) {
737
+ const st = await control.status(target)
738
+ if (st.state === 'unknown') {
739
+ // Preflight: the contract does not answer at all (an iapeer without `voice`, a
740
+ // broken CLI). Nothing can have been opened by THIS process yet, so the safe
741
+ // degradation is the STT path for this target — not a listener that goes deaf
742
+ // forever on the first wake because no `closed` can ever be confirmed.
743
+ this.nativeTargets.delete(target)
744
+ this.opts.log('native.unavailable', { target, error: st.error ?? null, fallback: 'stt' })
745
+ continue
746
+ }
747
+ if (st.state === 'open' || st.state === 'opening' || st.state === 'closing') {
748
+ this.native = { target, phase: 'open', since: Date.now(), session: st.session ?? null }
749
+ this.opts.log('native.adopted', { target, state: st.state, session: st.session ?? null })
750
+ void this.holdUntilClosed(target)
751
+ return true
752
+ }
753
+ }
754
+ return false
755
+ }
756
+
757
+ private waitNativeIdle(): Promise<void> {
758
+ if (!this.native) return Promise.resolve()
759
+ return new Promise(resolve => this.nativeWaiters.push(resolve))
760
+ }
761
+
572
762
  /** Speak an agent's answer on the owner's speaker, then hold the follow-up window
573
763
  * open — the conversational turn passes back to the human. */
574
764
  async speak(from: string, message: string): Promise<void> {
@@ -596,6 +786,11 @@ export class VoiceSession {
596
786
  }
597
787
 
598
788
  private async say(text: string, opts: { from?: string } = {}): Promise<void> {
789
+ if (this.native) {
790
+ // The room belongs to a peer's native voice; speaking now would be heard by it.
791
+ this.opts.log('tts.deferred', { from: opts.from ?? null, holder: this.native.target })
792
+ await this.waitNativeIdle()
793
+ }
599
794
  const out = join(this.opts.tmpDir, `say-${randomUUID()}.ogg`)
600
795
  const started = Date.now()
601
796
  try {
package/src/wake.ts CHANGED
@@ -120,6 +120,10 @@ export type VoicetalkBinding = {
120
120
  wake_words?: Record<string, string> | string[]
121
121
  default_target?: string
122
122
  device?: string
123
+ /** Targets answered in their own NATIVE voice mode (Codex `/voice`) instead of
124
+ * STT → envelope → TTS. The wake word still has to be spoken and pass every activation
125
+ * check — this only changes what an accepted activation does. */
126
+ native_voice?: string[]
123
127
  }
124
128
 
125
129
  export type AddressBookOptions = {
package/tsconfig.json CHANGED
@@ -11,5 +11,5 @@
11
11
  "allowImportingTsExtensions": true,
12
12
  "forceConsistentCasingInFileNames": true
13
13
  },
14
- "include": ["src/**/*.ts", "tests/**/*.ts"]
14
+ "include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"]
15
15
  }