@melaya/runner 1.0.36 → 1.0.38

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.
@@ -15,9 +15,14 @@ import chalk from "chalk";
15
15
  import ora from "ora";
16
16
  import { spawn } from "child_process";
17
17
  import { writeFileSync, mkdirSync } from "fs";
18
- import { join } from "path";
19
- import { tmpdir } from "os";
18
+ import { dirname, join } from "path";
19
+ import { fileURLToPath } from "url";
20
+ import { tmpdir, homedir } from "os";
20
21
  import { startLocalRelay, setActiveProject } from "./localRelay.js";
22
+ // ESM equivalent of __dirname — required for the rag:ingest handler to
23
+ // locate the bundled localRagIngest.py inside the npm package.
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = dirname(__filename);
21
26
  import { ensureSharedModules, getSharedDir } from "./sharedVendor.js";
22
27
  import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
23
28
  const HEARTBEAT_INTERVAL = 30_000;
@@ -374,6 +379,211 @@ export async function connect(opts) {
374
379
  socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
375
380
  }
376
381
  });
382
+ // ── RAG Mode B: local-folder ingest (privacy-preserving) ─────────────
383
+ // The user picks "Local folder" in the Agent Builder DocsTab and types
384
+ // a path on their machine (e.g. C:\Users\me\Documents\refs). The server
385
+ // routes the ingest to this handler. The runner reads the files locally,
386
+ // embeds via local Ollama, and persists the Qdrant store at
387
+ // ~/.melaya-runner/rag/<pipelineName>/store/. The text NEVER leaves the
388
+ // user's box — that's the entire point of Mode B.
389
+ //
390
+ // Hard refusal: if embedderProvider is "openai", we refuse with a clear
391
+ // error message. Cloud embedders would defeat the data-residency
392
+ // guarantee. Mode B = local-only embedder, period.
393
+ // ── Native folder picker ──────────────────────────────────────────────
394
+ // FE asks the runner to pop the OS-native folder dialog so the user can
395
+ // pick a path WITHOUT having to type it. Browsers can't reveal absolute
396
+ // paths for security, so the runner — which lives on the user's box —
397
+ // does it. Mac: osascript. Windows: PowerShell WinForms. Linux: zenity.
398
+ socket.on("runner:pick-folder", async (payload) => {
399
+ const sid = payload?.sessionId;
400
+ const reply = (ok, path, error) => {
401
+ socket.emit("runner:pick-folder-result", { session_id: sid, ok, path, error });
402
+ };
403
+ try {
404
+ const { execFile } = await import("child_process");
405
+ const platform = process.platform;
406
+ const run = (cmd, args) => new Promise((resolve, reject) => {
407
+ execFile(cmd, args, { maxBuffer: 1024 * 64 }, (err, stdout, stderr) => {
408
+ if (err) {
409
+ const out = (stdout || "").trim();
410
+ const errMsg = (stderr || err.message || "").trim();
411
+ // osascript returns code 1 when user cancels — empty stdout
412
+ if (!out && /User canceled|cancelled/i.test(errMsg))
413
+ return resolve("");
414
+ return reject(new Error(errMsg || "pick failed"));
415
+ }
416
+ resolve((stdout || "").trim());
417
+ });
418
+ });
419
+ let picked = "";
420
+ if (platform === "darwin") {
421
+ // osascript returns "Macintosh HD:Users:me:Documents" — convert to POSIX
422
+ const raw = await run("osascript", [
423
+ "-e", 'tell application "System Events"',
424
+ "-e", "activate",
425
+ "-e", "set p to choose folder with prompt \"Select your reference docs folder\"",
426
+ "-e", "end tell",
427
+ "-e", "return POSIX path of p",
428
+ ]);
429
+ picked = raw.replace(/\/$/, "");
430
+ }
431
+ else if (platform === "win32") {
432
+ const ps = [
433
+ "Add-Type -AssemblyName System.Windows.Forms;",
434
+ "$f = New-Object System.Windows.Forms.FolderBrowserDialog;",
435
+ "$f.Description = 'Select your reference docs folder';",
436
+ "$f.ShowNewFolderButton = $true;",
437
+ "if ($f.ShowDialog() -eq 'OK') { [Console]::Out.Write($f.SelectedPath) }",
438
+ ].join(" ");
439
+ picked = await run("powershell", ["-NoProfile", "-STA", "-Command", ps]);
440
+ }
441
+ else {
442
+ // Linux — try zenity, fall back to error if missing
443
+ picked = await run("zenity", [
444
+ "--file-selection", "--directory",
445
+ "--title=Select your reference docs folder",
446
+ ]);
447
+ }
448
+ if (!picked)
449
+ return reply(false, null, "cancelled");
450
+ reply(true, picked);
451
+ }
452
+ catch (e) {
453
+ const msg = String(e?.message || "pick failed");
454
+ const hint = /ENOENT|not found|spawn/i.test(msg) && process.platform === "linux"
455
+ ? "Install 'zenity' (apt install zenity) or type the path manually."
456
+ : "";
457
+ reply(false, null, hint ? `${msg} — ${hint}` : msg);
458
+ }
459
+ });
460
+ socket.on("rag:ingest", async (payload) => {
461
+ const sid = payload?.sessionId;
462
+ if (!sid)
463
+ return;
464
+ const emitProgress = (kind, fields = {}) => {
465
+ socket.emit("rag:ingest-progress", { session_id: sid, kind, ...fields });
466
+ };
467
+ try {
468
+ const prov = (payload.embedderProvider || "").toLowerCase();
469
+ if (prov === "openai") {
470
+ const reason = ("Mode B (local folder) refuses cloud embedders. " +
471
+ "Pick ollama or lmstudio to keep your documents private.");
472
+ console.log(chalk.red(` ✗ rag:ingest refused: ${reason}`));
473
+ socket.emit("rag:ingest-result", {
474
+ session_id: sid, ok: false, error: reason,
475
+ });
476
+ return;
477
+ }
478
+ console.log(chalk.hex("#10B981")(`\n 🔒 RAG local-folder ingest: ${payload.pipelineName} ` +
479
+ `← ${payload.localFolderPath} via ${prov}:${payload.embedderModel}\n`));
480
+ emitProgress("started");
481
+ // Reuse the same venv as runner:run — agentscope.rag + ollama +
482
+ // qdrant-client land here.
483
+ const { ensurePythonEnv } = await import("./pythonEnv.js");
484
+ const env = await ensurePythonEnv(opts.pythonPath, payload.sharedVersion, (m) => emitProgress("venv", { message: m }));
485
+ if (!env.ok) {
486
+ emitProgress("error", { error: `venv bootstrap failed: ${env.reason}` });
487
+ socket.emit("rag:ingest-result", {
488
+ session_id: sid, ok: false, error: `venv bootstrap failed: ${env.reason}`,
489
+ });
490
+ return;
491
+ }
492
+ // Stage the ingest script into the venv work dir so the spawned
493
+ // python sees the shared.runtime + the agentscope vendored tree.
494
+ await ensureSharedModules(opts.serverUrl, payload.sharedVersion);
495
+ const sharedDir = getSharedDir();
496
+ const ingestScript = join(sharedDir, "..", "localRagIngest.py");
497
+ // The script is published alongside the runner's shared bundle in
498
+ // the npm package itself — we copy it to a tmp dir per-invocation
499
+ // so re-runs are clean.
500
+ const workDir = join(tmpdir(), `melaya-rag-ingest-${payload.pipelineName}-${Date.now()}`);
501
+ mkdirSync(workDir, { recursive: true });
502
+ const { copyFileSync, existsSync } = await import("fs");
503
+ // Fallback path: when running from npm package layout
504
+ const candidates = [
505
+ ingestScript,
506
+ join(__dirname, "localRagIngest.py"),
507
+ join(__dirname, "..", "localRagIngest.py"),
508
+ ];
509
+ let foundIngest = "";
510
+ for (const c of candidates) {
511
+ if (existsSync(c)) {
512
+ foundIngest = c;
513
+ break;
514
+ }
515
+ }
516
+ if (!foundIngest) {
517
+ const msg = "localRagIngest.py not found in runner package — reinstall @melaya/runner";
518
+ emitProgress("error", { error: msg });
519
+ socket.emit("rag:ingest-result", {
520
+ session_id: sid, ok: false, error: msg,
521
+ });
522
+ return;
523
+ }
524
+ const stagedScript = join(workDir, "localRagIngest.py");
525
+ copyFileSync(foundIngest, stagedScript);
526
+ // Resolve the on-disk path where the store will live. The Python
527
+ // script can compute this itself from pipelineName, but we surface
528
+ // it here for the FE event so the user can see where their data
529
+ // lives.
530
+ const storePath = join(homedir(), ".melaya-runner", "rag", payload.pipelineName, "store");
531
+ console.log(chalk.gray(` [rag] store → ${storePath}`));
532
+ const proc = spawn(env.pythonPath, [
533
+ "-u", stagedScript,
534
+ "--pipeline-name", payload.pipelineName,
535
+ "--folder", payload.localFolderPath,
536
+ "--embedder-provider", prov,
537
+ "--embedder-model", payload.embedderModel,
538
+ ], {
539
+ env: {
540
+ ...process.env,
541
+ PYTHONPATH: sharedDir, // so `from agentscope.rag import …` resolves
542
+ },
543
+ cwd: workDir,
544
+ });
545
+ let stdoutBuf = "";
546
+ let stderrBuf = "";
547
+ proc.stdout.on("data", (data) => {
548
+ stdoutBuf += data.toString("utf-8");
549
+ // Stream each "progress: ..." JSON line up to the FE
550
+ const lines = stdoutBuf.split("\n");
551
+ stdoutBuf = lines.pop() || "";
552
+ for (const line of lines) {
553
+ if (line.startsWith("progress: ")) {
554
+ try {
555
+ const ev = JSON.parse(line.slice("progress: ".length));
556
+ emitProgress("step", ev);
557
+ }
558
+ catch { /* ignore malformed */ }
559
+ }
560
+ else if (opts.verbose && line.trim()) {
561
+ console.log(chalk.gray(` [ingest] ${line}`));
562
+ }
563
+ }
564
+ });
565
+ proc.stderr.on("data", (data) => {
566
+ stderrBuf += data.toString("utf-8");
567
+ });
568
+ proc.on("close", (code) => {
569
+ const ok = code === 0;
570
+ console.log(ok
571
+ ? chalk.green(` ✓ RAG ingest complete (exit ${code})`)
572
+ : chalk.red(` ✗ RAG ingest failed (exit ${code})\n${stderrBuf.slice(-1000)}`));
573
+ socket.emit("rag:ingest-result", {
574
+ session_id: sid, ok, exit: code,
575
+ store_path: storePath,
576
+ stderr_tail: ok ? "" : stderrBuf.slice(-2000),
577
+ });
578
+ });
579
+ }
580
+ catch (e) {
581
+ console.log(chalk.red(` ✗ rag:ingest crashed: ${e?.message || e}`));
582
+ socket.emit("rag:ingest-result", {
583
+ session_id: sid, ok: false, error: e?.message || String(e),
584
+ });
585
+ }
586
+ });
377
587
  // ── LinkedIn cookie capture (one-time) ────────────────────────────
