@melaya/runner 1.1.46 → 1.1.47

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.
@@ -1177,6 +1177,7 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
1177
1177
 
1178
1178
 
1179
1179
  def main() -> int:
1180
+ _boot_t0 = time.perf_counter()
1180
1181
  _log(f"booting (provider={os.environ.get('MEL_ASSISTANT_PROVIDER')})")
1181
1182
  # Wire OpenTelemetry → Melaya event relay so agentscope's @trace_llm actually
1182
1183
  # exports token spans to agents.spans — the SAME thing pipelines get via
@@ -1195,7 +1196,9 @@ def main() -> int:
1195
1196
  # in-process phone tools (phone.py._cmd) see it from turn one.
1196
1197
  _apply_hitl_mode(None)
1197
1198
  try:
1199
+ _t_agent = time.perf_counter()
1198
1200
  agent = _build_agent()
1201
+ _log(f"[timing] _build_agent {time.perf_counter() - _t_agent:.2f}s")
1199
1202
  except Exception as exc:
1200
1203
  _log("boot failed:\n" + traceback.format_exc())
1201
1204
  _emit("", "error", message="The assistant could not start on this runner. " + _turn_error_message(exc))
@@ -1218,7 +1221,7 @@ def main() -> int:
1218
1221
  _log("compaction hooks unavailable (non-fatal)")
1219
1222
 
1220
1223
  _emit("", "ready", memoryWatermark=_memory_watermark(agent), hostInstanceId=_HOST_ID, generation=_GENERATION, configHash=_config_hash())
1221
- _log("ready")
1224
+ _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
1225
 
1223
1226
  q: "queue.Queue[str]" = queue.Queue()
1224
1227
  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,15 @@ try {
55
55
  }
56
56
  catch { /* dev tree layout differs — non-fatal */ }
57
57
  const activeAssistants = new Map();
58
+ // Warm pool (size 1): a fully-booted host held in reserve, ready to be ADOPTED
59
+ // by the next new session whose config+generation match — skipping the ~7s cold
60
+ // boot (import + build_agent). Pure optimization: any mismatch or unhealthy
61
+ // spare falls through to a normal fresh boot, so it can never break a session.
62
+ let warmSpare = null;
63
+ // The (env, config, generation) to prewarm the next spare with — learned from
64
+ // the most recent real assistant_start so the spare matches the user's actual
65
+ // usage. null until the first real boot teaches us a config.
66
+ let warmSpareSeed = null;
58
67
  // PR4: sid-keyed boot reservation. The guard-then-spawn in runner:assistant_start
59
68
  // has an async gap (ensureSharedModules / ensurePythonEnv awaits) between the
60
69
  // activeAssistants.has() check and the .set(). A second assistant_start in that
