@melaya/runner 1.0.54 → 1.0.56

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.
Files changed (2) hide show
  1. package/dist/connection.js +280 -0
  2. package/package.json +1 -1
@@ -317,6 +317,26 @@ export async function connect(opts) {
317
317
  MEL_RELAY_NONCE: relay.nonce,
318
318
  LMSTUDIO_BASE_URL: "http://127.0.0.1:1234",
319
319
  OLLAMA_BASE_URL: "http://127.0.0.1:11434",
320
+ // Tool-output directory convention (2026-06-07). File-producing
321
+ // tools (meme_generate, excel writers, …) read MELAYA_OUTPUT_DIR
322
+ // and default their save_to here. For local-runner pipelines we
323
+ // point it at ~/Documents/Melaya-Pipelines/<pipeline>/outputs/ so
324
+ // the file lands somewhere persistent on the user's box (NOT the
325
+ // ephemeral runDir which the runbook expects to survive across
326
+ // runs). The pipeline_dir copy at <runDir>/outputs/ that codegen
327
+ // would create on cloud-spawn is overridden here because the
328
+ // codegen template uses `os.environ.setdefault(...)` — the
329
+ // runner-set value wins. See AddNewTool.md §2d.
330
+ MELAYA_OUTPUT_DIR: (() => {
331
+ const dir = join(homedir(), "Documents", "Melaya-Pipelines", payload.pipelineName, "outputs");
332
+ try {
333
+ mkdirSync(dir, { recursive: true });
334
+ }
335
+ catch (e) {
336
+ console.log(chalk.yellow(` ! could not create output dir ${dir}: ${e.message}`));
337
+ }
338
+ return dir;
339
+ })(),
320
340
  // Inject per-pipeline credentials (already scoped by the server)
321
341
  ...payload.credentials,
322
342
  // If a Luma browser bridge is up (i.e. the user has signed in
@@ -452,6 +472,266 @@ export async function connect(opts) {
452
472
  socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
453
473
  }
454
474
  });
