@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.
- package/dist/assistantFiles.d.ts +29 -0
- package/dist/assistantFiles.js +113 -0
- package/dist/assistantHost.py +333 -37
- package/dist/cli.js +7 -0
- package/dist/connection.js +236 -65
- package/dist/folderPicker.d.ts +15 -0
- package/dist/folderPicker.js +69 -0
- package/dist/localRelay.d.ts +8 -1
- package/dist/localRelay.js +21 -1
- package/dist/localVault.py +745 -0
- package/dist/localVaultMirror.py +507 -0
- package/dist/obsidianApp.d.ts +324 -0
- package/dist/obsidianApp.js +1143 -0
- package/dist/obsidianAppCli.d.ts +15 -0
- package/dist/obsidianAppCli.js +123 -0
- package/dist/sharedVendor.js +33 -1
- package/dist/vaultMirror.d.ts +104 -0
- package/dist/vaultMirror.js +305 -0
- package/dist/vaults.d.ts +350 -0
- package/dist/vaults.js +1130 -0
- package/localVault.py +745 -0
- package/localVaultMirror.py +507 -0
- package/package.json +4 -2
package/dist/connection.js
CHANGED
|
@@ -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;
|
|
@@ -54,7 +63,19 @@ const RESERVED_BROWSER_ENV = ["MEL_BROWSER_URL", "MEL_BROWSER_TOKEN"];
|
|
|
54
63
|
// versioned ready/hello). The server enforces a MINIMUM: a runner advertising a
|
|
55
64
|
// lower protocol never gets an assistant session routed to it (it gets a clear
|
|
56
65
|
// "update your runner" instead of a silent stale-memory turn).
|
|
57
|
-
|
|
66
|
+
// v3: per-turn connectors. The server boots the host WITHOUT connectors (and
|
|
67
|
+
// without their credentials) when it sees v3, sends the chat's selection + env
|
|
68
|
+
// on every runner:assistant_turn frame, and pushes mid-turn changes (autonomy
|
|
69
|
+
// mode, connectors) with runner:assistant_turn_update. Changing a connector or
|
|
70
|
+
// the autonomy mode never reboots the host, and a warm spare stays adoptable
|
|
71
|
+
// whatever the selection. A v2 server keeps sending connectors at boot; this
|
|
72
|
+
// runner then behaves exactly as before (turnConnectors absent).
|
|
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;
|
|
58
79
|
// Best-effort read of our own package version (mirrors cli.ts). Advertised in the
|
|
59
80
|
// hello so the server can log / gate on a package floor too.
|
|
60
81
|
let RUNNER_VERSION = "0.0.0";
|
|
@@ -143,6 +164,63 @@ export async function connect(opts) {
|
|
|
143
164
|
// is refused (plan Section 4 binding rule).
|
|
144
165
|
let socketGeneration = 0;
|
|
145
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
|
+
}
|
|
146
224
|
const _ensureBrowserBridge = async () => {
|
|
147
225
|
if (browserBridge)
|
|
148
226
|
return browserBridge;
|
|
@@ -339,7 +417,9 @@ export async function connect(opts) {
|
|
|
339
417
|
// event_triggers_v1: this runner writes payload.triggerJson to
|
|
340
418
|
// <runDir>/trigger.json + MEL_TRIGGER_FILE. The server reports
|
|
341
419
|
// triggerAccepted from it; older runners silently drop the block.
|
|
342
|
-
|
|
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],
|
|
343
423
|
package: "@melaya/runner",
|
|
344
424
|
version: RUNNER_VERSION,
|
|
345
425
|
assistantProtocol: ASSISTANT_PROTOCOL_VERSION,
|
|
@@ -351,10 +431,22 @@ export async function connect(opts) {
|
|
|
351
431
|
cdp: true,
|
|
352
432
|
engines: browserEngines,
|
|
353
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
|
+
},
|
|
354
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);
|
|
355
444
|
// Start local event relay
|
|
356
445
|
if (!relay) {
|
|
357
|
-
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);
|
|
358
450
|
if (opts.verbose) {
|
|
359
451
|
console.log(chalk.gray(` [relay] Listening on 127.0.0.1:${relay.port} (nonce: ${relay.nonce})`));
|
|
360
452
|
}
|
|
@@ -991,6 +1083,19 @@ export async function connect(opts) {
|
|
|
991
1083
|
...(isTriggeredRun
|
|
992
1084
|
? { MEL_HITL_APPROVAL_TTL_S: String(triggerApprovalTtl) }
|
|
993
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
|
+
})(),
|
|
994
1099
|
};
|
|
995
1100
|
// Surface the tier to the chat panel as a system message so the
|
|
996
1101
|
// operator sees WHY a small model is running with tighter caps.
|
|
@@ -1506,6 +1611,23 @@ export async function connect(opts) {
|
|
|
1506
1611
|
console.log(chalk.gray(` [assistant-stdout] ${t.slice(0, mi)}`));
|
|
1507
1612
|
try {
|
|
1508
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);
|
|
1509
1631
|
if (typeof parsed?.memoryWatermark === "number")
|
|
1510
1632
|
session.memoryWatermark = parsed.memoryWatermark;
|
|
1511
1633
|
if (parsed?.kind === "ready") {
|
|
@@ -1515,11 +1637,20 @@ export async function connect(opts) {
|
|
|
1515
1637
|
session.configHash = parsed.configHash;
|
|
1516
1638
|
session.ready = true;
|
|
1517
1639
|
parsed.protocol = ASSISTANT_PROTOCOL_VERSION;
|
|
1640
|
+
// A spare adopted while still booting reports the generation it
|
|
1641
|
+
// booted under; the session's is the one the adopt frame set.
|
|
1642
|
+
if (session.forward)
|
|
1643
|
+
parsed.generation = session.generation;
|
|
1518
1644
|
}
|
|
1519
1645
|
// A spare's frames are NOT forwarded (no session yet); adoption flips
|
|
1520
1646
|
// session.forward and re-emits `ready` with the real sid.
|
|
1521
1647
|
if (session.forward)
|
|
1522
1648
|
socket.emit("runner:assistant_event", { sessionId: session.sid, ...parsed });
|
|
1649
|
+
// A session host is up: NOW the warm spare may boot. Booting both at
|
|
1650
|
+
// once made them compile + import the shared bundle concurrently
|
|
1651
|
+
// (prod 2026-10-11, Windows: ~50 s each instead of ~7 s).
|
|
1652
|
+
if (parsed?.kind === "ready" && session.forward)
|
|
1653
|
+
schedulePrewarm();
|
|
1523
1654
|
}
|
|
1524
1655
|
catch {
|
|
1525
1656
|
// The literal "MELASSIST " appeared inside raw text (not a real frame)
|
|
@@ -1594,11 +1725,17 @@ export async function connect(opts) {
|
|
|
1594
1725
|
// turns it off.
|
|
1595
1726
|
const WARM_POOL_ENABLED = process.env.MEL_ASSISTANT_WARM_POOL !== "0";
|
|
1596
1727
|
let _prewarmInFlight = false;
|
|
1728
|
+
// Never while a session host is booting (it would compete for the same cold
|
|
1729
|
+
// import of the shared bundle): the session's `ready` frame schedules it.
|
|
1730
|
+
const _sessionHostBooting = () => bootingAssistants.size > 0 || [...activeAssistants.values()].some((s) => s.forward && !s.ready);
|
|
1597
1731
|
const schedulePrewarm = () => {
|
|
1598
1732
|
if (!WARM_POOL_ENABLED || !warmSpareSeed || _prewarmInFlight)
|
|
1599
1733
|
return;
|
|
1734
|
+
if (_sessionHostBooting())
|
|
1735
|
+
return;
|
|
1600
1736
|
if (warmSpare) {
|
|
1601
|
-
|
|
1737
|
+
const spareHash = warmSpare.configHash || warmSpare.seedConfigHash || "";
|
|
1738
|
+
if (spareHash && spareHash !== warmSpareSeed.configHash) {
|
|
1602
1739
|
try {
|
|
1603
1740
|
warmSpare.proc.kill();
|
|
1604
1741
|
}
|
|
@@ -1624,6 +1761,7 @@ export async function connect(opts) {
|
|
|
1624
1761
|
const spareEnv = { ...seed.env };
|
|
1625
1762
|
const spare = launchAssistantHost({ env: spareEnv, pythonPath: seed.pythonPath, workDir: wd, stagedHost: staged, sid: `__spare__:${stamp}`, generation: seed.generation, forward: false, providerLabel: "prewarm" });
|
|
1626
1763
|
spare.credHash = seed.credHash;
|
|
1764
|
+
spare.seedConfigHash = seed.configHash;
|
|
1627
1765
|
warmSpare = spare;
|
|
1628
1766
|
const t = setTimeout(() => { if (warmSpare === spare && !spare.forward) {
|
|
1629
1767
|
try {
|
|
@@ -1688,7 +1826,12 @@ export async function connect(opts) {
|
|
|
1688
1826
|
// Connector tool sets the user enabled for this chat (comma-joined service
|
|
1689
1827
|
// ids). The host seeds a lazy toolkit from these so ANY connector's tools
|
|
1690
1828
|
// are reachable without exploding context.
|
|
1691
|
-
|
|
1829
|
+
// Per-turn connectors (v3): empty at boot, the turn frame carries them.
|
|
1830
|
+
MEL_ASSISTANT_CONNECTORS: !payload.turnConnectors && Array.isArray(payload.connectors) ? payload.connectors.join(",") : "",
|
|
1831
|
+
// Tells the host to take connectors per turn (lazy registry scope + env
|
|
1832
|
+
// per turn, connector rule in the per-turn prompt). Part of its config
|
|
1833
|
+
// hash ("turn-connectors-v1"), byte-aligned with the server.
|
|
1834
|
+
MEL_ASSISTANT_TURN_CONNECTORS: payload.turnConnectors ? "1" : "",
|
|
1692
1835
|
// Surgical tool pinning: exact connector tool NAMES (not just service ids)
|
|
1693
1836
|
// the server already resolved creds for. The host registers these ACTIVE
|
|
1694
1837
|
// at boot instead of dumping them into the deferred pool, eliminating the
|
|
@@ -1696,7 +1839,8 @@ export async function connect(opts) {
|
|
|
1696
1839
|
// Byte-for-byte source of truth for _config_hash's pinned-tools field.
|
|
1697
1840
|
MEL_ASSISTANT_PINNED_TOOLS: payload.pinnedTools?.join(",") ?? "",
|
|
1698
1841
|
// Gate write connector-tools behind the in-chat approval card (fail-safe).
|
|
1699
|
-
|
|
1842
|
+
// Per-turn mode: the host arms it itself whenever a turn selects a connector.
|
|
1843
|
+
MEL_ASSISTANT_CONNECTOR_HITL: !payload.turnConnectors && Array.isArray(payload.connectors) && payload.connectors.length ? "1" : "",
|
|
1700
1844
|
// HITL autonomy mode ("safe" | "autonomous" | "payments_only"). The host
|
|
1701
1845
|
// reads this at start; each turn's frame can flip it (see assistant_turn).
|
|
1702
1846
|
// Fail-safe: unknown ⇒ "safe".
|
|
@@ -1849,12 +1993,17 @@ export async function connect(opts) {
|
|
|
1849
1993
|
// conversation's via an `adopt` frame, so a restored conversation's
|
|
1850
1994
|
// generation-bound rehydrate still applies (the old exact-generation match
|
|
1851
1995
|
// meant a spare could almost never be adopted).
|
|
1996
|
+
// A spare that is STILL BOOTING with the same seed config + credentials is
|
|
1997
|
+
// adopted too: the session waits for ITS ready frame (forwarded under this
|
|
1998
|
+
// sid once `forward` flips) instead of booting a second host next to it.
|
|
1852
1999
|
if (!activeAssistants.has(sid) && !bootingAssistants.has(sid)
|
|
1853
|
-
&& warmSpare &&
|
|
1854
|
-
&& warmSpare.
|
|
2000
|
+
&& warmSpare && !warmSpare.killed && expectedConfig
|
|
2001
|
+
&& (warmSpare.ready ? warmSpare.configHash : warmSpare.seedConfigHash) === expectedConfig
|
|
2002
|
+
&& (warmSpare.memoryWatermark || 0) === 0
|
|
1855
2003
|
&& warmSpare.credHash === _assistantCredHash(payload.credentials)) {
|
|
1856
2004
|
const spare = warmSpare;
|
|
1857
2005
|
warmSpare = null;
|
|
2006
|
+
// Queued on the host's stdin: a booting host reads it right after `ready`.
|
|
1858
2007
|
try {
|
|
1859
2008
|
spare.proc.stdin?.write(JSON.stringify({ kind: "adopt", generation }) + "\n");
|
|
1860
2009
|
}
|
|
@@ -1864,9 +2013,14 @@ export async function connect(opts) {
|
|
|
1864
2013
|
spare.forward = true;
|
|
1865
2014
|
spare.lastActivity = Date.now();
|
|
1866
2015
|
activeAssistants.set(sid, spare);
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
2016
|
+
if (spare.ready) {
|
|
2017
|
+
console.log(chalk.green(` ◆ Assistant ${sid.slice(0, 10)}… adopted warm spare — cold boot skipped`));
|
|
2018
|
+
emitEv({ kind: "ready", memoryWatermark: spare.memoryWatermark || 0, generation: spare.generation, hostInstanceId: spare.hostInstanceId, configHash: spare.configHash, protocol: ASSISTANT_PROTOCOL_VERSION });
|
|
2019
|
+
schedulePrewarm(); // refill the pool for the next session
|
|
2020
|
+
}
|
|
2021
|
+
else {
|
|
2022
|
+
console.log(chalk.green(` ◆ Assistant ${sid.slice(0, 10)}… adopted the warm spare still booting, no second host`));
|
|
2023
|
+
}
|
|
1870
2024
|
return;
|
|
1871
2025
|
}
|
|
1872
2026
|
// A boot is already in flight for this sid (concurrent start / server restart
|
|
@@ -1904,7 +2058,7 @@ export async function connect(opts) {
|
|
|
1904
2058
|
// Cloudflare's write-bot rule. Without it the tool falls back to aiohttp,
|
|
1905
2059
|
// which 403s ("cloudflare_mitigation" / "velocity_throttled") on every RSVP.
|
|
1906
2060
|
// Idempotent + best-effort; only paid when luma is actually selected.
|
|
1907
|
-
if (Array.isArray(payload.connectors) && payload.connectors.includes("luma")) {
|
|
2061
|
+
if (!payload.turnConnectors && Array.isArray(payload.connectors) && payload.connectors.includes("luma")) {
|
|
1908
2062
|
await _ensureLumaBridge();
|
|
1909
2063
|
}
|
|
1910
2064
|
const env = await buildAssistantEnv(payload, sid, generation);
|
|
@@ -1917,7 +2071,7 @@ export async function connect(opts) {
|
|
|
1917
2071
|
// matching spare for the NEXT new session — which then adopts it and skips
|
|
1918
2072
|
// the cold boot (see the ADOPTION fast path + schedulePrewarm above).
|
|
1919
2073
|
warmSpareSeed = { env, configHash: expectedConfig, generation, pythonPath: envResult.pythonPath, workDirBase: tmpdir(), hostScript, credHash: _assistantCredHash(payload.credentials) };
|
|
1920
|
-
|
|
2074
|
+
// The spare boots once this session reports ready (see the stdout parser).
|
|
1921
2075
|
}
|
|
1922
2076
|
catch (e) {
|
|
1923
2077
|
emitEv({ kind: "error", message: `assistant start failed: ${e?.message || e}` });
|
|
@@ -2076,6 +2230,42 @@ export async function connect(opts) {
|
|
|
2076
2230
|
if (opts.verbose)
|
|
2077
2231
|
console.log(chalk.gray(` [browser-bridge] ext-proxy turn (session=${payload.sessionId.slice(0, 10)} url=${turnBridgeUrl})`));
|
|
2078
2232
|
}
|
|
2233
|
+
// Per-turn connectors (v3). Luma writes go through the local browser bridge
|
|
2234
|
+
// (Cloudflare write rule): bring it up when this turn selects luma, and hand
|
|
2235
|
+
// its url/token to the host with luma's env for the turn.
|
|
2236
|
+
const turnConnectors = Array.isArray(payload.connectors) ? payload.connectors.filter((c) => typeof c === "string") : null;
|
|
2237
|
+
const turnConnectorEnv = {};
|
|
2238
|
+
if (turnConnectors) {
|
|
2239
|
+
for (const [svc, env] of Object.entries(payload.connectorEnv || {})) {
|
|
2240
|
+
if (env && typeof env === "object")
|
|
2241
|
+
turnConnectorEnv[svc] = { ...env };
|
|
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
|
+
}
|
|
2249
|
+
if (turnConnectors.includes("luma")) {
|
|
2250
|
+
await _ensureLumaBridge().catch(() => { });
|
|
2251
|
+
if (lumaBridge)
|
|
2252
|
+
turnConnectorEnv.luma = { ...(turnConnectorEnv.luma || {}), MEL_LUMA_BROWSER_URL: lumaBridge.url, MEL_LUMA_BROWSER_TOKEN: lumaBridge.token };
|
|
2253
|
+
}
|
|
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
|
+
}
|
|
2079
2269
|
// Capture for the stdout "done" handler closure below.
|
|
2080
2270
|
const _turnBridgeRunId = turnBridgeRunId;
|
|
2081
2271
|
const _turnId = payload.turnId;
|
|
@@ -2136,6 +2326,12 @@ export async function connect(opts) {
|
|
|
2136
2326
|
...(turnBridgeUrl && turnBridgeToken
|
|
2137
2327
|
? { mel_browser_url: turnBridgeUrl, mel_browser_token: turnBridgeToken }
|
|
2138
2328
|
: {}),
|
|
2329
|
+
// v3 per-turn connectors (absent from a v2 server: the host keeps its
|
|
2330
|
+
// boot-time connectors).
|
|
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 } : {}),
|
|
2139
2335
|
}) + "\n");
|
|
2140
2336
|
}
|
|
2141
2337
|
catch (e) {
|
|
@@ -2148,6 +2344,28 @@ export async function connect(opts) {
|
|
|
2148
2344
|
socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: `turn write failed: ${e?.message || e}` });
|
|
2149
2345
|
}
|
|
2150
2346
|
});
|
|
2347
|
+
// v3 live mid-turn update: the user changed the autonomy mode or the connector
|
|
2348
|
+
// selection while the reply streams. The host's stdin reader applies it
|
|
2349
|
+
// between tool calls (like STOP), so the NEXT tool call of the running turn
|
|
2350
|
+
// uses it; a pending approval stays pending. Ignored for any other turn.
|
|
2351
|
+
socket.on("runner:assistant_turn_update", (payload) => {
|
|
2352
|
+
const s = activeAssistants.get(String(payload?.sessionId || ""));
|
|
2353
|
+
if (!s || !payload?.turnId)
|
|
2354
|
+
return;
|
|
2355
|
+
s.lastActivity = Date.now();
|
|
2356
|
+
const frame = { kind: "turn_update", turnId: payload.turnId };
|
|
2357
|
+
if (typeof payload.hitlMode === "string") {
|
|
2358
|
+
frame.hitl_mode = (payload.hitlMode === "autonomous" || payload.hitlMode === "payments_only") ? payload.hitlMode : "safe";
|
|
2359
|
+
}
|
|
2360
|
+
if (Array.isArray(payload.connectors)) {
|
|
2361
|
+
frame.connectors = payload.connectors.filter((c) => typeof c === "string");
|
|
2362
|
+
frame.connector_env = payload.connectorEnv && typeof payload.connectorEnv === "object" ? payload.connectorEnv : {};
|
|
2363
|
+
}
|
|
2364
|
+
try {
|
|
2365
|
+
s.proc.stdin?.write(JSON.stringify(frame) + "\n");
|
|
2366
|
+
}
|
|
2367
|
+
catch { /* best-effort */ }
|
|
2368
|
+
});
|
|
2151
2369
|
socket.on("runner:assistant_stop", (payload) => {
|
|
2152
2370
|
const s = activeAssistants.get(String(payload.sessionId || ""));
|
|
2153
2371
|
if (s) {
|
|
@@ -2219,60 +2437,13 @@ export async function connect(opts) {
|
|
|
2219
2437
|
socket.emit("runner:pick-folder-result", { session_id: sid, ok, path, error });
|
|
2220
2438
|
};
|
|
2221
2439
|
try {
|
|
2222
|
-
const
|
|
2223
|
-
|
|
2224
|
-
const run = (cmd, args) => new Promise((resolve, reject) => {
|
|
2225
|
-
execFile(cmd, args, { maxBuffer: 1024 * 64 }, (err, stdout, stderr) => {
|
|
2226
|
-
if (err) {
|
|
2227
|
-
const out = (stdout || "").trim();
|
|
2228
|
-
const errMsg = (stderr || err.message || "").trim();
|
|
2229
|
-
// osascript returns code 1 when user cancels — empty stdout
|
|
2230
|
-
if (!out && /User canceled|cancelled/i.test(errMsg))
|
|
2231
|
-
return resolve("");
|
|
2232
|
-
return reject(new Error(errMsg || "pick failed"));
|
|
2233
|
-
}
|
|
2234
|
-
resolve((stdout || "").trim());
|
|
2235
|
-
});
|
|
2236
|
-
});
|
|
2237
|
-
let picked = "";
|
|
2238
|
-
if (platform === "darwin") {
|
|
2239
|
-
// osascript returns "Macintosh HD:Users:me:Documents" — convert to POSIX
|
|
2240
|
-
const raw = await run("osascript", [
|
|
2241
|
-
"-e", 'tell application "System Events"',
|
|
2242
|
-
"-e", "activate",
|
|
2243
|
-
"-e", "set p to choose folder with prompt \"Select your reference docs folder\"",
|
|
2244
|
-
"-e", "end tell",
|
|
2245
|
-
"-e", "return POSIX path of p",
|
|
2246
|
-
]);
|
|
2247
|
-
picked = raw.replace(/\/$/, "");
|
|
2248
|
-
}
|
|
2249
|
-
else if (platform === "win32") {
|
|
2250
|
-
const ps = [
|
|
2251
|
-
"Add-Type -AssemblyName System.Windows.Forms;",
|
|
2252
|
-
"$f = New-Object System.Windows.Forms.FolderBrowserDialog;",
|
|
2253
|
-
"$f.Description = 'Select your reference docs folder';",
|
|
2254
|
-
"$f.ShowNewFolderButton = $true;",
|
|
2255
|
-
"if ($f.ShowDialog() -eq 'OK') { [Console]::Out.Write($f.SelectedPath) }",
|
|
2256
|
-
].join(" ");
|
|
2257
|
-
picked = await run("powershell", ["-NoProfile", "-STA", "-Command", ps]);
|
|
2258
|
-
}
|
|
2259
|
-
else {
|
|
2260
|
-
// Linux — try zenity, fall back to error if missing
|
|
2261
|
-
picked = await run("zenity", [
|
|
2262
|
-
"--file-selection", "--directory",
|
|
2263
|
-
"--title=Select your reference docs folder",
|
|
2264
|
-
]);
|
|
2265
|
-
}
|
|
2266
|
-
if (!picked)
|
|
2440
|
+
const r = await pickNativeFolder("Select your reference docs folder");
|
|
2441
|
+
if (!("path" in r))
|
|
2267
2442
|
return reply(false, null, "cancelled");
|
|
2268
|
-
reply(true,
|
|
2443
|
+
reply(true, r.path);
|
|
2269
2444
|
}
|
|
2270
2445
|
catch (e) {
|
|
2271
|
-
|
|
2272
|
-
const hint = /ENOENT|not found|spawn/i.test(msg) && process.platform === "linux"
|
|
2273
|
-
? "Install 'zenity' (apt install zenity) or type the path manually."
|
|
2274
|
-
: "";
|
|
2275
|
-
reply(false, null, hint ? `${msg} — ${hint}` : msg);
|
|
2446
|
+
reply(false, null, String(e?.message || "pick failed"));
|
|
2276
2447
|
}
|
|
2277
2448
|
});
|
|
2278
2449
|
socket.on("rag:ingest", async (payload) => {
|
|
@@ -0,0 +1,15 @@
|
|
|
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. Mac: osascript. Windows:
|
|
7
|
+
* PowerShell WinForms. Linux: zenity. Fixed argv; the prompt is a constant
|
|
8
|
+
* chosen by the caller, never user or model input.
|
|
9
|
+
*/
|
|
10
|
+
export type FolderPickResult = {
|
|
11
|
+
path: string;
|
|
12
|
+
} | {
|
|
13
|
+
cancelled: true;
|
|
14
|
+
};
|
|
15
|
+
export declare function pickNativeFolder(prompt: string): Promise<FolderPickResult>;
|
|
@@ -0,0 +1,69 @@
|
|
|
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. Mac: osascript. Windows:
|
|
7
|
+
* PowerShell WinForms. Linux: zenity. Fixed argv; the prompt is a constant
|
|
8
|
+
* chosen by the caller, never user or model input.
|
|
9
|
+
*/
|
|
10
|
+
import { execFile } from "child_process";
|
|
11
|
+
export async function pickNativeFolder(prompt) {
|
|
12
|
+
const platform = process.platform;
|
|
13
|
+
const run = (cmd, args) => new Promise((resolve, reject) => {
|
|
14
|
+
execFile(cmd, args, { maxBuffer: 1024 * 64 }, (err, stdout, stderr) => {
|
|
15
|
+
if (err) {
|
|
16
|
+
const out = (stdout || "").trim();
|
|
17
|
+
const errMsg = (stderr || err.message || "").trim();
|
|
18
|
+
// osascript returns code 1 when user cancels — empty stdout
|
|
19
|
+
if (!out && /User canceled|cancelled/i.test(errMsg))
|
|
20
|
+
return resolve("");
|
|
21
|
+
return reject(new Error(errMsg || "pick failed"));
|
|
22
|
+
}
|
|
23
|
+
resolve((stdout || "").trim());
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
// Quotes would break out of the AppleScript / PowerShell string literal.
|
|
27
|
+
const safePrompt = prompt.replace(/["'`$\\]/g, "");
|
|
28
|
+
let picked = "";
|
|
29
|
+
try {
|
|
30
|
+
if (platform === "darwin") {
|
|
31
|
+
// osascript returns "Macintosh HD:Users:me:Documents" — convert to POSIX
|
|
32
|
+
const raw = await run("osascript", [
|
|
33
|
+
"-e", 'tell application "System Events"',
|
|
34
|
+
"-e", "activate",
|
|
35
|
+
"-e", `set p to choose folder with prompt "${safePrompt}"`,
|
|
36
|
+
"-e", "end tell",
|
|
37
|
+
"-e", "return POSIX path of p",
|
|
38
|
+
]);
|
|
39
|
+
picked = raw.replace(/\/$/, "");
|
|
40
|
+
}
|
|
41
|
+
else if (platform === "win32") {
|
|
42
|
+
const ps = [
|
|
43
|
+
"Add-Type -AssemblyName System.Windows.Forms;",
|
|
44
|
+
"$f = New-Object System.Windows.Forms.FolderBrowserDialog;",
|
|
45
|
+
`$f.Description = '${safePrompt}';`,
|
|
46
|
+
"$f.ShowNewFolderButton = $true;",
|
|
47
|
+
"if ($f.ShowDialog() -eq 'OK') { [Console]::Out.Write($f.SelectedPath) }",
|
|
48
|
+
].join(" ");
|
|
49
|
+
picked = await run("powershell", ["-NoProfile", "-STA", "-Command", ps]);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// Linux — try zenity, fall back to error if missing
|
|
53
|
+
picked = await run("zenity", [
|
|
54
|
+
"--file-selection", "--directory",
|
|
55
|
+
`--title=${safePrompt}`,
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
const msg = String(e?.message || "pick failed");
|
|
61
|
+
const hint = /ENOENT|not found|spawn/i.test(msg) && process.platform === "linux"
|
|
62
|
+
? "Install 'zenity' (apt install zenity) or type the path manually."
|
|
63
|
+
: "";
|
|
64
|
+
throw new Error(hint ? `${msg} — ${hint}` : msg);
|
|
65
|
+
}
|
|
66
|
+
if (!picked)
|
|
67
|
+
return { cancelled: true };
|
|
68
|
+
return { path: picked };
|
|
69
|
+
}
|
package/dist/localRelay.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* - Per-run nonce required in the URL path to prevent injection from
|
|
10
10
|
* other processes on the same machine
|
|
11
11
|
*/
|
|
12
|
+
import http from "http";
|
|
12
13
|
import type { Socket } from "socket.io-client";
|
|
13
14
|
export interface LocalRelay {
|
|
14
15
|
port: number;
|
|
@@ -16,4 +17,10 @@ export interface LocalRelay {
|
|
|
16
17
|
close: () => void;
|
|
17
18
|
}
|
|
18
19
|
export declare function setActiveProject(project: string): void;
|
|
19
|
-
export
|
|
20
|
+
export interface LocalRelayRoutes {
|
|
21
|
+
/** POST /vaults/op (Obsidian vault tools, protocol §2). Authenticated by the
|
|
22
|
+
* vault capability in `Authorization: Vault <cap>`, not by the nonce: the
|
|
23
|
+
* handler verifies it (local device vaults) or forwards it to the server. */
|
|
24
|
+
vaultOp?: (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void> | void;
|
|
25
|
+
}
|
|
26
|
+
export declare function startLocalRelay(socket: Socket, verbose: boolean, serverUrl?: string, routes?: LocalRelayRoutes): Promise<LocalRelay>;
|
package/dist/localRelay.js
CHANGED
|
@@ -55,7 +55,7 @@ function _flushThrottle(socket, verbose) {
|
|
|
55
55
|
_throttleQueue = [];
|
|
56
56
|
_throttleTimer = null;
|
|
57
57
|
}
|
|
58
|
-
export function startLocalRelay(socket, verbose, serverUrl) {
|
|
58
|
+
export function startLocalRelay(socket, verbose, serverUrl, routes = {}) {
|
|
59
59
|
const nonce = crypto.randomUUID().replace(/-/g, "");
|
|
60
60
|
return new Promise((resolve, reject) => {
|
|
61
61
|
const expectedPath = `/events/relay/${nonce}`;
|
|
@@ -147,6 +147,26 @@ export function startLocalRelay(socket, verbose, serverUrl) {
|
|
|
147
147
|
});
|
|
148
148
|
return;
|
|
149
149
|
}
|
|
150
|
+
// ── Obsidian vault ops (docs/plans/obsidian-vault-protocol.md §2) ──
|
|
151
|
+
if (req.method === "POST" && req.url && /^\/vaults\/op\/?(\?.*)?$/.test(req.url)) {
|
|
152
|
+
if (!routes.vaultOp) {
|
|
153
|
+
res.writeHead(404);
|
|
154
|
+
res.end("Not found");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
Promise.resolve(routes.vaultOp(req, res)).catch(() => {
|
|
158
|
+
if (res.headersSent) {
|
|
159
|
+
try {
|
|
160
|
+
res.end();
|
|
161
|
+
}
|
|
162
|
+
catch { /* closed */ }
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
166
|
+
res.end('{"ok":false,"error":"relay_failed"}');
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
150
170
|
if (req.method !== "POST" || req.url !== expectedPath) {
|
|
151
171
|
res.writeHead(404);
|
|
152
172
|
res.end("Not found");
|