@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.
- package/dist/connection.js +107 -0
- package/dist/linkedinLogin.js +41 -15
- package/dist/localRagRetrieve.py +162 -0
- package/dist/lumaBrowserBridge.js +21 -7
- package/dist/lumaLogin.js +36 -10
- package/localRagRetrieve.py +162 -0
- package/package.json +8 -7
package/dist/connection.js
CHANGED
|
@@ -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)
|
package/dist/linkedinLogin.js
CHANGED
|
@@ -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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
"
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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.
|
|
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
|
-
"
|
|
33
|
-
"
|
|
33
|
+
"@types/node": "^20.0.0",
|
|
34
|
+
"typescript": "^5.5.0"
|
|
34
35
|
},
|
|
35
36
|
"engines": {
|
|
36
37
|
"node": ">=18"
|