@melaya/runner 1.0.94 → 1.0.96

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.
@@ -95,6 +95,30 @@ if not isinstance(sys.stdout, _RedactingStdout):
95
95
  sys.stdout = _RedactingStdout(sys.stdout)
96
96
 
97
97
 
98
+ _HITL_MODES = ("safe", "autonomous", "payments_only")
99
+
100
+
101
+ def _apply_hitl_mode(mode: str | None) -> None:
102
+ """Set the assistant autonomy mode env AND mirror it into MEL_HITL_MODE.
103
+
104
+ The runner spawns this host with MEL_ASSISTANT_HITL_MODE (the assistant's
105
+ autonomy mode). The connector-write gate (lazy_registry._maybe_gate_write)
106
+ reads MEL_ASSISTANT_HITL_MODE, but phone.py._cmd — running INSIDE this same
107
+ host process — stamps the device command body from MEL_HITL_MODE. So we
108
+ MIRROR the assistant mode into MEL_HITL_MODE here, giving the phone tools
109
+ the operator's choice too. Called once at boot and again per turn (the TS
110
+ side passes an updated `hitl_mode` on each turn payload; absent ⇒ keep the
111
+ current/spawn value). Unknown / empty ⇒ "safe" (fail-safe)."""
112
+ if mode is None:
113
+ # Boot-time mirror: honour whatever the runner exported at spawn.
114
+ mode = os.environ.get("MEL_ASSISTANT_HITL_MODE", "safe")
115
+ normalized = (str(mode or "safe")).strip().lower()
116
+ if normalized not in _HITL_MODES:
117
+ normalized = "safe"
118
+ os.environ["MEL_ASSISTANT_HITL_MODE"] = normalized
119
+ os.environ["MEL_HITL_MODE"] = normalized
120
+
121
+
98
122
  def _emit(turn_id: str, kind: str, **fields) -> None:
99
123
  """Write one structured event line to stdout (flushed) for the runner to relay."""
100
124
  try:
@@ -213,7 +237,10 @@ def _build_agent():
213
237
  "phone_ask_user(question): it expands the Melaya tile ON THE PHONE into a reply "
214
238
  "card and BLOCKS for the user's typed answer WITHOUT ending your turn, so the run "
215
239
  "continues seamlessly the moment they reply. Use it SPARINGLY, for real decisions "
216
- "only — not routine ones. The EXACT-APP rule still holds (a missing app is a stop, "
240
+ "only — not routine ones. Do NOT call phone_ask_user to cope with repeated tool "
241
+ "ERRORS or because you feel stuck/struggling — that is a failure to REPORT, not a "
242
+ "decision for the user; stop and report what failed instead. Only ask for a genuine "
243
+ "who/which fork. The EXACT-APP rule still holds (a missing app is a stop, "
217
244
  "not a substitution). If a feed is algorithmic, use the app's own Search / Explore "
218
245
  "to find the right content yourself. Keep going until the task is COMPLETE (e.g. "
219
246
  "all N comments posted) or you are genuinely blocked; only then report what you "
@@ -450,6 +477,9 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
450
477
 
451
478
  def main() -> int:
452
479
  _log(f"booting (provider={os.environ.get('MEL_ASSISTANT_PROVIDER')})")
480
+ # Mirror the spawn-time assistant autonomy mode into MEL_HITL_MODE so the
481
+ # in-process phone tools (phone.py._cmd) see it from turn one.
482
+ _apply_hitl_mode(None)
453
483
  try:
454
484
  agent = _build_agent()
455
485
  except Exception as exc:
@@ -482,6 +512,13 @@ def main() -> int:
482
512
  continue
483
513
  turn_id = str(req.get("turnId") or "")
484
514
  message = str(req.get("message") or "")
515
+ # Per-turn autonomy mode: the TS side carries an updated `hitl_mode`
516
+ # on runner:assistant_turn so a mid-session flip takes effect next
517
+ # turn. Absent ⇒ keep the current (spawn / previous-turn) mode. The
518
+ # connector gate + phone tools read the env at call-time, so setting
519
+ # it here (before _run_turn) is enough — no agent rebuild needed.
520
+ if "hitl_mode" in req:
521
+ _apply_hitl_mode(req.get("hitl_mode"))
485
522
  if not message:
486
523
  _emit(turn_id, "done")
487
524
  continue
