@melaya/runner 1.0.70 → 1.0.72

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)
@@ -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()
@@ -105,6 +105,48 @@ export function startLocalRelay(socket, verbose, serverUrl) {
105
105
  });
106
106
  return;
107
107
  }
108
+ // ── Mode C RAG search proxy ─────────────────────────────────────
109
+ // A runner-dispatched pipeline's `rag_retrieve` (Mode C: cloud store +
110
+ // local embedder) embeds the query locally then POSTs the vector to
111
+ // `${MEL_BUILDER_URL}/pipelines/<name>/docs/retrieval/search_by_vector`.
112
+ // We forward it over the authenticated socket (the runner is the trust
113
+ // anchor) to `runner:ragSearch`, which calls the server's
114
+ // _forwardSearchByVector → builder search_by_vector on the prod store.
115
+ const sm = req.method === "POST" && req.url
116
+ ? req.url.match(/^\/pipelines\/([^/]+)\/docs\/retrieval\/search_by_vector\/?$/)
117
+ : null;
118
+ if (sm) {
119
+ const pipelineName = decodeURIComponent(sm[1]);
120
+ let sbody = "";
121
+ req.on("data", (chunk) => { sbody += chunk.toString(); });
122
+ req.on("end", () => {
123
+ const replyOnce = (status, body) => {
124
+ if (res.headersSent)
125
+ return;
126
+ res.writeHead(status, { "Content-Type": "application/json" });
127
+ res.end(JSON.stringify(body));
128
+ };
129
+ let parsed;
130
+ try {
131
+ parsed = JSON.parse(sbody || "{}");
132
+ }
133
+ catch {
134
+ replyOnce(400, { results: [], error: "invalid_json" });
135
+ return;
136
+ }
137
+ // Relay timeout < the pipeline's urlopen timeout (30s) so the
138
+ // pipeline always gets a JSON reply rather than its own abort.
139
+ const t = setTimeout(() => replyOnce(504, { results: [], error: "upstream_timeout" }), 25000);
140
+ socket.emit("runner:ragSearch", { name: pipelineName, vector: parsed.vector, limit: parsed.limit }, (resp) => {
141
+ clearTimeout(t);
142
+ if (resp && typeof resp === "object")
143
+ replyOnce(200, resp);
144
+ else
145
+ replyOnce(502, { results: [], error: "no_response" });
146
+ });
147
+ });
148
+ return;
149
+ }
108
150
  if (req.method !== "POST" || req.url !== expectedPath) {
109
151
  res.writeHead(404);
110
152
  res.end("Not found");
@@ -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.70",
3
+ "version": "1.0.72",
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,11 +14,12 @@
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": {