@melaya/runner 1.1.46 → 1.1.48

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.
@@ -230,21 +230,47 @@ class _RedactingStdout:
230
230
  def _scrub(self, s: str) -> str:
231
231
  return self._B64.sub(lambda m: f"<redacted base64 {len(m.group(0))} bytes>", s)
232
232
 
233
+ def _flush_transcript(self):
234
+ # Send any buffered agentscope transcript to STDERR (redacted). Keeping it
235
+ # off stdout means the runner's stdout channel carries ONLY `MELASSIST `
236
+ # protocol frames: a transcript print can never glue onto a frame and be
237
+ # dropped (the missing-delta / garbled-fragment bug), and frame parsing on
238
+ # the runner stays cheap — no multi-KB base64 transcript lines flooding its
239
+ # event loop (which was spiking lag and dropping the socket heartbeat).
240
+ if self._buf:
241
+ try:
242
+ sys.stderr.write(self._scrub(self._buf))
243
+ except Exception:
244
+ pass
245
+ self._buf = ""
246
+
233
247
  def write(self, s):
234
248
  try:
249
+ # A protocol frame is emitted in ONE write() call, whole and
250
+ # newline-terminated (see _emit). Route it to the REAL stdout byte for
251
+ # byte (no scrub — frames are short structured JSON), after flushing any
252
+ # pending partial transcript so nothing can glue onto it.
253
+ if s.startswith(_EVENT_PREFIX):
254
+ self._flush_transcript()
255
+ self._real.write(s)
256
+ self._real.flush()
257
+ return len(s)
258
+ # Everything else = agentscope's verbatim message transcript → stderr,
259
+ # line-buffered + base64-redacted, so it never pollutes the protocol.
235
260
  self._buf += s
236
261
  while "\n" in self._buf:
237
262
  line, self._buf = self._buf.split("\n", 1)
238
- self._real.write(self._scrub(line) + "\n")
263
+ try:
264
+ sys.stderr.write(self._scrub(line) + "\n")
265
+ except Exception:
266
+ pass
239
267
  return len(s)
240
268
  except Exception:
241
269
  return self._real.write(s)
242
270
 
243
271
  def flush(self):
244
272
  try:
245
- if self._buf:
246
- self._real.write(self._scrub(self._buf))
247
- self._buf = ""
273
+ self._flush_transcript()
248
274
  self._real.flush()
249
275
  except Exception:
250
276
  pass
@@ -1177,6 +1203,7 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
1177
1203
 
1178
1204
 
1179
1205
  def main() -> int:
1206
+ _boot_t0 = time.perf_counter()
1180
1207
  _log(f"booting (provider={os.environ.get('MEL_ASSISTANT_PROVIDER')})")
1181
1208
  # Wire OpenTelemetry → Melaya event relay so agentscope's @trace_llm actually
1182
1209
  # exports token spans to agents.spans — the SAME thing pipelines get via
@@ -1195,7 +1222,9 @@ def main() -> int:
1195
1222
  # in-process phone tools (phone.py._cmd) see it from turn one.
1196
1223
  _apply_hitl_mode(None)
1197
1224
  try:
1225
+ _t_agent = time.perf_counter()
1198
1226
  agent = _build_agent()
1227
+ _log(f"[timing] _build_agent {time.perf_counter() - _t_agent:.2f}s")
1199
1228
  except Exception as exc:
1200
1229
  _log("boot failed:\n" + traceback.format_exc())
1201
1230
  _emit("", "error", message="The assistant could not start on this runner. " + _turn_error_message(exc))
@@ -1218,7 +1247,7 @@ def main() -> int:
1218
1247
  _log("compaction hooks unavailable (non-fatal)")
1219
1248
 
1220
1249
  _emit("", "ready", memoryWatermark=_memory_watermark(agent), hostInstanceId=_HOST_ID, generation=_GENERATION, configHash=_config_hash())
1221
- _log("ready")
1250
+ _log(f"[timing] ready — {time.perf_counter() - _boot_t0:.2f}s (main start → ready). Import cost = gap from process spawn to the 'booting' line above.")
1222
1251
 
1223
1252
  q: "queue.Queue[str]" = queue.Queue()
1224
1253
  threading.Thread(target=_stdin_reader, args=(q,), daemon=True).start()
@@ -14,7 +14,7 @@ import { io } from "socket.io-client";
14
14
  import chalk from "chalk";
15
15
  import ora from "ora";
16
16
  import { spawn } from "child_process";
