@melaya/runner 1.1.60 → 1.1.62

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.
@@ -0,0 +1,29 @@
1
+ export declare const TURN_FILE_MAX_BYTES: number;
2
+ export declare const TURN_FILES_MAX = 5;
3
+ export declare const TURN_FILES_MAX_BYTES: number;
4
+ /** Mirror of the server's RUNNER_FILE_EXTS. */
5
+ export declare const TURN_FILE_EXTS: ReadonlySet<string>;
6
+ export interface TurnFileState {
7
+ dir: string;
8
+ sent: number;
9
+ bytes: number;
10
+ names: Set<string>;
11
+ }
12
+ /** The output dir of one Assistant turn (created). The turn id becomes a safe
13
+ * folder name; `base` is for tests. */
14
+ export declare function turnOutputDir(turnId: string, base?: string): string;
15
+ export declare function newTurnFileState(dir: string): TurnFileState;
16
+ export type TurnFileResult = {
17
+ ok: true;
18
+ file: {
19
+ name: string;
20
+ ext: string;
21
+ size: number;
22
+ b64: string;
23
+ };
24
+ } | {
25
+ ok: false;
26
+ reason: string;
27
+ };
28
+ /** Read one file the host reported for this turn, under the rules above. */
29
+ export declare function readTurnFile(state: TurnFileState, reportedPath: unknown): TurnFileResult;
@@ -0,0 +1,113 @@
1
+ // Files the runner-hosted Assistant's agent writes during a turn (assistant
2
+ // protocol 4): the runner, not the Python host, decides what leaves this
3
+ // computer.
4
+ //
5
+ // A turn the server marks `fileDelivery` gets its own output dir
6
+ // (<home>/Documents/Melaya-Pipelines/Melaya Assistant/outputs/<turn>), handed to
7
+ // the host as MELAYA_OUTPUT_DIR for that turn: every file-producing tool resolves
8
+ // its path there (shared/runtime/tool_meta.resolve_output_path). When the turn
9
+ // ends the host lists the files it finds there as `file` frames {name, path}.
10
+ // The runner then reads a file only when:
11
+ // - its real path is INSIDE that turn's dir (no `..`, no symlink out, no
12
+ // other turn's dir, no other folder of the computer),
13
+ // - it is a regular file with an allowed extension (documents, sheets, decks,
14
+ // PDFs, text exports, images, audio / video, archives; no HTML, SVG,
15
+ // scripts or executables),
16
+ // - it is at most 25 MB, at most 5 files and 50 MB per turn,
17
+ // and relays {name, ext, size, b64} to the server on the session's own socket.
18
+ // The server re-checks the same caps and stages the bytes for the turn's user
19
+ // (server/src/services/agents/assistantFiles.ts). The files also stay on the
20
+ // computer. A turn without `fileDelivery` (older server) keeps today's
21
+ // behaviour: no per-turn dir, nothing relayed.
22
+ import { closeSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, realpathSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { basename, isAbsolute, join, relative, sep } from "node:path";
25
+ export const TURN_FILE_MAX_BYTES = 25 * 1024 * 1024;
26
+ export const TURN_FILES_MAX = 5;
27
+ export const TURN_FILES_MAX_BYTES = 50 * 1024 * 1024;
28
+ /** Mirror of the server's RUNNER_FILE_EXTS. */
29
+ export const TURN_FILE_EXTS = new Set([
30
+ "docx", "doc", "odt", "rtf", "xlsx", "xls", "ods", "csv", "tsv", "pptx", "ppt", "odp", "pdf",
31
+ "txt", "md", "json", "ics", "png", "jpg", "jpeg", "gif", "webp", "mp3", "wav", "mp4", "zip",
32
+ ]);
33
+ /** The output dir of one Assistant turn (created). The turn id becomes a safe
34
+ * folder name; `base` is for tests. */
35
+ export function turnOutputDir(turnId, base = join(homedir(), "Documents", "Melaya-Pipelines", "Melaya Assistant", "outputs")) {
36
+ const safe = String(turnId || "").replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.{2,}/g, "_").replace(/^\.+/, "").slice(0, 80) || `turn-${Date.now()}`;
37
+ const dir = join(base, safe);
38
+ mkdirSync(dir, { recursive: true });
39
+ return dir;
40
+ }
41
+ export function newTurnFileState(dir) {
42
+ return { dir, sent: 0, bytes: 0, names: new Set() };
43
+ }
44
+ const inside = (root, p) => {
45
+ const rel = relative(root, p);
46
+ return !!rel && !rel.startsWith("..") && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
47
+ };
48
+ /** Read one file the host reported for this turn, under the rules above. */
49
+ export function readTurnFile(state, reportedPath) {
50
+ if (state.sent >= TURN_FILES_MAX)
51
+ return { ok: false, reason: "too_many_files" };
52
+ const raw = typeof reportedPath === "string" ? reportedPath : "";
53
+ if (!raw || raw.length > 4096 || raw.includes("\u0000"))
54
+ return { ok: false, reason: "bad_path" };
55
+ let root;
56
+ let real;
57
+ try {
58
+ root = realpathSync(state.dir);
59
+ const candidate = isAbsolute(raw) ? raw : join(state.dir, raw);
60
+ // A symlink is refused outright (even one pointing inside the dir).
61
+ if (lstatSync(candidate).isSymbolicLink())
62
+ return { ok: false, reason: "symlink" };
63
+ real = realpathSync(candidate);
64
+ }
65
+ catch {
66
+ return { ok: false, reason: "not_found" };
67
+ }
68
+ if (!inside(root, real))
69
+ return { ok: false, reason: "outside_turn_dir" };
70
+ const name = basename(real);
71
+ const dot = name.lastIndexOf(".");
72
+ const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
73
+ if (!ext || !TURN_FILE_EXTS.has(ext))
74
+ return { ok: false, reason: "extension_not_allowed" };
75
+ if (state.names.has(real))
76
+ return { ok: false, reason: "duplicate" };
77
+ let fd = -1;
78
+ try {
79
+ fd = openSync(real, "r");
80
+ const st = fstatSync(fd);
81
+ if (!st.isFile())
82
+ return { ok: false, reason: "not_a_file" };
83
+ if (st.size <= 0)
84
+ return { ok: false, reason: "empty" };
85
+ if (st.size > TURN_FILE_MAX_BYTES)
86
+ return { ok: false, reason: "too_large" };
87
+ if (state.bytes + st.size > TURN_FILES_MAX_BYTES)
88
+ return { ok: false, reason: "turn_too_large" };
89
+ const buf = Buffer.alloc(st.size);
90
+ let off = 0;
91
+ while (off < st.size) {
92
+ const n = readSync(fd, buf, off, st.size - off, off);
93
+ if (n <= 0)
94
+ break;
95
+ off += n;
96
+ }
97
+ state.sent += 1;
98
+ state.bytes += off;
99
+ state.names.add(real);
100
+ return { ok: true, file: { name, ext, size: off, b64: buf.subarray(0, off).toString("base64") } };
101
+ }
102
+ catch {
103
+ return { ok: false, reason: "unreadable" };
104
+ }
105
+ finally {
106
+ if (fd >= 0) {
107
+ try {
108
+ closeSync(fd);
109
+ }
110
+ catch { /* already closed */ }
111
+ }
112
+ }
113
+ }
@@ -172,10 +172,22 @@ def _config_hash() -> str:
172
172
  fields.append(pinned_tools)
