@melaya/runner 1.1.61 → 1.1.63

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
+ }
@@ -399,7 +399,10 @@ _RESERVED_ENV = frozenset({
399
399
  "PYTHONPATH", "PYTHONHOME", "PYTHONIOENCODING", "PYTHONUTF8", "SSL_CERT_FILE",
400
400
  "REQUESTS_CA_BUNDLE", "LAYA_LOCAL_URL", "LMSTUDIO_BASE_URL", "OLLAMA_BASE_URL",
401
401
  })
402
- _ALLOWED_MEL_ENV = frozenset({"MEL_LUMA_BROWSER_URL", "MEL_LUMA_BROWSER_TOKEN"})
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"})
403
406
  _LIVE = {
404
407
  "lock": threading.RLock(),
405
408
  "toolkit": None, # the lazy toolkit (set_lazy_connectors target)
@@ -675,6 +678,71 @@ def _emit(turn_id: str, kind: str, **fields) -> None:
675
678
  pass
676
679
 
677
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
+
678
746
  def _split_stream_blocks(content):
679
747
  """Split a streamed message's content into (answer_text, thinking_text,
680
748
  [(tool_id, tool_name)]).
@@ -1480,6 +1548,7 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1480
1548
  # that named neither what broke nor where. Reported 2026-09-03: two turns
1481
1549
  # in a row with no cause anywhere on screen.
1482
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
1483
1552
  _emit(turn_id, "done")
1484
1553
  return
1485
1554
 
@@ -1487,6 +1556,7 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1487
1556
  _stream["turnId"] = ""
1488
1557
  _log("turn cancelled by user STOP")
1489
1558
  _emit(turn_id, "text", content="Stopped.")
1559
+ _emit_turn_files(turn_id) # v4: before done, so the stream route stages them
1490
1560
  _emit(turn_id, "done")
1491
1561
  return
1492
1562
 
@@ -1497,6 +1567,7 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1497
1567
  "This browser task ran past its time budget and was stopped. "
1498
1568
  "Ask again with a narrower step, or split it into two."
1499
1569
  ))
1570
+ _emit_turn_files(turn_id) # v4: before done, so the stream route stages them
1500
1571
  _emit(turn_id, "done")
1501
1572
  return
1502
1573
 
@@ -1510,6 +1581,7 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1510
1581
  # once a browser-driving turn finishes cleanly.
1511
1582
  if _stream.get("usedBrowser"):
1512
1583
  _emit(turn_id, "browser_done", ok=True)
1584
+ _emit_turn_files(turn_id) # v4: before done, so the stream route stages them
1513
1585
  _emit(turn_id, "done")
1514
1586
 
1515
1587
 
@@ -1730,9 +1802,12 @@ def main() -> int:
1730
1802
  # ALWAYS purge it — success, error, or cancel — so no reusable grant
1731
1803
  # ever survives in the warm host environment.
1732
1804
  browser_turn = _apply_browser_turn_grant(req)
1805
+ # v4: this turn's output dir (absent: unchanged behaviour).
1806
+ _apply_turn_output_dir(req)
1733
1807
  try:
1734
1808
  _run_turn(agent, turn_id, message, browser_turn=browser_turn, images=images)
1735
1809
  finally:
1810
+ _clear_turn_output_dir()
1736
1811
  _purge_browser_turn_grant()
1737
1812
  # Connector credentials live for the turn only; the selection (ids,
1738
1813
  # no secret) stays and the next frame re-sends the env.
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) => {
@@ -17,7 +17,7 @@ import { spawn } from "child_process";
17
17
  import { writeFileSync, mkdirSync, existsSync, readFileSync, copyFileSync } from "fs";
18
18
  import { dirname, join } from "path";
19
19
  import { fileURLToPath } from "url";
20
- import { tmpdir, homedir } from "os";
20
+ import { tmpdir, homedir, hostname } from "os";
21
21
  import { createHash, createHmac, timingSafeEqual } from "crypto";
22
22
  import { startLocalRelay, setActiveProject } from "./localRelay.js";
23
23
  // ESM equivalent of __dirname — required for the rag:ingest handler to
@@ -34,6 +34,15 @@ import { verifyBrowserGrant, createInMemoryReplayStore, loadPinnedPublicKeysFrom
34
34
  import { discoverEngines } from "./browserProvisioner.js";
35
35
  import { getRunnerDeviceId } from "./sessionManager.js";
36
36
  import { scrubTriggeredEnv } from "./triggerEnv.js";
37
+ import { pickNativeFolder } from "./folderPicker.js";
38
+ import { newTurnFileState, readTurnFile, turnOutputDir } from "./assistantFiles.js";
39
+ // Obsidian vault host (docs/plans/obsidian-vault-protocol.md): device vaults
40
+ // registered on this computer, served after the runner verifies each
41
+ // capability itself.
42
+ import { VAULT_HOST_CAPABILITY, VaultHostProcess, VaultRegistry, VaultService, attachVaultSocketHandlers, findVaultHostScript, setVaultRelayPort, vaultToolEnv, trustRunVaultCapability, } from "./vaults.js";
43
+ // Obsidian app bridge: App ops through the user's running Obsidian (CLI /
44
+ // Local REST API); the REST key stays in ~/.melaya-runner/vaults.
45
+ import { ObsidianAppBridge } from "./obsidianApp.js";
37
46
  // Same ceiling the builder enforces on RunRequest.trigger (48 KB UTF-8 of the
38
47
  // compact JSON) and the server dispatch route re-checks (Buffer.byteLength).
39
48
  const MAX_TRIGGER_JSON_BYTES = 48 * 1024;
@@ -61,7 +70,12 @@ const RESERVED_BROWSER_ENV = ["MEL_BROWSER_URL", "MEL_BROWSER_TOKEN"];
61
70
  // the autonomy mode never reboots the host, and a warm spare stays adoptable
62
71
  // whatever the selection. A v2 server keeps sending connectors at boot; this
63
72
  // runner then behaves exactly as before (turnConnectors absent).
64
- const ASSISTANT_PROTOCOL_VERSION = 3;
73
+ // v4: output files. A turn frame with `fileDelivery` gets a per-turn output dir
74
+ // (MELAYA_OUTPUT_DIR for that turn); the files the agent wrote there are relayed
75
+ // to the server as `file` frames with their bytes, under the caps and path
76
+ // confinement of assistantFiles.ts. A server that does not send `fileDelivery`
77
+ // gets exactly the v3 behaviour (files stay on this computer only).
78
+ const ASSISTANT_PROTOCOL_VERSION = 4;
65
79
  // Best-effort read of our own package version (mirrors cli.ts). Advertised in the
66
80
  // hello so the server can log / gate on a package floor too.
67
81
  let RUNNER_VERSION = "0.0.0";
@@ -150,6 +164,63 @@ export async function connect(opts) {
150
164
  // is refused (plan Section 4 binding rule).
151
165
  let socketGeneration = 0;
152
166
  const runnerDeviceId = getRunnerDeviceId();
167
+ // ── Obsidian vault host ───────────────────────────────────────────────
168
+ // Registrations persist in ~/.melaya-runner/vaults/registry.json; the
169
+ // Python host (localVault.py, engine from the shared bundle) starts lazily
170
+ // on the first register/op, or now when vaults are already registered.
171
+ const vaultRegistry = new VaultRegistry();
172
+ vaultRegistry.load();
173
+ const vaultLog = (m) => { if (opts.verbose)
174
+ console.log(chalk.gray(` [vaults] ${m}`)); };
175
+ const vaultHost = new VaultHostProcess({
176
+ wanted: () => vaultRegistry.list().length > 0,
177
+ log: vaultLog,
178
+ launch: async () => {
179
+ const { ensurePythonEnv, getCertBundlePath } = await import("./pythonEnv.js");
180
+ const { getLocalSharedVersion } = await import("./sharedVendor.js");
181
+ let localVersion = getLocalSharedVersion();
182
+ if (!localVersion) {
183
+ await ensureSharedModules(opts.serverUrl, "latest", opts.token);
184
+ localVersion = getLocalSharedVersion();
185
+ }
186
+ if (!localVersion)
187
+ throw new Error("runner python runtime still setting up");
188
+ const env = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => vaultLog(`venv: ${m}`));
189
+ if (!env.ok)
190
+ throw new Error(`venv bootstrap failed: ${env.reason}`);
191
+ const script = findVaultHostScript(__dirname);
192
+ if (!script)
193
+ throw new Error("localVault.py not found — update @melaya/runner");
194
+ mkdirSync(vaultRegistry.dir, { recursive: true, mode: 0o700 });
195
+ const certBundle = getCertBundlePath();
196
+ return {
197
+ pythonPath: env.pythonPath,
198
+ scriptPath: script,
199
+ cwd: vaultRegistry.dir,
200
+ env: {
201
+ ...process.env,
202
+ PYTHONPATH: getSharedDir(),
203
+ PYTHONIOENCODING: "utf-8",
204
+ PYTHONUTF8: "1",
205
+ ...(certBundle ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {}),
206
+ },
207
+ };
208
+ },
209
+ });
210
+ const vaultService = new VaultService({
211
+ rnrId: runnerDeviceId,
212
+ registry: vaultRegistry,
213
+ host: vaultHost,
214
+ serverHttpBase: opts.serverUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"),
215
+ emitStatus: (ev) => { if (socket.connected)
216
+ socket.emit("runner:vault-status", ev); },
217
+ log: vaultLog,
218
+ app: new ObsidianAppBridge({ dir: vaultRegistry.dir, log: vaultLog }),
219
+ });
220
+ attachVaultSocketHandlers(socket, vaultService, () => pickNativeFolder("Select your Obsidian vault folder"));
221
+ if (vaultRegistry.list().length) {
222
+ void vaultService.restore().catch((e) => vaultLog(`restore failed: ${e?.message || e}`));
223
+ }
153
224
  const _ensureBrowserBridge = async () => {
154
225
  if (browserBridge)
155
226
  return browserBridge;
@@ -346,7 +417,9 @@ export async function connect(opts) {
346
417
  // event_triggers_v1: this runner writes payload.triggerJson to
347
418
  // <runDir>/trigger.json + MEL_TRIGGER_FILE. The server reports
348
419
  // triggerAccepted from it; older runners silently drop the block.
349
- capabilities: ["assistant_session", `browser_control_v${BROWSER_CONTROL_VERSION}`, EVENT_TRIGGERS_CAPABILITY],
420
+ // vault_host_v1: this runner hosts Obsidian vaults on this computer
421
+ // (runner:vault-* events); the server then pushes runner:vaultKeys.
422
+ capabilities: ["assistant_session", `browser_control_v${BROWSER_CONTROL_VERSION}`, EVENT_TRIGGERS_CAPABILITY, VAULT_HOST_CAPABILITY],
350
423
  package: "@melaya/runner",
351
424
  version: RUNNER_VERSION,
352
425
  assistantProtocol: ASSISTANT_PROTOCOL_VERSION,
@@ -358,10 +431,22 @@ export async function connect(opts) {
358
431
  cdp: true,
359
432
  engines: browserEngines,
360
433
  },
434
+ // Device vaults bind to this stable install id (protocol §3).
435
+ vaultHost: {
436
+ runnerDevice: runnerDeviceId,
437
+ deviceName: hostname(),
438
+ platform: process.platform,
439
+ },
361
440
  });
441
+ // Index statuses produced while disconnected (or before this socket).
442
+ for (const ev of vaultService.latestStatuses())
443
+ socket.emit("runner:vault-status", ev);
362
444
  // Start local event relay
363
445
  if (!relay) {
364
- relay = await startLocalRelay(socket, opts.verbose, opts.serverUrl);
446
+ relay = await startLocalRelay(socket, opts.verbose, opts.serverUrl, {
447
+ vaultOp: (req, res) => vaultService.handleRelayHttp(req, res),
448
+ });
449
+ setVaultRelayPort(relay.port);
365
450
  if (opts.verbose) {
366
451
  console.log(chalk.gray(` [relay] Listening on 127.0.0.1:${relay.port} (nonce: ${relay.nonce})`));
367
452
  }
@@ -998,6 +1083,19 @@ export async function connect(opts) {
998
1083
  ...(isTriggeredRun
999
1084
  ? { MEL_HITL_APPROVAL_TTL_S: String(triggerApprovalTtl) }
1000
1085
  : {}),
1086
+ // Obsidian vaults, reserved LAST: the capability the server issued for
1087
+ // this run on this computer, served by the local relay (a vault on this
1088
+ // computer is read in place, any other through Melaya). A credential
1089
+ // can never replace these names.
1090
+ MEL_VAULT_CAP: "",
1091
+ MEL_VAULT_API_URL: "",
1092
+ ...(() => {
1093
+ const cap = typeof payload.vaultCapability === "string"
1094
+ ? String(payload.vaultCapability)
1095
+ : "";
1096
+ trustRunVaultCapability(cap);
1097
+ return vaultToolEnv(cap);
1098
+ })(),
1001
1099
  };
1002
1100
  // Surface the tier to the chat panel as a system message so the
1003
1101
  // operator sees WHY a small model is running with tighter caps.
@@ -1513,6 +1611,23 @@ export async function connect(opts) {
1513
1611
  console.log(chalk.gray(` [assistant-stdout] ${t.slice(0, mi)}`));
1514
1612
  try {
1515
1613
  const parsed = JSON.parse(t.slice(mi + _EVENT_PREFIX.length));
1614
+ // v4 output file: the host only names a path; the runner reads it
1615
+ // (confined to the turn's dir, capped) and relays the bytes. A frame
1616
+ // for a turn without fileDelivery, or a refused file, is dropped:
1617
+ // the raw path never leaves this computer.
1618
+ if (parsed?.kind === "file") {
1619
+ const st = session.turnFiles?.get(String(parsed.turnId || ""));
1620
+ if (st && session.forward) {
1621
+ const r = readTurnFile(st, parsed.path);
1622
+ if (r.ok)
1623
+ socket.emit("runner:assistant_event", { sessionId: session.sid, turnId: String(parsed.turnId), kind: "file", ...r.file });
1624
+ else if (opts.verbose)
1625
+ console.log(chalk.gray(` [assistant] output file not relayed (${r.reason})`));
1626
+ }
1627
+ continue;
1628
+ }
1629
+ if (parsed?.kind === "done" && typeof parsed.turnId === "string")
1630
+ session.turnFiles?.delete(parsed.turnId);
1516
1631
  if (typeof parsed?.memoryWatermark === "number")
1517
1632
  session.memoryWatermark = parsed.memoryWatermark;
1518
1633
  if (parsed?.kind === "ready") {
@@ -2125,12 +2240,32 @@ export async function connect(opts) {
2125
2240
  if (env && typeof env === "object")
2126
2241
  turnConnectorEnv[svc] = { ...env };
2127
2242
  }
2243
+ // Obsidian vaults: the server sends only the capability; the local relay
2244
+ // serves vaults on this computer and forwards the rest to Melaya.
2245
+ if (turnConnectorEnv.obsidian?.MEL_VAULT_CAP) {
2246
+ trustRunVaultCapability(turnConnectorEnv.obsidian.MEL_VAULT_CAP);
2247
+ turnConnectorEnv.obsidian = { ...turnConnectorEnv.obsidian, ...vaultToolEnv(turnConnectorEnv.obsidian.MEL_VAULT_CAP) };
2248
+ }
2128
2249
  if (turnConnectors.includes("luma")) {
2129
2250
  await _ensureLumaBridge().catch(() => { });
2130
2251
  if (lumaBridge)
2131
2252
  turnConnectorEnv.luma = { ...(turnConnectorEnv.luma || {}), MEL_LUMA_BROWSER_URL: lumaBridge.url, MEL_LUMA_BROWSER_TOKEN: lumaBridge.token };
2132
2253
  }
2133
2254
  }
2255
+ // v4 output files: a fresh dir for this turn (MELAYA_OUTPUT_DIR in the host
2256
+ // for the turn); its files are relayed at turn end (stdout `file` frames).
2257
+ let turnOutDir = "";
2258
+ if (payload.fileDelivery === true && payload.turnId) {
2259
+ try {
2260
+ turnOutDir = turnOutputDir(String(payload.turnId));
2261
+ (s.turnFiles ??= new Map()).set(String(payload.turnId), newTurnFileState(turnOutDir));
2262
+ }
2263
+ catch (e) {
2264
+ turnOutDir = "";
2265
+ if (opts.verbose)
2266
+ console.log(chalk.gray(` [assistant] no output dir for this turn: ${e?.message || e}`));
2267
+ }
2268
+ }
2134
2269
  // Capture for the stdout "done" handler closure below.
2135
2270
  const _turnBridgeRunId = turnBridgeRunId;
2136
2271
  const _turnId = payload.turnId;
@@ -2194,6 +2329,9 @@ export async function connect(opts) {
2194
2329
  // v3 per-turn connectors (absent from a v2 server: the host keeps its
2195
2330
  // boot-time connectors).
2196
2331
  ...(turnConnectors ? { connectors: turnConnectors, connector_env: turnConnectorEnv } : {}),
2332
+ // v4: the turn's output dir (absent: the host keeps its boot-time dir
2333
+ // and reports no files).
2334
+ ...(turnOutDir ? { output_dir: turnOutDir } : {}),
2197
2335
  }) + "\n");
2198
2336
  }
2199
2337
  catch (e) {
@@ -2299,60 +2437,13 @@ export async function connect(opts) {
2299
2437
  socket.emit("runner:pick-folder-result", { session_id: sid, ok, path, error });
2300
2438
  };
2301
2439
  try {
2302
- const { execFile } = await import("child_process");
2303
- const platform = process.platform;
2304
- const run = (cmd, args) => new Promise((resolve, reject) => {
2305
- execFile(cmd, args, { maxBuffer: 1024 * 64 }, (err, stdout, stderr) => {
2306
- if (err) {
2307
- const out = (stdout || "").trim();
2308
- const errMsg = (stderr || err.message || "").trim();
2309
- // osascript returns code 1 when user cancels — empty stdout
2310
- if (!out && /User canceled|cancelled/i.test(errMsg))
2311
- return resolve("");
2312
- return reject(new Error(errMsg || "pick failed"));
2313
- }
2314
- resolve((stdout || "").trim());
2315
- });
2316
- });
2317
- let picked = "";
2318
- if (platform === "darwin") {
2319
- // osascript returns "Macintosh HD:Users:me:Documents" — convert to POSIX
2320
- const raw = await run("osascript", [
2321
- "-e", 'tell application "System Events"',
2322
- "-e", "activate",
2323
- "-e", "set p to choose folder with prompt \"Select your reference docs folder\"",
2324
- "-e", "end tell",
2325
- "-e", "return POSIX path of p",
2326
- ]);
2327
- picked = raw.replace(/\/$/, "");
2328
- }
2329
- else if (platform === "win32") {
2330
- const ps = [
2331
- "Add-Type -AssemblyName System.Windows.Forms;",
2332
- "$f = New-Object System.Windows.Forms.FolderBrowserDialog;",
2333
- "$f.Description = 'Select your reference docs folder';",
2334
- "$f.ShowNewFolderButton = $true;",
2335
- "if ($f.ShowDialog() -eq 'OK') { [Console]::Out.Write($f.SelectedPath) }",
2336
- ].join(" ");
2337
- picked = await run("powershell", ["-NoProfile", "-STA", "-Command", ps]);
2338
- }
2339
- else {
2340
- // Linux — try zenity, fall back to error if missing
2341
- picked = await run("zenity", [
2342
- "--file-selection", "--directory",
2343
- "--title=Select your reference docs folder",
2344
- ]);
2345
- }
2346
- if (!picked)
2440
+ const r = await pickNativeFolder("Select your reference docs folder");
2441
+ if (!("path" in r))
2347
2442
  return reply(false, null, "cancelled");
2348
- reply(true, picked);
2443
+ reply(true, r.path);
2349
2444
  }
2350
2445
  catch (e) {
2351
- const msg = String(e?.message || "pick failed");
2352
- const hint = /ENOENT|not found|spawn/i.test(msg) && process.platform === "linux"
2353
- ? "Install 'zenity' (apt install zenity) or type the path manually."
2354
- : "";
2355
- reply(false, null, hint ? `${msg} — ${hint}` : msg);
2446
+ reply(false, null, String(e?.message || "pick failed"));
2356
2447
  }
2357
2448
  });
2358
2449
  socket.on("rag:ingest", async (payload) => {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Native OS folder dialog, shared by runner:pick-folder (static-context / RAG
3
+ * folders) and runner:vault-pick-folder (Obsidian vaults).
4
+ *
5
+ * Browsers can't reveal absolute paths for security, so the runner — which
6
+ * lives on the user's box — pops the dialog. Fixed argv; the prompt is a
7
+ * constant chosen by the caller, never user or model input.
8
+ *
9
+ * - Windows: the Explorer folder dialog (IFileOpenDialog + FOS_PICKFOLDERS)
10
+ * through PowerShell. WinForms' FolderBrowserDialog is the old tree view
11
+ * on Windows PowerShell (.NET Framework), so it is only the fallback. The
12
+ * dialog is owned by a small topmost window so it opens in front of the
13
+ * browser instead of behind it.
14
+ * - macOS: AppleScript `choose folder` run by osascript itself. Wrapping it
15
+ * in `tell application "System Events"` needs the Automation permission
16
+ * and fails (-1743) or hides the dialog when it is not granted.
17
+ * - Linux: zenity, then kdialog.
18
+ */
19
+ export type FolderPickResult = {
20
+ path: string;
21
+ } | {
22
+ cancelled: true;
23
+ };
24
+ export declare function pickNativeFolder(prompt: string): Promise<FolderPickResult>;