@melaya/runner 1.0.69 → 1.0.71

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.
@@ -1083,6 +1083,113 @@ export async function connect(opts) {
1083
1083
  // connector. After that, the LinkedIn pipelines (auto-like / auto-reply
1084
1084
  // / auto-connect) just read LINKEDIN_LI_AT / LINKEDIN_JSESSIONID from
1085
1085
  // env at run time and call Voyager directly with aiohttp.
1086
+ socket.on("rag:retrieve", async (payload, ack) => {
1087
+ const sid = payload?.sessionId;
1088
+ if (!sid)
1089
+ return;
1090
+ ack?.({ ok: true });
1091
+ const embedOnly = payload.mode === "cloud_files";
1092
+ try {
1093
+ const prov = (payload.embedderProvider || "").toLowerCase();
1094
+ if (!["ollama", "lmstudio"].includes(prov)) {
1095
+ socket.emit("rag:retrieve-result", {
1096
+ session_id: sid,
1097
+ ok: false,
1098
+ error: "runner retrieval only supports ollama or lmstudio embedders",
1099
+ });
1100
+ return;
1101
+ }
1102
+ const { ensurePythonEnv } = await import("./pythonEnv.js");
1103
+ const env = await ensurePythonEnv(opts.pythonPath, payload.sharedVersion);
1104
+ if (!env.ok) {
1105
+ socket.emit("rag:retrieve-result", {
1106
+ session_id: sid,
1107
+ ok: false,
1108
+ error: `venv bootstrap failed: ${env.reason}`,
1109
+ });
1110
+ return;
1111
+ }
1112
+ await ensureSharedModules(opts.serverUrl, payload.sharedVersion);
1113
+ const sharedDir = getSharedDir();
1114
+ const workDir = join(tmpdir(), `melaya-rag-retrieve-${payload.pipelineName}-${Date.now()}`);
1115
+ mkdirSync(workDir, { recursive: true });
1116
+ const { copyFileSync, existsSync } = await import("fs");
1117
+ const candidates = [
1118
+ join(sharedDir, "..", "localRagRetrieve.py"),
1119
+ join(__dirname, "localRagRetrieve.py"),
1120
+ join(__dirname, "..", "localRagRetrieve.py"),
1121
+ ];
1122
+ let found = "";
1123
+ for (const c of candidates) {
1124
+ if (existsSync(c)) {
1125
+ found = c;
1126
+ break;
1127
+ }
1128
+ }
1129
+ if (!found) {
1130
+ socket.emit("rag:retrieve-result", {
1131
+ session_id: sid,
1132
+ ok: false,
1133
+ error: "localRagRetrieve.py not found in runner package - reinstall @melaya/runner",
1134
+ });
1135
+ return;
1136
+ }
1137
+ const stagedScript = join(workDir, "localRagRetrieve.py");
1138
+ copyFileSync(found, stagedScript);
1139
+ const ragCertBundle = (await import("./pythonEnv.js")).getCertBundlePath();
1140
+ const ragSslEnv = ragCertBundle
1141
+ ? { SSL_CERT_FILE: ragCertBundle, REQUESTS_CA_BUNDLE: ragCertBundle }
1142
+ : {};
1143
+ const proc = spawn(env.pythonPath, [
1144
+ "-u", stagedScript,
1145
+ "--pipeline-name", payload.pipelineName,
1146
+ "--embedder-provider", prov,
1147
+ "--embedder-model", payload.embedderModel,
1148
+ "--query", String(payload.query || ""),
1149
+ "--limit", String(Math.max(1, Math.min(20, Number(payload.limit ?? 5)))),
1150
+ // Mode C: embed only, print the vector; the server does the search.
1151
+ ...(embedOnly ? ["--embed-only"] : []),
1152
+ ], {
1153
+ env: {
1154
+ ...process.env,
1155
+ PYTHONPATH: sharedDir,
1156
+ ...ragSslEnv,
1157
+ },
1158
+ cwd: workDir,
1159
+ });
1160
+ let stdout = "";
1161
+ let stderr = "";
1162
+ proc.stdout.on("data", (data) => { stdout += data.toString("utf-8"); });
1163
+ proc.stderr.on("data", (data) => { stderr += data.toString("utf-8"); });
1164
+ proc.on("close", (code) => {
1165
+ const jsonLine = stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean).reverse()[0] || "";
1166
+ let parsed = null;
1167
+ try {
1168
+ parsed = JSON.parse(jsonLine);
1169
+ }
1170
+ catch { /* handled below */ }
1171
+ const ok = code === 0 && parsed?.ok === true;
1172
+ socket.emit("rag:retrieve-result", {
1173
+ session_id: sid,
1174
+ ok,
1175
+ // Mode C (embed-only): carry the query vector; the server searches
1176
+ // the prod store. Mode B: carry the local search results directly.
1177
+ vector: embedOnly && Array.isArray(parsed?.vector) ? parsed.vector : undefined,
1178
+ results: Array.isArray(parsed?.results) ? parsed.results : [],
1179
+ store_path: parsed?.store_path,
1180
+ error: ok ? undefined : (parsed?.error || `runner retrieve exit ${code ?? "?"}`),
1181
+ stderr_tail: stderr.slice(-2000),
1182
+ });
1183
+ });
1184
+ }
1185
+ catch (e) {
1186
+ socket.emit("rag:retrieve-result", {
1187
+ session_id: sid,
1188
+ ok: false,
1189
+ error: e?.message || String(e),
1190
+ });
1191
+ }
1192
+ });
1086
1193
  socket.on("linkedin:start-login", async (req) => {
1087
1194
  const sid = req?.session_id;
1088
1195
  if (!sid)
@@ -143,26 +143,52 @@ export async function runLinkedInLoginFlow(progress) {
143
143
  catch (e) {
144
144
  return { ok: false, error: "playwright_unavailable", detail: e?.message };
145
145
  }
146
- try {
147
- await _installChromium(progress);
148
- }
149
- catch (e) {
150
- return { ok: false, error: "chromium_install_failed", detail: e?.message };
151
- }
152
- // 2. Launch a headed Chromium window. Use a fresh context (no profile
153
- // reuse) so the user always sees a clean LinkedIn login page even
154
- // if they have an old / wrong account session lying around.
146
+ // 2. Launch a headed browser window. Prefer the user's REAL system Chrome
147
+ // (channel:"chrome") — validated 2026-06-22 as the only reliable way to
148
+ // render LinkedIn's login page (the bundled Chromium served a blank /
149
+ // never-painting page; system Chrome renders the form, accepts password
150
+ // + 2FA, and reaches /feed/). System Chrome also needs no 140 MB download.
151
+ // Fall back to bundled Chromium only if Chrome isn't installed.
152
+ // Fresh context (no profile reuse) so the user always sees a clean login.
153
+ //
154
+ // We KEEP the fixed Chrome/127 UA override below: LinkedIn's anti-bot binds
155
+ // cookie issuance to the client fingerprint (UA among others), and every
156
+ // Voyager call in shared/tools/social_linkedin.py sends that exact UA
157
+ // constant. Minting li_at/JSESSIONID under a DIFFERENT UA than the tools
158
+ // later replay triggers a redirect-loop on /voyager/api/me
159
+ // (aiohttp.TooManyRedirects) — verified 2026-06-22 when an interim
160
+ // native-UA attempt broke list_connections / search / resolve_profile /
161
+ // list_conversations while session_status still worked. Keep this string
162
+ // in lock-step with social_linkedin.py USER_AGENT.
155
163
  let browser = null;
156
164
  try {
157
165
  progress("Opening LinkedIn login window…");
158
- browser = await playwright.chromium.launch({
159
- headless: false,
160
- args: [
161
- "--disable-blink-features=AutomationControlled",
162
- ],
163
- });
166
+ try {
167
+ browser = await playwright.chromium.launch({
168
+ headless: false,
169
+ channel: "chrome",
170
+ args: ["--disable-blink-features=AutomationControlled"],
171
+ });
172
+ }
173
+ catch (chromeErr) {
174
+ progress(`System Chrome not available (${(chromeErr?.message || chromeErr).toString().split("\n")[0]}); ` +
175
+ `falling back to the bundled browser.`);
176
+ try {
177
+ await _installChromium(progress);
178
+ }
179
+ catch (e) {
180
+ return { ok: false, error: "chromium_install_failed", detail: e?.message };
181
+ }
182
+ browser = await playwright.chromium.launch({
183
+ headless: false,
184
+ args: ["--disable-blink-features=AutomationControlled"],
185
+ });
186
+ }
164
187
  _activeLoginBrowser = browser;
165
188
  const context = await browser.newContext({
189
+ // MUST stay byte-for-byte equal to shared/tools/social_linkedin.py
190
+ // USER_AGENT (Chrome/127). See the fingerprint-binding note above —
191
+ // a mismatch causes aiohttp.TooManyRedirects on Voyager endpoints.
166
192
  userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
167
193
  "(KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
168
194
  viewport: { width: 1280, height: 820 },
@@ -0,0 +1,162 @@
1
+ """Runner-side RAG retrieval test (local embedder paths).
2
+
3
+ Two modes, selected by --embed-only:
4
+ * Mode B (local folder, default): the vector store lives on THIS machine
5
+ (~/.melaya-runner/rag/<pipeline>/store), so we embed the query AND search
6
+ locally, returning the matched chunks.
7
+ * Mode C (cloud files, --embed-only): the chunks were upserted to the
8
+ PROD-side store during ingest, so they are NOT on this box. We only embed
9
+ the query with the user's local embedder and return the raw vector; the
10
+ server searches the prod store by that vector (see builder
11
+ /docs/retrieval/search_by_vector + runnerNamespace forwarding).
12
+
13
+ Either way the embedder is local (ollama / lmstudio), which is why this can't
14
+ run server-side: prod has no LM Studio / Ollama to embed the query.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import asyncio
20
+ import json
21
+ import sys
22
+ from pathlib import Path
23
+
24
+
25
+ def _resolve_store_path(pipeline_name: str) -> Path:
26
+ return Path.home() / ".melaya-runner" / "rag" / pipeline_name / "store"
27
+
28
+
29
+ def _build_embedder(provider: str, model: str):
30
+ if provider == "ollama":
31
+ from agentscope.embedding import OllamaTextEmbedding
32
+ dims = {
33
+ "nomic-embed-text": 768,
34
+ "nomic-embed-text:v1.5": 768,
35
+ "mxbai-embed-large": 1024,
36
+ "all-minilm": 384,
37
+ "snowflake-arctic-embed": 1024,
38
+ }
39
+ dim = dims.get(model.lower())
40
+ if dim is None:
41
+ raise ValueError(f"unknown ollama embedding model '{model}'")
42
+ return OllamaTextEmbedding(model_name=model, dimensions=dim, host=None)
43
+ if provider == "lmstudio":
44
+ from agentscope.embedding import OpenAITextEmbedding
45
+ embedder = OpenAITextEmbedding(
46
+ api_key="lmstudio",
47
+ model_name=model,
48
+ dimensions=768,
49
+ base_url="http://localhost:1234/v1",
50
+ )
51
+ original_create = embedder.client.embeddings.create
52
+
53
+ async def _lms_create(**kwargs):
54
+ kwargs.pop("dimensions", None)
55
+ return await original_create(**kwargs)
56
+
57
+ embedder.client.embeddings.create = _lms_create # type: ignore[assignment]
58
+ return embedder
59
+ raise ValueError(f"unsupported embedder provider '{provider}'")
60
+
61
+
62
+ def _extract_text(doc) -> str:
63
+ content = getattr(doc.metadata, "content", None)
64
+ if isinstance(content, dict):
65
+ return str(content.get("text") or "")
66
+ return str(getattr(content, "text", "") or "")
67
+
68
+
69
+ async def _embed_only(args) -> dict:
70
+ """Mode C: embed the query with the user's LOCAL embedder and return the
71
+ raw vector. The vectors live in the prod-side store (Mode C ingest upserted
72
+ them there), so the SERVER does the search — this box only embeds. No store
73
+ is opened here."""
74
+ from agentscope.message import TextBlock
75
+ embedder = _build_embedder(args.embedder_provider, args.embedder_model)
76
+ res = await embedder([TextBlock(type="text", text=str(args.query or "")[:2000])])
77
+ vector = list(res.embeddings[0])
78
+ return {"ok": True, "vector": vector}
79
+
80
+
81
+ async def _retrieve(args) -> dict:
82
+ if getattr(args, "embed_only", False):
83
+ return await _embed_only(args)
84
+ store_path = _resolve_store_path(args.pipeline_name)
85
+ if not store_path.exists():
86
+ return {
87
+ "ok": False,
88
+ "error": f"runner RAG store not found: {store_path}",
89
+ "results": [],
90
+ }
91
+
92
+ import qdrant_client as _qc
93
+ if not getattr(_qc.AsyncQdrantClient.__init__, "_melaya_patched", False):
94
+ original_init = _qc.AsyncQdrantClient.__init__
95
+
96
+ def _patched_init(self, location=None, **kwargs):
97
+ if (
98
+ location
99
+ and location != ":memory:"
100
+ and "://" not in str(location)
101
+ and not kwargs.get("path")
102
+ ):
103
+ kwargs["path"] = location
104
+ location = None
105
+ original_init(self, location=location, **kwargs)
106
+
107
+ _patched_init._melaya_patched = True # type: ignore[attr-defined]
108
+ _qc.AsyncQdrantClient.__init__ = _patched_init # type: ignore[assignment]
109
+
110
+ from agentscope.rag import QdrantStore, SimpleKnowledge
111
+
112
+ embedder = _build_embedder(args.embedder_provider, args.embedder_model)
113
+ store = QdrantStore(
114
+ location=str(store_path),
115
+ collection_name=args.pipeline_name,
116
+ dimensions=embedder.dimensions,
117
+ distance="Cosine",
118
+ )
119
+ kb = SimpleKnowledge(embedding_store=store, embedding_model=embedder)
120
+ docs = await kb.retrieve(
121
+ query=str(args.query or "")[:2000],
122
+ limit=max(1, min(20, int(args.limit))),
123
+ )
124
+ results = []
125
+ for doc in docs:
126
+ score = getattr(doc, "score", None)
127
+ results.append({
128
+ "source": getattr(doc.metadata, "doc_id", "?"),
129
+ "chunk_id": getattr(doc.metadata, "chunk_id", "?"),
130
+ "score": score,
131
+ "text": _extract_text(doc),
132
+ })
133
+ return {
134
+ "ok": True,
135
+ "store_path": str(store_path),
136
+ "results": results,
137
+ }
138
+
139
+
140
+ def main() -> None:
141
+ parser = argparse.ArgumentParser()
142
+ parser.add_argument("--pipeline-name", required=True)
143
+ parser.add_argument("--embedder-provider", required=True, choices=["ollama", "lmstudio"])
144
+ parser.add_argument("--embedder-model", required=True)
145
+ parser.add_argument("--query", required=True)
146
+ parser.add_argument("--limit", type=int, default=5)
147
+ # Mode C: embed the query and print {ok, vector}; do not open a local store.
148
+ parser.add_argument("--embed-only", dest="embed_only", action="store_true")
149
+ args = parser.parse_args()
150
+ try:
151
+ print(json.dumps(asyncio.run(_retrieve(args)), ensure_ascii=False))
152
+ except Exception as exc: # noqa: BLE001
153
+ print(json.dumps({
154
+ "ok": False,
155
+ "error": f"{type(exc).__name__}: {exc}",
156
+ "results": [],
157
+ }), file=sys.stdout)
158
+ sys.exit(1)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()
@@ -128,13 +128,23 @@ export async function startLumaBrowserBridge(opts) {
128
128
  if (browserLaunching)
129
129
  return browserLaunching;
130
130
  browserLaunching = (async () => {
131
- const b = await playwright.chromium.launch({
132
- headless: true,
133
- args: [
134
- "--disable-blink-features=AutomationControlled",
135
- "--disable-dev-shm-usage",
136
- ],
137
- });
131
+ // Prefer the user's real system Chrome (channel:"chrome"). Its TLS/JA3
132
+ // fingerprint matches the headed login that minted cf_clearance, so the
133
+ // Cloudflare-gated POST /event/register is far more likely to pass — and
134
+ // it skips the 140 MB bundled-Chromium download. Fall back to bundled
135
+ // Chromium when Chrome isn't installed.
136
+ const launchArgs = [
137
+ "--disable-blink-features=AutomationControlled",
138
+ "--disable-dev-shm-usage",
139
+ ];
140
+ let b;
141
+ try {
142
+ b = await playwright.chromium.launch({ headless: true, channel: "chrome", args: launchArgs });
143
+ }
144
+ catch {
145
+ opts.log("Luma browser bridge: system Chrome unavailable, using bundled Chromium.");
146
+ b = await playwright.chromium.launch({ headless: true, args: launchArgs });
147
+ }
138
148
  browser = b;
139
149
  browserLaunching = null;
140
150
  return b;
@@ -168,6 +178,10 @@ export async function startLumaBrowserBridge(opts) {
168
178
  const storageState = await _readStorageState();
169
179
  ctx = await b.newContext({
170
180
  storageState: storageState,
181
+ // MUST stay in lock-step with lumaLogin.ts and shared/tools/luma.py
182
+ // USER_AGENT. cf_clearance is UA-bound: it was minted under this exact
183
+ // string at login, so every replay (here + the direct aiohttp reads)
184
+ // must present the same UA or Cloudflare rejects it with a 403.
171
185
  userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
172
186
  "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
173
187
  viewport: { width: 1280, height: 820 },
package/dist/lumaLogin.js CHANGED
@@ -112,21 +112,47 @@ export async function runLumaLoginFlow(progress) {
112
112
  catch (e) {
113
113
  return { ok: false, error: "playwright_unavailable", detail: e?.message };
114
114
  }
115
- try {
116
- await _installChromium(progress);
117
- }
118
- catch (e) {
119
- return { ok: false, error: "chromium_install_failed", detail: e?.message };
120
- }
115
+ // Prefer the user's REAL system Chrome (channel:"chrome"). Validated for the
116
+ // LinkedIn login flow 2026-06-22 as the only reliable way to render a headed
117
+ // provider login (bundled Chromium served a blank page). For Luma it's doubly
118
+ // important: Luma is behind Cloudflare Turnstile and only issues cf_clearance
119
+ // (required for POST /event/register) after a challenge passes — a genuine
120
+ // Chrome clears that far more reliably than bundled Chromium, and it needs no
121
+ // 140 MB download. Fall back to bundled Chromium only if Chrome isn't present.
122
+ //
123
+ // We DO keep the fixed UA override below: cf_clearance is bound to the exact
124
+ // UA that minted it, and the replay paths (lumaBrowserBridge.ts and the direct
125
+ // aiohttp reads in shared/tools/luma.py) both send this same fixed string.
126
+ // Turnstile is solved under this overridden UA, so the binding stays internally
127
+ // consistent across mint + every replay. Keep all three in lock-step.
121
128
  let browser = null;
122
129
  try {
123
130
  progress("Opening Luma login window…");
124
- browser = await playwright.chromium.launch({
125
- headless: false,
126
- args: ["--disable-blink-features=AutomationControlled"],
127
- });
131
+ try {
132
+ browser = await playwright.chromium.launch({
133
+ headless: false,
134
+ channel: "chrome",
135
+ args: ["--disable-blink-features=AutomationControlled"],
136
+ });
137
+ }
138
+ catch (chromeErr) {
139
+ progress(`System Chrome not available (${(chromeErr?.message || chromeErr).toString().split("\n")[0]}); ` +
140
+ `falling back to the bundled browser.`);
141
+ try {
142
+ await _installChromium(progress);
143
+ }
144
+ catch (e) {
145
+ return { ok: false, error: "chromium_install_failed", detail: e?.message };
146
+ }
147
+ browser = await playwright.chromium.launch({
148
+ headless: false,
149
+ args: ["--disable-blink-features=AutomationControlled"],
150
+ });
151
+ }
128
152
  _activeLoginBrowser = browser;
129
153
  const context = await browser.newContext({
154
+ // MUST stay in lock-step with shared/tools/luma.py USER_AGENT and
155
+ // lumaBrowserBridge.ts — cf_clearance is UA-bound (see comment above).
130
156
  userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
131
157
  "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
132
158
  viewport: { width: 1280, height: 820 },
@@ -0,0 +1,162 @@
1
+ """Runner-side RAG retrieval test (local embedder paths).
2
+
3
+ Two modes, selected by --embed-only:
4
+ * Mode B (local folder, default): the vector store lives on THIS machine
5
+ (~/.melaya-runner/rag/<pipeline>/store), so we embed the query AND search
6
+ locally, returning the matched chunks.
7
+ * Mode C (cloud files, --embed-only): the chunks were upserted to the
8
+ PROD-side store during ingest, so they are NOT on this box. We only embed
9
+ the query with the user's local embedder and return the raw vector; the
10
+ server searches the prod store by that vector (see builder
11
+ /docs/retrieval/search_by_vector + runnerNamespace forwarding).
12
+
13
+ Either way the embedder is local (ollama / lmstudio), which is why this can't
14
+ run server-side: prod has no LM Studio / Ollama to embed the query.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import asyncio
20
+ import json
21
+ import sys
22
+ from pathlib import Path
23
+
24
+
25
+ def _resolve_store_path(pipeline_name: str) -> Path:
26
+ return Path.home() / ".melaya-runner" / "rag" / pipeline_name / "store"
27
+
28
+
29
+ def _build_embedder(provider: str, model: str):
30
+ if provider == "ollama":
31
+ from agentscope.embedding import OllamaTextEmbedding
32
+ dims = {
33
+ "nomic-embed-text": 768,
34
+ "nomic-embed-text:v1.5": 768,
35
+ "mxbai-embed-large": 1024,
36
+ "all-minilm": 384,
37
+ "snowflake-arctic-embed": 1024,
38
+ }
39
+ dim = dims.get(model.lower())
40
+ if dim is None:
41
+ raise ValueError(f"unknown ollama embedding model '{model}'")
42
+ return OllamaTextEmbedding(model_name=model, dimensions=dim, host=None)
43
+ if provider == "lmstudio":
44
+ from agentscope.embedding import OpenAITextEmbedding
45
+ embedder = OpenAITextEmbedding(
46
+ api_key="lmstudio",
47
+ model_name=model,
48
+ dimensions=768,
49
+ base_url="http://localhost:1234/v1",
50
+ )
51
+ original_create = embedder.client.embeddings.create
52
+
53
+ async def _lms_create(**kwargs):
54
+ kwargs.pop("dimensions", None)
55
+ return await original_create(**kwargs)
56
+
57
+ embedder.client.embeddings.create = _lms_create # type: ignore[assignment]
58
+ return embedder
59
+ raise ValueError(f"unsupported embedder provider '{provider}'")
60
+
61
+
62
+ def _extract_text(doc) -> str:
63
+ content = getattr(doc.metadata, "content", None)
64
+ if isinstance(content, dict):
65
+ return str(content.get("text") or "")
66
+ return str(getattr(content, "text", "") or "")
67
+
68
+
69
+ async def _embed_only(args) -> dict:
70
+ """Mode C: embed the query with the user's LOCAL embedder and return the
71
+ raw vector. The vectors live in the prod-side store (Mode C ingest upserted
72
+ them there), so the SERVER does the search — this box only embeds. No store
73
+ is opened here."""
74
+ from agentscope.message import TextBlock
75
+ embedder = _build_embedder(args.embedder_provider, args.embedder_model)
76
+ res = await embedder([TextBlock(type="text", text=str(args.query or "")[:2000])])
77
+ vector = list(res.embeddings[0])
78
+ return {"ok": True, "vector": vector}
79
+
80
+
81
+ async def _retrieve(args) -> dict:
82
+ if getattr(args, "embed_only", False):
83
+ return await _embed_only(args)
84
+ store_path = _resolve_store_path(args.pipeline_name)
85
+ if not store_path.exists():
86
+ return {
87
+ "ok": False,
88
+ "error": f"runner RAG store not found: {store_path}",
89
+ "results": [],
90
+ }
91
+
92
+ import qdrant_client as _qc
93
+ if not getattr(_qc.AsyncQdrantClient.__init__, "_melaya_patched", False):
94
+ original_init = _qc.AsyncQdrantClient.__init__
95
+
96
+ def _patched_init(self, location=None, **kwargs):
97
+ if (
98
+ location
99
+ and location != ":memory:"
100
+ and "://" not in str(location)
101
+ and not kwargs.get("path")
102
+ ):
103
+ kwargs["path"] = location
104
+ location = None
105
+ original_init(self, location=location, **kwargs)
106
+
107
+ _patched_init._melaya_patched = True # type: ignore[attr-defined]
108
+ _qc.AsyncQdrantClient.__init__ = _patched_init # type: ignore[assignment]
109
+
110
+ from agentscope.rag import QdrantStore, SimpleKnowledge
111
+
112
+ embedder = _build_embedder(args.embedder_provider, args.embedder_model)
113
+ store = QdrantStore(
114
+ location=str(store_path),
115
+ collection_name=args.pipeline_name,
116
+ dimensions=embedder.dimensions,
117
+ distance="Cosine",
118
+ )
119
+ kb = SimpleKnowledge(embedding_store=store, embedding_model=embedder)
120
+ docs = await kb.retrieve(
121
+ query=str(args.query or "")[:2000],
122
+ limit=max(1, min(20, int(args.limit))),
123
+ )
124
+ results = []
125
+ for doc in docs:
126
+ score = getattr(doc, "score", None)
127
+ results.append({
128
+ "source": getattr(doc.metadata, "doc_id", "?"),
129
+ "chunk_id": getattr(doc.metadata, "chunk_id", "?"),
130
+ "score": score,
131
+ "text": _extract_text(doc),
132
+ })
133
+ return {
134
+ "ok": True,
135
+ "store_path": str(store_path),
136
+ "results": results,
137
+ }
138
+
139
+
140
+ def main() -> None:
141
+ parser = argparse.ArgumentParser()
142
+ parser.add_argument("--pipeline-name", required=True)
143
+ parser.add_argument("--embedder-provider", required=True, choices=["ollama", "lmstudio"])
144
+ parser.add_argument("--embedder-model", required=True)
145
+ parser.add_argument("--query", required=True)
146
+ parser.add_argument("--limit", type=int, default=5)
147
+ # Mode C: embed the query and print {ok, vector}; do not open a local store.
148
+ parser.add_argument("--embed-only", dest="embed_only", action="store_true")
149
+ args = parser.parse_args()
150
+ try:
151
+ print(json.dumps(asyncio.run(_retrieve(args)), ensure_ascii=False))
152
+ except Exception as exc: # noqa: BLE001
153
+ print(json.dumps({
154
+ "ok": False,
155
+ "error": f"{type(exc).__name__}: {exc}",
156
+ "results": [],
157
+ }), file=sys.stdout)
158
+ sys.exit(1)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.69",
3
+ "version": "1.0.71",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -14,23 +14,24 @@
14
14
  "dist/**/*.d.ts",
15
15
  "dist/**/*.py",
16
16
  "localRagIngest.py",
17
+ "localRagRetrieve.py",
17
18
  "nltk_data/**",
18
19
  "README.md"
19
20
  ],
20
21
  "scripts": {
21
- "build": "tsc && node -e \"require('fs').copyFileSync('localRagIngest.py','dist/localRagIngest.py')\"",
22
+ "build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py')\"",
22
23
  "prepublishOnly": "npm run build"
23
24
  },
24
25
  "dependencies": {
25
- "socket.io-client": "^4.8.0",
26
- "commander": "^12.0.0",
27
26
  "chalk": "^5.3.0",
27
+ "commander": "^12.0.0",
28
28
  "ora": "^8.0.0",
29
- "playwright": "^1.47.0"
29
+ "playwright": "^1.47.0",
30
+ "socket.io-client": "^4.8.0"
30
31
  },
31
32
  "devDependencies": {
32
- "typescript": "^5.5.0",
33
- "@types/node": "^20.0.0"
33
+ "@types/node": "^20.0.0",
34
+ "typescript": "^5.5.0"
34
35
  },
35
36
  "engines": {
36
37
  "node": ">=18"