173
173
  if os.environ.get("MEL_ASSISTANT_TELEMETRY_SURFACE") == "marketing":
174
174
  fields.append("marketing-scoped-tools-v1")
175
+ # Per-turn connectors (assistant protocol 3): the connectors field above is
176
+ # empty (MEL_ASSISTANT_CONNECTORS is not set at boot) and this marker comes
177
+ # LAST, byte-for-byte like runnerAssistantRelay.ts assistantConfigHash. A
178
+ # connector change is then never a config change: no reboot.
179
+ if _turn_connectors_mode():
180
+ fields.append("turn-connectors-v1")
175
181
  canon = "|".join(fields)
176
182
  return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:32]
177
183
 
178
184
 
185
+ def _turn_connectors_mode() -> bool:
186
+ """Assistant protocol 3: the chat's connectors arrive on every turn frame
187
+ (and mid-turn updates), not in the boot env. Set by the runner spawn env."""
188
+ return (os.environ.get("MEL_ASSISTANT_TURN_CONNECTORS", "") or "") == "1"
189
+
190
+
179
191
  def _browser_capable() -> bool:
180
192
  """Browser CAPABILITY presence for this host (plan 0.4): set by the runner
181
193
  spawn env when the paired runner advertises browser control. Capability
@@ -368,6 +380,192 @@ def _purge_browser_turn_grant() -> None:
368
380
  os.environ.pop(k, None)
369
381
 
370
382
 
383
+ # ── Per-turn connectors (assistant protocol 3) ───────────────────────────────
384
+ # The chat's connector selection is a PER-TURN parameter, like hitl_mode: the
385
+ # turn frame carries {"connectors": [registry categories], "connector_env":
386
+ # {service: {ENV: value}}}. Before the turn, the selection becomes the lazy
387
+ # registry's searchable connector scope (set_lazy_connectors: deselected
388
+ # services' tools leave the pool AND the toolkit) and the env is installed for
389
+ # the turn. The env is PURGED at turn end (like the browser grant): between
390
+ # turns the warm host keeps only the selection (service ids, no secret). A
391
+ # mid-turn update (runner:assistant_turn_update, or a connector turned on by
392
+ # melaya_agent_request_connector_enable) goes through the same functions, under
393
+ # a lock, so the NEXT tool call of the running turn sees it.
394
+ #
395
+ # Never written here: the host's own identity / transport env. A connector env
396
+ # key that collides with a boot env value is restored to that value on purge.
397
+ _RESERVED_ENV = frozenset({
398
+ "MELAYA_API_KEY", "MELAYA_API_BASE", "MELAYA_API_URL", "_MEL_USER_ID", "PATH",
399
+ "PYTHONPATH", "PYTHONHOME", "PYTHONIOENCODING", "PYTHONUTF8", "SSL_CERT_FILE",
400
+ "REQUESTS_CA_BUNDLE", "LAYA_LOCAL_URL", "LMSTUDIO_BASE_URL", "OLLAMA_BASE_URL",
401
+ })
402
+ # Obsidian vaults: the turn's capability, the local relay URL and the selection
403
+ # (for the prompt only), installed and purged per turn like any connector env.
404
+ _ALLOWED_MEL_ENV = frozenset({"MEL_LUMA_BROWSER_URL", "MEL_LUMA_BROWSER_TOKEN",
405
+ "MEL_VAULT_CAP", "MEL_VAULT_API_URL", "MEL_VAULT_SELECTION"})
406
+ _LIVE = {
407
+ "lock": threading.RLock(),
408
+ "toolkit": None, # the lazy toolkit (set_lazy_connectors target)
409
+ "connectors": [], # current selection (registry categories)
410
+ "envKeys": set(), # connector env keys installed for this turn
411
+ "envOrig": {}, # key -> boot value (None = absent) to restore
412
+ "agent": None, "basePrompt": "", "staticContext": None, # mid-turn prompt refresh
413
+ }
414
+
415
+
416
+ def _connector_env_key_ok(k: str) -> bool:
417
+ if not k or not isinstance(k, str) or k in _RESERVED_ENV:
418
+ return False
419
+ if k.startswith("MEL_") or k.startswith("MELAYA_"):
420
+ return k in _ALLOWED_MEL_ENV
421
+ return True
422
+
423
+
424
+ def _install_connector_env(env_by_service, replace: bool) -> None:
425
+ """Install connector env for the turn. replace=True drops keys no longer
426
+ provided (a deselected service); False only adds (one service turned on)."""
427
+ new: dict = {}
428
+ if isinstance(env_by_service, dict):
429
+ for _svc, kv in env_by_service.items():
430
+ if isinstance(kv, dict):
431
+ for k, v in kv.items():
432
+ if isinstance(v, str) and _connector_env_key_ok(str(k)):
433
+ new[str(k)] = v
434
+ with _LIVE["lock"]:
435
+ keys: set = _LIVE["envKeys"]
436
+ orig: dict = _LIVE["envOrig"]
437
+ if replace:
438
+ for k in list(keys - set(new)):
439
+ _restore_env_key(k)
440
+ keys.discard(k)
441
+ for k, v in new.items():
442
+ if k not in orig:
443
+ orig[k] = os.environ.get(k)
444
+ os.environ[k] = v
445
+ keys.add(k)
446
+
447
+
448
+ def _restore_env_key(k: str) -> None:
449
+ orig = _LIVE["envOrig"].pop(k, None)
450
+ if orig is None:
451
+ os.environ.pop(k, None)
452
+ else:
453
+ os.environ[k] = orig
454
+
455
+
456
+ def _purge_connector_env() -> None:
457
+ """Turn end: remove every connector credential from the warm host env."""
458
+ with _LIVE["lock"]:
459
+ for k in list(_LIVE["envKeys"]):
460
+ _restore_env_key(k)
461
+ _LIVE["envKeys"] = set()
462
+
463
+
464
+ def _apply_turn_connectors(services, env_by_service, add_only: bool = False) -> bool:
465
+ """Make `services` the connector scope (add_only: add to it) and install
466
+ their env. Arms the write-approval gate while any connector is selected
467
+ (lazy_registry decides at call time in this mode). Returns True on success."""
468
+ svcs = [str(x).strip().lower() for x in (services or []) if isinstance(x, str) and str(x).strip()]
469
+ with _LIVE["lock"]:
470
+ toolkit = _LIVE.get("toolkit")
471
+ try:
472
+ from shared.orchestration.lazy_registry import set_lazy_connectors
473
+ res = set_lazy_connectors(toolkit, svcs, add_only=add_only) if toolkit is not None else {"ok": False}
474
+ except Exception as exc:
475
+ _log(f"connector scope update failed: {type(exc).__name__}")
476
+ res = {"ok": False}
477
+ current = sorted(set(_LIVE["connectors"]) | set(svcs)) if add_only else sorted(set(svcs))
478
+ _LIVE["connectors"] = current
479
+ _install_connector_env(env_by_service, replace=not add_only)
480
+ if current:
481
+ os.environ["MEL_ASSISTANT_CONNECTOR_HITL"] = "1"
482
+ else:
483
+ os.environ.pop("MEL_ASSISTANT_CONNECTOR_HITL", None)
484
+ if res.get("added") or res.get("removed"):
485
+ # Service ids only, never env.
486
+ _log(f"connectors updated (+{','.join(res.get('added') or []) or '-'} -{','.join(res.get('removed') or []) or '-'})")
487
+ return bool(res.get("ok"))
488
+
489
+
490
+ def _apply_live_connector_activation(services, env_by_service) -> bool:
491
+ """Applier registered with shared.runtime.assistant_live: a connector turned
492
+ on mid-turn (melaya_agent_request_connector_enable). Adds to the scope."""
493
+ if not _turn_connectors_mode() or not _stream.get("turnId"):
494
+ return False
495
+ ok = _apply_turn_connectors(services, env_by_service, add_only=True)
496
+ _refresh_live_prompt()
497
+ return ok
498
+
499
+
500
+ def _refresh_live_prompt() -> None:
501
+ """Re-render the running turn's system prompt (autonomy + connectors) after a
502
+ mid-turn change; agentscope rebuilds the system message from it each step."""
503
+ agent = _LIVE.get("agent")
504
+ if agent is not None:
505
+ _apply_static_context(agent, _LIVE.get("basePrompt") or "", _LIVE.get("staticContext"))
506
+
507
+
508
+ def _apply_turn_update(req) -> None:
509
+ """runner:assistant_turn_update, on the stdin-reader thread WHILE the turn
510
+ runs: the user changed the autonomy mode and/or the connectors mid-reply.
511
+ Applies only to the turn in flight; the next tool call reads it. A pending
512
+ approval already polling is not affected (the gate decides at call start)."""
513
+ tid = str(req.get("turnId") or "")
514
+ if not tid or tid != _stream.get("turnId"):
515
+ return
516
+ changed = False
517
+ if "hitl_mode" in req:
518
+ _apply_hitl_mode(req.get("hitl_mode"))
519
+ _log(f"autonomy changed mid-turn -> {os.environ.get('MEL_ASSISTANT_HITL_MODE')}")
520
+ changed = True
521
+ if _turn_connectors_mode() and isinstance(req.get("connectors"), list):
522
+ _apply_turn_connectors(req.get("connectors"), req.get("connector_env") or {})
523
+ changed = True
524
+ if changed:
525
+ _refresh_live_prompt()
526
+
527
+
528
+ def _connector_rule(connector_services, pinned_tool_names) -> str:
529
+ """Prompt rules for the selected connectors (or the pinned tool set)."""
530
+ if pinned_tool_names:
531
+ return (
532
+ "- These tools are loaded and directly callable: " + ", ".join(pinned_tool_names) + ". "
533
+ "Call them directly; do NOT call search_tools/activate_tool for any of them.\n"
534
+ "- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
535
+ "call the connector's GENERIC list/query tool, read the rows, and compute the "
536
+ "aggregation yourself. Never invent data - read it from the connector.\n"
537
+ )
538
+ if not connector_services:
539
+ return ""
540
+ return (
541
+ "- ACTIVE CONNECTORS for this turn: " + ", ".join(connector_services) + ". "
542
+ "These are the ONLY external systems available RIGHT NOW. This overrides the "
543
+ "conversation history: if earlier in this chat you used a DIFFERENT connector "
544
+ "(another ERP/app the user has since DESELECTED), it is NO LONGER available - do "
545
+ "NOT search for or call its tools, and do NOT reuse tool names or API verbs from "
546
+ "that system (e.g. do not look for another ERP's model/method names here).\n"
547
+ "- Their tools are not all loaded upfront: call search_tools(query=...) with PLAIN "
548
+ "BUSINESS keywords (\"sales orders\", \"customers\", \"unpaid invoices\", \"headcount\") "
549
+ "- never another system's internal API names - then activate_tool(name=...) ONCE, then "
550
+ "call it. The results ARE the available tools: pick the closest match and USE it; do "
551
+ "NOT keep re-searching for a tool from a different system. If two searches for the same "
552
+ "need return the same kind of tool, STOP and activate it.\n"
553
+ "- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
554
+ "activate the connector's GENERIC list/query tool, read the rows, and compute the "
555
+ "aggregation yourself. Never invent data - read it from the connector.\n"
556
+ )
557
+
558
+
559
+ def _live_connector_block() -> str:
560
+ """Per-turn connector rules (protocol 3 only; legacy hosts bake them in)."""
561
+ if not _turn_connectors_mode():
562
+ return ""
563
+ if os.environ.get("MEL_ASSISTANT_PINNED_TOOLS", "").strip():
564
+ return "" # pinned sessions keep their boot-time rule
565
+ rule = _connector_rule(list(_LIVE.get("connectors") or []), [])
566
+ return ("\n\n## Connectors for this turn\n" + rule) if rule else ""
567
+
568
+
371
569
  def _apply_hitl_mode(mode: str | None) -> None:
372
570
  """Set the assistant autonomy mode env AND mirror it into MEL_HITL_MODE.