475
+ // ── Trading Crew strategy spawn ──────────────────────────────────────
476
+ // Mirrors the runner:run pipeline path but for `strategy_type='agent_crew'`
477
+ // rows whose runtime_mode='local_runner'. The server's
478
+ // StrategyCrewRuntime branches on runtimeMode='local_runner' (decision
479
+ // tree in Obsidian/07 Trading/Trading Strategies/Trading Agents Crew/
480
+ // Local vs Cloud.md), emits `runner:start_crew_strategy` to the user's
481
+ // socket, and this handler boots the same crew worker as the cloud
482
+ // pool would — except spawned as a plain Python subprocess on the
483
+ // user's hardware so lmstudio/ollama personas reach localhost:1234 /
484
+ // localhost:11434 directly. There's no code signature here because the
485
+ // cfg.generated_code originates from the Melaya server (cloud-trusted),
486
+ // not from operator input. Agent message events flow back via the
487
+ // existing runner:event → runnerNamespace.pushEvent path so the FE
488
+ // Studio's RenderRunningCrew bubbles populate in real time.
489
+ socket.on("runner:start_crew_strategy", async (payload) => {
490
+ const runId = payload.run_id;
491
+ const sid = payload.strategy_id;
492
+ const cfg = payload.cfg ?? {};
493
+ console.log(chalk.hex("#10b981")(`\n ▶ Trading crew: ${sid.slice(0, 16)}… (run ${runId.slice(0, 10)}…)`));
494
+ try {
495
+ const mainPyContent = String(cfg.generated_code ?? "").trim();
496
+ if (!mainPyContent) {
497
+ // Server-side fallback should always populate generated_code now
498
+ // (runnerNamespace.startCrewStrategyOnRunner backfills via
499
+ // renderCrewFallbackMainPy). If we still arrive here, the server
500
+ // is older than this runner — surface a clear message instead of
501
+ // a silent no-op.
502
+ console.log(chalk.red(" ✗ Crew cfg missing generated_code — server-side fallback unavailable"));
503
+ socket.emit("runner:event", {
504
+ run_id: runId,
505
+ event_type: "agent_message",
506
+ replyId: `crew-init-${runId}`,
507
+ replyName: "Runner",
508
+ replyRole: "system",
509
+ msg: {
510
+ id: `crew-init-${runId}`,
511
+ name: "Runner",
512
+ role: "system",
513
+ content: [{ type: "text", text: "Crew cfg missing generated_code. Re-save the crew in the Studio." }],
514
+ metadata: {},
515
+ timestamp: new Date().toISOString(),
516
+ },
517
+ });
518
+ socket.emit("runner:runComplete", { runId, status: "failed" });
519
+ return;
520
+ }
521
+ // Ensure shared modules cached. The crew worker imports from
522
+ // `shared.runtime.trading_crew_personas` (per the codegen
523
+ // template's import_path) which lives in the same shared/ tree
524
+ // the pipeline runner uses.
525
+ await ensureSharedModules(opts.serverUrl, "latest");
526
+ // Dedicated venv with agentscope + anthropic + openai installed.
527
+ const { ensurePythonEnv } = await import("./pythonEnv.js");
528
+ const envResult = await ensurePythonEnv(opts.pythonPath, "latest", (msg) => {
529
+ console.log(chalk.gray(` [venv] ${msg}`));
530
+ socket.emit("runner:event", {
531
+ run_id: runId,
532
+ event_type: "pipeline_phase",
533
+ project: `crew-${sid.slice(-12)}`,
534
+ step: 1,
535
+ total: 5,
536
+ label: `venv: ${msg}`,
537
+ status: "started",
538
+ });
539
+ });
540
+ if (!envResult.ok) {
541
+ console.log(chalk.red(` ✗ python venv bootstrap failed: ${envResult.reason}`));
542
+ socket.emit("runner:event", {
543
+ run_id: runId,
544
+ event_type: "agent_message",
545
+ project: `crew-${sid.slice(-12)}`,
546
+ replyId: `venv-${runId}`,
547
+ replyName: "Runner",
548
+ replyRole: "system",
549
+ msg: {
550
+ id: `venv-${runId}`,
551
+ name: "Runner",
552
+ role: "system",
553
+ content: [{ type: "text", text: `Python env bootstrap failed: ${envResult.reason}` }],
554
+ metadata: {},
555
+ timestamp: new Date().toISOString(),
556
+ },
557
+ });
558
+ socket.emit("runner:runComplete", { runId, status: "failed" });
559
+ return;
560
+ }
561
+ // Register run + set relay project so agent_message → relay →
562
+ // server → FE join-by-runId works the same as for a pipeline.
563
+ const project = `crew-${sid.slice(-12)}`;
564
+ setActiveProject(project);
565
+ socket.emit("runner:registerRun", {
566
+ runId,
567
+ project,
568
+ pipelineName: project,
569
+ });
570
+ // Write main.py + config.json to a per-run scratch dir.
571
+ const runDir = join(tmpdir(), `melaya-crew-${runId}`);
572
+ mkdirSync(runDir, { recursive: true });
573
+ writeFileSync(join(runDir, "main.py"), mainPyContent, "utf-8");
574
+ writeFileSync(join(runDir, "config.json"), JSON.stringify(cfg, null, 2), "utf-8");
575
+ // Preflight the first persona's model so we surface an early
576
+ // failure if (e.g.) LM Studio hasn't loaded the requested
577
+ // qwen3.6-27b. Same flow as pipelines — it just reads the
578
+ // first model entry from config.json.
579
+ const { preflightModel } = await import("./modelLoader.js");
580
+ const preflight = await preflightModel(JSON.stringify(cfg), (msg) => {
581
+ console.log(chalk.gray(` [model] ${msg}`));
582
+ socket.emit("runner:event", {
583
+ run_id: runId,
584
+ event_type: "pipeline_phase",
585
+ project,
586
+ step: 2,
587
+ total: 5,
588
+ label: msg,
589
+ status: "started",
590
+ });
591
+ });
592
+ if (!preflight.ok) {
593
+ const detail = `${preflight.reason ?? "unknown"}${preflight.hint ? ` — ${preflight.hint}` : ""}`;
594
+ console.log(chalk.red(` ✗ model preflight failed: ${detail}`));
595
+ socket.emit("runner:event", {
596
+ run_id: runId,
597
+ event_type: "agent_message",
598
+ project,
599
+ replyId: `preflight-${runId}`,
600
+ replyName: "Runner",
601
+ replyRole: "system",
602
+ msg: {
603
+ id: `preflight-${runId}`,
604
+ name: "Runner",
605
+ role: "system",
606
+ content: [{ type: "text", text: `Model preflight failed: ${detail}` }],
607
+ metadata: {},
608
+ timestamp: new Date().toISOString(),
609
+ },
610
+ });
611
+ socket.emit("runner:runComplete", { runId, status: "failed" });
612
+ return;
613
+ }
614
+ // Build env. Same MEL_MODEL_* scrub + cert bundle wiring as the
615
+ // pipeline path; on top of that, set the MEL_CREW_* the crew loop
616
+ // reads (strategy id, cadence, dry-run mode, owner) plus
617
+ // MEL_OWNER_USER_ID/TIER which the HITL middleware uses for
618
+ // per-tier quota checks.
619
+ const sharedDir = getSharedDir();
620
+ const inherited = { ...process.env };
621
+ for (const k of Object.keys(inherited)) {
622
+ if (k.startsWith("MEL_MODEL_"))
623
+ delete inherited[k];
624
+ }
625
+ const certBundle = (await import("./pythonEnv.js")).getCertBundlePath();
626
+ const sslEnv = certBundle
627
+ ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle }
628
+ : {};
629
+ const env = {
630
+ ...inherited,
631
+ PYTHONPATH: sharedDir,
632
+ PYTHONIOENCODING: "utf-8",
633
+ PYTHONUTF8: "1",
634
+ ...sslEnv,
635
+ MEL_RUN_ID: runId,
636
+ MEL_STUDIO_URL: opts.serverUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"),
637
+ MEL_BUILDER_URL: `http://127.0.0.1:${relay.port}`,
638
+ MEL_RELAY_NONCE: relay.nonce,
639
+ LMSTUDIO_BASE_URL: "http://127.0.0.1:1234",
640
+ OLLAMA_BASE_URL: "http://127.0.0.1:11434",
641
+ MEL_CREW_STRATEGY_ID: sid,
642
+ MEL_CREW_CADENCE_MODE: String(cfg.cadenceMode ?? "hybrid"),
643
+ MEL_CREW_CADENCE_SECONDS: String(cfg.cadenceSeconds ?? 300),
644
+ MEL_CREW_DRY_RUN: String(cfg.dryRunMode ?? "paper"),
645
+ MEL_OWNER_USER_ID: payload.owner_user_id,
646
+ MEL_OWNER_TIER: payload.owner_tier,
647
+ MELAYA_OUTPUT_DIR: (() => {
648
+ const dir = join(homedir(), "Documents", "Melaya-Pipelines", project, "outputs");
649
+ try {
650
+ mkdirSync(dir, { recursive: true });
651
+ }
652
+ catch (e) {
653
+ console.log(chalk.yellow(` ! could not create output dir ${dir}: ${e.message}`));
654
+ }
655
+ return dir;
656
+ })(),
657
+ ...(preflight.profile
658
+ ? {
659
+ MEL_MODEL_TIER: preflight.profile.tier,
660
+ MEL_MODEL_FAMILY: preflight.profile.family ?? "",
661
+ MEL_MODEL_PARAMS_B: String(preflight.profile.paramsBillion ?? ""),
662
+ MEL_MODEL_MAX_TOKENS: String(preflight.profile.recommendedMaxTokens),
663
+ MEL_MODEL_MAX_ITERS: String(preflight.profile.recommendedMaxIters),
664
+ MEL_MODEL_DISABLE_THINKING: preflight.profile.thinkingDefault === "off" ? "1" : "0",
665
+ }
666
+ : {}),
667
+ };
668
+ const proc = spawn(envResult.pythonPath, ["-u", join(runDir, "main.py")], {
669
+ cwd: runDir,
670
+ env,
671
+ stdio: ["ignore", "pipe", "pipe"],
672
+ });
673
+ activeProcesses.set(runId, proc);
674
+ const stderrTail = [];
675
+ const STDERR_TAIL_MAX = 60;
676
+ proc.stdout?.on("data", (data) => {
677
+ const line = data.toString().trim();
678
+ if (line && opts.verbose)
679
+ console.log(chalk.gray(` [crew-stdout] ${line}`));
680
+ });
681
+ proc.stderr?.on("data", (data) => {
682
+ const text = data.toString();
683
+ for (const ln of text.split("\n")) {
684
+ const t = ln.trimEnd();
685
+ if (!t)
686
+ continue;
687
+ console.log(chalk.yellow(` [crew-stderr] ${t}`));
688
+ stderrTail.push(t);
689
+ if (stderrTail.length > STDERR_TAIL_MAX)
690
+ stderrTail.shift();
691
+ }
692
+ });
693
+ proc.on("exit", (code) => {
694
+ activeProcesses.delete(runId);
695
+ const status = code === 0 ? "done" : "failed";
696
+ if (status === "failed" && stderrTail.length > 0) {
697
+ const tail = stderrTail.slice(-15).join("\n");
698
+ socket.emit("runner:event", {
699
+ run_id: runId,
700
+ event_type: "agent_message",
701
+ project,
702
+ replyId: `runner-exit-${runId}`,
703
+ replyName: "Runner",
704
+ replyRole: "system",
705
+ msg: {
706
+ id: `runner-exit-${runId}`,
707
+ name: "Runner",
708
+ role: "system",
709
+ content: [{
710
+ type: "text",
711
+ text: `Crew exited with code ${code}. Last stderr:\n\`\`\`\n${tail}\n\`\`\``,
712
+ }],
713
+ metadata: { exitCode: code },
714
+ timestamp: new Date().toISOString(),
715
+ },
716
+ });
717
+ }
718
+ socket.emit("runner:event", {
719
+ run_id: runId,
720
+ event_type: "run_status",
721
+ status,
722
+ project,
723
+ });
724
+ setTimeout(() => {
725
+ socket.emit("runner:runComplete", { runId, status });
726
+ }, 200);
727
+ console.log(chalk.gray(` ■ Crew finished (exit ${code})\n`));
728
+ });
729
+ }
730
+ catch (e) {
731
+ console.log(chalk.red(` ✗ Crew spawn failed: ${e.message}`));
732
+ socket.emit("runner:runComplete", { runId: payload.run_id, status: "failed" });
733
+ }
734
+ });
455
735
  // ── RAG Mode B: local-folder ingest (privacy-preserving) ─────────────
456
736
  // The user picks "Local folder" in the Agent Builder DocsTab and types
457
737
  // a path on their machine (e.g. C:\Users\me\Documents\refs). The server
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.54",
3
+ "version": "1.0.56",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,