@@ -77,11 +77,42 @@ export async function connect(opts) {
77
77
  const localVersion = getLocalSharedVersion();
78
78
  if (!localVersion)
79
79
  return; // nothing cached yet — let assistant_start do it
80
- const { ensurePythonEnv } = await import("./pythonEnv.js");
81
- await ensurePythonEnv(opts.pythonPath, localVersion, (m) => { if (opts.verbose)
80
+ const { ensurePythonEnv, getCertBundlePath } = await import("./pythonEnv.js");
81
+ const envResult = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => { if (opts.verbose)
82
82
  console.log(chalk.gray(` [assistant prewarm] ${m}`)); });
83
83
  if (opts.verbose)
84
84
  console.log(chalk.gray(" [assistant prewarm] ready"));
85
+ // ALSO pre-import the heavy Python stack so the first real run/chat
86
+ // doesn't pay the ~6s cold `import shared.runtime.registry`. Spawn it
87
+ // detached + fire-and-forget with the SAME PYTHONPATH/env a real
88
+ // pipeline spawn uses (see the runner:run handler below) so `shared.*`
89
+ // resolves; this heats the OS file cache + bytecode. Never blocks
90
+ // connect, never throws — swallow everything. Only when a shared
91
+ // bundle exists locally (mirror the ensurePythonEnv guard above).
92
+ if (envResult.ok) {
93
+ try {
94
+ const sharedDir = getSharedDir();
95
+ if (existsSync(sharedDir)) {
96
+ const certBundle = getCertBundlePath();
97
+ const warmEnv = {
98
+ ...process.env,
99
+ PYTHONPATH: sharedDir,
100
+ PYTHONIOENCODING: "utf-8",
101
+ PYTHONUTF8: "1",
102
+ ...(certBundle ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {}),
103
+ };
104
+ const warmChild = spawn(envResult.pythonPath, ["-c", "import shared.runtime.registry"], { env: warmEnv, stdio: "ignore" });
105
+ warmChild.on("error", () => { });
106
+ warmChild.on("exit", (code) => {
107
+ if (code === 0 && opts.verbose) {
108
+ console.log(chalk.gray(" [assistant prewarm] import-warmed"));
109
+ }
110
+ });
111
+ warmChild.unref();
112
+ }
113
+ }
114
+ catch { /* fire-and-forget — never throw */ }
115
+ }
85
116
  }
86
117
  catch (e) {
87
118
  if (opts.verbose)
@@ -907,6 +938,21 @@ export async function connect(opts) {
907
938
  MEL_ASSISTANT_CONNECTORS: Array.isArray(payload.connectors) ? payload.connectors.join(",") : "",
908
939
  // Gate write connector-tools behind the in-chat approval card (fail-safe).
909
940
  MEL_ASSISTANT_CONNECTOR_HITL: Array.isArray(payload.connectors) && payload.connectors.length ? "1" : "",
941
+ // HITL autonomy mode ("safe" | "autonomous" | "payments_only"). The host
942
+ // reads this at start; each turn's frame can flip it (see assistant_turn).
943
+ // Fail-safe: unknown ⇒ "safe".
944
+ MEL_ASSISTANT_HITL_MODE: (payload.hitlMode === "autonomous" || payload.hitlMode === "payments_only") ? payload.hitlMode : "safe",
945
+ // Run context for phone-driving assistant turns. The assistant host has
946
+ // no pipeline MEL_RUN_ID, so without this phone.py._cmd omits `run_id`,
947
+ // the server's arm block (phone.ts, `if (runId)`) never fires, and the
948
+ // device never receives `hitlMode` — Autonomous still shows publish
949
+ // cards on the runner-assistant surface. Use the SAME stable
950
+ // `assistant:{userId}` runId the cloud assistant path uses (sid is
951
+ // `userId:conversationId`) so the arm / kill / active-run plumbing stays
952
+ // consistent across surfaces. assistantHost mirrors MEL_ASSISTANT_HITL_MODE
953
+ // → MEL_HITL_MODE so phone.py stamps the mode on the command body.
954
+ MEL_RUN_ID: `assistant:${sid.split(":")[0]}`,
955
+ MEL_PIPELINE_NAME: "Melaya · Assistant",
910
956
  // Luma browser bridge (same injection as pipeline runs, line ~431): lets
911
957
  // shared/tools/luma.py route /event/register through Playwright and bypass
912
958
  // Cloudflare's write rule. Reads keep using aiohttp. Empty when no signed-in
@@ -970,8 +1016,12 @@ export async function connect(opts) {
970
1016
  return;
971
1017
  }
972
1018
  s.lastActivity = Date.now();
1019
+ // Per-turn hitl_mode: the host applies it to os.environ["MEL_ASSISTANT_HITL_MODE"]
1020
+ // (mirrored to MEL_HITL_MODE) BEFORE running the turn, so a mid-session flip
1021
+ // takes effect on the very next message. Fail-safe: unknown ⇒ "safe".
1022
+ const hitlMode = (payload.hitlMode === "autonomous" || payload.hitlMode === "payments_only") ? payload.hitlMode : "safe";
973
1023
  try {
974
- s.proc.stdin?.write(JSON.stringify({ turnId: payload.turnId, message: payload.message }) + "\n");
1024
+ s.proc.stdin?.write(JSON.stringify({ turnId: payload.turnId, message: payload.message, hitl_mode: hitlMode }) + "\n");
975
1025
  }
976
1026
  catch (e) {
977
1027
  socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: `turn write failed: ${e?.message || e}` });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.94",
3
+ "version": "1.0.96",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,