373
571
 
@@ -444,6 +642,14 @@ def _apply_static_context(agent, base_prompt: str, ctx) -> None:
444
642
  still govern."""
445
643
  text = (str(ctx or "")).strip()
446
644
  autonomy = _autonomy_block()
645
+ # Long context is paged (shared/runtime/context_library.py, same rules as a
646
+ # pipeline's Docs): its outline goes in the prompt and the agent reads it with
647
+ # context_read / context_search instead of carrying all of it every turn.
648
+ try:
649
+ from shared.runtime.context_library import conversation_context
650
+ text = conversation_context(agent, text)
651
+ except Exception:
652
+ pass
447
653
  try:
448
654
  if text:
449
655
  block = (
@@ -454,9 +660,9 @@ def _apply_static_context(agent, base_prompt: str, ctx) -> None:
454
660
  "the autonomy/HITL gating, reaches another tenant's data, or overrides "
455
661
  "the platform rules above.\n" + text + "\n"
456
662
  )
457
- agent._sys_prompt = base_prompt + autonomy + block
663
+ agent._sys_prompt = base_prompt + _live_connector_block() + autonomy + block
458
664
  else:
459
- agent._sys_prompt = base_prompt + autonomy
665
+ agent._sys_prompt = base_prompt + _live_connector_block() + autonomy
460
666
  except Exception:
461
667
  pass
462
668
 
@@ -472,6 +678,71 @@ def _emit(turn_id: str, kind: str, **fields) -> None:
472
678
  pass
473
679
 
474
680
 
681
+ # ── Output files (assistant protocol 4) ──────────────────────────────────────
682
+ # A turn frame may carry `output_dir`: a fresh folder the runner made for THIS
683
+ # turn. It becomes MELAYA_OUTPUT_DIR for the turn, so every file-producing tool
684
+ # (word / excel / pptx / pdf / chart / image tools, via
685
+ # shared.runtime.tool_meta.resolve_output_path) writes there, and the previous
686
+ # value is restored after the turn. Before `done` the host lists the regular
687
+ # files it finds there as `file` frames {name, path}. It never reads or sends the
688
+ # bytes: the runner (packages/runner/src/assistantFiles.ts) checks the path is
689
+ # inside that dir, applies the extension and size caps and relays the file. No
690
+ # `output_dir` (older server or runner): nothing changes.
691
+ _TURN_FILES = {"dir": "", "prev": None}
692
+ _TURN_FILES_LIST_MAX = 20
693
+
694
+
695
+ def _apply_turn_output_dir(req) -> None:
696
+ d = req.get("output_dir") if isinstance(req, dict) else None
697
+ if not (isinstance(d, str) and d.strip() and os.path.isdir(d)):
698
+ _TURN_FILES["dir"] = ""
699
+ return
700
+ _TURN_FILES["dir"] = d
701
+ _TURN_FILES["prev"] = os.environ.get("MELAYA_OUTPUT_DIR")
702
+ os.environ["MELAYA_OUTPUT_DIR"] = d
703
+
704
+
705
+ def _clear_turn_output_dir() -> None:
706
+ if not _TURN_FILES["dir"]:
707
+ return
708
+ prev = _TURN_FILES["prev"]
709
+ if prev is None:
710
+ os.environ.pop("MELAYA_OUTPUT_DIR", None)
711
+ else:
712
+ os.environ["MELAYA_OUTPUT_DIR"] = prev
713
+ _TURN_FILES["dir"] = ""
714
+ _TURN_FILES["prev"] = None
715
+
716
+
717
+ def _emit_turn_files(turn_id: str) -> int:
718
+ """`file` frames for the files in this turn's output dir (oldest first)."""
719
+ root = _TURN_FILES["dir"]
720
+ if not root:
721
+ return 0
722
+ found: list = []
723
+ try:
724
+ for dirpath, dirs, files in os.walk(root, followlinks=False):
725
+ dirs[:] = [x for x in dirs if not x.startswith(".")]
726
+ for name in files:
727
+ p = os.path.join(dirpath, name)
728
+ if name.startswith(".") or os.path.islink(p) or not os.path.isfile(p):
729
+ continue
730
+ try:
731
+ found.append((os.path.getmtime(p), p, name))
732
+ except OSError:
733
+ continue
734
+ if len(found) > 200:
735
+ break
736
+ except Exception:
737
+ return 0
738
+ found.sort()
739
+ n = 0
740
+ for _mt, p, name in found[:_TURN_FILES_LIST_MAX]:
741
+ _emit(turn_id, "file", name=name, path=p)
742
+ n += 1
743
+ return n
744
+
745
+
475
746
  def _split_stream_blocks(content):
