@melaya/runner 1.0.48 → 1.0.50

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.
@@ -229,11 +229,24 @@ export async function connect(opts) {
229
229
  if (k.startsWith("MEL_MODEL_"))
230
230
  delete inherited[k];
231
231
  }
232
+ // certifi CA-bundle path resolved at venv setup. Set as both
233
+ // SSL_CERT_FILE (urllib / openssl) and REQUESTS_CA_BUNDLE (requests
234
+ // library) so EVERY HTTPS-using tool — gmail_send, slack_post_text,
235
+ // anthropic SDK, openai SDK, web_search, etc. — verifies against
236
+ // certifi's bundle rather than the platform default. Without this,
237
+ // macOS Python crashes with `[SSL: CERTIFICATE_VERIFY_FAILED]` on
238
+ // any cloud call (run eb1b8f67df56491b hit exactly this for
239
+ // gmail_send → api.googleapis.com).
240
+ const certBundle = (await import("./pythonEnv.js")).getCertBundlePath();
241
+ const sslEnv = certBundle
242
+ ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle }
243
+ : {};
232
244
  const env = {
233
245
  ...inherited,
234
246
  PYTHONPATH: sharedDir,
235
247
  PYTHONIOENCODING: "utf-8",
236
248
  PYTHONUTF8: "1",
249
+ ...sslEnv,
237
250
  MEL_RUN_ID: payload.runId,
238
251
  // Point agentscope hooks at the REAL server (not the browser's Vite URL).
239
252
  // Convert wss://api.melaya.org → https://api.melaya.org so pushMessage,
@@ -467,16 +480,25 @@ export async function connect(opts) {
467
480
  try {
468
481
  const prov = (payload.embedderProvider || "").toLowerCase();
469
482
  if (prov === "openai") {
470
- const reason = ("Mode B (local folder) refuses cloud embedders. " +
471
- "Pick ollama or lmstudio to keep your documents private.");
483
+ const reason = ("Runner-side ingest refuses cloud embedders. " +
484
+ "Pick ollama or lmstudio (the whole point of routing through " +
485
+ "the runner is to use a local model).");
472
486
  console.log(chalk.red(` ✗ rag:ingest refused: ${reason}`));
473
487
  socket.emit("rag:ingest-result", {
474
488
  session_id: sid, ok: false, error: reason,
475
489
  });
476
490
  return;
477
491
  }
478
- console.log(chalk.hex("#10B981")(`\n 🔒 RAG local-folder ingest: ${payload.pipelineName} ` +
479
- `← ${payload.localFolderPath} via ${prov}:${payload.embedderModel}\n`));
492
+ const _isModeC = payload.mode === "cloud_files";
493
+ if (_isModeC) {
494
+ console.log(chalk.hex("#7C6FF0")(`\n ☁️→💻 RAG cloud-files ingest (Mode C): ${payload.pipelineName} ` +
495
+ `via ${prov}:${payload.embedderModel}\n` +
496
+ ` (files fetched from ${opts.serverUrl}, embedded locally, vectors pushed back)\n`));
497
+ }
498
+ else {
499
+ console.log(chalk.hex("#10B981")(`\n 🔒 RAG local-folder ingest (Mode B): ${payload.pipelineName} ` +
500
+ `← ${payload.localFolderPath} via ${prov}:${payload.embedderModel}\n`));
501
+ }
480
502
  emitProgress("started");
481
503
  // Reuse the same venv as runner:run — agentscope.rag + ollama +
482
504
  // qdrant-client land here.
@@ -542,16 +564,55 @@ export async function connect(opts) {
542
564
  ];
543
565
  const { existsSync: _exists2 } = await import("fs");
544
566
  const bundledNltkData = candidateNltkPaths.find((p) => _exists2(p)) || "";
545
- const proc = spawn(env.pythonPath, [
567
+ // Resolve certifi bundle for HTTPS verification (same rationale as
568
+ // the runner:run spawn — embedder providers like OpenAI hit
569
+ // api.openai.com over TLS and crash on macOS without certifi).
570
+ const ragCertBundle = (await import("./pythonEnv.js")).getCertBundlePath();
571
+ const ragSslEnv = ragCertBundle
572
+ ? { SSL_CERT_FILE: ragCertBundle, REQUESTS_CA_BUNDLE: ragCertBundle }
573
+ : {};
574
+ const ingestMode = payload.mode === "cloud_files" ? "cloud_files" : "local_folder";
575
+ const ingestArgs = [
546
576
  "-u", stagedScript,
577
+ "--mode", ingestMode,
547
578
  "--pipeline-name", payload.pipelineName,
548
- "--folder", payload.localFolderPath,
549
579
  "--embedder-provider", prov,
550
580
  "--embedder-model", payload.embedderModel,
551
- ], {
581
+ ];
582
+ if (ingestMode === "cloud_files") {
583
+ // Server bundled file bytes into the event. Unpack them to a
584
+ // temp dir + a small manifest JSON so the Python script can
585
+ // walk them with the same code path as Mode B reads from disk.
586
+ const filesDir = join(workDir, "cloud-files");
587
+ mkdirSync(filesDir, { recursive: true });
588
+ const { writeFileSync } = await import("fs");
589
+ const filesArr = payload.cloudFiles || [];
590
+ for (const f of filesArr) {
591
+ try {
592
+ writeFileSync(join(filesDir, f.name), Buffer.from(f.contentB64, "base64"));
593
+ }
594
+ catch (e) {
595
+ console.log(chalk.red(` [rag] failed to unpack ${f.name}: ${e?.message || e}`));
596
+ }
597
+ }
598
+ const manifestPath = join(workDir, "cloud-manifest.json");
599
+ writeFileSync(manifestPath, JSON.stringify({
600
+ files: filesArr.map(f => ({ name: f.name, sha256: f.sha256, ext: f.ext })),
601
+ stored_embedder: payload.cloudManifest?.stored_embedder || {},
602
+ previously_ingested: payload.cloudManifest?.previously_ingested || {},
603
+ }, null, 2));
604
+ ingestArgs.push("--files-dir", filesDir);
605
+ ingestArgs.push("--manifest-json", manifestPath);
606
+ console.log(chalk.gray(` [rag] unpacked ${filesArr.length} cloud file(s) → ${filesDir}`));
607
+ }
608
+ else {
609
+ ingestArgs.push("--folder", payload.localFolderPath || "");
610
+ }
611
+ const proc = spawn(env.pythonPath, ingestArgs, {
552
612
  env: {
553
613
  ...process.env,
554
614
  PYTHONPATH: sharedDir, // so `from agentscope.rag import …` resolves
615
+ ...ragSslEnv,
555
616
  ...(bundledNltkData ? { NLTK_DATA: bundledNltkData } : {}),
556
617
  },
557
618
  cwd: workDir,
@@ -331,14 +331,245 @@ async def _ingest(args) -> int:
331
331
  return 0
332
332
 
333
333
 
334
+ def _build_embedder(provider: str, model: str):
335
+ """Embedder factory shared between Mode B (local folder) and Mode C
336
+ (cloud files via HTTP). Returns the agentscope embedder instance +
337
+ a probe error message string when something is misconfigured (empty
338
+ string means OK)."""
339
+ if provider == "ollama":
340
+ from agentscope.embedding import OllamaTextEmbedding
341
+ DIMS = {
342
+ "nomic-embed-text": 768, "nomic-embed-text:v1.5": 768,
343
+ "mxbai-embed-large": 1024, "all-minilm": 384,
344
+ "snowflake-arctic-embed": 1024,
345
+ }
346
+ dim = DIMS.get(model.lower())
347
+ if dim is None:
348
+ return None, (
349
+ f"unknown ollama embedding model '{model}' — supported: {sorted(DIMS)}"
350
+ )
351
+ return OllamaTextEmbedding(
352
+ model_name=model, dimensions=dim, host=None,
353
+ ), ""
354
+ if provider == "lmstudio":
355
+ from agentscope.embedding import OpenAITextEmbedding
356
+ embedder = OpenAITextEmbedding(
357
+ api_key="lmstudio", model_name=model, dimensions=768,
358
+ base_url="http://localhost:1234/v1",
359
+ )
360
+ # Strip OpenAI-specific `dimensions` kwarg that LMS rejects
361
+ _orig_create = embedder.client.embeddings.create
362
+ async def _lms_create(**kw):
363
+ kw.pop("dimensions", None)
364
+ return await _orig_create(**kw)
365
+ embedder.client.embeddings.create = _lms_create # type: ignore[assignment]
366
+ return embedder, ""
367
+ return None, f"unsupported embedder provider '{provider}'"
368
+
369
+
370
+ async def _ingest_cloud_files(args) -> int:
371
+ """Mode C ingest.
372
+
373
+ Files originate on prod but the runner does the embedding so the
374
+ operator can use a free local model (ollama / lmstudio) without
375
+ needing an OpenAI key. Both the file CONTENTS and the resulting
376
+ (chunks, vectors) tuples flow over Socket.IO end-to-end:
377
+
378
+ • The server (tRPC bridge in runnerNamespace.ts) reads the files
379
+ from prod's <pipeline_dir>/rag/retrieval/, base64-encodes them,
380
+ and bakes them into the `rag:ingest` event payload. The runner
381
+ connection.ts unpacks them into ``<tmp>/files-dir/`` and
382
+ passes ``--files-dir <tmp> --manifest-json <tmp>/manifest.json``
383
+ here.
384
+ • For each file we parse + chunk + embed locally, then emit a
385
+ ``progress: {"kind":"upsert_chunks", ...}`` line to stdout. The
386
+ runner forwards that line as a ``rag:ingest-progress`` Socket.IO
387
+ event; the server's progress handler recognises the
388
+ ``upsert_chunks`` kind and POSTs the payload to
389
+ ``http://127.0.0.1:8766/pipelines/{name}/docs/retrieval/upsert``
390
+ — the loopback-only builder API that owns the QdrantStore.
391
+ • Nothing on the wire ever needs to leave the user's machine
392
+ via HTTP. The whole transport is the already-authenticated
393
+ Socket.IO channel, which means we don't have to expose any
394
+ builder endpoints publicly.
395
+ """
396
+ files_dir = Path(args.files_dir)
397
+ if not files_dir.exists() or not files_dir.is_dir():
398
+ _emit("error", reason=f"files-dir not found: {files_dir}")
399
+ return 3
400
+
401
+ embedder, err = _build_embedder(args.embedder_provider, args.embedder_model)
402
+ if not embedder:
403
+ _emit("error", reason=err)
404
+ return 4
405
+
406
+ manifest: dict = {}
407
+ if args.manifest_json:
408
+ try:
409
+ manifest = json.loads(Path(args.manifest_json).read_text(encoding="utf-8"))
410
+ except Exception as exc:
411
+ _emit("warning", reason=f"manifest read failed: {exc}")
412
+ files: list[dict] = manifest.get("files") or []
413
+ stored_embedder = manifest.get("stored_embedder") or {}
414
+ previously_ingested = manifest.get("previously_ingested") or {}
415
+ embedder_changed = (
416
+ stored_embedder.get("provider") != args.embedder_provider
417
+ or stored_embedder.get("model") != args.embedder_model
418
+ )
419
+ _emit("started", mode="cloud_files", pipeline=args.pipeline_name,
420
+ embedder_changed=embedder_changed, total_supported=len(files))
421
+
422
+ started = time.time()
423
+ chunks_added_total = 0
424
+ files_processed = 0
425
+ skipped = 0
426
+ files_failed: list[dict] = []
427
+
428
+ embedder_payload = {
429
+ "provider": args.embedder_provider,
430
+ "model": args.embedder_model,
431
+ "dimensions": embedder.dimensions,
432
+ }
433
+
434
+ first_emit = True
435
+ for entry in files:
436
+ name = entry["name"]
437
+ sha = entry["sha256"]
438
+ ext = (entry.get("ext") or "").lower()
439
+ if ext not in SUPPORTED_EXTS:
440
+ files_failed.append({"file": name, "error": f"unsupported ext '{ext}'"})
441
+ _emit("file_failed", file=name, error=f"unsupported ext '{ext}'")
442
+ continue
443
+ if not embedder_changed and previously_ingested.get(name, {}).get("sha256") == sha:
444
+ skipped += 1
445
+ _emit("skipped_sha_clean", file=name)
446
+ continue
447
+ tmp_path = files_dir / name
448
+ if not tmp_path.is_file():
449
+ files_failed.append({"file": name, "error": "file missing from cloud-files dir"})
450
+ _emit("file_failed", file=name, error="missing locally")
451
+ continue
452
+ reader_cls = _reader_for(ext)
453
+ if reader_cls is None:
454
+ files_failed.append({"file": name, "error": "no extractor"})
455
+ _emit("file_failed", file=name, error="no extractor")
456
+ continue
457
+ try:
458
+ reader = reader_cls()
459
+ docs = await reader(str(tmp_path))
460
+ for i, doc in enumerate(docs):
461
+ if hasattr(doc.metadata, "doc_id"):
462
+ doc.metadata.doc_id = name
463
+ if hasattr(doc.metadata, "chunk_id"):
464
+ doc.metadata.chunk_id = i
465
+ texts: list[str] = []
466
+ for doc in docs:
467
+ content = getattr(doc.metadata, "content", None)
468
+ text = (
469
+ (content or {}).get("text", "")
470
+ if isinstance(content, dict)
471
+ else getattr(content, "text", "")
472
+ )
473
+ if text:
474
+ texts.append(text)
475
+ if not texts:
476
+ files_processed += 1
477
+ _emit("file_done", file=name, chunks=0)
478
+ _emit("upsert_chunks",
479
+ embedder=embedder_payload,
480
+ chunks=[],
481
+ files_done=[{"name": name, "sha256": sha, "chunks": 0}],
482
+ embedder_changed=embedder_changed and first_emit,
483
+ finalize=False)
484
+ first_emit = False
485
+ continue
486
+ vectors_resp = await embedder(text=texts)
487
+ vectors = [eb.embedding for eb in vectors_resp.embeddings]
488
+ if len(vectors) != len(texts):
489
+ raise RuntimeError(
490
+ f"embedder returned {len(vectors)} vectors for {len(texts)} chunks"
491
+ )
492
+ upsert_chunks = [
493
+ {
494
+ "text": text,
495
+ "vector": [float(v) for v in vec],
496
+ "metadata": {
497
+ "doc_id": name,
498
+ "chunk_id": i,
499
+ "total_chunks": len(texts),
500
+ },
501
+ }
502
+ for i, (text, vec) in enumerate(zip(texts, vectors))
503
+ ]
504
+ # Emit one upsert event per file. Server forwards to builder
505
+ # /upsert. For small per-file payloads this is well below the
506
+ # Socket.IO default 1 MB/event limit (60-doc corpora ≈ 6 MB
507
+ # spread across ~60 events).
508
+ _emit("upsert_chunks",
509
+ embedder=embedder_payload,
510
+ chunks=upsert_chunks,
511
+ files_done=[{"name": name, "sha256": sha, "chunks": len(texts)}],
512
+ embedder_changed=embedder_changed and first_emit,
513
+ finalize=False)
514
+ first_emit = False
515
+ chunks_added_total += len(texts)
516
+ files_processed += 1
517
+ _emit("file_done", file=name, chunks=len(texts))
518
+ except Exception as exc:
519
+ files_failed.append({
520
+ "file": name, "error": f"{type(exc).__name__}: {exc}",
521
+ })
522
+ _emit("file_failed", file=name, error=str(exc))
523
+
524
+ # Finalize: tells the server to stamp the embedder identity into the
525
+ # manifest. Sent as one final, empty-chunk upsert event.
526
+ _emit("upsert_chunks",
527
+ embedder=embedder_payload,
528
+ chunks=[],
529
+ files_done=[],
530
+ embedder_changed=False,
531
+ finalize=True)
532
+
533
+ elapsed_ms = int((time.time() - started) * 1000)
534
+ _emit("done",
535
+ mode="cloud_files",
536
+ files_processed=files_processed,
537
+ chunks_added=chunks_added_total,
538
+ skipped=skipped,
539
+ embedder_changed=embedder_changed,
540
+ elapsed_ms=elapsed_ms,
541
+ errors=files_failed)
542
+ return 0
543
+
544
+
334
545
  def main() -> None:
335
546
  ap = argparse.ArgumentParser()
547
+ ap.add_argument("--mode", choices=["local_folder", "cloud_files"],
548
+ default="local_folder")
336
549
  ap.add_argument("--pipeline-name", required=True)
337
- ap.add_argument("--folder", required=True)
550
+ ap.add_argument("--folder", default="")
338
551
  ap.add_argument("--embedder-provider", required=True, choices=["ollama", "lmstudio"])
339
552
  ap.add_argument("--embedder-model", required=True)
553
+ # Mode C only (cloud_files): files were base64-encoded by the
554
+ # server into the rag:ingest payload and the runner's connection.ts
555
+ # unpacked them to disk at --files-dir. --manifest-json points at
556
+ # a small JSON manifest (file list + sha + previously-ingested map
557
+ # so we can skip unchanged files).
558
+ ap.add_argument("--files-dir", default="",
559
+ help="Mode C only — directory containing the unpacked file payload.")
560
+ ap.add_argument("--manifest-json", default="",
561
+ help="Mode C only — path to manifest JSON (file list, prior ingest, embedder).")
340
562
  args = ap.parse_args()
341
- sys.exit(asyncio.run(_ingest(args)))
563
+ if args.mode == "cloud_files":
564
+ if not args.files_dir or not args.manifest_json:
565
+ _emit("error", reason="cloud_files mode requires --files-dir + --manifest-json")
566
+ sys.exit(2)
567
+ sys.exit(asyncio.run(_ingest_cloud_files(args)))
568
+ else:
569
+ if not args.folder:
570
+ _emit("error", reason="local_folder mode requires --folder")
571
+ sys.exit(2)
572
+ sys.exit(asyncio.run(_ingest(args)))
342
573
 
343
574
 
344
575
  if __name__ == "__main__":
@@ -10,6 +10,7 @@
10
10
  * shared-bundle version. Re-bootstraps when the bundle version changes.
11
11
  */
12
12
  export declare function venvPython(): string;
13
+ export declare function getCertBundlePath(): string;
13
14
  export declare function ensurePythonEnv(systemPython: string, expectedVersion: string, onProgress?: (msg: string) => void): Promise<{
14
15
  ok: boolean;
15
16
  pythonPath: string;
package/dist/pythonEnv.js CHANGED
@@ -119,12 +119,69 @@ const PIP_DEPS = [
119
119
  // "pandas is not installed". Numpy is already in PIP_DEPS (line 57) so
120
120
  // the wheel install is fast.
121
121
  "pandas",
122
+ // certifi — provides the CA root bundle that tools like gmail_send /
123
+ // any other HTTPS API rely on for cert verification. On macOS Python
124
+ // 3.x doesn't read the system keychain by default; without certifi +
125
+ // SSL_CERT_FILE pointed at its bundle, every Gmail / Slack / Anthropic
126
+ // / etc. API call raises SSL: CERTIFICATE_VERIFY_FAILED (run
127
+ // eb1b8f67df56491b hit exactly this — gmail_send dispatched but
128
+ // urllib couldn't verify api.googleapis.com). The runner exports
129
+ // SSL_CERT_FILE + REQUESTS_CA_BUNDLE pointing here when spawning
130
+ // pipeline Python — see getCertBundlePath() + spawn env merging in
131
+ // connection.ts.
132
+ "certifi",
122
133
  ];
123
134
  export function venvPython() {
124
135
  return platform() === "win32"
125
136
  ? join(VENV_DIR, "Scripts", "python.exe")
126
137
  : join(VENV_DIR, "bin", "python");
127
138
  }
139
+ // ── certifi CA-bundle path resolution + cache ────────────────────────────
140
+ // Set as SSL_CERT_FILE + REQUESTS_CA_BUNDLE when spawning pipeline Python
141
+ // so that ANY HTTPS-using tool (gmail_send, slack_post_text, anthropic /
142
+ // openai SDKs, requests, urllib, etc.) verifies certs against certifi's
143
+ // bundle rather than the platform default. On macOS specifically, the
144
+ // system Python doesn't read the keychain — without this, every API call
145
+ // crashes with `[SSL: CERTIFICATE_VERIFY_FAILED]`.
146
+ //
147
+ // We cache the path in ~/.melaya-runner/cert-bundle-path so we don't pay
148
+ // a Python startup roundtrip on every spawn. The cache is invalidated
149
+ // when the venv is rebuilt (the file lives outside VENV_DIR but is
150
+ // re-written every ensurePythonEnv).
151
+ const CERT_PATH_CACHE = join(CACHE_DIR, "cert-bundle-path");
152
+ export function getCertBundlePath() {
153
+ try {
154
+ if (existsSync(CERT_PATH_CACHE)) {
155
+ const cached = readFileSync(CERT_PATH_CACHE, "utf-8").trim();
156
+ if (cached && existsSync(cached))
157
+ return cached;
158
+ }
159
+ }
160
+ catch { /* fall through to resolution */ }
161
+ return "";
162
+ }
163
+ async function _resolveAndCacheCertBundle(onProgress) {
164
+ if (!existsSync(venvPython()))
165
+ return;
166
+ // Resolve certifi.where() — fast subprocess (<300 ms typical).
167
+ let resolved = "";
168
+ await runProc(venvPython(), ["-c", "import certifi, sys; sys.stdout.write(certifi.where())"], (line) => {
169
+ if (line && existsSync(line.trim()))
170
+ resolved = line.trim();
171
+ });
172
+ if (resolved) {
173
+ try {
174
+ writeFileSync(CERT_PATH_CACHE, resolved + "\n", "utf-8");
175
+ onProgress(`cert bundle: ${resolved}`);
176
+ }
177
+ catch (e) {
178
+ onProgress(`(warn: failed to cache cert bundle path: ${e?.message})`);
179
+ }
180
+ }
181
+ else {
182
+ onProgress("(warn: certifi.where() did not resolve a valid path — HTTPS calls may fail with cert verify errors)");
183
+ }
184
+ }
128
185
  // The marker is keyed on sharedVersion + a hash of PIP_DEPS so changes
129
186
  // to either invalidate the venv. Without the PIP_DEPS half, a runner
130
187
  // update that adds a dep (e.g. scrapling[fetchers] in 1.0.32) would
@@ -226,6 +283,7 @@ export async function ensurePythonEnv(systemPython, expectedVersion, onProgress
226
283
  // says we're up to date. This is what previously broke for boxes
227
284
  // that upgraded the runner without invalidating the marker.
228
285
  await ensureNltkData(onProgress);
286
+ await _resolveAndCacheCertBundle(onProgress);
229
287
  return { ok: true, pythonPath: venvPython() };
230
288
  }
231
289
  if (!existsSync(AGENTSCOPE)) {
@@ -298,6 +356,7 @@ export async function ensurePythonEnv(systemPython, expectedVersion, onProgress
298
356
  // Centralised in `ensureNltkData()` so the success/failure surface is
299
357
  // identical whether the venv is fresh or already valid.
300
358
  await ensureNltkData(onProgress);
359
+ await _resolveAndCacheCertBundle(onProgress);
301
360
  writeFileSync(VENV_MARK, venvMarkerValue(expectedVersion), "utf-8");
302
361
  // sanity: confirm shortuuid + agentscope (via PYTHONPATH) resolve now.
303
362
  // Probe must mirror the spawn env so PYTHONPATH=CACHE_DIR points at
package/localRagIngest.py CHANGED
@@ -331,14 +331,245 @@ async def _ingest(args) -> int:
331
331
  return 0
332
332
 
333
333
 
334
+ def _build_embedder(provider: str, model: str):
335
+ """Embedder factory shared between Mode B (local folder) and Mode C
336
+ (cloud files via HTTP). Returns the agentscope embedder instance +
337
+ a probe error message string when something is misconfigured (empty
338
+ string means OK)."""
339
+ if provider == "ollama":
340
+ from agentscope.embedding import OllamaTextEmbedding
341
+ DIMS = {
342
+ "nomic-embed-text": 768, "nomic-embed-text:v1.5": 768,
343
+ "mxbai-embed-large": 1024, "all-minilm": 384,
344
+ "snowflake-arctic-embed": 1024,
345
+ }
346
+ dim = DIMS.get(model.lower())
347
+ if dim is None:
348
+ return None, (
349
+ f"unknown ollama embedding model '{model}' — supported: {sorted(DIMS)}"
350
+ )
351
+ return OllamaTextEmbedding(
352
+ model_name=model, dimensions=dim, host=None,
353
+ ), ""
354
+ if provider == "lmstudio":
355
+ from agentscope.embedding import OpenAITextEmbedding
356
+ embedder = OpenAITextEmbedding(
357
+ api_key="lmstudio", model_name=model, dimensions=768,
358
+ base_url="http://localhost:1234/v1",
359
+ )
360
+ # Strip OpenAI-specific `dimensions` kwarg that LMS rejects
361
+ _orig_create = embedder.client.embeddings.create
362
+ async def _lms_create(**kw):
363
+ kw.pop("dimensions", None)
364
+ return await _orig_create(**kw)
365
+ embedder.client.embeddings.create = _lms_create # type: ignore[assignment]
366
+ return embedder, ""
367
+ return None, f"unsupported embedder provider '{provider}'"
368
+
369
+
370
+ async def _ingest_cloud_files(args) -> int:
371
+ """Mode C ingest.
372
+
373
+ Files originate on prod but the runner does the embedding so the
374
+ operator can use a free local model (ollama / lmstudio) without
375
+ needing an OpenAI key. Both the file CONTENTS and the resulting
376
+ (chunks, vectors) tuples flow over Socket.IO end-to-end:
377
+
378
+ • The server (tRPC bridge in runnerNamespace.ts) reads the files
379
+ from prod's <pipeline_dir>/rag/retrieval/, base64-encodes them,
380
+ and bakes them into the `rag:ingest` event payload. The runner
381
+ connection.ts unpacks them into ``<tmp>/files-dir/`` and
382
+ passes ``--files-dir <tmp> --manifest-json <tmp>/manifest.json``
383
+ here.
384
+ • For each file we parse + chunk + embed locally, then emit a
385
+ ``progress: {"kind":"upsert_chunks", ...}`` line to stdout. The
386
+ runner forwards that line as a ``rag:ingest-progress`` Socket.IO
387
+ event; the server's progress handler recognises the
388
+ ``upsert_chunks`` kind and POSTs the payload to
389
+ ``http://127.0.0.1:8766/pipelines/{name}/docs/retrieval/upsert``
390
+ — the loopback-only builder API that owns the QdrantStore.
391
+ • Nothing on the wire ever needs to leave the user's machine
392
+ via HTTP. The whole transport is the already-authenticated
393
+ Socket.IO channel, which means we don't have to expose any
394
+ builder endpoints publicly.
395
+ """
396
+ files_dir = Path(args.files_dir)
397
+ if not files_dir.exists() or not files_dir.is_dir():
398
+ _emit("error", reason=f"files-dir not found: {files_dir}")
399
+ return 3
400
+
401
+ embedder, err = _build_embedder(args.embedder_provider, args.embedder_model)
402
+ if not embedder:
403
+ _emit("error", reason=err)
404
+ return 4
405
+
406
+ manifest: dict = {}
407
+ if args.manifest_json:
408
+ try:
409
+ manifest = json.loads(Path(args.manifest_json).read_text(encoding="utf-8"))
410
+ except Exception as exc:
411
+ _emit("warning", reason=f"manifest read failed: {exc}")
412
+ files: list[dict] = manifest.get("files") or []
413
+ stored_embedder = manifest.get("stored_embedder") or {}
414
+ previously_ingested = manifest.get("previously_ingested") or {}
415
+ embedder_changed = (
416
+ stored_embedder.get("provider") != args.embedder_provider
417
+ or stored_embedder.get("model") != args.embedder_model
418
+ )
419
+ _emit("started", mode="cloud_files", pipeline=args.pipeline_name,
420
+ embedder_changed=embedder_changed, total_supported=len(files))
421
+
422
+ started = time.time()
423
+ chunks_added_total = 0
424
+ files_processed = 0
425
+ skipped = 0
426
+ files_failed: list[dict] = []
427
+
428
+ embedder_payload = {
429
+ "provider": args.embedder_provider,
430
+ "model": args.embedder_model,
431
+ "dimensions": embedder.dimensions,
432
+ }
433
+
434
+ first_emit = True
435
+ for entry in files:
436
+ name = entry["name"]
437
+ sha = entry["sha256"]
438
+ ext = (entry.get("ext") or "").lower()
439
+ if ext not in SUPPORTED_EXTS:
440
+ files_failed.append({"file": name, "error": f"unsupported ext '{ext}'"})
441
+ _emit("file_failed", file=name, error=f"unsupported ext '{ext}'")
442
+ continue
443
+ if not embedder_changed and previously_ingested.get(name, {}).get("sha256") == sha:
444
+ skipped += 1
445
+ _emit("skipped_sha_clean", file=name)
446
+ continue
447
+ tmp_path = files_dir / name
448
+ if not tmp_path.is_file():
449
+ files_failed.append({"file": name, "error": "file missing from cloud-files dir"})
450
+ _emit("file_failed", file=name, error="missing locally")
451
+ continue
452
+ reader_cls = _reader_for(ext)
453
+ if reader_cls is None:
454
+ files_failed.append({"file": name, "error": "no extractor"})
455
+ _emit("file_failed", file=name, error="no extractor")
456
+ continue
457
+ try:
458
+ reader = reader_cls()
459
+ docs = await reader(str(tmp_path))
460
+ for i, doc in enumerate(docs):
461
+ if hasattr(doc.metadata, "doc_id"):
462
+ doc.metadata.doc_id = name
463
+ if hasattr(doc.metadata, "chunk_id"):
464
+ doc.metadata.chunk_id = i
465
+ texts: list[str] = []
466
+ for doc in docs:
467
+ content = getattr(doc.metadata, "content", None)
468
+ text = (
469
+ (content or {}).get("text", "")
470
+ if isinstance(content, dict)
471
+ else getattr(content, "text", "")
472
+ )
473
+ if text:
474
+ texts.append(text)
475
+ if not texts:
476
+ files_processed += 1
477
+ _emit("file_done", file=name, chunks=0)
478
+ _emit("upsert_chunks",
479
+ embedder=embedder_payload,
480
+ chunks=[],
481
+ files_done=[{"name": name, "sha256": sha, "chunks": 0}],
482
+ embedder_changed=embedder_changed and first_emit,
483
+ finalize=False)
484
+ first_emit = False
485
+ continue
486
+ vectors_resp = await embedder(text=texts)
487
+ vectors = [eb.embedding for eb in vectors_resp.embeddings]
488
+ if len(vectors) != len(texts):
489
+ raise RuntimeError(
490
+ f"embedder returned {len(vectors)} vectors for {len(texts)} chunks"
491
+ )
492
+ upsert_chunks = [
493
+ {
494
+ "text": text,
495
+ "vector": [float(v) for v in vec],
496
+ "metadata": {
497
+ "doc_id": name,
498
+ "chunk_id": i,
499
+ "total_chunks": len(texts),
500
+ },
501
+ }
502
+ for i, (text, vec) in enumerate(zip(texts, vectors))
503
+ ]
504
+ # Emit one upsert event per file. Server forwards to builder
505
+ # /upsert. For small per-file payloads this is well below the
506
+ # Socket.IO default 1 MB/event limit (60-doc corpora ≈ 6 MB
507
+ # spread across ~60 events).
508
+ _emit("upsert_chunks",
509
+ embedder=embedder_payload,
510
+ chunks=upsert_chunks,
511
+ files_done=[{"name": name, "sha256": sha, "chunks": len(texts)}],
512
+ embedder_changed=embedder_changed and first_emit,
513
+ finalize=False)
514
+ first_emit = False
515
+ chunks_added_total += len(texts)
516
+ files_processed += 1
517
+ _emit("file_done", file=name, chunks=len(texts))
518
+ except Exception as exc:
519
+ files_failed.append({
520
+ "file": name, "error": f"{type(exc).__name__}: {exc}",
521
+ })
522
+ _emit("file_failed", file=name, error=str(exc))
523
+
524
+ # Finalize: tells the server to stamp the embedder identity into the
525
+ # manifest. Sent as one final, empty-chunk upsert event.
526
+ _emit("upsert_chunks",
527
+ embedder=embedder_payload,
528
+ chunks=[],
529
+ files_done=[],
530
+ embedder_changed=False,
531
+ finalize=True)
532
+
533
+ elapsed_ms = int((time.time() - started) * 1000)
534
+ _emit("done",
535
+ mode="cloud_files",
536
+ files_processed=files_processed,
537
+ chunks_added=chunks_added_total,
538
+ skipped=skipped,
539
+ embedder_changed=embedder_changed,
540
+ elapsed_ms=elapsed_ms,
541
+ errors=files_failed)
542
+ return 0
543
+
544
+
334
545
  def main() -> None:
335
546
  ap = argparse.ArgumentParser()
547
+ ap.add_argument("--mode", choices=["local_folder", "cloud_files"],
548
+ default="local_folder")
336
549
  ap.add_argument("--pipeline-name", required=True)
337
- ap.add_argument("--folder", required=True)
550
+ ap.add_argument("--folder", default="")
338
551
  ap.add_argument("--embedder-provider", required=True, choices=["ollama", "lmstudio"])
339
552
  ap.add_argument("--embedder-model", required=True)
553
+ # Mode C only (cloud_files): files were base64-encoded by the
554
+ # server into the rag:ingest payload and the runner's connection.ts
555
+ # unpacked them to disk at --files-dir. --manifest-json points at
556
+ # a small JSON manifest (file list + sha + previously-ingested map
557
+ # so we can skip unchanged files).
558
+ ap.add_argument("--files-dir", default="",
559
+ help="Mode C only — directory containing the unpacked file payload.")
560
+ ap.add_argument("--manifest-json", default="",
561
+ help="Mode C only — path to manifest JSON (file list, prior ingest, embedder).")
340
562
  args = ap.parse_args()
341
- sys.exit(asyncio.run(_ingest(args)))
563
+ if args.mode == "cloud_files":
564
+ if not args.files_dir or not args.manifest_json:
565
+ _emit("error", reason="cloud_files mode requires --files-dir + --manifest-json")
566
+ sys.exit(2)
567
+ sys.exit(asyncio.run(_ingest_cloud_files(args)))
568
+ else:
569
+ if not args.folder:
570
+ _emit("error", reason="local_folder mode requires --folder")
571
+ sys.exit(2)
572
+ sys.exit(asyncio.run(_ingest(args)))
342
573
 
343
574
 
344
575
  if __name__ == "__main__":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.48",
3
+ "version": "1.0.50",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,