@melaya/runner 1.0.49 → 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.
@@ -480,16 +480,25 @@ export async function connect(opts) {
480
480
  try {
481
481
  const prov = (payload.embedderProvider || "").toLowerCase();
482
482
  if (prov === "openai") {
483
- const reason = ("Mode B (local folder) refuses cloud embedders. " +
484
- "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).");
485
486
  console.log(chalk.red(` ✗ rag:ingest refused: ${reason}`));
486
487
  socket.emit("rag:ingest-result", {
487
488
  session_id: sid, ok: false, error: reason,
488
489
  });
489
490
  return;
490
491
  }
491
- console.log(chalk.hex("#10B981")(`\n 🔒 RAG local-folder ingest: ${payload.pipelineName} ` +
492
- `← ${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
+ }
493
502
  emitProgress("started");
494
503
  // Reuse the same venv as runner:run — agentscope.rag + ollama +
495
504
  // qdrant-client land here.
@@ -562,13 +571,44 @@ export async function connect(opts) {
562
571
  const ragSslEnv = ragCertBundle
563
572
  ? { SSL_CERT_FILE: ragCertBundle, REQUESTS_CA_BUNDLE: ragCertBundle }
564
573
  : {};
565
- const proc = spawn(env.pythonPath, [
574
+ const ingestMode = payload.mode === "cloud_files" ? "cloud_files" : "local_folder";
575
+ const ingestArgs = [
566
576
  "-u", stagedScript,
577
+ "--mode", ingestMode,
567
578
  "--pipeline-name", payload.pipelineName,
568
- "--folder", payload.localFolderPath,
569
579
  "--embedder-provider", prov,
570
580
  "--embedder-model", payload.embedderModel,
571
- ], {
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, {
572
612
  env: {
573
613
  ...process.env,
574
614
  PYTHONPATH: sharedDir, // so `from agentscope.rag import …` resolves
@@ -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/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.49",
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,