infinicode 2.8.86 → 2.8.87

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.
@@ -18,7 +18,7 @@
18
18
  * registration mechanics is reimplemented here.
19
19
  */
20
20
  import { spawn, execFile } from 'node:child_process';
21
- import { existsSync } from 'node:fs';
21
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
22
22
  import { hostname, networkInterfaces } from 'node:os';
23
23
  import { join } from 'node:path';
24
24
  import chalk from 'chalk';
@@ -103,6 +103,48 @@ function validateComposePath(path) {
103
103
  throw new Error(`no docker-compose.yaml/.yml found in '${path}'. Pass --path pointing at a ROBOVOICE checkout ` +
104
104
  `(the directory containing docker-compose.yaml), not this repo.`);
105
105
  }
106
+ /** Persist the single-track greeting fix in a ROBOVOICE checkout. */
107
+ function ensureRobovoiceSingleAudioTrack(path) {
108
+ const candidates = [
109
+ join(path, 'voice_agent.py'),
110
+ join(path, 'app', 'voice_agent.py'),
111
+ join(path, 'src', 'voice_agent.py'),
112
+ ];
113
+ const file = candidates.find((candidate) => existsSync(candidate));
114
+ if (!file) {
115
+ return { patched: false, warning: 'voice_agent.py was not found; skipping RoboPark greeting compatibility patch' };
116
+ }
117
+ const source = readFileSync(file, 'utf8');
118
+ if (source.includes('Initial greeting completed through AgentSession audio track')) {
119
+ return { patched: false, file };
120
+ }
121
+ const startMarker = ' # Synthesize the greeting first';
122
+ const endMarker = ' except Exception as e:';
123
+ const start = source.indexOf(startMarker);
124
+ const end = start >= 0 ? source.indexOf(endMarker, start) : -1;
125
+ if (start < 0 || end < 0) {
126
+ return {
127
+ patched: false,
128
+ file,
129
+ warning: 'voice_agent.py does not contain the known duplicate greeting-track block; no automatic patch applied',
130
+ };
131
+ }
132
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
133
+ const replacement = [
134
+ ' # Use the existing conversation track. A dedicated greeting',
135
+ ' # track makes Linux robots compete for the same ALSA device.',
136
+ ' import inspect as _inspect',
137
+ ' speech = session.say(greeting, allow_interruptions=False)',
138
+ ' if _inspect.isawaitable(speech):',
139
+ ' await speech',
140
+ " elif hasattr(speech, 'wait_for_playout'):",
141
+ ' await speech.wait_for_playout()',
142
+ " logger.info('Initial greeting completed through AgentSession audio track')",
143
+ '',
144
+ ].join(newline);
145
+ writeFileSync(file, source.slice(0, start) + replacement + source.slice(end), 'utf8');
146
+ return { patched: true, file };
147
+ }
106
148
  export async function roboparkAgentUp(opts) {
107
149
  console.log(chalk.bold('\n robopark agent up'));
108
150
  console.log(chalk.dim(' ' + '─'.repeat(52)));
@@ -125,8 +167,18 @@ export async function roboparkAgentUp(opts) {
125
167
  console.log(chalk.dim(` using: ${compose.cmd} ${compose.baseArgs.join(' ')}`.trimEnd()));
126
168
  console.log(` path: ${chalk.cyan(opts.path)}`);
127
169
  console.log();
170
+ const greetingPatch = ensureRobovoiceSingleAudioTrack(opts.path);
171
+ if (greetingPatch.patched) {
172
+ console.log(chalk.green(` ✓ persisted single-track RoboPark greeting in ${greetingPatch.file}`));
173
+ }
174
+ else if (greetingPatch.warning) {
175
+ console.log(chalk.yellow(` ⚠ ${greetingPatch.warning}`));
176
+ }
128
177
  console.log(chalk.bold(' 1. starting ROBOVOICE docker stack (docker compose up -d)…'));
129
- const upCode = await runCompose(compose, ['up', '-d'], opts.path);
178
+ // Rebuild only when compatibility source changed, making the fix survive
179
+ // container recreation without slowing normal restarts.
180
+ const upArgs = greetingPatch.patched ? ['up', '-d', '--build'] : ['up', '-d'];
181
+ const upCode = await runCompose(compose, upArgs, opts.path);
130
182
  if (upCode !== 0) {
131
183
  console.log(chalk.red(`\n ✗ docker compose up -d exited with code ${upCode}`));
132
184
  process.exit(upCode);
@@ -0,0 +1,111 @@
1
+ # RoboPark Production Memory
2
+
3
+ Last verified: 2026-07-16
4
+
5
+ This records production facts future operators and coding agents must
6
+ preserve. Never add enrollment tokens, mesh tokens, API keys, or device tokens.
7
+
8
+ ## Verified Architecture
9
+
10
+ - `livekit-1` is the Windows hub.
11
+ - Mesh/dashboard: TCP `47913`.
12
+ - RoboPark scheduler: TCP `8080`, proxied under `/robopark`.
13
+ - LiveKit: TCP `7880`.
14
+ - Production voice worker container: `caal-agent`.
15
+ - BMW maps to device `dev_0422e545` and character `vixen` / `VIXEN (BMW)`.
16
+ - Robots publish camera and microphone tracks to LiveKit. Park camera preview
17
+ uses the RoboVision relay rather than LiveKit video.
18
+
19
+ ## BMW Launch Device Lock
20
+
21
+ - Camera: `/dev/video0` (`H65 USB CAMERA`).
22
+ - Microphone: scheduler ID `2`, `Usb Audio Device: USB Audio (hw:3,0)`.
23
+ - Speaker: scheduler ID `1`, `USB Audio Device: - (hw:2,0)`.
24
+ - Linux live playback must use ALSA `plughw:2,0` through `aplay`.
25
+ - Production mic gain defaults to `ROBOPARK_MIC_GAIN=4.0` and is bounded to
26
+ `1.0..12.0` before PCM frames enter LiveKit.
27
+ - RoboVision/sounddevice numeric IDs are not PyAudio numeric IDs.
28
+ - Do not use HDMI or metadata-only `/dev/video*` devices as launch defaults.
29
+
30
+ ## Verified Production Flow
31
+
32
+ BMW passed these measured stages in one production session:
33
+
34
+ 1. `scheduler_session`
35
+ 2. `livekit_join`
36
+ 3. `tts_subscribed`
37
+ 4. `playback_started`
38
+ 5. `camera_published`
39
+ 6. `microphone_published`
40
+ 7. `robot_online`
41
+ 8. `camera_ready`
42
+ 9. `microphone_ready`
43
+ 10. `speaker_ready`
44
+
45
+ `playback_started` means the first remote TTS frame reached the robot. Never
46
+ report end-to-end audio delivery without this event.
47
+
48
+ ## Root Causes Fixed
49
+
50
+ ### Silent Greeting
51
+
52
+ The old ROBOVOICE greeting published a second dedicated LiveKit audio track.
53
+ The Pi subscribed to both tracks and attempted to open the same speaker twice.
54
+ ROBOVOICE must call `session.say(...)` so greeting and conversation share one
55
+ track. `robopark agent-up` now detects the old source block, patches the
56
+ ROBOVOICE checkout, and rebuilds Compose once.
57
+
58
+ ### Linux Speaker Playback
59
+
60
+ The preview agent previously used PyAudio for the selected speaker. BMW's
61
+ proven output is ALSA `aplay -D plughw:2,0`. Infinicode `2.8.86` uses that
62
+ same endpoint for live TTS whenever a Linux output selection contains `hw:`.
63
+
64
+ ### Silent Conversation After Greeting
65
+
66
+ Publishing a LiveKit microphone track does not prove that PCM capture started.
67
+ On Linux the production capture path is `arecord -D plughw:3,0 -f S16_LE -r
68
+ 48000 -c 1`; it must work even when PyAudio is unavailable. The Pi now waits
69
+ for actual PCM frames before reporting `microphone_published`, reports a blocked
70
+ stage when capture does not start within five seconds, and applies bounded mic
71
+ gain before publication.
72
+
73
+ ### Duplicate Speaker Tests
74
+
75
+ Only one robot process may claim a scheduler `speaker_test`. The scheduler
76
+ uses an atomic lease, and supervisor-status queries exclude speaker-test rows.
77
+
78
+ ### BMW Alias Configuration
79
+
80
+ Scheduler routes canonicalize alias `bmw` to `dev_0422e545` before reading
81
+ character, voice-stack, and greeting configuration.
82
+
83
+ ## Operational Rules
84
+
85
+ - Run one unified robot runtime per robot. Do not manually start preview,
86
+ vision, or mesh beside the systemd runtime.
87
+ - Do not run audio diagnostics during a live conversation.
88
+ - A direct ALSA test does not validate production if production uses another
89
+ API, index space, sample format, or opens the device twice.
90
+ - After a hub reboot, verify `47913`, `8080`, and `7880` before robot media.
91
+ - `tts_subscribed` alone is insufficient; require `playback_started`.
92
+ - Greeting `playback_started` alone does not prove conversation. A complete
93
+ turn also requires a user/STT event, an LLM response, and response playback.
94
+ - Container-local changes are temporary. Persist voice changes in the
95
+ ROBOVOICE checkout/image or use `robopark agent-up --path <checkout>`.
96
+
97
+ ## Minimal Verification
98
+
99
+ Run from the hub:
100
+
101
+ ```powershell
102
+ try { Invoke-RestMethod -Method Post http://127.0.0.1:8080/api/robots/bmw/pipeline-test/stop | Out-Null } catch {}
103
+ Start-Sleep 3
104
+ Invoke-RestMethod -Method Post http://127.0.0.1:8080/api/robots/bmw/pipeline-test/start | Out-Null
105
+ Start-Sleep 30
106
+ $s = Invoke-RestMethod http://127.0.0.1:8080/api/robots/bmw/pipeline-status
107
+ $id = $s.latest_session.id
108
+ $s.events | Where-Object session_id -eq $id | Select-Object timestamp,stage,status,message
109
+ ```
110
+
111
+ Required result: `playback_started ok`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.86",
3
+ "version": "2.8.87",
4
4
  "description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/kernel/index.js",
@@ -29,8 +29,9 @@
29
29
  "static",
30
30
  "packages/robopark/scheduler",
31
31
  "packages/robopark/pi-client",
32
- "packages/robopark/vision",
33
- ".opencode/plugins",
32
+ "packages/robopark/vision",
33
+ "docs/ROBOPARK_PRODUCTION_MEMORY.md",
34
+ ".opencode/plugins",
34
35
  ".opencode/themes",
35
36
  ".opencode/tui.json",
36
37
  "README.md"
@@ -1,4 +1,7 @@
1
- # robopark
1
+ # robopark
2
+
3
+ Production architecture, device locks, verified stages, and incident memory
4
+ are recorded in [`../../docs/ROBOPARK_PRODUCTION_MEMORY.md`](../../docs/ROBOPARK_PRODUCTION_MEMORY.md).
2
5
 
3
6
  The **RoboPark fleet control CLI** — set up, watch, and drive a room full of
4
7
  talking robots from one command. `robopark` is the operator front-end; it rides
@@ -1134,6 +1134,7 @@ class LiveKitPublisher:
1134
1134
  self._tasks: list[asyncio.Task] = []
1135
1135
  self._capture: Optional["VideoCapture"] = None
1136
1136
  self._mic_capture: Optional["AudioCapture"] = None
1137
+ self._mic_streaming = asyncio.Event()
1137
1138
  # Motion sampling state belongs to the publisher instance. Keeping it
1138
1139
  # initialized here prevents shutdown/reopen paths from raising while
1139
1140
  # the camera is being handed between preview and voice sessions.
@@ -1212,13 +1213,12 @@ class LiveKitPublisher:
1212
1213
  self.video_source.capture_frame(first_frame)
1213
1214
  await self.agent._report_pipeline("camera_published", "ok", "Camera track published")
1214
1215
 
1215
- if audio_enabled:
1216
- self.audio_source = self.rtc.AudioSource(48000, 1)
1217
- self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
1218
- aopts = self.rtc.TrackPublishOptions()
1219
- aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
1216
+ if audio_enabled:
1217
+ self.audio_source = self.rtc.AudioSource(48000, 1)
1218
+ self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
1219
+ aopts = self.rtc.TrackPublishOptions()
1220
+ aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
1220
1221
  await self.room.local_participant.publish_track(self.audio_track, aopts)
1221
- await self.agent._report_pipeline("microphone_published", "ok", "Microphone track published")
1222
1222
 
1223
1223
  # Register speaker playback (subscribe to the voice agent's TTS audio
1224
1224
  # track) BEFORE opening the camera. Camera open is a slow/occasionally
@@ -1227,8 +1227,18 @@ class LiveKitPublisher:
1227
1227
  # entire event loop — including receiving the agent's greeting audio
1228
1228
  # — until it finished, so a short "Hello friend!" greeting could be
1229
1229
  # over and gone before we ever got a chance to subscribe to it.
1230
- if has_audio() and audio_enabled:
1231
- self._tasks.append(asyncio.create_task(self._audio_loop()))
1230
+ if audio_enabled:
1231
+ self._tasks.append(asyncio.create_task(self._audio_loop()))
1232
+ try:
1233
+ await asyncio.wait_for(self._mic_streaming.wait(), timeout=5.0)
1234
+ await self.agent._report_pipeline(
1235
+ "microphone_published", "ok", "Microphone track published with live PCM frames"
1236
+ )
1237
+ except asyncio.TimeoutError:
1238
+ await self.agent._report_pipeline(
1239
+ "microphone_published", "blocked", "Microphone track published but PCM capture did not start"
1240
+ )
1241
+ raise RuntimeError(f"microphone capture did not start for {self.agent.audio_capture_device}")
1232
1242
 
1233
1243
  if self._capture and self.video_source:
1234
1244
  self._tasks.append(asyncio.create_task(self._video_loop()))
@@ -1557,7 +1567,8 @@ class LiveKitPublisher:
1557
1567
  samples_per_frame = int(48000 * frame_ms / 1000)
1558
1568
  samples_per_batch = int(48000 * BATCH_MS / 1000)
1559
1569
  bytes_per_frame = samples_per_frame * 2 # int16 mono
1560
- import audioop
1570
+ import audioop
1571
+ mic_gain = max(1.0, min(float(os.getenv("ROBOPARK_MIC_GAIN", "4.0")), 12.0))
1561
1572
  _diag_peak = 0
1562
1573
  _diag_count = 0
1563
1574
  _diag_last_log = time.monotonic()
@@ -1573,11 +1584,14 @@ class LiveKitPublisher:
1573
1584
  data = bytes(batch.data)
1574
1585
  _pub_start = time.monotonic()
1575
1586
  for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
1576
- chunk = data[off:off + bytes_per_frame]
1577
- frame = self.rtc.AudioFrame(
1587
+ chunk = data[off:off + bytes_per_frame]
1588
+ if mic_gain != 1.0:
1589
+ chunk = audioop.mul(chunk, 2, mic_gain)
1590
+ frame = self.rtc.AudioFrame(
1578
1591
  data=chunk, sample_rate=48000, num_channels=1, samples_per_channel=samples_per_frame,
1579
1592
  )
1580
- await self.audio_source.capture_frame(frame)
1593
+ await self.audio_source.capture_frame(frame)
1594
+ self._mic_streaming.set()
1581
1595
  p = audioop.max(chunk, 2)
1582
1596
  _diag_peak = max(_diag_peak, p)
1583
1597
  _diag_count += 1