infinicode 2.8.85 → 2.8.86

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.
@@ -9,7 +9,8 @@
9
9
  */
10
10
  import chalk from 'chalk';
11
11
  import { spawn, execSync } from 'node:child_process';
12
- import { existsSync } from 'node:fs';
12
+ import { cpSync, existsSync, mkdirSync, statSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
13
14
  import { dirname, join } from 'node:path';
14
15
  import { fileURLToPath } from 'node:url';
15
16
  function pkgDir() {
@@ -68,6 +69,33 @@ function findPython() {
68
69
  // ENOENT/"not found" error from the OS rather than a silent fallback.
69
70
  return process.platform === 'win32' ? 'python' : 'python3';
70
71
  }
72
+ function resolveSchedulerDataDir(explicit) {
73
+ if (explicit)
74
+ return explicit;
75
+ if (process.env.SCHEDULER_DATA_DIR)
76
+ return process.env.SCHEDULER_DATA_DIR;
77
+ const stable = join(homedir(), '.robopark', 'scheduler');
78
+ const stableDb = join(stable, 'scheduler.db');
79
+ if (existsSync(stableDb))
80
+ return stable;
81
+ // Older releases stored data beside whichever npm installation launched
82
+ // the scheduler. Local, global and npx installs therefore created separate
83
+ // databases. Migrate the largest existing DB once into stable user storage.
84
+ const legacyCandidates = [join(pkgDir(), '..', 'data')];
85
+ if (process.platform === 'win32' && process.env.APPDATA) {
86
+ legacyCandidates.push(join(process.env.APPDATA, 'npm', 'node_modules', 'data'));
87
+ }
88
+ const legacy = legacyCandidates
89
+ .filter((candidate, index, all) => all.indexOf(candidate) === index)
90
+ .filter(candidate => existsSync(join(candidate, 'scheduler.db')))
91
+ .sort((a, b) => statSync(join(b, 'scheduler.db')).size - statSync(join(a, 'scheduler.db')).size)[0];
92
+ mkdirSync(stable, { recursive: true });
93
+ if (legacy) {
94
+ cpSync(legacy, stable, { recursive: true, force: false, errorOnExist: false });
95
+ console.log(chalk.yellow(` migrated scheduler data: ${legacy} -> ${stable}`));
96
+ }
97
+ return stable;
98
+ }
71
99
  /** Resolve the actual infinicode CLI entry point so `robopark setup --start`
72
100
  * works even when global bins are not on PATH.
73
101
  *
@@ -110,6 +138,7 @@ export async function roboparkServe(opts) {
110
138
  const python = findPython();
111
139
  const schedulerDir = dirname(schedulerPath);
112
140
  const requirements = join(schedulerDir, 'requirements.txt');
141
+ const dataDir = resolveSchedulerDataDir(opts.dataDir);
113
142
  // Ensure scheduler Python dependencies are installed before starting.
114
143
  if (existsSync(requirements)) {
115
144
  console.log(chalk.dim(' ensuring scheduler Python deps…'));
@@ -125,7 +154,7 @@ export async function roboparkServe(opts) {
125
154
  ...process.env,
126
155
  SCHEDULER_HOST: host,
127
156
  SCHEDULER_PORT: String(port),
128
- SCHEDULER_DATA_DIR: opts.dataDir ?? join(pkgDir(), '..', 'data'),
157
+ SCHEDULER_DATA_DIR: dataDir,
129
158
  };
130
159
  if (opts.gateway)
131
160
  env.INFINIBOT_GATEWAY_URL = opts.gateway;
@@ -138,6 +167,7 @@ export async function roboparkServe(opts) {
138
167
  console.log(` scheduler: ${chalk.cyan(schedulerPath)}`);
139
168
  console.log(` python: ${chalk.cyan(python)}`);
140
169
  console.log(` listen: ${chalk.cyan(`${host}:${port}`)}`);
170
+ console.log(` data: ${chalk.cyan(dataDir)}`);
141
171
  if (opts.gateway)
142
172
  console.log(` gateway: ${chalk.cyan(opts.gateway)}`);
143
173
  console.log();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.85",
3
+ "version": "2.8.86",
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",
@@ -604,6 +604,9 @@ async def init_db():
604
604
  ("sessions", "voice_stack_id", "TEXT"),
605
605
  ("sessions", "initiated_by", "TEXT"),
606
606
  ("sessions", "event_token_hash", "TEXT"),
607
+ # Speaker tests are executed only by preview_agent, which can
608
+ # release and restore the active media publisher around ALSA I/O.
609
+ ("shell_requests", "claimed_at", "TEXT"),
607
610
  ):
608
611
  try:
609
612
  await db.execute(f"ALTER TABLE {_table} ADD COLUMN {_column} {_coldef}")
@@ -3317,8 +3320,9 @@ async def device_supervisor_status(device_id: str, payload: SupervisorStatusRepo
3317
3320
  pending = []
3318
3321
  try:
3319
3322
  async with db.execute(
3320
- "SELECT service_name, action, kind, params, id FROM shell_requests "
3321
- "WHERE device_id = ? AND completed_at IS NULL ORDER BY requested_at",
3323
+ "SELECT service_name, action, kind, params, id FROM shell_requests "
3324
+ "WHERE device_id = ? AND completed_at IS NULL AND kind != 'speaker_test' "
3325
+ "ORDER BY requested_at",
3322
3326
  (device_id,),
3323
3327
  ) as c:
3324
3328
  shell_rows = [dict(r) for r in await c.fetchall()]
@@ -3696,13 +3700,26 @@ async def next_device_speaker_test(device_id: str, authorization: Optional[str]
3696
3700
  """Allow the always-running preview agent to execute audio tests."""
3697
3701
  if not await _authorize_device(device_id, authorization):
3698
3702
  raise HTTPException(401, "Invalid device token")
3703
+ now = datetime.utcnow()
3704
+ stale_before = (now - timedelta(seconds=45)).isoformat()
3699
3705
  async with aiosqlite.connect(DB_PATH) as db:
3700
3706
  db.row_factory = aiosqlite.Row
3707
+ await db.execute("BEGIN IMMEDIATE")
3701
3708
  async with db.execute(
3702
3709
  "SELECT id, params FROM shell_requests WHERE device_id = ? AND kind = 'speaker_test' "
3703
- "AND completed_at IS NULL ORDER BY requested_at LIMIT 1", (device_id,),
3710
+ "AND completed_at IS NULL AND (claimed_at IS NULL OR claimed_at < ?) "
3711
+ "ORDER BY requested_at LIMIT 1", (device_id, stale_before),
3704
3712
  ) as cursor:
3705
3713
  row = await cursor.fetchone()
3714
+ if row:
3715
+ claimed = await db.execute(
3716
+ "UPDATE shell_requests SET claimed_at = ? WHERE id = ? AND completed_at IS NULL "
3717
+ "AND (claimed_at IS NULL OR claimed_at < ?)",
3718
+ (now.isoformat(), row["id"], stale_before),
3719
+ )
3720
+ if claimed.rowcount != 1:
3721
+ row = None
3722
+ await db.commit()
3706
3723
  return {"request": {"id": row["id"], "params": json.loads(row["params"] or "{}")} if row else None}
3707
3724
 
3708
3725
 
@@ -4171,25 +4188,35 @@ async def _auto_elevenlabs_voice(robot_name: Optional[str], current: Optional[st
4171
4188
 
4172
4189
 
4173
4190
  async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
4174
- """Public helper to resolve the effective voice config for a robot id."""
4175
- async with aiosqlite.connect(DB_PATH) as db:
4176
- db.row_factory = aiosqlite.Row
4177
- stack_dict = await _resolve_voice_stack_for_robot(db, robot_id)
4191
+ """Public helper to resolve the effective voice config for a robot id."""
4192
+ async with aiosqlite.connect(DB_PATH) as db:
4193
+ db.row_factory = aiosqlite.Row
4194
+ # Dashboard routes commonly use the human robot name ("bmw"), while
4195
+ # assignments are persisted against the enrolled device id. Resolve
4196
+ # that alias once so character, stack and greetings use one identity.
4197
+ async with db.execute(
4198
+ "SELECT id FROM devices WHERE id = ? OR lower(name) = lower(?) "
4199
+ "ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END LIMIT 1",
4200
+ (robot_id, robot_id, robot_id),
4201
+ ) as c:
4202
+ canonical_device = await c.fetchone()
4203
+ canonical_id = canonical_device["id"] if canonical_device else robot_id
4204
+ stack_dict = await _resolve_voice_stack_for_robot(db, canonical_id)
4178
4205
 
4179
4206
  # Determine character preset + name
4180
4207
  character_id = None
4181
4208
  character_name = None
4182
4209
  system_prompt = None
4183
4210
  async with db.execute(
4184
- "SELECT character_preset_id FROM robot_voice_configs WHERE robot_id = ?", (robot_id,)
4211
+ "SELECT character_preset_id FROM robot_voice_configs WHERE robot_id = ?", (canonical_id,)
4185
4212
  ) as c:
4186
4213
  rvc = await c.fetchone()
4187
4214
  if rvc and rvc["character_preset_id"]:
4188
4215
  character_id = rvc["character_preset_id"]
4189
4216
  else:
4190
4217
  async with db.execute(
4191
- "SELECT character_id FROM robots WHERE id = ? UNION ALL SELECT character_id FROM devices WHERE id = ?",
4192
- (robot_id, robot_id),
4218
+ "SELECT character_id FROM robots WHERE id = ? UNION ALL SELECT character_id FROM devices WHERE id = ?",
4219
+ (canonical_id, canonical_id),
4193
4220
  ) as c:
4194
4221
  row = await c.fetchone()
4195
4222
  if row:
@@ -4204,21 +4231,23 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
4204
4231
  system_prompt = row["system_prompt"]
4205
4232
  if not character_name:
4206
4233
  async with db.execute(
4207
- "SELECT name FROM robots WHERE id = ? UNION ALL SELECT name FROM devices WHERE id = ?",
4208
- (robot_id, robot_id),
4234
+ "SELECT name FROM robots WHERE id = ? UNION ALL SELECT name FROM devices WHERE id = ?",
4235
+ (canonical_id, canonical_id),
4209
4236
  ) as c:
4210
4237
  row = await c.fetchone()
4211
4238
  if row:
4212
4239
  character_name = row["name"]
4213
4240
 
4214
- async with db.execute("SELECT greeting_phrases FROM devices WHERE id = ?", (robot_id,)) as c:
4241
+ async with db.execute("SELECT greeting_phrases FROM devices WHERE id = ?", (canonical_id,)) as c:
4215
4242
  device_row = await c.fetchone()
4216
4243
  greeting_phrases = []
4217
4244
  if device_row and device_row["greeting_phrases"]:
4218
4245
  try:
4219
4246
  greeting_phrases = [str(p) for p in _json.loads(device_row["greeting_phrases"]) if str(p).strip()]
4220
- except Exception:
4221
- greeting_phrases = []
4247
+ except Exception:
4248
+ greeting_phrases = []
4249
+ if not greeting_phrases and (character_name or character_id):
4250
+ greeting_phrases = [f"Hello, I am {character_name or character_id}. How can I help you?"]
4222
4251
  if stack_dict:
4223
4252
  stack_dict["tts_voice"] = await _auto_elevenlabs_voice(character_name or character_id, stack_dict.get("tts_voice"))
4224
4253
  voice_stack = VoiceStack(**stack_dict) if stack_dict else None
@@ -1233,44 +1233,95 @@ class LiveKitPublisher:
1233
1233
  if self._capture and self.video_source:
1234
1234
  self._tasks.append(asyncio.create_task(self._video_loop()))
1235
1235
 
1236
- async def _play_remote_audio(self, track, sid: str) -> None:
1237
- try:
1238
- import pyaudio
1239
- except Exception as e:
1240
- logger.warning(f"pyaudio unavailable for playback: {e}")
1241
- return
1242
- OUT_RATE = 48000
1243
- OUT_CHANNELS = 2
1244
- pa = pyaudio.PyAudio()
1245
- # Same MME-vs-WASAPI gotcha as mic capture (see PyAudioCapture._resolve_device):
1246
- # PyAudio's global default output device resolves through MME, which on this
1247
- # machine will accept writes and report success with zero exceptions while
1248
- # never actually reaching the real speakers. Prefer WASAPI's default output,
1249
- # which is what Windows' own volume mixer/meter is backed by.
1250
- output_device_index = None
1236
+ async def _play_remote_audio(self, track, sid: str) -> None:
1237
+ OUT_RATE = 48000
1238
+ OUT_CHANNELS = 2
1251
1239
  selected_output = str(self.agent.audio_playback_device or "default")
1252
- if selected_output.strip().isdigit():
1253
- output_device_index = int(selected_output.strip())
1254
- try:
1255
- if selected_output.lower() == "default":
1256
- wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
1257
- idx = wasapi.get("defaultOutputDevice")
1258
- if idx is not None and idx >= 0:
1259
- output_device_index = idx
1260
- elif output_device_index is None:
1261
- needle = selected_output.lower()
1262
- for i in range(pa.get_device_count()):
1263
- info = pa.get_device_info_by_index(i)
1264
- if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
1265
- output_device_index = i
1266
- break
1267
- except Exception as e:
1268
- logger.debug(f"audio out: WASAPI default output lookup failed, using PyAudio default: {e}")
1269
- out = pa.open(
1270
- format=pyaudio.paInt16, channels=OUT_CHANNELS, rate=OUT_RATE, output=True,
1271
- output_device_index=output_device_index,
1272
- frames_per_buffer=960,
1273
- )
1240
+ pa = None
1241
+ output_device_index = None
1242
+
1243
+ # RoboVision inventories Linux devices through sounddevice/PortAudio,
1244
+ # but the numeric indices are not stable across PyAudio builds. More
1245
+ # importantly, BMW's production USB adapter is already proven through
1246
+ # ALSA's plughw conversion path. Use that exact endpoint for live TTS
1247
+ # instead of reopening the unrelated PyAudio index.
1248
+ if sys.platform.startswith("linux") and "hw:" in selected_output:
1249
+ import re
1250
+ import subprocess
1251
+
1252
+ match = re.search(r"\b(hw:\d+,\d+)\b", selected_output)
1253
+ if not match:
1254
+ logger.warning(f"audio out: no ALSA hardware address in {selected_output!r}")
1255
+ return
1256
+ alsa_device = f"plug{match.group(1)}"
1257
+ process = subprocess.Popen(
1258
+ [
1259
+ "aplay", "-q", "-D", alsa_device, "-t", "raw",
1260
+ "-f", "S16_LE", "-r", str(OUT_RATE), "-c", str(OUT_CHANNELS),
1261
+ ],
1262
+ stdin=subprocess.PIPE,
1263
+ stderr=subprocess.PIPE,
1264
+ )
1265
+ if process.stdin is None:
1266
+ logger.warning(f"audio out: aplay did not expose stdin for {alsa_device}")
1267
+ process.kill()
1268
+ return
1269
+
1270
+ class _AplayOutput:
1271
+ def write(self, chunk: bytes) -> None:
1272
+ if process.poll() is not None:
1273
+ detail = ""
1274
+ if process.stderr is not None:
1275
+ detail = process.stderr.read().decode("utf-8", errors="replace").strip()
1276
+ raise OSError(detail or f"aplay exited {process.returncode}")
1277
+ process.stdin.write(chunk)
1278
+ process.stdin.flush()
1279
+
1280
+ def stop_stream(self) -> None:
1281
+ if process.stdin and not process.stdin.closed:
1282
+ process.stdin.close()
1283
+ try:
1284
+ process.wait(timeout=2)
1285
+ except subprocess.TimeoutExpired:
1286
+ process.terminate()
1287
+
1288
+ def close(self) -> None:
1289
+ return
1290
+
1291
+ out = _AplayOutput()
1292
+ output_device_index = alsa_device
1293
+ else:
1294
+ try:
1295
+ import pyaudio
1296
+ except Exception as e:
1297
+ logger.warning(f"pyaudio unavailable for playback: {e}")
1298
+ return
1299
+ pa = pyaudio.PyAudio()
1300
+ # Same MME-vs-WASAPI gotcha as mic capture (see
1301
+ # PyAudioCapture._resolve_device): prefer the endpoint backing the
1302
+ # Windows volume mixer instead of the silent MME default.
1303
+ if selected_output.strip().isdigit():
1304
+ output_device_index = int(selected_output.strip())
1305
+ try:
1306
+ if selected_output.lower() == "default":
1307
+ wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
1308
+ idx = wasapi.get("defaultOutputDevice")
1309
+ if idx is not None and idx >= 0:
1310
+ output_device_index = idx
1311
+ elif output_device_index is None:
1312
+ needle = selected_output.lower()
1313
+ for i in range(pa.get_device_count()):
1314
+ info = pa.get_device_info_by_index(i)
1315
+ if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
1316
+ output_device_index = i
1317
+ break
1318
+ except Exception as e:
1319
+ logger.debug(f"audio out: WASAPI default output lookup failed, using PyAudio default: {e}")
1320
+ out = pa.open(
1321
+ format=pyaudio.paInt16, channels=OUT_CHANNELS, rate=OUT_RATE, output=True,
1322
+ output_device_index=output_device_index,
1323
+ frames_per_buffer=960,
1324
+ )
1274
1325
  logger.info(f"audio out: opened playback stream for {sid} (device_index={output_device_index})")
1275
1326
  frame_count = 0
1276
1327
  mismatch_logged = False
@@ -1409,9 +1460,10 @@ class LiveKitPublisher:
1409
1460
  f"audio out: {sid} received {frame_count} frames, "
1410
1461
  f"writer wrote {written_frames[0]} chunks, queue backlog at close={write_queue.qsize()}"
1411
1462
  )
1412
- out.stop_stream()
1413
- out.close()
1414
- pa.terminate()
1463
+ out.stop_stream()
1464
+ out.close()
1465
+ if pa is not None:
1466
+ pa.terminate()
1415
1467
 
1416
1468
  async def stop(self) -> None:
1417
1469
  self._stop_event.set()