@melaya/runner 1.0.76 → 1.0.78
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/assistantHost.py +210 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +1 -1
- package/dist/connection.d.ts +1 -0
- package/dist/connection.js +168 -14
- package/dist/pythonEnv.js +10 -0
- package/dist/sharedVendor.d.ts +1 -1
- package/dist/sharedVendor.js +12 -3
- package/package.json +2 -2
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Melaya Assistant — persistent runner-side host.
|
|
3
|
+
================================================
|
|
4
|
+
|
|
5
|
+
A LONG-LIVED process the runner spawns once per assistant chat session and keeps
|
|
6
|
+
alive across turns (unlike a pipeline run, which is one fire-and-forget
|
|
7
|
+
subprocess). It boots a single ReActAgent whose model runs on THIS machine
|
|
8
|
+
(claude_code / codex read the user's local OAuth off disk; ollama / lmstudio hit
|
|
9
|
+
localhost), then reads one user message per line on stdin, runs a turn, and
|
|
10
|
+
streams structured events on stdout. The agent's memory persists in-process, so
|
|
11
|
+
multi-turn conversation state (and the local model session) survives between
|
|
12
|
+
turns — that is the whole reason this host exists.
|
|
13
|
+
|
|
14
|
+
Protocol
|
|
15
|
+
--------
|
|
16
|
+
stdin (one JSON object per line): {"turnId": "...", "message": "..."}
|
|
17
|
+
stdout (one JSON per line, prefixed): MELASSIST {"turnId","kind",...}
|
|
18
|
+
kind ∈ ready | round | tool | delta | text | done | error
|
|
19
|
+
|
|
20
|
+
Tenant safety: the toolkit is the read-only `melaya_agent_*` tools, which POST to
|
|
21
|
+
the server's assistant-tool bridge authed by MELAYA_API_KEY — every query is
|
|
22
|
+
tenant-gated SERVER-side, so this host gains no data authority beyond the user's
|
|
23
|
+
own mk_ key. Identity is never taken from tool args.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import queue
|
|
30
|
+
import sys
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
import traceback
|
|
34
|
+
|
|
35
|
+
# The runner stages the shared/ tree and sets PYTHONPATH before spawning us, so
|
|
36
|
+
# `shared.*` imports resolve exactly like a pipeline subprocess.
|
|
37
|
+
_EVENT_PREFIX = "MELASSIST "
|
|
38
|
+
_IDLE_EXIT_SECONDS = int(os.environ.get("MEL_ASSISTANT_IDLE_SECONDS", "900")) # 15 min
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _emit(turn_id: str, kind: str, **fields) -> None:
|
|
42
|
+
"""Write one structured event line to stdout (flushed) for the runner to relay."""
|
|
43
|
+
try:
|
|
44
|
+
payload = {"turnId": turn_id, "kind": kind, **fields}
|
|
45
|
+
sys.stdout.write(_EVENT_PREFIX + json.dumps(payload, ensure_ascii=False, default=str) + "\n")
|
|
46
|
+
sys.stdout.flush()
|
|
47
|
+
except Exception:
|
|
48
|
+
# Never let event emission crash the turn loop.
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _log(msg: str) -> None:
|
|
53
|
+
# Diagnostics go to stderr (the runner logs stderr; stdout is the event channel).
|
|
54
|
+
try:
|
|
55
|
+
sys.stderr.write(f"[assistantHost] {msg}\n")
|
|
56
|
+
sys.stderr.flush()
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_text(result) -> str:
|
|
62
|
+
"""Pull the final assistant text out of an agentscope reply Msg."""
|
|
63
|
+
if result is None:
|
|
64
|
+
return ""
|
|
65
|
+
# agentscope Msg exposes get_text_content(); fall back to raw content.
|
|
66
|
+
for attr in ("get_text_content",):
|
|
67
|
+
fn = getattr(result, attr, None)
|
|
68
|
+
if callable(fn):
|
|
69
|
+
try:
|
|
70
|
+
txt = fn()
|
|
71
|
+
if txt:
|
|
72
|
+
return str(txt)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
content = getattr(result, "content", result)
|
|
76
|
+
if isinstance(content, str):
|
|
77
|
+
return content
|
|
78
|
+
if isinstance(content, list):
|
|
79
|
+
parts = []
|
|
80
|
+
for block in content:
|
|
81
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
82
|
+
parts.append(str(block.get("text", "")))
|
|
83
|
+
elif isinstance(block, str):
|
|
84
|
+
parts.append(block)
|
|
85
|
+
if parts:
|
|
86
|
+
return "\n".join(parts)
|
|
87
|
+
return str(content) if content is not None else ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build_agent():
|
|
91
|
+
"""Build the single long-lived ReActAgent for this session."""
|
|
92
|
+
from shared.runtime.agent_factory import make_agent
|
|
93
|
+
from shared.runtime.registry import build_toolkit
|
|
94
|
+
|
|
95
|
+
provider = os.environ.get("MEL_ASSISTANT_PROVIDER", "claude_code")
|
|
96
|
+
model = os.environ.get("MEL_ASSISTANT_MODEL", "") or None
|
|
97
|
+
language = os.environ.get("MEL_ASSISTANT_LANGUAGE", "en")
|
|
98
|
+
|
|
99
|
+
# Read-only platform tools (pipelines, runs, costs, usage, templates, evals).
|
|
100
|
+
# They POST to /api/v1/private/assistant-tool with MELAYA_API_KEY; tenant
|
|
101
|
+
# scope is enforced server-side.
|
|
102
|
+
try:
|
|
103
|
+
toolkit = build_toolkit(categories=["melaya_agent"])
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
_log(f"build_toolkit(melaya_agent) failed: {exc}; starting tool-less")
|
|
106
|
+
toolkit = build_toolkit(names=[])
|
|
107
|
+
|
|
108
|
+
sys_prompt = (
|
|
109
|
+
"You are the Melaya Assistant, the in-app copilot of the Melaya "
|
|
110
|
+
"agent-orchestration platform. You have READ-ONLY tools over the data "
|
|
111
|
+
"this user is allowed to see: pipelines, runs, LLM costs, plan usage, "
|
|
112
|
+
"validated workflow templates and eval results.\n"
|
|
113
|
+
"Rules:\n"
|
|
114
|
+
"- Use tools to answer questions about the user's pipelines, spend, usage, "
|
|
115
|
+
"templates or evals — never invent numbers.\n"
|
|
116
|
+
"- Be concise and concrete; format small tables or bullet lists when comparing items.\n"
|
|
117
|
+
"- If a question is outside the platform, answer normally without tools.\n"
|
|
118
|
+
+ (f"- Answer in the user's language: {language}.\n" if language and language != "en" else "")
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
agent = make_agent(
|
|
122
|
+
name="MelayaAssistant",
|
|
123
|
+
sys_prompt=sys_prompt,
|
|
124
|
+
toolkit=toolkit,
|
|
125
|
+
model_name=model,
|
|
126
|
+
provider=provider,
|
|
127
|
+
api_key="", # model.py resolves the provider's own creds (OAuth off disk / localhost)
|
|
128
|
+
max_iters=6,
|
|
129
|
+
reliability=True,
|
|
130
|
+
bounded_memory=True,
|
|
131
|
+
)
|
|
132
|
+
return agent
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _run_turn(agent, turn_id: str, message: str) -> None:
|
|
136
|
+
import asyncio
|
|
137
|
+
from agentscope.message import Msg
|
|
138
|
+
|
|
139
|
+
_emit(turn_id, "round", n=1)
|
|
140
|
+
|
|
141
|
+
async def _go():
|
|
142
|
+
return await agent(Msg("user", message, "user"))
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
result = asyncio.run(_go())
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
_log("turn error:\n" + traceback.format_exc())
|
|
148
|
+
_emit(turn_id, "error", message=str(exc) or "assistant turn failed")
|
|
149
|
+
_emit(turn_id, "done")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
text = _extract_text(result)
|
|
153
|
+
_emit(turn_id, "text", content=text)
|
|
154
|
+
_emit(turn_id, "done")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _stdin_reader(q: "queue.Queue[str]") -> None:
|
|
158
|
+
"""Feed stdin lines into a queue so the main loop can apply an idle timeout
|
|
159
|
+
(blocking readline can't be interrupted portably)."""
|
|
160
|
+
try:
|
|
161
|
+
for line in sys.stdin:
|
|
162
|
+
q.put(line)
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
finally:
|
|
166
|
+
q.put("") # sentinel: stdin closed
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main() -> int:
|
|
170
|
+
_log(f"booting (provider={os.environ.get('MEL_ASSISTANT_PROVIDER')})")
|
|
171
|
+
try:
|
|
172
|
+
agent = _build_agent()
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
_log("boot failed:\n" + traceback.format_exc())
|
|
175
|
+
_emit("", "error", message=f"assistant host failed to start: {exc}")
|
|
176
|
+
return 1
|
|
177
|
+
|
|
178
|
+
_emit("", "ready")
|
|
179
|
+
_log("ready")
|
|
180
|
+
|
|
181
|
+
q: "queue.Queue[str]" = queue.Queue()
|
|
182
|
+
threading.Thread(target=_stdin_reader, args=(q,), daemon=True).start()
|
|
183
|
+
|
|
184
|
+
while True:
|
|
185
|
+
try:
|
|
186
|
+
line = q.get(timeout=_IDLE_EXIT_SECONDS)
|
|
187
|
+
except queue.Empty:
|
|
188
|
+
_log("idle timeout — exiting")
|
|
189
|
+
return 0
|
|
190
|
+
if line == "": # stdin closed
|
|
191
|
+
_log("stdin closed — exiting")
|
|
192
|
+
return 0
|
|
193
|
+
line = line.strip()
|
|
194
|
+
if not line:
|
|
195
|
+
continue
|
|
196
|
+
try:
|
|
197
|
+
req = json.loads(line)
|
|
198
|
+
except Exception:
|
|
199
|
+
_log(f"bad stdin line (not json): {line[:120]}")
|
|
200
|
+
continue
|
|
201
|
+
turn_id = str(req.get("turnId") or "")
|
|
202
|
+
message = str(req.get("message") or "")
|
|
203
|
+
if not message:
|
|
204
|
+
_emit(turn_id, "done")
|
|
205
|
+
continue
|
|
206
|
+
_run_turn(agent, turn_id, message)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
sys.exit(main())
|
package/dist/cli.d.ts
CHANGED
|
@@ -11,6 +11,6 @@
|
|
|
11
11
|
* Security: this package is a thin executor. It does NOT contain any
|
|
12
12
|
* Melaya business logic, server code, or proprietary modules. The
|
|
13
13
|
* Python shared/ modules needed for pipeline execution are fetched
|
|
14
|
-
* at runtime from the server (
|
|
14
|
+
* at runtime from the server (authenticated and verified before writing).
|
|
15
15
|
*/
|
|
16
16
|
export {};
|
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Security: this package is a thin executor. It does NOT contain any
|
|
12
12
|
* Melaya business logic, server code, or proprietary modules. The
|
|
13
13
|
* Python shared/ modules needed for pipeline execution are fetched
|
|
14
|
-
* at runtime from the server (
|
|
14
|
+
* at runtime from the server (authenticated and verified before writing).
|
|
15
15
|
*/
|
|
16
16
|
import { Command } from "commander";
|
|
17
17
|
import chalk from "chalk";
|
package/dist/connection.d.ts
CHANGED
package/dist/connection.js
CHANGED
|
@@ -18,6 +18,7 @@ import { writeFileSync, mkdirSync, existsSync } from "fs";
|
|
|
18
18
|
import { dirname, join } from "path";
|
|
19
19
|
import { fileURLToPath } from "url";
|
|
20
20
|
import { tmpdir, homedir } from "os";
|
|
21
|
+
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
21
22
|
import { startLocalRelay, setActiveProject } from "./localRelay.js";
|
|
22
23
|
// ESM equivalent of __dirname — required for the rag:ingest handler to
|
|
23
24
|
// locate the bundled localRagIngest.py inside the npm package.
|
|
@@ -27,6 +28,8 @@ import { ensureSharedModules, getSharedDir } from "./sharedVendor.js";
|
|
|
27
28
|
import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
|
|
28
29
|
const HEARTBEAT_INTERVAL = 30_000;
|
|
29
30
|
const activeProcesses = new Map();
|
|
31
|
+
const activeAssistants = new Map();
|
|
32
|
+
const ASSISTANT_IDLE_MS = 15 * 60 * 1000;
|
|
30
33
|
export async function connect(opts) {
|
|
31
34
|
const spinner = ora("Connecting to Melaya...").start();
|
|
32
35
|
// Connect to the /api/v1/runner NAMESPACE on the existing Socket.IO server.
|
|
@@ -65,6 +68,10 @@ export async function connect(opts) {
|
|
|
65
68
|
spinner.succeed(chalk.green("Connected to Melaya"));
|
|
66
69
|
// Report detected models
|
|
67
70
|
socket.emit("runner:models", opts.models);
|
|
71
|
+
// Advertise capabilities so the server only routes assistant chat sessions
|
|
72
|
+
// to runners new enough to host them (older runners never get one → the
|
|
73
|
+
// server falls back with a clear "update your runner" instead of hanging).
|
|
74
|
+
socket.emit("runner:hello", { capabilities: ["assistant_session"], package: "@melaya/runner" });
|
|
68
75
|
// Start local event relay
|
|
69
76
|
if (!relay) {
|
|
70
77
|
relay = await startLocalRelay(socket, opts.verbose, opts.serverUrl);
|
|
@@ -102,13 +109,13 @@ export async function connect(opts) {
|
|
|
102
109
|
console.log(chalk.hex("#7C6FF0")(`\n ▶ Running pipeline: ${payload.pipelineName} (${payload.runId.slice(0, 10)}...)`));
|
|
103
110
|
try {
|
|
104
111
|
// Verify code signature
|
|
105
|
-
if (!verifySignature(payload.mainPyContent, payload.signature)) {
|
|
112
|
+
if (!verifySignature(payload.mainPyContent, payload.signature, opts.token)) {
|
|
106
113
|
console.log(chalk.red(" ✗ Code signature verification failed — refusing to execute"));
|
|
107
114
|
socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
|
|
108
115
|
return;
|
|
109
116
|
}
|
|
110
117
|
// Ensure shared modules are cached
|
|
111
|
-
await ensureSharedModules(opts.serverUrl, payload.sharedVersion);
|
|
118
|
+
await ensureSharedModules(opts.serverUrl, payload.sharedVersion, opts.token);
|
|
112
119
|
// Ensure the dedicated venv at ~/.melaya-runner/venv/ has
|
|
113
120
|
// agentscope + its transitive deps installed. Without this, the
|
|
114
121
|
// operator's system python3 typically doesn't have shortuuid /
|
|
@@ -535,7 +542,7 @@ export async function connect(opts) {
|
|
|
535
542
|
// `shared.runtime.trading_crew_personas` (per the codegen
|
|
536
543
|
// template's import_path) which lives in the same shared/ tree
|
|
537
544
|
// the pipeline runner uses.
|
|
538
|
-
await ensureSharedModules(opts.serverUrl, sharedVersion);
|
|
545
|
+
await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
|
|
539
546
|
// Dedicated venv with agentscope + anthropic + openai installed.
|
|
540
547
|
const { ensurePythonEnv } = await import("./pythonEnv.js");
|
|
541
548
|
const envResult = await ensurePythonEnv(opts.pythonPath, sharedVersion, (msg) => {
|
|
@@ -787,6 +794,149 @@ export async function connect(opts) {
|
|
|
787
794
|
socket.emit("runner:runComplete", { runId: payload.run_id, status: "failed" });
|
|
788
795
|
}
|
|
789
796
|
});
|
|
797
|
+
// ── Persistent Assistant chat sessions (runner-hosted models) ───────────
|
|
798
|
+
// The server drives a per-user chat session on THIS runner for claude_code /
|
|
799
|
+
// codex / ollama / lmstudio: assistant_start boots a long-lived host,
|
|
800
|
+
// assistant_turn feeds one user message, and the host streams events back as
|
|
801
|
+
// runner:assistant_event frames the server relays into the browser SSE.
|
|
802
|
+
socket.on("runner:assistant_start", async (payload) => {
|
|
803
|
+
const sid = String(payload.sessionId || "");
|
|
804
|
+
if (!sid)
|
|
805
|
+
return;
|
|
806
|
+
const emitEv = (ev) => socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", ...ev });
|
|
807
|
+
if (activeAssistants.has(sid)) {
|
|
808
|
+
emitEv({ kind: "ready" });
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
try {
|
|
812
|
+
const sharedVersion = String(payload.sharedVersion ?? "latest");
|
|
813
|
+
await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
|
|
814
|
+
const { ensurePythonEnv } = await import("./pythonEnv.js");
|
|
815
|
+
const envResult = await ensurePythonEnv(opts.pythonPath, sharedVersion, (m) => { if (opts.verbose)
|
|
816
|
+
console.log(chalk.gray(` [assistant venv] ${m}`)); });
|
|
817
|
+
if (!envResult.ok) {
|
|
818
|
+
emitEv({ kind: "error", message: `python env failed: ${envResult.reason}` });
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const { copyFileSync } = await import("fs");
|
|
822
|
+
const hostCandidates = [join(__dirname, "assistantHost.py"), join(__dirname, "..", "assistantHost.py")];
|
|
823
|
+
const hostScript = hostCandidates.find((c) => existsSync(c)) || "";
|
|
824
|
+
if (!hostScript) {
|
|
825
|
+
emitEv({ kind: "error", message: "assistantHost.py not found — reinstall @melaya/runner" });
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
// Sanitize the session id for a filesystem path — it is
|
|
829
|
+
// `userId:conversationId`, and ':' is illegal in a Windows dir name.
|
|
830
|
+
const safeSid = sid.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
831
|
+
const workDir = join(tmpdir(), `melaya-assistant-${safeSid}`);
|
|
832
|
+
mkdirSync(workDir, { recursive: true });
|
|
833
|
+
const stagedHost = join(workDir, "assistantHost.py");
|
|
834
|
+
copyFileSync(hostScript, stagedHost);
|
|
835
|
+
const sharedDir = getSharedDir();
|
|
836
|
+
const certBundle = (await import("./pythonEnv.js")).getCertBundlePath();
|
|
837
|
+
const sslEnv = certBundle ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {};
|
|
838
|
+
const httpBase = opts.serverUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
|
|
839
|
+
const env = {
|
|
840
|
+
...process.env,
|
|
841
|
+
PYTHONPATH: sharedDir,
|
|
842
|
+
PYTHONIOENCODING: "utf-8",
|
|
843
|
+
PYTHONUTF8: "1",
|
|
844
|
+
...sslEnv,
|
|
845
|
+
MEL_STUDIO_URL: httpBase,
|
|
846
|
+
MELAYA_API_BASE: httpBase,
|
|
847
|
+
MELAYA_API_URL: httpBase,
|
|
848
|
+
LMSTUDIO_BASE_URL: "http://127.0.0.1:1234",
|
|
849
|
+
OLLAMA_BASE_URL: "http://127.0.0.1:11434",
|
|
850
|
+
MEL_ASSISTANT_PROVIDER: String(payload.provider || "claude_code"),
|
|
851
|
+
MEL_ASSISTANT_MODEL: String(payload.model || ""),
|
|
852
|
+
MEL_ASSISTANT_LANGUAGE: String(payload.language || "en"),
|
|
853
|
+
// Per-user creds (incl. MELAYA_API_KEY for the tenant-gated tool bridge).
|
|
854
|
+
...(payload.credentials || {}),
|
|
855
|
+
};
|
|
856
|
+
const proc = spawn(envResult.pythonPath, ["-u", stagedHost], { cwd: workDir, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
857
|
+
const session = { proc, lastActivity: Date.now(), stdoutBuf: "" };
|
|
858
|
+
activeAssistants.set(sid, session);
|
|
859
|
+
console.log(chalk.hex("#7c6ff0")(` ◆ Assistant session ${sid.slice(0, 10)}… (${payload.provider})`));
|
|
860
|
+
proc.stdout?.on("data", (data) => {
|
|
861
|
+
session.stdoutBuf += data.toString();
|
|
862
|
+
let idx;
|
|
863
|
+
while ((idx = session.stdoutBuf.indexOf("\n")) >= 0) {
|
|
864
|
+
const line = session.stdoutBuf.slice(0, idx);
|
|
865
|
+
session.stdoutBuf = session.stdoutBuf.slice(idx + 1);
|
|
866
|
+
const t = line.trim();
|
|
867
|
+
if (t.startsWith("MELASSIST ")) {
|
|
868
|
+
try {
|
|
869
|
+
socket.emit("runner:assistant_event", { sessionId: sid, ...JSON.parse(t.slice("MELASSIST ".length)) });
|
|
870
|
+
}
|
|
871
|
+
catch { /* skip malformed */ }
|
|
872
|
+
}
|
|
873
|
+
else if (t && opts.verbose) {
|
|
874
|
+
console.log(chalk.gray(` [assistant-stdout] ${t}`));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
const stderrTail = [];
|
|
879
|
+
proc.stderr?.on("data", (data) => {
|
|
880
|
+
for (const ln of data.toString().split("\n")) {
|
|
881
|
+
const t = ln.trimEnd();
|
|
882
|
+
if (!t)
|
|
883
|
+
continue;
|
|
884
|
+
if (opts.verbose)
|
|
885
|
+
console.log(chalk.gray(` [assistant-stderr] ${t}`));
|
|
886
|
+
stderrTail.push(t);
|
|
887
|
+
if (stderrTail.length > 40)
|
|
888
|
+
stderrTail.shift();
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
proc.on("exit", (code) => {
|
|
892
|
+
activeAssistants.delete(sid);
|
|
893
|
+
const detail = code !== 0 && stderrTail.length ? stderrTail.slice(-12).join("\n") : "";
|
|
894
|
+
socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", kind: "session_closed", code, detail });
|
|
895
|
+
console.log(chalk.gray(` ■ Assistant session ${sid.slice(0, 10)}… closed (exit ${code})`));
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
catch (e) {
|
|
899
|
+
emitEv({ kind: "error", message: `assistant start failed: ${e?.message || e}` });
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
socket.on("runner:assistant_turn", (payload) => {
|
|
903
|
+
const s = activeAssistants.get(String(payload.sessionId || ""));
|
|
904
|
+
if (!s) {
|
|
905
|
+
socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: "session_not_found" });
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
s.lastActivity = Date.now();
|
|
909
|
+
try {
|
|
910
|
+
s.proc.stdin?.write(JSON.stringify({ turnId: payload.turnId, message: payload.message }) + "\n");
|
|
911
|
+
}
|
|
912
|
+
catch (e) {
|
|
913
|
+
socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: `turn write failed: ${e?.message || e}` });
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
socket.on("runner:assistant_stop", (payload) => {
|
|
917
|
+
const s = activeAssistants.get(String(payload.sessionId || ""));
|
|
918
|
+
if (s) {
|
|
919
|
+
try {
|
|
920
|
+
s.proc.kill();
|
|
921
|
+
}
|
|
922
|
+
catch { /* noop */ }
|
|
923
|
+
activeAssistants.delete(String(payload.sessionId));
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
// Idle sweep — reap assistant hosts with no turn in ASSISTANT_IDLE_MS.
|
|
927
|
+
setInterval(() => {
|
|
928
|
+
const now = Date.now();
|
|
929
|
+
for (const [sid, s] of activeAssistants) {
|
|
930
|
+
if (now - s.lastActivity > ASSISTANT_IDLE_MS) {
|
|
931
|
+
try {
|
|
932
|
+
s.proc.kill();
|
|
933
|
+
}
|
|
934
|
+
catch { /* noop */ }
|
|
935
|
+
activeAssistants.delete(sid);
|
|
936
|
+
console.log(chalk.gray(` ■ Assistant session ${sid.slice(0, 10)}… idle-swept`));
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}, 60_000);
|
|
790
940
|
// ── RAG Mode B: local-folder ingest (privacy-preserving) ─────────────
|
|
791
941
|
// The user picks "Local folder" in the Agent Builder DocsTab and types
|
|
792
942
|
// a path on their machine (e.g. C:\Users\me\Documents\refs). The server
|
|
@@ -908,7 +1058,7 @@ export async function connect(opts) {
|
|
|
908
1058
|
}
|
|
909
1059
|
// Stage the ingest script into the venv work dir so the spawned
|
|
910
1060
|
// python sees the shared.runtime + the agentscope vendored tree.
|
|
911
|
-
await ensureSharedModules(opts.serverUrl, payload.sharedVersion);
|
|
1061
|
+
await ensureSharedModules(opts.serverUrl, payload.sharedVersion, opts.token);
|
|
912
1062
|
const sharedDir = getSharedDir();
|
|
913
1063
|
const ingestScript = join(sharedDir, "..", "localRagIngest.py");
|
|
914
1064
|
// The script is published alongside the runner's shared bundle in
|
|
@@ -1109,7 +1259,7 @@ export async function connect(opts) {
|
|
|
1109
1259
|
});
|
|
1110
1260
|
return;
|
|
1111
1261
|
}
|
|
1112
|
-
await ensureSharedModules(opts.serverUrl, payload.sharedVersion);
|
|
1262
|
+
await ensureSharedModules(opts.serverUrl, payload.sharedVersion, opts.token);
|
|
1113
1263
|
const sharedDir = getSharedDir();
|
|
1114
1264
|
const workDir = join(tmpdir(), `melaya-rag-retrieve-${payload.pipelineName}-${Date.now()}`);
|
|
1115
1265
|
mkdirSync(workDir, { recursive: true });
|
|
@@ -1333,6 +1483,13 @@ export async function connect(opts) {
|
|
|
1333
1483
|
proc.kill("SIGTERM");
|
|
1334
1484
|
socket.emit("runner:runComplete", { runId: id, status: "failed" });
|
|
1335
1485
|
}
|
|
1486
|
+
for (const [, s] of activeAssistants) {
|
|
1487
|
+
try {
|
|
1488
|
+
s.proc.kill("SIGTERM");
|
|
1489
|
+
}
|
|
1490
|
+
catch { /* noop */ }
|
|
1491
|
+
}
|
|
1492
|
+
activeAssistants.clear();
|
|
1336
1493
|
relay?.close();
|
|
1337
1494
|
// Best-effort Chromium teardown. The bridge's shutdown is async but
|
|
1338
1495
|
// we can't await inside a SIGINT handler — fire and forget; the
|
|
@@ -1346,14 +1503,11 @@ export async function connect(opts) {
|
|
|
1346
1503
|
process.on("SIGINT", cleanup);
|
|
1347
1504
|
process.on("SIGTERM", cleanup);
|
|
1348
1505
|
}
|
|
1349
|
-
function verifySignature(content, signature) {
|
|
1350
|
-
|
|
1351
|
-
// The runner doesn't have the secret — but we verify the signature is
|
|
1352
|
-
// present and non-empty. A future version will use a pre-shared public key
|
|
1353
|
-
// for full client-side verification. For now, the signature's presence
|
|
1354
|
-
// proves the payload passed through the server (not injected by MITM).
|
|
1355
|
-
if (!signature || signature.length < 32) {
|
|
1506
|
+
export function verifySignature(content, signature, runnerToken) {
|
|
1507
|
+
if (!content || !signature || !runnerToken)
|
|
1356
1508
|
return false;
|
|
1357
|
-
|
|
1358
|
-
|
|
1509
|
+
const signingKey = createHash("sha256").update(runnerToken).digest();
|
|
1510
|
+
const expected = createHmac("sha256", signingKey).update(content).digest();
|
|
1511
|
+
const received = Buffer.from(signature, "hex");
|
|
1512
|
+
return received.length === expected.length && timingSafeEqual(received, expected);
|
|
1359
1513
|
}
|
package/dist/pythonEnv.js
CHANGED
|
@@ -117,6 +117,16 @@ const PIP_DEPS = [
|
|
|
117
117
|
// tool module cleanly — this just makes the dep present so the yf_ tools work
|
|
118
118
|
// when attached. Pulls pandas transitively.
|
|
119
119
|
"yfinance",
|
|
120
|
+
// ── Template-validation sweep deps (2026-07-06) — tools that hard-error
|
|
121
|
+
// "pip install X" on the runner without these, observed in live runs:
|
|
122
|
+
// yt-dlp — youtube_search_channel / youtube_get_transcript (1221 YouTube Intel)
|
|
123
|
+
// sentence-transformers — build_knowledge_from_text embedder (1221)
|
|
124
|
+
// flights — flight_price_alert / flight_search live Google Flights ("fli" import; 1261/1265)
|
|
125
|
+
// pymupdf4llm — pdf_to_text markdown extraction (1227 PDF Contract Analyser)
|
|
126
|
+
"yt-dlp",
|
|
127
|
+
"sentence-transformers",
|
|
128
|
+
"flights",
|
|
129
|
+
"pymupdf4llm",
|
|
120
130
|
// ── Vector RAG (retrieval mode) ────────────────────────────────────────
|
|
121
131
|
// ollama — Python client used by agentscope.embedding.OllamaTextEmbedding.
|
|
122
132
|
// Talks to localhost:11434 (the user's local Ollama daemon) to embed
|
package/dist/sharedVendor.d.ts
CHANGED
|
@@ -8,4 +8,4 @@
|
|
|
8
8
|
* integrity-verified via SHA-256, and cached at ~/.melaya-runner/shared/.
|
|
9
9
|
*/
|
|
10
10
|
export declare function getSharedDir(): string;
|
|
11
|
-
export declare function ensureSharedModules(serverUrl: string, expectedVersion: string): Promise<void>;
|
|
11
|
+
export declare function ensureSharedModules(serverUrl: string, expectedVersion: string, runnerToken: string): Promise<void>;
|
package/dist/sharedVendor.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "fs";
|
|
11
11
|
import { join } from "path";
|
|
12
12
|
import { homedir } from "os";
|
|
13
|
-
import { createHash } from "crypto";
|
|
13
|
+
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
14
14
|
const CACHE_DIR = join(homedir(), ".melaya-runner");
|
|
15
15
|
const SHARED_DIR = join(CACHE_DIR, "shared");
|
|
16
16
|
const VERSION_FILE = join(CACHE_DIR, "shared-version.txt");
|
|
@@ -35,7 +35,7 @@ const CANARY_FILES = [
|
|
|
35
35
|
function cacheLooksValid() {
|
|
36
36
|
return CANARY_FILES.every((p) => existsSync(p));
|
|
37
37
|
}
|
|
38
|
-
export async function ensureSharedModules(serverUrl, expectedVersion) {
|
|
38
|
+
export async function ensureSharedModules(serverUrl, expectedVersion, runnerToken) {
|
|
39
39
|
mkdirSync(CACHE_DIR, { recursive: true });
|
|
40
40
|
// Check cached version. We require BOTH a version match AND a valid nested
|
|
41
41
|
// layout — older runner versions wrote flat filenames into shared/, and
|
|
@@ -57,7 +57,10 @@ export async function ensureSharedModules(serverUrl, expectedVersion) {
|
|
|
57
57
|
// Fetch from server
|
|
58
58
|
const httpUrl = serverUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
|
|
59
59
|
const url = `${httpUrl}/api/v1/runner/shared-bundle?v=${expectedVersion}`;
|
|
60
|
-
const res = await fetch(url, {
|
|
60
|
+
const res = await fetch(url, {
|
|
61
|
+
headers: { Authorization: `Bearer ${runnerToken}` },
|
|
62
|
+
signal: AbortSignal.timeout(30_000),
|
|
63
|
+
});
|
|
61
64
|
if (!res.ok) {
|
|
62
65
|
throw new Error(`Failed to fetch shared modules: HTTP ${res.status}`);
|
|
63
66
|
}
|
|
@@ -67,6 +70,12 @@ export async function ensureSharedModules(serverUrl, expectedVersion) {
|
|
|
67
70
|
if (computed !== body.hash) {
|
|
68
71
|
throw new Error("Shared module integrity check failed — hash mismatch");
|
|
69
72
|
}
|
|
73
|
+
const signingKey = createHash("sha256").update(runnerToken).digest();
|
|
74
|
+
const expectedSignature = createHmac("sha256", signingKey).update(body.hash).digest();
|
|
75
|
+
const receivedSignature = Buffer.from(body.signature || "", "hex");
|
|
76
|
+
if (receivedSignature.length !== expectedSignature.length || !timingSafeEqual(receivedSignature, expectedSignature)) {
|
|
77
|
+
throw new Error("Shared module authenticity check failed: signature mismatch");
|
|
78
|
+
}
|
|
70
79
|
// Write files (sanitize filenames to prevent path traversal)
|
|
71
80
|
const writtenDirs = new Set();
|
|
72
81
|
for (const [filename, content] of Object.entries(body.files)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@melaya/runner",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.78",
|
|
4
4
|
"description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"README.md"
|
|
20
20
|
],
|
|
21
21
|
"scripts": {
|
|
22
|
-
"build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py')\"",
|
|
22
|
+
"build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
|
|
23
23
|
"prepublishOnly": "npm run build"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|