17
- import { writeFileSync, mkdirSync, existsSync, readFileSync } from "fs";
17
+ import { writeFileSync, mkdirSync, existsSync, readFileSync, copyFileSync } from "fs";
18
18
  import { dirname, join } from "path";
19
19
  import { fileURLToPath } from "url";
20
20
  import { tmpdir, homedir } from "os";
@@ -55,6 +55,20 @@ try {
55
55
  }
56
56
  catch { /* dev tree layout differs — non-fatal */ }
57
57
  const activeAssistants = new Map();
58
+ // Line prefix the assistant host uses to frame protocol events on stdout
59
+ // (mirror of assistantHost.py `_EVENT_PREFIX`). Everything else on stdout is
60
+ // raw agentscope transcript (now routed to stderr host-side, but we still parse
61
+ // defensively in case a frame arrives glued to a raw print).
62
+ const _EVENT_PREFIX = "MELASSIST ";
63
+ // Warm pool (size 1): a fully-booted host held in reserve, ready to be ADOPTED
64
+ // by the next new session whose config+generation match — skipping the ~7s cold
65
+ // boot (import + build_agent). Pure optimization: any mismatch or unhealthy
66
+ // spare falls through to a normal fresh boot, so it can never break a session.
67
+ let warmSpare = null;
68
+ // The (env, config, generation) to prewarm the next spare with — learned from
69
+ // the most recent real assistant_start so the spare matches the user's actual
70
+ // usage. null until the first real boot teaches us a config.
71
+ let warmSpareSeed = null;
58
72
  // PR4: sid-keyed boot reservation. The guard-then-spawn in runner:assistant_start
59
73
  // has an async gap (ensureSharedModules / ensurePythonEnv awaits) between the
60
74
  // activeAssistants.has() check and the .set(). A second assistant_start in that