378
588
  // When the user clicks "Connect LinkedIn" in the platform UI, the
379
589
  // server emits this event. We launch a controlled Chromium window on
@@ -0,0 +1,239 @@
1
+ """Local-folder RAG ingest (Mode B) — runs on the USER'S machine via the
2
+ @melaya/runner subprocess. Reads docs from a local folder, embeds via the
3
+ user's local Ollama (or LM Studio), persists the Qdrant vector store at
4
+ `~/.melaya-runner/rag/<pipeline_name>/store/`.
5
+
6
+ Data residency guarantee:
7
+ * Document text is NEVER uploaded to the prod box
8
+ * Embedding API calls go to localhost (user's Ollama at :11434)
9
+ * Vector store sits at ~/.melaya-runner/... on the user's filesystem
10
+ * The only network egress is the Socket.IO progress events
11
+ (file names + chunk counts only, NO chunk text)
12
+
13
+ Invoked by packages/runner/src/connection.ts on `rag:ingest` Socket.IO event.
14
+ Emits `progress: <json>` lines to stdout that the parent process forwards to
15
+ the FE so the user sees per-file ingest progress in real time.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import asyncio
21
+ import datetime as _dt
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import sys
26
+ import time
27
+ from pathlib import Path
28
+
29
+ # Path hack — the runner's spawned venv has agentscope vendored at the
30
+ # PYTHONPATH it sets (the parent connection.ts sets env.PYTHONPATH to the
31
+ # shared bundle dir). Confirm imports work, fail loud otherwise.
32
+ try:
33
+ from agentscope.rag import ( # noqa: F401
34
+ TextReader, PDFReader, WordReader, PowerPointReader, ExcelReader,
35
+ )
36
+ except ImportError as exc:
37
+ print(f"ImportError: agentscope.rag not reachable on PYTHONPATH={os.environ.get('PYTHONPATH')!r}: {exc}", file=sys.stderr)
38
+ sys.exit(2)
39
+
40
+
41
+ def _emit(kind: str, **fields):
42
+ """Stream a progress event to the runner subprocess parent which
43
+ forwards it to the FE over Socket.IO."""
44
+ payload = {"kind": kind, **fields, "ts": _dt.datetime.utcnow().isoformat() + "Z"}
45
+ print("progress: " + json.dumps(payload), flush=True)
46
+
47
+
48
+ SUPPORTED_EXTS = {".txt", ".md", ".csv", ".json", ".pdf",
49
+ ".docx", ".pptx", ".xlsx"}
50
+
51
+
52
+ def _reader_for(ext: str):
53
+ """Map extension to agentscope reader. Returns None when no extractor
54
+ exists (legacy .doc, image-only PDFs that need OCR, etc.)."""
55
+ return {
56
+ ".txt": TextReader, ".md": TextReader,
57
+ ".csv": TextReader, ".json": TextReader,
58
+ ".pdf": PDFReader,
59
+ ".docx": WordReader,
60
+ ".pptx": PowerPointReader,
61
+ ".xlsx": ExcelReader,
62
+ }.get(ext)
63
+
64
+
65
+ def _resolve_store_path(pipeline_name: str) -> Path:
66
+ """Mode B store path — under ~/.melaya-runner/rag/<pipeline>/store/.
67
+ Matches the server-side facade's resolve_store_path(mode="local_folder")
68
+ so cross-runtime debugging is consistent."""
69
+ return Path.home() / ".melaya-runner" / "rag" / pipeline_name / "store"
70
+
71
+
72
+ def _resolve_manifest_path(pipeline_name: str) -> Path:
73
+ return _resolve_store_path(pipeline_name).parent / ".ingest.json"
74
+
75
+
76
+ async def _ingest(args) -> int:
77
+ folder = Path(args.folder).expanduser()
78
+ if not folder.exists() or not folder.is_dir():
79
+ _emit("error", reason=f"folder not found or not a directory: {folder}")
80
+ return 3
81
+
82
+ # Build the facade-equivalent: pick the embedder, instantiate Qdrant.
83
+ # We import lazily so a missing dep produces a clear error message.
84
+ try:
85
+ if args.embedder_provider == "ollama":
86
+ from agentscope.embedding import OllamaTextEmbedding
87
+ # Centralized dimension lookup — same as the server-side facade
88
+ DIMS = {
89
+ "nomic-embed-text": 768, "nomic-embed-text:v1.5": 768,
90
+ "mxbai-embed-large": 1024, "all-minilm": 384,
91
+ "snowflake-arctic-embed": 1024,
92
+ }
93
+ dim = DIMS.get(args.embedder_model.lower())
94
+ if dim is None:
95
+ _emit("error", reason=(
96
+ f"unknown ollama embedding model '{args.embedder_model}' — "
97
+ f"supported: {sorted(DIMS)}"
98
+ ))
99
+ return 4
100
+ embedder = OllamaTextEmbedding(
101
+ model_name=args.embedder_model,
102
+ dimensions=dim,
103
+ host=None, # → uses default localhost:11434
104
+ )
105
+ elif args.embedder_provider == "lmstudio":
106
+ from agentscope.embedding import OpenAITextEmbedding
107
+ # LM Studio exposes an OpenAI-compatible /v1; reuse the OpenAI
108
+ # client pointed at its local endpoint. Dim default = 768 (the
109
+ # FE picker should report the real dim per loaded model).
110
+ embedder = OpenAITextEmbedding(
111
+ api_key="lmstudio",
112
+ model_name=args.embedder_model,
113
+ dimensions=768,
114
+ )
115
+ embedder.client.base_url = "http://localhost:1234/v1"
116
+ else:
117
+ _emit("error", reason=(
118
+ f"refused embedder provider '{args.embedder_provider}' for Mode B "
119
+ "— only ollama and lmstudio keep documents private"
120
+ ))
121
+ return 5
122
+
123
+ from agentscope.rag import QdrantStore, SimpleKnowledge
124
+ store_path = _resolve_store_path(args.pipeline_name)
125
+ store_path.mkdir(parents=True, exist_ok=True)
126
+ store = QdrantStore(
127
+ location=str(store_path),
128
+ collection_name=args.pipeline_name,
129
+ dimensions=embedder.dimensions,
130
+ distance="Cosine",
131
+ )
132
+ kb = SimpleKnowledge(embedding_store=store, embedding_model=embedder)
133
+ except Exception as exc:
134
+ _emit("error", reason=f"failed to build KB: {type(exc).__name__}: {exc}")
135
+ return 6
136
+
137
+ # Read the manifest to skip unchanged files
138
+ manifest_path = _resolve_manifest_path(args.pipeline_name)
139
+ if manifest_path.exists():
140
+ try:
141
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
142
+ except Exception:
143
+ manifest = {"files": {}, "embedder": None}
144
+ else:
145
+ manifest = {"files": {}, "embedder": None}
146
+ stored = manifest.get("embedder") or {}
147
+ embedder_changed = (
148
+ stored.get("provider") != args.embedder_provider
149
+ or stored.get("model") != args.embedder_model
150
+ )
151
+ if embedder_changed:
152
+ _emit("embedder_changed", was=stored, now={
153
+ "provider": args.embedder_provider, "model": args.embedder_model,
154
+ })
155
+
156
+ started = time.time()
157
+ chunks_added = 0
158
+ files_processed = 0
159
+ skipped = 0
160
+ files_failed = []
161
+
162
+ # Walk the folder — only top-level + one level deep. Going arbitrarily
163
+ # deep would silently include things like venv/.git which would be
164
+ # surprising. If someone needs recursive, they can manually flatten.
165
+ candidates = [p for p in sorted(folder.iterdir()) if p.is_file()]
166
+ candidates += [p for sub in folder.iterdir() if sub.is_dir() and not sub.name.startswith(".")
167
+ for p in sorted(sub.iterdir()) if p.is_file()]
168
+
169
+ total = sum(1 for p in candidates if p.suffix.lower() in SUPPORTED_EXTS)
170
+ _emit("scanned", folder=str(folder), total_supported=total)
171
+
172
+ for f in candidates:
173
+ ext = f.suffix.lower()
174
+ if ext not in SUPPORTED_EXTS:
175
+ continue
176
+ try:
177
+ raw = f.read_bytes()
178
+ except Exception as exc:
179
+ files_failed.append({"file": str(f), "error": f"read: {type(exc).__name__}: {exc}"})
180
+ continue
181
+ sha = hashlib.sha256(raw).hexdigest()
182
+ prev = manifest["files"].get(f.name, {})
183
+ if (not embedder_changed) and prev.get("sha256") == sha:
184
+ skipped += 1
185
+ _emit("skipped_sha_clean", file=f.name)
186
+ continue
187
+ reader_cls = _reader_for(ext)
188
+ if reader_cls is None:
189
+ files_failed.append({"file": str(f), "error": "no extractor"})
190
+ continue
191
+ try:
192
+ reader = reader_cls()
193
+ docs = await reader(str(f))
194
+ for doc in docs:
195
+ if hasattr(doc.metadata, "doc_id"):
196
+ doc.metadata.doc_id = f.name
197
+ await kb.add_documents(docs)
198
+ chunks_added += len(docs)
199
+ manifest["files"][f.name] = {
200
+ "sha256": sha, "chunks": len(docs),
201
+ "ingested_at": _dt.datetime.utcnow().isoformat() + "Z",
202
+ }
203
+ files_processed += 1
204
+ _emit("file_done", file=f.name, chunks=len(docs))
205
+ except Exception as exc:
206
+ files_failed.append({
207
+ "file": str(f), "error": f"{type(exc).__name__}: {exc}",
208
+ })
209
+ _emit("file_failed", file=f.name, error=str(exc))
210
+
211
+ manifest["embedder"] = {
212
+ "provider": args.embedder_provider, "model": args.embedder_model,
213
+ }
214
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
215
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
216
+
217
+ elapsed_ms = int((time.time() - started) * 1000)
218
+ _emit("done",
219
+ files_processed=files_processed,
220
+ chunks_added=chunks_added,
221
+ skipped=skipped,
222
+ embedder_changed=embedder_changed,
223
+ elapsed_ms=elapsed_ms,
224
+ errors=files_failed)
225
+ return 0
226
+
227
+
228
+ def main() -> None:
229
+ ap = argparse.ArgumentParser()
230
+ ap.add_argument("--pipeline-name", required=True)
231
+ ap.add_argument("--folder", required=True)
232
+ ap.add_argument("--embedder-provider", required=True, choices=["ollama", "lmstudio"])
233
+ ap.add_argument("--embedder-model", required=True)
234
+ args = ap.parse_args()
235
+ sys.exit(asyncio.run(_ingest(args)))
236
+
237
+
238
+ if __name__ == "__main__":
239
+ main()
@@ -382,42 +382,38 @@ export async function preflightModel(configJson, onProgress = (m) => console.log
382
382
  catch {
383
383
  return { ok: true }; /* let main.py error naturally */
384
384
  }
385
- // Pipeline-level + per-step / per-agent providers match the same
386
- // walk as builder_server.py:2716-2725 so we honour the agent that
387
- // overrides the top-level pick.
388
- const providers = new Set();
389
- const models = new Set();
390
- const top = String(cfg.model_provider ?? cfg.model?.provider ?? "").toLowerCase();
391
- if (top)
392
- providers.add(top);
393
- const topModel = String(cfg.model_name ?? cfg.model?.name ?? "");
394
- if (topModel)
395
- models.add(topModel);
385
+ // Pipeline-level + per-step / per-agent (provider, model) PAIRS must
386
+ // stay paired so a melaya_ai / openai / anthropic agent isn't checked
387
+ // against LM Studio just because a different agent in the same pipeline
388
+ // happens to use LM Studio. Mixed-provider pipelines were preflighting
389
+ // every cloud model against every local runner → spurious 'not found
390
+ // in LM Studio' failures.
391
+ const pairs = new Set(); // serialized "provider|model" for dedup
392
+ const addPair = (p, m) => {
393
+ if (p && m)
394
+ pairs.add(`${p.toLowerCase()}|${m}`);
395
+ };
396
+ addPair(String(cfg.model_provider ?? cfg.model?.provider ?? ""), String(cfg.model_name ?? cfg.model?.name ?? ""));
396
397
  for (const step of (cfg.steps ?? [])) {
397
398
  const a = step?.agent ?? {};
398
- const p = String(a?.model?.provider ?? "").toLowerCase();
399
- const n = String(a?.model?.name ?? "");
400
- if (p)
401
- providers.add(p);
402
- if (n)
403
- models.add(n);
399
+ addPair(String(a?.model?.provider ?? a?.model_provider ?? ""), String(a?.model?.name ?? a?.model_name ?? ""));
404
400
  }
405
401
  for (const a of (cfg.agents ?? [])) {
406
- const p = String(a?.model_provider ?? a?.model?.provider ?? "").toLowerCase();
407
- const n = String(a?.model_name ?? a?.model?.name ?? "");
408
- if (p)
409
- providers.add(p);
410
- if (n)
411
- models.add(n);
402
+ // Nested wins over flat the ModelPicker writes `agent.model`
403
+ // but doesn't always purge the legacy `agent.model_provider` /
404
+ // `agent.model_name` fields, so they drift stale across edits.
405
+ addPair(String(a?.model?.provider ?? a?.model_provider ?? ""), String(a?.model?.name ?? a?.model_name ?? ""));
412
406
  }
413
- // Run preflight for every distinct (provider, model) combo this
414
- // pipeline will exercise. One bad agent = whole run fails fast.
407
+ // Run preflight only for LOCAL providers — cloud (openai / anthropic /
408
+ // claude_code / codex / melaya_ai / zhipu) doesn't need the runner.
415
409
  const tasks = [];
416
- for (const m of models) {
417
- if (providers.has("lmstudio"))
418
- tasks.push(preflightLmStudio(m, onProgress));
419
- if (providers.has("ollama"))
420
- tasks.push(preflightOllama(m, onProgress));
410
+ for (const pair of pairs) {
411
+ const [provider, model] = pair.split("|");
412
+ if (provider === "lmstudio")
413
+ tasks.push(preflightLmStudio(model, onProgress));
414
+ else if (provider === "ollama")
415
+ tasks.push(preflightOllama(model, onProgress));
416
+ // every other provider is cloud-only — no preflight needed.
421
417
  }
422
418
  if (tasks.length === 0)
423
419
  return { ok: true };
package/dist/pythonEnv.js CHANGED
@@ -96,6 +96,18 @@ const PIP_DEPS = [
96
96
  // shared.tools.telegram_user_tools. The bot-API path in
97
97
  // shared.tools.messaging.py uses plain HTTP and needs no extra dep.
98
98
  "telethon",
99
+ // ── Vector RAG (retrieval mode) ────────────────────────────────────────
100
+ // ollama — Python client used by agentscope.embedding.OllamaTextEmbedding.
101
+ // Talks to localhost:11434 (the user's local Ollama daemon) to embed
102
+ // chunks without exfiltrating the underlying text to a cloud API. This
103
+ // is the only embedder allowed in privacy-preserving Mode B.
104
+ //
105
+ // (qdrant-client is ALREADY in this list above — it provides the
106
+ // embedded Qdrant store via agentscope.rag._store.QdrantStore at the
107
+ // ~/.melaya-runner/rag/<pipeline_name>/store/ path. Path-based Qdrant
108
+ // ships Windows wheels, MilvusLite does not — using Qdrant for OS
109
+ // parity across runners.)
110
+ "ollama",
99
111
  ];
100
112
  export function venvPython() {
101
113
  return platform() === "win32"
@@ -0,0 +1,239 @@
1
+ """Local-folder RAG ingest (Mode B) — runs on the USER'S machine via the
2
+ @melaya/runner subprocess. Reads docs from a local folder, embeds via the
3
+ user's local Ollama (or LM Studio), persists the Qdrant vector store at
4
+ `~/.melaya-runner/rag/<pipeline_name>/store/`.
5
+
6
+ Data residency guarantee:
7
+ * Document text is NEVER uploaded to the prod box
8
+ * Embedding API calls go to localhost (user's Ollama at :11434)
9
+ * Vector store sits at ~/.melaya-runner/... on the user's filesystem
10
+ * The only network egress is the Socket.IO progress events
11
+ (file names + chunk counts only, NO chunk text)
12
+
13
+ Invoked by packages/runner/src/connection.ts on `rag:ingest` Socket.IO event.
14
+ Emits `progress: <json>` lines to stdout that the parent process forwards to
15
+ the FE so the user sees per-file ingest progress in real time.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import asyncio
21
+ import datetime as _dt
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import sys
26
+ import time
27
+ from pathlib import Path
28
+
29
+ # Path hack — the runner's spawned venv has agentscope vendored at the
30
+ # PYTHONPATH it sets (the parent connection.ts sets env.PYTHONPATH to the
31
+ # shared bundle dir). Confirm imports work, fail loud otherwise.
32
+ try:
33
+ from agentscope.rag import ( # noqa: F401
34
+ TextReader, PDFReader, WordReader, PowerPointReader, ExcelReader,
35
+ )
36
+ except ImportError as exc:
37
+ print(f"ImportError: agentscope.rag not reachable on PYTHONPATH={os.environ.get('PYTHONPATH')!r}: {exc}", file=sys.stderr)
38
+ sys.exit(2)
39
+
40
+
41
+ def _emit(kind: str, **fields):
42
+ """Stream a progress event to the runner subprocess parent which
43
+ forwards it to the FE over Socket.IO."""
44
+ payload = {"kind": kind, **fields, "ts": _dt.datetime.utcnow().isoformat() + "Z"}
45
+ print("progress: " + json.dumps(payload), flush=True)
46
+
47
+
48
+ SUPPORTED_EXTS = {".txt", ".md", ".csv", ".json", ".pdf",
49
+ ".docx", ".pptx", ".xlsx"}
50
+
51
+
52
+ def _reader_for(ext: str):
53
+ """Map extension to agentscope reader. Returns None when no extractor
54
+ exists (legacy .doc, image-only PDFs that need OCR, etc.)."""
55
+ return {
56
+ ".txt": TextReader, ".md": TextReader,
57
+ ".csv": TextReader, ".json": TextReader,
58
+ ".pdf": PDFReader,
59
+ ".docx": WordReader,
60
+ ".pptx": PowerPointReader,
61
+ ".xlsx": ExcelReader,
62
+ }.get(ext)
63
+
64
+
65
+ def _resolve_store_path(pipeline_name: str) -> Path:
66
+ """Mode B store path — under ~/.melaya-runner/rag/<pipeline>/store/.
67
+ Matches the server-side facade's resolve_store_path(mode="local_folder")
68
+ so cross-runtime debugging is consistent."""
69
+ return Path.home() / ".melaya-runner" / "rag" / pipeline_name / "store"
70
+
71
+
72
+ def _resolve_manifest_path(pipeline_name: str) -> Path:
73
+ return _resolve_store_path(pipeline_name).parent / ".ingest.json"
74
+
75
+
76
+ async def _ingest(args) -> int:
77
+ folder = Path(args.folder).expanduser()
78
+ if not folder.exists() or not folder.is_dir():
79
+ _emit("error", reason=f"folder not found or not a directory: {folder}")
80
+ return 3
81
+
82
+ # Build the facade-equivalent: pick the embedder, instantiate Qdrant.
83
+ # We import lazily so a missing dep produces a clear error message.
84
+ try:
85
+ if args.embedder_provider == "ollama":
86
+ from agentscope.embedding import OllamaTextEmbedding
87
+ # Centralized dimension lookup — same as the server-side facade
88
+ DIMS = {
89
+ "nomic-embed-text": 768, "nomic-embed-text:v1.5": 768,
90
+ "mxbai-embed-large": 1024, "all-minilm": 384,
91
+ "snowflake-arctic-embed": 1024,
92
+ }
93
+ dim = DIMS.get(args.embedder_model.lower())
94
+ if dim is None:
95
+ _emit("error", reason=(
96
+ f"unknown ollama embedding model '{args.embedder_model}' — "
97
+ f"supported: {sorted(DIMS)}"
98
+ ))
99
+ return 4
100
+ embedder = OllamaTextEmbedding(
101
+ model_name=args.embedder_model,
102
+ dimensions=dim,
103
+ host=None, # → uses default localhost:11434
104
+ )
105
+ elif args.embedder_provider == "lmstudio":
106
+ from agentscope.embedding import OpenAITextEmbedding
107
+ # LM Studio exposes an OpenAI-compatible /v1; reuse the OpenAI
108
+ # client pointed at its local endpoint. Dim default = 768 (the
109
+ # FE picker should report the real dim per loaded model).
110
+ embedder = OpenAITextEmbedding(
111
+ api_key="lmstudio",
112
+ model_name=args.embedder_model,
113
+ dimensions=768,
114
+ )
115
+ embedder.client.base_url = "http://localhost:1234/v1"
116
+ else:
117
+ _emit("error", reason=(
118
+ f"refused embedder provider '{args.embedder_provider}' for Mode B "
119
+ "— only ollama and lmstudio keep documents private"
120
+ ))
121
+ return 5
122
+
123
+ from agentscope.rag import QdrantStore, SimpleKnowledge
124
+ store_path = _resolve_store_path(args.pipeline_name)
125
+ store_path.mkdir(parents=True, exist_ok=True)
126
+ store = QdrantStore(
127
+ location=str(store_path),
128
+ collection_name=args.pipeline_name,
129
+ dimensions=embedder.dimensions,
130
+ distance="Cosine",
131
+ )
132
+ kb = SimpleKnowledge(embedding_store=store, embedding_model=embedder)
133
+ except Exception as exc:
134
+ _emit("error", reason=f"failed to build KB: {type(exc).__name__}: {exc}")
135
+ return 6
136
+
137
+ # Read the manifest to skip unchanged files
138
+ manifest_path = _resolve_manifest_path(args.pipeline_name)
139
+ if manifest_path.exists():
140
+ try:
141
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
142
+ except Exception:
143
+ manifest = {"files": {}, "embedder": None}
144
+ else:
145
+ manifest = {"files": {}, "embedder": None}
146
+ stored = manifest.get("embedder") or {}
147
+ embedder_changed = (
148
+ stored.get("provider") != args.embedder_provider
149
+ or stored.get("model") != args.embedder_model
150
+ )
151
+ if embedder_changed:
152
+ _emit("embedder_changed", was=stored, now={
153
+ "provider": args.embedder_provider, "model": args.embedder_model,
154
+ })
155
+
156
+ started = time.time()
157
+ chunks_added = 0
158
+ files_processed = 0
159
+ skipped = 0
160
+ files_failed = []
161
+
162
+ # Walk the folder — only top-level + one level deep. Going arbitrarily
163
+ # deep would silently include things like venv/.git which would be
164
+ # surprising. If someone needs recursive, they can manually flatten.
165
+ candidates = [p for p in sorted(folder.iterdir()) if p.is_file()]
166
+ candidates += [p for sub in folder.iterdir() if sub.is_dir() and not sub.name.startswith(".")
167
+ for p in sorted(sub.iterdir()) if p.is_file()]
168
+
169
+ total = sum(1 for p in candidates if p.suffix.lower() in SUPPORTED_EXTS)
170
+ _emit("scanned", folder=str(folder), total_supported=total)
171
+
172
+ for f in candidates:
173
+ ext = f.suffix.lower()
174
+ if ext not in SUPPORTED_EXTS:
175
+ continue
176
+ try:
177
+ raw = f.read_bytes()
178
+ except Exception as exc:
179
+ files_failed.append({"file": str(f), "error": f"read: {type(exc).__name__}: {exc}"})
180
+ continue
181
+ sha = hashlib.sha256(raw).hexdigest()
182
+ prev = manifest["files"].get(f.name, {})
183
+ if (not embedder_changed) and prev.get("sha256") == sha:
184
+ skipped += 1
185
+ _emit("skipped_sha_clean", file=f.name)
186
+ continue
187
+ reader_cls = _reader_for(ext)
188
+ if reader_cls is None:
189
+ files_failed.append({"file": str(f), "error": "no extractor"})
190
+ continue
191
+ try:
192
+ reader = reader_cls()
193
+ docs = await reader(str(f))
194
+ for doc in docs:
195
+ if hasattr(doc.metadata, "doc_id"):
196
+ doc.metadata.doc_id = f.name
197
+ await kb.add_documents(docs)
198
+ chunks_added += len(docs)
199
+ manifest["files"][f.name] = {
200
+ "sha256": sha, "chunks": len(docs),
201
+ "ingested_at": _dt.datetime.utcnow().isoformat() + "Z",
202
+ }
203
+ files_processed += 1
204
+ _emit("file_done", file=f.name, chunks=len(docs))
205
+ except Exception as exc:
206
+ files_failed.append({
207
+ "file": str(f), "error": f"{type(exc).__name__}: {exc}",
208
+ })
209
+ _emit("file_failed", file=f.name, error=str(exc))
210
+
211
+ manifest["embedder"] = {
212
+ "provider": args.embedder_provider, "model": args.embedder_model,
213
+ }
214
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
215
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
216
+
217
+ elapsed_ms = int((time.time() - started) * 1000)
218
+ _emit("done",
219
+ files_processed=files_processed,
220
+ chunks_added=chunks_added,
221
+ skipped=skipped,
222
+ embedder_changed=embedder_changed,
223
+ elapsed_ms=elapsed_ms,
224
+ errors=files_failed)
225
+ return 0
226
+
227
+
228
+ def main() -> None:
229
+ ap = argparse.ArgumentParser()
230
+ ap.add_argument("--pipeline-name", required=True)
231
+ ap.add_argument("--folder", required=True)
232
+ ap.add_argument("--embedder-provider", required=True, choices=["ollama", "lmstudio"])
233
+ ap.add_argument("--embedder-model", required=True)
234
+ args = ap.parse_args()
235
+ sys.exit(asyncio.run(_ingest(args)))
236
+
237
+
238
+ if __name__ == "__main__":
239
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.36",
3
+ "version": "1.0.38",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -12,10 +12,12 @@
12
12
  "files": [
13
13
  "dist/**/*.js",
14
14
  "dist/**/*.d.ts",
15
+ "dist/**/*.py",
16
+ "localRagIngest.py",
15
17
  "README.md"
16
18
  ],
17
19
  "scripts": {
18
- "build": "tsc",
20
+ "build": "tsc && node -e \"require('fs').copyFileSync('localRagIngest.py','dist/localRagIngest.py')\"",
19
21
  "prepublishOnly": "npm run build"
20
22
  },
21
23
  "dependencies": {