@@ -1293,6 +1302,159 @@ export async function connect(opts) {
1293
1302
  // codex / ollama / lmstudio: assistant_start boots a long-lived host,
1294
1303
  // assistant_turn feeds one user message, and the host streams events back as
1295
1304
  // runner:assistant_event frames the server relays into the browser SSE.
1305
+ // ── Warm-pool: spawn + wire an assistant host ────────────────────────────
1306
+ // Used for a REAL session boot (forward=true) AND for a pooled spare
1307
+ // (forward=false → the host's frames are suppressed until adoption re-points
1308
+ // session.sid + flips forward). Reading session.sid/forward (not a captured
1309
+ // constant) is what lets one process be adopted by a later sid. The `error`
1310
+ // listener is registered UNCONDITIONALLY here — the old code nested it inside
1311
+ // `exit`, so a spawn 'error' (which never fires 'exit') had no listener and
1312
+ // took down the whole runner.
1313
+ const launchAssistantHost = (a) => {
1314
+ const proc = spawn(a.pythonPath, ["-u", a.stagedHost], { cwd: a.workDir, env: a.env, stdio: ["pipe", "pipe", "pipe"] });
1315
+ const session = {
1316
+ proc, sid: a.sid, forward: a.forward, ready: false, lastActivity: Date.now(),
1317
+ stdoutBuf: "", generation: a.generation, memoryWatermark: 0, hostInstanceId: "", configHash: "", stderrTail: [],
1318
+ };
1319
+ console.log(chalk.hex("#7c6ff0")(` ◆ Assistant ${a.forward ? "session" : "spare"} ${a.sid.slice(0, 12)}… (${a.providerLabel})`));
1320
+ proc.stdout?.on("data", (data) => {
1321
+ session.lastActivity = Date.now();
1322
+ session.stdoutBuf += data.toString();
1323
+ let idx;
1324
+ while ((idx = session.stdoutBuf.indexOf("\n")) >= 0) {
1325
+ const line = session.stdoutBuf.slice(0, idx);
1326
+ session.stdoutBuf = session.stdoutBuf.slice(idx + 1);
1327
+ const t = line.trim();
1328
+ if (t.startsWith("MELASSIST ")) {
1329
+ try {
1330
+ const parsed = JSON.parse(t.slice("MELASSIST ".length));
1331
+ if (typeof parsed?.memoryWatermark === "number")
1332
+ session.memoryWatermark = parsed.memoryWatermark;
1333
+ if (parsed?.kind === "ready") {
1334
+ if (typeof parsed.hostInstanceId === "string")
1335
+ session.hostInstanceId = parsed.hostInstanceId;
1336
+ if (typeof parsed.configHash === "string")
1337
+ session.configHash = parsed.configHash;
1338
+ session.ready = true;
1339
+ parsed.protocol = ASSISTANT_PROTOCOL_VERSION;
1340
+ }
1341
+ // A spare's frames are NOT forwarded (no session yet); adoption flips
1342
+ // session.forward and re-emits `ready` with the real sid.
1343
+ if (session.forward)
1344
+ socket.emit("runner:assistant_event", { sessionId: session.sid, ...parsed });
1345
+ }
1346
+ catch { /* skip malformed */ }
1347
+ }
1348
+ else if (t && opts.verbose) {
1349
+ console.log(chalk.gray(` [assistant-stdout] ${t}`));
1350
+ }
1351
+ }
1352
+ });
1353
+ proc.stderr?.on("data", (data) => {
1354
+ for (const ln of data.toString().split("\n")) {
1355
+ const t = ln.trimEnd();
1356
+ if (!t)
1357
+ continue;
1358
+ if (opts.verbose)
1359
+ console.log(chalk.gray(` [assistant-stderr] ${t}`));
1360
+ session.stderrTail.push(t);
1361
+ if (session.stderrTail.length > 40)
1362
+ session.stderrTail.shift();
1363
+ }
1364
+ });
1365
+ proc.on("error", (err) => {
1366
+ if (warmSpare === session)
1367
+ warmSpare = null;
1368
+ if (activeAssistants.get(session.sid) === session)
1369
+ activeAssistants.delete(session.sid);
1370
+ bootingAssistants.delete(session.sid);
1371
+ if (!session.forward) {
1372
+ if (opts.verbose)
1373
+ console.log(chalk.gray(` [prewarm] spare spawn error: ${err?.message || err}`));
1374
+ return;
1375
+ }
1376
+ browserBridge?.teardownRunsForSession(session.sid, "assistant_spawn_error").catch(() => { });
1377
+ socket.emit("runner:assistant_event", { sessionId: session.sid, turnId: "", kind: "error", message: `assistant host spawn failed: ${err?.message || err}` });
1378
+ });
1379
+ proc.on("exit", (code) => {
1380
+ if (warmSpare === session)
1381
+ warmSpare = null;
1382
+ const weOwned = activeAssistants.get(session.sid) === session;
1383
+ if (weOwned)
1384
+ activeAssistants.delete(session.sid);
1385
+ if (weOwned)
1386
+ browserBridge?.teardownRunsForSession(session.sid, `assistant_exit:${code}`).catch(() => { });
1387
+ if (!session.forward) {
1388
+ if (opts.verbose)
1389
+ console.log(chalk.gray(` ■ Assistant spare exited (${code}) pre-adoption`));
1390
+ return;
1391
+ }
1392
+ if (session.killed) {
1393
+ console.log(chalk.gray(` ■ Assistant host ${session.sid.slice(0, 10)}… replaced (config drift, exit ${code})`));
1394
+ return;
1395
+ }
1396
+ const detail = code !== 0 && session.stderrTail.length ? session.stderrTail.slice(-12).join("\n") : "";
1397
+ socket.emit("runner:assistant_event", { sessionId: session.sid, turnId: "", kind: "session_closed", code, detail });
1398
+ console.log(chalk.gray(` ■ Assistant session ${session.sid.slice(0, 10)}… closed (exit ${code})`));
1399
+ });
1400
+ return session;
1401
+ };
1402
+ // Prewarm ONE spare from the most-recent real config so the next matching new
1403
+ // session adopts it and skips the ~7s cold boot. Best-effort, ≤1 spare, kills a
1404
+ // stale spare (config changed) first, self-destructs after 10 min if unused.
1405
+ // Off by default: the adoption MECHANISM is proven, but a CLI-session provider
1406
+ // (claude_code/codex keep a per-user CLI/OAuth session) can contend when a
1407
+ // spare runs a turn alongside a live host — needs per-provider validation
1408
+ // before it's on for everyone. Set MEL_ASSISTANT_WARM_POOL=1 to enable.
1409
+ const WARM_POOL_ENABLED = process.env.MEL_ASSISTANT_WARM_POOL === "1";
1410
+ let _prewarmInFlight = false;
1411
+ const schedulePrewarm = () => {
1412
+ if (!WARM_POOL_ENABLED || !warmSpareSeed || _prewarmInFlight)
1413
+ return;
1414
+ if (warmSpare) {
1415
+ if (warmSpare.configHash && warmSpare.configHash !== warmSpareSeed.configHash) {
1416
+ try {
1417
+ warmSpare.proc.kill();
1418
+ }
1419
+ catch { /* gone */ }
1420
+ warmSpare = null;
1421
+ }
1422
+ else {
1423
+ return;
1424
+ }
1425
+ }
1426
+ _prewarmInFlight = true;
1427
+ try {
1428
+ const seed = warmSpareSeed;
1429
+ const stamp = String(Date.now());
1430
+ const wd = join(seed.workDirBase, `melaya-assistant-spare-${stamp}`);
1431
+ mkdirSync(wd, { recursive: true });
1432
+ const staged = join(wd, "assistantHost.py");
1433
+ copyFileSync(seed.hostScript, staged);
1434
+ // Keep seed.env's MEL_RUN_ID (`assistant:<userId>`, per-user, shared by all
1435
+ // of a user's sessions) so an adopted spare's run context / tracing / phone
1436
+ // arm plumbing matches the session that adopts it — overriding it to a
1437
+ // spare-specific id broke turn execution on adoption.
1438
+ const spareEnv = { ...seed.env };
1439
+ const spare = launchAssistantHost({ env: spareEnv, pythonPath: seed.pythonPath, workDir: wd, stagedHost: staged, sid: `__spare__:${stamp}`, generation: seed.generation, forward: false, providerLabel: "prewarm" });
1440
+ warmSpare = spare;
1441
+ const t = setTimeout(() => { if (warmSpare === spare && !spare.forward) {
1442
+ try {
1443
+ spare.proc.kill();
1444
+ }
1445
+ catch { /* gone */ }
1446
+ warmSpare = null;
1447
+ } }, 10 * 60_000);
1448
+ t.unref?.();
1449
+ }
1450
+ catch (e) {
1451
+ if (opts.verbose)
1452
+ console.log(chalk.gray(` [prewarm] launch failed: ${e?.message || e}`));
1453
+ }
1454
+ finally {
1455
+ _prewarmInFlight = false;
1456
+ }
1457
+ };
1296
1458
  socket.on("runner:assistant_start", async (payload) => {
1297
1459
  const sid = String(payload.sessionId || "");
1298
1460
  if (!sid)
@@ -1328,6 +1490,25 @@ export async function connect(opts) {
1328
1490
  return;
1329
1491
  }
1330
1492
  }
1493
+ // Warm-pool ADOPTION (fast path). A fully-booted spare with the SAME config
1494
+ // AND generation is waiting — re-point it to this sid and report ready
1495
+ // immediately, skipping the ~7s cold boot (import + build_agent). Fail-safe:
1496
+ // exact match of a READY, un-killed spare ONLY; anything else falls straight
1497
+ // through to the fresh-boot path below, so the pool can never break a session.
1498
+ if (!activeAssistants.has(sid) && !bootingAssistants.has(sid)
1499
+ && warmSpare && warmSpare.ready && !warmSpare.killed && expectedConfig
1500
+ && warmSpare.configHash === expectedConfig && warmSpare.generation === generation) {
1501
+ const spare = warmSpare;
1502
+ warmSpare = null;
1503
+ spare.sid = sid;
1504
+ spare.forward = true;
1505
+ spare.lastActivity = Date.now();
1506
+ activeAssistants.set(sid, spare);
1507
+ console.log(chalk.green(` ◆ Assistant ${sid.slice(0, 10)}… adopted warm spare — cold boot skipped`));
1508
+ emitEv({ kind: "ready", memoryWatermark: spare.memoryWatermark || 0, generation: spare.generation, hostInstanceId: spare.hostInstanceId, configHash: spare.configHash, protocol: ASSISTANT_PROTOCOL_VERSION });
1509
+ schedulePrewarm(); // refill the pool for the next session
1510
+ return;
1511
+ }
1331
1512
  // A boot is already in flight for this sid (concurrent start / server restart
1332
1513
  // mid-boot). Drop this duplicate — the in-flight boot will emit `ready`.
1333
1514
  if (bootingAssistants.has(sid))
@@ -1454,101 +1635,16 @@ export async function connect(opts) {
1454
1635
  // Per-user creds (incl. MELAYA_API_KEY + the selected connectors' env).
1455
1636
  ...(payload.credentials || {}),
1456
1637
  };
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})`));
1638
+ const session = launchAssistantHost({
1639
+ env, pythonPath: envResult.pythonPath, workDir, stagedHost,
1640
+ sid, generation, forward: true, providerLabel: String(payload.provider || ""),
1551
1641
  });
1642
+ activeAssistants.set(sid, session);
1643
+ // Teach the pool this exact (env, config, generation) so it prewarms a
1644
+ // matching spare for the NEXT new session — which then adopts it and skips
1645
+ // the cold boot (see the ADOPTION fast path + schedulePrewarm above).
1646
+ warmSpareSeed = { env, configHash: expectedConfig, generation, pythonPath: envResult.pythonPath, workDirBase: tmpdir(), hostScript };
1647
+ schedulePrewarm();
1552
1648
  }
1553
1649
  catch (e) {
1554
1650
  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.47",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,