@@ -1293,6 +1307,172 @@ export async function connect(opts) {
1293
1307
  // codex / ollama / lmstudio: assistant_start boots a long-lived host,
1294
1308
  // assistant_turn feeds one user message, and the host streams events back as
1295
1309
  // runner:assistant_event frames the server relays into the browser SSE.
1310
+ // ── Warm-pool: spawn + wire an assistant host ────────────────────────────
1311
+ // Used for a REAL session boot (forward=true) AND for a pooled spare
1312
+ // (forward=false → the host's frames are suppressed until adoption re-points
1313
+ // session.sid + flips forward). Reading session.sid/forward (not a captured
1314
+ // constant) is what lets one process be adopted by a later sid. The `error`
1315
+ // listener is registered UNCONDITIONALLY here — the old code nested it inside
1316
+ // `exit`, so a spawn 'error' (which never fires 'exit') had no listener and
1317
+ // took down the whole runner.
1318
+ const launchAssistantHost = (a) => {
1319
+ const proc = spawn(a.pythonPath, ["-u", a.stagedHost], { cwd: a.workDir, env: a.env, stdio: ["pipe", "pipe", "pipe"] });
1320
+ const session = {
1321
+ proc, sid: a.sid, forward: a.forward, ready: false, lastActivity: Date.now(),
1322
+ stdoutBuf: "", generation: a.generation, memoryWatermark: 0, hostInstanceId: "", configHash: "", stderrTail: [],
1323
+ };
1324
+ console.log(chalk.hex("#7c6ff0")(` ◆ Assistant ${a.forward ? "session" : "spare"} ${a.sid.slice(0, 12)}… (${a.providerLabel})`));
1325
+ proc.stdout?.on("data", (data) => {
1326
+ session.lastActivity = Date.now();
1327
+ session.stdoutBuf += data.toString();
1328
+ let idx;
1329
+ while ((idx = session.stdoutBuf.indexOf("\n")) >= 0) {
1330
+ const line = session.stdoutBuf.slice(0, idx);
1331
+ session.stdoutBuf = session.stdoutBuf.slice(idx + 1);
1332
+ const t = line.trim();
1333
+ // A protocol frame can arrive GLUED to a preceding raw print that had no
1334
+ // trailing newline (agentscope prints the agent transcript to the same
1335
+ // stdout), e.g. `Assistant: GotMELASSIST {"kind":"delta",…}`. Find the
1336
+ // prefix ANYWHERE in the line, not just at the start, or that delta is
1337
+ // dropped as raw output (the missing-deltas / garbled-fragment bug).
1338
+ const mi = t.indexOf(_EVENT_PREFIX);
1339
+ if (mi >= 0) {
1340
+ if (mi > 0 && opts.verbose)
1341
+ console.log(chalk.gray(` [assistant-stdout] ${t.slice(0, mi)}`));
1342
+ try {
1343
+ const parsed = JSON.parse(t.slice(mi + _EVENT_PREFIX.length));
1344
+ if (typeof parsed?.memoryWatermark === "number")
1345
+ session.memoryWatermark = parsed.memoryWatermark;
1346
+ if (parsed?.kind === "ready") {
1347
+ if (typeof parsed.hostInstanceId === "string")
1348
+ session.hostInstanceId = parsed.hostInstanceId;
1349
+ if (typeof parsed.configHash === "string")
1350
+ session.configHash = parsed.configHash;
1351
+ session.ready = true;
1352
+ parsed.protocol = ASSISTANT_PROTOCOL_VERSION;
1353
+ }
1354
+ // A spare's frames are NOT forwarded (no session yet); adoption flips
1355
+ // session.forward and re-emits `ready` with the real sid.
1356
+ if (session.forward)
1357
+ socket.emit("runner:assistant_event", { sessionId: session.sid, ...parsed });
1358
+ }
1359
+ catch {
1360
+ // The literal "MELASSIST " appeared inside raw text (not a real frame)
1361
+ // — the JSON tail won't parse; treat the whole line as raw output.
1362
+ if (t && opts.verbose)
1363
+ console.log(chalk.gray(` [assistant-stdout] ${t}`));
1364
+ }
1365
+ }
1366
+ else if (t && opts.verbose) {
1367
+ console.log(chalk.gray(` [assistant-stdout] ${t}`));
1368
+ }
1369
+ }
1370
+ });
1371
+ proc.stderr?.on("data", (data) => {
1372
+ for (const ln of data.toString().split("\n")) {
1373
+ const t = ln.trimEnd();
1374
+ if (!t)
1375
+ continue;
1376
+ if (opts.verbose)
1377
+ console.log(chalk.gray(` [assistant-stderr] ${t}`));
1378
+ session.stderrTail.push(t);
1379
+ if (session.stderrTail.length > 40)
1380
+ session.stderrTail.shift();
1381
+ }
1382
+ });
1383
+ proc.on("error", (err) => {
1384
+ if (warmSpare === session)
1385
+ warmSpare = null;
1386
+ if (activeAssistants.get(session.sid) === session)
1387
+ activeAssistants.delete(session.sid);
1388
+ bootingAssistants.delete(session.sid);
1389
+ if (!session.forward) {
1390
+ if (opts.verbose)
1391
+ console.log(chalk.gray(` [prewarm] spare spawn error: ${err?.message || err}`));
1392
+ return;
1393
+ }
1394
+ browserBridge?.teardownRunsForSession(session.sid, "assistant_spawn_error").catch(() => { });
1395
+ socket.emit("runner:assistant_event", { sessionId: session.sid, turnId: "", kind: "error", message: `assistant host spawn failed: ${err?.message || err}` });
1396
+ });
1397
+ proc.on("exit", (code) => {
1398
+ if (warmSpare === session)
1399
+ warmSpare = null;
1400
+ const weOwned = activeAssistants.get(session.sid) === session;
1401
+ if (weOwned)
1402
+ activeAssistants.delete(session.sid);
1403
+ if (weOwned)
1404
+ browserBridge?.teardownRunsForSession(session.sid, `assistant_exit:${code}`).catch(() => { });
1405
+ if (!session.forward) {
1406
+ if (opts.verbose)
1407
+ console.log(chalk.gray(` ■ Assistant spare exited (${code}) pre-adoption`));
1408
+ return;
1409
+ }
1410
+ if (session.killed) {
1411
+ console.log(chalk.gray(` ■ Assistant host ${session.sid.slice(0, 10)}… replaced (config drift, exit ${code})`));
1412
+ return;
1413
+ }
1414
+ const detail = code !== 0 && session.stderrTail.length ? session.stderrTail.slice(-12).join("\n") : "";
1415
+ socket.emit("runner:assistant_event", { sessionId: session.sid, turnId: "", kind: "session_closed", code, detail });
1416
+ console.log(chalk.gray(` ■ Assistant session ${session.sid.slice(0, 10)}… closed (exit ${code})`));
1417
+ });
1418
+ return session;
1419
+ };
1420
+ // Prewarm ONE spare from the most-recent real config so the next matching new
1421
+ // session adopts it and skips the ~7s cold boot. Best-effort, ≤1 spare, kills a
1422
+ // stale spare (config changed) first, self-destructs after 10 min if unused.
1423
+ // Off by default: the adoption MECHANISM is proven, but a CLI-session provider
1424
+ // (claude_code/codex keep a per-user CLI/OAuth session) can contend when a
1425
+ // spare runs a turn alongside a live host — needs per-provider validation
1426
+ // before it's on for everyone. Set MEL_ASSISTANT_WARM_POOL=1 to enable.
1427
+ const WARM_POOL_ENABLED = process.env.MEL_ASSISTANT_WARM_POOL === "1";
1428
+ let _prewarmInFlight = false;
1429
+ const schedulePrewarm = () => {
1430
+ if (!WARM_POOL_ENABLED || !warmSpareSeed || _prewarmInFlight)
1431
+ return;
1432
+ if (warmSpare) {
1433
+ if (warmSpare.configHash && warmSpare.configHash !== warmSpareSeed.configHash) {
1434
+ try {
1435
+ warmSpare.proc.kill();
1436
+ }
1437
+ catch { /* gone */ }
1438
+ warmSpare = null;
1439
+ }
1440
+ else {
1441
+ return;
1442
+ }
1443
+ }
1444
+ _prewarmInFlight = true;
1445
+ try {
1446
+ const seed = warmSpareSeed;
1447
+ const stamp = String(Date.now());
1448
+ const wd = join(seed.workDirBase, `melaya-assistant-spare-${stamp}`);
1449
+ mkdirSync(wd, { recursive: true });
1450
+ const staged = join(wd, "assistantHost.py");
1451
+ copyFileSync(seed.hostScript, staged);
1452
+ // Keep seed.env's MEL_RUN_ID (`assistant:<userId>`, per-user, shared by all
1453
+ // of a user's sessions) so an adopted spare's run context / tracing / phone
1454
+ // arm plumbing matches the session that adopts it — overriding it to a
1455
+ // spare-specific id broke turn execution on adoption.
1456
+ const spareEnv = { ...seed.env };
1457
+ const spare = launchAssistantHost({ env: spareEnv, pythonPath: seed.pythonPath, workDir: wd, stagedHost: staged, sid: `__spare__:${stamp}`, generation: seed.generation, forward: false, providerLabel: "prewarm" });
1458
+ warmSpare = spare;
1459
+ const t = setTimeout(() => { if (warmSpare === spare && !spare.forward) {
1460
+ try {
1461
+ spare.proc.kill();
1462
+ }
1463
+ catch { /* gone */ }
1464
+ warmSpare = null;
1465
+ } }, 10 * 60_000);
1466
+ t.unref?.();
1467
+ }
1468
+ catch (e) {
1469
+ if (opts.verbose)
1470
+ console.log(chalk.gray(` [prewarm] launch failed: ${e?.message || e}`));
1471
+ }
1472
+ finally {
1473
+ _prewarmInFlight = false;
1474
+ }
1475
+ };
1296
1476
  socket.on("runner:assistant_start", async (payload) => {
1297
1477
  const sid = String(payload.sessionId || "");
1298
1478
  if (!sid)
@@ -1328,6 +1508,25 @@ export async function connect(opts) {
1328
1508
  return;
1329
1509
  }
1330
1510
  }
1511
+ // Warm-pool ADOPTION (fast path). A fully-booted spare with the SAME config
1512
+ // AND generation is waiting — re-point it to this sid and report ready
1513
+ // immediately, skipping the ~7s cold boot (import + build_agent). Fail-safe:
1514
+ // exact match of a READY, un-killed spare ONLY; anything else falls straight
1515
+ // through to the fresh-boot path below, so the pool can never break a session.
1516
+ if (!activeAssistants.has(sid) && !bootingAssistants.has(sid)
1517
+ && warmSpare && warmSpare.ready && !warmSpare.killed && expectedConfig
1518
+ && warmSpare.configHash === expectedConfig && warmSpare.generation === generation) {
1519
+ const spare = warmSpare;
1520
+ warmSpare = null;
1521
+ spare.sid = sid;
1522
+ spare.forward = true;
1523
+ spare.lastActivity = Date.now();
1524
+ activeAssistants.set(sid, spare);
1525
+ console.log(chalk.green(` ◆ Assistant ${sid.slice(0, 10)}… adopted warm spare — cold boot skipped`));
1526
+ emitEv({ kind: "ready", memoryWatermark: spare.memoryWatermark || 0, generation: spare.generation, hostInstanceId: spare.hostInstanceId, configHash: spare.configHash, protocol: ASSISTANT_PROTOCOL_VERSION });
1527
+ schedulePrewarm(); // refill the pool for the next session
1528
+ return;
1529
+ }
1331
1530
  // A boot is already in flight for this sid (concurrent start / server restart
1332
1531
  // mid-boot). Drop this duplicate — the in-flight boot will emit `ready`.
1333
1532
  if (bootingAssistants.has(sid))
@@ -1454,101 +1653,16 @@ export async function connect(opts) {
1454
1653
  // Per-user creds (incl. MELAYA_API_KEY + the selected connectors' env).
1455
1654
  ...(payload.credentials || {}),
1456
1655
  };
1457
- const proc = spawn(envResult.pythonPath, ["-u", stagedHost], { cwd: workDir, env, stdio: ["pipe", "pipe", "pipe"] });
1458
- // hostInstanceId + configHash are filled from the host's OWN `ready` frame
1459
- // (assistantHost.py _HOST_ID / _config_hash) as it boots — authoritative and
1460
- // server-aligned. Start empty; the stdout parser captures them.
1461
- const session = { proc, lastActivity: Date.now(), stdoutBuf: "", generation, memoryWatermark: 0, hostInstanceId: "", configHash: "" };
1462
- activeAssistants.set(sid, session);
1463
- console.log(chalk.hex("#7c6ff0")(` ◆ Assistant session ${sid.slice(0, 10)}… (${payload.provider})`));
1464
- proc.stdout?.on("data", (data) => {
1465
- // ANY host output means the turn is actively working — refresh the idle
1466
- // clock. Without this, lastActivity was set ONLY at turn start (assistant_turn),
1467
- // so a SINGLE long turn (a phone task is one turn with ~100 tool calls streaming
1468
- // for >15 min) was reaped mid-action by the idle sweep despite continuous
1469
- // activity ("idle-swept" while properly working). Now only a genuinely silent
1470
- // host (no output for ASSISTANT_IDLE_MS) is swept.
1471
- session.lastActivity = Date.now();
1472
- session.stdoutBuf += data.toString();
1473
- let idx;
1474
- while ((idx = session.stdoutBuf.indexOf("\n")) >= 0) {
1475
- const line = session.stdoutBuf.slice(0, idx);
1476
- session.stdoutBuf = session.stdoutBuf.slice(idx + 1);
1477
- const t = line.trim();
1478
- if (t.startsWith("MELASSIST ")) {
1479
- try {
1480
- const parsed = JSON.parse(t.slice("MELASSIST ".length));
1481
- // Track the host's latest memory watermark (ready / rehydrated
1482
- // frames report it) so a re-`start` of the live host reports a
1483
- // truthful watermark and the server skips a needless rehydrate.
1484
- if (typeof parsed?.memoryWatermark === "number")
1485
- session.memoryWatermark = parsed.memoryWatermark;
1486
- // CAPTURE the host's authoritative identity off its `ready` frame
1487
- // (_HOST_ID / _config_hash) so the live-host ready path can re-report
1488
- // them without the host re-emitting; ADD only the wire protocol.
1489
- if (parsed?.kind === "ready") {
1490
- if (typeof parsed.hostInstanceId === "string")
1491
- session.hostInstanceId = parsed.hostInstanceId;
1492
- if (typeof parsed.configHash === "string")
1493
- session.configHash = parsed.configHash;
1494
- parsed.protocol = ASSISTANT_PROTOCOL_VERSION;
1495
- }
1496
- socket.emit("runner:assistant_event", { sessionId: sid, ...parsed });
1497
- }
1498
- catch { /* skip malformed */ }
1499
- }
1500
- else if (t && opts.verbose) {
1501
- console.log(chalk.gray(` [assistant-stdout] ${t}`));
1502
- }
1503
- }
1504
- });
1505
- const stderrTail = [];
1506
- proc.stderr?.on("data", (data) => {
1507
- for (const ln of data.toString().split("\n")) {
1508
- const t = ln.trimEnd();
1509
- if (!t)
1510
- continue;
1511
- if (opts.verbose)
1512
- console.log(chalk.gray(` [assistant-stderr] ${t}`));
1513
- stderrTail.push(t);
1514
- if (stderrTail.length > 40)
1515
- stderrTail.shift();
1516
- }
1517
- });
1518
- proc.on("exit", (code) => {
1519
- // Only evict if WE are still the registered host. A config-drift reboot
1520
- // (e.g. the user added a connector) kills this host and spawns a REPLACEMENT
1521
- // under the same sid; if this late exit deleted the map entry blindly it
1522
- // would orphan the newcomer and make the next turn "session_not_found".
1523
- proc.on("error", (err) => {
1524
- // A spawn that fails (python deleted post-check, EACCES/AV) emits "error"
1525
- // and never "exit"; with no listener this took down the WHOLE runner.
1526
- if (activeAssistants.get(sid) === session)
1527
- activeAssistants.delete(sid);
1528
- bootingAssistants.delete(sid);
1529
- browserBridge?.teardownRunsForSession(sid, "assistant_spawn_error").catch(() => { });
1530
- socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", kind: "error", message: `assistant host spawn failed: ${err?.message || err}` });
1531
- });
1532
- const weOwned = activeAssistants.get(sid) === session;
1533
- if (weOwned)
1534
- activeAssistants.delete(sid);
1535
- // Tear down any per-turn bridge runs this host leaked (a turn that never
1536
- // sent `done` before the host exited/crashed) so its lease + enforceOnPage
1537
- // handlers don't linger on the user's open tab. Only if a replacement
1538
- // host hasn't already taken over the sid.
1539
- if (weOwned)
1540
- browserBridge?.teardownRunsForSession(sid, `assistant_exit:${code}`).catch(() => { });
1541
- // A host we INTENTIONALLY killed for a config-drift reboot must NOT emit
1542
- // session_closed — that frame would fail the fresh boot's ready waiter
1543
- // (the server can't pre-mark it superseded here, the kill is runner-side).
1544
- if (session.killed) {
1545
- console.log(chalk.gray(` ■ Assistant host ${sid.slice(0, 10)}… replaced (config drift, exit ${code})`));
1546
- return;
1547
- }
1548
- const detail = code !== 0 && stderrTail.length ? stderrTail.slice(-12).join("\n") : "";
1549
- socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", kind: "session_closed", code, detail });
1550
- console.log(chalk.gray(` ■ Assistant session ${sid.slice(0, 10)}… closed (exit ${code})`));
1656
+ const session = launchAssistantHost({
1657
+ env, pythonPath: envResult.pythonPath, workDir, stagedHost,
1658
+ sid, generation, forward: true, providerLabel: String(payload.provider || ""),
1551
1659
  });
1660
+ activeAssistants.set(sid, session);
1661
+ // Teach the pool this exact (env, config, generation) so it prewarms a
1662
+ // matching spare for the NEXT new session — which then adopts it and skips
1663
+ // the cold boot (see the ADOPTION fast path + schedulePrewarm above).
1664
+ warmSpareSeed = { env, configHash: expectedConfig, generation, pythonPath: envResult.pythonPath, workDirBase: tmpdir(), hostScript };
1665
+ schedulePrewarm();
1552
1666
  }
1553
1667
  catch (e) {
1554
1668
  emitEv({ kind: "error", message: `assistant start failed: ${e?.message || e}` });
package/dist/pythonEnv.js CHANGED
@@ -15,6 +15,7 @@ import { createHash } from "crypto";
15
15
  import { join } from "path";
16
16
  import { homedir, platform } from "os";
17
17
  import chalk from "chalk";
18
+ import { localUvPath } from "./uvBootstrap.js";
18
19
  const CACHE_DIR = join(homedir(), ".melaya-runner");
19
20
  const VENV_DIR = join(CACHE_DIR, "venv");
20
21
  const VENV_MARK = join(CACHE_DIR, "venv-version.txt");
@@ -151,13 +152,18 @@ const PIP_DEPS = [
151
152
  // ── Template-validation sweep deps (2026-07-06) — tools that hard-error
152
153
  // "pip install X" on the runner without these, observed in live runs:
153
154
  // yt-dlp — youtube_search_channel / youtube_get_transcript (1221 YouTube Intel)
154
- // sentence-transformers — build_knowledge_from_text embedder (1221)
155
155
  // flights — flight_price_alert / flight_search live Google Flights ("fli" import; 1261/1265)
156
156
  // pymupdf4llm — pdf_to_text markdown extraction (1227 PDF Contract Analyser)
157
157
  "yt-dlp",
158
- "sentence-transformers",
159
158
  "flights",
160
159
  "pymupdf4llm",
160
+ // sentence-transformers REMOVED 2026-09-09 — it pulls torch (~2GB), which
161
+ // dominated the venv install/reinstall for EVERY runner, yet only
162
+ // build_knowledge_from_text (+ guarded fallbacks in rag/memory_search/aiml)
163
+ // uses it, and DocsTab local RAG embedding runs on OLLAMA/LM-Studio
164
+ // (localRagIngest.py), not sentence-transformers. All import sites are
165
+ // try/except-guarded, so the niche tool degrades to a "pip install
166
+ // sentence-transformers" message instead of shipping 2GB to everyone.
161
167
  // ── Vector RAG (retrieval mode) ────────────────────────────────────────
162
168
  // ollama — Python client used by agentscope.embedding.OllamaTextEmbedding.
163
169
  // Talks to localhost:11434 (the user's local Ollama daemon) to embed
@@ -215,12 +221,74 @@ const PIP_DEPS = [
215
221
  "asyncpg",
216
222
  "PyMySQL",
217
223
  "cryptography",
224
+ // ── Cloud-infra connector SDKs (shared/tools/{aws,route53,alibaba,gcp}.py) ──
225
+ // These modules LAZY-import their SDK inside the tool call, so the toolkit
226
+ // builds + creds.test passes GREEN without them — then the FIRST real tool
227
+ // call ModuleNotFoundErrors on the runner (AWS/Alibaba incident 2026-08-16;
228
+ // Alibaba re-hit 2026-09-09). They ship in requirements.lock (the cloud image)
229
+ // but MUST be mirrored HERE too — the runner venv installs from THIS list, not
230
+ // the lock. Surfaced by the runner dep audit (packages/runner/scripts/
231
+ // audit_runner_deps.py); keep the pins in step with requirements.lock.
232
+ // PURE-PYTHON WHEELS ONLY here: never add a dep that needs a system binary
233
+ // (libzbar / cairo+pango / tesseract / …) — a wheel-less dep fails the WHOLE
234
+ // venv bootstrap and bricks the runner; leave those lazy-guarded in the tool.
235
+ "boto3==1.42.42", // aws.py + route53.py
236
+ "alibabacloud-tea-openapi==0.4.5", // alibaba.py — generic Tea OpenAPI client
237
+ "alibabacloud-tea-util==0.3.15", // alibaba.py
238
+ "alibabacloud-openapi-util==0.2.4", // alibaba.py (pip resolves the rest of the Tea closure)
239
+ "google-api-python-client==2.198.0", // gcp.py — googleapiclient.discovery
240
+ "google-auth-httplib2==0.4.1", // gcp.py — AuthorizedHttp (pulls httplib2 + google-auth)
241
+ // ── Additional shared-tool deps (SOFT gaps from audit_runner_deps.py 2026-09-09)
242
+ // All lazy-imported inside the tool AND pure-python wheels, so they do NOT slow
243
+ // spawn (module import stays light) — only the one-time venv install. Without
244
+ // them the tool degrades with a "pip install X" message; cheap + common enough
245
+ // to ship. DELIBERATELY EXCLUDED (stay lazy-guarded, never in PIP_DEPS): the
246
+ // system-binary deps opencv/cv2, weasyprint (cairo+pango), pytesseract
247
+ // (tesseract), pyzbar (libzbar) — a wheel-less dep fails the whole venv
248
+ // bootstrap; and web3 / fastapi (heavy tree / odd for a tool runner).
249
+ "redis", // crew_blackout_watcher / crew_halts / hitl_middleware
250
+ "youtube-transcript-api", // youtube_tools
251
+ "icalendar", // ics_tools
252
+ "Unidecode", // translation_tools (imports as `unidecode`)
253
+ "pdfplumber", // core (PDF text extraction)
254
+ "segno", // qr_tools (QR generation)
255
+ "PyGithub", // github tool (imports as `github`)
256
+ "python-gitlab", // github tool (imports as `gitlab`)
257
+ "pandas-ta", // alphavantage_tools (TA indicators; pandas already present)
258
+ "python-telegram-bot", // messaging (bot-API rich path; pulls httpx, already present)
259
+ "aiomysql", // database (async MySQL; sync PyMySQL already present)
218
260
  ];
219
261
  export function venvPython() {
220
262
  return platform() === "win32"
221
263
  ? join(VENV_DIR, "Scripts", "python.exe")
222
264
  : join(VENV_DIR, "bin", "python");
223
265
  }
266
+ /** Prefer the runner-managed uv (localUvPath), else uv on PATH, else null.
267
+ * uv installs far faster than pip (parallel downloads + a SAFE content-
268
+ * addressed, per-hash-validated cache), turning the "1-2 min" venv reinstall on
269
+ * a deps/version change into seconds. Pure lookup — never downloads uv here
270
+ * (the pip fallback covers uv's absence, so the venv build can't be blocked). */
271
+ function _resolveUvBin() {
272
+ try {
273
+ const p = localUvPath();
274
+ if (existsSync(p))
275
+ return p;
276
+ }
277
+ catch { /* ignore */ }
278
+ const bin = platform() === "win32" ? "uv.exe" : "uv";
279
+ const sep = platform() === "win32" ? ";" : ":";
280
+ for (const dir of (process.env.PATH || "").split(sep)) {
281
+ if (!dir)
282
+ continue;
283
+ try {
284
+ const cand = join(dir, bin);
285
+ if (existsSync(cand))
286
+ return cand;
287
+ }
288
+ catch { /* ignore */ }
289
+ }
290
+ return null;
291
+ }
224
292
  // ── certifi CA-bundle path resolution + cache ────────────────────────────
225
293
  // Set as SSL_CERT_FILE + REQUESTS_CA_BUNDLE when spawning pipeline Python
226
294
  // so that ANY HTTPS-using tool (gmail_send, slack_post_text, anthropic /
@@ -488,15 +556,37 @@ async function _ensurePythonEnvImpl(systemPython, expectedVersion, onProgress =
488
556
  // only *.py files (no pyproject.toml), so PYTHONPATH-based import is
489
557
  // the only path. The cached agentscope source under
490
558
  // ~/.melaya-runner/agentscope/ resolves via PYTHONPATH at spawn time.
491
- onProgress(`installing ${PIP_DEPS.length} python deps (anthropic, openai, opentelemetry, …) — first run can take 1-2 min`);
492
- const acode = await runProc(venvPython(),
493
- // --no-cache-dir: skip pip's on-disk HTTP/wheel cache. It silences the
494
- // "Cache entry deserialization failed, entry ignored" warning flood (stale
495
- // entries left by other Python versions in the shared user cache) and, more
496
- // importantly, guarantees a corrupt cached wheel can never install a subtly
497
- // broken package into a fresh runner venv. Costs a re-download on rebuild,
498
- // which is rare (only on a deps change or a manual clear).
499
- ["-m", "pip", "install", "--use-feature=truststore", "--no-cache-dir", "--quiet", "--disable-pip-version-check", ...PIP_DEPS], onProgress);
559
+ // Prefer uv when available (the runner ships it — localUvPath — or provisions
560
+ // it for managed CPython). uv is ~10-100x faster than pip: parallel downloads,
561
+ // a resolver written in Rust, and a content-addressed cache that is validated
562
+ // per wheel hash — so unlike pip's SHARED, cross-Python-version cache (the very
563
+ // reason the pip path below passes --no-cache-dir) uv's cache can never install
564
+ // a corrupt wheel. We therefore get caching AND safety, which turns the "1-2
565
+ // min" reinstall on a deps/version change into seconds. UV_SYSTEM_CERTS mirrors
566
+ // pip's --use-feature=truststore (verify TLS against the OS trust store — vital
567
+ // behind intercepting proxies / AV, else CERTIFICATE_VERIFY_FAILED). Any uv
568
+ // failure falls straight back to the proven pip path so the venv build is never
569
+ // blocked by a uv edge case.
570
+ const _uvBin = _resolveUvBin();
571
+ let acode;
572
+ if (_uvBin) {
573
+ onProgress(`installing ${PIP_DEPS.length} python deps via uv (fast; cached reinstalls take seconds)`);
574
+ acode = await runProc(_uvBin, ["pip", "install", "--python", venvPython(), "--quiet", ...PIP_DEPS], onProgress, { UV_SYSTEM_CERTS: "1" });
575
+ if (acode !== 0) {
576
+ onProgress("uv install failed — falling back to pip (--no-cache-dir)");
577
+ acode = await runProc(venvPython(), ["-m", "pip", "install", "--use-feature=truststore", "--no-cache-dir", "--quiet", "--disable-pip-version-check", ...PIP_DEPS], onProgress);
578
+ }
579
+ }
580
+ else {
581
+ onProgress(`installing ${PIP_DEPS.length} python deps (anthropic, openai, opentelemetry, …) — first run can take 1-2 min`);
582
+ // --no-cache-dir: skip pip's on-disk HTTP/wheel cache. It silences the
583
+ // "Cache entry deserialization failed, entry ignored" warning flood (stale
584
+ // entries left by other Python versions in the shared user cache) and, more
585
+ // importantly, guarantees a corrupt cached wheel can never install a subtly
586
+ // broken package into a fresh runner venv. Costs a re-download on rebuild,
587
+ // which is rare (only on a deps change or a manual clear).
588
+ acode = await runProc(venvPython(), ["-m", "pip", "install", "--use-feature=truststore", "--no-cache-dir", "--quiet", "--disable-pip-version-check", ...PIP_DEPS], onProgress);
589
+ }
500
590
  if (acode !== 0) {
501
591
  return {
502
592
  ok: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.46",
3
+ "version": "1.1.48",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,