476
747
  """Split a streamed message's content into (answer_text, thinking_text,
477
748
  [(tool_id, tool_name)]).
@@ -709,13 +980,26 @@ def _build_agent():
709
980
  # sized to the ACTUAL pinned count instead of a fixed guess.
710
981
  if pinned_tool_names:
711
982
  _budget = max(_budget, len(pinned_tool_names) + len(categories))
712
- toolkit = build_lazy_toolkit(
983
+ _lazy_kwargs = dict(
713
984
  active_categories=categories,
714
985
  include_categories=categories + core_categories + connector_services,
715
986
  budget=_budget,
716
987
  pinned_names=pinned_tool_names or None,
717
988
  allowed_names=pinned_tool_names if marketing_tool_scope else None,
718
989
  )
990
+ if _turn_connectors_mode() and not pinned_tool_names:
991
+ # Per-turn connector scope: every connector's tools kept aside, made
992
+ # searchable per turn by set_lazy_connectors (no rebuild, no reboot).
993
+ try:
994
+ toolkit = build_lazy_toolkit(live_connectors=True, **_lazy_kwargs)
995
+ _LIVE["toolkit"] = toolkit
996
+ except TypeError:
997
+ # Older shared bundle without per-turn scope: boot works, the
998
+ # selection just cannot change the searchable pool mid-session.
999
+ _log("shared bundle predates per-turn connectors; connector scope is static")
1000
+ toolkit = build_lazy_toolkit(**_lazy_kwargs)
1001
+ else:
1002
+ toolkit = build_lazy_toolkit(**_lazy_kwargs)
719
1003
  except Exception as exc:
720
1004
  _log(f"toolkit build failed (connectors={connector_services}, core): {exc}; retrying melaya_agent only")
721
1005
  try:
@@ -786,8 +1070,9 @@ def _build_agent():
786
1070
  "call browser_get_screen_tree before you click or type, and re-read it "
787
1071
  "after anything that changes the page (stale element refs fail closed).\n"
788
1072
  "- GO FAST: once you have read the page and can name the next steps from it, "
789
- "send them in ONE browser_fast call (click/type/press/scroll/expect by the labels "
790
- "you read, collect to gather items across scrolls, for_each to walk list rows) "
1073
+ "send them in ONE browser_fast call (click/type/select/press/scroll/expect by the "
1074
+ "labels you read, collect to gather items across scrolls, for_each to walk list "
1075
+ "rows, visit to open many links and read or act on each, e.g. 50 posts) "
791
1076
  "instead of one tool call per click. It re-finds every target on the live page, "
792
1077
  "stops at the first surprise and returns the page, with the same autonomy and "
793
1078
  "approvals as single actions. Use the single browser_* tools to explore an unread "
@@ -836,37 +1121,11 @@ def _build_agent():
836
1121
  "or route around it; tell the user if the task needs another site.\n"
837
1122
  if browser_enabled else ""
838
1123
  )
839
- connector_rule = (
840
- # Surgical tool pinning: these exact tool names are already ACTIVE (not
841
- # deferred) — telling the model to search_tools/activate_tool them first
842
- # would be a wasted round-trip against a tool that's already callable.
843
- (
844
- "- These tools are loaded and directly callable: " + ", ".join(pinned_tool_names) + ". "
845
- "Call them directly; do NOT call search_tools/activate_tool for any of them.\n"
846
- "- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
847
- "call the connector's GENERIC list/query tool, read the rows, and compute the "
848
- "aggregation yourself. Never invent data - read it from the connector.\n"
849
- )
850
- if pinned_tool_names else
851
- (
852
- "- ACTIVE CONNECTORS for this turn: " + ", ".join(connector_services) + ". "
853
- "These are the ONLY external systems available RIGHT NOW. This overrides the "
854
- "conversation history: if earlier in this chat you used a DIFFERENT connector "
855
- "(another ERP/app the user has since DESELECTED), it is NO LONGER available - do "
856
- "NOT search for or call its tools, and do NOT reuse tool names or API verbs from "
857
- "that system (e.g. do not look for another ERP's model/method names here).\n"
858
- "- Their tools are not all loaded upfront: call search_tools(query=...) with PLAIN "
859
- "BUSINESS keywords (\"sales orders\", \"customers\", \"unpaid invoices\", \"headcount\") "
860
- "- never another system's internal API names - then activate_tool(name=...) ONCE, then "
861
- "call it. The results ARE the available tools: pick the closest match and USE it; do "
862
- "NOT keep re-searching for a tool from a different system. If two searches for the same "
863
- "need return the same kind of tool, STOP and activate it.\n"
864
- "- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
865
- "activate the connector's GENERIC list/query tool, read the rows, and compute the "
866
- "aggregation yourself. Never invent data - read it from the connector.\n"
867
- if connector_services else ""
868
- )
869
- )
1124
+ # Surgical tool pinning: those exact tool names are already ACTIVE (not
1125
+ # deferred), so the rule says to call them directly. Per-turn connectors
1126
+ # (protocol 3): connector_services is empty here and the rule is rendered per
1127
+ # turn instead (_live_connector_block, from the turn's selection).
1128
+ connector_rule = _connector_rule(connector_services, pinned_tool_names)
870
1129
  # Core primitives are ALWAYS in the lazy pool now, so the model must be told
871
1130
  # they exist (they are not pinned/loaded upfront) — otherwise it concludes "no
872
1131
  # web-search tool is available" and refuses, exactly the reported failure.
@@ -1011,7 +1270,7 @@ def _build_agent():
1011
1270
  # wall-clock budget is enforced separately in _run_turn.
1012
1271
  max_iters=(int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_PHONE", "300") or "300") if phone_enabled
1013
1272
  else (int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_BROWSER", "300") or "300") if browser_enabled
1014
- else (40 if connector_services else 8))),
1273
+ else (40 if (connector_services or _turn_connectors_mode()) else 8))),
1015
1274
  # EVERY assistant turn is long-horizon, not only device-driving ones.
1016
1275
  #
1017
1276
  # The codex cap is a latency default written for pipeline phases. An
@@ -1289,6 +1548,7 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1289
1548
  # that named neither what broke nor where. Reported 2026-09-03: two turns
1290
1549
  # in a row with no cause anywhere on screen.
1291
1550
  _emit(turn_id, "error", message=_turn_error_message(exc))
1551
+ _emit_turn_files(turn_id) # v4: before done, so the stream route stages them
1292
1552
  _emit(turn_id, "done")
1293
1553
  return
1294
1554
 
@@ -1296,6 +1556,7 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1296
1556
  _stream["turnId"] = ""
1297
1557
  _log("turn cancelled by user STOP")
1298
1558
  _emit(turn_id, "text", content="Stopped.")
1559
+ _emit_turn_files(turn_id) # v4: before done, so the stream route stages them
1299
1560
  _emit(turn_id, "done")
1300
1561
  return
1301
1562
 
@@ -1306,6 +1567,7 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1306
1567
  "This browser task ran past its time budget and was stopped. "
1307
1568
  "Ask again with a narrower step, or split it into two."
1308
1569
  ))
1570
+ _emit_turn_files(turn_id) # v4: before done, so the stream route stages them
1309
1571
  _emit(turn_id, "done")
1310
1572
  return
1311
1573
 
@@ -1319,6 +1581,7 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1319
1581
  # once a browser-driving turn finishes cleanly.
1320
1582
  if _stream.get("usedBrowser"):
1321
1583
  _emit(turn_id, "browser_done", ok=True)
1584
+ _emit_turn_files(turn_id) # v4: before done, so the stream route stages them
1322
1585
  _emit(turn_id, "done")
1323
1586
 
1324
1587
 
@@ -1344,6 +1607,14 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
1344
1607
  if not tid or tid == _stream.get("turnId"):
1345
1608
  _stream["cancel"] = True
1346
1609
  continue # consumed — do not queue as a turn
1610
+ # Live mid-turn update (autonomy mode / connectors): applied HERE,
1611
+ # while the turn runs, like STOP. Never queued as a turn.
1612
+ if isinstance(req, dict) and req.get("kind") == "turn_update":
1613
+ try:
1614
+ _apply_turn_update(req)
1615
+ except Exception as exc:
1616
+ _log(f"turn_update failed: {type(exc).__name__}")
1617
+ continue
1347
1618
  q.put(line)
1348
1619
  except Exception:
1349
1620
  pass
@@ -1382,6 +1653,17 @@ def main() -> int:
1382
1653
  # Capture the freshly-built BASE system prompt (before any static context is
1383
1654
  # folded in) so each turn can deterministically rebuild base + persona.
1384
1655
  base_sys_prompt = getattr(agent, "_sys_prompt", "") or ""
1656
+ _LIVE["agent"] = agent
1657
+ _LIVE["basePrompt"] = base_sys_prompt
1658
+ # Mid-turn connector activation (melaya_agent_request_connector_enable on a
1659
+ # surface that turns connectors on by itself): the tool hands the server's
1660
+ # categories + env to this host through shared.runtime.assistant_live.
1661
+ if _turn_connectors_mode():
1662
+ try:
1663
+ from shared.runtime.assistant_live import register_connector_applier
1664
+ register_connector_applier(_apply_live_connector_activation)
1665
+ except Exception:
1666
+ _log("assistant_live unavailable (older shared bundle): no mid-turn connector activation")
1385
1667
 
1386
1668
  # PR4: report memory watermark + host identity + generation so the server can
1387
1669
  # decide whether to offer a generation-bound rehydrate snapshot.
@@ -1498,11 +1780,18 @@ def main() -> int:
1498
1780
  # AgentScope spans are created; _emit_usage force-flushes them before the
1499
1781
  # next turn can replace the value.
1500
1782
  _apply_telemetry_surface(req.get("telemetry_surface"))
1783
+ # Per-turn connectors (protocol 3): the frame carries the chat's selection
1784
+ # + its env; apply both BEFORE the prompt is rendered (the connector rule
1785
+ # is part of it). Absent (older server) => keep the boot-time scope.
1786
+ if _turn_connectors_mode() and isinstance(req.get("connectors"), list):
1787
+ _apply_turn_connectors(req.get("connectors"), req.get("connector_env") or {})
1788
+ _LIVE["staticContext"] = req.get("static_context")
1501
1789
  # Per-turn STATIC CONTEXT: fold the conversation's persona / standing
1502
1790
  # instructions into the system prompt before running the turn (parity with
1503
1791
  # the cloud path; handles set / edit / clear mid-conversation).
1504
1792
  _apply_static_context(agent, base_sys_prompt, req.get("static_context"))
1505
1793
  if not message and not images:
1794
+ _purge_connector_env()
1506
1795
  _emit(turn_id, "done")
1507
1796
  continue
1508
1797
  # P2-6: nudge the live memory budget down if a prior turn's OOM downgraded
@@ -1513,10 +1802,17 @@ def main() -> int:
1513
1802
  # ALWAYS purge it — success, error, or cancel — so no reusable grant
1514
1803
  # ever survives in the warm host environment.
1515
1804
  browser_turn = _apply_browser_turn_grant(req)
1805
+ # v4: this turn's output dir (absent: unchanged behaviour).
1806
+ _apply_turn_output_dir(req)
1516
1807
  try:
1517
1808
  _run_turn(agent, turn_id, message, browser_turn=browser_turn, images=images)
1518
1809
  finally:
1810
+ _clear_turn_output_dir()
1519
1811
  _purge_browser_turn_grant()
1812
+ # Connector credentials live for the turn only; the selection (ids,
1813
+ # no secret) stays and the next frame re-sends the env.
1814
+ if _turn_connectors_mode():
1815
+ _purge_connector_env()
1520
1816
  _emit_usage(agent, turn_id)
1521
1817
 
1522
1818
 
package/dist/cli.js CHANGED
@@ -189,6 +189,13 @@ async function bootstrap() {
189
189
  await copilotLogin();
190
190
  return;
191
191
  }
192
+ // `melaya-runner obsidian-rest` — connect a vault's Obsidian Local REST API
193
+ // by hand; the key is typed here and never leaves this computer.
194
+ if (process.argv[2] === "obsidian-rest") {
195
+ const { obsidianRestCli } = await import("./obsidianAppCli.js");
196
+ await obsidianRestCli();
197
+ return;
198
+ }
192
199
  await main();
193
200
  }
194
201
  bootstrap().catch(async (e) => {